fix(approvals): auto-cancel a record's pending approvals when the record is deleted - #14061
Conversation
…ord is deleted Deleting a record left its pending `sys_approval_request` rows in the approvers' inbox — counted, openable, and pointing at a record id that no longer resolves. Any object whose approval node declares `lockRecord` walks the same path, because the lock makes "delete and recreate" the author's only route to fixing a submitted record. Per the maintainer ruling: pending requests now transition to a new terminal `cancelled` status carrying a machine-readable `cancel_reason`, the rows are KEPT as audit evidence, and they leave the pending count and the inbox's default view (status write plus a `sys_approval_approver` index clear — the index is what the approver filter actually resolves through). The linkage is one global `afterDelete` hook beside the existing global record-lock hook, so it is platform-level and every "approval + lockRecord" combination benefits at once. It runs no flow node and mirrors no status back onto the deleted record; the suspended run is reported and left to the automation service. Terminal rows are untouched, and the delete itself is never refused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi
… cancel path `cancelForDeletedRecord` adds two write call sites (the append-only `sys_approval_action` insert and the `sys_approval_request` status update), so the generated census moves 215 -> 217 and its hand-written prose figures move with it. Regenerated with `node scripts/tenant-audit-census.mjs --write`; the prose numbers the gate holds to the census (215/143/97) are updated in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi
📓 Docs Drift CheckThis PR changes 2 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 128 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 10a34671fdd0173fdfc340b1e2782348711dd354 && git checkout 10a34671fdd0173fdfc340b1e2782348711dd354
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin afbf271143715707a96d6255aee08261bf9ac15f 1112d50aa93f68cfd6eb88ae7fad224f51ef912d && git checkout -B drift-repro afbf271143715707a96d6255aee08261bf9ac15f && git merge --no-ff 1112d50aa93f68cfd6eb88ae7fad224f51ef912d
node scripts/docs-audit/affected-docs.mjs --json afbf271143715707a96d6255aee08261bf9ac15f
|
…elete Bringing the branch onto current main so the query-options-erasure ratchet is measured against the same baseline CI uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi
…ead of erasing them `check:query-options-erasure` counted 5 new sites (240 -> 245) from record-delete-cancel.integration.test.ts. None of them is deliberately off-contract — they are plain `where` + `context` reads — so the remedy the gate names is to type them, not to spell them `as unknown as EngineQueryOptions` (which would claim a contract bypass that is not happening) and not to raise the ratchet. Each of the five `engine.find` calls now carries `satisfies EngineQueryOptions`. Verified it is a real check rather than decoration: the file compiles clean under a tsconfig that includes it, and planting the #4674 shape the rule exists for (`direction: 'desc'`, an undeclared key) turns it red with TS2353. The ratchet is back at its 240 ceiling, unraised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi
…ovals insertions `check-system-context-census` reported 20 problems (10 site-without-a-row + 10 anchor-is-not-a-read-site) across plugin-approvals. Triaged all 20 against `origin/main` before touching anything: every one of the 10 sites exists verbatim in main's copy of the same file, and the 10 stale anchors are exactly main's line numbers for those same sites. So this is pure line rot from the `cancelForDeletedRecord` / `RECORD_DELETE_CANCEL_LIMIT` / `bindRecordDeleteCancelHook` insertions pushing existing reads down — zero new elevation reads on this branch, which the gate confirms independently by holding the site count at 109. In particular `lifecycle-hooks.ts:570` is NOT new code: it is the pre-existing `bindDelegationWriteGuard` system bypass (#1322 / #4839), main line 440. Repaired with the sanctioned `node scripts/check-system-context-census.mjs --fix` — anchors only. No `isSystem` check was deleted, weakened or re-worded, and no row's prose changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015adLit3ZYASJiXwxKG78Wi
Fixes #13568
Deleting a record left every
pendingapproval it had opened sitting in the approvers' inbox — counted in the pending total, openable, and pointing at arecord_idthat resolves to nothing. Nothing about it was module-specific: an approval node that declareslockRecordblocks the edit, so "delete and recreate" is the only route left to an author who needs to fix a submitted record, and every such delete added another orphan.Maintainer ruling 2026-08-31 (
总监席第 5 场决裁批 #5, verbatim 「同意」): pending requests auto-cancel on record delete — statuscancelledplus a machine-readable reason, rows kept for audit, out of the pending count and the inbox's default view. Landing is the approval service's record-delete lifecycle linkage, platform-level and once, so every "approval +lockRecord" combination benefits.What landed
Contract (
packages/spec/src/contracts/approval-service.ts)APPROVAL_STATUSESgainscancelled(+ its label). Its own terminal state rather than a re-use ofrecalled: a recall is an act by the submitter, and filing a platform-initiated void as one attributes a withdrawal to a person who never performed it.APPROVAL_CANCEL_REASONS/ApprovalCancelReason/APPROVAL_CANCEL_REASON_LABELS, single entryrecord_deleted. A vocabulary rather than free text because the reason has a non-human consumer, and a class per the ruling's wording, so the next platform-initiated cancellation cause extends this list instead of minting a second terminal status.APPROVAL_ACTION_KINDSgainscancel— the only kind with no human actor, by construction.ApprovalRequestRow.cancel_reasondeclared, optional-nullable.Plugin (
packages/plugins/plugin-approvals)sys_approval_request.cancel_reason, a select derived from the contract vocabulary and never re-typed. On the row, not on the audit entry, so a plain list view reads WHY without joining the append-only action log.bindRecordDeleteCancelHook— a globalafterDeleteregistration beside the existing global record-lock hook. It needs no row-set plumbing: the engine binds the deleted row's pre-image on the by-id path and fansafterDeleteout per matched row on a predicate delete, so a bulk delete is covered by the same handler. The five approvals tables are excluded at registration, so they do not pay the delete-side pre-image read.ApprovalService.cancelForDeletedRecordwrites the transition: one append-onlysys_approval_actionrow (action: 'cancel', no actor),status: 'cancelled'+cancel_reason: 'record_deleted'+completed_at, and asys_approval_approverindex clear — that last one is not garnish, it is what actually empties the inbox, because the approver filter resolves through that index rather than throughstatus.Completedlist view now includescancelled, so a kept audit row is visible in the one curated terminal view rather than only underAll.Held to the ruling, explicitly
update_recordagainst the row that was just deleted — the exact write this card's forensics caught failing elsewhere. The suspended run is reported atwarnwith its id and otherwise left alone; service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909 /POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 are not addressed here and remain open.approved/rejected/recalled/returnedkeep their recorded outcome. The tombstone presentation of their now-dead record reference is the separate console-side card, out of scope here.returnedis deliberately out of the cancel set. The ruling namespending, the record lock is keyed onpending, and the inbox count filters onpending— so areturnedrequest is not part of the reported symptom. Called out rather than silently decided.Seam correction
The dispatch anchored the landing site at
packages/services/plugin-approvals. That path does not exist — the package lives atpackages/plugins/plugin-approvals. Same package, different directory; no behavioural consequence. Reported in the claim comment before the first edit.Clause-2: YES (content-limb)
Declared from the actual diff, upward only. No existing declared field honestly carried a machine-readable cancellation reason:
submitter_commentis submitter prose andsys_approval_action.commentis free text — the "not machine-readable" shape the ruling asks to avoid.needs:contract-reviewis attached and this PR stays draft pending that review.Verification
Gate union and the suites below were run on the final commit,
408716c1.packages/plugins/plugin-approvals/src/record-delete-cancel.integration.test.ts, 11 cases on a realObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, the realsys_approval_*schemas and the realApprovalService(requests opened byopenNodeRequest, so the approver-index rows the tests assert the disappearance of were written by production code). Positive: the row survives ascancelled+record_deleted, one no-actor audit row, the pending count and inbox both empty, the approver index cleared, the reason surfaced through the service read path, and a predicate/bulk delete covering every matched row. Negative: a terminal request about the same record untouched; a pending request about a different record untouched; noresumeand nocancelRunon the automation spy; and the delete still succeeds when the bookkeeping write fails.pending, count still 1). Without it, the positives would also pass against an engine cancelling for some unrelated reason.engine.registerHook('afterDelete', …)inbindRecordDeleteCancelHookneutered on a committed tree, mutation confirmed on disk by anchor counts (1 injected, 0 unmutated originals) and by blob hash (10159f22…tof913e884…): 8 of 11 red. The 3 that stayed green are the two negatives and the control, i.e. exactly the cases that must be insensitive to the linkage. Restored viagit checkout HEAD -- pathunder a trap with absolute paths; restore proved by on-disk hash equal to the HEAD blob and an emptygit diff HEAD, then re-run green 11/11. No rebuild leg: the ablated subject is reached through a relative source import, not through a packageexportsmap, so nothing indist/participates — and the red proves the mutation reached the resolution path.pnpm --filter @objectstack/plugin-approvals exec vitest run→ 35 files / 643 tests passed.typecheckgreen for@objectstack/spec,@objectstack/plugin-approvalsand@objectstack/client..d.ts:ApprovalStatus = 'cancelled'andApprovalCancelReason = 'record_deleted'compile (exit 0); mutating the reason to a bogus literal goes red withTS2322: Type '"record_vanished"' is not assignable to type '"record_deleted"'. So the green above is a reading of the new declarations, not of a cached build.pnpm --filter @objectstack/spec check:generatedreportedapi-surface/andexport-origins/stale; both regenerated with--fix. The four locale bundles were regenerated with the plugin's ownos i18n extractcommand, and zh-CN / ja-JP / es-ES carry authored translations for the new leaves (已作废 / 無効化済み / Anulada) rather than English source fills — re-running the extract afterwards returned the source-hash companions to their pre-change contents, which is the instrument confirming the leaves are no longer fills.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(16 paths, merge basee612dd0f5), re-derived after the census edits pulled in the docs family: 55 of 58 green.check-tenant-audit-censuswas a genuine red —cancelForDeletedRecordadds two write call sites, moving the census 215 to 217; regenerated withnode scripts/tenant-audit-census.mjs --writeand the eight hand-written prose figures the gate holds to it updated in the same commit.check-dev-prereqsandcheck:dual-build-cjs-loadsneed a fully built workspace (exit 3, "this is NOT a pass: nothing was measured"), andcheck-test-completenessneeds a CIturbo run testlog it cannot produce locally (exit 3, explicitly not a finding). CI owns all three.pnpm lint(eslint . --no-inline-config) run in full, exit 0. No narrowing to declare.Generated by Claude Code