Repository navigation
fix(objectql): an aborting after* hook rolls its write back on the default write door - #22819
Conversation
A plain write opened no unit of work, so an after* hook whose onError is abort (the default) refused the caller while the row stayed stored. The write door now opens a unit for exactly that case. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Work a unit starts and does not await (an async: true hook) inherited the finished handle and lost its write. Measured on a booted stack. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…e door re-enters its own unit The system-context census anchors elevation reads by symbol, so the bodies keep their names: the door asks opensAfterAbortUnit, and the unit re-enters the same method, which joins it. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…caller's limit Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 9616b56e772038ab4430ceeb37d5baf4d19fd3c8 && git checkout 9616b56e772038ab4430ceeb37d5baf4d19fd3c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f a1ed7388631a7376b9df230401550a1be5b8de6f && git checkout -B drift-repro 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f && git merge --no-ff a1ed7388631a7376b9df230401550a1be5b8de6f
node scripts/docs-audit/affected-docs.mjs --json 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f
|
Text only: HookEvent's JSDoc gains the write door's unit as the fourth way a write ends up inside a unit of work; onError's TSDoc and describe() say where abort rolls back and what it means elsewhere. No shape, key, default or enum change. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…for the onError text Regenerated with check:generated --fix (gen:docs only, the one artifact it proved stale). Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…door's unit A sandboxed afterInsert body declaring no onError aborts, so the write door opens the unit, the body's ctx.api.transaction joins it and the abort rolls the outer row back too. The #6406 subject stays asserted: one begin, the body's write on that handle, one rollback (the owner's). The sibling case's comment says the begin is now the door's; its assertions are unchanged. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #22782 (body; triage 6106449072; claim 6106744769; os-dev-reports 6107830381, 6108597601, 6108994502), PR #22819 (body, 9-file list, net diff Check-runs on this head: 42, all ① Derived judgmentsThe unit-opening predicate (
Re-entry design (
Nesting — right. Inside a caller-opened Q1 — cross-datasource refusal — right (ruled A, declared 6107851318/6108611359 context). No new rule: Spec text against the code — matches. The runtime re-pin — a contract change, not a weakening; right. The old case made 4 observable assertions (begins 1, rollbacks 1, commits 0, Hand-written docs named by the docs-drift comment — one page still states the OLD behaviour, and that is wrong. Of the 14 pages listed in 6107821481, three state
② Semver level
③ Boundary flagsDeviations and open questions across the three reports — all answered or escalated:
The four out-of-scope findings of round 1:
The 444-line count: +444/−19 against the 300 suggestion. Source 72 ≤ 80. The excess is 286 test lines over two layers (the triage's four pins plus the measurement pins, and the real-door file with the regression the unit introduced), 36 for the runtime re-pin, 31 of changeset text. Proportionate to a stored-outcome change on a published door.
Unmeasured edge, for the seat's note (not a FAIL carrier): inside the door's unit a summary recompute runs enrolled; on a SQL driver a STATEMENT-level recompute failure (a deadlock or serialization failure on PostgreSQL) leaves the transaction aborted, so the subsequent Remediation for round 4 (the verdict rests on R1 and R3):
Everything else judged above is right: the predicate, the re-entry, the commit-then-rethrow, the ambient close, the nesting, Q1, the spec text, the re-pin, both semver levels and both Implemented-by: VERDICT: FAIL Read at 2026-10-11T13:25Z by the reviewer, read-only, from the shared checkout at Generated by Claude Code |
…-flow, events); changeset cites ADR-0087's launch-window exemption data-flow.mdx's lifecycle table said an after* hook cannot abort (record is saved); events.mdx gains where the rollback holds. The changeset's provenance for the minor-for-breaking convention named ADR-0131, which decides organization ownership; it now names ADR-0087's ratified launch-window exemption (amended 2026-09-13). The runtime sibling case's title states the door's unit; its assertions are unchanged. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review after the FAIL record 6109504950 on Check-runs on this head: 42, all ① Derived judgmentsThe delta is the four one-line changes the report names, and nothing else — right. R1 — R2 — R3 — the changeset citation — right. Line 11 now reads "(ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003)". On R4 — the sibling case's title — right in what it names; it says more than the case pins. The title is now "with NO host transaction the door's unit owns the one begin, the sandbox body joins it, and the door commits both rows (#22782)". Its four assertions are unchanged: Re-check of the docs-drift pages (6107821481) — no hand-written page still states the old ② Semver levelUnchanged. The delta touches no package source and no test assertion, and only the citation parenthetical in the objectql changeset. ③ Boundary flagsRound 4's deviations: none reported, and none found. The delta is text-only (4 lines); no rebase or force. The PR body was edited once through the relay: it now carries the AGENTS.md footer form (rule, one Round 1's (b) and (c), measured in round 4 and filed as #22855 / #22856 (not read here):
R6, recommended (from R4): add to the sibling case the three handle-identity lines its neighbour already has —
Standing from the prior record, not re-judged: the unmeasured SQL statement-level recompute edge inside the door's unit (a PG probe for the seat's note); round 1–3 deviations and the Q1–Q3 rulings. Governance: the 11-file list touches no governed surface ( Implemented-by: VERDICT: PASS Read at 2026-10-11T14:24Z by the reviewer, read-only, from the shared checkout at |
Fixes #22782
Clause-②: yes (narrowing)
What changed
HookSchema.onErrorsaysabort: Rollback transaction (if blocking), defaultabort. A plain write opened no unit of work. So a throwingafterInsertrefused the caller while the row stayed stored, and so did the rows earlierafter*hooks had written throughctx.api. A client that retried the refused create made a duplicate.The write door (
insert/update/delete,packages/objectql/src/engine.ts) now opens a unit of work for exactly the case that needs one. The abort then rolls back the row and the hook chain's engine writes together.opensAfterAbortUnit): the object has a metadata-bound hook (meta, the only hook entries that carry a declaredonError) on the write'safter*event. That hook is blocking (notasync) and itsonErrorisabortor omitted. Automations are not skipped, no unit is open yet (neither ambient nor threaded), the object is on the default driver, and that driver declares transactions.inAfterAbortUnit): it openstransaction()and re-enters the same door method, which joins the unit. Hook count and order are unchanged (ObjectQL.delete's single-id cascade is not transactional — a refusal mid-cascade leaves earlier children deleted while the response says the delete failed #7413). Only the boundary around the hooks moves.SummaryRecomputeErrormeans "the records WERE written". It is rethrown after the commit, never through the rollback.transaction()closes its ambient entry when the callback settles, before the commit. Work the unit starts and does not await (anasync: truehook) used to inherit the finished handle. Measured on the booted stack, that work failed withTransaction query already completeand its write was lost. It now runs on its own connection.No new authorable key. No change to #7413's hook count or order. No commit-time trigger and no
timingkey (#7477's guardrails).Step 1 measurements (base
9f5eca52b3)Which drivers implement transactions. Measured by symbol (
beginTransaction/commit/rollbackanddriverSupportsTransactions), not by atransaction(grep. That grep found only driver-sql and driver-turso. By symbol, driver-memory and driver-mongodb implement the trio too, and driver-sqlite-wasm inherits it.SqlDriversubclass, the verify stack's default)supports.transactionsUnsupported, so no unit opens. This is (B).beginTransactiondeep-copies every table androllbackrestores that copy. The cost is O(rows) per unit, and a rollback also drops writes other requests made in the meantime. It is not a boot store and is frozen as a test backend.transaction(), the atomic cascade and the atomic batch. See the open questions in the report.driverSupportsTransactionsis false, so no unit opens and nothing is warned.Cost, on the verify stack (sqlite-wasm, in-process). 5 rounds of 100 inserts per object, interleaved, on a shared box, so read the ratios rather than the absolute times:
aborthook (unit opened);onError: 'log'hook (no unit);That makes the unit about 1.36x. An object without such a hook pays one hook-map lookup.
Nesting. Inside a caller-opened
transaction()the door sees the handle and opens nothing.transaction()itself joins an ambient one (ADR-0067 D2). Pinned: exactly onebeginTransaction, and the caller's commit decides.Where (A) does not hold, so (B) applies, and the spec text it needs
transaction()covers the default driver only (ADR-0119 D1), and opening one would refuse the object's own write. This is the same verdict as ObjectQL.delete's single-id cascade is not transactional — a refusal mid-cascade leaves earlier children deleted while the response says the delete failed #7413's'split'cascade. After an abort, the row stays stored, as before.packages/spec/src/data/hook.zod.tschanges in text only.HookEvent's JSDoc now lists four ways a write ends up inside a unit, the write door's own being the new one.onError's TSDoc anddescribe()now say where the rollback holds and that elsewhere anafter*hook's abort refuses the caller while the row stays stored. No shape, key, default or enum changes.content/docs/references/data/hook.mdxwas regenerated (check:generated --fix, which rangen:docsfor the one artifact it proved stale). An@objectstack/specpatchchangeset covers it.content/docs/automation/hooks.mdx, which the claim declares, carries the same fourth path.Behaviour inside the new unit (stated in the changeset)
aborthook on such an object that writes a business row to another datasource is now refused withCrossDatasourceTransactionWriteError, as it would be inside any unit (the [spec] engine.transaction 契约收紧:opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696 ruling). The abort turns that refusal into a refused write. In-tree reach: zero. The one first-party blockingabortafter*hook, app-todo'stask_logic, only logs.onError: 'log',async: true, or one datasource.Tests
The final head is
1c880cd1cb. Its only change sinced572e29ec8is a test file: the unit test's driver double now honourslimit, forcheck:objectql-double-limit. The source runs below were taken atd572e29ec8, except where a run says1c880cd1cb.pnpm --filter @objectstack/objectql exec vitest run src/engine-after-hook-abort-unit.test.ts src/engine-cascade-delete-atomic.test.ts src/engine-summary-retry.test.ts: 3 files, 25 passed (at1c880cd1cb).pnpm --filter @objectstack/dogfood exec vitest run test/hook-after-abort-rollback.dogfood.test.ts test/hook-error-format.dogfood.test.ts: 2 files, 10 passed.pnpm --filter @objectstack/verify exec vitest run src/handle.system-insert-delete.test.ts src/handle.exemplar-deal-lifecycle.test.ts: 2 files, 19 passed. This is the canary: a blockingafterInsert/afterDeletecapture hook plus record-change flows, now inside the unit.@objectstack/objectqllocalproject (vitest run --project local): 399 files, 7803 passed, atd572e29ec8.repoproject: 1 file, 5 passed.pnpm --filter @objectstack/objectql run typecheck: clean at1c880cd1cb.check:test-typecheckholds the ledger, and the new test file compiles clean.eslint --no-inline-config --format json). The configuration ignores the.mdand.mdxfiles ("no matching configuration"). The three.tsfiles report 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintrun is left to CI.pnpm --filter @objectstack/dogfood run typecheck: clean.--listFilesincludes the new file.The pins. These are the triage's, on two layers:
packages/objectql/src/engine-after-hook-abort-unit.test.ts).POST /data/:object(createData) and the engine doorhooks.runon a booted stack (packages/qa/dogfood/test/hook-after-abort-rollback.dogfood.test.ts).What they assert:
afterInsertwith the defaultonError: the caller is refused; the row and thectx.apinote are absent.afterUpdate/afterDelete: the prior row is intact.onError: 'log': the row is stored and no unit opens.transaction(): one unit, and the caller keeps the row it committed.beforeInsert: refused, nothing stored, no unit.async, code-registered orskipAutomationswrites, for an object off the default driver, or for a driver with no transactions, and nothing is warned.async: truehook's late write lands after the unit commits.A plain
Errorfrom a function hook answers REST's sanitised500 INTERNAL_ERRORon both sides of the fix. The pins assert that envelope plus what is stored.Gates (head
1c880cd1cb)The dispatch's 80-gate list together with this diff's own derivation (
dispatch-gates.mjs --commands, 97 families) comes to 113 commands, and every one exited 0.dispatch-gates --ranreports 97 derived, 97 run, 0 NOT-MEASURED and 0 UNRUN, with every exit code recorded.check:skill-examplesandcheck:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3) because packages they read were not built. Both went green once those packages were built.check:objectql-double-limitflagged the new driver double as limit-blind. The double was fixed in1c880cd1cb.origin/main. Inpackages/objectqlthose commits touch onlyfilter-comparand-shape.tsand one new test, andgit merge-treeis clean. CI's merge ref is the joint check.Reverse verification and ablations
Each ablation leg was run with
scripts/ablation-replace.mjs(anchor hit, blob changed), a rebuild andablation-dist-preflight.mjswheredist/is read, and a restore proven by blob == HEAD and an emptygit diff HEAD, plus--absentafter the rebuild. Legs A, C and E were re-run on the final code shape atd572e29ec8.hooks.runpins (expected 1 to be +0, the row and the note both stored). This is also the reproduction of the card on a published door. The measured direction was the expected one: red.expected +0 to be 1, the async hook's late write lost). The first attempt at B was a no-op. Its marker (void 'ABLATION_22782_B') was eliminated by the build, the dist preflight refused it, and the leg was re-run with a marker that survives (B2).SummaryRecomputeErrorgoes through the rollback. Red: the roll-up case (one rollback, and the child row gone).beforeInsertveto never matches (fixture). Red: thebefore*control ({ status: 201 }).asyncandonError. Red: thelogcontrol and the no-unit case (one unit opened).Line budget (final head
a1ed738863)447 additions and 22 deletions over 11 files, against a 300-line suggestion. Measured from the base, round 4 is 3 additions and 3 deletions: text only, in two docs pages and one test title. Its changeset edit rewrites a line the PR already adds.
Round 2 — the seat's rulings and their verification
The seat ruled on the report's three open questions (verbatim):
What this PR does with each:
@objectstack/objectqlchangeset names the affected hooks and the remedy (onError: 'log',async: true, or one datasource).@objectstack/specpatchchangeset.Verification at
ad05283877:pnpm --filter @objectstack/spec runwithcheck:generated,check:docs,check:authorable-surfaceandcheck:api-surface: each exited 0.check:generatedreported all 14 artifacts up to date, against adistthatcheck:generated --fixhad just built.pnpm --filter @objectstack/spec run typecheck: clean, andcheck:test-typecheckholds the ledger.@objectstack/speclocalproject: 645 files; 19311 passed, 1 todo.objectqldistrebuilt and preflight-checked (no ablation marker).origin/mainis not merged. It is 18 commits ahead, touches none of this PR's files, andgit merge-treeis clean.--ranreconciliation are reported in the round-2os-dev-reportcomment on objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782.Round 3 — the ruling on the runtime test and its verification
CI on
ad05283877was red onTest Core (5/6)inpackages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts. The case "with NO host transaction a throwing body still ROLLS BACK its own — unchanged" pinnedcommittedNames('thing')as['outer']. Its sandboxedafterInsertbody declares noonError, so it takesabort, and the case pinned exactly the defect this card fixes. Both readings were measured. With this PR the result is[]. With the door's unit ablated the file passes 6 of 6, and this PR's own pins go red. The seat ruled (verbatim):What changed in round 3. Only that test file and one changeset line changed; no runtime source and no other case.
afterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782 contract. It is renamed "with NO host transaction the door's unit owns the begin, the body joins it, and the abort rolls the outer row back too (objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782)", and its comment states FROM and TO. It now makes 9 assertions where it made 5:/body boom/);committedNamesis[].@objectstack/objectqlchangeset gains one line. A sandboxed body's ownctx.api.transaction, with no transaction open around the write, now joins the door's unit, and the abort rolls back the triggering row too.Verification at
0b6b8275d1(objectqldisthas the change, and the preflight finds no ablation marker in it):ablation-replacelanded the mutation, the rebuild was preflight-checked (marker in 4 files), and the restore was proven (blob equals HEAD,--absent). The re-pinned case goes red withexpected undefined to be { __trx: 1 }: the outer write is outside any unit. The other 5 cases pass.localproject, this PR's unit and dogfood pins, and the gate re-derivation with its--ranreconciliation are reported in the round-3os-dev-reportcomment on objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782.Round 4 — the contract review's remediation (review 6109504950, FAIL on
0b6b8275d1)Round 4 changes text only: no engine source and no assertion.
content/docs/api/data-flow.mdxlifecycle table, theafterInsert/Update/Deleterow. It said "Can Abort? ❌ No (record is saved)". It now states this PR's contract: a blockingonError: 'abort'hook (the default) refuses the write and rolls it back; on an object outside the default datasource, or on a driver with no transactions, the caller is refused but the row stays stored. It links to the hooks page's unit-of-work section. Declared on the devx seat post [PM seat] domain:devx @ objectstack — ⏳ vacant #6023 (6109534840).content/docs/kernel/events.mdx, theonError: 'abort'bullet. It gains where the rollback holds, in the same wording as R1.@objectstack/objectqlchangeset's provenance for shipping a breaking change asminornamed ADR-0131 (organization ownership), copied from another changeset. It now names ADR-0087's ratified pre-launch launch-window exemption, as amended 2026-09-13. The level staysminor.afterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782)". Its assertions are unchanged. Declared in the [PM seat] domain:cli — 🟢 marchtian · session_01B5CHJNXuuqzChM4w6hkTN4 #6024 addendum (6109539378).Verification at
a1ed738863:objectqldisthas the change, and the preflight finds no ablation marker in it.check:docs,check:doc-authoring, both docs-audit scripts,check-adr-0087-registration,check-changeset-no-major,check-empty-changesetand the rest) and its--ranreconciliation are in the round-4os-dev-reportcomment on objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782.Acceptance notes
Each note below is unmeasured or out of scope, and none was filed as a card.
registerHook, nometa) that throws in anafter*event still refuses the caller with the row stored. It declares noonError, so it is outside the contract this card enforces. Carrier: none.transaction()threads its handle explicitly (trxCtx). Work that escapes such a callback still carries the finished handle; the close here covers the ambient entry only.ScopedContext.transaction()publishes an ambient entry that is not closed either. Read from the code, not measured. Carrier: none.after*hook withonError: 'log'whose condition cannot be evaluated raisesHookConditionError, whichonErrornever softens (hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775). Such a hook opens no unit, so the caller is refused while the row stays stored. Read from the code, not measured. Carrier: none.rollbackrestores a whole-store snapshot, which drops concurrent writes. This predates this change and is reachable from any unit. Carrier: none.Authored by session
session_01JfJfBUC3cQ6hhgm9MQK76T(os-dev, dispatched by thedomain:engineseat 1); rounds 2 to 4 edited this body through the fleet relay.Generated by Claude Code