Skip to content

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

Closed

Description

@objectstack-fleet

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/main 107853ce: git grep -niE "cascadeDelete|referentialDelete|isCascade|viaCascade" origin/main -- packages/spec/src finds only the field-level cascadeDelete → deleteBehavior alias rows (packages/spec/src/data/authoring-key-lint.ts). No hook-context key.
  • Positive control: the update-side twin exists. referentialFieldClear is declared at packages/spec/src/data/hook.zod.ts ~1180–1215: "true exactly 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 / installCtx in packages/runtime).

Who needs it (named consumers, measured)

HotCRM freezes settled records:

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:

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

  1. A declared, read-only hook-context key, true exactly 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; mirroring referentialFieldClear is the obvious shape.
  2. Carried into the QuickJS sandbox by contract, like referentialFieldClear, so that a *.hook.ts body can read it.
  3. 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

  1. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, enhancement · priority:p3 · domain:spec · area:records, into pm:queue. The delete twin of referentialFieldClear, as ruling C on objectstack-ai/hotcrm#2078 names it

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T11:01Z. ⛔ Not a claim, ⛔ not a dispatch.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Regrade: p3 → p2 · target:v18 added · a metadata-protocol widening · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T07:15Z

    Why it moves: the maintainer directed, on 2026-10-14, that spec-lane queue cards which change the metadata protocol (what @objectstack/spec accepts or declares, or what a metadata door accepts) be raised. The bar triage now applies: a protocol change gets target: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 main b5b1bf7277, packages/spec/src/data/hook.zod.ts declares only the update twin (referentialFieldClear, around :1182), and nothing marks a cascade-issued child delete.

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Regrade: p2 → p1 · metadata-protocol change, the objectui-dependency rule · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T07:43Z

    Why 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 next next.N cut.

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-14T08:16Z
    Session: session_0121ooBYRmS8ziFXm9ai5YHm
    Account: sunhaolin (the seat's linked user as GET /user answers it; this card's assignee)
    Branch: claude/issue-23221-cascade-delete-hook-signal
    Worktree: objectstack-issue-23221
    Domain: domain:spec
    Seat: domain:spec#4 (seat post #18917)
    File surface: packages/spec/src/data/hook.zod.ts, the hook-context key beside referentialFieldClear (near :1182 at 4f81f315); packages/objectql/src/engine.ts, the cascade child-delete dispatch and the hook-context build beside buildReferentialFieldClear (near :6342, :16214, :18704) — a declared cross-lane touch into domain:engine; the QuickJS carrier in packages/runtime/src/sandbox/ (body-runner.ts buildSandboxContext, quickjs-runner.ts installCtx) — cross-lane into domain: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/objectstack at 4f81f315: Clause-② SUSPECT on packages/spec/src/**, built at the default tier); the widening owes one review at CONTRACT_REVIEW_TIER before the queue.
    Clause-②: yes
    Responsibility: n/a — not a defect card
    Thread-read: 6163477638
    Serial constraints cleared: PR #23412 (#23403, domain:engine#2) edits engine.ts near :5514, :8856 and :20839, disjoint from the cascade and hook-context regions above; no open PR and no in-flight claim names hook.zod.ts or packages/runtime/src/sandbox/ (read this act).

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    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"
    ]
    }

  6. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23458 comment 6167122584 · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T11:18Z. Landing waits on a CONTRACT_REVIEW_TIER review on the landing head.

  7. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · PR #23458 · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T12:02Z

    reading result
    PR #23458 merged 2026-10-14T12:00:41Z through the merge queue, merge commit 584e737397
    origin/main git merge-base --is-ancestor 584e737397 origin/main exits 0 (tip cb9841aa25); referentialCascadeDelete present in packages/spec/src/data/hook.zod.ts there
    queue branch no gh-readonly-queue/main/pr-23458-* ref remains

    State change: the card is closed completed by Fixes #23221, and pm:dispatched is 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.

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchdomain:specenhancementNew feature or requestpriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions