From f87ecd9a4f2aedb286481cfa893e3bc13eb31ebb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:07:57 +0000 Subject: [PATCH 1/2] feat(compliance): make last_status/last_assessed_at a live nested-write roll-up (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compliance_control.last_status` / `last_assessed_at` were hand-maintained stored fields: the seed set them per control and nothing recomputed them when an assessment landed. The intended design (the `last_assessed_at` field description already said "set by hook when a related assessment moves to passed/failed/partial") was blocked because a nested cross-object write from a hook crashed the QuickJS sandbox — framework#1867. That is fixed, so the roll-up is now live. This is a NON-aggregate roll-up ("most recent completed assessment", not a sum/count), so a native `summary` field cannot express it — the right tool is a hook that writes the parent control via `ctx.api`. Add `assessmentRollupHook` (afterInsert/afterUpdate on compliance_assessment): it re-derives the control from ALL of its assessments and copies the latest completed (passed/partial/failed) one's status + assessed_at onto the control (or `not_tested` / null when none is completed). Notes for the body-only sandbox (learned by boot-testing against the pinned @objectstack 15.1.1 runtime): - The hook sandbox exposes the engine-repo FACADE, whose single-record update is `update(data, opts)` with the id in `data` — there is no `updateById`. The hook mirrors the engine's own recipe (id in payload + `where: { id }`). - `last_assessed_at` is a datetime field: it wants an ISO-8601 string, not an epoch number, so the hook normalises the (date-granular) assessed_at through `new Date(...).toISOString()`. Seed: - drop the hand-set last_status/last_assessed_at from the 6 assessed controls (now derived by the hook as the assessments load) - add two historical passed assessments (GDPR Art.32 ~200d ago, HIPAA §164.308(a)(1) ~40d ago) so those controls' rolled-up status reproduces the previous seed exactly - the two never-assessed controls (A.8.16, 164.312(a)(1)) seed an explicit `not_tested` initial state (the hook never fires for them) Boot-verified against a fresh dev server (@objectstack 15.1.1, 0 errors, no sandbox crash): - all 8 controls' last_status / last_assessed_at match the previous seeded baseline exactly - runtime afterInsert: POSTing a passed assessment for a never-assessed control flips it to passed@today live - runtime afterUpdate: transitioning that assessment out of a completed state reverts the control to not_tested hooks/index.ts, compliance_assessment.hook.ts, CHARTER.md: replace the stale "STORED field / sandbox crashes on nested writes" notes with the live roll-up description. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP --- packages/compliance/CHARTER.md | 4 +- packages/compliance/src/data/index.ts | 52 +++++--- packages/compliance/src/hooks/index.ts | 17 +-- .../src/objects/compliance_assessment.hook.ts | 116 ++++++++++++++++++ 4 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 packages/compliance/src/objects/compliance_assessment.hook.ts diff --git a/packages/compliance/CHARTER.md b/packages/compliance/CHARTER.md index 2326b82..c223d31 100644 --- a/packages/compliance/CHARTER.md +++ b/packages/compliance/CHARTER.md @@ -29,12 +29,12 @@ you can plug in your own collectors via the API. |---|---| | 4-object schema with relationships | `framework` → `control` → (`evidence`, `assessment`) | | State machines on multiple objects | Evidence + Assessment lifecycles | -| Cross-object rollup via hook | Assessment outcome → Control.last_status / last_assessed_at | +| Non-aggregate cross-object rollup via nested-write hook | Latest completed assessment → Control.last_status / last_assessed_at, kept live by `assessmentRollupHook` (a `summary` field can't express "most recent", so it writes the parent via `ctx.api`) | | Time-triggered alert flows | Evidence expiring at T-30 / T-7 | | Auto-state-transition flows | Evidence auto-expire when expires_on passes | | Threshold-driven escalation | Failed high-criticality controls → notify owner + compliance team | | Internationalization | English + 简体中文 ship out of the box | -| Realistic seed data | SOC2 + ISO27001 + GDPR with 6 controls, 7 evidence, 5 assessments | +| Realistic seed data | SOC2 + ISO27001 + GDPR + HIPAA with 8 controls, 7 evidence, 7 assessments (last_status derived live from assessments) | ## Limits (LOC budget) diff --git a/packages/compliance/src/data/index.ts b/packages/compliance/src/data/index.ts index 30dd567..8f9d2b2 100644 --- a/packages/compliance/src/data/index.ts +++ b/packages/compliance/src/data/index.ts @@ -9,10 +9,17 @@ import { Assessment } from '../objects/compliance_assessment.object'; /** * Seed data — exercises full template surface: - * • 3 frameworks (SOC2, ISO27001, GDPR) - * • 6 controls across them, mixed criticality & status + * • 4 frameworks (SOC2, ISO27001, GDPR, HIPAA) + * • 8 controls across them, mixed criticality * • 7 pieces of evidence, including one expiring soon + one expired - * • 5 assessments covering planned / in_progress / passed / failed / partial + * • 7 assessments covering in_progress / passed / failed / partial + * + * `compliance_control.last_status` / `last_assessed_at` are a live roll-up of each + * control's most-recent completed assessment, written by `assessmentRollupHook` as + * the assessments below load — they are NOT hand-maintained per assessed control. + * The two never-assessed controls (A.8.16, 164.312(a)(1)) seed an explicit initial + * `last_status: 'not_tested'` (the hook never fires for them); their + * `last_assessed_at` stays null. */ const frameworks = defineSeed(Framework, { @@ -65,9 +72,7 @@ const controls = defineSeed(Control, { framework: 'SOC2', category: 'access', criticality: 'high', - last_status: 'passed', review_frequency_days: 90, - last_assessed_at: cel`daysAgo(20)`, description: 'The entity implements logical access controls to protect data.', }, { @@ -76,9 +81,7 @@ const controls = defineSeed(Control, { framework: 'SOC2', category: 'change', criticality: 'high', - last_status: 'failed', review_frequency_days: 90, - last_assessed_at: cel`daysAgo(5)`, description: 'Vulnerability scans run monthly; remediation in SLA.', }, { @@ -87,9 +90,7 @@ const controls = defineSeed(Control, { framework: 'SOC2', category: 'change', criticality: 'medium', - last_status: 'partial', review_frequency_days: 90, - last_assessed_at: cel`daysAgo(50)`, description: 'All production changes are peer-reviewed and approved.', }, { @@ -98,9 +99,7 @@ const controls = defineSeed(Control, { framework: 'ISO27001', category: 'risk', criticality: 'high', - last_status: 'passed', review_frequency_days: 180, - last_assessed_at: cel`daysAgo(100)`, description: 'Information security policies approved by management & published.', }, { @@ -109,6 +108,9 @@ const controls = defineSeed(Control, { framework: 'ISO27001', category: 'incident', criticality: 'medium', + // No assessment seeded for this control, so the roll-up hook never fires + // for it — seed the initial `not_tested` state explicitly (this is the + // default/never-assessed state, not a hand-maintained roll-up value). last_status: 'not_tested', review_frequency_days: 90, description: 'Networks, systems and apps monitored for anomalous behaviour.', @@ -119,9 +121,7 @@ const controls = defineSeed(Control, { framework: 'GDPR', category: 'data', criticality: 'high', - last_status: 'passed', review_frequency_days: 180, - last_assessed_at: cel`daysAgo(200)`, description: 'Appropriate technical & organisational measures to ensure security of personal data. Past review-frequency window.', }, @@ -131,9 +131,7 @@ const controls = defineSeed(Control, { framework: 'HIPAA', category: 'risk', criticality: 'high', - last_status: 'passed', review_frequency_days: 365, - last_assessed_at: cel`daysAgo(40)`, description: 'Implement policies to prevent, detect, contain, and correct security violations (risk analysis + risk management).', }, @@ -143,6 +141,9 @@ const controls = defineSeed(Control, { framework: 'HIPAA', category: 'access', criticality: 'high', + // Never-assessed control (no assessment seeded) — seed its initial + // `not_tested` state explicitly; the roll-up hook only manages controls + // that have at least one completed assessment. last_status: 'not_tested', review_frequency_days: 180, description: @@ -266,6 +267,27 @@ const assessments = defineSeed(Assessment, { status: 'in_progress', finding: 'Encryption-at-rest confirmed; key rotation policy under review.', }, + { + // Prior Art.32 review (passed) ~200 days ago — past the 180-day review + // window, so the control rolls up as passed-but-overdue. The 2026-Q1 + // review above is still in_progress (no result yet), so this remains the + // latest *completed* outcome the roll-up hook copies onto the control. + title: '2025-H1 GDPR Art.32 review', + control: 'Art.32', + cycle: '2025-H1', + assessed_at: cel`daysAgo(200)`, + status: 'passed', + finding: + 'TOMs verified: encryption at rest + in transit, DPAs on file with all sub-processors.', + }, + { + title: '2026 HIPAA §164.308(a)(1) risk analysis', + control: '164.308(a)(1)', + cycle: '2026-Q1', + assessed_at: cel`daysAgo(40)`, + status: 'passed', + finding: 'Annual risk analysis completed; risk-management plan current, no high residual risks.', + }, ], }); diff --git a/packages/compliance/src/hooks/index.ts b/packages/compliance/src/hooks/index.ts index b2b40dc..6c8a848 100644 --- a/packages/compliance/src/hooks/index.ts +++ b/packages/compliance/src/hooks/index.ts @@ -1,12 +1,13 @@ // Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. import { evidenceHook } from '../objects/compliance_evidence.hook'; +import { assessmentRollupHook } from '../objects/compliance_assessment.hook'; -// NOTE: `compliance_control.last_status` / `last_assessed_at` are STORED fields, -// not a hook-maintained rollup from assessments. A cross-object rollup needs a -// nested engine write from a hook, which is unsupported in the standalone -// runtime (the QuickJS hook sandbox crashes on nested writes; `ctx.services.data` -// is undefined inside it). See packages/expense/CHARTER.md. Maintain them at the -// top level (client/seed) or, in a fork, via a native summary field / external -// worker. -export const allHooks = [evidenceHook]; +// `compliance_control.last_status` / `last_assessed_at` are a LIVE roll-up of the +// most recent completed assessment, maintained by `assessmentRollupHook` via a +// nested `ctx.api` write to the parent control (see compliance_assessment.hook.ts). +// This is a *non-aggregate* rollup ("latest", not a sum/count), so a native +// `summary` field can't express it — a nested-write hook is the right tool. That +// write used to crash the QuickJS hook sandbox (framework#1867); it is fixed now, +// so these fields are no longer hand-maintained by seed/client. +export const allHooks = [evidenceHook, assessmentRollupHook]; diff --git a/packages/compliance/src/objects/compliance_assessment.hook.ts b/packages/compliance/src/objects/compliance_assessment.hook.ts new file mode 100644 index 0000000..0d50dff --- /dev/null +++ b/packages/compliance/src/objects/compliance_assessment.hook.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. + +import type { Hook, HookContext } from '@objectstack/spec/data'; + +/** + * Assessment → Control roll-up. + * + * Copies the *latest completed* assessment's outcome onto its control: + * compliance_control.last_status ← latest passed/partial/failed status + * compliance_control.last_assessed_at ← that assessment's assessed_at (epoch ms) + * + * This is a NON-aggregate cross-object roll-up ("most recent", not a sum/count), + * so a native `summary` field cannot express it — the correct tool is a hook that + * issues a nested `ctx.api` write to the parent control. That write used to crash + * the QuickJS hook sandbox (`memory access out of bounds`, framework#1867), which + * is why `last_status` / `last_assessed_at` were hand-maintained stored fields. + * framework#1867 is fixed, so the roll-up is now live. + * + * IMPORTANT — body-only sandbox: only the handler's function body ships to + * QuickJS at runtime; module-scope helpers are NOT in scope. Everything the + * handler needs is therefore declared INSIDE the handler. + * + * Recompute strategy: on every assessment insert/update we re-derive the control + * from ALL of its assessments (not just the changed row), so the control always + * reflects the true latest outcome regardless of the order rows arrive in. + * + * Delete caveat: `afterDelete` receives only `{ id }` — no `control` FK — so it + * cannot know which control to recompute (framework#1867's note). Deleting the + * most-recent assessment therefore leaves `last_status` stale until the next + * assessment on that control writes; assessments are effectively immutable audit + * records, so this is an accepted edge, not a routine path. + */ +const assessmentRollupHook: Hook = { + name: 'compliance_assessment_rollup', + object: 'compliance_assessment', + events: ['afterInsert', 'afterUpdate'], + priority: 100, + description: "Roll the latest completed assessment's result up onto its control.", + handler: async (ctx: HookContext) => { + const c = ctx as HookContext & { + input?: Record; + result?: Record; + api?: { + object: (name: string) => { + find: (q: unknown) => Promise>>; + findOne: (q: unknown) => Promise | undefined>; + update: (data: Record, opts?: unknown) => Promise; + }; + }; + }; + const api = c.api; + if (!api) return; + + // Resolve the changed assessment's control FK. Different lifecycle paths + // expose the record under different keys (result / input / input.doc / + // input.data), so probe all of them, then fall back to reading the row. + const input = (c.input ?? {}) as Record; + const carriers = [c.result, input, input.doc, input.data].filter( + (o): o is Record => !!o && typeof o === 'object', + ); + const pick = (key: string): unknown => { + for (const o of carriers) { + if (o[key] !== undefined && o[key] !== null) return o[key]; + } + return undefined; + }; + + let controlId = pick('control') as string | undefined; + const assessmentId = pick('id') as string | undefined; + if (!controlId && assessmentId) { + const row = await api.object('compliance_assessment').findOne({ where: { id: assessmentId } }); + controlId = (row?.control as string | undefined) ?? undefined; + } + if (!controlId) return; + + // Re-derive from all of the control's assessments: pick the completed one + // (passed/partial/failed) with the greatest assessed_at. + const rows = await api.object('compliance_assessment').find({ where: { control: controlId } }); + const toEpoch = (v: unknown): number | null => { + if (v == null) return null; + if (typeof v === 'number') return v; + const p = Date.parse(String(v)); + return Number.isNaN(p) ? null : p; + }; + let latest: Record | null = null; + let latestAt = -Infinity; + for (const a of rows ?? []) { + const s = a.status; + if (s !== 'passed' && s !== 'partial' && s !== 'failed') continue; + const at = toEpoch(a.assessed_at); + if (at == null) continue; + if (at > latestAt) { + latestAt = at; + latest = a; + } + } + + // The body-only sandbox exposes the engine-repo FACADE, whose single-record + // update is `update(data, opts)` with the id carried in `data` (there is no + // `updateById` here) — mirror the engine's own updateById recipe: id in the + // payload AND a `where: { id }` scope. + // `last_assessed_at` is a datetime field — it wants an ISO-8601 string, not + // an epoch number. Normalise the (date-granular) assessed_at through Date. + // The control's `is_overdue_for_review` formula coerces the datetime back to + // epoch for its arithmetic, so an ISO string is the correct stored form. + const patch = latest + ? { last_status: latest.status, last_assessed_at: new Date(latestAt).toISOString() } + : { last_status: 'not_tested', last_assessed_at: null }; + await api + .object('compliance_control') + .update({ id: controlId, ...patch }, { where: { id: controlId } }); + }, +}; + +export default assessmentRollupHook; +export { assessmentRollupHook }; From abbdd7ab7f61229cdcc350e1fa68ff547f090082 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:11:02 +0000 Subject: [PATCH 2/2] style(compliance): prettier --write on rollup hook + seed (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the CI `format:check` (prettier --check) failure on #90 — apply the repo's Prettier style to the two changed files (no logic change): - src/objects/compliance_assessment.hook.ts - src/data/index.ts Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP --- packages/compliance/src/data/index.ts | 3 ++- packages/compliance/src/objects/compliance_assessment.hook.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/compliance/src/data/index.ts b/packages/compliance/src/data/index.ts index 8f9d2b2..799ea1a 100644 --- a/packages/compliance/src/data/index.ts +++ b/packages/compliance/src/data/index.ts @@ -286,7 +286,8 @@ const assessments = defineSeed(Assessment, { cycle: '2026-Q1', assessed_at: cel`daysAgo(40)`, status: 'passed', - finding: 'Annual risk analysis completed; risk-management plan current, no high residual risks.', + finding: + 'Annual risk analysis completed; risk-management plan current, no high residual risks.', }, ], }); diff --git a/packages/compliance/src/objects/compliance_assessment.hook.ts b/packages/compliance/src/objects/compliance_assessment.hook.ts index 0d50dff..b0de6f5 100644 --- a/packages/compliance/src/objects/compliance_assessment.hook.ts +++ b/packages/compliance/src/objects/compliance_assessment.hook.ts @@ -68,7 +68,9 @@ const assessmentRollupHook: Hook = { let controlId = pick('control') as string | undefined; const assessmentId = pick('id') as string | undefined; if (!controlId && assessmentId) { - const row = await api.object('compliance_assessment').findOne({ where: { id: assessmentId } }); + const row = await api + .object('compliance_assessment') + .findOne({ where: { id: assessmentId } }); controlId = (row?.control as string | undefined) ?? undefined; } if (!controlId) return;