Repository navigation
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
Description
Filing gate ③: a task the maintainer set. objectstack-ai/hotcrm#2078 was ruled letter C on 2026-10-13, the maintainer's word verbatim 「hotcrm 三个决裁都同意」, recorded in objectstack-ai/hotcrm#2078 comment 6142043303. Step 1 of that ruling, quoted: "The platform half: the seat files an objectstack card asking for a declared hook-context signal on each child delete that a parent's cascade issues. It is the delete twin of referentialFieldClear, carried into the QuickJS sandbox. The named consumers are quote lines and deal lines."
Filed by the repo:hotcrm seat (session_01BCDqr1DqnQjEh5jA9gGkUG). ⛔ Not a claim. File-at-destination, for triage: domain:*, type and grade are the triage seat's.
Reader: the objectstack triage seat routes and grades it; the lane that owns the hook-context contract and the engine's cascade-delete dispatch builds it.
Dedupe (semantic issue search on objectstack-ai/objectstack, all states): "hook context signal for child delete issued by parent cascade delete, beforeDelete cannot tell cascade from hand delete, referentialFieldClear delete twin" → 8 hits; "cascadeDelete hook context key declared isCascade child beforeDelete frozen parent line items" → 6 hits. Nearest: #22305, #20006, #7413, #7477 and #10240, all closed and all about other cascade defects; none asks for this signal.
The gap
When a record is deleted and its children are removed by the cascade (deleteBehavior: 'cascade' on a master_detail), the engine deletes each child as the calling user. Each child's beforeDelete hook then sees the same ctx.user, ctx.session and ctx.input that a user's hand delete of that one child would carry. No declared hook-context key tells the two apart.
- Re-checked on objectstack
origin/main107853ce:git grep -niE "cascadeDelete|referentialDelete|isCascade|viaCascade" origin/main -- packages/spec/srcfinds only the field-levelcascadeDelete→deleteBehavioralias rows (packages/spec/src/data/authoring-key-lint.ts). No hook-context key. - Positive control: the update-side twin exists.
referentialFieldClearis declared atpackages/spec/src/data/hook.zod.ts~1180–1215: "trueexactly when THIS write is the engine's own reference cleanup … Absent on every other dispatch". Its docblock gives the reason it had to be declared rather than read off__referentialFieldClear: the operation-private key is "the platform's own 'not part of the contract' convention … which is exactly why an app must not build correctness on it", and only a declared key crosses the QuickJS sandbox boundary (buildSandboxContext/installCtxinpackages/runtime).
Who needs it (named consumers, measured)
HotCRM freezes settled records:
- a quote once it is accepted or expired (objectstack-ai/hotcrm PR feat(app-showcase): ADR-0056 Option A public form + D7 default-profile CLI wiring #2076);
- an opportunity once it is closed (A closed deal still takes line items: a rep adds a line to a closed-won opportunity, the deal amount keeps its closed figure, and the lines stop summing to it (family of #2060 item 1) hotcrm#2077).
It already refuses a user's line insert and update on those. It cannot refuse a user's hand delete of one line without also refusing the cascade:
- The HotCRM dev measured a plain delete guard (report on Browser pass #2058 — HotCRM-side defects (18 items: accepted quote still takes lines, half-converted leads, agents losing their cases, unreachable Sales Home, …) — list card, split per item at dispatch hotcrm#2060, ablation 2b): a manager deleting any accepted or expired quote with lines is refused with the line message, and so is deleting a prospect account that holds one.
- That would break the maintainer's earlier ruling on 冻结/锁定守卫会拒绝引擎的引用清理:被冻结的记录让它引用的人和客户永远删不掉 hotcrm#720 (Option A): "A frozen record must not make the people and accounts it references undeletable".
- Sniffing
ctx.transactionor a__-prefixed key to detect the engine's own write is excluded by the ruling recorded 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.
Mainstream precedent for the semantics: Salesforce does not fire a child's delete triggers on a master-detail cascade, and Odoo's ondelete='cascade' runs in the database without calling the child's unlink() guard. A declared signal lets an app keep its guard and yield to the cascade, which is what the update twin already does.
Ask
- A declared, read-only hook-context key,
trueexactly on each child delete that the engine's cascade issues while deleting the parent, and absent on every other delete. The name and placement belong to the owning lane; mirroringreferentialFieldClearis the obvious shape. - Carried into the QuickJS sandbox by contract, like
referentialFieldClear, so that a*.hook.tsbody can read it. - A pin for each face: a cascade child delete carries it; a hand delete of the same child does not; a sandboxed hook body sees it.
After it ships
objectstack-ai/hotcrm files the consumer card now, pm:blocked with Blocked-by: this card. Once a release carrying the key is installable in HotCRM's pin, that card refuses only a hand delete of a settled quote's or a closed deal's line. HotCRM's unlock criterion is "released and installable", not "merged".
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 13, 2026
Triage: first grade,
enhancement·priority:p3·domain:spec·area:records, intopm:queue. The delete twin ofreferentialFieldClear, as ruling C on objectstack-ai/hotcrm#2078 names itTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-13T11:01Z. ⛔ Not a claim, ⛔ not a dispatch.packages/spec/src/data/hook.zod.ts) widens a published surface, soClause-②: yesand spec-lane work. The engine's cascade dispatch (objectql) and the QuickJS sandbox carrier are declared cross-lane in the claim.trueexactly when this delete is a child removed by a parent's cascade (deleteBehavior: 'cascade'), and absent on every other dispatch, mirroringreferentialFieldClear's docblock and shape.referentialFieldClearis.beforeDeletesees the key;referentialFieldClearand not this key;objectstack-fleet commentedon Oct 14, 2026
Regrade: p3 → p2 ·
target:v18added · a metadata-protocol widening · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T07:15ZWhy it moves: the maintainer directed, on 2026-10-14, that spec-lane queue cards which change the metadata protocol (what
@objectstack/specaccepts or declares, or what a metadata door accepts) be raised. The bar triage now applies: a protocol change getstarget:v18, so it lands in the 18 window and objectui, cloud and HotCRM mirror it once. A narrowing (breaking) one is p1, because missing 18 GA means waiting for 19. A widening (additive) one is at least p2.Read fresh. On
mainb5b1bf7277,packages/spec/src/data/hook.zod.tsdeclares only the update twin (referentialFieldClear, around :1182), and nothing marks a cascade-issued child delete.objectstack-fleet commentedon Oct 14, 2026
Regrade: p2 → p1 · metadata-protocol change, the objectui-dependency rule · triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T07:43ZWhy it moves to p1: the maintainer added, on 2026-10-14, 「协议变更还经常会影响 objectui 项目的开发,相关任务经常等 spec 发版本」. A widening is not an upgrade cost for apps, but objectui (types mirror, renderers) waits on it and then on its release. So every metadata-protocol change, widening included, is now p1 +
target:v18: it lands early and rides the nextnext.Ncut.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 · 2026-10-14T08:16Z
Session:
session_0121ooBYRmS8ziFXm9ai5YHmAccount:
sunhaolin(the seat's linked user asGET /useranswers it; this card's assignee)Branch:
claude/issue-23221-cascade-delete-hook-signalWorktree:
objectstack-issue-23221Domain:
domain:specSeat:
domain:spec#4(seat post #18917)File surface:
packages/spec/src/data/hook.zod.ts, the hook-context key besidereferentialFieldClear(near:1182at4f81f315);packages/objectql/src/engine.ts, the cascade child-delete dispatch and the hook-context build besidebuildReferentialFieldClear(near:6342,:16214,:18704) — a declared cross-lane touch intodomain:engine; the QuickJS carrier inpackages/runtime/src/sandbox/(body-runner.tsbuildSandboxContext,quickjs-runner.tsinstallCtx) — cross-lane intodomain:cli; their tests; regenerated spec artifacts; one.changeset/23221-*.md. Stop on a breach and explain it in the report.Container & model:
M,mode:subagent,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstackat4f81f315: Clause-② SUSPECT onpackages/spec/src/**, built at the default tier); the widening owes one review atCONTRACT_REVIEW_TIERbefore the queue.Clause-②: yes
Responsibility: n/a — not a defect card
Thread-read: 6163477638
Serial constraints cleared: PR #23412 (#23403,
domain:engine#2) editsengine.tsnear:5514,:8856and:20839, disjoint from the cascade and hook-context regions above; no open PR and no in-flight claim nameshook.zod.tsorpackages/runtime/src/sandbox/(read this act).objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{
"issue": 23221,
"status": "done",
"branch": "claude/issue-23221-cascade-delete-hook-signal",
"pr": "#23458",
"session": "session_0121ooBYRmS8ziFXm9ai5YHm (subagent run; the parent's harness-stamped id, which the fleet relay also resolved)",
"premise_still_valid": true,
"summary": "Declared HookContext.referentialCascadeDelete in packages/spec/src/data/hook.zod.ts beside referentialFieldClear (optional boolean, true or absent, docblock = contract text). objectql: the cascade walk issues each child delete inside an engine-private AsyncLocalStorage token naming that child's object and id (cascadeIssuedDeletes), and delete() claims a matching token once, before middleware and hooks, then projects it onto the declared key in both phases. runtime: buildSandboxContext, installCtx and the ScriptContext type carry the key into QuickJS. PM mechanism assumption 1 (stamp a __ marker on the child context) was measured and not taken: buildHookApi hands that very context to ScopedContext, so the stamp would ride into deletes a child's hooks issue through ctx.api, and a fresh-object stamp turns an absent caller context into a present one (the reason cascadeDeleteSets already rejects a context key). Assumption 2 measured: grandchildren are marked; neither the declared key nor any envelope key reaches non-delete writes, and a hook-issued delete is not marked either. Premise held at 04a3148: referentialCascadeDelete 0 hits repo-wide; control referentialFieldClear 7 hits in hook.zod.ts.",
"tests": "Final head 7e4509b (merged origin/main at 92980cc, plus one comment-only commit), each command via scripts/pm/os-verify-lock.sh, VERDICT command-exit 0: spec test 674 files, 20528 passed, 1 todo; objectql test 415 files, 8093 passed; runtime test (local project) 374 files, 5456 passed, 19 skipped; plugin-security test 185 files, 3773 passed, 45 skipped; cli lowered-body-face-enumeration pin (--project unit) 4 passed; typecheck exit 0 for spec, objectql and runtime (each with its check:test-typecheck leg); objectql rebuilt at that head (exit 0). cli integration tier declared to CI: no integration-tier file or spawn entry is touched. Pre-merge 670114e: spec 20478 passed, 1 todo; objectql 8093 passed; spec/objectql/runtime typechecks exit 0. Targeted pins: hook.test.ts 96 passed; engine-cascade-delete.test.ts 25 passed (6 new); both runtime sandbox signal pins 2 passed. check:generated: all 14 up to date at 92980cc; pre-merge --fix regenerated only check:docs (the one proved stale). Ablations via scripts/ablation-replace.mjs WRAP mode, each mutation proven on disk and each restore blob-equal to HEAD with empty git diff HEAD, no rebuild needed (relative src imports), direction predicted first: A1 projection spread removed: 6 failed / 19 passed (exactly the new pins); A2 buildSandboxContext carrier removed: runtime pin 1 failed; A3 installCtx setProp removed: runtime pin 1 failed; A4 claimed=true removed: 1 failed / 24 passed (only the same-row pin); A5 object/id key check removed: 25 passed, as predicted (a defensive invariant with no reachable counter-case at this head).",
"gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands from the merge base (11 paths, +671/-1). Each was run byte for byte with its exit captured before any pipe, then --ran reconciled. Final head 7e4509b: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN, --ran exit 0; all 115 exit 0. First pass at 670114e had 4 refusals, none a finding: check-engine-split-ratio --days 90 (exit 2) and check-plugin-teardown-shape --self-test (exit 3) on the shallow clone, both exit 0 after git fetch --shallow-since=2026-07-09 origin main; check:skill-examples and check:dual-build-cjs-loads PREREQUISITE NOT MET (missing dist), both measured exit 0 at the final head (263 prose examples type-check; 110 CJS entry points across 68 packages load). --ran flags the final tree as 7 commits behind origin/main at report time (6 gate scripts plus a different line of authorable-surface/data.json); mergeable_state clean. CI on PR 23458 at 7e4509b, converged: 41 check-runs, 36 success, 5 skipped, 0 failed. Required: Lint & Repo Gates=success, TypeScript Type Check=success, Test Core=success (shards 1-6 success), Build Core=success, Governed Surface Queue Guard=success. Also: Check Changeset=success, Spec property liveness=success, Dogfood Regression Gate=success, Lint · PM dispatch-gates=success, Type Check · consumer gates/debt ledger/source gates/workspace=success, The card this PR closes must claim this branch=success. Skipped: Console Pin Gate, Dogfood Regression Gate matrix shards, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance.",
"mcp_calls": "0 — no MCP GitHub tool was called",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches under the session token, executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft, forced) + POST /repos//issues/23458/assignees [sunhaolin]; (2) comment = POST /repos//issues/23221/comments (this report). No label-write: none was named and skip-changeset does not apply (the labels on the PR were added by the path labeler). git push is not REST. GitHub reads went through gh api GET only.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed (observation, wording precision only): the cascadeDeleteSets docblock in packages/objectql/src/engine.ts says a context key 'would turn a caller's absent context into a present one on every nested delete'. That holds only on the 'none'/'split' plans; under 'atomic' the child already receives { ...callerContext, transaction }. The new cascadeIssuedDeletes docblock states the narrower form. PR 23458's body is written once; if wanted, the seat can append this line to its Acceptance notes."
],
"deviations": [
"Mechanism: PM assumption 1 (a server-derived __ stamp on the child delete's context) was falsified by measurement and replaced by an engine-private (object, id)-keyed AsyncLocalStorage token claimed once. The ruling (declared key only; private stamp never the contract) is met by construction: no operation-private spelling exists.",
"Gate list: the dispatch-time list had 77 commands; the derivation from the final diff has 115 (adds the changeset, the regenerated hook.mdx and the new test files). All 115 were run.",
"Merged origin/main once before opening the PR (AGENTS.md §10: main had moved engine.ts, plugin-security and spec) via scripts/pm/os-regen-merge.sh, then rebuilt, re-ran check:generated and the full suites. Main moved 7 more commits afterwards; not merged again, per the after-open rule (conflict, main-caused red or dequeue only).",
"Deepened the shared clone (git fetch --shallow-since=2026-07-09 origin main) as two shallow-refusing gates prescribed; this advanced the shared refs/remotes/origin/main.",
"Stopped my own runner processes twice (recorded PIDs and their lock-call children, never by name) so no measurement ran across a changing tree. The first runtime suite attempt hit queue-timeout (exit 99, nothing ran); it was re-run at the final head with retry-on-99 under the same slot.",
"engine.ts additions beyond the claim's line anchors: the CascadeIssuedDelete interface (about :4131) and the cascadeIssuedDeletes field (about :4292), beside CascadeDeleteSet and cascadeDeleteSets, as part of the cascade dispatch machinery. They are disjoint from PR 23412's regions, and that PR has since landed and merged cleanly. The update-side assembly (:16214) is unchanged.",
"Attribution conflict, noted: the harness reminder asked for a model-named Co-Authored-By trailer and an emoji PR footer. AGENTS.md (which takes precedence) governs, so commits carry the model-free pair Claude-Session + Co-authored-by: Claude, and the PR body carries the session-URL footer."
],
"files_changed": [
".changeset/23221-cascade-delete-hook-signal.md",
"content/docs/references/data/hook.mdx",
"packages/objectql/src/engine-cascade-delete.test.ts",
"packages/objectql/src/engine.ts",
"packages/runtime/src/sandbox/body-runner.ts",
"packages/runtime/src/sandbox/quickjs-runner.ts",
"packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts",
"packages/runtime/src/sandbox/script-runner.ts",
"packages/spec/authorable-surface/data.json",
"packages/spec/src/data/hook.test.ts",
"packages/spec/src/data/hook.zod.ts"
],
"dedupe_words": [
"referentialCascadeDelete",
"cascade child delete hook signal",
"referentialFieldClear delete twin",
"cascadeIssuedDeletes"
]
}
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23458 comment 6167122584 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T11:18Z. Landing waits on aCONTRACT_REVIEW_TIERreview on the landing head.objectstack-fleet commentedon Oct 14, 2026
Landed · PR #23458 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T12:02Z584e737397origin/maingit merge-base --is-ancestor 584e737397 origin/mainexits 0 (tipcb9841aa25);referentialCascadeDeletepresent inpackages/spec/src/data/hook.zod.tstheregh-readonly-queue/main/pr-23458-*ref remainsState change: the card is closed
completedbyFixes #23221, andpm:dispatchedis removed in this act. HotCRM's consumer (objectstack-ai/hotcrm#2078's freeze guards) unblocks once a release carrying the key is installable in its pin, not on this merge.