Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a35a112
docs(verify): bootStack takes defineStack output; a raw literal regis…
claude Oct 12, 2026
aff5934
docs(verify): the membership-policy sentence names the auth.membershi…
claude Oct 12, 2026
42d15b2
docs(changeset): the unique-scope family's second organization is sig…
claude Oct 12, 2026
591513f
fix(verify): a field-less unique refusal is unattributed where a uniq…
claude Oct 12, 2026
28bd69c
feat(verify): the parent-derived write reach family (#15953 slice 4, …
claude Oct 12, 2026
3a48179
feat(cli): os verify --parent-write-reach, and the family's tests (#1…
claude Oct 12, 2026
06ed55d
feat(verify): write through the engine as the persona, so a refusal k…
claude Oct 12, 2026
1aad8e3
test(verify): the run without the verifier sets uses the single-level…
claude Oct 12, 2026
7a95beb
test(verify): the second negative control — details admitted under a …
claude Oct 12, 2026
fa8171e
chore(gates): classify runParentWriteReachProofs and formatParentWrit…
claude Oct 12, 2026
b3b9407
Merge remote-tracking branch 'origin/main' into claude/issue-15953-s4…
claude Oct 12, 2026
0cc4adc
feat(verify): the no-master_detail exclusion names the runtime's requ…
claude Oct 12, 2026
63d869d
fix(verify): read no grants from an empty permission-set answer
claude Oct 12, 2026
8b4a580
docs(verify): a persona whose effective-permission answer names no se…
claude Oct 12, 2026
3478955
chore(changeset): restore slice 3's unique-scope note byte for byte f…
claude Oct 12, 2026
deb5c6e
docs(changeset): carry the unique-scope family's two corrections in t…
claude Oct 12, 2026
fc4329f
docs(verify): the parent write reach exports' comment names the engin…
claude Oct 12, 2026
7e11653
Merge origin/main (2d21b9c891) into claude/issue-15953-s4-parent-writ…
claude Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/15953-verify-parent-write-reach-family.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@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.

**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.
41 changes: 36 additions & 5 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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": "<sentence>" }`, plus `code` and `httpStatus`
when the failure carries them.
Expand Down
38 changes: 35 additions & 3 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, 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.
*/
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',
Expand All @@ -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 = {
Expand All @@ -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 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({
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,
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading