Repository navigation
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
Description
Activity
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·priority:p2·domain:engine·area:workflow, kept inpm:queue. Direction (A), measured firstTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T06:57Z. ⛔ Not a claim, ⛔ not a dispatch.- Lane: the write door's hook dispatch in
packages/objectql/src/engine.tsisdomain:engine. Hooks are documented undercontent/docs/automation/hooks.mdx, so the area isarea:workflow. - Why p2: the caller is told a create failed while the row exists, on the default
onErrorand on the default write door. A retry makes a duplicate. - The gate's own history, read before grading: the 2026-08-11 ruling on
afterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477 (5248467447).- It ruled WHEN
after*fires: inside the unit of work, meaning "will happen unless this unit is undone". - Its guardrails bar fire-after-commit (its Option 2) and an authorable
timing(its Option 3). They keep 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 pinned hook count and order. - It did not rule whether a plain write opens a unit. So (A) here does not reopen
afterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477: it gives the declared meaning a unit to undo. ⛔ No decision card is owed for (A).
- It ruled WHEN
- Direction: (A), measured first.
- (B) alone documents a default (
abort) whose only effect on the default door is to refuse a write that was stored. That is not a coherent contract to publish. - Step 1, measure before writing:
- which drivers implement
transaction(), and what a driver without it does inside one; - the per-write cost of opening a unit only when the object has a blocking
after*hook whoseonErrorisabort; - that a caller-opened
transaction()nests, and does not open a second unit.
- which drivers implement
- Step 2: the write door opens that unit for exactly that case, so an
abortrolls back the row and anyctx.apiwrites the hook chain made through the engine. Hook count and order stay 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 pins them. - When the measurement points to (B): for example, a supported driver has no transaction, so (A) cannot hold on it. Then (B) lands for that driver only. Its text says what
abortmeans there, and the changeset states FROM → TO. - Escalate: if Step 1 finds that (A) needs a new authorable key, or must change 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 order, stop. That goes to a decision card with the measurement attached.
- (B) alone documents a default (
- Workload: (A) is M: one dispatch site plus per-driver measurement and pins. (B) is S, docs only.
- Pins: throwing
afterInsertwith the defaultonError→ the row is absent and the caller is refused. CONTROL:onError: 'log'→ the row is stored. CONTROL: inside a caller-openedtransaction()→ unchanged. CONTROL: a throwingbefore*hook → unchanged. Clause-②: yes: it changes the stored outcome of a published door.
- Lane: the write door's hook dispatch in
- addedarea:workflowApprovals and automation — the work that runs without a person driving itApprovals and automation — the work that runs without a person driving itpriority:p2Medium: important, M3Medium: important, M3
on Oct 11, 2026 objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsClaim: PM loop round 3 · 2026-10-11T07:36Z
Session:session_01JfJfBUC3cQ6hhgm9MQK76T
Account:os-project-manager(the seat's linked user, asGET /useranswers it; the card's assignee)
Branch:claude/issue-22782-after-hook-abort-rollback
Worktree:objectstack-issue-22782
Domain:domain:engine
Seat:domain:engine#1(seat post #6367)
File surface (read onorigin/mainc74d843997; stop on a breach and explain it in the report):packages/objectql/src/engine.ts: the write door'safter*hook dispatch. Following triage's direction (A), after its Step 1 measurement: a write whose object has a blockingafter*hook withonError: 'abort'runs inside a unit of work when none is open, so an abort rolls back the row and the hook chain's engine writes. Hook count and order stay 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 pins them.- Tests under
packages/objectql/src/, real-door pins inpackages/qa/dogfood(domain:cli, test files only, declared here), and one changeset. - Conditional, declared before any edit:
- If the measurement routes a driver to (B),
packages/spec/src/data/hook.zod.ts'sonErrortext for that case. This is the spec's published text, so it isdomain:specand declared on [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017 before the edit. content/docs/automation/hooks.mdx(domain:devx).
- If the measurement routes a driver to (B),
- ⛔ No new authorable key, and no change to 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 hook count or order. Either one stops the card for a decision card, with the measurement attached (triage 6106449072).
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate)
Clause-②: yes - It changes the stored outcome of a published door, per triage: with an
after*abort, a row the default door stores today is rolled back. It owes one contract-review-tier review before the queue, and a changeset stating FROM → TO. The arm is measured and stated by the PR.
Responsibility: the write door, which opens no unit of work on a plaininsert, so anafter*hook'saborthas nothing to roll back |HookSchema.onError(abort: Rollback transaction), andafterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477's ruling thatafter*runs inside the unit of work when one is open | any caller of the default write door on an object with a throwingafter*hook: it is told the write failed while the row exists, and a retry duplicates it; measured on a booted stack by verify: the in-process handle boots a leaner stack thanserveand has no door for eight things an app's tests need (requires[] capabilities, system/predicate update, the form door, user-less triggers, …), measured by hotcrm#2013 #22301's dev
Thread-read: 6106449072
Serial constraints cleared: - No open PR touches
packages/objectql/src/engine.ts's hook dispatch orhook.zod.ts. - In flight in this lane: security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 (
metadata-protocol'sprotocol.tsandpackages/types) and import: auserfield written withoutreferencefailsreference_not_foundon import, while the spec's arbiter gives itsys_user#22785 (core'simport-field-meta.ts). Both are disjoint files.
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22782,
"status": "done",
"branch": "claude/issue-22782-after-hook-abort-rollback",
"pr": "#22819",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T (subagent run; this is the parent PM session's id)",
"premise_still_valid": true,
"summary": "Direction (A), measured first. WHAT CHANGED: the write door (insert / update / delete in packages/objectql/src/engine.ts) opens a unit of work exactly when the object has a metadata-bound (meta), blocking (not async) after* hook whose onError is abort or omitted, no unit is open (neither ambient nor threaded), automations are not skipped, the object is on the default driver, and that driver declares transactions. The unit runs transaction() and re-enters the same door method, which joins it. An abort now rolls back the row and the hook chain's ctx.api writes. #7413's hook count and order are unchanged; no new authorable key; no commit-time trigger and no timing key. SummaryRecomputeError ('records WERE written') is rethrown after the commit, never through the rollback. transaction() now closes its ambient ALS entry when the callback settles. Measured: an async:true hook started inside the unit lost its write with 'Transaction query already complete' (probe on the verify stack: inUnit 0 vs noUnit 1). STEP 1 MEASUREMENTS. Transactions by symbol, not by grep: driver-sql and driver-sqlite-wasm (subclass): (A) holds. driver-turso: local/replica hold; remote declares transactionsUnsupported, so no unit and (B). driver-memory: implements the trio with a whole-store deep-copy snapshot (O(rows) per unit; a rollback drops concurrent writes); not a boot store. driver-mongodb: implements the trio; its README says a replica set is required. NOT MEASURED: no mongod in this container. The PM's grep, which saw only sql and turso, missed memory and mongodb. Cost on sqlite-wasm (verify stack, 5x100 interleaved inserts, shared box): 1.309 vs 0.961 ms per insert, a ratio of 1.36 against the same object with an onError:'log' hook; no-hook objects pay one map lookup. Nesting: a caller-opened transaction() opens no second unit (pinned: one beginTransaction). (B) holds where (A) cannot: an object off the default datasource (transaction() covers the default driver only, as in #7413's 'split' verdict), and a driver without transactions. FILES_CHANGED: packages/objectql/src/engine.ts (+72/-7); packages/objectql/src/engine-after-hook-abort-unit.test.ts (+170); packages/qa/dogfood/test/hook-after-abort-rollback.dogfood.test.ts (+116); content/docs/automation/hooks.mdx (+3/-2); .changeset/22782-objectql-after-hook-abort-rolls-back.md (+18). LINE_BUDGET: +379/-9 against the 300 suggestion; source +72 against 80. The excess is the two test layers: the triage's 4 pins plus measurement pins, and the real-door file plus the regression pin the unit introduced. DEVIATIONS: (1) transaction() edited beyond the door: the ambient close was needed because the unit itself made an async hook's write fail. (2) Spec text not edited. hook.zod.ts HookEvent JSDoc ('Three ordinary ways') is now incomplete and needs the domain:spec seat's #6017 declaration; see open_questions. (3) origin/main not merged. The tree is 17 commits behind; in objectql those commits touch only filter-comparand-shape.ts and a new test; merge-tree is clean; CI's merge ref is the joint check. (4) Ablation leg B's first attempt was a no-op: the marker was eliminated by the build and the dist preflight refused; it was re-run as B2. (5) Zero label writes: the dispatch named no label and skip-changeset does not apply (the package publishes; changeset present). (6) Commits carry AGENTS.md's model-free trailer pair rather than the harness reminder's model-named trailer. (7) Clause-② arm measured as (narrowing), written in the changeset and the PR body as 'Clause-②: yes (narrowing)'. (8) The bodies kept their method names (insert/update/delete) after check-system-context-census flagged a rename. (9) Correction the seat should write, since the dev does not PATCH the body: PR #22819's 'Line budget' section says 376 additions and 9 deletions with 283 test lines. It was written before the double fix; at 1c880cd the counts are 379 additions and 9 deletions with 286 test lines.",
"tests": "HEAD 1c880cd (source byte-identical to d572e29; only the unit test's double changed). objectql focused, at 1c880cd: vitest run src/engine-after-hook-abort-unit.test.ts src/engine-cascade-delete-atomic.test.ts src/engine-summary-retry.test.ts -> 'Test Files 3 passed (3) / Tests 25 passed (25)'. objectql typecheck, at 1c880cd: tsc --noEmit clean; 'check:test-typecheck: OK ... 40 file(s) / 234 error(s) held'. objectql full local project, at d572e29: 'Test Files 399 passed (399) / Tests 7803 passed (7803)'. objectql repo project: 1 file, 5 passed. dogfood, at d572e29: test/hook-after-abort-rollback.dogfood.test.ts + test/hook-error-format.dogfood.test.ts -> 'Test Files 2 passed / Tests 10 passed'. verify canaries, at d572e29: src/handle.system-insert-delete.test.ts + src/handle.exemplar-deal-lifecycle.test.ts -> 'Test Files 2 passed / Tests 19 passed' (a blocking afterInsert/afterDelete capture hook plus record-change flows, now inside the unit). dogfood typecheck: clean; --listFiles includes the new file. eslint --no-inline-config --format json over the 5 changed paths: the .md and .mdx are 'File ignored because no matching configuration'; the 3 .ts files report 0 errors and 0 warnings; eslint.config.mjs enables no type-aware linting (no parserOptions.project), so untouched files' verdicts cannot move; repo-wide pnpm lint is left to CI. GATES at 1c880cd: the union of the dispatch's 80 and dispatch-gates --commands (97 families) is 113 commands, all exit 0. dispatch-gates --ran: '97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero: every exit code recorded). check:skill-examples and check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, unbuilt packages) and exited 0 once those were built. check:objectql-double-limit first exited 1 (the new driver double was limit-blind) and was fixed in 1c880cd. check:system-context-census first exited 1 (renamed bodies) and was fixed in d572e29. check:query-options-erasure once hit ENOENT, a race with a concurrent check:skill-examples run, and is exit 0 on re-run. ABLATIONS: each leg used scripts/ablation-replace.mjs (anchor 1->0, blob changed), a rebuild and ablation-dist-preflight where dist is read, a restore proven by blob==HEAD and an empty git diff HEAD, and --absent after the rebuild. Legs A, C and E were re-run on the final shape (d572e29). A, the predicate always answers no (marker in 4 dist files): 3 unit cases red ('expected [ { name: c1 } ] to deeply equal []') and the dogfood REST and hooks.run pins red ('expected 1 to be +0'). This is the reproduction of the card on REST createData and hooks.run; direction: red as expected. B2, ambient entry not closed: the dogfood positive control is red ('expected +0 to be 1': the async hook's late write was lost). C, SummaryRecomputeError goes through the rollback: the roll-up case is red. D, the beforeInsert veto never matches (fixture): the before* control is red ('{ status: 201 }'). E, the predicate ignores async and onError: the log control and the no-unit case are red ('[ { trx: 1 } ] length 1').",
"mcp_calls": "0",
"api_writes": "5 — two repository_dispatch POSTs (POST /repos/objectstack-ai/objectstack/dispatches) through scripts/pm/with-fleet.sh --via dispatch, which the relay executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft #22819) + POST /repos//issues/22819/assignees (os-project-manager); (2) comment = POST /repos//issues/22782/comments (this report). git push is not counted. Zero label writes.",
"open_questions": [
{
"question": "Inside the door's new unit, a blocking abort after* hook on a default-datasource object that writes a BUSINESS row to an object on another datasource is now refused with CrossDatasourceTransactionWriteError, the #5696 rule for any unit, and the abort turns that into a refused write. Today that write succeeds. In-tree reach is zero: the one first-party blocking abort after* hook, app-todo task_logic, only logs. Is the refusal the intended answer?",
"options": [
"A: Keep the refusal, as implemented: one rule for every unit, loud on the first write, remedy stated in the changeset (onError log, async, or one datasource). Cost: a deployment with such a hook breaks loudly until it applies the remedy. Workload: none.",
"B: Carve cross-datasource business writes out of the door's implicit unit, executing them outside like a system ledger. Cost: an abort rolls back only part of the chain, silently, which is a second rule beside #5696 and a consumer-side leniency. Workload: S.",
"C: Do not open the unit at all when the hook chain might write elsewhere. Cost: not decidable statically (hooks are code). Workload: n/a."
],
"recommendation": "A, because it adds no rule (the #5696 ruling already governs writes inside a unit), fails loudly rather than partially, and has measured zero in-tree reach; the changeset states FROM -> TO and the remedy."
},
{
"question": "MongoDB default store on a standalone server: driver-mongodb publishes beginTransaction/commit/rollback but its README says multi-document transactions need a replica set, so writes inside the door's unit fail on a standalone server. NOT MEASURED here (no mongod). transaction(), the #7413 atomic cascade and the atomic batch already behave this way, but the door makes it reachable on every write to an object with such a hook.",
"options": [
"A: Ship as is; the changeset names the replica-set requirement and the remedy (onError log / async). Workload: none.",
"B: driver-mongodb declares supports.transactionsUnsupported after connect when the topology is standalone, so every engine unit takes the declared ADR-0119 D1 path there. Cost: that bit's spec text currently scopes it to inherited methods, so this is a domain:spec text change plus a driver change, with tests that need a mongod. Workload: S-M.",
"C: Exclude drivers by name. Cost: a workaround, refused by Prime Directive 5."
],
"recommendation": "A in this PR, plus B as its own card in the driver lane, because B fixes all four transaction paths at the producer (the driver's declaration) and needs a spec-text ruling this card may not make."
},
{
"question": "Spec text made incomplete by this change, which this dev may not edit: packages/spec/src/data/hook.zod.ts. (1) HookEvent's JSDoc says 'Three ordinary ways a write ends up inside such a unit' and needs the fourth (the write door, for a blocking abort after* hook). (2) onError's TSDoc/describe ('abort: Rollback transaction (if blocking)') should state where it holds (the default datasource on a driver with transactions) and what abort means elsewhere (the caller is refused, the row stays stored). The claim makes this domain:spec, declared on #6017 by the seat before the edit.",
"options": [
"A: The seat declares on #6017 and the edit rides PR #22819 before the queue (a JSDoc edit plus gen:schema/gen:docs if describe() moves). Workload: S.",
"B: A follow-up spec-text PR right after this lands. Cost: the published text is incomplete for one release window."
],
"recommendation": "A, because the docs page (hooks.mdx) already states the fourth path in this PR, and the spec JSDoc is the text an author's editor shows."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — 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 HookSchema.onError's contract (PR Acceptance notes).",
"carrier: none · noted, not filed — a caller-opened transaction() threads its handle explicitly (trxCtx), so work escaping that callback still carries the finished handle; the close here covers the ambient entry only; ScopedContext.transaction() publishes an ambient entry it does not close. Read from code, not measured (PR Acceptance notes).",
"carrier: none · noted, not filed — an after* hook with onError 'log' whose condition cannot be evaluated raises HookConditionError, which onError never softens (#4775); such a hook opens no unit, so the caller is refused while the row stays stored. Read from code, not measured (PR Acceptance notes).",
"carrier: none · noted, not filed — a driver-memory rollback restores a whole-store snapshot and drops concurrent writes; pre-existing, reachable from any unit (PR Acceptance notes)."
]
}objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22782,
"status": "needs_decision",
"branch": "claude/issue-22782-after-hook-abort-rollback",
"pr": "#22819",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T (subagent run, round 2; this is the parent PM session's id)",
"premise_still_valid": true,
"summary": "ROUND 2 DONE (head ad05283, pushed fast-forward from 1c880cd; no rebase or force; origin/main not merged, since merge-tree is clean and no PR file overlaps). Q3: packages/spec/src/data/hook.zod.ts changed in text only (commit 536cd0e). HookEvent's JSDoc now lists 'Four ordinary ways', the write door's unit being the new one. onError's TSDoc and describe() 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 by 'check:generated --fix' (gen:docs only, the one artifact it proved stale), and .changeset/22782-spec-hook-onerror-abort-text.md adds an @objectstack/spec patch (commit ad05283). This also fixes CI's check:docs red on 536cd0e. Q1 and Q2: the objectql changeset already names the cross-datasource population, the replica-set requirement and the remedy (onError log, async, one datasource); it was not edited. The PR body was edited once (issue_patch): the line budget at the final head, the verbatim rulings, the round-2 verification, a needs-decision section, and attribution in prose (no footer sent; stored body identical, 17220 bytes). WHY needs_decision: CI's runtime red is a genuine conflict between this card's contract and a pinned test, so per the coordinator's instruction the test was not edited and this is reported instead. The test is packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts, case 'with NO host transaction a throwing body still ROLLS BACK its own — unchanged' (line 230). Its sandboxed afterInsert body declares no onError (so abort) and throws, and it pins committedNames == ['outer']: the refused insert's row stays stored. That is exactly #22782's defect. Reading 1, this PR: the door opens a unit; the body's ctx.api.transaction joins it (begins 1); the abort rolls the outer row back: committed [], rollbacks 1, commits 0. The assertion fails with "expected [] to deeply equal [ 'outer' ]", reproduced locally. Reading 2, the door's unit ablated (leg A: rebuild, dist preflight marker present in 4 files, restored and --absent proven): the file passes 6/6. Under the same ablation this PR's own pins go red (round 1). No code state satisfies both readings. #6406's own subject still holds under reading 1: a single begin, the joined rollback abstains, and no second connection (the file's other 5 cases pass). FILES_CHANGED round 2: packages/spec/src/data/hook.zod.ts (+21/-3); content/docs/references/data/hook.mdx (+1/-1); .changeset/22782-spec-hook-onerror-abort-text.md (+12). LINE_BUDGET final head ad05283: +413/-13 over 8 files (engine source +72/-7; tests 286; spec text +21/-3). DEVIATIONS: (1) the round-2 gate run was restarted once after a fresh worktree lacked built packages (two lint gates exited 3); my orphaned check:generated process tree was killed by PID; (2) the PR body records the conflict as a 'Status: needs a decision' section; (3) the PR body edit stored no platform footer, so attribution is in body prose.",
"tests": "At ad05283. Spec: check:generated, check:docs, check:authorable-surface and check:api-surface each exit 0 ('All 14 generated artifacts are up to date', against the dist that check:generated --fix had built; its first run exit 1 named only content/docs/references/** stale). pnpm --filter @objectstack/spec run typecheck: exit 0 (check:test-typecheck OK). Spec local project: 'Test Files 645 passed (645) / Tests 19311 passed | 1 todo'. Runtime: the single file 'Tests 1 failed | 5 passed (6)' (FILE-EXIT=1) at transaction-ambient-join.integration.test.ts:230:37. Full runtime local project: 'Test Files 1 failed | 348 passed (349) / Tests 1 failed | 5031 passed | 19 skipped (5051)', the same single case. Runtime resolves @objectstack/objectql through dist (no alias in packages/runtime/vitest.config.ts; require.resolve gives packages/objectql/dist/index.js; dist rebuilt at this head and containing opensAfterAbortUnit). Reading-2 ablation of the runtime file: 'Tests 6 passed (6)', LEG-TEST-EXIT=0; restore blob 1fef9c8fc2a1 == HEAD; ABSENT-PREFLIGHT-EXIT=0. Own pins at ad05283 (objectql dist --absent preflight exit 0): engine-after-hook-abort-unit.test.ts 'Tests 8 passed (8)'; hook-after-abort-rollback.dogfood.test.ts 'Tests 6 passed (6)'. GATES at ad05283: the union of the dispatch's 80, dispatch-gates --commands (115 families, spec paths included) and the 4 named spec gates is 115 commands, all exit 0. These include check:objectql-double-limit, both node scripts/check-system-context-census.mjs commands, and check:docs, check:generated, check:authorable-surface and check:api-surface. dispatch-gates --ran: '115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero: every exit code recorded). Tree 21 commits behind origin/main; git merge-tree clean.",
"mcp_calls": "0",
"api_writes": "2 round-2 relay strokes — POST /repos/objectstack-ai/objectstack/dispatches x2 via scripts/pm/with-fleet.sh --via dispatch, executed as objectstack-fleet[bot]: (1) issue_patch = PATCH /repos//issues/22819 (PR body); (2) comment = POST /repos//issues/22782/comments (this report). git push x3 (536cd0e, ad05283, fast-forward) is not counted. Zero label writes.",
"open_questions": [
{
"question": "packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts, case 'with NO host transaction a throwing body still ROLLS BACK its own — unchanged', pins that a sandboxed afterInsert body with the default onError (abort) that throws leaves the outer row stored (committedNames == ['outer']). That is the behaviour #22782 removes. Measured: with this PR committed is [] (begins 1, rollbacks 1, commits 0); with the door's unit ablated the file passes 6/6 and this PR's pins go red. Which reading lands?",
"options": [
"A: Re-pin that case in this PR to the #22782 contract: the abort rolls back the outer row (committed [], begins 1 owned by the door, the body joins, rollbacks 1, commits 0). Keep its #6406 subject: one begin, no second connection, the joined rollback abstains. Reword its 'unchanged' title and comment, and the sibling 'still OPENS one' case's comment ('the body's is, and owns it' — the begin is now the door's; its assertions already pass). Cost: one runtime test file edited, in domain:engine's own lane, as a contract change and not a weakening. Workload: S.",
"B: Keep the test as is and exempt this configuration from the door's unit (for example a hook body that uses ctx.api.transaction). Cost: an ad-hoc carve-out, and HookSchema.onError's abort would again leave the row stored for that hook, which contradicts the spec text this PR just landed. Workload: S.",
"C: Withdraw the door's unit (fall back to (B) for every driver: the spec text says abort refuses the caller and the row stays stored). Cost: reverses triage's direction (A) and the round-2 spec text; the duplicate-on-retry defect stays. Workload: M."
],
"recommendation": "A, because the case's own premise ('the outer one was never transactional') is the defect this card was ruled to fix. Its #6406 subject (join, no second begin, abstaining rollback) is measured intact under this PR. B is a consumer-side carve-out that re-creates the contract violation, and C reverses a ruled direction."
}
],
"out_of_scope_findings": []
}objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22782,
"status": "done",
"branch": "claude/issue-22782-after-hook-abort-rollback",
"pr": "#22819",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T (subagent run, round 3; this is the parent PM session's id)",
"premise_still_valid": true,
"summary": "ROUND 3 per the seat's ruling A, head 0b6b827 (fast-forward from ad05283; no rebase or force; origin/main 24 commits ahead, merge-tree clean, not merged). FILES_CHANGED round 3: packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts (+30/-6) and .changeset/22782-objectql-after-hook-abort-rolls-back.md (+1). No runtime source and no other case changed. The case is re-pinned to the #22782 contract and 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 (#22782)". Its comment states FROM -> TO, citing #22782 and triage 6106449072. It went from 5 assertions to 9: refused /body boom/; begins 1; creates 2; creates[0].transaction === begins[0] (the outer write is in the door's unit); creates[1].transaction === begins[0] (the body joined); rollbacks 1; rollbacks[0] === begins[0] (the joined rollback abstained); commits 0; committedNames []. The sibling case 'still OPENS one' changed in its comment only (the begin is now the door's); its assertions and title are unchanged. Its title still reads 'the sandbox still OPENS one — unchanged behaviour', which is now stale, and it was left as instructed. The changeset gains the line: a sandboxed hook body's own ctx.api.transaction with no open transaction now joins the door's unit, and the abort rolls back the triggering row too. The PR body was edited once (issue_patch): the needs-decision section removed, ruling A quoted verbatim, the round-3 verification added, the line budget set to the final head; stored identical, 18468 bytes. LINE_BUDGET at 0b6b827: +444/-19 over 9 files (engine source +72/-7). DEVIATIONS: (1) a bash '&' grouped the cd and SP assignment into a background job, so one derivation ran with an empty SP and wrote /gates4.txt, /gates4.err and /union.txt at the filesystem root. Removing them was denied by the permission system and was not retried; they hold only the gate list and derivation stderr and need a manual rm. (2) The round-3 derivation was re-run correctly in the worktree.",
"tests": "At 0b6b827; objectql dist has the change and the ablation-dist-preflight --absent check exits 0. Runtime file src/sandbox/transaction-ambient-join.integration.test.ts: 'Tests 6 passed (6)'. ABLATION A-r3 (door unit removed via ablation-replace, anchor 1->0, rebuild, preflight marker in 4 dist files): 'Tests 1 failed | 5 passed (6)', LEG-TEST-EXIT=1. The re-pinned case is red at "expected undefined to be { __trx: 1 }" (the outer create carries no unit). Restore: blob 1fef9c8fc2a1 == HEAD, git diff HEAD empty, ABSENT-PREFLIGHT-EXIT=0, tree clean. Full runtime local project: 'Test Files 349 passed (349) / Tests 5032 passed | 19 skipped (5051)'. Own pins: objectql src/engine-after-hook-abort-unit.test.ts 'Tests 8 passed (8)'; dogfood test/hook-after-abort-rollback.dogfood.test.ts 'Tests 6 passed (6)'. GATES at 0b6b827: dispatch-gates --commands re-derived 115 families, the same set as round 2 (the runtime test path adds none). The union with the dispatch's 80 and the 4 named spec gates is 115 commands, all exit 0, including check:objectql-double-limit, check-system-context-census (both commands), and check:docs, check:generated, check:authorable-surface and check:api-surface. dispatch-gates --ran: '115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero: every exit code recorded).",
"mcp_calls": "0",
"api_writes": "2 round-3 relay strokes — POST /repos/objectstack-ai/objectstack/dispatches x2 via scripts/pm/with-fleet.sh --via dispatch, executed as objectstack-fleet[bot]: (1) issue_patch = PATCH /repos//issues/22819 (PR body); (2) comment = POST /repos//issues/22782/comments (this report). git push x1 (0b6b827, fast-forward) is not counted. Zero label writes.",
"open_questions": [],
"out_of_scope_findings": []
}objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22782,
"status": "done",
"branch": "claude/issue-22782-after-hook-abort-rollback",
"pr": "#22819",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T (subagent run, round 4; this is the parent PM session's id)",
"premise_still_valid": true,
"summary": "ROUND 4 per contract review 6109504950 (FAIL on 0b6b827, carriers R1 and R3), head a1ed738 (fast-forward; no rebase or force; origin/main 34 ahead, merge-tree clean, not merged). Text only: no engine source and no assertion changed. R1: content/docs/api/data-flow.mdx lifecycle table, the after* row. 'Can Abort? ❌ No (record is saved)' is now the #22782 contract (a blocking onError 'abort' hook, the default, refuses the write and rolls it back; on an object outside the default datasource or a driver with no transactions the caller is refused but the row stays stored), with a link to hooks.mdx#after-hooks-run-inside-the-unit-of-work; the Phase column reads 'Post-persist, before the unit of work commits'. R2: content/docs/kernel/events.mdx onError 'abort' bullet gains where the rollback holds, in the same wording. R3: the objectql changeset's '(ADR-0131 D3, D9)' is replaced with '(ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003)'. That text is verified at docs/adr/0087-metadata-protocol-upgrade-contract.md:466 ('Ratified: the pre-launch launch-window exemption') and its 'Amended 2026-09-13 (#18003)' paragraph ('Pre-GA, a metadata-facing retirement or break ships minor'). R4: the runtime sibling case's 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)"; assertions unchanged. The PR body was edited once (issue_patch): line budget 447/22 over 11 files, the round-4 section, and AGENTS.md's footer block restored (one session-URL footer stored; 20608 bytes identical on read-back). FILES_CHANGED round 4: content/docs/api/data-flow.mdx (+1/-1), content/docs/kernel/events.mdx (+1/-1), .changeset/22782-objectql-after-hook-abort-rolls-back.md (1 line rewritten), packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts (+1/-1). LINE_BUDGET at a1ed738: +447/-22 over 11 files. Measure-only items (b) and (c): readings are in out_of_scope_findings; nothing was fixed; the scratch probe was deleted before the push. DEVIATIONS: none this round; the stray /gates4.txt, /gates4.err and /union.txt from round 3 remain at the container root.",
"tests": "At a1ed738; objectql dist has the change and the ablation-dist-preflight --absent check exits 0. Runtime src/sandbox/transaction-ambient-join.integration.test.ts: 'Tests 6 passed (6)'. Own pins: objectql src/engine-after-hook-abort-unit.test.ts 'Tests 8 passed (8)'; dogfood test/hook-after-abort-rollback.dogfood.test.ts 'Tests 6 passed (6)'. Named gates, all exit 0: pnpm --filter @objectstack/spec run check:docs; pnpm check:doc-authoring; node scripts/docs-audit/check-affected-docs.mjs; node scripts/docs-audit/check-drift-comment.mjs; node scripts/check-adr-0087-registration.mjs --base origin/main; node scripts/check-changeset-no-major.mjs --base origin/main; node scripts/check-empty-changeset.mjs --base origin/main. dispatch-gates --commands at a1ed738 re-derived 115 families, the same set as round 3. The union with the dispatch's 80 and the named gates is 115 commands, all exit 0. dispatch-gates --ran: '115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero: every exit code recorded). Probe for (b) and (c): a scratch dogfood file on the booted verify stack (sqlite-wasm), 'Tests 2 passed (2)', deleted before the push.",
"mcp_calls": "0",
"api_writes": "2 round-4 relay strokes — POST /repos/objectstack-ai/objectstack/dispatches x2 via scripts/pm/with-fleet.sh --via dispatch, executed as objectstack-fleet[bot]: (1) issue_patch = PATCH /repos//issues/22819 (PR body); (2) comment = POST /repos//issues/22782/comments (this report). git push x1 (a1ed738, fast-forward) is not counted. Zero label writes.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: measured on the engine door (verify hooks.run, a published door); named producer: any metadata or action handler that calls ctx.api.transaction(fn), which is ScopedContext.transaction() at packages/objectql/src/engine.ts:20382 (the QuickJS sandbox does not reach it: it drives the discrete begin/commit/rollback trio) · evidence: when that method opens its own unit it publishes an ambient entry it never closes (txStore.run at about :20453) and threads the handle explicitly into trxCtx. Probe: prq_trigger has an afterInsert handler (onError log, so no door unit) that runs ctx.api.transaction(trx => trx.object('prq_case').insert(...)); prq_case has an async:true afterInsert hook that waits 40 ms and then writes a note through ctx.api. Readings: caseRowViaScopedTx 1, lateNoteViaScopedTx 0, error 'Transaction query already complete, run with DEBUG=knex:tx for more info'; control, the same insert with no transaction: lateNoteNoTx 1. This is the same defect class #22782 closed for engine.transaction() (its ambient close); the explicit trxCtx handle half also escapes from a caller-opened engine.transaction() · dedupe words: ScopedContext.transaction unawaited work; Transaction query already complete async hook; ctx.api.transaction ambient entry not closed; fire-and-forget hook write lost after commit",
"class: a · reach: measured on REST POST /data/:object (createData) · evidence: #4775's stated intent (packages/objectql/src/hook-wrappers.ts:100-124) is that an unevaluable condition aborts the operation, and that 'a typo in an afterUpdate audit condition fails the write it was only watching'; HookConditionError is raised outside onError on purpose (:127-133), so onError 'log' never softens it. Probe: prq_cond_log has an afterInsert hook with onError 'log' and condition "previous.name == 'x'" (previous is unbound on an insert). REST createData answered 500 INTERNAL_ERROR ('Internal server error') with the row STORED (1): the caller is refused while the row exists, the same outcome #22782 fixed for abort hooks. Control: the same condition on a default-onError hook (prq_cond_abort) answered 500 with the row ABSENT (0), because the door's unit opens for an abort hook and rolls it back. Also observed: the refusal is a sanitised 500, not a 4xx naming the hook and key, on this door. Candidate fixes are a lane contract call (for example the door's predicate also opening for any meta after* hook that carries a condition) · dedupe words: HookConditionError log hook row stored; unevaluable condition after hook refuses write row persists; condition gate outside onError after insert; previous unbound afterInsert condition 500"
]
}objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsACCEPT (seat review): PR #22819 at head
a1ed738863domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-11T14:25Z. Claim 6106744769.- Dev reports: 6107830381 (round 1), 6108597601 (round 2), 6108994502 (round 3), 6109907864 (round 4).
- Contract review: FAIL 6109504950 on
0b6b8275d1(two shipped-text defects), then PASS 6110025812 on this head.
Read against GitHub and
origin/main, not against the reports.Shape.
- Ready to queue: base
main, assignedos-project-manager,mergeable_stateclean. - Line 1 is
Fixes #22782, and a closing-keyword scan of the whole body finds that line only. Clause-②: yes (narrowing).- 11 files, +447/−22. NOT governed.
What it does.
- The default write door (
insert/update/delete) opens a unit of work exactly when the object has a metadata-bound, blockingafter*hook whoseonErrorisabortor omitted, under all of these:- no unit is already open;
- automations are not skipped;
- the object is on the default datasource, and its driver declares transactions.
- The unit re-enters the same door method, which joins it. An abort rolls back the row and the hook chain's writes. 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 hook count and order are unchanged, and there is no new authorable key.
SummaryRecomputeError("records WERE written") is rethrown after the commit.transaction()closes its ambient entry when the callback settles. Measured: before, anasynchook's late write was lost.- Elsewhere (off the default datasource, or a driver without transactions) the caller is refused and the row stays stored. The spec text now says so.
Ruled by this seat across the rounds:
- Q1 A: a cross-datasource business write inside the unit is refused by [spec] engine.transaction 契约收紧:
opts.requirefail-closed、跨驱动拒绝、owned-vs-joined 信号(#4619 的契约半边,维护者已批 P2) #5696's existing rule. - Q2 A: driver-mongodb's replica-set requirement is stated, with no card, under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499's freeze.
- Q3 A: the spec text in
hook.zod.ts, declared on [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017 (6107851318). - The runtime conflict, A: 沙箱三件套(__txBegin)同样不 join ambient 事务 —— #6168 修好了回调面,VM 侧 ctx.api.transaction 仍在事务中再开事务 #6406's no-host-transaction case is re-pinned to this card's contract. Its subject is kept, now with nine assertions. Declared on [PM seat] domain:cli — ⏳ vacant #6024 (6108611359, 6109539378).
- Round 4: the two docs rows, declared on [PM seat] domain:devx @ objectstack — 🟢 os-warren · session_012yK1ddAdAqfzwigRHZq3r2 #6023 (6109534840), and the changeset's ADR citation corrected to ADR-0087's ratified launch-window exemption.
Evidence read.
- Measured on a real door (REST and
hooks.run): an abort rolls the row back where it stayed stored before. Thelog,asyncand off-default controls are unchanged. - Ablations each went red where predicted: A (the predicate), B2 (the ambient close), C (the roll-up), D (
before*), E (the predicate'sasync/onErrorreading), and A-r3 (the re-pin). - Local runs at the final head:
- the runtime suite, 349 files;
- the objectql pins, 8/8;
- the dogfood pins, 6/6.
- Gates: 115 derived, 115 run.
- CI on this head: 42 check-runs, all concluded (38 success, 4 rostered skips;
check-expected-skipsOK).
Changesets checked.
@objectstack/objectql:minor, BREAKING narrowing. Its FROM → TO names the cross-datasource, replica-set, sandbox-join and async-late-write populations, with the remedy.@objectstack/spec:patch, text only.
Out-of-scope findings. Round 1's (b) and (c) were measured in round 4 and filed to triage:
- objectql:
ScopedContext.transaction()publishes an ambient transaction entry it never closes, so anasynchook's later write inside it is lost ("Transaction query already complete") #22855:ScopedContext.transaction()'s ambient entry is never closed, so anasynchook's write is lost. - objectql: an
after*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856: aloghook's unevaluableconditionrefuses the write while the row stays stored.
Round 1's (a) (a code-registered hook has no
onError) and (d) (driver-memory's snapshot rollback, under #5499's freeze) are notes.Not taken, recommended by the review (R6, not a carrier): three handle-identity assertions on the re-pinned sibling case. Its title is true and pinned by its neighbour on the same wiring.
Landing.
- Ready, then auto-merge through the queue.
mainis 35 commits past the merge base, none touching these 11 paths, andmerge-treeis clean.- On merge, objectql: an
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 closes, and objectql: a detail write reads its master-detail header elevated (tenant kept), so a parent-scopedrequiredWhenmay disclose one bit of a header the caller cannot read — measure whether any real configuration reaches it #22519 (the master-detail header read in the same file) becomes dispatchable under the serial order.
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsLanded: PR #22819 →
3f0b6f239f·domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-11T14:55Z- Merged through the queue at 2026-10-11T14:54:42Z as
3f0b6f239f. The two readings:- The merge commit is an ancestor of
origin/main, andopensAfterAbortUnitis inobjectql'sengine.ts. - The queue branch
gh-readonly-queue/main/pr-22819-*is gone.
- The merge commit is an ancestor of
- The card closed
completedthroughFixes #22782.pm:dispatchedwas removed in this act, andbug,domain:engine,area:workflowand the grade stay. The lane's closed set since 13:53Z is this card alone. - What landed (
@objectstack/objectqlminor, BREAKING narrowing;@objectstack/specpatch, text only):- An
after*hook withonError: 'abort'(the default) that throws now rolls its write back on the default write door. That holds on the default datasource with a driver that declares transactions. Elsewhere the caller is refused and the row stays stored, as the spec text and the docs now say. transaction()closes its ambient entry when the callback settles.- 沙箱三件套(__txBegin)同样不 join ambient 事务 —— #6168 修好了回调面,VM 侧 ctx.api.transaction 仍在事务中再开事务 #6406's sandbox case is re-pinned to this contract.
- An
- Follow-ups filed to triage:
- objectql:
ScopedContext.transaction()publishes an ambient transaction entry it never closes, so anasynchook's later write inside it is lost ("Transaction query already complete") #22855:ScopedContext.transaction()'s ambient entry. Note 6110048031 there: this PR's changeset sentence on un-awaited work becomes true forctx.api.transactiononly when objectql:ScopedContext.transaction()publishes an ambient transaction entry it never closes, so anasynchook's later write inside it is lost ("Transaction query already complete") #22855 lands. - objectql: an
after*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856: aloghook's unevaluablecondition.
- objectql:
- Unblocked on seat 1's serial line: objectql: a detail write reads its master-detail header elevated (tenant kept), so a parent-scoped
requiredWhenmay disclose one bit of a header the caller cannot read — measure whether any real configuration reaches it #22519 (the master-detail header read in the same write bodies). - Records:
- claim 6106744769;
- dev reports 6107830381, 6108597601, 6108994502 and 6109907864;
- contract review FAIL 6109504950, then PASS 6110025812;
- this seat's ACCEPT 6110035418.
Generated by Claude Code
- Merged through the queue at 2026-10-11T14:54:42Z as
Filing gate ①: a published contract statement that the runtime contradicts, with its reach measured on a published door.
Filed by
domain:specseat 2 (#18549) ·marchtian· sessionsession_016njDy8ozy9B9Ns5Y8kAWEK, from the #22301 item-6 dev's out-of-scope finding (os-dev-report on #22301, PR #22781). ⛔ Not a claim. The seat filed it because the dev's write budget did not include issue creation. Lane and grade are triage's.What is measured
On a booted stack at
490cb6d9fa(the@objectstack/verifyin-process handle, a published door):afterInserthook withonErroromitted, so the default'abort'applies, and the hook throws.hooks.run(object, 'insert', …)rejects with the hook's error.rows()reads it back. So is the row an earlierafterInserthook wrote throughctx.api.Measured by the dev with a scratch probe that was deleted afterwards, so it is not in the PR. The seat re-read the code and did not re-run the probe.
REST
createData(metadata-protocol'sprotocol.ts→engine.insert, with no transaction opened) is the same door shape. That is by code reading only; it was not measured over HTTP.What the contract says
packages/spec/src/data/hook.zod.ts(about:420–:425onorigin/maine84aeb36ce), ononError:abort: Rollback transaction (if blocking)log: Log error and continue'abort'Why the runtime does this (the seat's reading of
origin/main)packages/objectql/src/engine.ts, theDISPATCHABLE_HOOK_EVENTSdocblock (about:392–:423), records the ruled semantics of #7477 (2026-08-11):after*hooks are dispatched inside the unit of work if one is open;runAtomicBatch;transaction().A plain
insertthroughhooks.runor RESTcreateDataopens none. So an abort inafterInserthas nothing to roll back: the driver write has already committed when the hook throws.Why it matters
The caller is told the write FAILED while the row EXISTS:
lockRecord-style side effect, or a roll-up, may already have run for a row the caller believes was never written.The spec text tells an author that
abortis the safe default. On the default door it is not.The fork (not decided here)
after*hook withonError: 'abort', so the abort rolls the row back as the contract says. Measure the cost on drivers without transactions, andafterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477's "inside the unit of work" ruling.after*abort refuses the caller but the row is stored. The docs then tell authors to opentransaction(), or to move the check to abefore*hook.Which one is a contract call for the owning lane, or the maintainer's if it reopens #7477.
Dedupe
MCP
search_issueson this repo with two queries found no open duplicate:hook onError abort does not roll back the write; afterInsert hook throws but the row stays stored, no transaction: 8 hits, all closed and all on other subjects (objectql: insert shows beforeInsert hooks the caller's readonly keys, then strips them — the insert-side twin of #16344 #22306, A three-lock readonlyWhen cascade drops a write whose own lock is FALSE on the stored row: a legitimate edit is silently ignored #19927, Three provenance-stamp beforeUpdate hooks still carry the pre-#6966 "no input.id ⇒ bulk write" guard and re-read a row the engine already bound #15302, finding: the flow and hook readonly rules still carry the superseded "INSERT is engine-exempt" premise as a scan gap — a non-system create_record / ctx.api.insert of a readonly column is now a silent no-op nothing reports at build time #15394, Undeclared fields still reach the driver: beforeInsert hooks run and an auto-number is consumed before the request is refused, and the whole INSERT with its values is logged at ERROR (17.0.0 GA) #8682,afterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477,WriteObservabilityOptions.strictReadonlyWritesstill documents "INSERT ignores it", but #5503 wired insert to refuse #7064, data: updateMany runs hooks for a nonexistent id — the row fails INTERNAL_ERROR from a hook-condition abort instead of RECORD_NOT_FOUND #5088);onError abort rollback transaction after hook: 1 hit,afterDeletefires inside a rollback-able transaction, so a rolled-back cascade leaves a hook that fired for a row that still exists #7477 (closed:afterDeleteinside a rollback-able transaction). That is this card's nearest record and is cited above.Dedupe words: onError abort rollback; afterInsert throw row persists; hook abort transaction; after hook rollback; abort leaves row.
Generated by Claude Code