Skip to content

Commit 965a1bf

Browse files
feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) (#23091)
Part of #15953 Clause-②: yes ## What this is Slice 4 of #15953: the D half of the classification table's row 12 (comment 6115219467), plus the four acceptance notes slice 3's review left (6128105426). It adds the parent-derived write reach family to `@objectstack/verify`, runs it from `os verify --parent-write-reach` (opt-in), and documents it. Row 12's K residue (`sales_manager`'s grant) stays in hotcrm; its R residue (the spec gate on a hierarchy scope) belongs to `packages/spec` and is not in this PR. #15953 stays open for its remaining rows. **Property, for any app.** ADR-0055 section 3 and its 2026-09-07 amendment: editing, deleting or creating a detail of a `controlled_by_parent` object requires edit access to its master, walked hop by hop to the first master that governs its own rows; the detail's own object permission is the first door. For a principal P, a detail record D under a master record M, and a verb (edit, delete, create), the write is expected admitted exactly when both halves hold: - P holds the verb on D's object; - P can edit M. ## The family (`packages/verify/src/parent-write-reach.ts`) - **Population.** Every object the spec's own parse (`ObjectSchema.safeParse`) declares `sharingModel: 'controlled_by_parent'`, with exactly one `master_detail` field (ADR-0055 section 1), read through `referenceCarrierOf`. The chain is walked through each master's own parse until a master that governs its own rows. - **Published sources each expectation is read from.** - The detail half: 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 `objectPermissionGrants` (`@objectstack/spec/security`). Nothing re-merges permission sets here. - The master half: 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. Measured at REST first: an empty `PATCH` is refused by the object gate (no edit grant), by the record gate (edit grant, another owner, no all-records grant: `You do not have access to this record`) and by the master gate, and admitted on the caller's own record. - The rule: ADR-0055 section 3 and the amendment. No sharing, ownership, hierarchy or chain resolution is copied: the master half is the engine's own answer on M. - **Door.** The engine's write as the signed-in principal: the handle's `hooks.run` with `as: token`, the `insert`, `update` by id and `delete` by id the REST data ingress makes, with the caller's context, the security middleware and the bound hooks included. See Deviations for why not the REST route itself. The system door bypasses authorization by design and is out of this family's population. - **Principals.** - Three verifier personas whose permission sets the family authors from the app's own objects, registered at boot by `parentWriteReachSecurity(config)` and granted by name (ADR-0131 D3/D4, the RLS family's probe-persona path): - `master-all-records`: the root master by the all-records grant (`modifyAllRecords`), every verb on the details; - `detail-only`: the root master readable (`viewAllRecords`) but not editable, every verb on the details; - `master-all-records-no-delete`: the all-records grant, no delete on the details. - One persona per declared position, through the RLS family's exported `provisionRlsPositionPersona` and `declaredPositionNames`: its whole capability is what the app binds to the position. - The permission sets each persona holds are reported from the server's own answer. - **Fixtures.** Reused `deriveCrudCases` / `fillRelationalRefs` and slice 3's required-fields fixture shape, through the seed door. Per persona, two master chains: one another principal owns (so the all-records grant, not ownership, is what is exercised) and one the persona owns. Under each, a detail another principal owns and one the persona owns. - **Probes.** Per persona and master chain: the master read (a chain the persona cannot read is outside its reach, counted, not probed: a write to a record the caller cannot read is the RLS family's subject), the master's empty update, then edit and delete of each detail and one create. - **Attribution.** A refusal holds only as `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) is `unattributed`. A write the door admits is checked on the record (an edit moves `updated_at`, a delete removes the row, a create holds the master), and a refusal whose record shows the write is contradicted. - **Exclusions, with reasons, counted.** No `master_detail` field (the runtime would still derive through a required lookup, its own precedence, not compared here); two of them; a master the app does not declare; a cycle; a chain reaching the write gate's depth bound (eight controlled-by-parent masters, ADR-0055's amendment). Not derivable: the detail or a master in its chain cannot be synthesized. - **Report.** Per object `proven` / `contradicted` / `failed` / `inconclusive` / `excluded` / `not-derivable`; per persona, run or not run with the reason. `summary.hardFailures` is `contradicted + failed` objects plus personas that could not be provisioned. `os verify --parent-write-reach` boots its own fresh stack with `parentWriteReachSecurity(config)`, adds `parentWriteReach.summary.hardFailures` to the exit status, and emits `parentWriteReach` under `--json`. Without the flag, nothing changes. ## Finding: today's engine refuses every write to a detail two levels below its root master On this tree's engine, a detail whose master is itself `controlled_by_parent` (a quote line under a quote under an account) is refused every edit, delete and create for every non-admin principal, with `403 PERMISSION_DENIED`, `master 'X' not editable by this user (row-level security)`, while the same principal's update of that middle master is admitted. This holds when the principal owns the whole chain and when it holds `modifyAllRecords` on both masters. - Measured through REST on the fixture app: `PATCH /api/v1/data/pwr_quote/ID` with an empty body answers 200 and `PATCH /api/v1/data/pwr_quote_line/ID` answers 403, as the same principal. - On hotcrm's real chain (`crm_quote_line_item` under `crm_quote` under `crm_account`), hotcrm's pinned 17.7.0 engine admits all 15 such writes for `sales_manager` and `sales_rep`; this tree's engine refuses all of them. **Corrected after #23093's analysis (`6133315184`):** this is not a regression. The released 17.7.0 engine refuses the same writes for an organization member. Run A admitted them because 17.7.0's `bootStack` ran organization-less personas (removed by #22186). The maintainer ruled A on #23093 (`6133922339`): the middle hop's ownership floor is lifted, so `contradicted` is the family reading today's engine against the contract as ruled. - The seam: ADR-0055's amendment says a detail whose whole chain is reachable stays writable; the hop runs `assertMasterRowEditable` (`packages/plugins/plugin-security/src/security-plugin.ts`) on the middle master, whose write filter re-read under the caller's context answers no row. No engine change here, per the slice's ruling: the family reports it (`contradicted`), the fixture suite pins what the engine answers today (it flips to proven when the chain walk agrees with the middle master's own write), and the README and `cli.mdx` state that an app declaring such a chain fails `--parent-write-reach` until then. The report on #15953 carries it for filing. ## Evidence **Fixture app** (`packages/verify/src/parent-write-reach.test.ts`, `Tests 10 passed (10)`): - Population (no boot): every exclusion with its reason, the chain hops, and the depth bound (eight controlled-by-parent masters above an object: excluded; seven: walked). - Booted with `parentWriteReachSecurity`: 3 objects (two single-level, one two-level), 5 personas run (3 verifier, 2 position), 27 reaches probed, 3 outside the rep's read reach, 135 writes (54 edit, 54 delete, 27 create), 104 held. Both single-level objects proven for every persona under both master chains. The two-level object contradicted on 31 writes (the finding above), its refusals still held where the conjunction refuses. - hotcrm's contract shape: the manager edits another principal's contract through the account by the all-records grant (detail another's and its own), is refused the delete (`PERMISSION_DENIED`, `details` `delete` on the contract, with the master's edit measured admitted, so the refusal is the contract's own permission), creates one; the rep reads none of another principal's accounts (outside its reach) and on its own account edits the master and is still refused every contract write (the object gate). - **Negative controls** (no engine change): - a detail hook refusing every edit and delete with the platform's own envelope where the master is editable: contradicted on 4 of 4 writes per chain for `master-all-records`, held for `detail-only`; - a master hook refusing every edit of the master that way, so the master reads not editable while nothing stops its details: every detail write admitted and contradicted, for `master-all-records` on both chains. - **Attribution control:** a refusal naming another object (`PERMISSION_DENIED`, `details` `update` on the account) and an app's own code (`RECORD_LOCKED`, 409) are `unattributed`, never held, never contradicted. - A stack booted without `parentWriteReachSecurity` reports the three verifier personas not run, with the reason, counted in `hardFailures`; the position personas still run. **CLI** (`packages/cli/test/verify-parent-write-reach.test.ts`, spawned, `Tests 3 passed (3)`): without the flag no `parentWriteReach` key and exit 0; with it the contract proven by 4 personas and exit 0; with a hook refusing the contract's edits with the platform envelope, contradicted, `hardFailures` 1, exit 1. **Ablation of the family's own comparison** (`judge`), through `scripts/ablation-replace.mjs` (wrap mode) inside a scratch script with `trap restore EXIT INT TERM` on absolute paths, at `8b4a5802f0` (first taken at `fa8171ea2c` with the same result). No build involved: the test imports the module by a relative path. - **A.** `if (probe.expected === 'refused') return { outcome: 'held' };` replaced by an unconditional `held` plus the marker `ABLATION_15953_S4_A`. On disk: anchor 1 to 0, marker 0 to 1, blob `560c6d69` to `a6218f46`. RED: `Tests 4 failed | 6 passed (10)`: both negative controls, the two-level pin and the count that reads it. Restored: blob `560c6d69` equals HEAD, `git diff HEAD` empty. - **B.** The same on `if (probe.expected === 'allowed') …` (marker `ABLATION_15953_S4_B`). On disk: anchor 1 to 0, blob `560c6d69` to `53cb38f1`. RED: `Tests 1 failed | 9 passed (10)`, exactly the second negative control. Restored to HEAD's blob, empty diff. - Predicted direction for both: red. Observed: red. **hotcrm, the first consumer.** Read-only clone at `1d7148bf`, fetched first: `main` is at `0f84c9c7`, and `test/contract-write-depth.test.ts` and every `controlled_by_parent` / permission declaration are unchanged there. The clone's `git status` stayed empty. `os verify` cannot boot hotcrm on this tree (protocol `^17.7.0` versus 18), so the family ran directly over slice 3's artifact (hotcrm's own CLI 17.7.0) and its `os migrate meta --from 17` snapshot (only `engines.protocol` moved to `^18`, and the 76 `email_template.category` tombstones the migration lists for hand application removed in memory). | run | engine, hooks | personas run | proven | contradicted | inconclusive | excluded | writes (edit / delete / create) | held | |---|---|---|--:|--:|--:|--:|---|--:| | A | hotcrm's pinned 17.7.0 `bootStack` over its own artifact, hooks live | 12 position (the 3 verifier personas not run: 17.7.0 has no `permission_set` grant column) | 4 | 0 | 1 | 3 | 70 / 70 / 35 | 137 | | B | this tree, the snapshot with the artifact's 44 hook bodies grafted by name | 15 (3 verifier, 12 position) | 2 | 1 | 2 | 3 | 130 / 130 / 65 | 224 | | C | this tree, the snapshot with hooks inert | 15 | 4 | 1 | 0 | 3 | 130 / 130 / 65 | 294 | - **Proven:** `crm_contact` (A, B, C), `crm_quote` (A, B, C), `crm_opportunity_line_item` (A, C), `crm_contract` (C), `crm_quote_line_item` (A only: hotcrm's pinned engine). - **Contradicted:** `crm_quote_line_item` on this tree, 31 writes in B and in C, for every persona that edits the quote (the finding above). - **Inconclusive, with the app's own reasons:** `crm_contract` in A and B (hotcrm's `contract_validation` refuses a fixture whose term does not match its dates); `crm_opportunity_line_item` in B (its `opportunity_line_item_price_fill` hook refuses four creates on another object: `unattributed`, never read as the detail's own). - **Excluded:** `crm_campaign_member`, `crm_event_attendee`, `crm_article_feedback` (no `master_detail` field; see the acceptance notes). - 85 persona and master-chain pairs outside a persona's read reach in each run, counted, not probed. **Against what row 12's D half asserted** (run C, every hotcrm hook inert; run B has the same verdicts where its fixtures could be written): - "the manager changes the value of a contract standing in a rep's name" and "terminates it": `position:sales_manager` on a contract another principal owns, under an account another principal owns, reached by the all-records grant alone: edit allowed, held. - "and still edits their OWN contract": the same, on a contract the manager owns: held. - "the rep still cannot edit the contract they own — the object gate, unchanged": `position:sales_rep` edits its own account (measured admitted) and is refused every contract write with `PERMISSION_DENIED` naming the contract and the operation: held. - "DELETE stays refused": the manager's delete refused with `PERMISSION_DENIED`, `details` `delete` on `crm_contract`, while the manager's update of the account is measured admitted, so what refuses it is the contract's own permission lacking delete: held. - 0 contradictions with what the file asserted. Its "does not leak to another object" describe (`crm_case`, not controlled by its parent) is outside this family. ## Carried acceptance notes (slice 3's review, 6128105426) - **a. Attribution tightening** (`591513f0f5`). `judge` in `unique-scope.ts` reports a field-less unique refusal `unattributed` when a unique `indexes[]` entry covers the probed column, for insert and update. The control: a field whose own index cannot be created (the ADR-0120 D4 two-boot shape) beside a unique composite `indexes[{ fields: [name, kind], unique: 'organization' }]`. Before the change it read `proven`, 4 of 4 writes held with field-less refusals and admitted twins; after, `inconclusive`, 4 of 4 `unattributed`. The README says so, and the release note carries it as a change in this PR's own changeset (patch round 1, `deb5c6ecc9`). The second twin was not built: the entry's other columns may be of any type, and `unattributed` is the safe reading. - **b. `bootStack`'s input** (`a35a11217c`). One docblock sentence in `harness.ts` and one README sentence: the config is `defineStack` output; a raw object literal registers raw fields (an `autonumber` gets no unique and no index). No behaviour change. - **c. The membership-policy sentence** (`aff59344f9`): the README names the `auth.membership_policy` setting beside `OS_AUTH_MEMBERSHIP_POLICY`. - **d. The second-organization wording**: "a sign-up that creates its own" is `signUp`, then `POST /auth/organization/create`. It was first edited into slice 3's unreleased note (`42d15b2d57`). Under the maintainer's ruling B (`6133232470`), that note is restored byte for byte (`347895565e`), and the correction is carried in this PR's own changeset (`deb5c6ecc9`). The same two sentences in `cli.mdx`'s `os verify` section take the a and d corrections too (`06ed55ddbe`). ## Verification At head `8b4a5802f0`: this branch with `origin/main` `038d4c9588` merged; 13 files and 1954 changed lines (`+1937 / -17`) against that merge base. Readings below are at that head unless another commit is named. **Suites.** - **`@objectstack/verify`:** `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2` reads `Test Files 35 passed (35)` / `Tests 281 passed (281)`, lock verdict `command-exit 0`. The family file alone reads `Tests 10 passed (10)`; `unique-scope.test.ts` reads `Tests 13 passed (13)`. - **Typechecks:** `pnpm --filter @objectstack/verify typecheck` and `pnpm --filter @objectstack/cli typecheck` exit 0, `check:test-typecheck: OK` for both. `tsc --listFilesOnly -p tsconfig.test.json` shows the family module and its test in verify's program (2 hits) and `verify-parent-write-reach.test.ts` in the CLI's (1 hit; control `verify-unique-scopes.test.ts`, 1 hit). - **CLI integration tier** over the seven `verify-*` files, this PR's new one included: `Test Files 7 passed (7)` / `Tests 21 passed (21)`. The rest of the integration tier is declared to CI. - **CLI unit tier** at `0cc4adcb16`: `Test Files 283 passed (283)` / `Tests 4270 passed (4270)`. No `packages/cli` file changed after it. - **Core `repo` project** (the `#22661` second-object read pin among its 6 files): `Test Files 6 passed (6)` / `Tests 83 passed (83)`, with no `CALLERS` row: the family never calls the reference-target arbiter. - **`pnpm check:verify-stand-in`:** before the rows exit 1, `2 problem(s)` (`runParentWriteReachProofs`, `formatParentWriteReachReport`); after, exit 0, `OK check-verify-stand-in-erasure: 2 stand-in check(s) guarded, 15 candidate(s) explicitly exempt, 10 call site(s) reached, 0 asserted driver arguments`. `--self-test` OK. **Gates.** - **Derivation:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, at `8b4a5802f0`: 118 commands (67 `pnpm`, 51 direct `node`). - **Results:** all 118 run, each exit code captured before any pipe. 117 exit 0. `node scripts/check-empty-changeset.mjs --base origin/main` exited 1 at `8b4a5802f0`, by design (see the next section). It exits 0 at `7e11653135`, after patch round 1. - **Patch round 1, at `7e11653135`** (the merge of `origin/main` `2d21b9c891`): - `@objectstack/verify`: 35 files, 281 tests. - `@objectstack/cli` unit: 285 files, 4280 tests. - Both typechecks are OK. - 118 gates derived, 118 run, 0 NOT MEASURED. - `check-empty-changeset` exits 0. `--ran` reads `Run reconciliation — 118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN.` - A first battery at `0cc4adcb16` recorded 3 `PREREQUISITE NOT MET` (exit 3: `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage`, each reading packages outside the built closures). After `turbo run build --filter=!@objectstack/docs` (73 tasks, 66 cached) the final battery measured all three, exit 0. - `origin/main` moved 4 commits after the merge (to `0aef788816`), none on this PR's paths. One of them makes `/auth/me/permissions` answer its closed body on a fault, the endpoint this family reads; the family reads an answer naming no permission set as no grants to read (`63d869dc02`). - Every command with its printed verdict line is in the report comment on #15953. **Lint, by proven narrowing** at `8b4a5802f0`: `eslint --no-inline-config --format json` over the 9 touched source files. 1. **Population, read from eslint's own config:** `--print-config` resolves for 9 of 9. 2. **Counts, read from the JSON:** 9 files, 0 errors, 0 warnings. 3. **Invariance:** `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules, its own comment at `:327-328`), so this diff cannot move any untouched file's verdict. The `.md` / `.mdx` files are outside eslint's population. ## The pending release note (ruled B; resolved in patch round 1) `check-empty-changeset` refuses this PR by design: it changes `.changeset/15953-verify-unique-scope-family.md`, which is on the merge base and was not added here. At `8b4a5802f0` this was its DELIBERATE CORRECTION class, not a collision: - the note is slice 3's own, for the same card, and still unreleased (`pre.json` lists no consumed changeset); - this PR changes the behaviour it describes (carried note a): its attribution sentence said a field-less refusal is the field's once the twin is admitted, which is no longer true where a unique `indexes[]` entry covers the field; - and it takes carried note d (`signUp`, then `POST /auth/organization/create`). Both edits were ordered by the dispatch for this slice. Superseded in patch round 1: the maintainer ruled B (`6133232470`), so this note is restored byte for byte from the merge base (`347895565e`), both corrections are carried as sentences in this PR's own changeset (`deb5c6ecc9`), and `check-empty-changeset` now exits 0. ## Acceptance notes Noted, not filed (none is a reproducible defect through a public door, a contract violation or an authoring trap): - The runtime and the lint accept a required `lookup` as a `controlled_by_parent` master (the third tier of `resolveCbpRelation` in plugin-security and of the lint's master candidates), which ADR-0055 section 1 and the spec's `sharingModel` docblock do not state. The family follows the spec and names those objects excluded: hotcrm's `crm_campaign_member`, `crm_event_attendee` and `crm_article_feedback`, 3 of its 8. Carrier: none. - A generic fixture cannot satisfy every app rule: with hotcrm's hooks live, `contract_validation` refuses a contract whose term does not match its dates, so `crm_contract` reads inconclusive there and is proven with the hooks inert. Carrier: none. - `fixtureBody` and its helpers are now copied in three family modules (delete-behavior, unique-scope, this one), each module-private. A shared one would live in `derive.ts`, outside this claim. Carrier: none. - `provisionRlsPositionPersona` writes the reason "minted by `objectstack verify --rls`" on the position row it creates, whichever family calls it. Stored on an ephemeral stack only. Carrier: none. ## Deviations - **The door.** PM mechanism assumption 5 named the REST data route; assumption 4 requires a refusal to name the detail object and the operation. Measured, the REST envelope cannot: it names the ROUTE's object on every refusal (an app hook refusing with another object named comes back naming the route's) and carries no operation. On hotcrm with hooks live that turned `crm_opportunity_line_item`'s `opportunity_line_item_price_fill` hook, refused on another object, into four false contradictions at REST. The family therefore writes through the engine as the signed-in principal, the three calls the REST ingress makes with the caller's context, where the refusal keeps its own `details`; there the same four read `unattributed`. The two-level finding was measured at REST as well. - **Assumption 2's object-gate half** is read from the server's effective permissions (`/auth/me/permissions`), not re-merged from the declared sets: it is the one merge of the sets the principal holds (ADR-0124 D4), and a second copy of it is what ADR-0124 refuses. - Conditional surfaces: two `NOT_A_STAND_IN` rows (the gate asked); the `#22661` `CALLERS` row was not needed (the family never calls the reference-target arbiter; the core `repo` project is green without one). - `origin/main` (`038d4c9588`, one commit, not on this surface) was merged before the PR, per AGENTS.md section 10; the build was refreshed and every suite and gate here ran on the merged tree. - Commits carry the model-free trailer pair per AGENTS.md, and this body ends with its session-URL footer rather than the harness's own attribution form. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 252a582 commit 965a1bf

12 files changed

Lines changed: 1941 additions & 15 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes
9+
10+
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.
11+
12+
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:
13+
14+
- 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`;
15+
- 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.
16+
17+
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`.
18+
19+
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.
20+
21+
**`@objectstack/verify` — new exports.**
22+
23+
- `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.
24+
- `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile.
25+
- `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`.
26+
- Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`.
27+
28+
```ts
29+
import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify';
30+
31+
const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) });
32+
const report = await runParentWriteReachProofs(stack, myApp);
33+
console.log(formatParentWriteReachReport(report)); // report.personas names who wrote
34+
expect(report.summary.hardFailures).toBe(0);
35+
await stack.stop();
36+
```
37+
38+
**`@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.
39+
40+
**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:
41+
42+
- **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.
43+
- **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.

‎content/docs/deployment/cli.mdx‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,6 +1925,7 @@ os verify --rls --multi-tenant --json # Org-scoped boot, structured rep
19251925
os verify --reference-shapes # Also prove reference fields refuse non-id values
19261926
os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does
19271927
os verify --unique-scopes --multi-tenant # Also prove each declared unique collides where its scope says
1928+
os verify --parent-write-reach # Also prove each controlled_by_parent detail write follows its master
19281929
```
19291930
19301931
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
19721973
and a second one is offered it, by insert and by update, through the REST
19731974
door and the system door. Within one organization the second holder must be
19741975
refused with the platform's unique refusal, attributed to the field (it
1975-
names the field, or it names none and the same record with only that value
1976-
changed is admitted). With `--multi-tenant` the run also provisions a second
1977-
organization (a sign-up that creates its own) and offers it the value: a
1976+
names the field, or it names none, the same record with only that value
1977+
changed is admitted, and no unique `indexes[]` entry covers the field). With
1978+
`--multi-tenant` the run also provisions a second organization (`signUp`,
1979+
then `POST /auth/organization/create`) and offers it the value: a
19781980
`true` or `'organization'` field must admit it, and a `'global'` field, or
19791981
any field of an object with no organization column, must refuse it. Without
19801982
`--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
19851987
only. A second holder met wrongly fails the run; a refusal that is not the
19861988
field's own constraint (an app hook's dedupe, a rule, a permission) is
19871989
reported inconclusive, never proven.
1990+
With `--parent-write-reach`, another fresh boot proves ADR-0055's write rule
1991+
for every object declared `controlled_by_parent` (exactly one
1992+
`master_detail` field names its master): editing, deleting or creating a
1993+
detail is admitted exactly when the caller holds that verb on the detail's
1994+
object **and** can edit its master. The first half is read from the
1995+
server's effective permissions for the caller (`/auth/me/permissions`); the
1996+
second is measured, by an empty update of the master as the caller, which
1997+
runs every write gate the master has — for a master that is itself
1998+
controlled by its parent, the gate above it too. The writes go through the
1999+
engine as the signed-in caller (the insert, update and delete by id the
2000+
REST data route makes, with the caller's context), where a refusal keeps
2001+
the object and the operation it names — the REST envelope names the
2002+
route's object whatever was refused; the system door bypasses
2003+
authorization and is not asked. They are made by three verifier
2004+
personas whose permission sets the run registers from the app's own objects
2005+
(the master's root by the all-records grant `modifyAllRecords` with the
2006+
detail's every verb; the master readable but not editable; the all-records
2007+
grant with no detail delete) and by one persona per declared position, each
2008+
under a master chain another principal owns and one it owns. A write whose
2009+
answer disagrees fails the run, and so does a persona that could not be
2010+
provisioned; a refusal that is not the platform's `403 PERMISSION_DENIED`
2011+
naming the detail and the operation (a rule, a hook's own code, a refusal
2012+
on another object) is reported inconclusive, never proven. An object
2013+
with two `master_detail` fields, none, or a chain at the write gate's depth
2014+
bound is reported `excluded`. Today's engine refuses every write to a detail
2015+
two levels below its root master (a line under a quote under an account),
2016+
even where the caller edits the middle master, so an app that declares such
2017+
a chain fails the run.
19882018
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
19892019
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
19902020
`group`) asks for the same boot.
@@ -2014,8 +2044,9 @@ document carries depends on where the run ended:
20142044
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
20152045
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`),
20162046
`deleteBehaviors` (with `--delete-behaviors`), `uniqueScopes` (with
2017-
`--unique-scopes`; its `boot` names the legs the run could prove) and
2018-
`hardFailures`, the count
2047+
`--unique-scopes`; its `boot` names the legs the run could prove),
2048+
`parentWriteReach` (with `--parent-write-reach`; its `personas` name who
2049+
wrote and who could not be provisioned) and `hardFailures`, the count
20192050
the exit status is read from.
20202051
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
20212052
when the failure carries them.

‎packages/cli/src/commands/verify.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ import {
2121
formatDeleteBehaviorReport,
2222
runUniqueScopeProofs,
2323
formatUniqueScopeReport,
24+
runParentWriteReachProofs,
25+
formatParentWriteReachReport,
26+
parentWriteReachSecurity,
2427
type VerifyReport,
2528
type RlsReport,
2629
type ReferenceShapeReport,
2730
type DeleteBehaviorReport,
2831
type UniqueScopeReport,
32+
type ParentWriteReachReport,
2933
type RlsProbeDescriptor,
3034
type RlsPositionPersonaInput,
3135
} from '@objectstack/verify';
@@ -99,12 +103,17 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b
99103
* the REST door and the system door, and — on an org-scoped boot
100104
* (--multi-tenant) — admits or refuses one in another organization
101105
* as its declared scope says
106+
* - parent-derived write reach (--parent-write-reach, ADR-0055 §3):
107+
* for every `controlled_by_parent` object, a persona's edit, delete
108+
* or create of a detail, written through the engine as the persona,
109+
* is admitted exactly when it holds that verb on the detail's object
110+
* and can edit the master
102111
*
103112
* Exits non-zero on either stage's failures so it drops straight into CI.
104113
*/
105114
export default class Verify extends Command {
106115
static override description =
107-
'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)';
116+
'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)';
108117

109118
static override examples = [
110119
'<%= config.bin %> verify',
@@ -113,6 +122,7 @@ export default class Verify extends Command {
113122
'<%= config.bin %> verify --reference-shapes',
114123
'<%= config.bin %> verify --delete-behaviors',
115124
'<%= config.bin %> verify --unique-scopes --multi-tenant',
125+
'<%= config.bin %> verify --parent-write-reach',
116126
];
117127

118128
static override flags = {
@@ -136,6 +146,10 @@ export default class Verify extends Command {
136146
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)",
137147
default: false,
138148
}),
149+
'parent-write-reach': Flags.boolean({
150+
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)",
151+
default: false,
152+
}),
139153
'multi-tenant': Flags.boolean({
140154
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)',
141155
default: false,
@@ -216,6 +230,7 @@ export default class Verify extends Command {
216230
'reference-shapes': boolean;
217231
'delete-behaviors': boolean;
218232
'unique-scopes': boolean;
233+
'parent-write-reach': boolean;
219234
'multi-tenant': boolean;
220235
json: boolean;
221236
}): Promise<void> {
@@ -359,6 +374,19 @@ export default class Verify extends Command {
359374
}
360375
}
361376

377+
// The parent-derived write reach proofs run on a fresh stack of their own,
378+
// booted with the verifier personas' permission sets registered: they
379+
// sign up personas, and they edit, delete and create details on purpose.
380+
let parentWriteReach: ParentWriteReachReport | undefined;
381+
if (flags['parent-write-reach']) {
382+
const parentStack = await bootStack(config, { multiTenant, hostRoot, security: parentWriteReachSecurity(config) });
383+
try {
384+
parentWriteReach = await runParentWriteReachProofs(parentStack, config);
385+
} finally {
386+
await parentStack.stop();
387+
}
388+
}
389+
362390
// Failure contract: a "real" runtime break the app's author must see.
363391
// A degraded RLS probe counts: the run reported verdicts it could not have
364392
// established, which is worse than no verifier at all.
@@ -387,19 +415,23 @@ export default class Verify extends Command {
387415
// A second holder a declared unique scope met wrongly, or a door that
388416
// faulted on one. A leg this boot cannot run is reported on the report's
389417
// `boot`, not counted.
390-
(uniqueScopes?.summary.hardFailures ?? 0);
418+
(uniqueScopes?.summary.hardFailures ?? 0) +
419+
// A detail write whose answer disagreed with the conjunction, a door
420+
// that faulted on one, or a persona that could not be provisioned.
421+
(parentWriteReach?.summary.hardFailures ?? 0);
391422

392423
if (flags.json) {
393424
// `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the
394425
// report has to leave through the real stream — and awaiting the write
395426
// drains a report larger than one pipe buffer before `this.exit` below.
396-
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, hardFailures });
427+
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, uniqueScopes, parentWriteReach, hardFailures });
397428
} else {
398429
this.log(formatReport(crud));
399430
if (rls) this.log(formatRlsReport(rls));
400431
if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes));
401432
if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors));
402433
if (uniqueScopes) this.log(formatUniqueScopeReport(uniqueScopes));
434+
if (parentWriteReach) this.log(formatParentWriteReachReport(parentWriteReach));
403435
this.log('');
404436
this.log(
405437
hardFailures > 0

0 commit comments

Comments
 (0)