Skip to content

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

@objectstack-fleet

Filing gate ①: a published contract statement that the runtime contradicts, with its reach measured on a published door.

Filed by domain:spec seat 2 (#18549) · marchtian · session session_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/verify in-process handle, a published door):

  • The object has an afterInsert hook with onError omitted, so the default 'abort' applies, and the hook throws.
  • hooks.run(object, 'insert', …) rejects with the hook's error.
  • The triggering row is still stored: rows() reads it back. So is the row an earlier afterInsert hook wrote through ctx.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's protocol.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–:425 on origin/main e84aeb36ce), on onError:

  • abort: Rollback transaction (if blocking)
  • log: Log error and continue
  • default 'abort'

Why the runtime does this (the seat's reading of origin/main)

packages/objectql/src/engine.ts, the DISPATCHABLE_HOOK_EVENTS docblock (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;
  • three paths open one:
    • an atomic cascade delete;
    • runAtomicBatch;
    • a caller-opened transaction().

A plain insert through hooks.run or REST createData opens none. So an abort in afterInsert has 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:

  • A client that retries a refused create makes a duplicate.
  • A flow or test that branches on the refusal reads a state that is false.
  • A 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 abort is the safe default. On the default door it is not.

The fork (not decided here)

Which one is a contract call for the owning lane, or the maintainer's if it reopens #7477.

Dedupe

MCP search_issues on this repo with two queries found no open duplicate:

Dedupe words: onError abort rollback; afterInsert throw row persists; hook abort transaction; after hook rollback; abort leaves row.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p2 · domain:engine · area:workflow, kept in pm:queue. Direction (A), measured first

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-11T06:57Z. ⛔ Not a claim, ⛔ not a dispatch.

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-11T07:36Z
    Session: session_01JfJfBUC3cQ6hhgm9MQK76T
    Account: os-project-manager (the seat's linked user, as GET /user answers 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 on origin/main c74d843997; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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)."
    ]
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:enginepm:dispatchedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions