Repository navigation
Commit 584e737
feat(spec,objectql,runtime): declare HookContext.referentialCascadeDelete, the delete twin of referentialFieldClear (#23458)
Fixes #23221
Clause-②: yes
## What
Declares `HookContext.referentialCascadeDelete`, the delete twin of
`referentialFieldClear`. The engine sets it on every child delete its
cascade issues, and it is carried into the QuickJS sandbox by contract.
- `true` exactly on each child delete that `cascadeDeleteRelations`
issues for a relation whose `deleteBehavior` resolves to `cascade`, in
both phases (`beforeDelete`, `afterDelete`) and at any depth: a
grandchild that the child's own cascade removes is marked too.
- Absent, never `false`, on every other dispatch. That covers the delete
the caller asked for, a hand delete of the same child, the `set_null`
cleanup update (which keeps `referentialFieldClear` and never carries
this key), and any write a cascaded child's hooks issue through
`ctx.api`, deletes included.
- Named consumers: HotCRM's freeze guards on quote lines and deal lines
(objectstack-ai/hotcrm#2078, ruling C). With the key, a guard can refuse
a hand delete of a settled line and yield to the parent's cascade.
## Where it landed, and one departure from the dispatch's mechanism
assumption
The landing sites are the card's: `packages/spec/src/data/hook.zod.ts`
beside `referentialFieldClear`; `packages/objectql/src/engine.ts` (the
cascade child-delete site, the `delete()` hook-context assembly, and a
helper beside `buildReferentialFieldClear`);
`packages/runtime/src/sandbox/` (`body-runner.ts` `buildSandboxContext`,
`quickjs-runner.ts` `installCtx`, `script-runner.ts` `ScriptContext`).
The dispatch assumed the engine would stamp a `__` marker on the child
delete's context, which is the twin's trust model. I measured that route
and did not take it, for two reasons:
1. **It would leak into the child's hook writes.**
`buildHookApi(opCtx.context)` hands the delete's own context object to
`ScopedContext`, whose repositories pass it unchanged into every write
(`ObjectRepository.deleteById` / `delete` / `update`), and
`ObjectQL.transaction` spreads it (`{ ...baseContext, transaction }`).
So a key stamped on the child's context rides into every write the
child's hooks issue through `ctx.api`. A hook's own delete during the
cascade would then read as cascade-issued, which contradicts "true
exactly on each child delete the cascade issues, absent on every other
dispatch".
2. **It would change what the child is handed.** A stamp needs a fresh
object, `{ ...(context ?? {}), __x: true }`. Where no transaction is
opened, that turns an absent caller context into a present one, and both
`buildSession(undefined)` (returns `undefined`) and
`buildHookApi(undefined)` (falls back to `{ isSystem: true }`) would
move. The engine already rejected a context key for the sibling cascade
state for exactly this reason; see the `cascadeDeleteSets` docblock.
**What I did instead.** The walk issues each child delete inside an
engine-private `AsyncLocalStorage` token that names that child's object
and id (`cascadeIssuedDeletes`, beside `cascadeDeleteSets`). `delete()`
claims a matching, unclaimed token once, before any middleware or hook
runs, and projects it onto the declared key. The context still reaches
the child by reference and unchanged, as before.
**Consequence for the contract** (written into the docblock and the
changeset): this fact has no operation-private spelling at all. The
declared key is the only channel, and there is nothing on `ctx.api`,
`ctx.transaction` or a `__` key to sniff instead, so the
objectstack-ai/hotcrm#1165 ruling holds by construction. The key is a
signal to app code, not an authorization input: each child delete is
still authorized as the caller, and plugin-security reads nothing new.
## Pins, one per face
- **spec** `packages/spec/src/data/hook.test.ts`: the key survives a
parse, typed; it is absent (not `false`) when not carried; and it is a
key of its own (neither twin appears on the other's parsed context).
- **objectql** `packages/objectql/src/engine-cascade-delete.test.ts`,
new `describe` with 6 cases:
- the cascaded child carries the key in both phases, beside the
inherited caller identity; the hand delete of the same child under the
same identity, and the root delete, carry no key;
- a grandchild removed by the child's cascade is marked;
- in the same cascade, the `set_null` cleanup carries
`referentialFieldClear` and not this key, and the cascaded child carries
this key and not `referentialFieldClear`;
- a delete and an update that a cascaded child's hooks issue through
`ctx.api` are not marked;
- a delete of the very same row from inside the cascaded dispatch finds
the token already claimed;
- an identity-less cascade still marks its child and puts no `__` key on
the envelope.
- **runtime**
`packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts`:
real `ObjectQL` + `SqlDriver` (better-sqlite3) + `QuickJSScriptRunner`
behind `hookBodyRunnerFactory`. A shipped body reads
`ctx.referentialCascadeDelete === true` on the cascaded line in both
phases, with the inherited `session.userId` present; on the hand delete
of a sibling line under the same identity it reads the key as absent
(`typeof` is `'undefined'`).
- **cli** `packages/cli/test/lowered-body-face-enumeration.test.ts`
derives both the in-process face and the body face from source, so it is
a reader of this change. It stays green because the key is present on
both faces.
## Ablations (one-shot, through `scripts/ablation-replace.mjs` WRAP
mode)
Each mutation was proven on disk (anchor 1 to 0, blob changed) and each
restore was proven equal to the HEAD blob with an empty `git diff HEAD`.
All five targets are reached through relative source imports, so no
rebuild was needed. Expected directions were written down before
running.
| # | Mutation | Expected | Observed |
|---|---|---|---|
| A1 | delete the `delete()` projection spread (`engine.ts`) | the 6 new
objectql pins red | 6 failed / 19 passed |
| A2 | delete the `buildSandboxContext` carrier (`body-runner.ts`) |
runtime pin red | 1 failed |
| A3 | delete the `installCtx` `setProp` (`quickjs-runner.ts`) | runtime
pin red | 1 failed |
| A4 | delete `issued.claimed = true` (`engine.ts`) | only the same-row
pin red | 1 failed / 24 passed |
| A5 | delete the object/id key check (`engine.ts`) | green: no
reachable mismatch | 25 passed |
A5 is a characterisation, not a pin. The check is a stated defensive
invariant, and it has no reachable counter-case at this head: the claim
runs before any middleware or hook, so nothing can enter `delete()`
inside the token scope first. See Acceptance notes.
## Tests
Two heads, named. `670114eec` is this branch before it merged `main`;
`7e4509b6cb` is the final head, which merged `main` at `92980cc027` plus
one comment-only commit.
- **Targeted pins** (on the branch, after the build, before the merge):
- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
src/data/hook.test.ts`: 96 passed;
- `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2
src/engine-cascade-delete.test.ts`: 25 passed (19 existing + 6 new);
- `pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2` on both sandbox signal pins (this one and the update
twin's): 2 passed.
- **Full suites at `670114eec`**, each through
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`):
- `pnpm --filter @objectstack/spec test`: 674 files, 20,478 passed, 1
todo;
- `pnpm --filter @objectstack/objectql test`: 415 files, 8,093 passed;
- `typecheck` for spec, objectql and runtime: exit 0 each (each includes
its `check:test-typecheck` leg).
- **`check:generated`**: after the merge, at `92980cc027`, all 14
generated artifacts are up to date (exit 0). Before the merge, `--fix`
regenerated only `check:docs`, the one artifact it proved stale.
- **At `7e4509b6cb`**: the objectql rebuild exits 0. The full suites are
queued behind the shared verify lock at the time this PR opens: spec,
objectql, runtime and plugin-security tests; spec, objectql and runtime
typechecks; and the cli face-enumeration pin (`--project unit`, the only
tier this change owes). Their readings go in the `os-dev-report` on
#23221. NOT MEASURED in this body: the runtime full suite (its first
attempt never acquired the lock, exit 99, so nothing ran),
plugin-security, and the cli face-enumeration pin.
- **cli integration tier**: declared to CI. The diff touches no
integration-tier file and no spawn entry.
## Gates
Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, which takes the change set from the merge
base: 11 paths, +671/-1, 115 commands. Every command was run byte for
byte from that list, its exit recorded before any pipe, and the record
reconciled with `--ran`.
- **Final head `7e4509b6cb`:** 115 derived, 115 run, 0 NOT-MEASURED, 0
UNRUN; `--ran` exit 0. All 115 commands exit 0.
- **At `670114eec`, the same list:** 4 refusals, each fixed and none of
them a finding.
- `check-engine-split-ratio.mjs --days 90` (exit 2) and
`check-plugin-teardown-shape.mjs --self-test` (exit 3) refused on the
shallow clone. After `git fetch --shallow-since=2026-07-09 origin main`,
both exit 0.
- `check:skill-examples` and `check:dual-build-cjs-loads` returned
PREREQUISITE NOT MET (some `dist/` absent). At the final head they
measured and passed: 263 prose examples type-check, and 110 CJS entry
points across 68 packages load.
- **Families CI runs** are cited from the PR's check-runs in the report,
not re-run here.
- **Drift note:** `--ran` flags the tree as 7 commits behind
`origin/main` at report time. Those commits change 6 gate-script files
and `packages/spec/authorable-surface/data.json`, one line away from
this branch's one-line addition there, so the merge is textual-clean.
CI's `check:authorable-surface` runs on the merge ref.
## Acceptance notes
- **The object/id key check in `claimReferentialCascadeDelete`** is kept
as a defensive invariant ("the delete that claims the token is the one
the walk issued"). Ablation A5 shows nothing at this head can reach a
mismatch. Claim-once (A4) is the pinned half.
- **Liveness ledger: no row is needed.** `HookContextSchema` is the
runtime hook context, not a registered metadata type.
`packages/spec/liveness/hook.json` covers `HookSchema`'s 15 authored
keys only, and the update twin has no row either. `check:liveness` is
green.
- **Generated faces:** `packages/spec/authorable-surface/data.json`
gains `data/HookContext:referentialCascadeDelete` (from the spec build),
and `content/docs/references/data/hook.mdx` gains its row
(`check:generated --fix`, where `check:docs` was the only artifact
proved stale). `authorable-surface.base.json` is untouched.
- **`engine.ts` regions touched:** the `CascadeIssuedDelete` interface
(about :4131), the `cascadeIssuedDeletes` field (about :4292),
`claimReferentialCascadeDelete` (about :6407), the cascade site (about
:18750) and the `delete()` claim and projection (about :18880 and
:18915). The serial constraint named PR #23412's regions (:5514, :8856,
:20839); that PR has since landed on `main`, and this branch merged it
cleanly.
- **No behaviour moves for anything but the new key.** The child delete
is still handed the caller's context by reference, and no authorization
path reads the token.
- **No ADR governs** the hook-context referential markers or the cascade
dispatch's hook semantics (checked: `docs/adr` names neither
`referentialFieldClear` nor `cascadeDeleteRelations`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3d231ba commit 584e737
11 files changed
Lines changed: 672 additions & 1 deletion
File tree
- .changeset
- content/docs/references/data
- packages
- objectql/src
- runtime/src/sandbox
- spec
- authorable-surface
- src/data
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
| 102 | + | |
102 | 103 | | |
103 | 104 | | |
104 | 105 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
787 | 787 | | |
788 | 788 | | |
789 | 789 | | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
0 commit comments