From 4cf25b3e9e62ba962b4e42d3f0c4b266fd350e30 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:07:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(verify):=20ADR-0060=20P1=20=E2=80=94=20reu?= =?UTF-8?q?sable=20conformance-ledger=20helper=20+=20unify=20two=20ledgers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the conformance-ledger discipline (hand-written twice: ADR-0056 D10 authz matrix, ADR-0058 D7 expression surface) to a reusable platform capability: - @objectstack/verify gains a `conformance` module: `ConformanceRow` + `checkLedger (rows, opts): string[]` — returns problems (empty = sound), so the helper carries no test-runner dependency. It encodes the shared invariants once (unique ids, valid state, enforced-has-enforcement, experimental/removed-has-note, proof-file- exists, high-risk-has-proof, exactly-one-cover) and the ratchet (discover the real surface from source; fail on unclassified or stale covers). 12-case unit test. - authz-conformance + expression-conformance refactored onto checkLedger: one call replaces the duplicated assertion logic. The expression ledger's `site` field is unified to `enforcement` and ExprSurface now `extends ConformanceRow`; its expression-specific invariants (mode/dialect/fail-policy, compile rows name the canonical compiler) stay local. Ratchet verified still has teeth. Green: full build 75/75, dogfood 133 (incl. 16 conformance tests). Co-Authored-By: Claude Opus 4.8 --- .changeset/conformance-ledger-helper.md | 10 ++ .../dogfood/test/authz-conformance.test.ts | 60 +++------- .../dogfood/test/conformance-helper.test.ts | 54 +++++++++ .../test/expression-conformance.ledger.ts | 35 +++--- .../test/expression-conformance.test.ts | 86 ++++---------- packages/verify/src/conformance.ts | 110 ++++++++++++++++++ packages/verify/src/index.ts | 5 + 7 files changed, 231 insertions(+), 129 deletions(-) create mode 100644 .changeset/conformance-ledger-helper.md create mode 100644 packages/dogfood/test/conformance-helper.test.ts create mode 100644 packages/verify/src/conformance.ts diff --git a/.changeset/conformance-ledger-helper.md b/.changeset/conformance-ledger-helper.md new file mode 100644 index 0000000000..34fd836afb --- /dev/null +++ b/.changeset/conformance-ledger-helper.md @@ -0,0 +1,10 @@ +--- +"@objectstack/verify": minor +--- + +ADR-0060 P1 — add the reusable conformance-ledger helper. `@objectstack/verify` +now exports `checkLedger(rows, opts)` + `ConformanceRow`: the static complement to +its runtime harness, encoding the shared invariants the platform had hand-written +twice (unique ids / valid state / enforced-has-site / experimental·removed-has-note +/ proof-file-exists / high-risk-has-proof / exactly-one-cover / discover ratchet). +The ADR-0056 authz and ADR-0058 expression ledgers are refactored onto it. diff --git a/packages/dogfood/test/authz-conformance.test.ts b/packages/dogfood/test/authz-conformance.test.ts index b56c147dce..2ad4bbcd3d 100644 --- a/packages/dogfood/test/authz-conformance.test.ts +++ b/packages/dogfood/test/authz-conformance.test.ts @@ -1,55 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// ADR-0056 D10 — the conformance matrix is a CHECKED artifact. These assertions -// make "every authorization primitive is in exactly one honest state, and every -// claimed proof exists" a green CI gate. A new fail-open (enforced row with no -// site/proof) or a deleted proof file breaks the build. +// ADR-0056 D10 — the authorization conformance matrix is a CHECKED artifact. +// Refactored onto the reusable ADR-0060 `checkLedger` helper: one call asserts +// every shared invariant (valid state, enforced-has-site, experimental/removed- +// has-note, proof-file-exists, high-risk-has-proof). A new fail-open or a deleted +// proof breaks the build. -import { describe, it, expect } from 'vitest'; -import { existsSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js'; +import { dirname } from 'node:path'; +import { checkLedger } from '@objectstack/verify'; +import { AUTHZ_CONFORMANCE } from './authz-conformance.matrix.js'; const HERE = dirname(fileURLToPath(import.meta.url)); -const VALID = new Set(['enforced', 'experimental', 'removed']); describe('ADR-0056 D10 — authorization conformance matrix', () => { - it('has no duplicate primitive ids', () => { - const ids = AUTHZ_CONFORMANCE.map((p) => p.id); - expect(new Set(ids).size).toBe(ids.length); - }); - - it('every primitive is in exactly one honest state', () => { - for (const p of AUTHZ_CONFORMANCE) { - expect(VALID.has(p.state), `${p.id} has invalid state '${p.state}'`).toBe(true); - } - }); - - it('every ENFORCED primitive declares an enforcement site (no silent claims)', () => { - const missing = AUTHZ_CONFORMANCE.filter((p) => p.state === 'enforced' && !p.enforcement).map((p) => p.id); - expect(missing, `enforced primitives missing an enforcement site: ${missing.join(', ')}`).toEqual([]); - }); - - it('every experimental/removed primitive carries a note (honest rationale)', () => { - const missing = AUTHZ_CONFORMANCE.filter((p) => p.state !== 'enforced' && !p.note).map((p) => p.id); - expect(missing, `non-enforced primitives missing a note: ${missing.join(', ')}`).toEqual([]); - }); - - it('every referenced dogfood proof FILE EXISTS (the ratchet)', () => { - const broken: string[] = []; - for (const p of AUTHZ_CONFORMANCE as AuthzPrimitive[]) { - if (p.proof && !existsSync(join(HERE, p.proof))) broken.push(`${p.id} → ${p.proof}`); - } - expect(broken, `conformance proofs missing on disk: ${broken.join(', ')}`).toEqual([]); - }); - - it('the high-risk owner/derived OWD primitives each carry an end-to-end proof', () => { - const highRisk = ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile']; - for (const id of highRisk) { - const p = AUTHZ_CONFORMANCE.find((x) => x.id === id); - expect(p, `missing matrix entry: ${id}`).toBeTruthy(); - expect(p!.proof, `${id} must carry a dogfood proof`).toBeTruthy(); - } + it('is a sound conformance ledger (ADR-0060 checkLedger)', () => { + const problems = checkLedger(AUTHZ_CONFORMANCE, { + proofRoot: HERE, // proofs are dogfood test files alongside this one + highRisk: ['owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile'], + }); + expect(problems, problems.join('\n')).toEqual([]); }); }); diff --git a/packages/dogfood/test/conformance-helper.test.ts b/packages/dogfood/test/conformance-helper.test.ts new file mode 100644 index 0000000000..597525e165 --- /dev/null +++ b/packages/dogfood/test/conformance-helper.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0060 P1 — unit coverage for the reusable `checkLedger` helper. The two +// real ledgers (authz, expression) exercise it end-to-end; this pins each +// invariant in isolation. + +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { checkLedger, type ConformanceRow } from '@objectstack/verify'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ok = (extra: Partial = {}): ConformanceRow => + ({ id: 'a', summary: 's', state: 'enforced', enforcement: 'site', ...extra }); + +describe('checkLedger (ADR-0060)', () => { + it('a sound ledger yields no problems', () => { + expect(checkLedger([ok()], { proofRoot: HERE })).toEqual([]); + }); + it('flags duplicate ids', () => { + expect(checkLedger([ok(), ok()], { proofRoot: HERE }).some((x) => x.includes('duplicate id'))).toBe(true); + }); + it('flags invalid state', () => { + expect(checkLedger([{ id: 'a', summary: 's', state: 'bogus' as never }], { proofRoot: HERE }).some((x) => x.includes('invalid state'))).toBe(true); + }); + it('flags enforced-without-enforcement', () => { + expect(checkLedger([{ id: 'a', summary: 's', state: 'enforced' }], { proofRoot: HERE }).some((x) => x.includes('names no enforcement'))).toBe(true); + }); + it('flags experimental-without-note', () => { + expect(checkLedger([{ id: 'a', summary: 's', state: 'experimental' }], { proofRoot: HERE }).some((x) => x.includes('carries no note'))).toBe(true); + }); + it('flags a missing proof file; accepts an existing one', () => { + expect(checkLedger([ok({ proof: 'does/not/exist.ts' })], { proofRoot: HERE }).some((x) => x.includes('proof missing on disk'))).toBe(true); + expect(checkLedger([ok({ proof: 'conformance-helper.test.ts' })], { proofRoot: HERE })).toEqual([]); + }); + it('high-risk must carry a proof', () => { + expect(checkLedger([ok()], { proofRoot: HERE, highRisk: ['a'] }).some((x) => x.includes('must carry a proof'))).toBe(true); + }); + it('proofRequiredForEnforced flags enforced-without-proof', () => { + expect(checkLedger([ok()], { proofRoot: HERE, proofRequiredForEnforced: true }).some((x) => x.includes('carries no proof'))).toBe(true); + }); + it('flags a surface classified by two rows', () => { + expect(checkLedger([ok({ id: 'a', covers: ['x'] }), ok({ id: 'b', covers: ['x'] })], { proofRoot: HERE }).some((x) => x.includes('more than one row'))).toBe(true); + }); + it('ratchet: unclassified discovered surface', () => { + expect(checkLedger([ok({ covers: ['x'] })], { proofRoot: HERE, discover: () => ['x', 'y'] }).some((x) => x.includes('UNCLASSIFIED surface') && x.includes('y'))).toBe(true); + }); + it('ratchet: stale covers', () => { + expect(checkLedger([ok({ covers: ['x', 'z'] })], { proofRoot: HERE, discover: () => ['x'] }).some((x) => x.includes('STALE covers') && x.includes('z'))).toBe(true); + }); + it('ratchet: fully covered yields no problems', () => { + expect(checkLedger([ok({ covers: ['x', 'y'] })], { proofRoot: HERE, discover: () => ['x', 'y'] })).toEqual([]); + }); +}); diff --git a/packages/dogfood/test/expression-conformance.ledger.ts b/packages/dogfood/test/expression-conformance.ledger.ts index a940af153f..93b00c8d0f 100644 --- a/packages/dogfood/test/expression-conformance.ledger.ts +++ b/packages/dogfood/test/expression-conformance.ledger.ts @@ -1,4 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ConformanceRow } from '@objectstack/verify'; // // ADR-0058 D7 — Expression Surface Conformance ledger. // @@ -24,21 +26,14 @@ export type ExprState = 'enforced' | 'experimental' | 'removed'; /** ADR-0058 D5 fail-policy tiers. */ export type FailPolicy = 'compile-error' | 'fail-closed' | 'fail-soft-log' | 'throw'; -export interface ExprSurface { - id: string; - summary: string; +export interface ExprSurface extends ConformanceRow { dialect: ExprDialect; mode: ExprMode; - state: ExprState; failPolicy: FailPolicy; - /** Runtime evaluator / compiler site. */ - site: string; + /** Runtime evaluator / compiler site (ConformanceRow.enforcement, required here). */ + enforcement: string; /** `file:field` surfaces (relative to packages/spec/src) this row classifies — the ratchet keys. */ covers: string[]; - /** Proof path (repo-root-relative). Required for ENFORCED COMPILE (security) rows. */ - proof?: string; - /** Rationale for experimental/removed, or a roadmap pointer. */ - note?: string; } export const EXPRESSION_SURFACE: ExprSurface[] = [ @@ -47,7 +42,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'rls-using', summary: 'RLS `using` read / pre-image predicate', dialect: 'cel', mode: 'compile', state: 'enforced', failPolicy: 'fail-closed', - site: 'plugin-security/rls-compiler.ts → @objectstack/formula compileCelToFilter (legacy SQL bridged); AND-injected by security-plugin computeRlsFilter + service-analytics read-scope-sql', + enforcement: 'plugin-security/rls-compiler.ts → @objectstack/formula compileCelToFilter (legacy SQL bridged); AND-injected by security-plugin computeRlsFilter + service-analytics read-scope-sql', covers: ['security/rls.zod.ts:using'], proof: 'packages/dogfood/test/rls-fixture.dogfood.test.ts', }, @@ -55,7 +50,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'rls-check', summary: 'RLS `check` write post-image validation (ADR-0058 D4)', dialect: 'cel', mode: 'compile', state: 'enforced', failPolicy: 'fail-closed', - site: 'plugin-security/security-plugin.ts step 3.6 → compileCelToFilter + @objectstack/formula matchesFilterCondition', + enforcement: 'plugin-security/security-plugin.ts step 3.6 → compileCelToFilter + @objectstack/formula matchesFilterCondition', covers: ['security/rls.zod.ts:check'], proof: 'packages/plugins/plugin-security/src/security-plugin.test.ts', }, @@ -63,7 +58,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'sharing-condition', summary: 'sharing-rule `condition` → criteria_json (ADR-0058 D3, closes #1887)', dialect: 'cel', mode: 'compile', state: 'enforced', failPolicy: 'fail-closed', - site: 'plugin-sharing/bootstrap-declared-sharing-rules.ts celToFilter → compileCelToFilter; matched by sharing-rule-service findMatchingRecords', + enforcement: 'plugin-sharing/bootstrap-declared-sharing-rules.ts celToFilter → compileCelToFilter; matched by sharing-rule-service findMatchingRecords', covers: ['security/sharing.zod.ts:condition'], proof: 'packages/plugins/plugin-sharing/src/sharing-rule.test.ts', }, @@ -73,21 +68,21 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'cel-validation', summary: 'object validation predicate (condition / when)', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', - site: '@objectstack/formula celEngine (interpret) via the validation runner', + enforcement: '@objectstack/formula celEngine (interpret) via the validation runner', covers: ['data/validation.zod.ts:condition', 'data/validation.zod.ts:when'], }, { id: 'cel-hook', summary: 'hook gate condition', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', - site: '@objectstack/formula celEngine (interpret) via the hook runner', + enforcement: '@objectstack/formula celEngine (interpret) via the hook runner', covers: ['data/hook.zod.ts:condition'], }, { id: 'cel-formula', summary: 'computed / formula field + mapping / graphql / feature expressions', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', - site: '@objectstack/formula celEngine (interpret)', + enforcement: '@objectstack/formula celEngine (interpret)', covers: [ 'data/field.zod.ts:expression', 'shared/mapping.zod.ts:expression', @@ -99,7 +94,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'cel-field-rule', summary: 'field UI rules (requiredWhen / readonlyWhen / visibleWhen / conditionalRequired)', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', - site: '@objectstack/formula celEngine (interpret) — console (objectui) + server', + enforcement: '@objectstack/formula celEngine (interpret) — console (objectui) + server', covers: [ 'data/field.zod.ts:requiredWhen', 'data/field.zod.ts:readonlyWhen', @@ -111,7 +106,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'cel-ui', summary: 'UI visibility / routing / submit predicates', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', - site: 'console (objectui) SchemaRenderer + server celEngine (interpret)', + enforcement: 'console (objectui) SchemaRenderer + server celEngine (interpret)', covers: [ 'data/object.zod.ts:visibleOn', 'ui/action.zod.ts:visible', @@ -127,7 +122,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'cel-flow', summary: 'flow / sync / loader branching + filter predicates', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'throw', - site: '@objectstack/formula celEngine (interpret) via the automation runtime', + enforcement: '@objectstack/formula celEngine (interpret) via the automation runtime', covers: [ 'automation/flow.zod.ts:condition', 'automation/sync.zod.ts:condition', @@ -138,7 +133,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ id: 'cel-advanced-policy', summary: 'advanced security / versioning policy conditions', dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'fail-closed', - site: '(no runtime consumer yet)', + enforcement: '(no runtime consumer yet)', covers: [ 'kernel/plugin-security-advanced.zod.ts:condition', 'kernel/plugin-versioning.zod.ts:condition', diff --git a/packages/dogfood/test/expression-conformance.test.ts b/packages/dogfood/test/expression-conformance.test.ts index ffec9ba6db..99861778cb 100644 --- a/packages/dogfood/test/expression-conformance.test.ts +++ b/packages/dogfood/test/expression-conformance.test.ts @@ -1,24 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // ADR-0058 D7 — the Expression Surface Conformance ledger is a CHECKED artifact. -// These assertions make "every expression-holding declaration is classified in -// exactly one honest state, every COMPILE security surface is reachable by the -// canonical compiler and proven, and no new surface slips in unclassified" a -// green CI gate. A new ExpressionInputSchema field with no ledger row — the -// #1887 class of declared-but-unwired predicate — breaks the build. +// Refactored onto the reusable ADR-0060 `checkLedger` helper: one call asserts +// the shared invariants AND the ratchet (re-discover every ExpressionInputSchema +// field in packages/spec/src + the RLS using/check predicates; fail if any is +// unclassified). The expression-specific invariants (mode/dialect/fail-policy, +// compile rows name the canonical compiler) stay here. -import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative } from 'node:path'; -import { EXPRESSION_SURFACE, type ExprSurface } from './expression-conformance.ledger.js'; +import { checkLedger } from '@objectstack/verify'; +import { EXPRESSION_SURFACE } from './expression-conformance.ledger.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '../../..'); const SPEC_SRC = join(REPO_ROOT, 'packages/spec/src'); const MODES = new Set(['compile', 'interpret']); -const STATES = new Set(['enforced', 'experimental', 'removed']); const FAIL_POLICIES = new Set(['compile-error', 'fail-closed', 'fail-soft-log', 'throw']); const DIALECTS = new Set(['cel', 'cron', 'template', 'js']); @@ -41,79 +41,37 @@ function discoverSurfaces(): Set { } }; walk(SPEC_SRC); - // RLS using/check are expression predicates too (legacy z.string() fields, not - // ExpressionInputSchema) — classify them explicitly so they cannot drift. + // RLS using/check are expression predicates too (legacy z.string() fields). found.add('security/rls.zod.ts:using'); found.add('security/rls.zod.ts:check'); return found; } describe('ADR-0058 D7 — expression surface conformance ledger', () => { - it('has no duplicate ids', () => { - const ids = EXPRESSION_SURFACE.map((s) => s.id); - expect(new Set(ids).size).toBe(ids.length); + it('is a sound conformance ledger + ratchet (ADR-0060 checkLedger)', () => { + const problems = checkLedger(EXPRESSION_SURFACE, { + proofRoot: REPO_ROOT, + discover: discoverSurfaces, + }); + expect(problems, problems.join('\n')).toEqual([]); }); - it('every row has a valid mode / state / dialect / fail-policy', () => { + it('every row has a valid expression mode / dialect / fail-policy', () => { for (const s of EXPRESSION_SURFACE) { expect(MODES.has(s.mode), `${s.id}: mode '${s.mode}'`).toBe(true); - expect(STATES.has(s.state), `${s.id}: state '${s.state}'`).toBe(true); expect(DIALECTS.has(s.dialect), `${s.id}: dialect '${s.dialect}'`).toBe(true); expect(FAIL_POLICIES.has(s.failPolicy), `${s.id}: failPolicy '${s.failPolicy}'`).toBe(true); - expect(s.site && s.site.length > 0, `${s.id}: missing site`).toBe(true); - expect(Array.isArray(s.covers) && s.covers.length > 0, `${s.id}: empty covers`).toBe(true); } }); - it('every COMPILE row is security fail-closed, names the canonical compiler, and is proven', () => { + it('every COMPILE row is fail-closed and names the canonical compiler', () => { for (const s of EXPRESSION_SURFACE.filter((x) => x.mode === 'compile')) { expect(s.failPolicy, `${s.id}: a compile/security surface must fail closed`).toBe('fail-closed'); - // Compiler-reachable: the site must reference the canonical compiler entry. - expect(/compileCelToFilter|celToFilter|matchesFilterCondition/.test(s.site), `${s.id}: site does not name the canonical compiler`).toBe(true); + expect( + /compileCelToFilter|celToFilter|matchesFilterCondition/.test(s.enforcement), + `${s.id}: enforcement does not name the canonical compiler`, + ).toBe(true); expect(s.proof, `${s.id}: an enforced compile surface must carry a proof`).toBeTruthy(); } }); - - it('every referenced proof FILE EXISTS (the proof ratchet)', () => { - const broken: string[] = []; - for (const s of EXPRESSION_SURFACE as ExprSurface[]) { - if (s.proof && !existsSync(join(REPO_ROOT, s.proof))) broken.push(`${s.id} → ${s.proof}`); - } - expect(broken, `ledger proofs missing on disk: ${broken.join(', ')}`).toEqual([]); - }); - - it('every experimental/removed row carries a note (honest rationale)', () => { - const missing = EXPRESSION_SURFACE.filter((s) => s.state !== 'enforced' && !s.note).map((s) => s.id); - expect(missing, `non-enforced rows missing a note: ${missing.join(', ')}`).toEqual([]); - }); - - // ── THE RATCHET ────────────────────────────────────────────────────────── - it('classifies EVERY expression surface in the spec — no unclassified declaration', () => { - const discovered = discoverSurfaces(); - const covered = new Set(EXPRESSION_SURFACE.flatMap((s) => s.covers)); - - // (a) every discovered surface is classified by some row - const unclassified = [...discovered].filter((s) => !covered.has(s)).sort(); - expect( - unclassified, - `NEW unclassified expression surface(s) — add a row to expression-conformance.ledger.ts ` + - `(ADR-0058 D7): ${unclassified.join(', ')}`, - ).toEqual([]); - - // (b) no stale `covers` entry that no longer exists in the spec - const stale = [...covered].filter((s) => !discovered.has(s)).sort(); - expect(stale, `STALE ledger covers (surface removed from spec): ${stale.join(', ')}`).toEqual([]); - }); - - it('each surface is covered by EXACTLY ONE row (no double classification)', () => { - const seen = new Map(); - const dup: string[] = []; - for (const s of EXPRESSION_SURFACE) { - for (const c of s.covers) { - if (seen.has(c)) dup.push(`${c} (in ${seen.get(c)} and ${s.id})`); - else seen.set(c, s.id); - } - } - expect(dup, `surfaces classified by more than one row: ${dup.join(', ')}`).toEqual([]); - }); }); diff --git a/packages/verify/src/conformance.ts b/packages/verify/src/conformance.ts new file mode 100644 index 0000000000..7ce47ea660 --- /dev/null +++ b/packages/verify/src/conformance.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Conformance ledger — the reusable platform pattern (ADR-0060). + * + * A "conformance ledger" classifies every declarable property of a surface into + * exactly one honest state — `enforced` / `experimental` / `removed` (ADR-0049) — + * names the runtime site that enforces it, and (for high-risk) references a proof. + * The platform hand-wrote this twice (ADR-0056 D10 authz matrix, ADR-0058 D7 + * expression surface) before promoting the shared invariants here. + * + * `checkLedger` returns a list of problems (empty = sound) so the helper carries + * no test-runner dependency — callers assert `toEqual([])`. The optional + * `discover` enables the **ratchet**: re-derive the real surface from source and + * fail when a declaration is unclassified (the #1887 / declared-but-unenforced + * class) or a `covers` entry is stale. + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +export type ConformanceState = 'enforced' | 'experimental' | 'removed'; + +export interface ConformanceRow { + /** Stable unique id. */ + id: string; + /** One-line human summary. */ + summary: string; + /** The declaration site this row classifies (free-form; e.g. `file:field`). */ + surface?: string; + /** Exactly one honest state (ADR-0049). */ + state: ConformanceState; + /** Runtime enforcement site — REQUIRED when `state === 'enforced'`. */ + enforcement?: string; + /** Proof path (resolved against {@link CheckLedgerOptions.proofRoot}); file must exist. */ + proof?: string; + /** Ratchet keys this row accounts for (matched against `discover()`). */ + covers?: string[]; + /** Rationale — REQUIRED when `state !== 'enforced'`. */ + note?: string; + /** Per-surface extras (dialect, mode, fail-policy, …) — not checked here. */ + meta?: Record; +} + +export interface CheckLedgerOptions { + /** Directory each row's `proof` is resolved against. */ + proofRoot: string; + /** Re-derive the real surface from source; enables the ratchet. */ + discover?: () => Iterable; + /** Row ids that MUST carry a proof. */ + highRisk?: string[]; + /** When true, EVERY enforced row must carry a proof (default: only high-risk). */ + proofRequiredForEnforced?: boolean; +} + +const VALID_STATES: ReadonlySet = new Set(['enforced', 'experimental', 'removed']); + +/** + * Assert a conformance ledger's shared invariants. Returns a list of problem + * strings; an empty array means the ledger is sound. + */ +export function checkLedger(rows: readonly ConformanceRow[], opts: CheckLedgerOptions): string[] { + const problems: string[] = []; + + // Unique ids. + const seenIds = new Set(); + for (const r of rows) { + if (seenIds.has(r.id)) problems.push(`duplicate id: ${r.id}`); + seenIds.add(r.id); + } + + for (const r of rows) { + if (!VALID_STATES.has(r.state)) problems.push(`${r.id}: invalid state '${r.state}'`); + if (!r.summary) problems.push(`${r.id}: missing summary`); + if (r.state === 'enforced' && !r.enforcement) problems.push(`${r.id}: enforced but names no enforcement site`); + if (r.state !== 'enforced' && !r.note) problems.push(`${r.id}: ${r.state} but carries no note (honest rationale)`); + if (r.proof && !existsSync(join(opts.proofRoot, r.proof))) problems.push(`${r.id}: proof missing on disk: ${r.proof}`); + if (opts.proofRequiredForEnforced && r.state === 'enforced' && !r.proof) problems.push(`${r.id}: enforced but carries no proof`); + } + + // High-risk rows must carry a proof. + for (const id of opts.highRisk ?? []) { + const r = rows.find((x) => x.id === id); + if (!r) problems.push(`high-risk id not in ledger: ${id}`); + else if (!r.proof) problems.push(`high-risk ${id} must carry a proof`); + } + + // `covers`: each surface classified by exactly one row. + const covered = new Map(); + for (const r of rows) { + for (const c of r.covers ?? []) { + const prev = covered.get(c); + if (prev) problems.push(`surface "${c}" classified by more than one row (${prev}, ${r.id})`); + else covered.set(c, r.id); + } + } + + // The ratchet: every discovered surface is covered; no stale covers. + if (opts.discover) { + const discovered = new Set(opts.discover()); + for (const s of discovered) { + if (!covered.has(s)) problems.push(`UNCLASSIFIED surface — add a ledger row (ADR-0060): ${s}`); + } + for (const c of covered.keys()) { + if (!discovered.has(c)) problems.push(`STALE covers — surface no longer in source: ${c}`); + } + } + + return problems; +} diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 6874babcce..96af85216e 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -18,3 +18,8 @@ export type { VerifyReport, ObjectVerifyResult } from './verify.js'; export { runRlsProofs, formatRlsReport } from './rls.js'; export type { RlsReport, RlsResult } from './rls.js'; + +// ADR-0060 — reusable conformance-ledger helper (static complement to the +// runtime harness): classify every declarable property, fail closed on drift. +export { checkLedger } from './conformance.js'; +export type { ConformanceRow, ConformanceState, CheckLedgerOptions } from './conformance.js';