From a35a11217c18518412adc8170b59080498f1e41b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:28:46 +0000 Subject: [PATCH 01/16] docs(verify): bootStack takes defineStack output; a raw literal registers raw fields One docblock sentence on bootStack and one README sentence: the config it takes is defineStack output, and a raw object literal registers its fields as authored, so an autonumber that authors no unique gets no unique scope and no index. No behaviour change. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/README.md | 5 +++++ packages/verify/src/harness.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/verify/README.md b/packages/verify/README.md index 53f80bc0d9d..a07377af529 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -430,6 +430,11 @@ that declares `requires: ['automation']` gets it without this), `orgContext`, `databaseFile`, `extraPlugins`, `artifact` (boot the compiled artifact as the app, so hooks and actions run their lowered `body`). +The `config` `bootStack` takes is `defineStack` output, as every example above +passes it: a raw object literal registers its fields as authored, without the +spec's parse defaults, so an `autonumber` that authors no `unique` gets no +unique scope and no index. + ## Known limitations - **Intentional write-transforms read back as fidelity gaps.** The fidelity diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index f100aaf6ef1..0230cbdecca 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -788,6 +788,12 @@ function claimConfiguration(config: unknown): ConfigurationClaim { /** * Boot an app config in-process and return a live verification stack. * + * `config` is `defineStack` output, which is what an app's configuration + * module exports and what `objectstack verify` loads: a raw object literal is + * not parsed here, so it registers its fields as authored, without the spec's + * parse defaults (an `autonumber` that authors no `unique` gets no unique + * scope and no index). + * * `NODE_ENV` is forced to `development` so the auth plugin's dev-admin * bootstrap provisions a known, loginable admin (mirrors `objectstack dev`). * From aff59344f9974630e434b21e53b47934e837bf17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:28:57 +0000 Subject: [PATCH 02/16] docs(verify): the membership-policy sentence names the auth.membership_policy setting A walled boot with the open organizations package needs a declared membership policy, and the policy is declared by the auth.membership_policy setting or by OS_AUTH_MEMBERSHIP_POLICY. The README named the variable only. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/verify/README.md b/packages/verify/README.md index a07377af529..853902413a5 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -96,7 +96,8 @@ probed through the system door only, which keeps an explicit value. Without cross-organization leg is reported **not run**, with the reason, on the report's `boot` — run it org-scoped to prove that `true` / `'organization'` lets two organizations hold one value (the walled boot needs a declared -membership policy, `OS_AUTH_MEMBERSHIP_POLICY`). A refusal proves the +membership policy: the `auth.membership_policy` setting or +`OS_AUTH_MEMBERSHIP_POLICY`). A refusal proves the collision only when it is the platform's unique refusal and it is this field's: it names the field, or it names none (a per-organization constraint is an expression index, which the driver reports by index name) and the same record From 42d15b2d57cbe5b27251fc155eac0d093dee5e94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:29:04 +0000 Subject: [PATCH 03/16] docs(changeset): the unique-scope family's second organization is signUp then POST /auth/organization/create The parenthetical compressed two calls into one phrase. The changeset is still unreleased (pre.json lists no consumed changeset), so it is corrected in place. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/15953-verify-unique-scope-family.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15953-verify-unique-scope-family.md b/.changeset/15953-verify-unique-scope-family.md index b1c6a6f48bd..d55ed016dd6 100644 --- a/.changeset/15953-verify-unique-scope-family.md +++ b/.changeset/15953-verify-unique-scope-family.md @@ -10,7 +10,7 @@ Clause-②: yes A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. -- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. +- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (`signUp`, then `POST /auth/organization/create`) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. From 591513f0f5b3f5f0ca9dda9494019421231132df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:32:02 +0000 Subject: [PATCH 04/16] fix(verify): a field-less unique refusal is unattributed where a unique indexes[] entry covers the column The unique-scope family credited a refusal that names no field to the probed field once its twin was admitted (insert) or outright (update). A declared unique indexes[] entry over the same column refuses that same write, naming no field either, whenever the fixtures hold its other columns constant, and admits the twin. With the field's own index absent the family read the entry's refusal as the field's declaration holding. judge now reports such a refusal unattributed, naming the covering entry. The attribution control pins it: a field whose own index cannot be created (the ADR-0120 D4 two-boot shape) beside a unique composite indexes[] over the same column read proven on 4 of 4 writes before this change, and reads inconclusive, 4 of 4 unattributed, after it. The README and the unreleased changeset state the narrower attribution. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../15953-verify-unique-scope-family.md | 2 +- packages/verify/README.md | 7 ++- packages/verify/src/unique-scope.test.ts | 57 +++++++++++++++++++ packages/verify/src/unique-scope.ts | 33 +++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/.changeset/15953-verify-unique-scope-family.md b/.changeset/15953-verify-unique-scope-family.md index d55ed016dd6..fa04fafb3cc 100644 --- a/.changeset/15953-verify-unique-scope-family.md +++ b/.changeset/15953-verify-unique-scope-family.md @@ -12,7 +12,7 @@ A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runR - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. - **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (`signUp`, then `POST /auth/organization/create`) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. -A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. +A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none, the same record with only the probed value changed is admitted, and no unique `indexes[]` entry covers the field. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it; a unique `indexes[]` entry over the same column refuses the same write naming no field either, so there a field-less refusal is not attributed. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. diff --git a/packages/verify/README.md b/packages/verify/README.md index 853902413a5..1e6661ce195 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -101,7 +101,10 @@ membership policy: the `auth.membership_policy` setting or collision only when it is the platform's unique refusal and it is this field's: it names the field, or it names none (a per-organization constraint is an expression index, which the driver reports by index name) and the same record -with only the probed value changed is admitted. +with only the probed value changed is admitted. Where a unique `indexes[]` +entry covers the field, a refusal that names none is `unattributed`: that +entry refuses the same write, naming no field either, so it may be the +entry's rather than the field's own declaration. Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, `fidelity-gaps`, `rls-hole`, with `--reference-shapes` a field with `holes` or @@ -373,7 +376,7 @@ on a walled boot `across` × door × insert: | `proven` | every second holder met what the declared scope says: refused within one organization; across, admitted for `true` / `'organization'`, refused for `'global'` or with no organization column | | `contradicted` | a second holder was admitted where the scope refuses it, or refused where it admits it (the constraint is installation-wide, and its refusal tells one organization another holds the value) **(failure)** | | `failed` | a door faulted **(failure)** | -| `inconclusive` | a refusal was not this field's unique constraint (an app's own dedupe, a rule, a permission, a constraint on another field), or a fixture could not be written (the reason is reported) | +| `inconclusive` | a refusal was not this field's unique constraint (an app's own dedupe, a rule, a permission, a constraint on another field, or one that names no field where a unique `indexes[]` entry covers the field), or a fixture could not be written (the reason is reported) | | `excluded` | a derived value (`formula`, `summary`) is not stored, or the declaration does not parse | | `not-derivable` | no fresh value can be built: a closed or tiny value space (`boolean`, `select`, …), a multi-valued reference, a target the app does not declare, or an object the CRUD derivation cannot synthesize | diff --git a/packages/verify/src/unique-scope.test.ts b/packages/verify/src/unique-scope.test.ts index 25cc7a632b2..761b94d4ede 100644 --- a/packages/verify/src/unique-scope.test.ts +++ b/packages/verify/src/unique-scope.test.ts @@ -307,6 +307,63 @@ describe('runUniqueScopeProofs — NEGATIVE CONTROL, within: a declaration its d }); }); +describe('runUniqueScopeProofs — ATTRIBUTION CONTROL, a unique indexes[] entry over the probed column', () => { + // The field's own index cannot be created (the table already holds a + // duplicate when the declaration arrives, ADR-0120 D4), while a declared + // unique composite over the same column can: its other column takes one + // default on every fixture, so the composite refuses every second holder, + // naming no field, and admits the twin. A field-less refusal there is + // possibly that entry's, never proof of the field's own declaration. + const controlApp = (declared: boolean) => ({ + manifest: { id: 'com.example.unique-scope-composite', namespace: 'uqc', version: '0.0.1', type: 'app', name: 'Composite Control' }, + objects: [ + { + name: 'uqc_item', + label: 'Item', + fields: { + name: { type: 'text', label: 'Name', ...(declared ? { unique: true } : {}) }, + kind: { type: 'text', label: 'Kind', ...(declared ? { defaultValue: 'standard' } : {}) }, + }, + ...(declared ? { indexes: [{ fields: ['name', 'kind'], unique: 'organization' }] } : {}), + }, + ], + }); + let dir: string; + let stack: VerifyStack; + let report: UniqueScopeReport; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'verify-unique-scope-composite-')); + const databaseFile = join(dir, 'db.sqlite'); + const before = await bootStack(defineStack(controlApp(false) as never) as never, { databaseFile }); + await before.seed('uqc_item', [{ name: 'held twice', kind: 'a' }, { name: 'held twice', kind: 'b' }]); + await before.stop(); + const app = defineStack(controlApp(true) as never) as never; + stack = await bootStack(app, { databaseFile }); + report = await runUniqueScopeProofs(stack, await stack.signIn(), app); + }, 2 * BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('never credits a field-less refusal to the field when a unique indexes[] entry covers its column', () => { + const r = resultFor(report, 'uqc_item', 'name'); + expect(r.status, JSON.stringify(r.probes)).toBe('inconclusive'); + expect(shape(report, 'uqc_item', 'name')).toEqual([ + 'within rest insert refused unattributed', 'within rest update refused unattributed', + 'within system insert refused unattributed', 'within system update refused unattributed', + ]); + for (const p of r.probes) { + expect(p.answer).toMatchObject({ kind: 'refused' }); + expect(p.answer!.field).toBeUndefined(); + expect(p.detail).toMatch(/^the unique refusal names no field, and a unique indexes\[\] entry \(name, kind\) covers name/); + } + expect(report.summary).toMatchObject({ proven: 0, inconclusive: 1, unattributed: 4, hardFailures: 0 }); + }); +}); + describe('runUniqueScopeProofs — NEGATIVE CONTROL, across: a per-organization field a global index overrides', () => { const controlApp = { manifest: { id: 'com.example.unique-scope-across', namespace: 'uqx', version: '0.0.1', type: 'app', name: 'Across Control' }, diff --git a/packages/verify/src/unique-scope.ts b/packages/verify/src/unique-scope.ts index fee9f33f4a0..c81e31ce5f2 100644 --- a/packages/verify/src/unique-scope.ts +++ b/packages/verify/src/unique-scope.ts @@ -61,6 +61,13 @@ // permission, a constraint on another field — is `unattributed`: an engine // that refused every write would otherwise prove every scope. // +// A refusal that names no field is never attributed where a unique +// `indexes[]` entry covers the probed column: that entry refuses the same +// second holder, naming no field either, whenever the fixtures hold its other +// columns constant, and its twin is admitted too. So there the refusal could +// be the entry's while the field's own constraint is absent, and it is +// `unattributed`. +// // Run it on a fresh stack of its own: it writes duplicates by design, and the // records it creates would collide with another family's on a unique field. @@ -371,6 +378,16 @@ function uniqueFields(objectDef: any): Set { return out; } +/** The columns of each unique `indexes[]` entry of `objectDef` that includes `field`. */ +function uniqueIndexesOver(objectDef: any, field: string): string[][] { + const out: string[][] = []; + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + const columns = Array.isArray(index?.fields) ? index.fields.map(String) : []; + if (index?.unique && columns.includes(field)) out.push(columns); + } + return out; +} + /** * The smallest record of `objectDef` the CRUD derivation can offer: its values * for the fields the object REQUIRES (required relations already filled), so @@ -485,6 +502,8 @@ interface UniqueScopeObservation { secondHolds?: boolean; /** When the second write was admitted: both records carry the same organization. */ sameOrganization?: boolean; + /** The unique `indexes[]` entries whose columns include the probed field. */ + coveringIndexes: string[][]; } /** @@ -508,6 +527,17 @@ function judge( if (answer.field !== undefined && answer.field !== c.field) { return { outcome: 'unattributed', detail: `refused by the unique constraint on another field (${answer.field})` }; } + // A unique `indexes[]` entry over this column refuses the same write, + // naming no field, and admits the twin: a field-less refusal is then + // possibly the entry's, whatever the field's own constraint does. + if (answer.field === undefined && o.coveringIndexes.length > 0) { + return { + outcome: 'unattributed', + detail: + `the unique refusal names no field, and a unique indexes[] entry (${o.coveringIndexes[0]!.join(', ')}) covers ` + + `${c.field}, so the refusal may be that entry's rather than the field's own declaration`, + }; + } // A refusal naming no field is this field's when the only change that // turns it into an admitted write is the probed value: an update changes // nothing else, and an insert's twin changes nothing else. @@ -699,6 +729,7 @@ export async function runUniqueScopeProofs( continue; } const source = valueSource(c.type, c.field, objectDefs.get(c.object)?.fields?.[c.field], c.object, crud.get(c.object)); + const coveringIndexes = uniqueIndexesOver(objectDefs.get(c.object), c.field); // Probe values draw on the fixtures' counter, so no probe value equals a fixture's. const fresh = async (w: Writer): Promise<{ value?: unknown; reason?: string }> => { if (source.base !== undefined) return { value: vary(source.base, ++variant) }; @@ -742,6 +773,7 @@ export async function runUniqueScopeProofs( const observation: UniqueScopeObservation = { answer: d.answer, ...(twin ? { twin } : {}), + coveringIndexes, holderHolds: holds(holderRow?.[c.field], h.value), ...(d.id ? { secondHolds: holds(secondRow?.[c.field], h.value), @@ -763,6 +795,7 @@ export async function runUniqueScopeProofs( const changed = await row(c.object, e.id); const observation: UniqueScopeObservation = { answer, + coveringIndexes, holderHolds: holds(holderRow?.[c.field], h.value), ...(answer.kind === 'written' ? { secondHolds: holds(changed?.[c.field], h.value), From 28bd69c8e51c185628090390bd5b77e3101c941b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:38:15 +0000 Subject: [PATCH 05/16] feat(verify): the parent-derived write reach family (#15953 slice 4, WIP) deriveParentWriteReachCases / runParentWriteReachProofs / formatParentWriteReachReport / parentWriteReachSecurity. For every object the spec's parse declares controlled_by_parent, each persona's edit, delete and create of a detail through the REST door is compared with the conjunction ADR-0055 section 3 states: the persona holds the verb on the detail's object (the server's effective permissions) AND edits the master (measured by an empty PATCH of the master as the persona). Tests and the CLI flag follow. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/index.ts | 28 +- packages/verify/src/parent-write-reach.ts | 915 ++++++++++++++++++++++ 2 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 packages/verify/src/parent-write-reach.ts diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 5ceca92d529..43401499e7e 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -3,12 +3,13 @@ // @objectstack/verify — public API. // // Boot any ObjectStack app in-process and verify it through the real HTTP -// stack. Five proof families, all app-agnostic (derived from your metadata): +// stack. Six proof families, all app-agnostic (derived from your metadata): // - data fidelity : runCrudVerification — author → write → read → assert // - authorization : runRlsProofs — "you can't write what you can't read" // - value shape : runReferenceShapeProofs — "a reference refuses a value that is not a record id" // - delete behaviour: runDeleteBehaviorProofs — "a declared deleteBehavior is what a delete does" // - unique scope : runUniqueScopeProofs — "a declared unique collides where its scope says" +// - parent write : runParentWriteReachProofs — "a detail write needs edit on its master, and its own verb" export { bootStack, bootStackOnce } from './harness.js'; export type { VerifyStack, BootOptions } from './harness.js'; @@ -125,6 +126,31 @@ export type { UniqueScopeProofOptions, } from './unique-scope.js'; +// ADR-0055 §3 — the parent-derived write reach family: for every object a +// stack declares `controlled_by_parent`, a principal's edit, delete or create +// of a detail is admitted exactly when it holds that verb on the detail's +// object AND can edit the master (measured, never re-derived), asked of the +// engine through the REST door as verifier and declared-position personas. +export { + deriveParentWriteReachCases, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, +} from './parent-write-reach.js'; +export type { + ParentWriteReachHop, + ParentWriteReachCase, + ParentWriteReachVerb, + ParentWriteReachOwner, + ParentWriteReachAnswer, + ParentWriteReachProbeResult, + ParentWriteReachReach, + ParentWriteReachObjectStatus, + ParentWriteReachObjectResult, + ParentWriteReachPersona, + ParentWriteReachReport, +} from './parent-write-reach.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'; diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts new file mode 100644 index 00000000000..9d46bd4d2e6 --- /dev/null +++ b/packages/verify/src/parent-write-reach.ts @@ -0,0 +1,915 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The parent-derived write reach family: for every object a stack declares +// `controlled_by_parent`, a principal's write on a detail record is what +// ADR-0055 §3 says it is. +// +// ## The promise, and where each half of it is read from +// +// ADR-0055 §3: "editing/deleting/creating a detail requires **edit** access to +// its master", and its 2026-09-07 amendment: the write gate walks the chain hop +// by hop to the first master that governs its own rows. The detail's OWN +// object permission is the first door. So for a principal P, a detail record D +// under a master record M, and a verb (edit, delete, create), the expected +// answer is a conjunction: +// +// - P holds the verb on D's object — read from the server's effective object +// permissions for P (`GET /auth/me/permissions`, the one merge of the sets +// P holds, ADR-0124 D4), folded by the spec's `objectPermissionGrants`; +// - P can edit M — MEASURED, never re-derived: an empty `PATCH` of M as P, +// which runs every write gate M has (the object gate, the record gate and, +// for a master that is itself controlled by its parent, the master gate +// above it) and changes no business value. +// +// Expected `allowed` iff both hold. ⛔ Nothing here resolves sharing, +// ownership, hierarchy or a chain: the master half is the engine's own answer +// on M, and the family's subject is that D's answer agrees with it. +// +// Which objects are in the population, and through which field, is the spec's +// own parse of the object (`ObjectSchema`): `sharingModel: +// 'controlled_by_parent'` and exactly one `master_detail` field (ADR-0055 §1). +// +// ## The door, and who walks through it +// +// The REST data route as the signed-in principal: `PATCH`, `DELETE` and `POST` +// on `/data/:object[/:id]`. The engine's own write as the system principal +// bypasses authorization by design, so it is not in this family's population. +// +// The principals: three verifier personas whose permission sets this module +// authors from the app's own objects (registered at boot by +// `parentWriteReachSecurity`, granted by name, ADR-0131 D3/D4 — the RLS +// family's probe-persona path), and one persona per position the app declares +// (`provisionRlsPositionPersona`, whose whole capability is what the app binds +// to the position). Each persona meets two master chains: one another +// principal owns (so an all-records grant, not ownership, is what is +// exercised) and one the persona owns; and under each, a detail another +// principal owns and one the persona owns. +// +// ## Attribution is the control +// +// A refusal proves the conjunction held only when it is the platform's +// authorization refusal for this detail: `403 PERMISSION_DENIED` naming the +// detail's object. The REST envelope carries no operation; the route names +// it. Any other refusal (validation, a hook's own code, a uniqueness +// collision, a missing field) is `unattributed`, never proven. +// +// Run it on a fresh stack of its own, booted with +// `parentWriteReachSecurity(config)`: it writes, deletes and signs up. + +import { ObjectSchema, referenceCarrierOf } from '@objectstack/spec/data'; +import { objectPermissionGrants, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets, appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { declaredCollection } from './artifact-collections.js'; +import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import type { VerifyStack } from './harness.js'; +import { declaredPositionNames, provisionRlsPositionPersona } from './rls.js'; + +/** + * ADR-0055's amendment: the write gate walks at most `CBP_MAX_CHAIN_DEPTH` + * (currently 8) controlled-by-parent masters and DENIES at the bound — a cost + * ceiling, not a supported chain length. A detail with that many + * controlled-by-parent masters above it is excluded: the engine refuses it by + * design there, whatever its master answers. + */ +const CHAIN_DEPTH_BOUND = 8; + +/** One `master_detail` link a write on a detail walks. */ +export interface ParentWriteReachHop { + /** The object holding the link. */ + object: string; + /** Its `master_detail` field (the field key is the FK column). */ + field: string; + /** The object that field references. */ + master: string; +} + +/** One object the app declares `controlled_by_parent`, and the masters a write on it walks. */ +export interface ParentWriteReachCase { + object: string; + /** + * The detail's own link first, then each master's own link while that + * master is itself `controlled_by_parent`. The last hop's master governs + * its own rows. + */ + hops: ParentWriteReachHop[]; + /** Why the declaration states no master this family can compare against. */ + excluded?: string; + /** Why no detail under its master chain can be built from the app's metadata. */ + notDerivable?: string; +} + +/** The verbs a detail write is probed with. */ +export type ParentWriteReachVerb = 'edit' | 'delete' | 'create'; + +/** Who owns a fixture record: another principal, or the probing persona. */ +export type ParentWriteReachOwner = 'other' | 'own'; + +/** What one REST call answered. */ +export interface ParentWriteReachAnswer { + /** `written` — 2xx; `refused` — a coded refusal; `faulted` — a 5xx, or no code. */ + kind: 'written' | 'refused' | 'faulted'; + status?: number; + code?: string; + /** The object the refusal names. */ + object?: string; + detail?: string; +} + +/** What one detail write found. */ +export interface ParentWriteReachProbeResult { + verb: ParentWriteReachVerb; + /** Who owns the detail record written (a create writes a new one, owned by the persona). */ + detailOwner?: ParentWriteReachOwner; + /** The conjunction: the persona holds the verb on the detail's object AND edited the master. */ + expected: 'allowed' | 'refused'; + /** + * - `held` — the write met the conjunction; + * - `contradicted` — it met the other answer (a hard failure; see `detail`); + * - `unattributed` — refused, but not by the platform's authorization refusal for this detail; + * - `failed` — the door faulted; + * - `not-run` — a fixture could not be written, or the record does not show what the door answered. + */ + outcome: 'held' | 'contradicted' | 'unattributed' | 'failed' | 'not-run'; + answer?: ParentWriteReachAnswer; + detail?: string; +} + +/** One persona against one master chain of one detail object. */ +export interface ParentWriteReachReach { + persona: string; + /** Who owns the master chain. */ + masters: ParentWriteReachOwner; + /** `unreadable` — the persona cannot read the master, so the detail is outside its reach (not probed). */ + masterRead: 'readable' | 'unreadable' | 'unmeasured'; + /** The measured half: an empty `PATCH` of the master as the persona. */ + masterEdit?: 'editable' | 'not-editable' | 'unmeasured'; + masterAnswer?: ParentWriteReachAnswer; + /** The detail object's effective grants for the persona (`/auth/me/permissions`). */ + grants?: Record; + /** Why this reach has no probe. */ + reason?: string; + probes: ParentWriteReachProbeResult[]; +} + +/** + * - `proven` — every probe held; + * - `contradicted` — some probe met the other answer (a hard failure); + * - `failed` — a door faulted and none contradicted (a hard failure); + * - `inconclusive` — some probe was unattributed or could not run, a master's edit reach could not be measured, or no probe ran; + * - `excluded` — the declaration names no master this family can compare against (see `reason`); + * - `not-derivable` — no detail under its master chain can be built from the app's metadata (see `reason`). + */ +export type ParentWriteReachObjectStatus = 'proven' | 'contradicted' | 'failed' | 'inconclusive' | 'excluded' | 'not-derivable'; + +export interface ParentWriteReachObjectResult { + object: string; + hops: ParentWriteReachHop[]; + status: ParentWriteReachObjectStatus; + reason?: string; + reaches: ParentWriteReachReach[]; +} + +/** A principal the family writes as. */ +export interface ParentWriteReachPersona { + /** `master-all-records`, `detail-only`, `master-all-records-no-delete`, or `position:`. */ + name: string; + kind: 'verifier' | 'position'; + label: string; + status: 'run' | 'not-run'; + reason?: string; + /** The permission sets the server resolved for it (`/auth/me/permissions`). */ + permissionSets?: string[]; +} + +export interface ParentWriteReachReport { + app: string; + /** The one door asked. The system door bypasses authorization by design and is not. */ + door: 'rest'; + personas: ParentWriteReachPersona[]; + results: ParentWriteReachObjectResult[]; + summary: { + objects: number; + /** Objects probed (neither excluded nor not-derivable). */ + probed: number; + proven: number; + contradicted: number; + failed: number; + inconclusive: number; + excluded: number; + notDerivable: number; + personas: { run: number; notRun: number }; + /** Persona × master chain pairs: probed, outside the persona's read reach, or with an unmeasured master. */ + reaches: { probed: number; unreadable: number; unmeasured: number }; + /** Probes per verb. */ + probes: Record; + held: number; + contradictions: number; + unattributed: number; + faults: number; + notRun: number; + /** `contradicted + failed` objects, plus personas that could not be provisioned — the count a CI gate reads. */ + hardFailures: number; + }; +} + +const clip = (s: unknown, n = 200): string => String(s ?? '').slice(0, n); + +// ── The population ────────────────────────────────────────────────────────── + +/** The spec's parse of an object, or the reason it does not parse. */ +function parseObject(obj: any): { data?: any; reason?: string } { + const parsed = ObjectSchema.safeParse(obj); + if (parsed.success) return { data: parsed.data }; + const issue = parsed.error.issues[0]; + return { reason: `the object does not parse under the spec's ObjectSchema (${issue?.path.join('.') || 'object'}: ${clip(issue?.message, 120)})` }; +} + +/** A parsed object's one `master_detail` link, or why it states none. */ +function masterLinkOf(name: string, parsed: any): { hop?: ParentWriteReachHop; reason?: string } { + const links = Object.entries(parsed?.fields ?? {}).filter(([, f]) => f?.type === 'master_detail'); + if (links.length === 0) { + return { reason: `${name} declares no master_detail field, so it names no master (ADR-0055 §1: exactly one)` }; + } + if (links.length > 1) { + return { + reason: + `${name} declares ${links.length} master_detail fields (${links.map(([k]) => k).join(', ')}), so which master ` + + 'governs it is not a fact the declaration states (ADR-0055 §1: exactly one)', + }; + } + const [field, def] = links[0]!; + let master: string | undefined; + try { + master = referenceCarrierOf({ reference: def?.reference }, 'verify parent write reach') ?? undefined; + } catch (e) { + return { reason: `${name}.${field}: ${clip((e as Error)?.message, 160)}` }; + } + return master ? { hop: { object: name, field, master } } : { reason: `${name}.${field} names no master object` }; +} + +/** + * Derive one case per object the app declares `controlled_by_parent`, as the + * spec's parse of the object states it, with the chain of masters a write on + * it walks. An object the family cannot compare is still returned, + * `excluded` or `notDerivable`, with the reason, so the report counts it. + */ +export function deriveParentWriteReachCases(config: any): ParentWriteReachCase[] { + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const byName = new Map(); + for (const obj of declaredCollection(config, 'objects')) if (obj?.name) byName.set(obj.name, obj); + const parsed = new Map(); + const parse = (name: string) => { + if (!parsed.has(name)) parsed.set(name, parseObject(byName.get(name))); + return parsed.get(name)!; + }; + + const cases: ParentWriteReachCase[] = []; + for (const [name, obj] of byName) { + const own = parse(name); + if (own.reason) { + if (obj?.sharingModel === 'controlled_by_parent') cases.push({ object: name, hops: [], excluded: own.reason }); + continue; + } + if (own.data.sharingModel !== 'controlled_by_parent') continue; + + const c: ParentWriteReachCase = { object: name, hops: [] }; + const visited = new Set([name]); + let current = name; + let currentParsed = own.data; + for (;;) { + const link = masterLinkOf(current, currentParsed); + if (!link.hop) { + c.excluded = current === name ? link.reason : `its master ${current} is controlled_by_parent, and ${link.reason}`; + break; + } + c.hops.push(link.hop); + const master = link.hop.master; + if (!byName.has(master)) { + c.excluded = `the master ${master} is not an object this app declares, so no master record can be built from its metadata`; + break; + } + const m = parse(master); + if (m.reason) { + c.excluded = `the master ${master}: ${m.reason}`; + break; + } + if (m.data.sharingModel !== 'controlled_by_parent') break; + if (visited.has(master)) { + c.excluded = `the master chain re-enters ${master} (a cycle): the engine's write gate refuses by its cycle guard, whatever a master answers`; + break; + } + visited.add(master); + if (c.hops.length >= CHAIN_DEPTH_BOUND) { + c.excluded = + `the master chain reaches ${CHAIN_DEPTH_BOUND} controlled_by_parent masters, the write gate's depth bound ` + + "(ADR-0055's amendment), where it refuses by design whatever a master answers"; + break; + } + current = master; + currentParsed = m.data; + } + if (!c.excluded) { + for (const objectName of [name, ...c.hops.map((h) => h.master)]) { + const blocked = crud.get(objectName)?.blocked; + if (blocked) { + c.notDerivable = objectName === name + ? `the detail cannot be synthesized: ${blocked}` + : `the master ${objectName} cannot be synthesized: ${blocked}`; + break; + } + } + } + cases.push(c); + } + return cases; +} + +// ── The verifier personas ─────────────────────────────────────────────────── + +/** The three verifier personas: the set each holds, and the email it signs up with. */ +const VERIFIER_PERSONAS = [ + { name: 'master-all-records', set: 'verify_parent_write_master_all', email: 'verify-parent-write-master-all@objectstack.test' }, + { name: 'detail-only', set: 'verify_parent_write_detail_only', email: 'verify-parent-write-detail-only@objectstack.test' }, + { name: 'master-all-records-no-delete', set: 'verify_parent_write_no_delete', email: 'verify-parent-write-no-delete@objectstack.test' }, +] as const; +const VERIFIER_PASSWORD = 'Parent-Write-Probe-123'; +const OWNER_EMAIL = 'verify-parent-write-owner@objectstack.test'; + +/** + * The verifier personas' permission sets, authored from the app's own + * objects. A DETAIL is every probed object and every master above it that is + * itself controlled by its parent (its rows' access is derived, so it is a + * detail of the next master); a ROOT is the master each chain ends at, which + * governs its own rows. + * + * - `verify_parent_write_master_all` — the root by the all-records grant + * (`modifyAllRecords`), the details read / create / edit / delete: every + * write is expected allowed. + * - `verify_parent_write_detail_only` — the root readable (`viewAllRecords`) + * but not editable, the details read / create / edit / delete: every write + * is expected refused, by the master. + * - `verify_parent_write_no_delete` — the root by the all-records grant, the + * details read / create / edit: an edit and a create are expected allowed, + * and a delete refused by the detail's own permission (the master's + * all-records grant widens which rows, never which verbs). + */ +function parentWriteReachPermissionSets(config: any): PermissionSet[] { + const details = new Set(); + const roots = new Set(); + for (const c of deriveParentWriteReachCases(config)) { + if (c.excluded || c.notDerivable) continue; + details.add(c.object); + c.hops.forEach((h, i) => (i === c.hops.length - 1 ? roots.add(h.master) : details.add(h.master))); + } + const set = (name: string, label: string, detail: Record, root: Record) => + ({ + name, + label, + objects: { + ...Object.fromEntries([...details].map((o) => [o, { ...detail }])), + ...Object.fromEntries([...roots].map((o) => [o, { ...root }])), + }, + }) as unknown as PermissionSet; + const all = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + const modifyAll = { allowRead: true, allowEdit: true, modifyAllRecords: true }; + return [ + set(VERIFIER_PERSONAS[0].set, 'Verify parent write: master by all-records grant', all, modifyAll), + set(VERIFIER_PERSONAS[1].set, 'Verify parent write: detail only', all, { allowRead: true, viewAllRecords: true }), + set(VERIFIER_PERSONAS[2].set, 'Verify parent write: master by all-records grant, no detail delete', { + allowRead: true, allowCreate: true, allowEdit: true, + }, modifyAll), + ]; +} + +/** + * The `SecurityPlugin` a parent-write-reach boot needs: the platform defaults, + * the app's own declared default profile, and the three verifier personas' + * sets. Registered at boot because a set's definition comes from the registry + * alone (ADR-0131 D3/D4), exactly as `rlsProbeSecurity` registers the RLS + * probe's set; a stack booted without it runs the position personas only and + * reports the verifier personas not run. + */ +export function parentWriteReachSecurity(config: any): SecurityPlugin { + return new SecurityPlugin({ + ...(appSecurityPluginOptions(config) ?? {}), + defaultPermissionSets: [...securityDefaultPermissionSets, ...parentWriteReachPermissionSets(config)], + }); +} + +// ── The door ──────────────────────────────────────────────────────────────── + +async function rest( + stack: VerifyStack, + token: string, + method: string, + path: string, + body?: unknown, +): Promise { + let res: Response; + try { + res = await stack.apiAs(token, method, path, body); + } catch (e) { + return { kind: 'faulted', detail: clip((e as Error)?.message ?? e) }; + } + const status = res.status; + const text = await res.text(); + let payload: any; + try { + payload = JSON.parse(text); + } catch { + payload = undefined; + } + if (status >= 200 && status < 300) { + const id = payload?.id ?? payload?.record?.id; + return { kind: 'written', status, payload, ...(id != null ? { id: String(id) } : {}) }; + } + if (status >= 500 || typeof payload?.code !== 'string') { + return { kind: 'faulted', status, ...(payload?.code ? { code: payload.code } : {}), detail: clip(payload?.error ?? text) }; + } + return { + kind: 'refused', + status, + code: payload.code, + ...(typeof payload.object === 'string' ? { object: payload.object } : {}), + detail: clip(payload.error ?? text), + }; +} + +/** The platform's authorization refusal for `object`. */ +const deniedFor = (answer: ParentWriteReachAnswer, object: string): boolean => + answer.kind === 'refused' && answer.code === 'PERMISSION_DENIED' && answer.status === 403 && answer.object === object; + +const plain = (a: ParentWriteReachAnswer): ParentWriteReachAnswer => ({ + kind: a.kind, + ...(a.status !== undefined ? { status: a.status } : {}), + ...(a.code !== undefined ? { code: a.code } : {}), + ...(a.object !== undefined ? { object: a.object } : {}), + ...(a.detail !== undefined && a.kind !== 'written' ? { detail: a.detail } : {}), +}); + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const DAY_MS = 86_400_000; + +/** A `date` / `datetime` value moved `days` later, in the same spelling. */ +function laterBy(value: string, days: number): string { + const at = Date.parse(value); + if (Number.isNaN(at) || days === 0) return value; + const moved = new Date(at + days * DAY_MS).toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; +} + +/** The `k`-th distinct value of a sample `base`. */ +function vary(base: string | number, k: number): string | number { + if (typeof base === 'number') return base + k; + if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); + const at = base.indexOf('@'); + if (at > 0) return `${base.slice(0, at)}-p${k}${base.slice(at)}`; + if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/p${k}`; + if (/^\+\d+$/.test(base)) return `${base}${k}`; + return `${base}-p${k}`; +} + +/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ +function uniqueFields(objectDef: any): Set { + const out = new Set(); + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.unique !== undefined && def?.unique !== false) out.add(name); + } + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); + } + return out; +} + +/** + * The smallest record of `objectDef` the CRUD derivation can offer (the + * unique-scope family's fixture shape): its values for the fields the object + * REQUIRES, so every optional field takes the app's own default. Each `date` / + * `datetime` is one day after the one declared before it, and each + * unique-constrained value is made distinct by `n`. + */ +function fixtureBody(body: Record, objectDef: any, n: number): Record { + const unique = uniqueFields(objectDef); + const out: Record = {}; + let dates = 0; + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.required !== true || !(name in body)) continue; + let value = body[name]; + if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); + if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); + out[name] = value; + } + return out; +} + +// ── The verdict ───────────────────────────────────────────────────────────── + +/** The grant key a verb is read from on the detail's effective permission. */ +const GRANT: Record = { + edit: 'allowEdit', + delete: 'allowDelete', + create: 'allowCreate', +}; + +/** + * Compare one detail write against the conjunction. The whole verdict of the + * family is this function: it reads the expectation, the door's answer and + * whether the record shows the write, and nothing else. + */ +function judge( + c: ParentWriteReachCase, + probe: { verb: ParentWriteReachVerb; expected: 'allowed' | 'refused'; why: string }, + answer: ParentWriteReachAnswer, + /** Whether the record shows the write (`undefined` when it cannot say). */ + landed: boolean | undefined, +): Pick { + if (answer.kind === 'faulted') return { outcome: 'failed', detail: `the door faulted: ${answer.status ?? ''} ${answer.detail ?? ''}` }; + if (answer.kind === 'refused') { + if (!deniedFor(answer, c.object)) { + return { + outcome: 'unattributed', + detail: `refused for another reason: ${answer.code}${answer.status ? ` (${answer.status})` : ''}${answer.object ? ` naming ${answer.object}` : ''} ${answer.detail ?? ''}`, + }; + } + if (landed === true) return { outcome: 'contradicted', detail: `the ${probe.verb} was refused, yet the record shows it landed` }; + if (probe.expected === 'refused') return { outcome: 'held' }; + return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was refused (403 PERMISSION_DENIED)` }; + } + if (landed === false) return { outcome: 'not-run', detail: `the door answered ${answer.status}, but the record does not show the ${probe.verb}` }; + if (probe.expected === 'allowed') return { outcome: 'held' }; + return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was admitted (${answer.status})` }; +} + +function objectStatus(reaches: ParentWriteReachReach[]): { status: ParentWriteReachObjectStatus; reason?: string } { + const probes = reaches.flatMap((r) => r.probes); + if (probes.some((p) => p.outcome === 'contradicted')) return { status: 'contradicted' }; + if (probes.some((p) => p.outcome === 'failed')) return { status: 'failed' }; + const open = probes.filter((p) => p.outcome === 'unattributed' || p.outcome === 'not-run'); + const unmeasured = reaches.filter((r) => r.masterRead === 'unmeasured' || r.masterEdit === 'unmeasured'); + if (open.length > 0 || unmeasured.length > 0 || probes.length === 0) { + const first = open[0]?.detail ?? unmeasured[0]?.reason; + return { + status: 'inconclusive', + reason: probes.length === 0 && unmeasured.length === 0 + ? 'no probe could be run: no persona reads a master of this object' + : `${open.length} of ${probes.length} write(s) unattributed or not run, ${unmeasured.length} master(s) unmeasured: ${clip(first, 160)}`, + }; + } + return { status: 'proven' }; +} + +// ── The run ───────────────────────────────────────────────────────────────── + +interface RunningPersona extends ParentWriteReachPersona { + token: string; + userId: string; + /** The effective object permissions the server answered for it. */ + objects: Record; +} + +/** + * Run the parent-derived write reach family against a live stack: every + * persona (the three verifier personas, then one per declared position) + * writes every probed detail object's records through the REST door — edit, + * delete and create — under a master chain another principal owns and one it + * owns, and each answer is compared with the conjunction. Returns a + * structured report; a per-object failure is collected, not thrown. + */ +export async function runParentWriteReachProofs(stack: VerifyStack, config: any): Promise { + const cases = deriveParentWriteReachCases(config); + const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); + const objectDefs = new Map(); + for (const o of declaredCollection(config, 'objects')) if (o?.name) objectDefs.set(o.name, o); + const hasOwnerColumn = (object: string): boolean => { + try { + return 'owner_id' in ((stack.metadata.object(object) as any)?.fields ?? {}); + } catch { + return false; + } + }; + + // ── The principals ──────────────────────────────────────────────────────── + const ownerToken = await stack.signUp(OWNER_EMAIL); + const ownerId = String((await stack.contextFor(ownerToken)).userId); + const personas: RunningPersona[] = []; + const notRun: ParentWriteReachPersona[] = []; + + const admit = async (base: ParentWriteReachPersona, token: string): Promise => { + const userId = String((await stack.contextFor(token)).userId); + const me = await rest(stack, token, 'GET', '/auth/me/permissions'); + const sets: string[] = Array.isArray(me.payload?.permissionSets) ? me.payload.permissionSets.map(String) : []; + if (me.kind !== 'written' || typeof me.payload?.objects !== 'object') { + notRun.push({ ...base, status: 'not-run', reason: `GET /auth/me/permissions answered ${me.status ?? me.kind}: no effective grants to read` }); + return; + } + personas.push({ ...base, status: 'run', permissionSets: sets, token, userId, objects: me.payload.objects }); + }; + + for (const v of VERIFIER_PERSONAS) { + const base: ParentWriteReachPersona = { name: v.name, kind: 'verifier', label: v.email, status: 'run' }; + try { + const first = await stack.signUp(v.email, VERIFIER_PASSWORD); + const userId = (await stack.contextFor(first)).userId; + // [ADR-0131 D4] The grant names its set; the set itself is registered at + // boot by `parentWriteReachSecurity`, never written as a row. + await stack.seed('sys_user_permission_set', [{ user_id: userId, permission_set: v.set }]); + const token = await stack.signIn(v.email, VERIFIER_PASSWORD); + const me = await rest(stack, token, 'GET', '/auth/me/permissions'); + if (!Array.isArray(me.payload?.permissionSets) || !me.payload.permissionSets.includes(v.set)) { + notRun.push({ + ...base, + status: 'not-run', + reason: + `the grant names ${v.set}, and the server does not resolve it for the persona: boot the stack with ` + + '`parentWriteReachSecurity(config)`, which registers it (a grant naming an unregistered set confers nothing)', + }); + continue; + } + await admit(base, token); + } catch (e) { + notRun.push({ ...base, status: 'not-run', reason: clip((e as Error)?.message ?? e) }); + } + } + for (const position of declaredPositionNames(config)) { + const email = `verify-parent-write-pos-${position.toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}@objectstack.test`; + const base: ParentWriteReachPersona = { name: `position:${position}`, kind: 'position', label: email, status: 'run' }; + try { + const p = await provisionRlsPositionPersona(stack, position, { email }); + await admit(base, p.token); + } catch (e) { + notRun.push({ ...base, status: 'not-run', reason: clip((e as Error)?.message ?? e) }); + } + } + + // ── Fixtures: through the seed door, as system, owned as each probe needs ── + let counter = 0; + const bases = new Map(); + + async function seedRecord(object: string, overrides: Record, owner: string): Promise<{ id?: string; reason?: string }> { + const c = crud.get(object); + if (!c || c.blocked) return { reason: c?.blocked ?? `object ${object} is not derived` }; + const ids = new Map(); + for (const ref of c.relationalRefs ?? []) { + if (!ref.required || ref.field in overrides) continue; + const dep = await baseRecord(ref.target); + if (!dep.id) return { reason: `required relation ${ref.field} → ${ref.target}: ${dep.reason}` }; + ids.set(ref.target, dep.id); + } + const refs = (c.relationalRefs ?? []).filter((r) => !(r.field in overrides)); + const { body, missing } = fillRelationalRefs({ ...c, relationalRefs: refs }, ids); + if (missing) return { reason: missing }; + const row = { ...fixtureBody(body, objectDefs.get(object), ++counter), ...overrides, ...(hasOwnerColumn(object) ? { owner_id: owner } : {}) }; + try { + const [written] = await stack.seed(object, [row]); + return written?.id != null ? { id: String(written.id) } : { reason: `the seed of ${object} returned no id` }; + } catch (e) { + const err = e as { code?: unknown; message?: unknown }; + return { reason: `the seed of ${object} was refused: ${typeof err?.code === 'string' ? `${err.code} ` : ''}${clip(err?.message, 160)}` }; + } + } + + async function baseRecord(object: string): Promise<{ id?: string; reason?: string }> { + const known = bases.get(object); + if (known) return known; + bases.set(object, { reason: `the base ${object} record is being created` }); + const made = await seedRecord(object, {}, ownerId); + bases.set(object, made); + return made; + } + + /** The master chain, root first, every record owned by `owner`; returns the immediate master's id. */ + async function seedChain(c: ParentWriteReachCase, owner: string): Promise<{ id?: string; reason?: string }> { + let parent: string | undefined; + for (let i = c.hops.length - 1; i >= 0; i--) { + const object = c.hops[i]!.master; + const above = c.hops[i + 1]; + const made = await seedRecord(object, above ? { [above.field]: parent } : {}, owner); + if (!made.id) return { reason: `the master chain's ${object} record: ${made.reason}` }; + parent = made.id; + } + return { id: parent }; + } + + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]; + + // ── Probes ──────────────────────────────────────────────────────────────── + const results: ParentWriteReachObjectResult[] = []; + for (const c of cases) { + if (c.excluded) { + results.push({ object: c.object, hops: c.hops, status: 'excluded', reason: c.excluded, reaches: [] }); + continue; + } + if (c.notDerivable) { + results.push({ object: c.object, hops: c.hops, status: 'not-derivable', reason: c.notDerivable, reaches: [] }); + continue; + } + const { field, master } = c.hops[0]!; + const root = c.hops[c.hops.length - 1]!.master; + const detailOwnerColumn = hasOwnerColumn(c.object); + const reaches: ParentWriteReachReach[] = []; + const othersChain = await seedChain(c, ownerId); + + for (const p of personas) { + const entry = p.objects?.[c.object]; + const grants: Record = { + edit: objectPermissionGrants(entry, GRANT.edit), + delete: objectPermissionGrants(entry, GRANT.delete), + create: objectPermissionGrants(entry, GRANT.create), + }; + for (const masters of ['other', 'own'] as const) { + const reach: ParentWriteReachReach = { persona: p.name, masters, masterRead: 'unmeasured', probes: [] }; + reaches.push(reach); + if (masters === 'own' && !hasOwnerColumn(root)) { + reach.reason = `${root} has no owner column, so no principal owns a master chain of ${c.object}`; + reach.masterRead = 'unreadable'; + continue; + } + const chain = masters === 'other' ? othersChain : await seedChain(c, p.userId); + if (!chain.id) { + reach.reason = chain.reason; + continue; + } + const masterId = chain.id; + + // The measured half: can this persona read, then edit, the master? + const read = await rest(stack, p.token, 'GET', `/data/${master}/${masterId}`); + if (read.kind !== 'written') { + if (read.kind === 'refused' && (read.status === 404 || read.status === 403)) { + reach.masterRead = 'unreadable'; + reach.reason = + `GET ${master} answered ${read.status}: the master is outside the persona's read reach, so its details are ` + + "too (a write to a record the caller cannot read is the RLS family's subject)"; + } else { + reach.reason = `GET ${master} answered ${read.status ?? read.kind}: the master's read reach could not be measured`; + } + continue; + } + reach.masterRead = 'readable'; + const edit = await rest(stack, p.token, 'PATCH', `/data/${master}/${masterId}`, {}); + reach.masterAnswer = plain(edit); + reach.masterEdit = edit.kind === 'written' ? 'editable' : deniedFor(edit, master) ? 'not-editable' : 'unmeasured'; + reach.grants = grants; + if (reach.masterEdit === 'unmeasured') { + reach.reason = `the empty PATCH of ${master} answered ${edit.code ?? edit.status ?? edit.kind}, not a write gate's verdict: its edit reach could not be measured`; + continue; + } + + const expect = (verb: ParentWriteReachVerb) => { + const holds = grants[verb]; + const editable = reach.masterEdit === 'editable'; + return { + verb, + expected: holds && editable ? ('allowed' as const) : ('refused' as const), + why: + `the persona ${holds ? 'holds' : 'lacks'} ${GRANT[verb]} on ${c.object} and ` + + `${editable ? 'edited' : 'was refused an edit of'} its master ${master} (${edit.status})`, + }; + }; + + for (const detailOwner of detailOwnerColumn ? (['other', 'own'] as const) : (['other'] as const)) { + const d = await seedRecord(c.object, { [field]: masterId }, detailOwner === 'own' ? p.userId : ownerId); + if (!d.id) { + for (const verb of ['edit', 'delete'] as const) { + const e = expect(verb); + reach.probes.push({ verb, detailOwner, expected: e.expected, outcome: 'not-run', detail: `the detail record: ${d.reason}` }); + } + continue; + } + { + const e = expect('edit'); + const before = await row(c.object, d.id); + const answer = await rest(stack, p.token, 'PATCH', `/data/${c.object}/${d.id}`, {}); + const after = await row(c.object, d.id); + const stamp = before && 'updated_at' in before ? after?.updated_at !== before.updated_at : undefined; + reach.probes.push({ verb: 'edit', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, stamp) }); + } + { + const e = expect('delete'); + const answer = await rest(stack, p.token, 'DELETE', `/data/${c.object}/${d.id}`); + const gone = !(await row(c.object, d.id)); + reach.probes.push({ verb: 'delete', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, gone) }); + } + } + { + const e = expect('create'); + const cc = crud.get(c.object)!; + const ids = new Map(); + let reason: string | undefined; + for (const ref of cc.relationalRefs ?? []) { + if (!ref.required || ref.field === field) continue; + const dep = await baseRecord(ref.target); + if (!dep.id) { + reason = `required relation ${ref.field} → ${ref.target}: ${dep.reason}`; + break; + } + ids.set(ref.target, dep.id); + } + if (reason) { + reach.probes.push({ verb: 'create', expected: e.expected, outcome: 'not-run', detail: reason }); + } else { + const refs = (cc.relationalRefs ?? []).filter((r) => r.field !== field); + const { body } = fillRelationalRefs({ ...cc, relationalRefs: refs }, ids); + const answer = await rest(stack, p.token, 'POST', `/data/${c.object}`, { + ...fixtureBody(body, objectDefs.get(c.object), ++counter), + [field]: masterId, + }); + const made = answer.kind === 'written' && answer.id ? await row(c.object, answer.id) : undefined; + const landed = answer.kind === 'written' ? made !== undefined && String(made?.[field]) === masterId : undefined; + reach.probes.push({ verb: 'create', expected: e.expected, answer: plain(answer), ...judge(c, e, answer, landed) }); + } + } + } + } + results.push({ object: c.object, hops: c.hops, ...objectStatus(reaches), reaches }); + } + + const all = results.flatMap((r) => r.reaches.flatMap((x) => x.probes)); + const allReaches = results.flatMap((r) => r.reaches); + const count = (s: ParentWriteReachObjectStatus) => results.filter((r) => r.status === s).length; + const reported: ParentWriteReachPersona[] = [ + ...personas.map((p) => ({ + name: p.name, + kind: p.kind, + label: p.label, + status: p.status, + ...(p.permissionSets ? { permissionSets: p.permissionSets } : {}), + })), + ...notRun, + ]; + return { + app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', + door: 'rest', + personas: reported, + results, + summary: { + objects: results.length, + probed: results.filter((r) => r.status !== 'excluded' && r.status !== 'not-derivable').length, + proven: count('proven'), + contradicted: count('contradicted'), + failed: count('failed'), + inconclusive: count('inconclusive'), + excluded: count('excluded'), + notDerivable: count('not-derivable'), + personas: { run: personas.length, notRun: notRun.length }, + reaches: { + probed: allReaches.filter((r) => r.probes.length > 0).length, + unreadable: allReaches.filter((r) => r.masterRead === 'unreadable').length, + unmeasured: allReaches.filter((r) => r.masterRead === 'unmeasured' || r.masterEdit === 'unmeasured').length, + }, + probes: { + edit: all.filter((p) => p.verb === 'edit').length, + delete: all.filter((p) => p.verb === 'delete').length, + create: all.filter((p) => p.verb === 'create').length, + }, + held: all.filter((p) => p.outcome === 'held').length, + contradictions: all.filter((p) => p.outcome === 'contradicted').length, + unattributed: all.filter((p) => p.outcome === 'unattributed').length, + faults: all.filter((p) => p.outcome === 'failed').length, + notRun: all.filter((p) => p.outcome === 'not-run').length, + hardFailures: count('contradicted') + count('failed') + notRun.length, + }, + }; +} + +/** Pretty one-line-per-object summary for logs. */ +export function formatParentWriteReachReport(report: ParentWriteReachReport): string { + const lines: string[] = [`\n=== objectstack verify — parent-derived write reach — ${report.app} ===`]; + lines.push(' door: REST (POST / PATCH / DELETE /data/:object); the system door bypasses authorization and is not asked'); + const ran = report.personas.filter((p) => p.status === 'run'); + lines.push(` personas: ${ran.map((p) => p.name).join(', ') || 'none'}`); + for (const p of report.personas.filter((x) => x.status === 'not-run')) { + lines.push(` ⛔ persona ${p.name} NOT RUN — ${p.reason ?? ''}`); + } + for (const r of report.results) { + const chain = r.hops.length > 0 ? ` (${r.hops.map((h) => `${h.object}.${h.field} → ${h.master}`).join(', ')})` : ''; + const probes = r.reaches.flatMap((x) => x.probes.map((p) => ({ ...p, persona: x.persona, masters: x.masters }))); + if (r.status === 'proven') { + lines.push(` ✓ ${r.object}${chain} held on ${probes.length} write(s)`); + } else if (r.status === 'contradicted' || r.status === 'failed') { + const bad = probes.filter((p) => p.outcome === (r.status === 'contradicted' ? 'contradicted' : 'failed')); + lines.push(` ✗ ${r.object}${chain} ${r.status === 'contradicted' ? 'CONTRADICTED' : 'FAULTED'} on ${bad.length} of ${probes.length} write(s):`); + for (const p of bad.slice(0, 12)) { + lines.push( + ` ${p.persona}, masters ${p.masters === 'own' ? 'its own' : "another's"}, ${p.verb}` + + `${p.detailOwner ? ` (detail ${p.detailOwner === 'own' ? 'its own' : "another's"})` : ''}: ${clip(p.detail, 220)}`, + ); + } + if (bad.length > 12) lines.push(` … and ${bad.length - 12} more`); + } else if (r.status === 'inconclusive') { + lines.push(` ~ ${r.object}${chain} inconclusive: ${r.reason ?? ''}`); + } else { + lines.push(` – ${r.object}${chain} ${r.status}: ${r.reason ?? ''}`); + } + } + const s = report.summary; + lines.push( + ` ── ${s.proven} proven, ${s.contradicted} CONTRADICTED, ${s.failed} FAILED, ${s.inconclusive} inconclusive, ` + + `${s.excluded} excluded, ${s.notDerivable} not derivable ` + + `(${s.personas.run} persona(s) run, ${s.personas.notRun} not run; ${s.reaches.probed} reach(es) probed, ` + + `${s.reaches.unreadable} outside read reach, ${s.reaches.unmeasured} unmeasured; ` + + `writes: ${s.probes.edit} edit, ${s.probes.delete} delete, ${s.probes.create} create — ${s.held} held, ` + + `${s.contradictions} contradicted, ${s.unattributed} unattributed, ${s.faults} faulted, ${s.notRun} not run)`, + ); + return lines.join('\n'); +} From 3a48179ed2c8f58c3b684abe16bd156e881a04fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:42:27 +0000 Subject: [PATCH 06/16] feat(cli): os verify --parent-write-reach, and the family's tests (#15953 slice 4, WIP) The opt-in flag boots a fresh stack with parentWriteReachSecurity(config), runs runParentWriteReachProofs, adds its hardFailures to the run's and emits the report as parentWriteReach under --json. Without the flag nothing moves. The family's tests: the derivation (population through the spec's parse, the chain, and every exclusion with its reason), the fixture app run (single level proven for every persona; the two-level chain contradicted on today's engine), the negative control (a detail hook refusing with the platform code where the master is editable), the attribution control (an app's own code is unattributed) and a run on a stack without the verifier sets. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/cli/src/commands/verify.ts | 38 +- .../test/verify-parent-write-reach.test.ts | 192 +++++++++ .../verify/src/parent-write-reach.test.ts | 402 ++++++++++++++++++ 3 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 packages/cli/test/verify-parent-write-reach.test.ts create mode 100644 packages/verify/src/parent-write-reach.test.ts diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 95ab269a525..1fc56df27b1 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -21,11 +21,15 @@ import { formatDeleteBehaviorReport, runUniqueScopeProofs, formatUniqueScopeReport, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, type VerifyReport, type RlsReport, type ReferenceShapeReport, type DeleteBehaviorReport, type UniqueScopeReport, + type ParentWriteReachReport, type RlsProbeDescriptor, type RlsPositionPersonaInput, } from '@objectstack/verify'; @@ -99,12 +103,17 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b * the REST door and the system door, and — on an org-scoped boot * (--multi-tenant) — admits or refuses one in another organization * as its declared scope says + * - parent-derived write reach (--parent-write-reach, ADR-0055 §3): + * for every `controlled_by_parent` object, a persona's edit, delete + * or create of a detail through the REST door is admitted exactly + * when it holds that verb on the detail's object and can edit the + * master * * Exits non-zero on either stage's failures so it drops straight into CI. */ export default class Verify extends Command { static override description = - 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant, the reference-value shape proofs, the declared delete-behaviour proofs and the declared unique-scope proofs on request)'; + 'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity, plus the cross-owner RLS invariant, the reference-value shape proofs, the declared delete-behaviour proofs, the declared unique-scope proofs and the parent-derived write reach proofs on request)'; static override examples = [ '<%= config.bin %> verify', @@ -113,6 +122,7 @@ export default class Verify extends Command { '<%= config.bin %> verify --reference-shapes', '<%= config.bin %> verify --delete-behaviors', '<%= config.bin %> verify --unique-scopes --multi-tenant', + '<%= config.bin %> verify --parent-write-reach', ]; static override flags = { @@ -136,6 +146,10 @@ export default class Verify extends Command { description: "Also prove every field declared unique collides as its scope says (true / 'organization': within one organization only; 'global': across the installation), through the REST door and the system door; the cross-organization half needs --multi-tenant and is reported not run without it (ADR-0120 unique scopes)", default: false, }), + 'parent-write-reach': Flags.boolean({ + description: "Also prove, for every controlled_by_parent object, that a persona's edit, delete or create of a detail through the REST door is admitted exactly when it holds that verb on the detail's object and can edit the master, as verifier personas and one persona per declared position (ADR-0055 master-detail write reach)", + default: false, + }), 'multi-tenant': Flags.boolean({ description: 'Boot org-scoped (register the @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors a walled $OS_TENANCY_POSTURE, and the legacy $OS_MULTI_ORG_ENABLED it falls back to)', default: false, @@ -216,6 +230,7 @@ export default class Verify extends Command { 'reference-shapes': boolean; 'delete-behaviors': boolean; 'unique-scopes': boolean; + 'parent-write-reach': boolean; 'multi-tenant': boolean; json: boolean; }): Promise { @@ -359,6 +374,19 @@ export default class Verify extends Command { } } + // The parent-derived write reach proofs run on a fresh stack of their own, + // booted with the verifier personas' permission sets registered: they + // sign up personas, and they edit, delete and create details on purpose. + let parentWriteReach: ParentWriteReachReport | undefined; + if (flags['parent-write-reach']) { + const parentStack = await bootStack(config, { multiTenant, hostRoot, security: parentWriteReachSecurity(config) }); + try { + parentWriteReach = await runParentWriteReachProofs(parentStack, config); + } finally { + await parentStack.stop(); + } + } + // Failure contract: a "real" runtime break the app's author must see. // A degraded RLS probe counts: the run reported verdicts it could not have // established, which is worse than no verifier at all. @@ -387,19 +415,23 @@ export default class Verify extends Command { // A second holder a declared unique scope met wrongly, or a door that // faulted on one. A leg this boot cannot run is reported on the report's // `boot`, not counted. - (uniqueScopes?.summary.hardFailures ?? 0); + (uniqueScopes?.summary.hardFailures ?? 0) + + // A detail write whose answer disagreed with the conjunction, a door + // that faulted on one, or a persona that could not be provisioned. + (parentWriteReach?.summary.hardFailures ?? 0); if (flags.json) { // `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the // report has to leave through the real stream — and awaiting the write // drains a report larger than one pipe buffer before `this.exit` below. - await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, hardFailures }); + await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, parentWriteReach, hardFailures }); } else { this.log(formatReport(crud)); if (rls) this.log(formatRlsReport(rls)); if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes)); if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors)); if (uniqueScopes) this.log(formatUniqueScopeReport(uniqueScopes)); + if (parentWriteReach) this.log(formatParentWriteReachReport(parentWriteReach)); this.log(''); this.log( hardFailures > 0 diff --git a/packages/cli/test/verify-parent-write-reach.test.ts b/packages/cli/test/verify-parent-write-reach.test.ts new file mode 100644 index 00000000000..f71d34f8119 --- /dev/null +++ b/packages/cli/test/verify-parent-write-reach.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — `os verify --parent-write-reach` runs the parent-derived write reach + * family and counts what it finds in the exit status. + * + * ## What is pinned + * + * - without the flag the run is what it was: no `parentWriteReach` key in + * the `--json` document, and the exit status reads CRUD only; + * - with the flag, the stack is booted with the verifier personas' sets + * registered, so all three verifier personas run beside the one declared + * position; the `controlled_by_parent` contract is `proven`, its manager + * edits another principal's contract through the account and is refused + * the delete it lacks, `hardFailures` stays 0 and the run exits 0; + * - with the flag, on the same metadata plus a hook that refuses every edit + * of the contract with the platform's own `403 PERMISSION_DENIED`, the + * detail's answer disagrees with its master's: the contract is reported + * `contradicted`, `hardFailures` counts it and the run exits 1. + * + * Spawned rather than run in-process: the exit status is the contract a CI + * gate reads, and only a real process has one. + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { defineStackSource, defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; + +const MANIFEST = { + id: 'com.example.verify-parent-write-reach', + namespace: 'vpw', + version: '1.0.0', + name: 'Verify Parent Write Reach', + type: 'app', + engines: { protocol: '^18' }, +}; + +const OBJECTS = [ + { + name: 'vpw_account', + label: 'Account', + pluralLabel: 'Accounts', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name', required: true } }, + }, + { + name: 'vpw_contract', + label: 'Contract', + pluralLabel: 'Contracts', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', label: 'Account', reference: 'vpw_account', required: true }, + title: { type: 'text', label: 'Title' }, + }, + }, +]; + +const PERMISSIONS = [ + { + name: 'vpw_manager', + label: 'Manager', + objects: { + vpw_account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + vpw_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + }, + }, +]; + +const POSITIONS = [{ name: 'vpw_manager', label: 'Manager' }]; + +/** The same app, plus a hook that refuses every edit of a contract with the platform's own code. */ +const CONTROL_SOURCE = `{ + manifest: ${JSON.stringify({ ...MANIFEST, id: 'com.example.verify-parent-write-reach-control' })}, + objects: ${JSON.stringify(OBJECTS)}, + permissions: ${JSON.stringify(PERMISSIONS)}, + positions: ${JSON.stringify(POSITIONS)}, + hooks: [ + { + name: 'vpw_contract_refuses_every_edit', + object: 'vpw_contract', + events: ['beforeUpdate'], + handler: async () => { + throw Object.assign(new Error('refused by the app'), { code: 'PERMISSION_DENIED', status: 403 }); + }, + }, + ], +}`; + +interface Run { + status: number | null; + stdout: string; + stderr: string; +} + +interface Doc { + hardFailures?: number; + parentWriteReach?: { + door: string; + personas: Array<{ name: string; kind: string; status: string }>; + results: Array<{ + object: string; + status: string; + reaches: Array<{ persona: string; masters: string; masterEdit?: string; probes: Array<{ verb: string; detailOwner?: string; expected: string; outcome: string }> }>; + }>; + summary: { proven: number; contradicted: number; failed: number; hardFailures: number; personas: { run: number; notRun: number } }; + }; +} + +function runCli(dir: string, args: string[]): Run { + // Through tsx, so the child runs this checkout's `src/`. + const r = spawnSync(TSX, [CLI, ...args], { + cwd: dir, + encoding: 'utf8', + env: childEnv({ NO_COLOR: '1' }), + maxBuffer: 64 * 1024 * 1024, + }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +const docOf = (run: Run): Doc => JSON.parse(run.stdout) as Doc; + +const shape = (doc: Doc, persona: string, masters: string) => + doc.parentWriteReach!.results[0]!.reaches + .find((r) => r.persona === persona && r.masters === masters)! + .probes.map((p) => `${p.verb}${p.detailOwner ? `/${p.detailOwner}` : ''} ${p.expected} ${p.outcome}`); + +describe('os verify --parent-write-reach', () => { + let root: string; + let plain: Run; + let proven: Run; + let control: Run; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-verify-parent-write-reach-')); + const app = join(root, 'app'); + const controlApp = join(root, 'control'); + for (const [dir, source] of [ + [app, defineStackSource({ manifest: MANIFEST, objects: OBJECTS, permissions: PERMISSIONS, positions: POSITIONS })], + [controlApp, defineStackSourceFromLiteral(CONTROL_SOURCE)], + ] as const) { + mkdirSync(dir); + writeFileSync(join(dir, 'objectstack.config.mjs'), source); + linkSpec(dir); + } + plain = runCli(app, ['verify', '--json']); + proven = runCli(app, ['verify', '--parent-write-reach', '--json']); + control = runCli(controlApp, ['verify', '--parent-write-reach', '--json']); + }, 480_000); + + afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); + }); + + it('leaves the run unchanged without the flag', () => { + expect(plain.status, `${plain.stdout}\n${plain.stderr}`).toBe(0); + const doc = docOf(plain); + expect('parentWriteReach' in doc).toBe(false); + expect(doc.hardFailures).toBe(0); + }); + + it("proves the contract for every persona, the manager's reach through the account included, and exits 0", () => { + expect(proven.status, `${proven.stdout}\n${proven.stderr}`).toBe(0); + const doc = docOf(proven); + const report = doc.parentWriteReach!; + expect(report.door).toBe('rest'); + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + 'master-all-records run', 'detail-only run', 'master-all-records-no-delete run', 'position:vpw_manager run', + ]); + expect(report.results).toEqual([expect.objectContaining({ object: 'vpw_contract', status: 'proven' })]); + // Another principal's account, edited by the all-records grant: the contract follows, never its delete. + expect(shape(doc, 'position:vpw_manager', 'other')).toEqual([ + 'edit/other allowed held', 'delete/other refused held', 'edit/own allowed held', 'delete/own refused held', 'create allowed held', + ]); + expect(report.summary).toMatchObject({ proven: 1, contradicted: 0, failed: 0, hardFailures: 0, personas: { run: 4, notRun: 0 } }); + expect(doc.hardFailures).toBe(0); + }); + + it("counts a detail whose answer disagrees with its master's, and exits 1", () => { + expect(control.status, `${control.stdout}\n${control.stderr}`).toBe(1); + const doc = docOf(control); + expect(doc.parentWriteReach!.results).toEqual([expect.objectContaining({ object: 'vpw_contract', status: 'contradicted' })]); + expect(shape(doc, 'master-all-records', 'other')).toEqual([ + 'edit/other allowed contradicted', 'delete/other allowed held', 'edit/own allowed contradicted', 'delete/own allowed held', 'create allowed held', + ]); + expect(doc.parentWriteReach!.summary).toMatchObject({ contradicted: 1, hardFailures: 1 }); + expect(doc.hardFailures).toBe(1); + }); +}); diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts new file mode 100644 index 00000000000..ec207a4c24f --- /dev/null +++ b/packages/verify/src/parent-write-reach.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The parent-derived write reach family — what it reads from the metadata, +// what it proves on a real stack, and the controls that keep its green honest: +// +// • the NEGATIVE control: an app hook on a detail that refuses with the +// platform's own `403 PERMISSION_DENIED` where the master is editable, so +// the detail's answer disagrees with its master's — found without any +// engine change, and reported contradicted; +// • the ATTRIBUTION control: a refusal that is not the platform's +// authorization refusal (an app's own code) never reads as a held refusal; +// • a run on a stack booted without `parentWriteReachSecurity` reports the +// verifier personas not run, with the reason, and counts them. +// +// The REST envelope names the route's object on every refusal and carries no +// operation, so attribution there is `403 PERMISSION_DENIED` at a route whose +// one operation is the request's own. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { + deriveParentWriteReachCases, + runParentWriteReachProofs, + formatParentWriteReachReport, + parentWriteReachSecurity, + type ParentWriteReachReport, +} from './parent-write-reach.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; +const RUN_TIMEOUT = 180_000; + +const manager = { + name: 'pwr_manager', + label: 'Manager', + objects: { + // Every account by the all-records grant, owning none of them. + pwr_account: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + // Edit and create a contract, never delete one. + pwr_contract: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }, + pwr_quote: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + pwr_quote_line: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + }, +}; +const rep = { + name: 'pwr_rep', + label: 'Rep', + objects: { + // Its own accounts only, and a contract it reads but never edits. + pwr_account: { allowCreate: true, allowRead: true, allowEdit: true, readScope: 'own' }, + pwr_contract: { allowRead: true }, + pwr_quote: { allowCreate: true, allowRead: true, allowEdit: true }, + pwr_quote_line: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + }, +}; + +const source = { + manifest: { id: 'com.example.parent-write-reach', namespace: 'pwr', version: '0.0.1', type: 'app', name: 'Parent Write Reach Fixture' }, + objects: [ + { name: 'pwr_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { name: 'pwr_person', label: 'Person', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwr_contract', + label: 'Contract', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', label: 'Account', reference: 'pwr_account' }, + // A second required reference, to an object the personas hold no grant on. + signer: { type: 'lookup', label: 'Signer', reference: 'pwr_person', required: true }, + title: { type: 'text', label: 'Title' }, + }, + }, + { + name: 'pwr_quote', + label: 'Quote', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwr_account' }, title: { type: 'text', label: 'Title' } }, + }, + { + // Two levels: a line's master is a quote, whose own master is an account. + name: 'pwr_quote_line', + label: 'Quote line', + sharingModel: 'controlled_by_parent', + fields: { quote: { type: 'master_detail', label: 'Quote', reference: 'pwr_quote' }, title: { type: 'text', label: 'Title' } }, + }, + ], + permissions: [manager, rep], + positions: [ + { name: 'pwr_manager', label: 'Manager' }, + { name: 'pwr_rep', label: 'Rep' }, + ], +}; + +const VERIFIERS = ['master-all-records', 'detail-only', 'master-all-records-no-delete'] as const; + +const resultFor = (report: ParentWriteReachReport, object: string) => { + const r = report.results.find((x) => x.object === object); + if (!r) throw new Error(`no result for ${object}`); + return r; +}; + +const reachOf = (report: ParentWriteReachReport, object: string, persona: string, masters: 'other' | 'own') => { + const r = resultFor(report, object).reaches.find((x) => x.persona === persona && x.masters === masters); + if (!r) throw new Error(`no reach for ${object} ${persona} ${masters}`); + return r; +}; + +/** `verb[/detail owner] expected outcome`, per probe of one reach. */ +const shape = (report: ParentWriteReachReport, object: string, persona: string, masters: 'other' | 'own') => + reachOf(report, object, persona, masters).probes.map((p) => `${p.verb}${p.detailOwner ? `/${p.detailOwner}` : ''} ${p.expected} ${p.outcome}`); + +const ALL_ALLOWED = [ + 'edit/other allowed held', 'delete/other allowed held', 'edit/own allowed held', 'delete/own allowed held', 'create allowed held', +]; +const ALL_REFUSED = [ + 'edit/other refused held', 'delete/other refused held', 'edit/own refused held', 'delete/own refused held', 'create refused held', +]; +const NO_DELETE = [ + 'edit/other allowed held', 'delete/other refused held', 'edit/own allowed held', 'delete/own refused held', 'create allowed held', +]; + +describe('deriveParentWriteReachCases — what the family reads from the metadata', () => { + it("reads controlled_by_parent and the master through the spec's parse, walks the chain, and names what it cannot compare", () => { + // Ten controlled_by_parent objects in a row above an account: eight + // controlled_by_parent masters above `pwr_deep_0` and `pwr_deep_1` reach + // the write gate's depth bound; `pwr_deep_2` has seven. + const deep = Array.from({ length: 10 }, (_, i) => ({ + name: `pwr_deep_${i}`, + sharingModel: 'controlled_by_parent', + fields: { up: { type: 'master_detail', reference: i === 9 ? 'pwr_account' : `pwr_deep_${i + 1}` } }, + })); + const config = { + objects: [ + ...source.objects, + { name: 'pwr_public', sharingModel: 'public_read_write', fields: { account: { type: 'master_detail', reference: 'pwr_account' } } }, + { + name: 'pwr_two_masters', + sharingModel: 'controlled_by_parent', + fields: { + account: { type: 'master_detail', reference: 'pwr_account' }, + person: { type: 'master_detail', reference: 'pwr_person' }, + }, + }, + { name: 'pwr_no_master', sharingModel: 'controlled_by_parent', fields: { account: { type: 'lookup', reference: 'pwr_account', required: true } } }, + { name: 'pwr_outside', sharingModel: 'controlled_by_parent', fields: { owner: { type: 'master_detail', reference: 'crm_elsewhere' } } }, + { name: 'pwr_loop_a', sharingModel: 'controlled_by_parent', fields: { b: { type: 'master_detail', reference: 'pwr_loop_b' } } }, + { name: 'pwr_loop_b', sharingModel: 'controlled_by_parent', fields: { a: { type: 'master_detail', reference: 'pwr_loop_a' } } }, + { name: 'pwr_badge', label: 'Badge', fields: { photo: { type: 'image', required: true } } }, + { name: 'pwr_badge_line', sharingModel: 'controlled_by_parent', fields: { badge: { type: 'master_detail', reference: 'pwr_badge' } } }, + { name: 'pwr_bad', sharingModel: 'controlled_by_parent', fields: { account: { type: 'master_detail', reference: 'pwr_account', bogus: true } } }, + ...deep, + ], + }; + const byObject = new Map(deriveParentWriteReachCases(config).map((c) => [c.object, c])); + + // Every object the parse declares controlled_by_parent, and nothing else. + expect([...byObject.keys()].sort()).toEqual([ + 'pwr_bad', 'pwr_badge_line', 'pwr_contract', + ...deep.map((d) => d.name).sort(), + 'pwr_loop_a', 'pwr_loop_b', 'pwr_no_master', 'pwr_outside', 'pwr_quote', 'pwr_quote_line', 'pwr_two_masters', + ].sort()); + expect(byObject.get('pwr_contract')).toEqual({ object: 'pwr_contract', hops: [{ object: 'pwr_contract', field: 'account', master: 'pwr_account' }] }); + expect(byObject.get('pwr_quote_line')).toEqual({ + object: 'pwr_quote_line', + hops: [ + { object: 'pwr_quote_line', field: 'quote', master: 'pwr_quote' }, + { object: 'pwr_quote', field: 'account', master: 'pwr_account' }, + ], + }); + // Named instead of probed. + expect(byObject.get('pwr_two_masters')!.excluded).toMatch(/^pwr_two_masters declares 2 master_detail fields \(account, person\)/); + expect(byObject.get('pwr_no_master')!.excluded).toMatch(/^pwr_no_master declares no master_detail field/); + expect(byObject.get('pwr_outside')!.excluded).toMatch(/^the master crm_elsewhere is not an object this app declares/); + expect(byObject.get('pwr_loop_a')!.excluded).toMatch(/^the master chain re-enters pwr_loop_a \(a cycle\)/); + expect(byObject.get('pwr_deep_0')!.excluded).toMatch(/^the master chain reaches 8 controlled_by_parent masters, the write gate's depth bound/); + expect(byObject.get('pwr_deep_1')!.excluded).toMatch(/^the master chain reaches 8 controlled_by_parent masters/); + expect(byObject.get('pwr_deep_2')!.excluded).toBeUndefined(); + expect(byObject.get('pwr_deep_2')!.hops).toHaveLength(8); + expect(byObject.get('pwr_deep_2')!.hops.at(-1)).toEqual({ object: 'pwr_deep_9', field: 'up', master: 'pwr_account' }); + expect(byObject.get('pwr_badge_line')!.notDerivable).toMatch(/^the master pwr_badge cannot be synthesized: required image field "photo"/); + expect(byObject.get('pwr_bad')!.excluded).toMatch(/^the object does not parse under the spec's ObjectSchema/); + }); +}); + +describe('runParentWriteReachProofs — the fixture app, booted with parentWriteReachSecurity', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + const app = defineStack(source as never) as unknown as typeof source; + + beforeAll(async () => { + stack = await bootStack(app as never, { security: parentWriteReachSecurity(app) }); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('runs every persona: the three verifier personas and one per declared position', () => { + expect(report.door).toBe('rest'); + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + ...VERIFIERS.map((v) => `${v} run`), 'position:pwr_manager run', 'position:pwr_rep run', + ]); + // The server's own resolution of the sets each holds. + expect(report.personas.find((p) => p.name === 'position:pwr_manager')!.permissionSets).toContain('pwr_manager'); + expect(report.personas.find((p) => p.name === 'master-all-records')!.permissionSets).toContain('verify_parent_write_master_all'); + }); + + it('proves every single-level detail, for every persona, under a master chain another principal owns and one it owns', () => { + for (const object of ['pwr_contract', 'pwr_quote']) { + expect(resultFor(report, object).status, formatParentWriteReachReport(report)).toBe('proven'); + for (const masters of ['other', 'own'] as const) { + expect(shape(report, object, 'master-all-records', masters)).toEqual(ALL_ALLOWED); + // The detail's own verbs, and the master readable but not editable: refused by the master. + expect(shape(report, object, 'detail-only', masters)).toEqual(ALL_REFUSED); + expect(reachOf(report, object, 'detail-only', masters).masterEdit).toBe('not-editable'); + // The master's all-records grant widens which rows, never which verbs. + expect(shape(report, object, 'master-all-records-no-delete', masters)).toEqual(NO_DELETE); + } + } + }); + + it("reads hotcrm's contract shape: the manager writes another's contract through the account, never deletes it; the rep's object gate stands", () => { + // The account is another principal's; the manager edits it by the all-records grant alone. + const other = reachOf(report, 'pwr_contract', 'position:pwr_manager', 'other'); + expect(other).toMatchObject({ masterRead: 'readable', masterEdit: 'editable', grants: { edit: true, delete: false, create: true } }); + expect(shape(report, 'pwr_contract', 'position:pwr_manager', 'other')).toEqual(NO_DELETE); + // A refusal is the platform's authorization refusal for the detail. + expect(other.probes.find((p) => p.verb === 'delete')!.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_contract' }); + // The rep cannot read another's account, so that chain is outside its reach, and named. + expect(reachOf(report, 'pwr_contract', 'position:pwr_rep', 'other')).toMatchObject({ masterRead: 'unreadable', probes: [] }); + // On its own account the rep edits the master and is still refused the contract: the object gate. + expect(reachOf(report, 'pwr_contract', 'position:pwr_rep', 'own')).toMatchObject({ masterEdit: 'editable', grants: { edit: false, delete: false, create: false } }); + expect(shape(report, 'pwr_contract', 'position:pwr_rep', 'own')).toEqual(ALL_REFUSED); + expect(shape(report, 'pwr_quote', 'position:pwr_rep', 'own')).toEqual(NO_DELETE); + }); + + it("finds today's engine refusing a two-level detail whose master the persona edits", () => { + // ADR-0055's amendment: the write gate walks the chain hop by hop, and a + // detail whose whole chain is reachable stays writable. Measured on this + // engine, a quote line is refused for every persona, its own chain + // included, while the same persona's empty PATCH of the quote is admitted. + // This flips to `proven` the day the chain walk agrees with the quote's own write. + const r = resultFor(report, 'pwr_quote_line'); + expect(r.status).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + const reach = reachOf(report, 'pwr_quote_line', 'master-all-records', masters); + expect(reach.masterEdit).toBe('editable'); + expect(reach.probes.map((p) => p.outcome)).toEqual(['contradicted', 'contradicted', 'contradicted', 'contradicted', 'contradicted']); + for (const p of reach.probes) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_quote_line' }); + expect(p.detail).toMatch(/^the persona holds allow(Edit|Delete|Create) on pwr_quote_line and edited its master pwr_quote \(200\), and the (edit|delete|create) was refused/); + } + // Where the conjunction refuses, the refusal still holds. + expect(shape(report, 'pwr_quote_line', 'detail-only', masters)).toEqual(ALL_REFUSED); + } + expect(formatParentWriteReachReport(report)).toContain('✗ pwr_quote_line (pwr_quote_line.quote → pwr_quote, pwr_quote.account → pwr_account) CONTRADICTED'); + }); + + it('counts every reach and write, and the contradicted chain is its one hard failure', () => { + expect(report.summary).toEqual({ + objects: 3, + probed: 3, + proven: 2, + contradicted: 1, + failed: 0, + inconclusive: 0, + excluded: 0, + notDerivable: 0, + personas: { run: 5, notRun: 0 }, + // 3 objects × 5 personas × 2 master chains, the rep's three chains another principal owns outside its reach. + reaches: { probed: 27, unreadable: 3, unmeasured: 0 }, + probes: { edit: 54, delete: 54, create: 27 }, + held: 104, + contradictions: 31, + unattributed: 0, + faults: 0, + notRun: 0, + hardFailures: 1, + }); + }); +}); + +/** + * The negative and attribution controls. `pwr_signed`'s hooks refuse every + * write with the platform's own `403 PERMISSION_DENIED`, whoever writes and + * whatever the master says; `pwr_locked`'s refuse with the app's own code. + */ +const deny = (code: string, status: number) => async () => { + throw Object.assign(new Error(`refused by the app (${code})`), { code, status }); +}; +const controlSource = { + manifest: { id: 'com.example.parent-write-reach-controls', namespace: 'pwc', version: '0.0.1', type: 'app', name: 'Parent Write Reach Controls' }, + objects: [ + { name: 'pwc_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwc_signed', + label: 'Signed', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwc_account' }, title: { type: 'text', label: 'Title' } }, + }, + { + name: 'pwc_locked', + label: 'Locked', + sharingModel: 'controlled_by_parent', + fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwc_account' }, title: { type: 'text', label: 'Title' } }, + }, + ], + hooks: [ + // Update and delete only: the fixtures are seeded through a door that + // runs hooks, so an insert refusal would refuse the fixtures too. + { + name: 'pwc_signed_refuses', + object: 'pwc_signed', + events: ['beforeUpdate', 'beforeDelete'], + handler: deny('PERMISSION_DENIED', 403), + }, + { + name: 'pwc_locked_refuses', + object: 'pwc_locked', + events: ['beforeUpdate', 'beforeDelete'], + handler: deny('RECORD_LOCKED', 409), + }, + ], +}; + +describe('runParentWriteReachProofs — NEGATIVE and ATTRIBUTION controls', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + const app = defineStack(controlSource as never) as unknown as typeof controlSource; + + beforeAll(async () => { + stack = await bootStack(app as never, { security: parentWriteReachSecurity(app) }); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('NEGATIVE CONTROL: a detail refusing with the platform code where its master is editable is reported contradicted', () => { + const r = resultFor(report, 'pwc_signed'); + expect(r.status, formatParentWriteReachReport(report)).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + expect(shape(report, 'pwc_signed', 'master-all-records', masters)).toEqual([ + 'edit/other allowed contradicted', 'delete/other allowed contradicted', + 'edit/own allowed contradicted', 'delete/own allowed contradicted', + 'create allowed held', + ]); + // Where the conjunction refuses too, the same refusal holds. + expect(shape(report, 'pwc_signed', 'detail-only', masters)).toEqual(ALL_REFUSED); + } + expect(report.summary.hardFailures).toBe(1); + }); + + it("ATTRIBUTION CONTROL: an app's own refusal is never read as the platform's, held or contradicted", () => { + const r = resultFor(report, 'pwc_locked'); + expect(r.status, formatParentWriteReachReport(report)).toBe('inconclusive'); + for (const masters of ['other', 'own'] as const) { + // Expected allowed, and refused by the app's own code: unattributed. + expect(shape(report, 'pwc_locked', 'master-all-records', masters)).toEqual([ + 'edit/other allowed unattributed', 'delete/other allowed unattributed', + 'edit/own allowed unattributed', 'delete/own allowed unattributed', + 'create allowed held', + ]); + for (const p of reachOf(report, 'pwc_locked', 'master-all-records', masters).probes.filter((x) => x.verb !== 'create')) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 409, code: 'RECORD_LOCKED' }); + expect(p.detail).toMatch(/^refused for another reason: RECORD_LOCKED \(409\)/); + } + } + expect(r.reaches.flatMap((x) => x.probes).some((p) => p.outcome === 'contradicted')).toBe(false); + }); +}); + +describe('runParentWriteReachProofs — a stack booted without parentWriteReachSecurity', () => { + let stack: VerifyStack; + let report: ParentWriteReachReport; + const app = defineStack({ ...source, objects: source.objects.filter((o) => o.name !== 'pwr_quote_line') } as never) as unknown as typeof source; + + beforeAll(async () => { + stack = await bootStack(app as never); + report = await runParentWriteReachProofs(stack, app); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('reports the verifier personas not run, with the reason, and counts them; the position personas still run', () => { + expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ + 'position:pwr_manager run', 'position:pwr_rep run', ...VERIFIERS.map((v) => `${v} not-run`), + ]); + for (const p of report.personas.filter((x) => x.kind === 'verifier')) { + expect(p.reason).toMatch(/the server does not resolve it for the persona: boot the stack with `parentWriteReachSecurity\(config\)`/); + } + expect(report.summary).toMatchObject({ proven: 2, contradicted: 0, personas: { run: 2, notRun: 3 }, hardFailures: 3 }); + expect(formatParentWriteReachReport(report)).toContain('⛔ persona master-all-records NOT RUN'); + }); +}); From 06ed55ddbef52a40c9bff0a0df49db061e2deca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:47:26 +0000 Subject: [PATCH 07/16] feat(verify): write through the engine as the persona, so a refusal keeps what it refused; docs and changeset (#15953 slice 4, WIP) Measured on hotcrm's artifact: the REST envelope names the route's object on every refusal and carries no operation, so a hook's nested refusal on another object (crm_opportunity_line_item's afterInsert updating crm_opportunity) read there as the detail's own and was reported as a contradiction. The family now writes through the engine as the persona (hooks.run with as: token, the three calls the REST data ingress makes) and attributes a refusal only as PERMISSION_DENIED 403 whose details name the detail and the operation. The attribution control gains a refusal naming another object. README, the os verify section of cli.mdx and the new changeset document the family, the flag, the door and today's two-level chain finding; cli.mdx's unique-scope paragraph takes the same two corrections as its changeset. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../15953-verify-parent-write-reach-family.md | 38 ++++ content/docs/deployment/cli.mdx | 41 +++- packages/cli/src/commands/verify.ts | 8 +- .../test/verify-parent-write-reach.test.ts | 11 +- packages/verify/README.md | 74 ++++++- .../verify/src/parent-write-reach.test.ts | 59 ++++-- packages/verify/src/parent-write-reach.ts | 191 ++++++++++-------- 7 files changed, 308 insertions(+), 114 deletions(-) create mode 100644 .changeset/15953-verify-parent-write-reach-family.md diff --git a/.changeset/15953-verify-parent-write-reach-family.md b/.changeset/15953-verify-parent-write-reach-family.md new file mode 100644 index 00000000000..3e3abcdc1c0 --- /dev/null +++ b/.changeset/15953-verify-parent-write-reach-family.md @@ -0,0 +1,38 @@ +--- +'@objectstack/verify': minor +'@objectstack/cli': minor +--- + +feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + +Clause-②: yes + +A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + +The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + +- the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; +- the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + +The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + +Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and the run fails. + +**`@objectstack/verify` — new exports.** + +- `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. +- `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. +- `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. +- Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + +```ts +import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + +const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); +const report = await runParentWriteReachProofs(stack, myApp); +console.log(formatParentWriteReachReport(report)); // report.personas names who wrote +expect(report.summary.hardFailures).toBe(0); +await stack.stop(); +``` + +**`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 3d97153b4cc..f83c26a079d 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1925,6 +1925,7 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep os verify --reference-shapes # Also prove reference fields refuse non-id values os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does os verify --unique-scopes --multi-tenant # Also prove each declared unique collides where its scope says +os verify --parent-write-reach # Also prove each controlled_by_parent detail write follows its master ``` 1. **Author-time rules.** The rule registry `os validate` runs, over the stack @@ -1972,9 +1973,10 @@ os verify --unique-scopes --multi-tenant # Also prove each declared unique and a second one is offered it, by insert and by update, through the REST door and the system door. Within one organization the second holder must be refused with the platform's unique refusal, attributed to the field (it - names the field, or it names none and the same record with only that value - changed is admitted). With `--multi-tenant` the run also provisions a second - organization (a sign-up that creates its own) and offers it the value: a + names the field, or it names none, the same record with only that value + changed is admitted, and no unique `indexes[]` entry covers the field). With + `--multi-tenant` the run also provisions a second organization (`signUp`, + then `POST /auth/organization/create`) and offers it the value: a `true` or `'organization'` field must admit it, and a `'global'` field, or any field of an object with no organization column, must refuse it. Without `--multi-tenant` there is one organization, so that leg is reported **not @@ -1985,6 +1987,34 @@ os verify --unique-scopes --multi-tenant # Also prove each declared unique only. A second holder met wrongly fails the run; a refusal that is not the field's own constraint (an app hook's dedupe, a rule, a permission) is reported inconclusive, never proven. + With `--parent-write-reach`, another fresh boot proves ADR-0055's write rule + for every object declared `controlled_by_parent` (exactly one + `master_detail` field names its master): editing, deleting or creating a + detail is admitted exactly when the caller holds that verb on the detail's + object **and** can edit its master. The first half is read from the + server's effective permissions for the caller (`/auth/me/permissions`); the + second is measured, by an empty update of the master as the caller, which + runs every write gate the master has — for a master that is itself + controlled by its parent, the gate above it too. The writes go through the + engine as the signed-in caller (the insert, update and delete by id the + REST data route makes, with the caller's context), where a refusal keeps + the object and the operation it names — the REST envelope names the + route's object whatever was refused; the system door bypasses + authorization and is not asked. They are made by three verifier + personas whose permission sets the run registers from the app's own objects + (the master's root by the all-records grant `modifyAllRecords` with the + detail's every verb; the master readable but not editable; the all-records + grant with no detail delete) and by one persona per declared position, each + under a master chain another principal owns and one it owns. A write whose + answer disagrees fails the run, and so does a persona that could not be + provisioned; a refusal that is not the platform's `403 PERMISSION_DENIED` + naming the detail and the operation (a rule, a hook's own code, a refusal + on another object) is reported inconclusive, never proven. An object + with two `master_detail` fields, none, or a chain at the write gate's depth + bound is reported `excluded`. Today's engine refuses every write to a detail + two levels below its root master (a line under a quote under an account), + even where the caller edits the middle master, so an app that declares such + a chain fails the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or `group`) asks for the same boot. @@ -2014,8 +2044,9 @@ document carries depends on where the run ended: - **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`, `crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`), `deleteBehaviors` (with `--delete-behaviors`), `uniqueScopes` (with - `--unique-scopes`; its `boot` names the legs the run could prove) and - `hardFailures`, the count + `--unique-scopes`; its `boot` names the legs the run could prove), + `parentWriteReach` (with `--parent-write-reach`; its `personas` name who + wrote and who could not be provisioned) and `hardFailures`, the count the exit status is read from. - **Could not run:** `{ "error": "" }`, plus `code` and `httpStatus` when the failure carries them. diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 1fc56df27b1..f3f99713a7e 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -105,9 +105,9 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b * as its declared scope says * - parent-derived write reach (--parent-write-reach, ADR-0055 §3): * for every `controlled_by_parent` object, a persona's edit, delete - * or create of a detail through the REST door is admitted exactly - * when it holds that verb on the detail's object and can edit the - * master + * or create of a detail, written through the engine as the persona, + * is admitted exactly when it holds that verb on the detail's object + * and can edit the master * * Exits non-zero on either stage's failures so it drops straight into CI. */ @@ -147,7 +147,7 @@ export default class Verify extends Command { default: false, }), 'parent-write-reach': Flags.boolean({ - description: "Also prove, for every controlled_by_parent object, that a persona's edit, delete or create of a detail through the REST door is admitted exactly when it holds that verb on the detail's object and can edit the master, as verifier personas and one persona per declared position (ADR-0055 master-detail write reach)", + description: "Also prove, for every controlled_by_parent object, that a persona's edit, delete or create of a detail is admitted exactly when it holds that verb on the detail's object and can edit the master, written through the engine as verifier personas and one persona per declared position (ADR-0055 master-detail write reach)", default: false, }), 'multi-tenant': Flags.boolean({ diff --git a/packages/cli/test/verify-parent-write-reach.test.ts b/packages/cli/test/verify-parent-write-reach.test.ts index f71d34f8119..e7c13170614 100644 --- a/packages/cli/test/verify-parent-write-reach.test.ts +++ b/packages/cli/test/verify-parent-write-reach.test.ts @@ -14,7 +14,8 @@ * edits another principal's contract through the account and is refused * the delete it lacks, `hardFailures` stays 0 and the run exits 0; * - with the flag, on the same metadata plus a hook that refuses every edit - * of the contract with the platform's own `403 PERMISSION_DENIED`, the + * of the contract with the platform's own envelope (`403 + * PERMISSION_DENIED`, `details` naming the update of the contract), the * detail's answer disagrees with its master's: the contract is reported * `contradicted`, `hardFailures` counts it and the run exits 1. * @@ -84,7 +85,11 @@ const CONTROL_SOURCE = `{ object: 'vpw_contract', events: ['beforeUpdate'], handler: async () => { - throw Object.assign(new Error('refused by the app'), { code: 'PERMISSION_DENIED', status: 403 }); + throw Object.assign(new Error('refused by the app'), { + code: 'PERMISSION_DENIED', + status: 403, + details: { operation: 'update', object: 'vpw_contract' }, + }); }, }, ], @@ -166,7 +171,7 @@ describe('os verify --parent-write-reach', () => { expect(proven.status, `${proven.stdout}\n${proven.stderr}`).toBe(0); const doc = docOf(proven); const report = doc.parentWriteReach!; - expect(report.door).toBe('rest'); + expect(report.door).toBe('user'); expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ 'master-all-records run', 'detail-only run', 'master-all-records-no-delete run', 'position:vpw_manager run', ]); diff --git a/packages/verify/README.md b/packages/verify/README.md index 1e6661ce195..8869babc011 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -1,7 +1,7 @@ # @objectstack/verify Boot any ObjectStack app **in-process** and verify it through the **real HTTP -stack** — no mocks, no ports, no sockets. Five app-agnostic proof families, all +stack** — no mocks, no ports, no sockets. Six app-agnostic proof families, all derived from your own metadata: - **Data fidelity** — author one record per object covering every field type, @@ -29,6 +29,13 @@ derived from your own metadata: org-scoped boot a second organization is provisioned too, and there a `true` / `'organization'` value must be admitted while a `'global'` one, or one on an object with no organization column, must be refused. +- **Parent-derived write reach** (opt-in) — *a write on a `controlled_by_parent` + detail is admitted exactly when the caller holds that verb on the detail's + object and can edit its master* (ADR-0055 §3): per detail object, persona and + master chain, an edit, a delete and a create through the engine as that + persona, each compared with that conjunction — the caller's effective grants + on the detail, and its edit of the master MEASURED by an empty update of the + master. And, on the same booted stack, an **in-process handle** so an app's own tests can run a hook, a validation rule, a flow, an action, a seed or a read against @@ -73,6 +80,9 @@ objectstack verify --delete-behaviors # also prove every declared unique collides where its scope says (both legs) objectstack verify --unique-scopes --multi-tenant + +# also prove every controlled_by_parent detail write follows its master +objectstack verify --parent-write-reach ``` `--reference-shapes` is opt-in, on a fresh boot of its own: the engine still @@ -106,10 +116,33 @@ entry covers the field, a refusal that names none is `unattributed`: that entry refuses the same write, naming no field either, so it may be the entry's rather than the field's own declaration. +`--parent-write-reach` is opt-in, on a fresh boot of its own that registers +the three verifier personas' permission sets (`parentWriteReachSecurity`). The +population is every object the spec's parse declares `controlled_by_parent` +with exactly one `master_detail` field, and the chain of masters above it. The +writes are made by three verifier personas — the root master by the +all-records grant (`modifyAllRecords`) with every verb on the detail; the root +master readable but not editable with every verb on the detail; the +all-records grant with no delete on the detail — and by one persona per +declared position (its capability is what the app binds to the position), each +under a master chain another principal owns and one it owns, and on a detail +another principal owns and one it owns. The writes go through the engine as +the persona (the insert, update and delete by id the REST data route makes, +with the caller's context): there a refusal keeps the object and operation it +names, while the REST envelope names the route's object whatever was refused. +A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the +detail and the operation; any other refusal (a rule, a hook's own code, a +refusal on another object) is `unattributed`. The system door bypasses +authorization and is not asked. Today's engine refuses every write to a detail +two levels below its root master, even where the caller edits the middle +master, so on an app that declares such a chain the family reports a +contradiction and fails the run. + Exit code is **non-zero** on real failures (`create-failed`, `read-failed`, `fidelity-gaps`, `rls-hole`, with `--reference-shapes` a field with `holes` or -`failed`, and with `--delete-behaviors` or `--unique-scopes` a field -`contradicted` or `failed`) so +`failed`, with `--delete-behaviors` or `--unique-scopes` a field +`contradicted` or `failed`, and with `--parent-write-reach` an object +`contradicted` or `failed` or a persona that could not be provisioned) so it drops straight into a CI gate. Inconclusive verdicts (`needs-fixture`, `skipped`, `member-visible`) are warnings and exit 0. @@ -121,6 +154,7 @@ import { runReferenceShapeProofs, formatReferenceShapeReport, runDeleteBehaviorProofs, formatDeleteBehaviorReport, runUniqueScopeProofs, formatUniqueScopeReport, + runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity, } from '@objectstack/verify'; import myApp from './objectstack.config.js'; @@ -160,6 +194,13 @@ const uniques = await runUniqueScopeProofs(uniqueStack, await uniqueStack.signIn console.log(formatUniqueScopeReport(uniques)); // `uniques.boot` says which legs ran expect(uniques.summary.hardFailures).toBe(0); await uniqueStack.stop(); + +// Parent-derived write reach, on a fresh stack that registers the verifier personas' sets +const parentStack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); +const reach = await runParentWriteReachProofs(parentStack, myApp); +console.log(formatParentWriteReachReport(reach)); // `reach.personas` says who wrote +expect(reach.summary.hardFailures).toBe(0); +await parentStack.stop(); ``` ## The in-process handle (drive the real engine from a test) @@ -380,6 +421,28 @@ on a walled boot `across` × door × insert: | `excluded` | a derived value (`formula`, `summary`) is not stored, or the declaration does not parse | | `not-derivable` | no fresh value can be built: a closed or tiny value space (`boolean`, `select`, …), a multi-valued reference, a target the app does not declare, or an object the CRUD derivation cannot synthesize | +**Parent-derived write reach** (`runParentWriteReachProofs`), per object +declared `controlled_by_parent`. Probes per persona and master chain (another +principal's, the persona's own): edit and delete of a detail another principal +owns and of one the persona owns, and a create. Each is expected `allowed` +exactly when the persona's effective grant on the detail (`/auth/me/permissions`, +folded by `objectPermissionGrants`) holds the verb and the persona's empty +update of the master was admitted: + +| status | meaning | +| --- | --- | +| `proven` | every write met that conjunction: admitted where both halves hold, refused with `403 PERMISSION_DENIED` naming the detail and the operation where either does not | +| `contradicted` | a write met the other answer — refused where the persona holds the verb and edits the master, admitted where it does not, or refused while the record shows it landed **(failure)** | +| `failed` | a door faulted **(failure)** | +| `inconclusive` | a refusal was not the platform's authorization refusal of this write (a rule, a hook's own code, a refusal on another object), a fixture could not be written, or a master's edit could not be measured (the reason is reported) | +| `excluded` | the declaration names no one master: no `master_detail` field, two of them, a master the app does not declare, a cycle, or a chain at the write gate's depth bound | +| `not-derivable` | the detail or a master in its chain cannot be synthesized by the CRUD derivation | + +A master chain a persona cannot read is outside its reach and is counted, not +probed (a write to a record the caller cannot read is the RLS family's +subject). `summary.hardFailures` is `contradicted + failed` objects plus +personas that could not be provisioned. + `member-visible` everywhere usually means the app is single-tenant; pass `--multi-tenant` (or `{ multiTenant: true }`) to register org-scoping so tenant isolation policies actually apply. @@ -425,6 +488,11 @@ run" must never read like "nothing to find". second organization meets, or the reason it is not compared; `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport` (`boot` names the legs this boot ran; `summary.hardFailures` is what a gate reads); `formatUniqueScopeReport(report)`. +- `deriveParentWriteReachCases(config)` → one case per object declared `controlled_by_parent`, with the chain of + `master_detail` hops a write on it walks, or the reason it is not compared; `parentWriteReachSecurity(config)` → the + `SecurityPlugin` to boot with (registers the verifier personas' sets); `runParentWriteReachProofs(stack, config)` → + `ParentWriteReachReport` (`personas` names who wrote and who could not be provisioned; `summary.hardFailures` is what + a gate reads); `formatParentWriteReachReport(report)`. `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` for owner-scoped fixtures), `multiTenant` (also what decides the posture diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts index ec207a4c24f..7e82679a66b 100644 --- a/packages/verify/src/parent-write-reach.test.ts +++ b/packages/verify/src/parent-write-reach.test.ts @@ -12,9 +12,9 @@ // • a run on a stack booted without `parentWriteReachSecurity` reports the // verifier personas not run, with the reason, and counts them. // -// The REST envelope names the route's object on every refusal and carries no -// operation, so attribution there is `403 PERMISSION_DENIED` at a route whose -// one operation is the request's own. +// The door is the engine's write as each persona (the calls the REST data +// ingress makes, with the caller's context), so a refusal keeps its own +// `details`: attribution reads the object and the operation it names. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -200,7 +200,7 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite }); it('runs every persona: the three verifier personas and one per declared position', () => { - expect(report.door).toBe('rest'); + expect(report.door).toBe('user'); expect(report.personas.map((p) => `${p.name} ${p.status}`)).toEqual([ ...VERIFIERS.map((v) => `${v} run`), 'position:pwr_manager run', 'position:pwr_rep run', ]); @@ -229,7 +229,9 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite expect(other).toMatchObject({ masterRead: 'readable', masterEdit: 'editable', grants: { edit: true, delete: false, create: true } }); expect(shape(report, 'pwr_contract', 'position:pwr_manager', 'other')).toEqual(NO_DELETE); // A refusal is the platform's authorization refusal for the detail. - expect(other.probes.find((p) => p.verb === 'delete')!.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_contract' }); + expect(other.probes.find((p) => p.verb === 'delete')!.answer).toMatchObject({ + kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_contract', operation: 'delete', + }); // The rep cannot read another's account, so that chain is outside its reach, and named. expect(reachOf(report, 'pwr_contract', 'position:pwr_rep', 'other')).toMatchObject({ masterRead: 'unreadable', probes: [] }); // On its own account the rep edits the master and is still refused the contract: the object gate. @@ -242,7 +244,7 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite // ADR-0055's amendment: the write gate walks the chain hop by hop, and a // detail whose whole chain is reachable stays writable. Measured on this // engine, a quote line is refused for every persona, its own chain - // included, while the same persona's empty PATCH of the quote is admitted. + // included, while the same persona's empty update of the quote is admitted. // This flips to `proven` the day the chain walk agrees with the quote's own write. const r = resultFor(report, 'pwr_quote_line'); expect(r.status).toBe('contradicted'); @@ -252,7 +254,7 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite expect(reach.probes.map((p) => p.outcome)).toEqual(['contradicted', 'contradicted', 'contradicted', 'contradicted', 'contradicted']); for (const p of reach.probes) { expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwr_quote_line' }); - expect(p.detail).toMatch(/^the persona holds allow(Edit|Delete|Create) on pwr_quote_line and edited its master pwr_quote \(200\), and the (edit|delete|create) was refused/); + expect(p.detail).toMatch(/^the persona holds allow(Edit|Delete|Create) on pwr_quote_line and edited its master pwr_quote, and the (edit|delete|create) was refused/); } // Where the conjunction refuses, the refusal still holds. expect(shape(report, 'pwr_quote_line', 'detail-only', masters)).toEqual(ALL_REFUSED); @@ -285,12 +287,14 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite }); /** - * The negative and attribution controls. `pwr_signed`'s hooks refuse every - * write with the platform's own `403 PERMISSION_DENIED`, whoever writes and - * whatever the master says; `pwr_locked`'s refuse with the app's own code. + * The negative and attribution controls. `pwc_signed`'s hooks refuse every + * edit and delete with the platform's own envelope (`403 PERMISSION_DENIED`, + * `details` naming the write), whoever writes and whatever the master says; + * `pwc_locked`'s refuse an edit with that envelope naming ANOTHER object, and + * a delete with the app's own code. */ -const deny = (code: string, status: number) => async () => { - throw Object.assign(new Error(`refused by the app (${code})`), { code, status }); +const deny = (code: string, status: number, details?: { operation: string; object: string }) => async () => { + throw Object.assign(new Error(`refused by the app (${code})`), { code, status, ...(details ? { details } : {}) }); }; const controlSource = { manifest: { id: 'com.example.parent-write-reach-controls', namespace: 'pwc', version: '0.0.1', type: 'app', name: 'Parent Write Reach Controls' }, @@ -313,15 +317,27 @@ const controlSource = { // Update and delete only: the fixtures are seeded through a door that // runs hooks, so an insert refusal would refuse the fixtures too. { - name: 'pwc_signed_refuses', + name: 'pwc_signed_refuses_an_edit', + object: 'pwc_signed', + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_signed' }), + }, + { + name: 'pwc_signed_refuses_a_delete', object: 'pwc_signed', - events: ['beforeUpdate', 'beforeDelete'], - handler: deny('PERMISSION_DENIED', 403), + events: ['beforeDelete'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'delete', object: 'pwc_signed' }), }, { - name: 'pwc_locked_refuses', + name: 'pwc_locked_refuses_an_edit_naming_the_account', object: 'pwc_locked', - events: ['beforeUpdate', 'beforeDelete'], + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_account' }), + }, + { + name: 'pwc_locked_refuses_a_delete_with_its_own_code', + object: 'pwc_locked', + events: ['beforeDelete'], handler: deny('RECORD_LOCKED', 409), }, ], @@ -360,13 +376,18 @@ describe('runParentWriteReachProofs — NEGATIVE and ATTRIBUTION controls', () = const r = resultFor(report, 'pwc_locked'); expect(r.status, formatParentWriteReachReport(report)).toBe('inconclusive'); for (const masters of ['other', 'own'] as const) { - // Expected allowed, and refused by the app's own code: unattributed. + // Expected allowed, and refused — naming another object, or with the app's own code: unattributed. expect(shape(report, 'pwc_locked', 'master-all-records', masters)).toEqual([ 'edit/other allowed unattributed', 'delete/other allowed unattributed', 'edit/own allowed unattributed', 'delete/own allowed unattributed', 'create allowed held', ]); - for (const p of reachOf(report, 'pwc_locked', 'master-all-records', masters).probes.filter((x) => x.verb !== 'create')) { + const probes = reachOf(report, 'pwc_locked', 'master-all-records', masters).probes; + for (const p of probes.filter((x) => x.verb === 'edit')) { + expect(p.answer).toMatchObject({ kind: 'refused', status: 403, code: 'PERMISSION_DENIED', object: 'pwc_account', operation: 'update' }); + expect(p.detail).toMatch(/^refused for another reason: PERMISSION_DENIED \(403\) naming update on pwc_account/); + } + for (const p of probes.filter((x) => x.verb === 'delete')) { expect(p.answer).toMatchObject({ kind: 'refused', status: 409, code: 'RECORD_LOCKED' }); expect(p.detail).toMatch(/^refused for another reason: RECORD_LOCKED \(409\)/); } diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts index 9d46bd4d2e6..8acac6bd5db 100644 --- a/packages/verify/src/parent-write-reach.ts +++ b/packages/verify/src/parent-write-reach.ts @@ -16,7 +16,7 @@ // - P holds the verb on D's object — read from the server's effective object // permissions for P (`GET /auth/me/permissions`, the one merge of the sets // P holds, ADR-0124 D4), folded by the spec's `objectPermissionGrants`; -// - P can edit M — MEASURED, never re-derived: an empty `PATCH` of M as P, +// - P can edit M — MEASURED, never re-derived: an empty update of M as P, // which runs every write gate M has (the object gate, the record gate and, // for a master that is itself controlled by its parent, the master gate // above it) and changes no business value. @@ -31,9 +31,17 @@ // // ## The door, and who walks through it // -// The REST data route as the signed-in principal: `PATCH`, `DELETE` and `POST` -// on `/data/:object[/:id]`. The engine's own write as the system principal -// bypasses authorization by design, so it is not in this family's population. +// The engine's write as the signed-in principal: the handle's `hooks.run` with +// `{ as: token }`, which makes the three calls the REST data ingress makes +// (`insert`, `update` by id, `delete` by id) with the caller's resolved +// context, the security middleware and the bound hooks included. It is the +// door, rather than the REST route itself, because only it keeps the refusal's +// own identity: the REST envelope names the ROUTE's object on every refusal +// (measured: a hook refusal naming another object comes back naming the +// route's) and carries no operation, so a hook's nested refusal on another +// object would read there as the detail's own. The engine's own write as the +// system principal bypasses authorization by design, so it is not in this +// family's population. // // The principals: three verifier personas whose permission sets this module // authors from the app's own objects (registered at boot by @@ -48,10 +56,11 @@ // ## Attribution is the control // // A refusal proves the conjunction held only when it is the platform's -// authorization refusal for this detail: `403 PERMISSION_DENIED` naming the -// detail's object. The REST envelope carries no operation; the route names -// it. Any other refusal (validation, a hook's own code, a uniqueness -// collision, a missing field) is `unattributed`, never proven. +// authorization refusal for this write: `PERMISSION_DENIED` with status 403 +// whose `details` name the detail's object and the probed operation +// (`update`, `delete`, `insert`). Any other refusal (validation, a hook's own +// code, a refusal on another object, a uniqueness collision, a missing field) +// is `unattributed`, never proven. // // Run it on a fresh stack of its own, booted with // `parentWriteReachSecurity(config)`: it writes, deletes and signs up. @@ -104,14 +113,15 @@ export type ParentWriteReachVerb = 'edit' | 'delete' | 'create'; /** Who owns a fixture record: another principal, or the probing persona. */ export type ParentWriteReachOwner = 'other' | 'own'; -/** What one REST call answered. */ +/** What one write answered. */ export interface ParentWriteReachAnswer { - /** `written` — 2xx; `refused` — a coded refusal; `faulted` — a 5xx, or no code. */ + /** `written` — the engine wrote; `refused` — a coded refusal; `faulted` — a 5xx, or a throw with no code. */ kind: 'written' | 'refused' | 'faulted'; status?: number; code?: string; - /** The object the refusal names. */ + /** The object and the operation the refusal's `details` name. */ object?: string; + operation?: string; detail?: string; } @@ -141,7 +151,7 @@ export interface ParentWriteReachReach { masters: ParentWriteReachOwner; /** `unreadable` — the persona cannot read the master, so the detail is outside its reach (not probed). */ masterRead: 'readable' | 'unreadable' | 'unmeasured'; - /** The measured half: an empty `PATCH` of the master as the persona. */ + /** The measured half: an empty update of the master as the persona. */ masterEdit?: 'editable' | 'not-editable' | 'unmeasured'; masterAnswer?: ParentWriteReachAnswer; /** The detail object's effective grants for the persona (`/auth/me/permissions`). */ @@ -183,8 +193,8 @@ export interface ParentWriteReachPersona { export interface ParentWriteReachReport { app: string; - /** The one door asked. The system door bypasses authorization by design and is not. */ - door: 'rest'; + /** The one door asked: the engine's write as the signed-in principal. The system door bypasses authorization by design and is not. */ + door: 'user'; personas: ParentWriteReachPersona[]; results: ParentWriteReachObjectResult[]; summary: { @@ -398,52 +408,61 @@ export function parentWriteReachSecurity(config: any): SecurityPlugin { // ── The door ──────────────────────────────────────────────────────────────── -async function rest( - stack: VerifyStack, - token: string, - method: string, - path: string, - body?: unknown, -): Promise { - let res: Response; +/** The server's effective permissions for the caller: `GET /auth/me/permissions`, or why it gave none. */ +async function effectivePermissions(stack: VerifyStack, token: string): Promise<{ objects?: Record; sets: string[]; reason?: string }> { try { - res = await stack.apiAs(token, method, path, body); + const res = await stack.apiAs(token, 'GET', '/auth/me/permissions'); + const payload = (await res.json().catch(() => undefined)) as { objects?: unknown; permissionSets?: unknown } | undefined; + const sets = Array.isArray(payload?.permissionSets) ? payload!.permissionSets.map(String) : []; + if (res.status !== 200 || !payload?.objects || typeof payload.objects !== 'object') { + return { sets, reason: `GET /auth/me/permissions answered ${res.status}: no effective grants to read` }; + } + return { objects: payload.objects as Record, sets }; } catch (e) { - return { kind: 'faulted', detail: clip((e as Error)?.message ?? e) }; + return { sets: [], reason: `GET /auth/me/permissions faulted: ${clip((e as Error)?.message ?? e)}` }; } - const status = res.status; - const text = await res.text(); - let payload: any; +} + +/** One write through the engine as the signed-in principal, and what it answered. */ +async function write( + stack: VerifyStack, + token: string, + object: string, + operation: 'insert' | 'update' | 'delete', + input: Record, +): Promise { try { - payload = JSON.parse(text); - } catch { - payload = undefined; - } - if (status >= 200 && status < 300) { - const id = payload?.id ?? payload?.record?.id; - return { kind: 'written', status, payload, ...(id != null ? { id: String(id) } : {}) }; - } - if (status >= 500 || typeof payload?.code !== 'string') { - return { kind: 'faulted', status, ...(payload?.code ? { code: payload.code } : {}), detail: clip(payload?.error ?? text) }; + const answered = (await stack.hooks.run(object, operation, input, { as: token })) as { id?: unknown } | boolean | undefined; + const id = typeof answered === 'object' && answered?.id != null ? String(answered.id) : undefined; + return { kind: 'written', ...(id ? { id } : {}) }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown; details?: any; message?: unknown }; + const status = typeof err?.status === 'number' ? err.status : typeof err?.statusCode === 'number' ? err.statusCode : undefined; + if (typeof err?.code !== 'string' || (status !== undefined && status >= 500)) { + return { kind: 'faulted', ...(status !== undefined ? { status } : {}), detail: clip(err?.message ?? e) }; + } + return { + kind: 'refused', + code: err.code, + ...(status !== undefined ? { status } : {}), + ...(typeof err.details?.object === 'string' ? { object: err.details.object } : {}), + ...(typeof err.details?.operation === 'string' ? { operation: err.details.operation } : {}), + detail: clip(err.message), + }; } - return { - kind: 'refused', - status, - code: payload.code, - ...(typeof payload.object === 'string' ? { object: payload.object } : {}), - detail: clip(payload.error ?? text), - }; } -/** The platform's authorization refusal for `object`. */ -const deniedFor = (answer: ParentWriteReachAnswer, object: string): boolean => - answer.kind === 'refused' && answer.code === 'PERMISSION_DENIED' && answer.status === 403 && answer.object === object; +/** The platform's authorization refusal of `operation` on `object`. */ +const deniedFor = (answer: ParentWriteReachAnswer, object: string, operation: string): boolean => + answer.kind === 'refused' && answer.code === 'PERMISSION_DENIED' && answer.status === 403 + && answer.object === object && answer.operation === operation; const plain = (a: ParentWriteReachAnswer): ParentWriteReachAnswer => ({ kind: a.kind, ...(a.status !== undefined ? { status: a.status } : {}), ...(a.code !== undefined ? { code: a.code } : {}), ...(a.object !== undefined ? { object: a.object } : {}), + ...(a.operation !== undefined ? { operation: a.operation } : {}), ...(a.detail !== undefined && a.kind !== 'written' ? { detail: a.detail } : {}), }); @@ -512,6 +531,13 @@ const GRANT: Record = { + edit: 'update', + delete: 'delete', + create: 'insert', +}; + /** * Compare one detail write against the conjunction. The whole verdict of the * family is this function: it reads the expectation, the door's answer and @@ -526,19 +552,21 @@ function judge( ): Pick { if (answer.kind === 'faulted') return { outcome: 'failed', detail: `the door faulted: ${answer.status ?? ''} ${answer.detail ?? ''}` }; if (answer.kind === 'refused') { - if (!deniedFor(answer, c.object)) { + if (!deniedFor(answer, c.object, OPERATION[probe.verb])) { return { outcome: 'unattributed', - detail: `refused for another reason: ${answer.code}${answer.status ? ` (${answer.status})` : ''}${answer.object ? ` naming ${answer.object}` : ''} ${answer.detail ?? ''}`, + detail: + `refused for another reason: ${answer.code}${answer.status ? ` (${answer.status})` : ''}` + + `${answer.object || answer.operation ? ` naming ${answer.operation ?? '?'} on ${answer.object ?? '?'}` : ''} ${answer.detail ?? ''}`, }; } if (landed === true) return { outcome: 'contradicted', detail: `the ${probe.verb} was refused, yet the record shows it landed` }; if (probe.expected === 'refused') return { outcome: 'held' }; return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was refused (403 PERMISSION_DENIED)` }; } - if (landed === false) return { outcome: 'not-run', detail: `the door answered ${answer.status}, but the record does not show the ${probe.verb}` }; + if (landed === false) return { outcome: 'not-run', detail: `the engine answered the ${probe.verb} written, but the record does not show it` }; if (probe.expected === 'allowed') return { outcome: 'held' }; - return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was admitted (${answer.status})` }; + return { outcome: 'contradicted', detail: `${probe.why}, and the ${probe.verb} was admitted` }; } function objectStatus(reaches: ParentWriteReachReach[]): { status: ParentWriteReachObjectStatus; reason?: string } { @@ -571,9 +599,9 @@ interface RunningPersona extends ParentWriteReachPersona { /** * Run the parent-derived write reach family against a live stack: every * persona (the three verifier personas, then one per declared position) - * writes every probed detail object's records through the REST door — edit, - * delete and create — under a master chain another principal owns and one it - * owns, and each answer is compared with the conjunction. Returns a + * writes every probed detail object's records through the engine as itself — + * edit, delete and create — under a master chain another principal owns and + * one it owns, and each answer is compared with the conjunction. Returns a * structured report; a per-object failure is collected, not thrown. */ export async function runParentWriteReachProofs(stack: VerifyStack, config: any): Promise { @@ -597,13 +625,12 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) const admit = async (base: ParentWriteReachPersona, token: string): Promise => { const userId = String((await stack.contextFor(token)).userId); - const me = await rest(stack, token, 'GET', '/auth/me/permissions'); - const sets: string[] = Array.isArray(me.payload?.permissionSets) ? me.payload.permissionSets.map(String) : []; - if (me.kind !== 'written' || typeof me.payload?.objects !== 'object') { - notRun.push({ ...base, status: 'not-run', reason: `GET /auth/me/permissions answered ${me.status ?? me.kind}: no effective grants to read` }); + const me = await effectivePermissions(stack, token); + if (!me.objects) { + notRun.push({ ...base, status: 'not-run', reason: me.reason }); return; } - personas.push({ ...base, status: 'run', permissionSets: sets, token, userId, objects: me.payload.objects }); + personas.push({ ...base, status: 'run', permissionSets: me.sets, token, userId, objects: me.objects }); }; for (const v of VERIFIER_PERSONAS) { @@ -615,8 +642,8 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) // boot by `parentWriteReachSecurity`, never written as a row. await stack.seed('sys_user_permission_set', [{ user_id: userId, permission_set: v.set }]); const token = await stack.signIn(v.email, VERIFIER_PASSWORD); - const me = await rest(stack, token, 'GET', '/auth/me/permissions'); - if (!Array.isArray(me.payload?.permissionSets) || !me.payload.permissionSets.includes(v.set)) { + const me = await effectivePermissions(stack, token); + if (!me.sets.includes(v.set)) { notRun.push({ ...base, status: 'not-run', @@ -733,25 +760,29 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) const masterId = chain.id; // The measured half: can this persona read, then edit, the master? - const read = await rest(stack, p.token, 'GET', `/data/${master}/${masterId}`); - if (read.kind !== 'written') { - if (read.kind === 'refused' && (read.status === 404 || read.status === 403)) { - reach.masterRead = 'unreadable'; - reach.reason = - `GET ${master} answered ${read.status}: the master is outside the persona's read reach, so its details are ` + - "too (a write to a record the caller cannot read is the RLS family's subject)"; - } else { - reach.reason = `GET ${master} answered ${read.status ?? read.kind}: the master's read reach could not be measured`; - } + let readable: boolean | undefined; + try { + readable = (await stack.rows(master, { id: masterId }, { as: p.token })).length > 0; + } catch (e) { + const err = e as { code?: unknown; message?: unknown }; + if (err?.code === 'PERMISSION_DENIED') readable = false; + else reach.reason = `the read of ${master} as the persona faulted: ${clip(err?.message ?? e, 160)}`; + } + if (readable === undefined) continue; + if (!readable) { + reach.masterRead = 'unreadable'; + reach.reason = + `the persona's read of ${master} returns no row: the master is outside its read reach, so its details are ` + + "too (a write to a record the caller cannot read is the RLS family's subject)"; continue; } reach.masterRead = 'readable'; - const edit = await rest(stack, p.token, 'PATCH', `/data/${master}/${masterId}`, {}); + const edit = await write(stack, p.token, master, 'update', { id: masterId }); reach.masterAnswer = plain(edit); - reach.masterEdit = edit.kind === 'written' ? 'editable' : deniedFor(edit, master) ? 'not-editable' : 'unmeasured'; + reach.masterEdit = edit.kind === 'written' ? 'editable' : deniedFor(edit, master, 'update') ? 'not-editable' : 'unmeasured'; reach.grants = grants; if (reach.masterEdit === 'unmeasured') { - reach.reason = `the empty PATCH of ${master} answered ${edit.code ?? edit.status ?? edit.kind}, not a write gate's verdict: its edit reach could not be measured`; + reach.reason = `the empty update of ${master} answered ${edit.code ?? edit.kind}, not a write gate's verdict on it: its edit reach could not be measured`; continue; } @@ -763,7 +794,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) expected: holds && editable ? ('allowed' as const) : ('refused' as const), why: `the persona ${holds ? 'holds' : 'lacks'} ${GRANT[verb]} on ${c.object} and ` + - `${editable ? 'edited' : 'was refused an edit of'} its master ${master} (${edit.status})`, + `${editable ? 'edited' : 'was refused an edit of'} its master ${master}`, }; }; @@ -779,14 +810,14 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) { const e = expect('edit'); const before = await row(c.object, d.id); - const answer = await rest(stack, p.token, 'PATCH', `/data/${c.object}/${d.id}`, {}); + const answer = await write(stack, p.token, c.object, 'update', { id: d.id }); const after = await row(c.object, d.id); const stamp = before && 'updated_at' in before ? after?.updated_at !== before.updated_at : undefined; reach.probes.push({ verb: 'edit', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, stamp) }); } { const e = expect('delete'); - const answer = await rest(stack, p.token, 'DELETE', `/data/${c.object}/${d.id}`); + const answer = await write(stack, p.token, c.object, 'delete', { id: d.id }); const gone = !(await row(c.object, d.id)); reach.probes.push({ verb: 'delete', detailOwner, expected: e.expected, answer: plain(answer), ...judge(c, e, answer, gone) }); } @@ -810,7 +841,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) } else { const refs = (cc.relationalRefs ?? []).filter((r) => r.field !== field); const { body } = fillRelationalRefs({ ...cc, relationalRefs: refs }, ids); - const answer = await rest(stack, p.token, 'POST', `/data/${c.object}`, { + const answer = await write(stack, p.token, c.object, 'insert', { ...fixtureBody(body, objectDefs.get(c.object), ++counter), [field]: masterId, }); @@ -839,7 +870,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) ]; return { app: config?.manifest?.id ?? config?.manifest?.namespace ?? 'app', - door: 'rest', + door: 'user', personas: reported, results, summary: { @@ -875,7 +906,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) /** Pretty one-line-per-object summary for logs. */ export function formatParentWriteReachReport(report: ParentWriteReachReport): string { const lines: string[] = [`\n=== objectstack verify — parent-derived write reach — ${report.app} ===`]; - lines.push(' door: REST (POST / PATCH / DELETE /data/:object); the system door bypasses authorization and is not asked'); + lines.push(' door: the engine write as each persona (insert, update and delete by id); the system door bypasses authorization and is not asked'); const ran = report.personas.filter((p) => p.status === 'run'); lines.push(` personas: ${ran.map((p) => p.name).join(', ') || 'none'}`); for (const p of report.personas.filter((x) => x.status === 'not-run')) { From 1aad8e3be09ac3e5a061cca24150553a8913445d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:49:07 +0000 Subject: [PATCH 08/16] test(verify): the run without the verifier sets uses the single-level objects and their grants Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/parent-write-reach.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts index 7e82679a66b..a6d0bcd44c9 100644 --- a/packages/verify/src/parent-write-reach.test.ts +++ b/packages/verify/src/parent-write-reach.test.ts @@ -399,7 +399,16 @@ describe('runParentWriteReachProofs — NEGATIVE and ATTRIBUTION controls', () = describe('runParentWriteReachProofs — a stack booted without parentWriteReachSecurity', () => { let stack: VerifyStack; let report: ParentWriteReachReport; - const app = defineStack({ ...source, objects: source.objects.filter((o) => o.name !== 'pwr_quote_line') } as never) as unknown as typeof source; + // The single-level objects only, so every probe this stack answers is one the engine agrees with. + const singleLevel = { + ...source, + objects: source.objects.filter((o) => o.name !== 'pwr_quote_line'), + permissions: source.permissions.map((set) => ({ + ...set, + objects: Object.fromEntries(Object.entries(set.objects).filter(([name]) => name !== 'pwr_quote_line')), + })), + }; + const app = defineStack(singleLevel as never) as unknown as typeof source; beforeAll(async () => { stack = await bootStack(app as never); From 7a95beb944c366d3f99d22a7f2ed16dca879cbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:55:27 +0000 Subject: [PATCH 09/16] =?UTF-8?q?test(verify):=20the=20second=20negative?= =?UTF-8?q?=20control=20=E2=80=94=20details=20admitted=20under=20a=20maste?= =?UTF-8?q?r=20whose=20own=20edit=20is=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hook on the master refuses every edit of it with the platform's own envelope, so the persona's empty update of the master reads refused while nothing stops a write of its details: the family must report those writes contradicted. It pins the comparison's other direction, so an ablation that credits every admitted write turns red. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .../verify/src/parent-write-reach.test.ts | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/verify/src/parent-write-reach.test.ts b/packages/verify/src/parent-write-reach.test.ts index a6d0bcd44c9..fbe87f73169 100644 --- a/packages/verify/src/parent-write-reach.test.ts +++ b/packages/verify/src/parent-write-reach.test.ts @@ -290,8 +290,10 @@ describe('runParentWriteReachProofs — the fixture app, booted with parentWrite * The negative and attribution controls. `pwc_signed`'s hooks refuse every * edit and delete with the platform's own envelope (`403 PERMISSION_DENIED`, * `details` naming the write), whoever writes and whatever the master says; - * `pwc_locked`'s refuse an edit with that envelope naming ANOTHER object, and - * a delete with the app's own code. + * `pwc_frozen`'s hook refuses every edit of the MASTER that way, so its edit + * reads refused while nothing stops a write of its details; `pwc_locked`'s + * refuse an edit with that envelope naming ANOTHER object, and a delete with + * the app's own code. */ const deny = (code: string, status: number, details?: { operation: string; object: string }) => async () => { throw Object.assign(new Error(`refused by the app (${code})`), { code, status, ...(details ? { details } : {}) }); @@ -312,6 +314,13 @@ const controlSource = { sharingModel: 'controlled_by_parent', fields: { account: { type: 'master_detail', label: 'Account', reference: 'pwc_account' }, title: { type: 'text', label: 'Title' } }, }, + { name: 'pwc_frozen', label: 'Frozen', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name', required: true } } }, + { + name: 'pwc_frozen_line', + label: 'Frozen line', + sharingModel: 'controlled_by_parent', + fields: { frozen: { type: 'master_detail', label: 'Frozen', reference: 'pwc_frozen' }, title: { type: 'text', label: 'Title' } }, + }, ], hooks: [ // Update and delete only: the fixtures are seeded through a door that @@ -334,6 +343,12 @@ const controlSource = { events: ['beforeUpdate'], handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_account' }), }, + { + name: 'pwc_frozen_refuses_every_edit', + object: 'pwc_frozen', + events: ['beforeUpdate'], + handler: deny('PERMISSION_DENIED', 403, { operation: 'update', object: 'pwc_frozen' }), + }, { name: 'pwc_locked_refuses_a_delete_with_its_own_code', object: 'pwc_locked', @@ -369,7 +384,23 @@ describe('runParentWriteReachProofs — NEGATIVE and ATTRIBUTION controls', () = // Where the conjunction refuses too, the same refusal holds. expect(shape(report, 'pwc_signed', 'detail-only', masters)).toEqual(ALL_REFUSED); } - expect(report.summary.hardFailures).toBe(1); + }); + + it('NEGATIVE CONTROL: details admitted under a master whose own edit is refused are reported contradicted', () => { + const r = resultFor(report, 'pwc_frozen_line'); + expect(r.status, formatParentWriteReachReport(report)).toBe('contradicted'); + for (const masters of ['other', 'own'] as const) { + const reach = reachOf(report, 'pwc_frozen_line', 'master-all-records', masters); + expect(reach).toMatchObject({ masterEdit: 'not-editable', masterAnswer: { code: 'PERMISSION_DENIED', object: 'pwc_frozen', operation: 'update' } }); + expect(shape(report, 'pwc_frozen_line', 'master-all-records', masters)).toEqual([ + 'edit/other refused contradicted', 'delete/other refused contradicted', + 'edit/own refused contradicted', 'delete/own refused contradicted', + 'create refused contradicted', + ]); + for (const p of reach.probes) expect(p.detail).toMatch(/was refused an edit of its master pwc_frozen, and the (edit|delete|create) was admitted$/); + } + // The two negative controls, and nothing else, are the run's hard failures. + expect(report.summary).toMatchObject({ contradicted: 2, failed: 0, hardFailures: 2 }); }); it("ATTRIBUTION CONTROL: an app's own refusal is never read as the platform's, held or contradicted", () => { From fa8171ea2c3b2cc45a255b8b608417f7aa9d1422 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:55:27 +0000 Subject: [PATCH 10/16] chore(gates): classify runParentWriteReachProofs and formatParentWriteReachReport as not stand-ins The verify stand-in erasure gate asked: both take a value this package produces (the VerifyStack handle, a ParentWriteReachReport), so NOT_A_STAND_IN rows, worded as the sibling families' are. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- scripts/check-verify-stand-in-erasure.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check-verify-stand-in-erasure.mjs b/scripts/check-verify-stand-in-erasure.mjs index aa664273167..8059a78e85b 100644 --- a/scripts/check-verify-stand-in-erasure.mjs +++ b/scripts/check-verify-stand-in-erasure.mjs @@ -230,6 +230,9 @@ const NOT_A_STAND_IN = { runUniqueScopeProofs: 'same `VerifyStack` handle as runCrudVerification.', formatUniqueScopeReport: 'formats a `UniqueScopeReport` this package produced; presentation, not conformance.', + runParentWriteReachProofs: 'same `VerifyStack` handle as runCrudVerification.', + formatParentWriteReachReport: + 'formats a `ParentWriteReachReport` this package produced; presentation, not conformance.', fillRelationalRefs: 'takes one `CrudCase` derived by `deriveCrudCases` — a data record this package built, with no ' + 'external implementer.', From 0cc4adcb16b22026b18a7dc6c949d4d2fb1f4786 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:05:09 +0000 Subject: [PATCH 11/16] feat(verify): the no-master_detail exclusion names the runtime's required-lookup precedence An object declaring controlled_by_parent with no master_detail field states no master in its declaration (ADR-0055 section 1); the runtime would still derive through a required lookup, its own precedence, which this family does not compare. The reason now says so (hotcrm: 3 of its 8 such objects). Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/parent-write-reach.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts index 8acac6bd5db..7b70f14a8a1 100644 --- a/packages/verify/src/parent-write-reach.ts +++ b/packages/verify/src/parent-write-reach.ts @@ -238,7 +238,11 @@ function parseObject(obj: any): { data?: any; reason?: string } { function masterLinkOf(name: string, parsed: any): { hop?: ParentWriteReachHop; reason?: string } { const links = Object.entries(parsed?.fields ?? {}).filter(([, f]) => f?.type === 'master_detail'); if (links.length === 0) { - return { reason: `${name} declares no master_detail field, so it names no master (ADR-0055 §1: exactly one)` }; + return { + reason: + `${name} declares no master_detail field, so its declaration names no master (ADR-0055 §1: exactly one); ` + + "a master the runtime would derive through a required lookup is the runtime's own precedence, not compared here", + }; } if (links.length > 1) { return { From 63d869dc0252757f7cf7f6f0500cb03ccb6f268b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:04:40 +0000 Subject: [PATCH 12/16] fix(verify): read no grants from an empty permission-set answer GET /auth/me/permissions answers an empty objects map with no permission set when it could not compute the caller's grants (its closed body), the same body as a caller with nothing. Reading that as the persona's grants would expect every write refused and report a contradiction on each admitted one. A persona whose answer names no permission set is now reported not run, with the reason, and counted. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/parent-write-reach.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts index 7b70f14a8a1..560c6d698f8 100644 --- a/packages/verify/src/parent-write-reach.ts +++ b/packages/verify/src/parent-write-reach.ts @@ -421,6 +421,11 @@ async function effectivePermissions(stack: VerifyStack, token: string): Promise< if (res.status !== 200 || !payload?.objects || typeof payload.objects !== 'object') { return { sets, reason: `GET /auth/me/permissions answered ${res.status}: no effective grants to read` }; } + // The endpoint answers this same empty body when it could not compute the + // caller's grants, so "no set at all" is not read as "no grant anywhere". + if (sets.length === 0) { + return { sets, reason: 'GET /auth/me/permissions resolved no permission set for the persona, which is also its answer when it could not compute one: no effective grants to read' }; + } return { objects: payload.objects as Record, sets }; } catch (e) { return { sets: [], reason: `GET /auth/me/permissions faulted: ${clip((e as Error)?.message ?? e)}` }; From 8b4a5802f071991f38272a0d7af9b29c9f76341d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:04:54 +0000 Subject: [PATCH 13/16] docs(verify): a persona whose effective-permission answer names no set is reported not run Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/verify/README.md b/packages/verify/README.md index 8869babc011..5d8705cf861 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -440,8 +440,10 @@ update of the master was admitted: A master chain a persona cannot read is outside its reach and is counted, not probed (a write to a record the caller cannot read is the RLS family's -subject). `summary.hardFailures` is `contradicted + failed` objects plus -personas that could not be provisioned. +subject). A persona whose `/auth/me/permissions` answer names no permission set +is reported not run: that empty answer is also the endpoint's when it could not +compute one, so it is not read as "no grant anywhere". `summary.hardFailures` is +`contradicted + failed` objects plus personas that could not be provisioned. `member-visible` everywhere usually means the app is single-tenant; pass `--multi-tenant` (or `{ multiTenant: true }`) to register org-scoping so tenant From 347895565ea5ff2a2caf28af0b13dd534ce554bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:51:06 +0000 Subject: [PATCH 14/16] chore(changeset): restore slice 3's unique-scope note byte for byte from the merge base The maintainer ruled B: slice 3's pending note stays as it was written, and the two corrections it needs are carried as sentences in this slice's own changeset instead. Restored from 038d4c9588. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/15953-verify-unique-scope-family.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/15953-verify-unique-scope-family.md b/.changeset/15953-verify-unique-scope-family.md index fa04fafb3cc..b1c6a6f48bd 100644 --- a/.changeset/15953-verify-unique-scope-family.md +++ b/.changeset/15953-verify-unique-scope-family.md @@ -10,9 +10,9 @@ Clause-②: yes A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. -- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (`signUp`, then `POST /auth/organization/create`) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. +- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. -A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none, the same record with only the probed value changed is admitted, and no unique `indexes[]` entry covers the field. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it; a unique `indexes[]` entry over the same column refuses the same write naming no field either, so there a field-less refusal is not attributed. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. +A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. From deb5c6ecc9c49d9f29797db2611f7fd724e12379 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:51:48 +0000 Subject: [PATCH 15/16] docs(changeset): carry the unique-scope family's two corrections in this slice's note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the maintainer's ruling B, slice 3's note stays as written and this slice's own changeset says what changed under it: a field-less unique refusal is now unattributed where a unique indexes[] entry covers the probed column, and the second organization is provisioned by signUp, then POST /auth/organization/create. Frontmatter and the Clause-② line unchanged. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/15953-verify-parent-write-reach-family.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.changeset/15953-verify-parent-write-reach-family.md b/.changeset/15953-verify-parent-write-reach-family.md index 3e3abcdc1c0..31b67a49e79 100644 --- a/.changeset/15953-verify-parent-write-reach-family.md +++ b/.changeset/15953-verify-parent-write-reach-family.md @@ -36,3 +36,8 @@ await stack.stop(); ``` **`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + +**The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + +- **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. +- **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. From fc4329f61ab4f50fad904befd03d70e19d2863a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:52:02 +0000 Subject: [PATCH 16/16] docs(verify): the parent write reach exports' comment names the engine's write as the persona The comment above the new exports still said the conjunction is asked "through the REST door", from before the door moved to the engine's own write as the signed-in persona. It now says what the module header, the README, cli.mdx and the changeset say. Comment only. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- packages/verify/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 43401499e7e..90ba308c903 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -130,7 +130,8 @@ export type { // stack declares `controlled_by_parent`, a principal's edit, delete or create // of a detail is admitted exactly when it holds that verb on the detail's // object AND can edit the master (measured, never re-derived), asked of the -// engine through the REST door as verifier and declared-position personas. +// engine's own write as the persona, signed in: verifier and declared-position +// personas. export { deriveParentWriteReachCases, runParentWriteReachProofs,