Skip to content

fix(objectql): an aborting after* hook rolls its write back on the default write door - #22819

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22782-after-hook-abort-rollback
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22782-after-hook-abort-rollback

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22782

Clause-②: yes (narrowing)

What changed

HookSchema.onError says abort: Rollback transaction (if blocking), default abort. A plain write opened no unit of work. So a throwing afterInsert refused the caller while the row stayed stored, and so did the rows earlier after* hooks had written through ctx.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.

  • When a unit opens (opensAfterAbortUnit): the object has a metadata-bound hook (meta, the only hook entries that carry a declared onError) on the write's after* event. That hook is blocking (not async) and its onError is abort or 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.
  • How the unit runs (inAfterAbortUnit): it opens transaction() 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.
  • SummaryRecomputeError means "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 (an async: true hook) used to inherit the finished handle. Measured on the booted stack, that work failed with Transaction query already complete and 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 timing key (#7477's guardrails).

Step 1 measurements (base 9f5eca52b3)

Which drivers implement transactions. Measured by symbol (beginTransaction / commit / rollback and driverSupportsTransactions), not by a transaction( 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.

Driver Inside the door's unit
driver-sql (pg / mysql / sqlite) Knex transaction. (A) holds.
driver-sqlite-wasm (SqlDriver subclass, the verify stack's default) (A) holds. Every pin below runs on it.
driver-turso, local or embedded replica (A) holds.
driver-turso, remote Declares supports.transactionsUnsupported, so no unit opens. This is (B).
driver-memory beginTransaction deep-copies every table and rollback restores 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.
driver-mongodb The trio is present. Its README says multi-document transactions require a replica set. On a standalone server the unit's writes fail. NOT MEASURED: there is no mongod in this container; the reading is from the code and the README. The same is already true of transaction(), the atomic cascade and the atomic batch. See the open questions in the report.
A driver without the trio driverSupportsTransactions is 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:

  • 1.309 ms per insert on an object with a blocking abort hook (unit opened);
  • 0.961 ms per insert on the same object with an onError: 'log' hook (no unit);
  • 0.867 ms per insert with no hook.

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 one beginTransaction, and the caller's commit decides.

Where (A) does not hold, so (B) applies, and the spec text it needs

  • An object off the default datasource. 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.
  • A default driver with no transactions. After an abort, the row stays stored, as before.
  • The spec text (round 2, after the seat's declaration on [PM seat] domain:spec — ⏳ vacant #6017, comment 6107851318). packages/spec/src/data/hook.zod.ts changes 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 and describe() now say where the rollback holds and that elsewhere an after* hook's abort refuses the caller while the row stays stored. No shape, key, default or enum changes. content/docs/references/data/hook.mdx was regenerated (check:generated --fix, which ran gen:docs for the one artifact it proved stale). An @objectstack/spec patch changeset 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)

Tests

The final head is 1c880cd1cb. Its only change since d572e29ec8 is a test file: the unit test's driver double now honours limit, for check:objectql-double-limit. The source runs below were taken at d572e29ec8, except where a run says 1c880cd1cb.

  • 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 (at 1c880cd1cb).
  • 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 blocking afterInsert / afterDelete capture hook plus record-change flows, now inside the unit.
  • The full @objectstack/objectql local project (vitest run --project local): 399 files, 7803 passed, at d572e29ec8.
  • The repo project: 1 file, 5 passed.
  • pnpm --filter @objectstack/objectql run typecheck: clean at 1c880cd1cb. check:test-typecheck holds the ledger, and the new test file compiles clean.
  • eslint on the changed paths (eslint --no-inline-config --format json). The configuration ignores the .md and .mdx files ("no matching configuration"). The three .ts files report 0 errors and 0 warnings. eslint.config.mjs enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint run is left to CI.
  • pnpm --filter @objectstack/dogfood run typecheck: clean. --listFiles includes the new file.

The pins. These are the triage's, on two layers:

  • Unit layer: an engine with a snapshot-rollback driver (packages/objectql/src/engine-after-hook-abort-unit.test.ts).
  • Real doors: REST POST /data/:object (createData) and the engine door hooks.run on a booted stack (packages/qa/dogfood/test/hook-after-abort-rollback.dogfood.test.ts).

What they assert:

  • A throwing afterInsert with the default onError: the caller is refused; the row and the ctx.api note are absent.
  • afterUpdate / afterDelete: the prior row is intact.
  • CONTROL onError: 'log': the row is stored and no unit opens.
  • CONTROL caller-opened transaction(): one unit, and the caller keeps the row it committed.
  • CONTROL throwing beforeInsert: refused, nothing stored, no unit.
  • No unit for async, code-registered or skipAutomations writes, for an object off the default driver, or for a driver with no transactions, and nothing is warned.
  • A roll-up failure is committed, then thrown.
  • The async: true hook's late write lands after the unit commits.

A plain Error from a function hook answers REST's sanitised 500 INTERNAL_ERROR on 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 --ran reports 97 derived, 97 run, 0 NOT-MEASURED and 0 UNRUN, with every exit code recorded.

  • check:skill-examples and check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) because packages they read were not built. Both went green once those packages were built.
  • check:objectql-double-limit flagged the new driver double as limit-blind. The double was fixed in 1c880cd1cb.
  • The tree is 17 commits behind origin/main. In packages/objectql those commits touch only filter-comparand-shape.ts and one new test, and git merge-tree is 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 and ablation-dist-preflight.mjs where dist/ is read, and a restore proven by blob == HEAD and an empty git diff HEAD, plus --absent after the rebuild. Legs A, C and E were re-run on the final code shape at d572e29ec8.

  • A, no unit (the predicate always answers no). Red: 3 unit cases, plus the dogfood REST and hooks.run pins (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.
  • B, the ambient entry is not closed. Red: the dogfood positive control (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).
  • C, SummaryRecomputeError goes through the rollback. Red: the roll-up case (one rollback, and the child row gone).
  • D, the beforeInsert veto never matches (fixture). Red: the before* control ({ status: 201 }).
  • E, the predicate ignores async and onError. Red: the log control 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.

  • Engine source: 72 additions and 7 deletions, under the 80-line suggestion.
  • Spec text: 21 additions and 3 deletions.
  • Generated reference doc: 1 addition and 1 deletion.
  • Changesets: 31 lines.
  • Re-pinned runtime test (round 3): 30 additions and 6 deletions.
  • Hooks page: 3 additions and 2 deletions.
  • Tests: 286 lines over two layers. This is the excess. The triage asked for four pins plus a real door. The unit file also carries the measurement pins: the no-unit cases, the cross-driver case, the driver with no transactions, and the roll-up case. The dogfood file carries the regression the unit introduced and the fix for it.

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:

  • Q1: the refusal stays. The @objectstack/objectql changeset names the affected hooks and the remedy (onError: 'log', async: true, or one datasource).
  • Q2: this PR ships as it stands. The same changeset states the replica-set requirement and the remedy. No card is filed for the driver.
  • Q3: the spec text edit above, its regenerated reference page and an @objectstack/spec patch changeset.

Verification at ad05283877:

Round 3 — the ruling on the runtime test and its verification

CI on ad05283877 was red on Test Core (5/6) in packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts. The case "with NO host transaction a throwing body still ROLLS BACK its own — unchanged" pinned committedNames('thing') as ['outer']. Its sandboxed afterInsert body declares no onError, so it takes abort, 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):

席位裁定 #22782 round-2 的 open_question(runtime 测试冲突):取 A。依据:分诊 6106449072 已裁定 after* abort 在默认写门回滚该行;该用例的 'outer' 存活正是该卡的 FROM;#6406 的主题(一次 begin、body 的 ctx.api.transaction 加入、被加入的回滚弃权、无第二连接)在本 PR 下实测完好。B 是消费端例外、与本 PR 刚落的 spec 文本相悖;C 推翻已裁方向。跨道申报已在 #6024 发出(6108611359)。

What changed in round 3. Only that test file and one changeset line changed; no runtime source and no other case.

Verification at 0b6b8275d1 (objectql dist has the change, and the preflight finds no ablation marker in it):

Round 4 — the contract review's remediation (review 6109504950, FAIL on 0b6b8275d1)

Round 4 changes text only: no engine source and no assertion.

Verification at a1ed738863:

Acceptance notes

Each note below is unmeasured or out of scope, and none was filed as a card.

  • A hook registered in code (registerHook, no meta) that throws in an after* event still refuses the caller with the row stored. It declares no onError, so it is outside the contract this card enforces. Carrier: none.
  • A caller-opened 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.
  • An after* hook with onError: 'log' whose condition cannot be evaluated raises HookConditionError, which onError never 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.
  • A driver-memory rollback restores 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 the domain:engine seat 1); rounds 2 to 4 edited this body through the fleet relay.


Generated by Claude Code

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>
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/ai/skills-reference.mdx (via afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/api/data-flow.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/api/error-handling-server.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/automation/hook-bodies.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/automation/hooks.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/data-modeling/formulas.mdx (via afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/data-modeling/schema-design.mdx (via afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/deployment/production-readiness.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/kernel/events.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/permissions/system-context.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/plugins/development.mdx (via afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/protocol/diagram.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit))
  • content/docs/protocol/objectql/schema.mdx (via afterDelete (literal, a string literal in ObjectQL; a string literal in opensAfterAbortUnit), afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/ui/forms.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit))

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

  • content/docs/releases/implementation-status.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit), afterUpdate (literal, a string literal in opensAfterAbortUnit; a string literal in update))
  • content/docs/releases/v15.mdx (via afterInsert (literal, a string literal in insert; a string literal in opensAfterAbortUnit))
  • content/docs/releases/v17/17-0.mdx (via HookSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-7.mdx (via HookSchema (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 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 3 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 — 140 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 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9616b56e772038ab4430ceeb37d5baf4d19fd3c8 — the merge of head a1ed7388631a7376b9df230401550a1be5b8de6f into base 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f, 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 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

⚠️ 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 31b5a5f7f51b7a7fd1131299a5b1dbe95910bc4f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0b6b8275d161059992c14d084c9af8f041d81f76
Local-runs: none

Inputs read: card #22782 (body; triage 6106449072; claim 6106744769; os-dev-reports 6107830381, 6108597601, 6108994502), PR #22819 (body, 9-file list, net diff 9f5eca52b3..0b6b8275d1 = +444/−19), declarations 6107851318 (#6017) and 6108611359 (#6024), the 42 check-runs on this head, and the docs-drift comment 6107821481 on this PR. Read-only: the diff, the head's files via git show, git merge-tree against origin/main; nothing built, run or re-run.

Check-runs on this head: 42, all completed — 38 success, 4 skipped (the edited-event re-runs of Auto Label and Check PR Size, the path-filtered Console Pin Gate, the opt-in Packed-tarball smoke); commit status Vercel success. None in progress. These conclusions are the gate verdicts; Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, all six Test Core shards (including the runtime file that was red at ad05283877), the three Dogfood Regression Gate shards and Temporal Conformance (live PG + MySQL) are green.

① Derived judgments

The unit-opening predicate (opensAfterAbortUnit, engine.ts:4700) — right. Six conditions, each read against the dispatch it mirrors:

  • no unit open — context.transaction undefined AND txStore.getStore()?.transaction undefined. Right: the same two sources buildDriverOptions (:6110) reads to enrol a write, so the predicate can never open a second unit around a write the engine would already enrol.
  • automations not skipped — reads context.skipAutomations; triggerHooks (:4642) reads the session.skipAutomations that is set from it. Right. (A caller setting only session.skipAutomations would open a needless unit that commits — cost, not outcome.)
  • a meta entry (metadata-bound) on this after* event matching the object, blocking (!meta.async), onError abort or omitted. Right: mirrors wrapDeclarativeHook exactly — onError = meta.onError ?? 'abort' and fireAndForget = Boolean(meta.async) && isAfterEvent (hook-wrappers.ts). A log hook never rethrows; an async hook cannot abort; a code-registered hook has no meta and no onError, so it is outside HookSchema.onError's contract (right to leave out — the changeset's "Unchanged" bullet says so).
  • object on the default driver, by identity (getDriver(object) === defaultDriver), and driverSupportsTransactions(defaultDriver) (the declaration gate, spec/data/driver.zod.ts:850: beginTransaction present and supports.transactionsUnsupported !== true). Right: transaction() covers the default driver only (ADR-0119 D1); a unit around an off-default object would refuse its own write under [spec] engine.transaction 契约收紧:opts.require fail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696; a non-transactional driver would only reach transaction()'s warn-and-degrade path, which the pin "no warning" proves is never reached from the door. getDriver throwing (unroutable object) answers false and the write reports it — right.
  • Pulled in, cost only: a matching hook whose condition evaluates false (skipped, nothing to abort — a unit still opens and commits) and a global (object absent or '*') metadata hook, which opens a unit on every default-driver write of that event. Both are consistent with the contract (such a hook CAN abort) and pay only the measured 1.36x.
  • Left out, and rightly so for this card: a log hook with an unevaluable condition (HookConditionError, never softened, hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775) still refuses while the row stays stored — outside onError: abort; flagged in ③ as a card.

Re-entry design (inAfterAbortUnit, :4715) — right. this.transaction(cb) opens the owned unit and publishes the ambient entry; cb re-enters insert/update/delete with the ORIGINAL options; on re-entry the predicate sees the ambient handle and answers false (no loop), buildDriverOptions enrols the driver write ambiently (ADR-0034), and the after* dispatch runs inside the unit (#7477's declared timing). Hook count and order are untouched — only the boundary moved (#7413 honoured; the engine-cascade-delete-atomic pins ran green). Return types pass through unchanged (update → number | null, delete → boolean | number). The runtime re-pin's creates[0].transaction === begins[0] is the direct evidence the outer write is enrolled, which the unit-test double (whole-store snapshot) alone could not show.

SummaryRecomputeError rethrown after commit — right in design. "The records WERE written" (:15115, :16944, :18719) is preserved: the unit commits, then the error is rethrown with the written records. Routing it through the rollback would have made every bulk caller that treats it as a warning (seed/import) re-write rows that were gone. Ablation C proved the direction. One unmeasured edge is in ③ (a statement-level recompute failure on a SQL driver that poisons the open transaction).

transaction()'s ambient close (:19516-19525) — right, and its blast radius is contained. The entry is mutated to { transaction: undefined, scope: undefined } in a .finally on the callback's promise, before commit/rollback, so the AsyncLocalStorage continuation of un-awaited work reads an empty entry. Every reader is optional-chained on .transaction / .scope (:6110, :6435, :9815, :19481, :19679, :20408, :20507, :20588), so after the close: the driver write runs unenrolled, enforceTransactionOrigin has no scope to be outside of (no spurious CrossDatasourceTransactionWriteError), releaseJoinedHandle falls back to its own joinedHandles record, and a transaction() call from that work opens its own owned unit. What it changes for OTHER callers: a caller-opened transaction() whose callback started un-awaited work (an async: true hook, or the caller's own fire-and-forget write) used to hand that work the finished handle — Transaction query already complete, write lost (measured on the verify stack). It now lands on its own connection after the unit. That is a repair of a broken path, not an accept-set change, and the dogfood positive control pins it. Work holding the EXPLICIT trxCtx handle still escapes, and ScopedContext.transaction() (:20453-20457) publishes an entry it does not close — the acceptance note is accurate; see ③.

Nesting — right. Inside a caller-opened transaction() the predicate's first clause answers false; a transaction() called inside the door's unit JOINS it (ADR-0067 D2, :19481-19488, owned: false). Pinned twice: the unit-test control (one beginTransaction, zero rollbacks, the caller keeps the row) and the dogfood control on the booted stack. The atomic cascade's own transaction() joins the door's unit the same way, so the parent's afterDelete now runs inside a unit when the first path opened one — the engine docblock (:400-412) and hooks.mdx:300 say exactly that.

Q1 — cross-datasource refusal — right (ruled A, declared 6107851318/6108611359 context). No new rule: enforceTransactionOrigin (:19675-19690) is the #5696 rule for every unit — a BUSINESS write to another datasource is refused with CrossDatasourceTransactionWriteError, an append-only system ledger is carved out and survives the rollback (#5351). Inside the door's unit the refusal becomes a refused write; the changeset's FROM → TO names the population and the three remedies. In-tree reach zero (the one first-party blocking abort after* hook only logs). Right.

Spec text against the code — matches. hook.zod.ts:155-160 (the fourth path: blocking, abort default, default datasource, driver declares transactions) and the onError TSDoc + describe() (:425-443) state the same four conditions the predicate enforces, say where the rollback holds and what abort means elsewhere (caller refused, row stays stored), and keep before* separate. No shape, key, default or enum change (z.enum(['abort','log']).default('abort') unchanged). The describe() string is regenerated byte-for-byte into content/docs/references/data/hook.mdx:44 (check:generated green). The spec text omits the engine-side opt-outs (skipAutomations, code-registered hooks, "no unit already open"); those are not authorable and the changeset's "Unchanged" bullet carries them — acceptable.

The runtime re-pin — a contract change, not a weakening; right. The old case made 4 observable assertions (begins 1, rollbacks 1, commits 0, committedNames == ['outer']) and pinned exactly the card's FROM. The new case makes 9: refused /body boom/; begins 1; creates 2; creates[0].transaction === begins[0]; creates[1].transaction === begins[0]; rollbacks 1; rollbacks[0] === begins[0]; commits 0; committedNames == []. Against #6406's subject (one begin / no second connection; the body's ctx.api.transaction joins the same handle; the joined rollback abstains; exactly one rollback, the owner's): all four are asserted, and tighter than before — the old case never pinned handle identity. The FROM → TO is stated in the case's comment and in the changeset. Ablation A-r3 reds it at expected undefined to be { __trx: 1 } — the right direction. The sibling case's title "with NO host transaction the sandbox still OPENS one — unchanged behaviour" is now false in what it names: the sandbox no longer opens the begin, it joins the door's (its own new comment says so), and the ownership of the begin is changed behaviour. Its assertions (begins 1, commits 1, commits[0] === begins[0], both rows committed) still hold. The dev reported it stale and left it per the ruling's wording, which scoped the sibling to its comment. Flagged in ③ as a one-string fix.

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 onError/after* semantics:

  • content/docs/api/data-flow.mdx:314 — the lifecycle table's row afterInsert/Update/Delete | Post-persist | … | Can Abort? ❌ No (record is saved). After this PR that row is false on the default write door (a blocking onError: 'abort' after* hook refuses the write and rolls the row back), and it contradicts content/docs/automation/hooks.mdx:299 in the same docs tree. Its Callout at :317 ("use onError: 'log' so a failing side effect doesn't roll back the write") describes the NEW contract and only becomes true with this PR. A published statement the runtime will contradict is the card's own Filing gate ① shape; this PR would create one. Wrong — must be corrected before the queue (③ R1).
  • content/docs/kernel/events.mdx:115 — "onError: 'abort' (default) — the error propagates, rolling back the transaction (when the hook is blocking) and cancelling the operation." Unqualified, but it is the sentence the spec used to carry and it is now true on the default door; it lacks only the where-it-holds qualifier (③ R2, recommended).
  • content/docs/api/error-handling-server.mdx:224-235 and content/docs/automation/hook-bodies.mdx:265,275 say "aborts the write" / "aborting the triggering write" — consistent with the new contract.
    The other ten pages name afterInsert/afterUpdate/afterDelete without stating abort semantics; the four release-owned pages are read-only and state nothing this PR falsifies.

② Semver level

  • @objectstack/objectql — minor, **BREAKING** banner, Clause-②: yes (narrowing), ADR-0087 marker not-required (no-migration-prescription): level and arm right. A stored-outcome narrowing on a published door (REST createData/updateData/deleteData, ctx.api, the engine door) is BREAKING; under the repo's launch-window convention a pre-GA break ships minor with the banner (ADR-0087, "Ratified: the pre-launch launch-window exemption", amended 2026-09-13 [Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minor vs major)—— 退役一个可写键到底发哪一级? #18003 — "Pre-GA, a metadata-facing retirement or break ships minor"). The FROM → TO states the populations the brief names: the cross-datasource business write (now refused, remedy stated), the MongoDB replica-set requirement, the sandbox body's ctx.api.transaction now joining the door's unit, the async: true late write now landing, and the measured cost. The marker's category is one the gate enumerates (check-adr-0087-registration.mjs, no-migration-prescription) and its reason is true: no authorable key is removed, renamed or re-shaped. Check Changeset is green. One citation is wrong: line 17 attributes the convention to "(ADR-0131 D3, D9)". ADR-0131 is "Organization ownership is total — no NULL organization_id"; its D3 and D9 decide catalog/organization ownership and have nothing to do with hooks or with the semver convention. The sentence is copied from the 15195-* changesets, where ADR-0131 was the decision being implemented. This text ships to consumers in CHANGELOG.md; the provenance must name the record that carries the convention (③ R3).
  • @objectstack/spec — patch, Clause-②: no: right. Text only: TSDoc, a JSDoc list and one describe() string; every hook that parsed before parses the same; the regenerated artifact is the reference page (check:generated, check:docs, check:authorable-surface, check:api-surface green).
  • The PR body's Clause-②: yes (narrowing) line matches the objectql changeset's, which is where the gate reads the arm. Right.

③ Boundary flags

Deviations and open questions across the three reports — all answered or escalated:

  • Round 1 (1) transaction() edited beyond the door — justified by a measured regression the unit itself introduced; judged right in ①. (2) spec text — resolved in round 2 under declaration 6107851318. (3) origin/main not merged — see below. (4) ablation B re-run as B2 after the dist preflight refused a build-eliminated marker — right. (5) zero label writes — right. (6) model-free commit trailers per AGENTS.md — right. (7) arm (narrowing) — right. (8) method names kept after check:system-context-census — right. (9) the line-budget correction — the PR body now reads 444/19 at the final head, which matches git diff --stat (9 files, +444/−19; engine source +72/−7).
  • Round 1's three open questions — ruled by the seat (Q1 A, Q2 A, Q3 A), the ruling quoted verbatim in the PR body, each applied and verified in rounds 2–3. Q2 (MongoDB standalone) stays NOT MEASURED (no mongod); the changeset states the replica-set requirement and remedy; the same requirement already binds transaction(), the atomic cascade and the atomic batch; no driver card under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499's freeze — consistent with the ruling.
  • Round 2 (1) gate restart on an unbuilt worktree — fine. (2) the needs-decision section — removed in round 3. (3) the PR body stores no platform footer; attribution is in prose. AGENTS.md's footer form for PR bodies (blank line, rule, one _Generated by …session_…_ line) is not met — minor, no gate reads it; note for the seat.
  • Round 2's open question (the runtime pin conflict) — ruled A (6108611359), applied in round 3, judged in ①.
  • Round 3 (1) three files written at the dev container's filesystem root (/gates4.txt, /gates4.err, /union.txt) — outside the repo; the PR's file list carries nothing at the root (9 files, verified); a manual rm in that container is owed, not a repo concern. (2) re-derivation — fine.

The four out-of-scope findings of round 1:

  • (a) code-registered hook, no onError — correctly noted, no card: HookSchema.onError does not cover it, and kernel/events.mdx:103 already says a registerHook throw propagates.
  • (b) explicit trxCtx handle escaping un-awaited work, and ScopedContext.transaction()'s unclosed ambient entry — should be a card, not a note: it is the same defect class the dev measured for the engine face (Transaction query already complete, write lost) and is reproducible by that same probe on the scoped face. Prime Directive chore: version packages #10 files a reproducible defect.
  • (c) a log hook whose condition cannot be evaluated: HookConditionError refuses the caller on the default door while the row stays stored — a "refused while stored" outcome on a published door that neither the new spec text nor this fix covers. Should be a card (the predicate could open the unit for any meta hook carrying a condition; that is a contract call for the lane, not this PR).
  • (d) driver-memory's whole-store snapshot rollback — pre-existing, frozen test backend; note is right.

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.

main moved: at this reading origin/main (1eff3224d7) is 29 commits past the merge-base 9f5eca52b3 (24 when round 3 reported). git merge-tree --write-tree origin/main 0b6b8275d1 is clean; of the 275 files main touched, none is in this PR's file list. CI ran on the merge ref (07c03d277d, head into b7cd1af9df), which is the joint check. Not a blocker.

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 commit is a rollback while SummaryRecomputeError still says "the records WERE written". The same shape exists today inside a caller's transaction() and the atomic batch; the pins cover the snapshot double and sqlite-wasm only. Worth a probe on the live PG lane.

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 Clause-② lines, with every check-run on this head green.

Implemented-by: claude/issue-22782-after-hook-abort-rollback
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

VERDICT: FAIL

Read at 2026-10-11T13:25Z by the reviewer, read-only, from the shared checkout at /home/user/objectstack (git fetch of the PR head and origin/main only).


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a1ed7388631a7376b9df230401550a1be5b8de6f
Local-runs: none

Re-review after the FAIL record 6109504950 on 0b6b8275d1 (posted 2026-10-11T13:26:06Z). Inputs read: that record; the round-4 os-dev-report 6109907864 on #22782; PR #22819 (body, 11-file list, net diff 9f5eca52b3..a1ed738863 = +447/−22, and the delta 0b6b8275d1..a1ed738863); docs/adr/0087-metadata-protocol-upgrade-contract.md on origin/main for R3 only; the docs-drift comment 6107821481 for its page list; the check-runs and commit status on this head. Read-only: git fetch, git show, git diff, git merge-tree against the shared checkout; nothing built, run or re-run. The declarations (6109534840, 6109539378) and the new cards #22855 / #22856 were not read; they are cited as the report states them.

Check-runs on this head: 42, all completed — 38 success, 4 skipped (the edited-event re-runs of Auto Label and Check PR Size, the path-filtered Console Pin Gate, the opt-in Packed-tarball smoke); commit status Vercel success. None in progress. Check Changeset (both runs), Governed Surface Queue Guard, Lint & Repo Gates (which runs check:doc-anchors), Check Documentation Links, Build Docs, all six Test Core shards, the three Dogfood Regression Gate shards and Temporal Conformance (live PG + MySQL) are green. These conclusions are the gate verdicts.

① Derived judgments

The delta is the four one-line changes the report names, and nothing else — right. git diff --stat 0b6b8275d1..a1ed738863 is 4 files, +4/−4: one line each in content/docs/api/data-flow.mdx, content/docs/kernel/events.mdx, .changeset/22782-objectql-after-hook-abort-rolls-back.md and packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts. git diff 0b6b8275d1 a1ed738863 -- packages/objectql packages/spec packages/qa content/docs/automation content/docs/references is empty: engine.ts, hook.zod.ts, both new test files, the regenerated reference page, hooks.mdx and the spec changeset are byte-identical to the head the prior record judged. The prior record's judgments on those files stand and are not re-derived here — the predicate, the re-entry, the commit-then-rethrow, the ambient close, the nesting, Q1, the spec text and the runtime re-pin's nine assertions. The net diff against the merge-base is +447/−22 over 11 files, which matches the PR body's line budget; 0b6b8275d1 is the parent of a1ed738863 (fast-forward, the nine branch commits unchanged beneath it).

R1 — data-flow.mdx:314 — right. The afterInsert/Update/Delete row now reads: Phase "Post-persist, before the unit of work commits"; Can Abort? "✅ Yes (a blocking hook that throws with onError: 'abort', the default): the write is refused and rolled back. On an object outside the default datasource, or on a driver with no transactions, the caller is refused but the row stays stored", with a link to hooks.mdx. Against the code (byte-identical opensAfterAbortUnit): blocking (!meta.async), onError abort or omitted (the default), default driver by identity, driverSupportsTransactions — the two named exceptions are exactly the predicate's two driver clauses, and "refused but the row stays stored" is the behaviour on those paths. Against hooks.mdx: the row restates hooks.mdx:299 (the first row of its own table) in the same two sentences, and the Phase cell restates hooks.mdx:258 and :291-293 ("dispatched before the enclosing transaction commits"). The Phase cell is vacuous rather than false when no unit exists (an onError: 'log' hook on a plain write runs after the driver's autocommit); it follows hooks.mdx's own framing and I do not count it. Like the spec text, the row leaves out the engine-side opt-outs (metadata-bound only, skipAutomations, no unit already open) — the same acceptance the prior record gave hook.zod.ts; events.mdx:113 carries the metadata-only half. The Callout at :317 ("use onError: 'log' so a failing side effect doesn't roll back the write") now agrees with the row above it. The anchor link: /docs/automation/hooks#after-hooks-run-inside-the-unit-of-work — the heading ## After hooks run inside the unit of work is at hooks.mdx:287, whose github-slugger id is exactly that fragment; the path form matches the 183 other /docs/...#fragment links in the tree; and check:doc-anchors (required, lint.yml:382; it resolves every internal fragment against the destination page's real heading ids) is green on this head. Check Documentation Links resolves the file only (lychee with include_fragments = "none"), so the anchor's evidence is the lint gate plus the read, not that job.

R2 — events.mdx:115 — right. The added sentence: "For an after* hook the write door opens that transaction itself when none is open, but only for an object on the default datasource whose driver declares transactions; elsewhere the operation is refused while its row stays stored." Each clause maps to a predicate condition: "when none is open" = the no-unit-open clause (ambient and threaded); "default datasource" = getDriver(object) === defaultDriver; "driver declares transactions" = driverSupportsTransactions; "blocking" is carried by the bullet's existing "(when the hook is blocking)"; "metadata" by the paragraph it sits in ("Hooks declared as Hook metadata … registerHook() has no onError option"). The "elsewhere" sentence is the same wording as R1 and hooks.mdx:299. The log bullet beneath it is unchanged.

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 origin/main, docs/adr/0087-metadata-protocol-upgrade-contract.md:472 is "### Ratified: the pre-launch launch-window exemption (majors 12–15)"; :489 opens "Amended 2026-09-13 (#18003) — the level half."; :498-500 state "Pre-GA, a metadata-facing retirement or break ships minor, carrying the **BREAKING** banner and its ADR-0087 disposition entry." The section title, the amendment date and its PR are quoted exactly; the changeset carries both carriers the amendment makes mandatory (the banner on the same line, the adr-0087: not-required (no-migration-prescription) marker above it). ADR-0131 no longer appears in the file. The #18003 follows the ADR's own citation form; check:doc-authoring's roots (.claude, docs, skills, content) do not include .changeset/, and 97 of the 372 changesets on main carry a tracker number, so the form is conventional. (The report's :466 is the line at the branch's base; main has since added lines above the section — the citation names no line.)

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: begins 1, commits 1, commits[0] === begins[0], committedNames('thing') equal to ['inner', 'outer']. The title is true against the code (the hook declares no onError, so the door opens the unit; TX_BODY's ctx.api.transaction joins it; one commit, the owner's), and the ownership is pinned on the same wiring by the adjacent re-pinned case (creates[0].transaction === begins[0], creates[1].transaction === begins[0]), which ablation A-r3 reds. But this case's own four assertions held under the FROM shape too — the driver double commits a create with no handle directly, so "the body owns the begin, outer autocommitted, inner committed by the body" gives the same 1 / 1 / identity / sorted-names reading — so the case cannot by itself tell the door's begin from the body's. The stale and false title the prior record flagged is gone, which is what R4 asked; the remaining gap is a test that names more than it asserts, listed in ③ as a three-line addition, not a carrier.

Re-check of the docs-drift pages (6107821481) — no hand-written page still states the old after* abort behaviour. At this head "record is saved" occurs nowhere under content/docs. Read by hand on the 14 hand-written pages: data-flow.mdx:314 (R1) and events.mdx:115 (R2) now state the new contract; hooks.mdx:258-262 and :287-302 are the source text; error-handling-server.mdx:83 ("errors thrown from a hook handler abort the operation and propagate to the API layer") and :220-235 (onError decides whether a failure "aborts the operation or is merely logged"; the example is a before* hook) are true on both sides of the fix; hook-bodies.mdx:265 ("aborting the triggering write under the default onError: 'abort'") is consistent. The other nine pages (ai/skills-reference, data-modeling/formulas, data-modeling/schema-design, deployment/production-readiness, permissions/system-context, plugins/development, protocol/diagram, protocol/objectql/schema, ui/forms) name the events without stating abort semantics. The four release-owned pages are read-only and state nothing this PR falsifies; v17/17-0.mdx:1811 ("a condition that cannot be evaluated aborts the write") is the #4775 text that (c) / #22856 is about, pre-existing and not this PR's.

② Semver level

Unchanged. The delta touches no package source and no test assertion, and only the citation parenthetical in the objectql changeset. @objectstack/objectql stays minor with the **BREAKING** banner, Clause-②: yes (narrowing) and the adr-0087: not-required (no-migration-prescription) marker, now provenanced to the convention that actually carries it (R3); @objectstack/spec stays patch, Clause-②: no. The PR body's Clause-②: yes (narrowing) line matches the objectql changeset, which is where the gate reads the arm. Check Changeset is green on both workflow runs for this head. The prior record's reasoning on both levels stands.

③ Boundary flags

Round 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 _Generated by …session_…_ line) the prior record noted missing, and its line budget (447/22, 11 files; round 4 = +3/−3 against the base) matches git diff --stat. The three stray files at the dev container's root from round 3 remain outside the repo (the file list carries nothing at the root). The report's mcp_calls: 0, two relay strokes and zero label writes are consistent with what the PR and card show.

Round 1's (b) and (c), measured in round 4 and filed as #22855 / #22856 (not read here):

  • (b) — a separate card is right; the fix does not belong in this PR. The measured loss (ScopedContext.transaction() at engine.ts:20382 publishes an ambient entry it never closes and threads its handle explicitly into trxCtx; an async: true hook started inside such a unit loses its late write with Transaction query already complete) is on a unit ctx.api.transaction(fn) opens in-process — the probe's handler is onError: 'log', so the door's unit is not involved. It is pre-existing, the same defect class this PR closed for engine.transaction()'s ambient entry, with a different owner (ScopedContext) and a different mechanism half (the explicit handle). The PR body's acceptance notes disclose it. One sentence in this PR now reads wider than the measurement: the objectql changeset's bullet "Work a unit starts and does not await (an async: true hook) no longer inherits the unit's finished transaction handle … It now runs on its own connection, as it would outside any unit" is true for the door's unit (the dogfood positive control pins it) and for a caller's engine.transaction() callback whose writes enrol ambiently, and false for the ctx.api.transaction(fn) unit the (b) probe measured. The prior record read and accepted that bullet before the measurement existed; by its own calibration (R2: true where it holds, lacking the where-it-holds qualifier) this is a recommendation, not a carrier. It ships to CHANGELOG.md exactly true only if objectql: ScopedContext.transaction() publishes an ambient transaction entry it never closes, so an async hook's later write inside it is lost ("Transaction query already complete") #22855's close lands in the same release; otherwise the bullet owes one scope phrase — "the door's unit, or a caller's transaction() callback; a unit opened in-process by ctx.api.transaction(fn) still threads its handle, tracked separately" — before changeset version consumes the file. R5, recommended.
  • (c) — a separate card is right; it does not belong in this PR. HookConditionError is raised outside onError by design (hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775, hook-wrappers.ts:100-133), so a log hook whose condition cannot be evaluated refuses the caller while the row stays stored on the default door (measured on REST createData: 500 with the row present; the abort control rolls back because the door's unit opens for it). The objectql: an afterInsert hook with onError: 'abort' (the default) that throws rejects the write, but the row stays stored — HookSchema.onError says abort rolls the transaction back, and a plain write opens none #22782 contract is onError: 'abort'; whether the door's predicate should also open for any after* hook carrying a condition, or the condition gate should move, is the lane's contract call. Neither the new spec text nor the two docs rows claim anything about an unevaluable condition, so this PR states nothing (c) contradicts.

R6, recommended (from R4): add to the sibling case the three handle-identity lines its neighbour already has — expect(seen.creates).toHaveLength(2), expect(seen.creates[0].transaction).toBe(seen.begins[0]), expect(seen.creates[1].transaction).toBe(seen.begins[0]) — so the title "the door's unit owns the one begin, the sandbox body joins it" is asserted by the case that carries it, and ablation A reds it too. Test-only, in the file already declared on #6024; not a carrier because the claim is true and pinned in the same describe block on the same wiring.

main moved: origin/main is 12b9daf749, 35 commits past the merge-base 9f5eca52b3 (29 at the prior record, 34 when round 4 reported). git merge-tree --write-tree origin/main a1ed738863 is clean (tree 3a5e8a0da9); of the 329 files main touched since the merge-base, none is in this PR's 11-file list; the PR's mergeable_state is clean. CI ran on the merge ref 9616b56e77 (head into 31b5a5f7f5), which is the joint check at that reading. Not a blocker.

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 (docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md, CLAUDE.md); the head repo is the base repo; Governed Surface Queue Guard is green. The PR is still a draft — readying and queueing are the owning seat's acts, not this record's.

Implemented-by: claude/issue-22782-after-hook-abort-rollback
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

VERDICT: PASS

Read at 2026-10-11T14:24Z by the reviewer, read-only, from the shared checkout at /home/user/objectstack (git fetch of the PR head and origin/main only). This record names a1ed738863 only; the prior record names 0b6b8275d1.

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/m tests tooling

Projects

None yet

2 participants