From d06131a2da7b1de60c7a07157a6f990f6cb725f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:46:28 +0000 Subject: [PATCH 1/2] fix(verify): bind a conformance row to its proof by NAME, not just by path (#7976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkLedger` asserted only that a cited `proof` file EXISTS — nothing ever read it — so a row could cite a test exercising a neighbouring primitive and stay green forever. `rls-read` and `rls-by-id-write` cite the same file, and until PR #7975 read it by hand nothing could tell whether it exercised one or both. "Does this test prove this row" is not mechanically decidable and is NOT attempted. It is converted into a checkable question: a proof file NAMES the rows it is the proof for (`// authz-row: `), and the new opt-in `CheckLedgerOptions.attribution` asserts the pairing is mutual — a cited file must claim the citing row, and every claim must be reciprocated by the ledger (with `scan` reaching claims in files no row cites, so a stale claim fails rather than rots). A comment marker, not an exported manifest: proof files are test modules whose import boots real stacks, so the claim must be readable without executing them. The keyword is deliberately not `@proof:` — that channel carries ADR-0054 liveness ids, a different vocabulary from matrix row ids. All 24 authz rows citing a proof were annotated by reading the cited file. One citation did not survive that read: `requireAuth-removed` cited showcase-anonymous-deny.dogfood.test.ts, which proves the anonymous-deny posture (what the `anonymous-deny` row already claims) and never authors `requireAuth: false`, reads the spec tombstone, or boots an auth-less stack — so it cannot prove this row's distinguishing half, that there is no opt-out. The citation is dropped rather than rubber-stamped; state and enforcement site are unchanged and the note now records where the retirement is actually pinned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019zbHQZ3tutYjjZwvsw3DoF --- .changeset/authz-proof-attribution.md | 60 ++++++++++ .../dogfood/test/authz-conformance.matrix.ts | 24 +++- .../qa/dogfood/test/authz-conformance.test.ts | 93 ++++++++++++++- .../dogfood/test/conformance-helper.test.ts | 49 ++++++++ .../test/controlled-by-parent.dogfood.test.ts | 8 ++ .../dogfood/test/flow-runas.dogfood.test.ts | 4 + ...ner-anchor-and-bulk-writes.dogfood.test.ts | 7 ++ .../dogfood/test/rls-fixture.dogfood.test.ts | 9 ++ .../test/rls-multitenant.dogfood.test.ts | 11 ++ ...se-anonymous-deny-surfaces.dogfood.test.ts | 10 ++ .../showcase-anonymous-deny.dogfood.test.ts | 12 ++ ...wcase-bu-hierarchy-sharing.dogfood.test.ts | 8 ++ ...e-declarative-rbac-seeding.dogfood.test.ts | 4 + .../showcase-default-profile.dogfood.test.ts | 4 + ...showcase-mcp-http-identity.dogfood.test.ts | 4 + ...howcase-permission-seeding.dogfood.test.ts | 4 + .../test/showcase-private-owd.dogfood.test.ts | 4 + .../test/showcase-public-form.dogfood.test.ts | 6 + .../showcase-public-read-owd.dogfood.test.ts | 4 + .../test/showcase-scope-depth.dogfood.test.ts | 6 + .../showcase-static-readonly.dogfood.test.ts | 6 + packages/verify/src/conformance.ts | 108 +++++++++++++++++- packages/verify/src/index.ts | 7 +- 23 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 .changeset/authz-proof-attribution.md diff --git a/.changeset/authz-proof-attribution.md b/.changeset/authz-proof-attribution.md new file mode 100644 index 0000000000..8992e84b71 --- /dev/null +++ b/.changeset/authz-proof-attribution.md @@ -0,0 +1,60 @@ +--- +"@objectstack/verify": patch +--- + +fix(verify): a conformance ledger's `proof` must NAME the row it proves, not merely exist (#7976) + +`checkLedger` asserted exactly one thing about a `proof`: that the file is on +disk (`existsSync(join(proofRoot, r.proof))`). Nothing ever read it. So a row +could cite a test that exercises a **neighbouring** primitive and stay green +forever — and the ADR-0056 D10 authz matrix is where that bites, because it is +the artifact reviewers consult *instead of* re-deriving the audit by hand. Not +hypothetical bookkeeping: `rls-read` and `rls-by-id-write` cite the **same** +file, and until PR #7975 read it line by line nothing could tell whether it +exercised one, the other, or both. That answer cost a manual read of two proof +files and a live ablation; no gate could have produced it either way. + +"Does this test actually prove this row" is **not** mechanically decidable, and +this does not attempt it — no heuristic, no coverage inference. It converts the +undecidable question into a checkable one: **the proof file names the rows it is +the proof for, and the pairing must be MUTUAL.** + +`CheckLedgerOptions.attribution` (opt-in; every existing ledger is unchanged +until it opts in) takes a marker keyword — the authz matrix uses `authz-row` — +which proof files declare in their header, one line per row: + +```ts +// authz-row: rls-read +// authz-row: rls-by-id-write +``` + +Both directions are then asserted: + +1. every row's cited proof file **claims that row's id**, and +2. every claim is **reciprocated** — the claimed id is a real ledger row, and + that row cites this very file. + +Direction 2 is why the option also takes a `scan`: a claim sitting in a file no +row cites is invisible to the citation walk by construction, which is exactly +what a renamed row or a re-pointed proof leaves behind. + +A **comment marker** rather than an exported manifest is deliberate. Proof files +are test modules whose import registers — and can boot — real stacks, so a claim +has to be readable without executing them; this is the same `readFileSync` the +existence check already implied. It also mirrors the `@proof:` header idiom +dogfood proofs already carry for the ADR-0054 liveness registry, while keeping a +**separate keyword on purpose**: liveness proof ids and matrix row ids are +different vocabularies (one file is `@proof: cbp-controlled-by-parent` *and* +`authz-row: controlled-by-parent`), and collapsing them would let one gate's +rename silently re-point the other's. + +All 24 authz rows that cite a proof were annotated by **reading the cited file**, +never by pattern-matching names. One citation did not survive that read and was +dropped rather than rubber-stamped: `requireAuth-removed` cited +`showcase-anonymous-deny.dogfood.test.ts`, which drives the platform default and +observes 401 — precisely what the `anonymous-deny` row (the same file) already +claims. It never authors `requireAuth: false`, never reads the spec tombstone +and never boots an auth-less stack, so it cannot prove this row's distinguishing +half: that there is **no opt-out**. The row keeps `state: 'enforced'` on its +unchanged enforcement site; it simply stops borrowing a sibling's credibility, +and its note now says where the retirement really is pinned. diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 04e547edb9..c886d48dbe 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -6,10 +6,23 @@ // primitive, each in EXACTLY ONE honest state (enforced / experimental / // removed). `enforced` rows name their runtime enforcement site; high-risk // enforced rows additionally reference an end-to-end dogfood proof. The -// companion test (`authz-conformance.test.ts`) asserts the matrix is complete -// and that every referenced proof file exists — so "the permission model is -// landed" is a CHECKED artifact, not a one-time scan. A new fail-open (a -// declared-but-unenforced primitive) or a deleted proof breaks CI. +// companion test (`authz-conformance.test.ts`) asserts the matrix is complete, +// that every referenced proof file exists, AND that the row ↔ proof pairing is +// MUTUAL — so "the permission model is landed" is a CHECKED artifact, not a +// one-time scan. A new fail-open (a declared-but-unenforced primitive) or a +// deleted proof breaks CI. +// +// [#7976] Existence used to be the whole `proof` contract, which meant a row +// could cite a file exercising a NEIGHBOURING primitive and stay green forever: +// `rls-read` and `rls-by-id-write` cite the same file, and until PR #7975 +// nothing could tell whether it exercised one, the other, or both. "Does this +// test prove this row" is not mechanically decidable and is deliberately NOT +// attempted. The checkable question it is converted into: each proof file NAMES +// the rows it is the proof for (a header `// authz-row: ` line), and the +// checker asserts both directions — a cited file must claim the citing row, and +// every claim must be reciprocated by the ledger. A shared proof file therefore +// has to SAY which rows it covers, and a row whose proof will not claim it +// loses the citation out loud instead of borrowing a sibling's credibility. export type AuthzState = 'enforced' | 'experimental' | 'removed'; @@ -216,8 +229,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ note: 'REMOVED from spec (rls.zod.ts — RLSConfigSchema/RLSAuditEventSchema/RLSAuditConfigSchema deleted). The enforced RLS path (plugin-security computeRlsFilter) never read them; per-policy RowLevelSecurityPolicySchema is the live surface and is unchanged.' }, { id: 'requireAuth-removed', summary: 'anonymous access to object data is denied unconditionally (no opt-out)', state: 'enforced', enforcement: 'core/security/anonymous-deny.ts shouldDenyAnonymous — no `requireAuth` input; every seam denies an anonymous, non-system caller outside the control-plane allowlist. spec tombstones `api.requireAuth` (retiredKey).', - proof: 'showcase-anonymous-deny.dogfood.test.ts', - note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open.' }, + note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open. [#7976] The `showcase-anonymous-deny.dogfood.test.ts` CITATION WAS DROPPED under mutual attribution — that file drives the platform default and observes 401, which is precisely what the `anonymous-deny` row (same file) already claims; it never authors `requireAuth: false`, never reads the spec tombstone and never boots an auth-less stack, so it cannot prove the distinguishing half of THIS row (that there is no opt-out). The retirement is pinned elsewhere and unit-side: the spec tombstone + the ADR-0087 conversion entry `stack.api.requireAuth` (conversions/registry.ts, which strips a surviving key) and rest/rest-auth-gate.test.ts. Not high-risk, so the row is sound without a dogfood proof; writing a real one (author `api: { requireAuth: false }` → expect the boot/authoring rejection) is the honest upgrade path, not re-citing the posture proof.' }, // ── Removed — by ADR-0049 (roadmap M2) ───────────────────────────────── { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed', diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index af3df295b8..3a8339c208 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -15,7 +15,7 @@ import { describe, expect, it } from 'vitest'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { checkLedger } from '@objectstack/verify'; import { AUTHZ_CONFORMANCE, type AuthzPrimitive } from './authz-conformance.matrix.js'; @@ -23,6 +23,21 @@ const HERE = dirname(fileURLToPath(import.meta.url)); // packages/qa/dogfood/test → repo root. const REPO_ROOT = join(HERE, '../../../..'); +// ── #7976 — mutual row ↔ proof attribution ──────────────────────────────── +// A proof file self-declares the rows it is the proof FOR with header +// `// authz-row: ` lines, and `checkLedger` asserts the pairing both ways. +// The keyword is deliberately NOT `@proof:` — that channel carries ADR-0054 +// LIVENESS ids, a different vocabulary from matrix row ids (the same file is +// `@proof: cbp-controlled-by-parent` and `authz-row: controlled-by-parent`), and +// collapsing them would make one gate's rename silently re-point the other's. +const ATTRIBUTION_MARKER = 'authz-row'; +// Scanned so a claim can never rot unnoticed: a claim living in a dogfood file +// NO row cites is invisible to the citation walk by construction, and is exactly +// what a renamed row or a re-pointed proof leaves behind. +const scanProofCandidates = (): string[] => + readdirSync(HERE).filter((f) => f.endsWith('.dogfood.test.ts')); +const ATTRIBUTION = { marker: ATTRIBUTION_MARKER, scan: scanProofCandidates } as const; + // ── #2567 ratchet — static enumeration of anonymous-deny HTTP entry points ── // // A CURATED per-file probe table (not a blind repo grep): scoped to the four @@ -208,6 +223,8 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { // classified by exactly one row's `covers`, and no `covers` key may be // stale (no longer in source). discover: () => discoverAnonymousDenySurfaces(), + // #7976 — and the cited proofs must NAME the rows they prove. + attribution: ATTRIBUTION, }); expect(problems, problems.join('\n')).toEqual([]); }); @@ -218,7 +235,12 @@ describe('ADR-0056 D10 — authorization conformance matrix', () => { // needs no source edits. If these ever pass vacuously, the ratchet is asleep. describe('#2567 — anonymous-deny surface ratchet bites', () => { const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE)); - const opts = (discover: () => Iterable) => ({ proofRoot: HERE, highRisk: HIGH_RISK, discover }); + const opts = (discover: () => Iterable) => ({ + proofRoot: HERE, + highRisk: HIGH_RISK, + discover, + attribution: ATTRIBUTION, + }); it('the real matrix + real discover is sound (baseline lock)', () => { const problems = checkLedger(AUTHZ_CONFORMANCE, opts(() => discoverAnonymousDenySurfaces())); @@ -304,3 +326,70 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { } }); }); + +// ── #7976 — the row ↔ proof ATTRIBUTION bites ───────────────────────────── +// Existence was the whole `proof` contract before this: a row could cite a file +// that exercises a neighbouring primitive and stay green forever. These cases +// drive `checkLedger` with controlled inputs (deep-cloned matrix) so they are +// deterministic and need no source edits — the same shape as the #2567 block +// above. If they ever pass vacuously, the attribution gate is asleep and the +// ledger is back to vouching for citations nobody checked. +describe('#7976 — row ↔ proof attribution is mutual', () => { + const clone = (): AuthzPrimitive[] => JSON.parse(JSON.stringify(AUTHZ_CONFORMANCE)); + const opts = () => ({ proofRoot: HERE, highRisk: HIGH_RISK, attribution: ATTRIBUTION }); + + it('every row that cites a proof is CLAIMED by it (baseline lock)', () => { + // Baseline sanity: the walk has real work to do — this must never become a + // vacuous pass because the rows stopped carrying proofs. + const cited = AUTHZ_CONFORMANCE.filter((r) => r.proof); + expect(cited.length, 'the matrix must still cite proofs').toBeGreaterThanOrEqual(20); + expect(checkLedger(AUTHZ_CONFORMANCE, opts())).toEqual([]); + }); + + it('a row re-pointed at a proof that does NOT claim it fails, NAMING the row', () => { + // The exact defect #7976 filed: `flow-runas.dogfood.test.ts` exists, so the + // pre-#7976 existence check was perfectly happy with this citation. + const m = clone(); + m.find((r) => r.id === 'rls-read')!.proof = 'flow-runas.dogfood.test.ts'; + const problems = checkLedger(m, opts()); + expect(problems.some((p) => p.startsWith('rls-read:') && /does not claim this row/.test(p))).toBe(true); + }); + + it('a shared proof file must claim BOTH rows — dropping one is not covered by the other', () => { + // `rls-fixture.dogfood.test.ts` proves `rls-read` AND `rls-by-id-write` + // (PR #7975). Borrowing the sibling's credibility is the thing that must fail. + const m = clone(); + m.find((r) => r.id === 'rls-by-id-write')!.proof = 'controlled-by-parent.dogfood.test.ts'; + const problems = checkLedger(m, opts()); + expect(problems.some((p) => p.startsWith('rls-by-id-write:') && /does not claim this row/.test(p))).toBe(true); + }); + + it('a claim naming a row the ledger does not have is an ORPHAN', () => { + const m = clone().filter((r) => r.id !== 'flow-run-as'); + const problems = checkLedger(m, opts()); + expect( + problems.some((p) => p.includes('flow-runas.dogfood.test.ts') && /orphaned claim/.test(p)), + ).toBe(true); + }); + + it('a claim the row does not reciprocate fails (attribution is not one-way)', () => { + // The row still exists and its proof still exists — only the pairing broke. + const m = clone(); + m.find((r) => r.id === 'flow-run-as')!.proof = undefined; + const problems = checkLedger(m, opts()); + expect(problems.some((p) => /attribution is not mutual/.test(p) && p.includes('flow-run-as'))).toBe(true); + }); + + it('scanning is what catches a claim no row cites at all', () => { + // Without `scan`, a file nothing cites is never read — so a renamed row + // leaves its stale claim behind, silently. With it, the same edit is loud. + const m = clone(); + m.find((r) => r.id === 'owd-private')!.proof = undefined; + const unscanned = checkLedger(m, { proofRoot: HERE, attribution: { marker: ATTRIBUTION_MARKER } }); + expect(unscanned.some((p) => p.includes('showcase-private-owd.dogfood.test.ts'))).toBe(false); + const scanned = checkLedger(m, opts()); + expect( + scanned.some((p) => p.includes('showcase-private-owd.dogfood.test.ts') && /not mutual/.test(p)), + ).toBe(true); + }); +}); diff --git a/packages/qa/dogfood/test/conformance-helper.test.ts b/packages/qa/dogfood/test/conformance-helper.test.ts index 597525e165..5d01838bbe 100644 --- a/packages/qa/dogfood/test/conformance-helper.test.ts +++ b/packages/qa/dogfood/test/conformance-helper.test.ts @@ -52,3 +52,52 @@ describe('checkLedger (ADR-0060)', () => { expect(checkLedger([ok({ covers: ['x', 'y'] })], { proofRoot: HERE, discover: () => ['x', 'y'] })).toEqual([]); }); }); + +// #7976 — `attribution` binds a row to its proof BY NAME. Without it, existence +// is the whole contract, which is what let a row cite a file exercising a +// neighbouring primitive. These pin the helper's half in isolation; the authz +// ledger drives it end-to-end. +describe('checkLedger attribution (#7976)', () => { + // This very file carries the fixture claims below, in the form the checker + // reads them (comment-anchored, so a mention in prose or a string is not a + // claim). `checkLedger` is being pointed at the test file that declares them. + // helper-fixture-row: a + const SELF = 'conformance-helper.test.ts'; + const attribution = { marker: 'helper-fixture-row' } as const; + + it('is OPT-IN — existence alone still passes without it', () => { + expect(checkLedger([ok({ id: 'unclaimed', proof: SELF })], { proofRoot: HERE })).toEqual([]); + }); + + it('accepts a row its proof claims', () => { + expect(checkLedger([ok({ id: 'a', proof: SELF })], { proofRoot: HERE, attribution })).toEqual([]); + }); + + it('flags a row its proof does NOT claim, naming the row', () => { + const problems = checkLedger([ok({ id: 'unclaimed', proof: SELF })], { proofRoot: HERE, attribution }); + expect(problems.some((x) => x.startsWith('unclaimed:') && x.includes('does not claim this row'))).toBe(true); + }); + + it('flags an orphaned claim — a claimed id that is not a ledger row', () => { + const problems = checkLedger([ok({ id: 'b', proof: SELF })], { proofRoot: HERE, attribution }); + expect(problems.some((x) => x.includes('orphaned claim') && x.includes('a'))).toBe(true); + }); + + it('flags a one-way claim — the row exists but cites something else', () => { + const rows = [ok({ id: 'a', proof: 'authz-conformance.test.ts' }), ok({ id: 'c', proof: SELF })]; + const problems = checkLedger(rows, { proofRoot: HERE, attribution }); + expect(problems.some((x) => x.includes('attribution is not mutual'))).toBe(true); + }); + + it('`scan` reaches claims in files NO row cites', () => { + const rows = [ok({ id: 'a' })]; // 'a' is claimed by SELF, but cites nothing + expect(checkLedger(rows, { proofRoot: HERE, attribution })).toEqual([]); + const scanned = checkLedger(rows, { proofRoot: HERE, attribution: { ...attribution, scan: () => [SELF] } }); + expect(scanned.some((x) => x.includes('attribution is not mutual'))).toBe(true); + }); + + it('a proof missing on disk is reported once, not twice', () => { + const problems = checkLedger([ok({ proof: 'does/not/exist.ts' })], { proofRoot: HERE, attribution }); + expect(problems.filter((x) => x.includes('does/not/exist.ts'))).toHaveLength(1); + }); +}); diff --git a/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts b/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts index 479bb06a50..3085d1e339 100644 --- a/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts +++ b/packages/qa/dogfood/test/controlled-by-parent.dogfood.test.ts @@ -3,6 +3,14 @@ // Master-detail "controlled by parent" RLS proof (ADR-0055 P2), end-to-end // through the real HTTP + security stack. // +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). The +// claim rests on this file's OWN evidence, as re-decided in PR #7975: +// `fixtures/cbp-fixture.ts` grants the member full CRUD on BOTH master and +// detail, so the derived READ and by-id WRITE denials asserted below are the +// derived record gate answering, never the object gate. +// authz-row: controlled-by-parent +// // @proof: cbp-controlled-by-parent // ADR-0055 runtime proof for derived master-detail access. Referenced by the // liveness ledger entry `object.sharingModel` (packages/spec/liveness/object.json); diff --git a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts index 3936d64a66..9ad6c477d0 100644 --- a/packages/qa/dogfood/test/flow-runas.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-runas.dogfood.test.ts @@ -3,6 +3,10 @@ // FLOW runAs identity-enforcement proof (#1888), exercised end-to-end through the // real HTTP + automation + security stack. // +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: flow-run-as +// // @proof: flow-runas-identity // Security-layer instance of the "configured in the UI, silently does nothing at // runtime" anti-pattern (sibling of the assignment/decision node fixes). A flow's diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 3d58dc4a63..735ce77d17 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -19,6 +19,13 @@ // to rows the caller may edit. // // @proof: owner-anchor-and-bulk-writes +// +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). Two +// rows, two distinct bodies of evidence in this one file: the #3004 forge / +// transfer / disown cases, and the #2982 bulk update + bulk delete cases. +// authz-row: ownership-anchor-guard +// authz-row: bulk-write-owner-scoping import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts b/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts index 9a773de438..a1df25c765 100644 --- a/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-fixture.dogfood.test.ts @@ -2,6 +2,15 @@ // // The HARD, revert-provable #1994 gate. // +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). BOTH +// are claimed on their own evidence, as re-decided in PR #7975: the read side by +// the member who cannot GET the admin note, the by-id write by the select-only +// block below (its member set grants FULL CRUD on `rls_note`, so a refusal is +// the record gate) asserting the PATCH is refused with the row unchanged. +// authz-row: rls-read +// authz-row: rls-by-id-write +// // @proof: rls-by-id-write // ADR-0054 runtime proof for the RLS / sharing high-risk class. Referenced by the // liveness ledger entry `permission.rowLevelSecurity.using` diff --git a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts index 5cd708d816..e9d66364de 100644 --- a/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts +++ b/packages/qa/dogfood/test/rls-multitenant.dogfood.test.ts @@ -36,6 +36,17 @@ // // Empirically (CRM): single-tenant → every object `member-visible`; multi-tenant // → `rls-consistent` with zero holes. This test asserts that faithful state. +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// ⚠️ The claim is CONDITIONAL by construction: the suite is +// `describe.skipIf(!organizationsAvailable)`, so in the open workspace it does +// not run at all and only enterprise/cloud CI (which ships +// `@objectstack/organizations`) actually exercises the row. The marker records +// what this file proves WHERE IT RUNS — it is not an assertion that open-core CI +// proved it. `OS_TEST_MULTI_ORG_ENABLED=1` turns an unexpected skip into a +// failure, which is the mechanism that keeps the skip honest (#4700). +// authz-row: multi-tenant import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import crmStack from '@objectstack/example-crm'; diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index abd5942f76..16fe5c24e1 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -28,6 +28,16 @@ // half, so the proof file once again covers everything the matrix row claims // it covers. // +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). One +// per SURFACE, each with its own anonymous-401 cases below. It deliberately does +// NOT claim `anonymous-deny`: the REST `/data` cases here are the cross-surface +// contrast, and that row's cited proof is showcase-anonymous-deny.dogfood.test.ts. +// authz-row: anonymous-deny-meta +// authz-row: anonymous-deny-actions +// authz-row: anonymous-deny-automation +// authz-row: anonymous-deny-packages +// // The value this boot adds OVER #5569's own runtime integration test // (`dispatcher-plugin.anonymous-gate.integration.test.ts`) is precisely the // comparison that test documented it could NOT make: it boots a LiteKernel that diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts index 346eae1981..ffe0b1a012 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny.dogfood.test.ts @@ -8,6 +8,18 @@ // control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). // Public forms survive the same default via the declaration-derived // publicFormGrant — see showcase-public-form.dogfood.test.ts. +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: anonymous-deny +// +// ⚠️ It claims `anonymous-deny` and NOTHING ELSE. The `requireAuth-removed` row +// used to cite this same file (#7976): what that row asserts is that the +// deployment-wide OPT-OUT is RETIRED — `api.requireAuth` tombstoned in spec, a +// stack that mounts no auth failing AT BOOT — and this file exercises none of +// it. It drives the platform default and observes 401, which is exactly what +// `anonymous-deny` already claims. Under mutual attribution that citation could +// not be made honestly, so it was dropped rather than rubber-stamped here. import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts index 8d0ffcb8c5..98137decf6 100644 --- a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -9,6 +9,14 @@ // rows; a non-owner in the unit subtree can then read a private record. // // @proof: showcase-bu-hierarchy-sharing +// +// ADR-0056 D10 — the authz-conformance matrix rows this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). Two +// rows on two assertions: the criteria rule MATERIALISING into sys_record_share +// (`sharing-rules`, here via the `business_unit` recipient), and the subordinate +// unit's member reading through the tree (`hierarchy-widening`). +// authz-row: sharing-rules +// authz-row: hierarchy-widening import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts index 66840dcc9b..350778d655 100644 --- a/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-rbac-seeding.dogfood.test.ts @@ -6,6 +6,10 @@ // count = 0. This proves the opposite, plus the spec→runtime translation. // // @proof: showcase-declarative-rbac-seeding +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: declarative-rbac-seeding import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts index b0da95f108..fd19705a77 100644 --- a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -34,6 +34,10 @@ // The pair below is therefore red in both directions: the first case goes red if // the declared default is not in force, the second if it DISPLACES the built-in // one instead of composing with it. +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: default-profile import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts index f88ddaff13..f7bef824e0 100644 --- a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts @@ -8,6 +8,10 @@ // caller's RLS, so a member sees only their own rows, and an anonymous caller is // denied before any tool executes. // +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: mcp-http-identity +// // The target object is `showcase_private_note` (OWD `private`, owner-only) — the // same object the declarative-OWD proof uses. Owner isolation is enforced by the // engine, so if the MCP tool ran unscoped (the stdio posture, mcp-stdio-authority) diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 6d8ca2d993..7d27aa2ce7 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -7,6 +7,10 @@ // package's sets, and uninstall/upgrade have a well-defined owner axis. // Proven on the real showcase stack, which declares `showcase_contributor` // and `showcase_member_default` in `src/security/`. +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: declarative-permission-seeding import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts index 9f344abbc9..a11693176d 100644 --- a/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts @@ -7,6 +7,10 @@ // every read/write to the owner purely from the OWD baseline + the auto-stamped // `owner_id`. This is the canonical "declare one word, get owner isolation" // capability — proven end-to-end through the real HTTP stack. +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: owd-private import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts index 91a3c8b23b..49a1f176ff 100644 --- a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts @@ -9,6 +9,12 @@ // anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with // NO `guest_portal` profile, authorized solely by the declaration-derived // `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY). +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). The +// row's own evidence is the #3022 case: a forged owner_id / organization_id on +// the anonymous submit never lands on the row. +// authz-row: public-form-managed-anchors import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts index 5ce478f02a..f9d3b8db48 100644 --- a/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-read-owd.dogfood.test.ts @@ -6,6 +6,10 @@ // from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the // sibling of the `private` proof: same owner-write protection, but rows are // VISIBLE across owners (the read-visibility axis of OWD). +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). +// authz-row: owd-public-read import { describe, it, expect, beforeAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; diff --git a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts index fe8382894f..13f16ba41b 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -12,6 +12,12 @@ // the contract end-to-end; production ships the enterprise resolver. // // @proof: showcase-scope-depth +// +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). It +// drives `unit`, `unit_and_below` and `own_and_reports` plus the open-edition +// fail-closed fallback; `own` is proven by showcase-private-owd. +// authz-row: scope-depth import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; diff --git a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts index 9e25468959..77645d39cf 100644 --- a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts @@ -2,6 +2,12 @@ // // @proof: readonly-static-write // +// ADR-0056 D10 — the authz-conformance matrix row this file is the cited proof +// for; `authz-conformance.test.ts` asserts the pairing is mutual (#7976). Note +// the two vocabularies coincide here by accident, not by rule: the `@proof:` +// id above is an ADR-0054 liveness id, `authz-row:` below is a matrix row id. +// authz-row: readonly-static-write +// // #2948 / #3003 (UPDATE) + #3043 (INSERT) — static `readonly: true` is // SERVER-enforced on BOTH write paths, not a UI-only affordance. The #3003 // field report: an approval-flow object declared `approval_status` / diff --git a/packages/verify/src/conformance.ts b/packages/verify/src/conformance.ts index 7ce47ea660..b9010a3287 100644 --- a/packages/verify/src/conformance.ts +++ b/packages/verify/src/conformance.ts @@ -14,9 +14,18 @@ * `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. + * + * The optional `attribution` closes the gap #7976 named: a `proof` used to be + * checked for EXISTENCE only, so a row could cite a file that exercises a + * neighbouring primitive and stay green forever (`rls-read` and + * `rls-by-id-write` cited the same file, and nothing could tell whether it + * exercised one, the other, or both). "Does this test actually prove this row" + * is not mechanically decidable and is deliberately NOT attempted; the + * checkable question it is converted into is **mutual naming** — the proof file + * must claim the rows it is cited for, and every claim must be reciprocated. */ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; export type ConformanceState = 'enforced' | 'experimental' | 'removed'; @@ -32,7 +41,11 @@ export interface ConformanceRow { state: ConformanceState; /** Runtime enforcement site — REQUIRED when `state === 'enforced'`. */ enforcement?: string; - /** Proof path (resolved against {@link CheckLedgerOptions.proofRoot}); file must exist. */ + /** + * Proof path (resolved against {@link CheckLedgerOptions.proofRoot}); the file + * must exist — and, when {@link CheckLedgerOptions.attribution} is on, must + * also CLAIM this row's id (see {@link ProofAttributionOptions}). + */ proof?: string; /** Ratchet keys this row accounts for (matched against `discover()`). */ covers?: string[]; @@ -51,6 +64,60 @@ export interface CheckLedgerOptions { highRisk?: string[]; /** When true, EVERY enforced row must carry a proof (default: only high-risk). */ proofRequiredForEnforced?: boolean; + /** Bind row ↔ proof by NAME, not just by path (#7976). */ + attribution?: ProofAttributionOptions; +} + +/** + * Mutual row ↔ proof attribution (#7976). + * + * A cited proof file self-declares the rows it is a proof FOR, as header + * comment lines: `// : `, one per row. `checkLedger` then + * asserts the pairing in BOTH directions: + * + * 1. every row's cited proof file claims that row's id, and + * 2. every claim is reciprocated — the claimed id is a real ledger row AND + * that row cites this very file. + * + * Direction 2 is what makes a stale claim (a renamed row, a proof that was + * re-pointed elsewhere) fail rather than rot, which is why {@link scan} exists: + * a file no row cites is invisible to direction 1 by construction. + * + * A comment marker — rather than an exported manifest — is deliberate: proof + * files are test modules whose import registers (and can boot) real stacks, so + * the claim must be readable WITHOUT executing them. It is the same + * `readFileSync` the existence check already implies, and it mirrors the + * `@proof:` header idiom dogfood proofs already carry for the ADR-0054 liveness + * registry. The marker keyword is per-ledger precisely so the two vocabularies + * (liveness proof ids vs ledger row ids) cannot be confused for one another. + */ +export interface ProofAttributionOptions { + /** Claim keyword, e.g. `'authz-row'` → a proof file line `// authz-row: rls-read`. */ + marker: string; + /** + * Extra proof-root-relative files to scan for claims, beyond the ones rows + * cite. Any claim found in a file NO row cites is reported (direction 2). + */ + scan?: () => Iterable; +} + +/** Escape a literal for embedding in a RegExp source. */ +function escapeRe(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Extract a proof file's row claims. Matches the marker at the start of a + * comment line (`//` or a block-comment `*` continuation) so a mention inside + * prose or a string literal is not mistaken for a claim. + */ +function readClaims(absPath: string, marker: string): string[] { + const re = new RegExp(`^[ \\t]*(?://+|\\*)[ \\t]*${escapeRe(marker)}:[ \\t]*(\\S+)`, 'gm'); + const src = readFileSync(absPath, 'utf8'); + const claims: string[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(src)) !== null) claims.push(m[1]); + return claims; } const VALID_STATES: ReadonlySet = new Set(['enforced', 'experimental', 'removed']); @@ -85,6 +152,43 @@ export function checkLedger(rows: readonly ConformanceRow[], opts: CheckLedgerOp else if (!r.proof) problems.push(`high-risk ${id} must carry a proof`); } + // Mutual row ↔ proof attribution (#7976) — a proof must NAME what it proves. + if (opts.attribution) { + const { marker, scan } = opts.attribution; + + // Which rows cite each proof file (a shared file legitimately proves several). + const citedBy = new Map(); + for (const r of rows) { + if (r.proof) citedBy.set(r.proof, [...(citedBy.get(r.proof) ?? []), r.id]); + } + + for (const file of new Set([...citedBy.keys(), ...(scan?.() ?? [])])) { + const abs = join(opts.proofRoot, file); + if (!existsSync(abs)) continue; // already reported as missing on disk + const claims = readClaims(abs, marker); + const citers = citedBy.get(file) ?? []; + + // Direction 1 — the cited file must claim every row citing it. + for (const id of citers) { + if (!claims.includes(id)) { + problems.push(`${id}: proof does not claim this row — add \`${marker}: ${id}\` to ${file}, or stop citing it`); + } + } + + // Direction 2 — every claim is reciprocated by the ledger. + for (const id of claims) { + if (!seenIds.has(id)) { + problems.push(`${file}: claims \`${marker}: ${id}\`, but no such row is in the ledger (orphaned claim)`); + } else if (!citers.includes(id)) { + const cited = rows.find((x) => x.id === id)?.proof; + problems.push( + `${file}: claims \`${marker}: ${id}\`, but that row cites ${cited ? `\`${cited}\`` : 'no proof'} — attribution is not mutual`, + ); + } + } + } + } + // `covers`: each surface classified by exactly one row. const covered = new Map(); for (const r of rows) { diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index f742f4ad77..26f727a7fd 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -36,7 +36,12 @@ export type { // 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'; +export type { + ConformanceRow, + ConformanceState, + CheckLedgerOptions, + ProofAttributionOptions, +} from './conformance.js'; // Driver read-coercion conformance: a stored value must read back as its // declared type on every driver (the case_escalation `1 != true` invariant). From 0c54af8d4b6e1664fb15dd5bcc98d7ca46219805 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:03:01 +0000 Subject: [PATCH 2/2] docs(permissions): the conformance matrix now checks proof ATTRIBUTION, not just existence (#7976) The authorization page described the matrix's proof contract as "a deleted proof fails CI", which was the whole of it. It now also asserts the row <-> proof pairing is mutual, so say that: a cited proof must name the rows it proves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019zbHQZ3tutYjjZwvsw3DoF --- content/docs/permissions/authorization.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 008c920309..45edf13789 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -360,7 +360,11 @@ Five mechanisms — four CI-time, one runtime — make the security posture a ADR-0056 D10): every authorization primitive sits in exactly one honest state — `enforced` (must name its enforcement site; high-risk rows must reference an end-to-end dogfood proof), `experimental`, or `removed`. A new - fail-open or a deleted proof fails CI. + fail-open or a deleted proof fails CI. A cited proof must also **name the rows + it proves** — a header `// authz-row: ` line — and CI asserts the pairing + is mutual in both directions (#7976), so a row cannot cite a test that + exercises a neighbouring primitive, and a shared proof file has to say which + rows it covers. - **Liveness ledger** (`packages/spec/liveness/`, ADR-0049/0054): every governed spec property is classified live / experimental / dead, with author-time warnings for declared-but-unenforced flags.