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 — ⏳ vacant #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)."
]
}
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