Skip to content

Commit 9f4b652

Browse files
feat(verify): the delete-behaviour derived proof family, runnable as os verify --delete-behaviors (#15953 slice 2) (#22953)
Part of #15953 Clause-②: yes Slice 2 of the card: the `deleteBehavior` derived proof family (classification table `6115219467`, rows 1-3, the D halves) in `@objectstack/verify` beside `runCrudVerification`, runnable as the opt-in `os verify --delete-behaviors`, with its docs. The card stays open for its remaining rows; the K residues of rows 1-3 stay in hotcrm, and row 3's R residue (the required `set_null` escalation) belongs to `packages/objectql` tests. **The property.** Every `deleteBehavior` a reference field declares is what a by-id delete of the referenced record does: `cascade` removes the holder, `set_null` keeps the holder and clears the link, `restrict` refuses the delete. ## How the family reads the promise, and asks the engine - **Population.** Every field of the app's objects (read through `declaredCollection`, so a `packages[]` artifact is covered) whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), with its target from `referenceTargetOf`. - **The promise comes from a published source only.** The declaration is the field as the spec's own parse produces it: `FieldSchema.safeParse` from `@objectstack/spec/data`, which is the value a built artifact carries. Measured on this tree: a `lookup` or `tree` that authors nothing parses to `set_null`, an authored value is kept, a bare `master_detail` and a `user` parse with no key, and an authored `set_null` on a `master_detail` is a parse refusal. What each value means is the enum's own meaning ("What happens if referenced record is deleted") as `content/docs/data-modeling/field-types.mdx` states it. No engine resolution rule is copied into the family. - **Excluded, named, and never given an expectation** (the dispatch's route (a)): - a `set_null` on a required reference: a required reference cannot be cleared, so what a delete does there is the engine's escalation rule, not the declaration (row 3's R residue); - a field the parse gives no `deleteBehavior`: a bare `master_detail`, where the engine's default decides, and a `user`; - a field that does not parse under `FieldSchema`, with the first issue. - **Not derivable, named:** the target is not an object the app declares (such as `sys_user`); the field is `readonly` or `system` (the REST door drops a caller's value there, and fixtures are made through it); or the CRUD derivation cannot synthesize the holder or the target object. - **Doors.** The target is deleted by id through two doors: the REST data route as the signed-in caller (`DELETE /api/v1/data/OBJECT/ID`, the door an app's user deletes through), and the engine's own delete as the system principal (`hooks.run(object, 'delete', { id }, { system: true })`, the door an integration or a system job deletes through). Only by-id deletes are asked: that is the engine's cascade scope, and a predicate or multi-row delete is outside this family. - **The probe.** Per field there is one bystander, a second holder that references a different record of the target object. Per door there is a fresh target record and a fresh holder referencing it. After the delete, the target, the holder and the bystander are read back through the handle's system read. A holder that does not reference its target after creation (a hook or default changed it) leaves the probe `not-run`. - **Fixtures** reuse `deriveCrudCases` and `fillRelationalRefs` from `derive.ts`, cut down to the fields the object requires plus the probed reference. A record's synthesized dates are one day apart in declaration order, and unique-constrained values are made distinct. They are created through the REST door as the same caller. **Throwaway stack:** the CLI boots a fresh stack for the family and stops it; the family does not clean up, and every `restrict` probe leaves its rows. - **Attribution.** A `restrict` holds only on `DELETE_RESTRICTED` naming the holder's object as `dependentObject`, the structured half of the ADR-0112 envelope. Any other refusal, such as a hook's veto, a validation rule or a permission, is `unattributed` and leaves the field inconclusive, never `proven`. No refusal prose is parsed. - **Statuses** per field: `proven`, `contradicted` (hard failure), `failed` (a door faulted; hard failure), `inconclusive`, `excluded`, `not-derivable`. `hardFailures = contradicted + failed`. ## Controls and ablation - **Negative control** (`packages/verify/src/delete-behavior.test.ts`): a fixture stack whose target `beforeDelete` hook rewrites the dependents before the engine's referential pass (it detaches the `cascade` and `restrict` holders and deletes the `set_null` one). All three are reported `contradicted` through both doors, and `hardFailures` is 3. Through the CLI as well: `packages/cli/test/verify-delete-behaviors.test.ts` exits 1 with `hardFailures` 1 on a hook that undoes a cascade. - **Attribution control:** a target whose hook refuses every delete with its own `VALIDATION_FAILED`. Its `restrict` and `cascade` holders read `inconclusive`, neither `proven` nor `contradicted`. - **Ablation of the family's own comparison**, with `scripts/ablation-replace.mjs` in wrap mode inside a scratch script carrying its own `trap restore EXIT INT TERM` on absolute paths: - first attempt: a non-run. The tool refused because the replacement re-contained the anchor text (anchor count 1 to 1), restored, and ran no test; - second attempt: anchor in `judge` (the bystander check) replaced with an early `return { outcome: 'held' }` plus a marker. Anchor 1 to 0, marker 0 to 1, blob `21b48c85` to `c0431325`, marker count 1 on disk during the run; - red: `Tests 2 failed | 6 passed (8)`, the NEGATIVE CONTROL and the counts test (`cascade_ref`: expected `contradicted`, received `proven`); - restored: blob `21b48c85` equals HEAD and `git diff HEAD` is empty, proven by the tool and again by the script's trap; - green from the committed tree at `f97985a9f5`: `Tests 8 passed (8)`. ## First consumer: hotcrm (read-only clone at hotcrm `main` `1d7148bf`, nothing written there) `os verify` cannot boot hotcrm on this tree: hotcrm pins protocol `^17.7.0` and this tree is 18. So, as slice 1 did, the family was run directly, three ways: | run | engine | input | doors | proven | contradicted | inconclusive | excluded | not derivable | deletes | |---|---|---|---|--:|--:|--:|--:|--:|---| | A | hotcrm's pinned `@objectstack/verify` 17.7.0 | hotcrm's artifact built by its own CLI 17.7.0 (hook bodies live) | REST (17.7.0's `hooks.run` has no system door) | 29 | 0 | 6 | 6 | 15 | 35: 29 held, 2 unattributed, 4 not run | | B | this tree | `os migrate meta --from 17 --out` snapshot, `engines.protocol` moved to `^18`, the artifact's 44 hook bodies grafted back by name (the JSON snapshot drops them) | REST and system | 29 | 0 | 6 | 6 | 15 | 70: 58 held, 4 unattributed, 8 not run | | C | this tree | the same snapshot, hooks inert | REST and system | 34 | 0 | 1 | 6 | 15 | 70: 68 held, 2 not run | 56 reference fields; probed: 10 `cascade`, 25 `set_null`, 0 `restrict` (hotcrm declares no explicit `restrict`). **Against what the retired files asserted (the D halves):** - Row 1, `campaign-member-cascade.test.ts`: `crm_campaign_member.crm_lead` and `.crm_contact` (`cascade`) are proven: the target goes and the member row goes with it. The "leaves every membership that named somebody else alone" case is the bystander, untouched on every probe. `crm_campaign` (required, `set_null`) is excluded; it is the K residue and row 3's R rule. - Row 2, `event-attendee-cascade.test.ts`: `crm_event_attendee.crm_contact` (`cascade`) is proven. `crm_event_attendee.crm_lead` is inconclusive: hotcrm's own attendee rule refuses the fixture, because the synthesized `attendee_type` names the contact party and a generic fixture cannot pick the type that matches the probed party. `crm_event_attendee.sys_user` is not derivable (a platform target). `crm_event` (required, `set_null`) is excluded. - Row 3, `line-item-cascade.test.ts`: `crm_opportunity_line_item.crm_opportunity` and `crm_quote_line_item.crm_quote` (`master_detail`, `cascade`) are proven. `crm_product` (required, `set_null`) is excluded; it is the R residue. - **0 contradictions** with the retired pins on every reached field. 5 of the 7 cascades the three files pinned end to end are proven; the other 2 are named above with their reasons. The six other inconclusive fields in runs A and B are crm_contract (twice; a hook ties the term to the date range), `crm_lead.duplicate_of_lead` (a hook rewrites the holder's link), and `crm_opportunity.primary_contact` and `crm_quote.crm_contact` (hotcrm's `contact_integrity` hook vetoes the contact delete with its own `DELETE_RESTRICTED`, and the family reads it as unattributed, not as the relation). With hooks inert (run C), the last four hold. ## The showcase, through the real door `os verify --app examples/app-showcase/objectstack.config.ts --delete-behaviors --json` on this tree exits 1 with `hardFailures` 1: - 24 reference fields; 8 proven, 1 contradicted, 1 inconclusive, 11 excluded, 3 not derivable; - the contradiction is `showcase_field_zoo.f_tree`, a `tree` field the spec's parse gives `set_null`. Through both doors the delete of its parent answers success and the child keeps the deleted parent's id. That is an engine finding, reported for the seat to file, and the reason the flag stays opt-in (see the docs); - the inconclusive one is `showcase_invoice.contact`: the engine refuses the delete because its own `set_null` clear faults a traversing validation rule on the invoice (fail-closed). The refusal names the relation only in prose, so the family reports it with the reason and does not count it. ## Files - `packages/verify/src/delete-behavior.ts` (+640): the family. - `packages/verify/src/delete-behavior.test.ts` (+332): derivation, positive run, negative and attribution controls. - `packages/verify/src/index.ts` (+22 / -1): the exports. - `packages/cli/src/commands/verify.ts` (+34 / -3): `--delete-behaviors`, on a fresh stack. Its hard failures count in the exit status, and `--json` carries the report as `deleteBehaviors`. - `packages/cli/test/verify-delete-behaviors.test.ts` (+183): the flag through a spawned CLI (integration tier). - `packages/verify/README.md` (+44 / -3) and `content/docs/deployment/cli.mdx` (+18 / -2, the `os verify` section only; cross-lane, declared on #6023). - `scripts/check-verify-stand-in-erasure.mjs` (+3): two `NOT_A_STAND_IN` rows, the gate's own prescribed remedy (declared on #6017). - `packages/core/src/security/second-object-read-exposure.pin.test.ts` (+8): one `CALLERS` row (`not-a-served-read`). The family calls `referenceTargetOf` to name the object whose records it creates and deletes as the addressed object (declared on #6367). - `.changeset/15953-verify-delete-behavior-family.md` (+37): `@objectstack/verify` minor, `@objectstack/cli` minor, `Clause-②: yes`. ## Tests and gates, all at `f2591d6b15` (the PR head, with `origin/main` `e357a441c6` merged in) - `pnpm --filter @objectstack/verify test`: `Test Files 33 passed (33)`, `Tests 258 passed (258)`, lock `VERDICT command-exit 0`. The family's own file: `Tests 8 passed (8)`. - `pnpm --filter @objectstack/verify typecheck` and `pnpm --filter @objectstack/cli typecheck`: exit 0, and `check:test-typecheck: OK` for both. `--listFilesOnly` puts `delete-behavior.test.ts` in verify's `tsconfig.test.json` program and `verify-delete-behaviors.test.ts` in cli's. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 282 passed (282)`, `Tests 4262 passed (4262)`. Same result at `8e531760af`, before the merge. - `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/verify-delete-behaviors.test.ts test/verify-reference-shapes.test.ts`: `Test Files 2 passed (2)`, `Tests 6 passed (6)`. The rest of the integration tier is declared to CI. - `pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2`: `Test Files 6 passed (6)`, `Tests 83 passed (83)`. Before the `CALLERS` row, the second-object read pin was red: `Tests 1 failed | 3 passed (4)`, naming `packages/verify/src/delete-behavior.ts`. - `pnpm check:verify-stand-in`: exit 1 before the rows (2 problems: `runDeleteBehaviorProofs` and `formatDeleteBehaviorReport` unclassified). After them, exit 0: `OK check-verify-stand-in-erasure: 2 stand-in check(s) guarded, 11 candidate(s) explicitly exempt, 10 call site(s) reached, 0 asserted driver arguments`. Its `--self-test` passes. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on `f2591d6b15` derives 117 commands, and all 117 exit 0. `check:dual-build-cjs-loads` and `check:skill-examples` first exited 3 (prerequisite not met: unbuilt packages) and exit 0 once the full build was in place. `--ran` reads: `Run reconciliation — 117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN.` - **Ablation** re-run at `f2591d6b15`: the mutation landed (anchor 1 to 0, blob `5db1a1ef` to `2e0d8c0a`), the run went red (`Tests 2 failed | 6 passed (8)`), the restore was proven (blob equals HEAD, `git diff HEAD` empty), and the restored tree is green (`Tests 8 passed (8)`). - **Lint, a proven narrowing.** `eslint --no-inline-config --format json` over the 7 touched source files: 7 files, 0 errors, 0 warnings (counts read from the JSON). Population: `--print-config` resolves for 7 of 7. Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules, as its own comment states), so the diff cannot move any untouched file's verdict. ## Acceptance notes (observations, not filed) - **Fixture reach on hotcrm.** A generic fixture cannot satisfy every app rule: a term tied to a date range, or a type select that must name the probed party. Those fields read inconclusive with the app's own refusal, never proven. Carrier: none. - **The referential-clear refusal carries no structured relation.** When the engine's `set_null` clear faults a holder's rule, the refusal is a `VALIDATION_FAILED` naming the relation in prose only. The family deliberately does not parse it (showcase `showcase_invoice.contact`). Carrier: none. - **`tree` and `user`.** A `user` field's `deleteBehavior` is not materialized by the spec and is excluded when absent. When authored, its target `sys_user` makes it not derivable, so the family cannot measure whether such a declaration is honoured. - `origin/main` was merged in at `e357a441c6` (no conflicts, no force-push), and every suite above was re-run on the merged tree. ## Out of scope (reported for the seat to file) - **class b**: a `tree` field's `deleteBehavior` is declared but not honoured. The spec materializes `deleteBehavior: 'set_null'` on `tree` (`packages/spec/src/data/field.zod.ts`, the `.overwrite` at about line 2764), and the engine's referential pass walks `master_detail` and `lookup` only (`packages/objectql/src/engine.ts`, `cascadeRelationBehavior`, the type guard at about line 17806). Reach: public door. `DELETE /api/v1/data/showcase_field_zoo/ID` on a parent answers 200 and the child's `f_tree` keeps the deleted id (named producer: `examples/app-showcase/src/data/objects/field-zoo.object.ts:117`). Seam: `spec:FieldSchema.deleteBehavior (tree overwrite) → runtime:ObjectQL.cascadeRelationBehavior`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4267484 commit 9f4b652

10 files changed

Lines changed: 1321 additions & 9 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/verify': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953)
7+
8+
Clause-②: yes
9+
10+
A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal.
11+
12+
- `cascade` holds when the holder is gone with the target.
13+
- `set_null` holds when the holder survives and no longer references the deleted record.
14+
- `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there.
15+
- In every case a second holder, referencing another record of the same target object, must be untouched.
16+
17+
A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`.
18+
19+
Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family.
20+
21+
**`@objectstack/verify` — new exports.**
22+
23+
- `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind.
24+
- `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`.
25+
- Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`.
26+
27+
```ts
28+
import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify';
29+
30+
const stack = await bootStack(myApp);
31+
const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp);
32+
console.log(formatDeleteBehaviorReport(report));
33+
expect(report.summary.hardFailures).toBe(0);
34+
await stack.stop();
35+
```
36+
37+
**`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,6 +1923,7 @@ os verify # Author-time rules, then CRUD ro
19231923
os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant
19241924
os verify --rls --multi-tenant --json # Org-scoped boot, structured report
19251925
os verify --reference-shapes # Also prove reference fields refuse non-id values
1926+
os verify --delete-behaviors # Also prove each declared deleteBehavior is what a delete does
19261927
```
19271928
19281929
1. **Author-time rules.** The rule registry `os validate` runs, over the stack
@@ -1951,6 +1952,20 @@ os verify --reference-shapes # Also prove reference fields ref
19511952
door; a field that admits one fails the run. It is opt-in because the engine
19521953
still admits wrong-shaped elements on a `multiple: true` `lookup` or `user`
19531954
field, so an app that declares one would fail until the engine refuses them.
1955+
With `--delete-behaviors`, another fresh boot proves that every
1956+
`deleteBehavior` a reference field declares is what a delete of the
1957+
referenced record does: per field, a fresh target and a holder referencing
1958+
it, then the target is deleted by id through the REST door and the system
1959+
door. `cascade` must remove the holder, `set_null` must keep it with the
1960+
link cleared, `restrict` must refuse the delete naming the holder's object,
1961+
and a holder of another record must be left alone; a delete that does
1962+
anything else fails the run. The declaration is read as the spec's parse of
1963+
the field states it (a `lookup` or `tree` that authors nothing states
1964+
`set_null`). A `set_null` on a required reference, and a field the parse
1965+
gives no `deleteBehavior` (a bare `master_detail`, a `user`), are reported
1966+
`excluded` rather than compared, because an engine rule decides their
1967+
outcome, not the declaration. It is opt-in, and a `tree` field's delete is
1968+
not honoured today, so an app that declares one would fail.
19541969
`--multi-tenant` boots org-scoped so tenant-isolation RLS policies
19551970
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
19561971
`group`) asks for the same boot.
@@ -1978,8 +1993,9 @@ document carries depends on where the run ended:
19781993
`path`, `message`, `hint`, plus `package` for a finding raised inside one
19791994
package of the artifact), or the schema issues when the stack does not parse.
19801995
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
1981-
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`)
1982-
and `hardFailures`, the count the exit status is read from.
1996+
`crud`, `rls` (with `--rls`), `referenceShapes` (with `--reference-shapes`),
1997+
`deleteBehaviors` (with `--delete-behaviors`) and `hardFailures`, the count
1998+
the exit status is read from.
19831999
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
19842000
when the failure carries them.
19852001

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import {
1717
rlsProbeSecurity,
1818
runReferenceShapeProofs,
1919
formatReferenceShapeReport,
20+
runDeleteBehaviorProofs,
21+
formatDeleteBehaviorReport,
2022
type VerifyReport,
2123
type RlsReport,
2224
type ReferenceShapeReport,
25+
type DeleteBehaviorReport,
2326
type RlsProbeDescriptor,
2427
type RlsPositionPersonaInput,
2528
} from '@objectstack/verify';
@@ -85,18 +88,22 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b
8588
* - reference-value shape (--reference-shapes, ADR-0104 D1): every
8689
* client-writable reference field refuses a value that is not a
8790
* record id, through the REST door and the system door
91+
* - delete behaviour (--delete-behaviors): every `deleteBehavior` a
92+
* reference field declares is what a by-id delete of the referenced
93+
* record does, through the REST door and the system door
8894
*
8995
* Exits non-zero on either stage's failures so it drops straight into CI.
9096
*/
9197
export default class Verify extends Command {
9298
static override description =
93-
'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 and the reference-value shape proofs on request)';
99+
'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 and the declared delete-behaviour proofs on request)';
94100

95101
static override examples = [
96102
'<%= config.bin %> verify',
97103
'<%= config.bin %> verify --app ./objectstack.config.ts --rls',
98104
'<%= config.bin %> verify --rls --multi-tenant --json',
99105
'<%= config.bin %> verify --reference-shapes',
106+
'<%= config.bin %> verify --delete-behaviors',
100107
];
101108

102109
static override flags = {
@@ -112,6 +119,10 @@ export default class Verify extends Command {
112119
description: 'Also prove every client-writable reference field refuses a value that is not a record id (a boolean, a number, an embedded record), through the REST door and the system door (ADR-0104 value shapes)',
113120
default: false,
114121
}),
122+
'delete-behaviors': Flags.boolean({
123+
description: 'Also prove every deleteBehavior a reference field declares (cascade, set_null, restrict) is what a delete of the referenced record does, through the REST door and the system door',
124+
default: false,
125+
}),
115126
'multi-tenant': Flags.boolean({
116127
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)',
117128
default: false,
@@ -190,6 +201,7 @@ export default class Verify extends Command {
190201
app?: string;
191202
rls: boolean;
192203
'reference-shapes': boolean;
204+
'delete-behaviors': boolean;
193205
'multi-tenant': boolean;
194206
json: boolean;
195207
}): Promise<void> {
@@ -305,6 +317,20 @@ export default class Verify extends Command {
305317
}
306318
}
307319

320+
// The delete-behaviour proofs run on a fresh stack of their own too: every
321+
// `restrict` probe leaves its target and holder behind, and the fixtures
322+
// the family creates would collide with the other phases' on a unique field.
323+
let deleteBehaviors: DeleteBehaviorReport | undefined;
324+
if (flags['delete-behaviors']) {
325+
const deleteStack = await bootStack(config, { multiTenant, hostRoot });
326+
try {
327+
const adminToken = await deleteStack.signIn();
328+
deleteBehaviors = await runDeleteBehaviorProofs(deleteStack, adminToken, config);
329+
} finally {
330+
await deleteStack.stop();
331+
}
332+
}
333+
308334
// Failure contract: a "real" runtime break the app's author must see.
309335
// A degraded RLS probe counts: the run reported verdicts it could not have
310336
// established, which is worse than no verifier at all.
@@ -325,17 +351,22 @@ export default class Verify extends Command {
325351
// faulted on one. Inconclusive fields (the probe could not be attributed
326352
// or written) are reported, not counted, exactly as CRUD's
327353
// `needs-fixture` is.
328-
(referenceShapes?.summary.hardFailures ?? 0);
354+
(referenceShapes?.summary.hardFailures ?? 0) +
355+
// A declared deleteBehavior a delete contradicted, or a door that
356+
// faulted on one. Inconclusive, excluded and not-derivable fields are
357+
// reported, not counted.
358+
(deleteBehaviors?.summary.hardFailures ?? 0);
329359

330360
if (flags.json) {
331361
// `emitJson`, not `this.log`: stdout is reserved (see `run()`), so the
332362
// report has to leave through the real stream — and awaiting the write
333363
// drains a report larger than one pipe buffer before `this.exit` below.
334-
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, hardFailures });
364+
await emitJson({ app: crud.app, config: absolutePath, multiTenant, crud, rls, referenceShapes, deleteBehaviors, hardFailures });
335365
} else {
336366
this.log(formatReport(crud));
337367
if (rls) this.log(formatRlsReport(rls));
338368
if (referenceShapes) this.log(formatReferenceShapeReport(referenceShapes));
369+
if (deleteBehaviors) this.log(formatDeleteBehaviorReport(deleteBehaviors));
339370
this.log('');
340371
this.log(
341372
hardFailures > 0
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN — `os verify --delete-behaviors` runs the delete-behaviour family and
5+
* counts what it finds in the exit status.
6+
*
7+
* ## What is pinned
8+
*
9+
* - without the flag the run is what it was: no `deleteBehaviors` key in the
10+
* `--json` document, and the exit status reads CRUD only;
11+
* - with the flag, each declared behaviour (`cascade`, `restrict`, and the
12+
* `set_null` the spec states for a lookup that authors nothing) is
13+
* `proven` through both doors, `hardFailures` stays 0 and the run exits 0;
14+
* - with the flag, on the same metadata plus a `beforeDelete` hook that
15+
* detaches the cascading holders before the engine's referential pass
16+
* runs, the `cascade` field is reported contradicted, `hardFailures`
17+
* counts it and the run exits 1 — the negative control, through the CLI.
18+
*
19+
* Spawned rather than run in-process: the exit status is the contract a CI
20+
* gate reads, and only a real process has one.
21+
*/
22+
23+
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
24+
import { spawnSync } from 'node:child_process';
25+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
26+
import { tmpdir } from 'node:os';
27+
import { join } from 'node:path';
28+
import { CLI, TSX, childEnv } from './helpers/serve-process.js';
29+
import { defineStackSource, defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js';
30+
31+
const MANIFEST = {
32+
id: 'com.example.verify-delete-behaviors',
33+
namespace: 'vdb',
34+
version: '1.0.0',
35+
name: 'Verify Delete Behaviors',
36+
type: 'app',
37+
engines: { protocol: '^18' },
38+
};
39+
40+
const OBJECTS = [
41+
{
42+
name: 'vdb_account',
43+
label: 'Account',
44+
pluralLabel: 'Accounts',
45+
sharingModel: 'private',
46+
fields: { name: { type: 'text', label: 'Name', required: true } },
47+
},
48+
{
49+
name: 'vdb_contact',
50+
label: 'Contact',
51+
pluralLabel: 'Contacts',
52+
sharingModel: 'private',
53+
fields: {
54+
name: { type: 'text', label: 'Name', required: true },
55+
account: { type: 'lookup', reference: 'vdb_account', label: 'Account', deleteBehavior: 'cascade' },
56+
},
57+
},
58+
{
59+
name: 'vdb_note',
60+
label: 'Note',
61+
pluralLabel: 'Notes',
62+
sharingModel: 'private',
63+
fields: {
64+
name: { type: 'text', label: 'Name', required: true },
65+
account: { type: 'lookup', reference: 'vdb_account', label: 'Account', deleteBehavior: 'restrict' },
66+
},
67+
},
68+
{
69+
name: 'vdb_tag',
70+
label: 'Tag',
71+
pluralLabel: 'Tags',
72+
sharingModel: 'private',
73+
fields: {
74+
name: { type: 'text', label: 'Name', required: true },
75+
account: { type: 'lookup', reference: 'vdb_account', label: 'Account' },
76+
},
77+
},
78+
];
79+
80+
/** The same app, plus a hook that undoes the cascade before the engine judges the delete. */
81+
const CONTROL_SOURCE = `{
82+
manifest: ${JSON.stringify({ ...MANIFEST, id: 'com.example.verify-delete-behaviors-control' })},
83+
objects: ${JSON.stringify(OBJECTS)},
84+
hooks: [
85+
{
86+
name: 'vdb_account_detaches_contacts',
87+
object: 'vdb_account',
88+
events: ['beforeDelete'],
89+
handler: async (ctx) => {
90+
const contacts = ctx.api.object('vdb_contact');
91+
for (const row of await contacts.find({ where: { account: ctx.input.id } })) {
92+
await contacts.updateById(row.id, { account: null });
93+
}
94+
},
95+
},
96+
],
97+
}`;
98+
99+
interface Run {
100+
status: number | null;
101+
stdout: string;
102+
stderr: string;
103+
}
104+
105+
interface Doc {
106+
hardFailures?: number;
107+
deleteBehaviors?: {
108+
results: Array<{ object: string; field: string; declared?: string; status: string }>;
109+
summary: { proven: number; contradicted: number; hardFailures: number };
110+
};
111+
}
112+
113+
function runCli(dir: string, args: string[]): Run {
114+
// Through tsx, so the child runs this checkout's `src/`.
115+
const r = spawnSync(TSX, [CLI, ...args], {
116+
cwd: dir,
117+
encoding: 'utf8',
118+
env: childEnv({ NO_COLOR: '1' }),
119+
maxBuffer: 64 * 1024 * 1024,
120+
});
121+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
122+
}
123+
124+
const docOf = (run: Run): Doc => JSON.parse(run.stdout) as Doc;
125+
126+
describe('os verify --delete-behaviors', () => {
127+
let root: string;
128+
let plain: Run;
129+
let proven: Run;
130+
let control: Run;
131+
132+
beforeAll(() => {
133+
root = mkdtempSync(join(tmpdir(), 'os-verify-delete-behaviors-'));
134+
const app = join(root, 'app');
135+
const controlApp = join(root, 'control');
136+
for (const [dir, source] of [
137+
[app, defineStackSource({ manifest: MANIFEST, objects: OBJECTS })],
138+
[controlApp, defineStackSourceFromLiteral(CONTROL_SOURCE)],
139+
] as const) {
140+
mkdirSync(dir);
141+
writeFileSync(join(dir, 'objectstack.config.mjs'), source);
142+
linkSpec(dir);
143+
}
144+
plain = runCli(app, ['verify', '--json']);
145+
proven = runCli(app, ['verify', '--delete-behaviors', '--json']);
146+
control = runCli(controlApp, ['verify', '--delete-behaviors', '--json']);
147+
}, 480_000);
148+
149+
afterAll(() => {
150+
if (root) rmSync(root, { recursive: true, force: true });
151+
});
152+
153+
it('leaves the run unchanged without the flag', () => {
154+
expect(plain.status, `${plain.stdout}\n${plain.stderr}`).toBe(0);
155+
const doc = docOf(plain);
156+
expect('deleteBehaviors' in doc).toBe(false);
157+
expect(doc.hardFailures).toBe(0);
158+
});
159+
160+
it('proves each declared behaviour, and exits 0', () => {
161+
expect(proven.status, `${proven.stdout}\n${proven.stderr}`).toBe(0);
162+
const doc = docOf(proven);
163+
expect(doc.deleteBehaviors?.results).toEqual([
164+
expect.objectContaining({ object: 'vdb_contact', field: 'account', declared: 'cascade', status: 'proven' }),
165+
expect.objectContaining({ object: 'vdb_note', field: 'account', declared: 'restrict', status: 'proven' }),
166+
expect.objectContaining({ object: 'vdb_tag', field: 'account', declared: 'set_null', status: 'proven' }),
167+
]);
168+
expect(doc.deleteBehaviors?.summary).toMatchObject({ proven: 3, contradicted: 0, hardFailures: 0 });
169+
expect(doc.hardFailures).toBe(0);
170+
});
171+
172+
it('NEGATIVE CONTROL: reports a cascade a hook undid, counts it, and exits 1', () => {
173+
expect(control.status, `${control.stdout}\n${control.stderr}`).toBe(1);
174+
const doc = docOf(control);
175+
expect(doc.deleteBehaviors?.results).toEqual([
176+
expect.objectContaining({ object: 'vdb_contact', field: 'account', declared: 'cascade', status: 'contradicted' }),
177+
expect.objectContaining({ object: 'vdb_note', field: 'account', status: 'proven' }),
178+
expect.objectContaining({ object: 'vdb_tag', field: 'account', status: 'proven' }),
179+
]);
180+
expect(doc.deleteBehaviors?.summary).toMatchObject({ proven: 2, contradicted: 1, hardFailures: 1 });
181+
expect(doc.hardFailures).toBe(1);
182+
});
183+
});

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,14 @@ const CALLERS: Record<string, Classification> = {
229229
"to the SOURCE object (the REST data routes, which judge that object's exposure, and the engine's system write) " +
230230
'and reads no record of the target',
231231
},
232+
'packages/verify/src/delete-behavior.ts': {
233+
kind: 'not-a-served-read',
234+
reason:
235+
"the verification harness's delete-behaviour family: the target names the object whose records the family " +
236+
'itself creates and then deletes, through the REST data routes (where that object is the ADDRESSED one, ' +
237+
"judged as such) and the engine's system delete. It reads those records back only through the harness's " +
238+
'system read, to see whether the delete removed them, and serves no field of the target to any caller',
239+
},
232240
};
233241

234242
const SCAN_TIMEOUT_MS = 60_000;

0 commit comments

Comments
 (0)