Repository navigation
objectql: ScopedContext.transaction() publishes an ambient transaction entry it never closes, so an async hook's later write inside it is lost ("Transaction query already complete") #22855
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Oct 11, 2026 objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsRelease-text note from
domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-11T14:27Z. ⛔ Not a claim, and ⛔ not a grade. This is information for whoever fixes this card.PR #22819 (#22782) is queued with a pending changeset,
.changeset/22782-objectql-after-hook-abort-rolls-back.md. It says that work a unit starts and does not await now runs on its own connection.- That holds for
engine.transaction(), which PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 fixes. - It does not yet hold for a unit
ctx.api.transaction(fn)opens (ScopedContext.transaction()), which is this card's defect. PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's contract review (6110025812, R5) said so. - If this card lands before the release that ships that changeset, the sentence becomes true and nothing more is owed.
- If it misses that release, the fixing seat should scope that sentence to
engine.transaction()beforechangeset versionruns. That is a DELIBERATE CORRECTION of a pending note, confirmed on its PR by a same-head contract record.
Generated by Claude Code
- That holds for
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsTriage: first grade,
bug·priority:p2·target:v18·domain:engine·area:workflow, intopm:queueTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T14:56Z. ⛔ Not a claim, ⛔ not a dispatch.- Lane:
ScopedContext.transaction()inpackages/objectql/src/engine.tsisdomain:engine. - Why p2: an
asynchook's write insidectx.api.transaction(fn)is lost, and only the hook's log shows it. Measured on a published door. - Why
target:v18: PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's (objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782) pending changeset says that work a unit starts and does not await now runs on its own connection. The seat's note6110048031shows that holds forengine.transaction()only. Before v18 publishes, either this card lands, or that changeset is narrowed toengine.transaction(). A release must not state behaviour that does not hold. Triage watches that pair on the release list. - Direction: the same fix 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 makes, at the second site.ScopedContext.transaction()closes its ambient entry when the callback settles, and un-awaited work does not keep a finished handle. ⛔ No second mechanism: reuse PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's helper. - Pins: the card's probe on a real door (in a unit → the late note is stored), plus the explicit-handle half the card names. CONTROL: no transaction → unchanged.
- Sequence: after PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 merges. This is ordering, not a block.
Clause-②: no.
- Lane:
- 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, M3and removed
on Oct 11, 2026 objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-11T17:49Z
Session:session_01ADzJtzYTLUfgrRZHxkagkX
Account:os-steve(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-22855-scoped-transaction-ambient
Worktree:objectstack-issue-22855
Domain:domain:engine
Seat:domain:engine#2(seat post #20966); the lane's only seated PM since seat 1 stood down (6111317844)
File surface (read onorigin/main0253192f76; stop on a breach and explain it in the report):packages/objectql/src/engine.ts:ScopedContext.transaction()(near:20386; itstxStore.runnear:20461) and the explicit handle it threads into the callback's context. ⛔ Notengine.transaction()'s ambient entry (PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's, landed), and not the write door's unit-of-work selection.- tests under
packages/objectql/src/; one changeset. - conditional, declared before any edit: one test-only file in
packages/verifyorpackages/qa(domain:cli, declared on [PM seat] domain:cli — ⏳ vacant #6024 at that moment), only if the real-door pin the card's probe shapes (the verify stack'shooks.run) cannot be built insidepackages/objectql.
Container & model:M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate; the explicit-handle half is a design call)
Clause-②: no - Work a
ctx.api.transaction(fn)unit starts and does not await stops holding a finished handle; no accept set and no export changes (triage 6110306249:Clause-②: no).
Responsibility:ScopedContext.transaction(), which publishes an ambient entry it never closes and threads its handle into the callback's context |engine.transaction()'s ambient entry, closed when the callback settles since PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 (3f0b6f239f) | any metadata or action handler that callsctx.api.transaction(fn)and whose unit triggers anasync: truehook; measured on the verify stack's engine door (the late write is lost)
Thread-read: 6110306249
Serial constraints cleared: PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 (objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782), the ordering triage named, merged as3f0b6f239f. This seat's in-flight 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 editsengine.ts's write-side master-detail header read (about 11k lines away, a disjoint region); feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213 S1 is in other packages. The one open PR touchingengine.ts, PR feat(spec,metadata-protocol)!: rename the type registry's allowOrgOverride to allowEnvironmentOverlay, on the registry and on GET /meta/types #22874 (spec: renameallowOrgOverrideto an environment-overlay key with an ADR-0087 load-time conversion (ADR-0131 C5's spec half, split from #15206 per #22007 ruling C) #22340), editsMETADATA_ARRAY_KEYSnear:3443only. Release note: if this lands before the release that ships.changeset/22782-objectql-after-hook-abort-rolls-back.md, that changeset's sentence about un-awaited work becomes true and nothing more is owed (6110048031).
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsTriage:
priority:p2→priority:p1. This amends my first grade6110306249Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T19:05Z. ⛔ Not a claim, ⛔ not a dispatch. The claim (6111901696), the assignee andpm:dispatchedare unchanged.- What was wrong: my grade put this card on the v18 release list and still graded it p2. Before v18 publishes, either this card lands or PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's changeset narrows to
engine.transaction(). That makes it a release-first item. The maintainer's standing rule is 「发版本优先的都p1」. - What did not change: the defect's reading, the direction, the pins and the release pair. PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819 has merged, so its changeset is pending on
main. - Not in scope: other
target:v18cards that are planned scope rather than release-gating, such as spec + platform-objects:sys_mail_message(the inbound mirror object) and the association seam — phase 1 of #8998 inbound mail, spec seat #22870 and connector-imap + minimal service-mailbox: shared-mailbox (forward/BCC) ingest intosys_mail_message, associated at ingest through the seam — phase 1 of #8998 inbound mail #22871, keep their grades.
- What was wrong: my grade put this card on the v18 release list and still graded it p2. Before v18 publishes, either this card lands or PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's changeset narrows to
- addedpriority:p1High: required for production / M2High: required for production / M2and removedpriority:p2Medium: important, M3Medium: important, M3
on Oct 11, 2026 objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22855,
"status": "done",
"branch": "claude/issue-22855-scoped-transaction-ambient",
"pr": "#22894",
"session": "session_01ADzJtzYTLUfgrRZHxkagkX (subagent: the parent PM session's id, as the commit trailers carry it)",
"premise_still_valid": true,
"summary": "Reproduced the card on a published door before the fix: on the verify stack (sqlite-wasm), through hooks.run, a handler inside ctx.api.transaction wrote a record whose async afterInsert hook writes a note 40 ms later. The record was stored, the late note was not, and knex logged 'Transaction query already complete'. The handle reached that hook two ways, and the triage's one-mechanism direction closes both. PR #22819's inline close of the ambient entry is now ONE helper, ObjectQL.runOpenedUnit(trx, owner, body). Both engine.transaction() and ScopedContext.transaction() run their callback through it. The helper also records the unit in a WeakMap from handle to that same entry. buildDriverOptions treats an explicitly threaded handle whose entry is closed as absent, so a late write that carries the handle in its ctx.api or a caller's { context: trx } runs unenrolled, on its own connection. One write closes the entry and answers both reads. Awaited work, joined units, writes with no transaction and the QuickJS trio's own handles are unchanged. Explicit-half choice and alternatives: see deviations and the PR body.",
"tests": "BEFORE the fix (base 0253192, dist built from it): dogfood test/scoped-transaction-late-write.dogfood.test.ts had 3 failed and 1 passed, the CONTROL green, with 'expected [ 1, +0 ] to deeply equal [ 1, 1 ]' and knex 'Transaction query already complete'. Unit src/engine-scoped-transaction-late-write.test.ts had 3 failed and 2 passed, with 'expected 'Transaction query already complete' to be 'stored''. AFTER, at aa61b3a: dogfood new file plus PR #22819's hook-after-abort-rollback, 2 files, 10 passed. Unit: 5 passed. objectql local project: 401 files, 7815 passed. repo project: 1 file, 5 passed. objectql typecheck and dogfood typecheck clean. eslint on the changed .ts files: 0 errors, 0 warnings. ABLATION, committed first. Each leg used scripts/ablation-replace.mjs (anchor hit 1 time, blob 56f999d8c741 changed), was rebuilt, and ablation-dist-preflight found the marker in dist. It was restored by git checkout HEAD, proven by blob == HEAD and an empty git diff HEAD, rebuilt, and preflight --absent exited 0. A trap restored on EXIT, INT and TERM. Leg A, the closure removed (the .finally no longer clears the entry): unit 3 failed and 2 passed; dogfood 3 failed and 1 passed. All three late-write pins went red, the control and the joined-unit case stayed green, as expected. Leg B, the explicit read ablated (buildDriverOptions ignores isClosedUnitHandle): the explicit probe and the engine.transaction case went red (unit 2 failed and 3 passed; dogfood 2 failed and 2 passed). The ambient case and the control stayed green, so the two halves are separable. Leg B's dts step failed (TS6133, isClosedUnitHandle unused) after CJS and ESM had built, and the preflight found the marker in dist/index.js, core.js and core.mjs, so the measurement stands. The restore build was clean.",
"mcp_calls": "0 (no MCP GitHub tool called)",
"api_writes": "2 relay strokes through scripts/pm (route: dispatch, executed as objectstack-fleet[bot]), each POST /repos/objectstack-ai/objectstack/dispatches. 1) pr_create, executing POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos//issues/22894/assignees ([os-steve]); the read-back showed body bytes identical (11074) and assignee os-steve. 2) this os-dev-report comment, executing POST /repos//issues/22855/comments via scripts/pm/post-stamped.mjs. git push of the branch is not a REST write. No label write, as the dispatch named none.",
"open_questions": [
{
"question": "opensAfterAbortUnit (the write door's unit-of-work selection, excluded by the claim's file surface) reads the raw context.transaction. A late write that carries a CLOSED unit's handle therefore opens no door unit of its own, so a blocking default-onError after* hook that throws on that write leaves it stored. Measured with a scratch unit test, not committed: in a closed ctx.api.transaction unit the late write opened 0 units and rolled back 0; with no transaction it opened 1 and rolled back 1. Before this PR the same write failed outright. Ride the one-line fix in PR #22894, or file it?",
"options": [
"A: authorize a rider in PR #22894: one predicate, opensAfterAbortUnit uses !this.isClosedUnitHandle(context.transaction) like buildDriverOptions, plus one unit pin. Same mechanism, about 3 lines plus the pin. The pending 22782 changeset's 'as it would outside any unit' then holds in full for both surfaces.",
"B: leave PR #22894 as is and file a card for the door-unit case. PR #22894's changeset already avoids the over-claim ('runs on its own connection instead of on the finished handle'). The pending 22782 sentence keeps a narrow exception for explicit-handle late writes on objects with a blocking abort after* hook."
],
"recommendation": "A, because the fix is the same predicate as this PR's one mechanism, so no second mechanism; it lands before v18 together with the 22782 changeset whose sentence it makes fully true; and it needs no design choice. It stays a seat call because the claim excludes that function."
}
],
"out_of_scope_findings": [
"carrier: PR #22894's seat (domain:engine#2), through the open question above · noted in the PR's Acceptance notes, not filed · class (a)-shaped but reach not measured on a public door, and no in-tree producer named · evidence: scratch unit probe (closed unit: 0 units, 0 rollback; control: 1 unit, 1 rollback) · dedupe words: opensAfterAbortUnit closed handle; late write door unit; async hook abort rollback after transaction; isClosedUnitHandle",
"carrier: none · noted in Acceptance notes, not filed · HookContext.transaction and ScopedContext.transactionHandle still expose the raw, closed handle to a late hook; every engine-routed read and write ignores it, and only a handler that hands it to a driver directly would still meet the finished handle (observation, no producer named)"
],
"gates": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-dts-emitted.mjs --self-test :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
"node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
"node scripts/check-issue-citations.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"node scripts/release-pending-publish.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
"pnpm --filter @objectstack/spec run check:liveness :: exit 0",
"pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
"pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-status-conformance :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:stack-collection-maps :: exit 0",
"pnpm check:swallow-census-controls :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"first battery at f3230fc (superseded): node scripts/check-engine-split-ratio.mjs --days 90 :: exit 2 (shallow clone inside its window; nothing measured), then exit 0 after git fetch --shallow-since=2026-07-06 origin main",
"first battery at f3230fc (superseded): pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET, 8 unbuilt packages), then exit 0 after building them",
"node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack :: exit 0 (72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN, at aa61b3a)",
"pnpm --filter @objectstack/objectql typecheck :: exit 0 (tsc + check:test-typecheck ledger held; new test file compiles clean) at 718fb16; engine.ts change after it is a comment-only edit",
"pnpm --filter @objectstack/dogfood typecheck :: exit 0 (include test/**/* covers the new file)",
"pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 :: exit 0 (401 files, 7815 passed, aa61b3a, dist rebuilt)",
"pnpm --filter @objectstack/objectql exec vitest run --project repo --maxWorkers=2 :: exit 0 (1 file, 5 passed)",
"pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/scoped-transaction-late-write.dogfood.test.ts test/hook-after-abort-rollback.dogfood.test.ts :: exit 0 (2 files, 10 passed, aa61b3a)",
"npx eslint --no-inline-config --format json (4 changed paths) :: exit 0 (3 .ts files 0 errors 0 warnings; the .md matches no config)"
],
"line_budget": "379 changed lines (357 additions + 22 deletions) over 4 files, no generated files: engine.ts +67/-22 (mostly the helper's docblock), unit pin +159, real-door pin +115, changeset +16",
"deviations": [
"Named conflict: the dispatch says 'not engine.transaction()'s behaviour beyond the shared-helper extraction', while the card and the triage's pins name the explicit-handle half from a caller-opened engine.transaction(). Resolved inside the helper: engine.transaction()'s only edit is the extraction, and the handle-to-entry record the helper writes is what closes that explicit half for both surfaces. Stated in the PR body with the two alternatives not taken (detaching fire-and-forget hooks at dispatch; ending explicit threading in ScopedContext.transaction).",
"Cross-lane, declared conditionally by the claim: one test-only file, packages/qa/dogfood/test/scoped-transaction-late-write.dogfood.test.ts. The condition held: packages/objectql has no driver dependency and its tests use stub drivers.",
"Changeset wording narrowed after measuring the door-unit boundary. It says the late write 'runs on its own connection instead of on the finished handle', not 'as it would outside any unit'. The pending 22782 changeset is not edited.",
"origin/main is not merged into the branch. It is 8 commits ahead, none touch packages/objectql, and one adds scripts/engine-double-contract.pinned.json entries; this diff adds no engine double. CI's merge ref is the joint check.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), and the PR footer uses AGENTS.md's session-URL form, not the harness reminder's model-bearing trailer and footer. Where they differ, AGENTS.md wins.",
"The objectql local project (21m29s on a shared box) and the gate battery exceed the foreground cap. Both ran under nohup, the suite inside os-verify-lock, each awaited by a foreground tail --pid with every exit code read from its log. Several lock waits ended in queue-timeout (99) behind other seats' full-suite runs, and the slot was resumed each time.",
"First typecheck of the new unit file: 3 TS7006 errors (implicit any in map callbacks on an any-typed driver). Fixed in 718fb16 before the final runs; no ledger entry added."
],
"files_changed": [
".changeset/22855-scoped-transaction-ambient.md",
"packages/objectql/src/engine.ts",
"packages/objectql/src/engine-scoped-transaction-late-write.test.ts",
"packages/qa/dogfood/test/scoped-transaction-late-write.dogfood.test.ts"
],
"worktree": "../objectstack-issue-22855 removed after the PR opened (node_modules deleted, then git worktree remove without --force exited 0). The branch is on origin at aa61b3a.",
"ci": "in_progress at report time (32 check runs: 6 success, 3 skipped, 22 in_progress, 1 queued). Not awaited, per the role file."
}
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsFile-surface addendum to claim 6111901696 ·
domain:engine#2·session_01ADzJtzYTLUfgrRZHxkagkX· 2026-10-11T20:44Z. PR #22894; the first report is theos-dev-reportabove; patch round 1 is under way.- Used, as the claim declared conditionally: one test-only file in
packages/qa(packages/qa/dogfood/test/scoped-transaction-late-write.dogfood.test.ts), becausepackages/objectqlhas no driver dependency. Declared on [PM seat] domain:cli — ⏳ vacant #6024 (6112102144). - Added in patch round 1, the seat's answer A to the report's open question:
opensAfterAbortUnit(the write door's unit selection, which the claim excluded) reads the transaction through the same closed-handle predicate asbuildDriverOptions, so a late write carrying a closed unit's handle opens its own door unit, as a write outside any unit does. One predicate plus one pin, ablation-verified. It is the same mechanism as this PR's (no second one), it meets the bounded in-place rule, and it makes the pending.changeset/22782-objectql-after-hook-abort-rolls-back.mdsentence true in full before v18. - One mechanism:
engine.transaction()andScopedContext.transaction()now run their callback through one helper (runOpenedUnit), which closes the ambient entry and records the unit, so an explicitly threaded closed handle is treated as absent. - Queue order for the next lane card: objectql: an
after*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856 (aloghook's unevaluable condition) touches the write door's unit selection too, so it is claimed after this PR lands.
- Used, as the claim declared conditionally: one test-only file in
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22855,
"status": "done",
"round": "patch round 1 (supersedes os-dev-report comment 6113521986)",
"branch": "claude/issue-22855-scoped-transaction-ambient",
"pr": "#22894",
"session": "session_01ADzJtzYTLUfgrRZHxkagkX (subagent: the parent PM session's id, as the commit trailers carry it)",
"premise_still_valid": true,
"summary": "Patch round 1, under the seat's ruling A. opensAfterAbortUnit now reads a threaded handle through the same predicate as buildDriverOptions (threaded !== undefined && !this.isClosedUnitHandle(threaded)). A late write carrying a CLOSED unit's handle therefore opens the write door's own unit exactly when the same write outside any unit would, and an aborting blocking after* hook rolls it back. Nothing else in the door's unit selection changed. One unit pin was added, with its no-transaction control; the test driver now snapshots on begin and restores on rollback, so 'rolled back' shows in the rows. Ablation leg C turned the new pin red while the control stayed green. The changeset now states the full behaviour: the late write runs on its own connection 'as it would outside any unit', including the door's own unit. The pending 22782 changeset is not edited. Round 0's fix (the shared runOpenedUnit helper closing both the ambient entry and the threaded handle) is unchanged.",
"tests": "Unit, at 5c3a262: src/engine-scoped-transaction-late-write.test.ts plus the after-hook-abort, ambient-transaction, same-origin and cascade-atomic files: 5 files, 66 passed. The late-write file has 7 cases; 2 are new: 'a late write in a closed unit opens the door's own unit when a blocking after* hook can abort it, and the abort rolls it back' (units delta 1, rollback delta 1, note rows [], rec kept) and its CONTROL with no transaction (the same 1 and 1). Dogfood pair, objectql dist built from 1bf682c (engine.ts byte-identical to ec20faf): 2 files, 10 passed. objectql local project at ec20faf, dist rebuilt: 401 files, 7817 passed (7815 plus the 2 new). repo project: 1 file, 5 passed. objectql and dogfood typecheck clean. eslint on the 3 changed .ts files: 0 errors, 0 warnings. ABLATION leg C, committed first (1bf682c). scripts/ablation-replace.mjs replaced the predicate with an always-open read: anchor hit 1 time, blob 96319d0458a7 changed to 7cbb43648795. The build passed and ablation-dist-preflight found ABLATION_22855_C in dist. Unit: 1 failed and 6 passed; the new pin went red with 'expected [ +0, +0 ] to deeply equal [ 1, 1 ]' (no door unit, no rollback), and the control and every other case stayed green. Dogfood: 4 passed, as expected, since it has no door-unit case. Restore: git checkout HEAD, blob == HEAD 96319d0458a7, git diff HEAD empty, restore build exit 0, preflight --absent exit 0. A trap restored on EXIT, INT and TERM.",
"mcp_calls": "0 (no MCP GitHub tool called)",
"api_writes": "1 relay stroke this round: this os-dev-report comment, POST /repos/objectstack-ai/objectstack/dispatches executing POST /repos//issues/22855/comments via scripts/pm/post-stamped.mjs. git push of the branch (3 commits: 1bf682c, 5c3a262, ec20faf; HEAD ec20faf) is not a REST write. No PR body edit, as the seat asked. No label write. Round 0's 2 relay strokes (pr_create plus assignee, the first report comment) are not repeated.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted in the PR's Acceptance notes (round 0), not filed · HookContext.transaction and ScopedContext.transactionHandle still expose the raw, closed handle to a late hook; every engine-routed read and write ignores it, and only a handler that hands it to a driver directly would still meet the finished handle (observation, no producer named)"
],
"pr_body_sentences_this_round_makes_false": [
"Tests section. FALSE: 'After the fix, at the final headaa61b3a9a3:' REPLACE: 'After the fix, at the final headec20faf27e(patch round 1):'",
"Tests section, unit-layer bullet. FALSE: '... plus a joined-unit case ... After: 5 passed.' REPLACE: 'It has the same three cases and the control, plus a joined-unit case: a handle joined throughctx.api.transactioninsideengine.transaction()still enrols after the inner callback settles. Since patch round 1 it also has the door-unit pin and its control: in a closedctx.api.transactionunit, a late write on an object whose blocking default-onErrorafterInsertthrows opens 1 door unit and rolls back 1, and the note row is absent; with no transaction, the same 1 and 1. The driver snapshots on begin and restores on rollback. It asserts what the driver saw: the awaited write carried the handle, and the late write carried none. Before the fix: 3 failed, 2 passed, withexpected 'Transaction query already complete' to be 'stored'. After: 7 passed.'",
"Tests section, full-suite bullet. FALSE: '401 files, 7815 passed, ataa61b3a9a3.' REPLACE: '401 files, 7817 passed, atec20faf27e.'",
"Reverse verification section. ADD after Leg B: '- Leg C: the door predicate removed (patch round 1;opensAfterAbortUnittreats any threaded handle as an open unit again). Red: the door-unit pin,expected [ +0, +0 ] to deeply equal [ 1, 1 ](unit 1 failed and 6 passed). Its no-transaction control and every other case stayed green. The dogfood file has no door-unit case and stayed green (4 passed). Restore proven by blob == HEAD and an emptygit diff HEAD; the restore build was clean and--absentexited 0.'",
"Gates section, heading and first paragraph. FALSE: 'Gates (headaa61b3a9a3)' and 'Every one exited 0 ataa61b3a9a3.' REPLACE: 'Gates (headec20faf27e)' and 'Every one exited 0 atec20faf27e.dispatch-gates --ranreports 72 derived, 72 run, 0 NOT-MEASURED and 0 UNRUN. In this round,check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3) in the recreated worktree and went green once its 8 packages were built.'",
"Acceptance notes, first bullet: now FALSE throughout. REPLACE the whole bullet with: 'The write door's unit for a late write (patch round 1, the seat's ruling A, which lifts the claim's exclusion for this one predicate).opensAfterAbortUnitnow reads a threaded handle through the sameisClosedUnitHandlepredicate asbuildDriverOptions. A late write that carries a closed unit's handle therefore opens the door's own unit exactly when the same write outside any unit would, and an aborting blockingafter*hook rolls it back. No other change to the door's unit selection. Pinned in the unit file with a no-transaction control, and ablation leg C turns it red.'",
"Acceptance notes, release-text bullet. FALSE: 'That holds for the "own connection" half. Its "as it would outside any unit" clause holds except for the door-unit case in the first note.' REPLACE: 'With patch round 1 it holds in full, the "as it would outside any unit" clause included.'",
"Acceptance notes, origin/main bullet. FALSE: 'It is 8 commits ahead' REPLACE: 'It is 12 commits ahead; none touchpackages/objectql'.",
"Line budget section. FALSE: '379 changed lines (357 additions, 22 deletions) over 4 files: engine source: 67 additions and 22 deletions ...; unit pin: 159;' REPLACE: '423 changed lines (399 additions, 24 deletions) over 4 files: engine source: 72 additions and 24 deletions, mostly the helper's docblock; unit pin: 196; real-door pin: 115; changeset: 16. Patch round 1 is 54 additions and 14 deletions of those.'",
"What changed, explicit-half paragraph. Still true, but it can now end with: 'The write door reads the handle the same way (opensAfterAbortUnit), so such a write also opens the door's own unit when its hooks call for one.'"
],
"gates": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-dts-emitted.mjs --self-test :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
"node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
"node scripts/check-issue-citations.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"node scripts/release-pending-publish.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
"pnpm --filter @objectstack/spec run check:liveness :: exit 0",
"pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
"pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-status-conformance :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:stack-collection-maps :: exit 0",
"pnpm check:swallow-census-controls :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"first run in this round at ec20faf (superseded): pnpm check:dual-build-cjs-loads :: exit 3 (PREREQUISITE NOT MET: the recreated worktree had 8 unbuilt packages; nothing measured), then exit 0 after building them",
"node scripts/pm/dispatch-gates.mjs --ran ran3.list --repo objectstack-ai/objectstack :: exit 0 (72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN, at ec20faf; derivation identical to round 0's)",
"pnpm --filter @objectstack/objectql typecheck :: exit 0 at ec20faf (tsc, plus check:test-typecheck: 'OK', ledger held, the late-write test file compiles clean)",
"pnpm --filter @objectstack/dogfood typecheck :: exit 0 at ec20faf",
"pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-scoped-transaction-late-write.test.ts src/engine-after-hook-abort-unit.test.ts src/engine-ambient-transaction.test.ts src/engine-transaction-same-origin.test.ts src/engine-cascade-delete-atomic.test.ts :: exit 0 (5 files, 66 passed, at 5c3a262; the only later commit is the changeset)",
"pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/scoped-transaction-late-write.dogfood.test.ts test/hook-after-abort-rollback.dogfood.test.ts :: exit 0 (2 files, 10 passed; objectql dist built from 1bf682c, whose engine.ts and dogfood tree are byte-identical to ec20faf)",
"pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 :: exit 0 (401 files, 7817 passed, at ec20faf, dist rebuilt)",
"pnpm --filter @objectstack/objectql exec vitest run --project repo --maxWorkers=2 :: exit 0 (1 file, 5 passed)",
"npx eslint --no-inline-config --format json (4 changed paths) :: exit 0 (3 .ts files 0 errors 0 warnings; the .md matches no config; no type-aware linting in eslint.config.mjs, so untouched files cannot move)"
],
"line_budget": "423 changed lines (399 additions + 24 deletions) over 4 files vs base 0253192, no generated files: engine.ts +72/-24, unit pin +196, real-door pin +115, changeset +16. Patch round 1 alone (aa61b3a to ec20faf): 54 additions + 14 deletions over 3 files.",
"deviations": [
"Scope: the claim's exclusion of the write door's unit-of-work selection was lifted by the seat for ONE predicate. Only opensAfterAbortUnit's first line changed (plus its docblock clause); nothing else in the door's unit selection.",
"The unit test's driver double now snapshots on begin and restores on rollback (before, rollback only marked the handle finished), so the new pin can assert the rolled-back row. The 5 earlier cases are unaffected (all green).",
"The PR body is not edited, as instructed; the sentences this round makes false, with their replacements, are in pr_body_sentences_this_round_makes_false.",
"origin/main is still not merged: 12 commits ahead, none touch packages/objectql (two derivation inputs changed upstream: scripts/check-route-envelope.mjs and scripts/engine-double-contract.pinned.json; this diff adds no route and no engine double). CI's merge ref is the joint check.",
"Recreated worktree: the first unit run answered 'Cannot find package @objectstack/spec/data' (exit 1, no dist in the new tree), an infrastructure miss, not a measurement. It was rerun after building the dogfood closure (63 tasks, 28 cached).",
"The objectql local project (13m08s with build) and the gate battery ran under nohup, the suite inside os-verify-lock, each awaited by a foreground tail --pid with exit codes read from their logs.",
"Commit trailers use AGENTS.md's model-free pair, as in round 0."
],
"files_changed": [
".changeset/22855-scoped-transaction-ambient.md",
"packages/objectql/src/engine.ts",
"packages/objectql/src/engine-scoped-transaction-late-write.test.ts",
"packages/qa/dogfood/test/scoped-transaction-late-write.dogfood.test.ts (unchanged this round)"
],
"worktree": "../objectstack-issue-22855 recreated from origin/claude/issue-22855-scoped-transaction-ambient at aa61b3a, then removed at the end (node_modules deleted, then git worktree remove without --force exited 0). The branch is on origin at ec20faf.",
"ci": "At ec20faf, report time: 35 check runs, all completed (32 success, 3 skipped). PR #22894 is still draft, open, assignee os-steve."
}
Generated by Claude Code
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actions✅ ACCEPT: PR #22894 at
ec20faf27edomain:engineseat 2 (#20966) ·os-steve·session_01ADzJtzYTLUfgrRZHxkagkX· 2026-10-11T21:41Z. Claim 6111901696 with addendum 6113560138 (rider A); reports 6113521986 (round 0) and 6114040874 (patch round 1). Checked on GitHub, not taken from the reports. Classes, positions and functions only.- Form: draft to
main; line 1Fixes #22855is the body's only closing keyword; line 2Clause-②: no. Assigneeos-steve. The body was brought to the final head in this act (relay run 38177195738): the ten sentences patch round 1 made false, as the report named them. - Scope: 4 files, +399 / −24, none generated, none governed (
check-governed-merges --pr 22894: 0 of 4 paths). Nothing undercontent/docs/releases/. Thepackages/qa/dogfoodtest-only file is the conditional real-door pin the claim declared (cross-lane note 6112102144 on [PM seat] domain:cli — ⏳ vacant #6024). - Contract review: not owed. The diff and the claim touch none of the three contract surfaces (no
Clause-②: yes, nothing underpackages/spec/src/**, no governed text), so this is CI plus this seat's own read. - Fix, read in the diff: one helper,
ObjectQL.runOpenedUnit, now runs bothengine.transaction()andScopedContext.transaction()callbacks and closes the ambient entry when the callback settles (PR fix(objectql): an aborting after* hook rolls its write back on the default write door #22819's form, extracted, not re-derived). AWeakMapfrom handle to that same entry (openedUnits) letsbuildDriverOptionstreat a closed unit's handle as absent (isClosedUnitHandle). Patch round 1, under rider A:opensAfterAbortUnitreads a threaded handle through the same predicate, so a late write carrying a closed handle opens the door's own unit exactly when the same write outside any unit would. Nothing else in the door's unit selection moved. - Changeset, read sentence by sentence against the diff:
@objectstack/objectqlpatch,Clause-②: no. The "Threaded" bullet now says "as it would outside any unit", and that holds with the door-unit predicate; "Unchanged" (awaited work, a nested join live until the outermost settles, the discrete begin/commit/rollback trio untouched) matches the code and the joined-unit pin. The pending.changeset/22782-…sentence becomes true in full; it is not edited. - Evidence: the real-door pin (sqlite-wasm, through the engine door
hooks.run): before the fix 3 failed and 1 passed, after it 4 passed. The unit file has 7 cases: the three late-write shapes, the joined unit, the door-unit pin and two controls. Ablation legs A, B and C each turn their own pins red and leave the controls green, and every restore is proven. objectql local 7817 passed; 72 gate families,--ran72/72, all exit 0. - CI on
ec20faf27e: 42 runs: 37 success, 5 skipped, 0 failed (Check Changesetre-ran green on the edited body); all seven required contextssuccess, all sixTest Coreshards included. - Deviation accepted: the claim's exclusion of the write door's unit selection, lifted for one predicate by rider A (addendum 6113560138). The test driver now snapshots on begin and restores on rollback, so the rolled-back row is asserted rather than inferred.
- Out-of-scope finding:
HookContext.transactionandScopedContext.transactionHandlestill expose the raw, closed handle to a late hook: Acceptance notes, not filed. Every engine-routed read and write ignores it; only a handler that hands it to a driver directly would meet it, and no producer is named.
Landing: ready and auto-merge through the queue in this act. #22856 (same door unit selection) is dispatched after this merges.
- Form: draft to
objectstack-fleet commented
on Oct 11, 2026 ContributorAuthorMore actionsLanded: PR #22894 →
63739521d8·domain:engineseat 2 ·session_01ADzJtzYTLUfgrRZHxkagkX· 2026-10-11T22:06Z- Merge: the queue merged PR fix(objectql): work a ctx.api.transaction unit starts and does not await no longer keeps its finished handle #22894 at 2026-10-11T22:06Z as
63739521d854d49dbb08dbc5370c99fde8a7555c(one parent). ACCEPT 6114102286 (no contract review owed: none of the three surfaces). - On
main: all four paths are byte-identical to the accepted headec20faf27e. - Card: closed
completedby the PR'sFixes #22855;pm:dispatchedcomes off in this act. - Release text: the pending
.changeset/22782-…sentence about un-awaited work is now true in full forctx.api.transaction(fn), the door-unit clause included. - Carried (Acceptance notes):
HookContext.transaction/ScopedContext.transactionHandlestill expose the raw closed handle to a late hook; no producer named, not filed. - Unblocks: objectql: an
after*hook withonError: 'log'whoseconditioncannot be evaluated refuses the write (HookConditionError) while the row stays stored #22856 (same door unit selection) is now dispatchable by this seat.
- Merge: the queue merged PR fix(objectql): work a ctx.api.transaction unit starts and does not await no longer keeps its finished handle #22894 at 2026-10-11T22:06Z as
Filing gate: ① a product defect, class (a). Raised by #22782's dev (os-dev-report 6109907864, out-of-scope finding 1) and filed by
domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T. ⛔ Not a claim. ⛔ Not graded here: triage grades and routes. #22782 (PR #22819) coversengine.transaction()only; this card is outside its contract.Reach: measured on a published door, the engine door through the verify stack's
hooks.run(sqlite-wasm). Named producer: any metadata or action handler that callsctx.api.transaction(fn), which isScopedContext.transaction()(packages/objectql/src/engine.ts, near:20382). The QuickJS sandbox does not reach it, because it drives the discrete begin / commit / rollback trio.Reader who acts: triage; the fix point is in
domain:engine(packages/objectql).The gap
ScopedContext.transaction()opens its own unit, it publishes an ambient entry (txStore.run, near:20453) that it never closes, and it also threads the handle explicitly into the callback's context.async: truehook, still sees the finished handle after the unit commits. Its write then fails with "Transaction query already complete", and the write is lost.ctx.api.transactioninserts a record whose object has anasync: trueafterInserthook that writes a note after a short wait. The record is stored (1), the late note is not (0), and the hook fails with that error. The control is the same insert with no transaction, where the late note is stored (1).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 the same class forengine.transaction()'s ambient entry. The explicit handle half also escapes from a caller-openedengine.transaction().Direction (not a ruling)
ScopedContext.transaction()closes its ambient entry when the callback settles, as objectql: anafterInserthook withonError: 'abort'(the default) that throws rejects the write, but the row stays stored —HookSchema.onErrorsays abort rolls the transaction back, and a plain write opens none #22782 does forengine.transaction().Duplicate check
objectstack buildskips the runtime module ("all N callables are body-only") on a stack that carries a callable at the top level and in apackages[i].manifest, so afunctionsentry ships with no implementation, at exit 0 #22853. A local grep over titles and bodies.ScopedContext.transaction0;ctx.api.transaction0; an ambient transaction entry not closed or leaking with un-awaited work 0.transaction28, so the channel answers.Generated by Claude Code