Repository navigation
feat(spec,objectql,runtime): declare HookContext.referentialCascadeDelete, the delete twin of referentialFieldClear - #23458
Conversation
…lete The delete twin of referentialFieldClear: true exactly on each child delete the engine's cascade issues while deleting a parent, absent on every other dispatch, and carried into the QuickJS sandbox by contract. The engine recognises the delete it issued through an engine-private, (object, id)-keyed AsyncLocalStorage token claimed once by that delete, not a context key: a context key would turn an absent caller context into a present one, and would ride buildHookApi into deletes a child's hooks issue through ctx.api. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…angeset objectql cascade suite: cascaded child (both phases, identity inherited) vs hand-delete control and root delete; grandchild; set_null cleanup keeps the update twin only; ctx.api writes from a cascaded child's hooks are not marked; the token is claimed once; an identity-less cascade marks its child and leaves no __ key on the envelope. runtime: the key observed from inside a real QuickJS body. Docblock wording corrected to what holds under the atomic plan. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
… pre-image Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
… referentialCascadeDelete Produced by the spec build and check:generated --fix (check:docs was the only artifact it proved stale). Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…scade-delete-hook-signal
…he afterDelete unit re-entry Comment only. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cd7647da48c202eada0c5be6b6856563baceaf86 && git checkout cd7647da48c202eada0c5be6b6856563baceaf86
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f && git checkout -B drift-repro 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68 && git merge --no-ff 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f
node scripts/docs-audit/affected-docs.mjs --json 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68
|
席位核对
Verdict: ACCEPT. Not governed (
|
Contract reviewServed-tier: ① Derived judgmentsGate verdicts are the head's check-runs: 41 on
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #23221
Clause-②: yes
What
Declares
HookContext.referentialCascadeDelete, the delete twin ofreferentialFieldClear. The engine sets it on every child delete its cascade issues, and it is carried into the QuickJS sandbox by contract.trueexactly on each child delete thatcascadeDeleteRelationsissues for a relation whosedeleteBehaviorresolves tocascade, in both phases (beforeDelete,afterDelete) and at any depth: a grandchild that the child's own cascade removes is marked too.false, on every other dispatch. That covers the delete the caller asked for, a hand delete of the same child, theset_nullcleanup update (which keepsreferentialFieldClearand never carries this key), and any write a cascaded child's hooks issue throughctx.api, deletes included.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.tsbesidereferentialFieldClear;packages/objectql/src/engine.ts(the cascade child-delete site, thedelete()hook-context assembly, and a helper besidebuildReferentialFieldClear);packages/runtime/src/sandbox/(body-runner.tsbuildSandboxContext,quickjs-runner.tsinstallCtx,script-runner.tsScriptContext).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:buildHookApi(opCtx.context)hands the delete's own context object toScopedContext, whose repositories pass it unchanged into every write (ObjectRepository.deleteById/delete/update), andObjectQL.transactionspreads it ({ ...baseContext, transaction }). So a key stamped on the child's context rides into every write the child's hooks issue throughctx.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".{ ...(context ?? {}), __x: true }. Where no transaction is opened, that turns an absent caller context into a present one, and bothbuildSession(undefined)(returnsundefined) andbuildHookApi(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 thecascadeDeleteSetsdocblock.What I did instead. The walk issues each child delete inside an engine-private
AsyncLocalStoragetoken that names that child's object and id (cascadeIssuedDeletes, besidecascadeDeleteSets).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.transactionor 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
packages/spec/src/data/hook.test.ts: the key survives a parse, typed; it is absent (notfalse) when not carried; and it is a key of its own (neither twin appears on the other's parsed context).packages/objectql/src/engine-cascade-delete.test.ts, newdescribewith 6 cases:set_nullcleanup carriesreferentialFieldClearand not this key, and the cascaded child carries this key and notreferentialFieldClear;ctx.apiare not marked;__key on the envelope.packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts: realObjectQL+SqlDriver(better-sqlite3) +QuickJSScriptRunnerbehindhookBodyRunnerFactory. A shipped body readsctx.referentialCascadeDelete === trueon the cascaded line in both phases, with the inheritedsession.userIdpresent; on the hand delete of a sibling line under the same identity it reads the key as absent (typeofis'undefined').packages/cli/test/lowered-body-face-enumeration.test.tsderives 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.mjsWRAP 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.delete()projection spread (engine.ts)buildSandboxContextcarrier (body-runner.ts)installCtxsetProp(quickjs-runner.ts)issued.claimed = true(engine.ts)engine.ts)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.
670114eecis this branch before it mergedmain;7e4509b6cbis the final head, which mergedmainat92980cc027plus one comment-only commit.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=2on both sandbox signal pins (this one and the update twin's): 2 passed.670114eec, each throughscripts/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;typecheckfor spec, objectql and runtime: exit 0 each (each includes itscheck:test-typecheckleg).check:generated: after the merge, at92980cc027, all 14 generated artifacts are up to date (exit 0). Before the merge,--fixregenerated onlycheck:docs, the one artifact it proved stale.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 theos-dev-reporton A hook cannot tell a child delete that a parent's cascade issues from a hand delete: declare the delete twin ofreferentialFieldClearon the hook context, carried into the QuickJS sandbox #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.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.7e4509b6cb: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN;--ranexit 0. All 115 commands exit 0.670114eec, the same list: 4 refusals, each fixed and none of them a finding.check-engine-split-ratio.mjs --days 90(exit 2) andcheck-plugin-teardown-shape.mjs --self-test(exit 3) refused on the shallow clone. Aftergit fetch --shallow-since=2026-07-09 origin main, both exit 0.check:skill-examplesandcheck:dual-build-cjs-loadsreturned PREREQUISITE NOT MET (somedist/absent). At the final head they measured and passed: 263 prose examples type-check, and 110 CJS entry points across 68 packages load.--ranflags the tree as 7 commits behindorigin/mainat report time. Those commits change 6 gate-script files andpackages/spec/authorable-surface/data.json, one line away from this branch's one-line addition there, so the merge is textual-clean. CI'scheck:authorable-surfaceruns on the merge ref.Acceptance notes
claimReferentialCascadeDeleteis 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.HookContextSchemais the runtime hook context, not a registered metadata type.packages/spec/liveness/hook.jsoncoversHookSchema's 15 authored keys only, and the update twin has no row either.check:livenessis green.packages/spec/authorable-surface/data.jsongainsdata/HookContext:referentialCascadeDelete(from the spec build), andcontent/docs/references/data/hook.mdxgains its row (check:generated --fix, wherecheck:docswas the only artifact proved stale).authorable-surface.base.jsonis untouched.engine.tsregions touched: theCascadeIssuedDeleteinterface (about :4131), thecascadeIssuedDeletesfield (about :4292),claimReferentialCascadeDelete(about :6407), the cascade site (about :18750) and thedelete()claim and projection (about :18880 and :18915). The serial constraint named PR docs(adr),refactor(plugin-security): restate the ADR text that names C3-retired security machinery; delete the unreachable audience-anchor binding gate #23412's regions (:5514, :8856, :20839); that PR has since landed onmain, and this branch merged it cleanly.docs/adrnames neitherreferentialFieldClearnorcascadeDeleteRelations).Generated by Claude Code