Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/compliance/CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
53 changes: 38 additions & 15 deletions packages/compliance/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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.',
},
{
Expand All @@ -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.',
},
{
Expand All @@ -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.',
},
{
Expand All @@ -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.',
},
{
Expand All @@ -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.',
Expand All @@ -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.',
},
Expand All @@ -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).',
},
Expand All @@ -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:
Expand Down Expand Up @@ -266,6 +267,28 @@ 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.',
},
],
});

Expand Down
17 changes: 9 additions & 8 deletions packages/compliance/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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];
118 changes: 118 additions & 0 deletions packages/compliance/src/objects/compliance_assessment.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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<string, unknown>;
result?: Record<string, unknown>;
api?: {
object: (name: string) => {
find: (q: unknown) => Promise<Array<Record<string, unknown>>>;
findOne: (q: unknown) => Promise<Record<string, unknown> | undefined>;
update: (data: Record<string, unknown>, opts?: unknown) => Promise<unknown>;
};
};
};
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<string, unknown>;
const carriers = [c.result, input, input.doc, input.data].filter(
(o): o is Record<string, unknown> => !!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<string, unknown> | 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 };
Loading