Repository navigation
Commit 9f4b652
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
- .changeset
- content/docs/deployment
- packages
- cli
- src/commands
- test
- core/src/security
- verify
- src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1923 | 1923 | | |
1924 | 1924 | | |
1925 | 1925 | | |
| 1926 | + | |
1926 | 1927 | | |
1927 | 1928 | | |
1928 | 1929 | | |
| |||
1951 | 1952 | | |
1952 | 1953 | | |
1953 | 1954 | | |
| 1955 | + | |
| 1956 | + | |
| 1957 | + | |
| 1958 | + | |
| 1959 | + | |
| 1960 | + | |
| 1961 | + | |
| 1962 | + | |
| 1963 | + | |
| 1964 | + | |
| 1965 | + | |
| 1966 | + | |
| 1967 | + | |
| 1968 | + | |
1954 | 1969 | | |
1955 | 1970 | | |
1956 | 1971 | | |
| |||
1978 | 1993 | | |
1979 | 1994 | | |
1980 | 1995 | | |
1981 | | - | |
1982 | | - | |
| 1996 | + | |
| 1997 | + | |
| 1998 | + | |
1983 | 1999 | | |
1984 | 2000 | | |
1985 | 2001 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
20 | 22 | | |
21 | 23 | | |
22 | 24 | | |
| 25 | + | |
23 | 26 | | |
24 | 27 | | |
25 | 28 | | |
| |||
85 | 88 | | |
86 | 89 | | |
87 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
88 | 94 | | |
89 | 95 | | |
90 | 96 | | |
91 | 97 | | |
92 | 98 | | |
93 | | - | |
| 99 | + | |
94 | 100 | | |
95 | 101 | | |
96 | 102 | | |
97 | 103 | | |
98 | 104 | | |
99 | 105 | | |
| 106 | + | |
100 | 107 | | |
101 | 108 | | |
102 | 109 | | |
| |||
112 | 119 | | |
113 | 120 | | |
114 | 121 | | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
115 | 126 | | |
116 | 127 | | |
117 | 128 | | |
| |||
190 | 201 | | |
191 | 202 | | |
192 | 203 | | |
| 204 | + | |
193 | 205 | | |
194 | 206 | | |
195 | 207 | | |
| |||
305 | 317 | | |
306 | 318 | | |
307 | 319 | | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
308 | 334 | | |
309 | 335 | | |
310 | 336 | | |
| |||
325 | 351 | | |
326 | 352 | | |
327 | 353 | | |
328 | | - | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
329 | 359 | | |
330 | 360 | | |
331 | 361 | | |
332 | 362 | | |
333 | 363 | | |
334 | | - | |
| 364 | + | |
335 | 365 | | |
336 | 366 | | |
337 | 367 | | |
338 | 368 | | |
| 369 | + | |
339 | 370 | | |
340 | 371 | | |
341 | 372 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
229 | 229 | | |
230 | 230 | | |
231 | 231 | | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
232 | 240 | | |
233 | 241 | | |
234 | 242 | | |
| |||
0 commit comments