Skip to content

feat(spec,objectql,runtime): declare HookContext.referentialCascadeDelete, the delete twin of referentialFieldClear - #23458

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23221-cascade-delete-hook-signal
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23221-cascade-delete-hook-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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.

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 A hook cannot tell a child delete that a parent's cascade issues from a hand delete: declare the delete twin of referentialFieldClear on 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.
  • 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 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 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

…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>
… 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>
…he afterDelete unit re-entry

Comment only.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via HookContextSchema (symbol, a top-level const), ScriptContext (symbol, a top-level interface))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via HookContextSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd7647da48c202eada0c5be6b6856563baceaf86 — the merge of head 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f into base 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7b3d5585ca9deab5c627eaa826a6d622a1a8cd68 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6164006610 on #23221 · reviewed head 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f · 2026-10-14T11:17Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23458: 0 of 11 paths), 11 files, +672 / −1. It lands after the landing clauses in item 6.

  1. Shape. Draft, base main, first line Fixes #23221, Clause-②: yes at line start in the body and the changeset (minor for spec, objectql and runtime). No other closing keyword sits beside a card number. mergeable_state clean.
  2. Premises against the dispatch's ruling.
    • The seat's naming ruling and its premise held: referentialCascadeDelete had 0 hits repo-wide, against 7 for the control referentialFieldClear.
    • The key is declared beside referentialFieldClear in hook.zod.ts. It is optional, the engine writes true or omits it, and its docblock is the contract text: meaning, absence, why it is declared, trust model.
    • Ruling fix: serve studio at /_studio in Vercel deployment instead of public root #1165 (no undeclared-key sniffing) holds by construction: no __-prefixed spelling exists. The engine marks the cascade's child delete through an engine-private AsyncLocalStorage record keyed on that child's object and id, claimed once by delete() before middleware and hooks, and projected onto the declared key in both phases.
    • The QuickJS carrier is buildSandboxContext, installCtx and the ScriptContext type.
    • The changeset's guard idiom reads only declared keys (ctx.previous is declared at hook.zod.ts:798 on main).
  3. Gates and evidence, pinned to this head. Check-runs on 7e4509b6c: 36 success, 5 skipped, 0 failures. Lint & Repo Gates, TypeScript Type Check, Test Core (1–6/6), Build Core, Governed Surface Queue Guard, Check Changeset and Spec property liveness all succeeded. Pins cover the four faces triage named: the child sees the key; a hand delete of the same child does not; a set_null cleanup keeps referentialFieldClear without this key; a sandboxed body reads it. Further pins cover grandchildren, and a hook-issued ctx.api delete is not marked. The ablations went red exactly on their pins (A1–A4). A5, the object and id key check, stayed green as predicted: it is a defensive invariant with no reachable counter-case.
  4. Accepted deviations.
    • PM mechanism assumption 1 was measured and replaced: a __ stamp on the child context would ride into deletes a child's hooks issue through ctx.api. Its replacement meets the ruling more strictly.
    • The gate list grew from 77 to 115 on the final diff, and all 115 ran.
    • One origin/main merge was made through os-regen-merge.sh before the PR opened.
    • The shared clone was deepened as two shallow-refusing gates prescribed.
  5. Out-of-scope dispositions. One wording-precision observation, carrier: none, noted and not filed: the cascadeDeleteSets docblock's "absent context turns present" holds only on the none and split plans. The new docblock states the narrower form.
  6. Landing clauses.
    • (a) A CONTRACT_REVIEW_TIER ## Contract review PASS on the landing head: published schema face (packages/spec/src/data/hook.zod.ts) plus the Clause-②: yes declaration.
    • (b) Every check green on the landing head.
    • (c) mergeable_state clean at enqueue.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f
Local-runs: none

① Derived judgments

Gate verdicts are the head's check-runs: 41 on 7e4509b6cb666ad6fa8fa5b26a063ee7ae15886f, 36 success, 5 skipped (Console Pin Gate, the Dogfood matrix shards, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance — each path- or opt-in-filtered), 0 failures. Governed Surface Queue Guard success; the file list (11 paths) touches no governed surface. Face: a published schema (packages/spec/src/data/hook.zod.ts), hence this tier.

  • The new HookContext key — right. HookContextSchema.referentialCascadeDelete is z.boolean().optional() beside referentialFieldClear, on a schema the file header keeps deliberately non-strict, so the engine-written key survives .parse() and an absent key stays absent (never false, never present-but-undefined). Accept-set change: a widening of the published HookContext type (z.input of the schema); nothing previously valid changes meaning. The docblock is the contract text and declares the two consequences of the no-envelope design (a hook's own ctx.api writes are not marked; nothing the child delete is handed moves). Pinned in hook.test.ts: survives a parse typed, absent when not carried, a key of its own against the twin.
  • The engine's marking rule — right. The walk opens one engine-private cascadeIssuedDeletes AsyncLocalStorage token per cascade dependent, around only its own this.delete(childName, { where: { id: depId }, context }); delete() claims it once at engine.ts 18884, after resolveEngineDeleteDispatch (a scalar where.id resolves to by-id, so id is bound) and before opCtx, middleware and hooks. The opensAfterAbortUnit re-entry at 18840 to 18842 returns before the claim, so the inner call that dispatches the hooks is the claimant. The projection is a conditional spread on the one hook-context object afterDelete reuses, so both phases carry it. The context still reaches the child by reference, unchanged.
  • Its exclusions — right, each pinned in engine-cascade-delete.test.ts. Root delete and a hand delete of the same child under the same identity: no token, absent. Bulk or multi deletes: id undefined, never a cascade shape. The set_null cleanup: an update, keeps referentialFieldClear only (engine.ts 16279) and never this key; a cascade child delete never carries the update twin. Writes a cascaded child's hooks issue through ctx.api: the same token is already claimed, so issued.claimed short-circuits, and a different row also fails the object/id match. Grandchildren: the child's own walk opens its own token, marked at any depth. An identity-less cascade is marked and the envelope carries no __ key.
  • Trust model — right. The hook context is assembled member by member (session, provenance, user, api, transaction) with no spread of opCtx.context, so a client-supplied referentialCascadeDelete on a request context never reaches ctx; the fact has no operation-private spelling, so there is nothing on ctx.api, ctx.transaction or a __ key to sniff — the ruling on The engine's reference-cleanup marker IS reachable from a hook on 17.0.0 GA — #720's "no marker reaches a hook" note is falsified, and three copies of a shape predicate rest on it hotcrm#1165 holds by construction. The key is a signal, not an authorization input; plugin-security reads nothing new.
  • Declared divergence from the update twin — right. The twin's docblock gives hook-issued writes envelope semantics (they inherit the __ marker); this key gives dispatch semantics (a hook's own delete is not cascade-issued). That is the card's letter — true exactly on each child delete the cascade issues, absent on every other delete — and the docblock states the divergence as contract rather than leaving it implicit.
  • The sandbox carrier — right. buildSandboxContext spreads { referentialCascadeDelete: true } only on === true; installCtx does vm.setProp(ctxObj, 'referentialCascadeDelete', vm.true) under the same guard; ScriptContext gains the optional member with its docblock. These are the twin's three sites exactly. QuickJSScriptRunner is the only ScriptRunner implementation besides UnimplementedScriptRunner, so no second carrier is missing. The cli lowered-body-face-enumeration pin classifies conditional spreads on both faces, so the key appears on both and introduces no in-process-only member. Pinned from inside the VM (referential-cascade-delete-signal.integration.test.ts: real ObjectQL, SqlDriver, QuickJS runner; both phases true with the inherited session.userId; the hand delete of a sibling line under the same identity reads typeof as 'undefined'; the update twin absent on both).
  • Regenerated authorable surface and reference doc — right. packages/spec/authorable-surface/data.json gains data/HookContext:referentialCascadeDelete in sorted position; content/docs/references/data/hook.mdx (auto-generated header) gains the row built from the .describe() text, in source order after the twin. Both are byte-exact per the green TypeScript Type Check job, which runs every spec generated-artifact gate. authorable-surface.base.json is untouched — right, it is the baseRev anchor written only by gen:schema from a git-resolved baseline. api-surface/data.json and json-schema.manifest/data.json record exports and schema names, not members (neither lists the twin), so no further regeneration is owed. No liveness row is owed: HookContextSchema is not a registered metadata type, the twin has none, and Spec property liveness is success.
  • ADR coverage — right as stated. docs/adr at the head names neither referentialFieldClear, cascadeDeleteRelations nor walkReferencingRelations; ADR-0025 and ADR-0102 govern sandbox tiers and CPU budget, not the ctx member set; ADR-0058 D2 governs the input.options projection, untouched. The engine.ts anchor (ADR-0057, ADR-0067, ADR-0119 transaction seam) is a region the diff does not touch; the anchor gate is inside the green lint job.
  • Card pins, all four present: cascade child beforeDelete sees the key; hand delete of the same child does not; set-null cleanup keeps referentialFieldClear and not this key; the sandboxed body reads it.

② Semver level

  • Changeset .changeset/23221-cascade-delete-hook-signal.md: @objectstack/spec, @objectstack/objectql, @objectstack/runtime at minor — matches what the diff publishes: a widening of a published schema, the engine assembly that populates it, and the sandbox members that carry it. All three are public (no private flag), members of the fixed group, pre-mode tag next, so the group moves to the next next.N together. Nothing removed or renamed; no migration and no ADR-0087 disposition owed. Check Changeset is success.
  • Clause-②: yes — right: declared in the PR body and repeated in the changeset body, with no arm, which the rule allows (at most one; a widening needs none); yes takes at least minor, and minor is what is declared.

③ Boundary flags

open_questions: none in the os-dev-report. Every dev flag answered; nothing escalated.

  • Mechanism deviation (ALS token instead of a __ stamp on the child context) — accepted as right. Both stated reasons hold at the head: api: this.buildHookApi(opCtx.context) hands the delete's own context to the scoped repositories, so a stamp would ride into every hook-issued write; and a stamp needs a fresh object, turning an absent caller context into a present one, the very reason the cascadeDeleteSets docblock already rejects a context key. The dispatch's mechanism assumption is not an input to this review.
  • Gate list 77 to 115 — accepted. Derived from the final diff; the head's check-runs are the verdict in any case.
  • One merge of origin/main before opening, none after — accepted. The head contains merge commit 92980cc027; mergeable_state is clean.
  • Deepened the shared clone, advancing shared refs/remotes/origin/main — noted, no action. A hazard flagged for siblings, as the discipline asks; not a breach.
  • Stopped its own runner processes by recorded PID — accepted. Within the rule: never a process it did not start.
  • engine.ts additions beyond the claim's line anchors (CascadeIssuedDelete, cascadeIssuedDeletes) — accepted. Both are the cascade dispatch machinery the claim's file surface names; disjoint from 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, which has landed and is merged into this head.
  • Attribution — right. All six branch commits end with the session-URL trailer plus the model-free co-author line AGENTS.md prescribes; the PR body footer is the session-URL form; no model identifier appears in the diff, the changeset or the PR body.
  • Out-of-scope finding (cascadeDeleteSets docblock says a context key would make an absent context present "on every nested delete", imprecise under the atomic plan) — not filing is right. A wording imprecision in an untouched docblock is neither a reproducible defect, a contract violation nor an authoring trap (Prime Directive 10); it is recorded in the report's out_of_scope_findings, and the new cascadeIssuedDeletes docblock states the narrower form. No action owed before landing.
  • Acceptance note A5 (the object/id key check has no reachable counter-case) — accepted. The claim precedes middleware and hooks, so nothing enters delete() inside an unclaimed token first; keeping the check as a defensive invariant without a pin is right, since a pin would need a path that does not exist. Claim-once (A4) is the pinned half.

Implemented-by: claude/issue-23221-cascade-delete-hook-signal
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants