Summary
ADR-0067 Decision-2 promises "all writes of a commit land in ONE engine.transaction(); a commit cannot half-land." The 2026-07-16 status audit found this is not implemented: publishPackageDrafts promotes drafts per-item with catch-and-continue, so a mid-batch failure leaves earlier items live and later ones unpublished — the exact "Published! but half-landed" state the ADR exists to kill. For an AI-authoring platform this is a main-path risk: AI turns are batchy, and a partial landing is a metadata state nobody ever reviewed. Today's mitigation (revert the partial commit) is after-the-fact and requires someone to notice.
Verified current behavior (HEAD 2026-07-16)
- Per-item catch-and-continue:
metadata-protocol/src/protocol.ts:4711-4722 — a failing draft is pushed to failed[] and the loop continues; earlier publishMetaItem calls are already live. - Commit records the partial landing:
recordPackageCommit runs after the loop over publishedKeys only (:4760-4774, insert at :5180). - Even a single promote is two transactions:
repo.promoteDraft = put() (own withTxn) + best-effort delete() of the draft (second withTxn) — sys-metadata-repository.ts:577-636. - The test suite locks in the non-atomic contract:
objectql/src/protocol-publish-package-drafts.test.ts:84-101 ("collects per-item failures without aborting the rest") asserts publishedCount === 2, failedCount === 1. - The namespace pre-check (
protocol.ts:4621-4638) is the only all-or-nothing step today — the model to extend.
Design (two-phase publish)
Phase 1 — transactional metadata writes. Open ONE engine.transaction() (ADR-0034 ambient tx, objectql/src/engine.ts:2869-2896) around the whole batch: every draft's promoteDraft (put + draft-delete) and the sys_metadata_commit insert. Any failure → rollback → nothing landed, success:false, zero published.
Phase 2 — post-commit side effects (once, best-effort, in this order): in-memory registry mutations (applyObjectRegistryMutation), DDL (ensureObjectStorage/syncObjectSchema), batched applySeedBodies, publish materializers, mutation projectors (ADR-0094), emitMetadataMutation/watch events, build probes. These are exactly the effects that (a) cannot run inside a driver tx — DDL runs on bare knex and deadlocks SQLite's single-connection pool (sql-driver.ts:1429 assertBareKnexSafe); in-memory registry writes can't roll back — and (b) already have boot-reconciliation healing (the ADR-0094 lesson: side effects may self-heal, half-landed metadata cannot).
Blockers each requiring a real change
- Repo tx threading:
promoteDraft/put/delete accept no ctx and withTxn (sys-metadata-repository.ts:238-243) always opens a FRESH engine.transaction — under an outer batch tx this deadlocks SQLite. Fix: teach withTxn to detect and JOIN an already-open ambient tx (preferred — no signature change; the engine's txStore already auto-threads ambient tx into engine calls), or thread ctx explicitly. publishMetaItem side-effect extraction: split into promote-only (in-tx) and side-effects (post-commit) phases; single-item publish keeps its current order (it is already effectively promote-then-effects).- Contract flip:
protocol-publish-package-drafts.test.ts:84-101 changes from "partial lands" to "all-or-nothing: publishedCount 0 on any failure"; the response shape keeps failed[] (now describing why the batch rolled back). Release-notes BREAKING for API consumers relying on partial publishes. - Bonus fix for free: promoteDraft's best-effort draft-delete gap closes (put+delete join the same tx).
Acceptance
- A batch where item N fails leaves items 1..N-1 absent from
sys_metadata active state, no sys_metadata_commit row, registry/DDL/projections untouched. - Existing revert/rollback suites still green (
protocol-commit-history.test.ts); memory-driver (no transaction) keeps the direct-fallthrough path with documented weaker guarantees. - SQLite + PG both covered: an in-tx failure test on driver-sql proves rollback; the ambient-tx join is exercised by
engine-ambient-transaction.test.ts patterns.
Refs
ADR-0067 (Decision-2 is the unmet load-bearing piece; status line annotated 2026-07-16), ADR-0034 (ambient tx), ADR-0094 (one-authority + side-effect healing precedent). Code: metadata-protocol/src/protocol.ts:4562-4794, metadata-protocol/src/sys-metadata-repository.ts:238-243,577-636, objectql/src/engine.ts:2869-2896, driver-sql/src/sql-driver.ts:1400-1458.
Summary
ADR-0067 Decision-2 promises "all writes of a commit land in ONE
engine.transaction(); a commit cannot half-land." The 2026-07-16 status audit found this is not implemented:publishPackageDraftspromotes drafts per-item with catch-and-continue, so a mid-batch failure leaves earlier items live and later ones unpublished — the exact "Published! but half-landed" state the ADR exists to kill. For an AI-authoring platform this is a main-path risk: AI turns are batchy, and a partial landing is a metadata state nobody ever reviewed. Today's mitigation (revert the partial commit) is after-the-fact and requires someone to notice.Verified current behavior (HEAD 2026-07-16)
metadata-protocol/src/protocol.ts:4711-4722— a failing draft is pushed tofailed[]and the loop continues; earlierpublishMetaItemcalls are already live.recordPackageCommitruns after the loop overpublishedKeysonly (:4760-4774, insert at:5180).repo.promoteDraft=put()(ownwithTxn) + best-effortdelete()of the draft (secondwithTxn) —sys-metadata-repository.ts:577-636.objectql/src/protocol-publish-package-drafts.test.ts:84-101("collects per-item failures without aborting the rest") assertspublishedCount === 2, failedCount === 1.protocol.ts:4621-4638) is the only all-or-nothing step today — the model to extend.Design (two-phase publish)
Phase 1 — transactional metadata writes. Open ONE
engine.transaction()(ADR-0034 ambient tx,objectql/src/engine.ts:2869-2896) around the whole batch: every draft'spromoteDraft(put + draft-delete) and thesys_metadata_commitinsert. Any failure → rollback → nothing landed,success:false, zero published.Phase 2 — post-commit side effects (once, best-effort, in this order): in-memory registry mutations (
applyObjectRegistryMutation), DDL (ensureObjectStorage/syncObjectSchema), batchedapplySeedBodies, publish materializers, mutation projectors (ADR-0094),emitMetadataMutation/watch events, build probes. These are exactly the effects that (a) cannot run inside a driver tx — DDL runs on bare knex and deadlocks SQLite's single-connection pool (sql-driver.ts:1429 assertBareKnexSafe); in-memory registry writes can't roll back — and (b) already have boot-reconciliation healing (the ADR-0094 lesson: side effects may self-heal, half-landed metadata cannot).Blockers each requiring a real change
promoteDraft/put/deleteaccept no ctx andwithTxn(sys-metadata-repository.ts:238-243) always opens a FRESHengine.transaction— under an outer batch tx this deadlocks SQLite. Fix: teachwithTxnto detect and JOIN an already-open ambient tx (preferred — no signature change; the engine'stxStorealready auto-threads ambient tx into engine calls), or thread ctx explicitly.publishMetaItemside-effect extraction: split into promote-only (in-tx) and side-effects (post-commit) phases; single-item publish keeps its current order (it is already effectively promote-then-effects).protocol-publish-package-drafts.test.ts:84-101changes from "partial lands" to "all-or-nothing: publishedCount 0 on any failure"; the response shape keepsfailed[](now describing why the batch rolled back). Release-notes BREAKING for API consumers relying on partial publishes.Acceptance
sys_metadataactive state, nosys_metadata_commitrow, registry/DDL/projections untouched.protocol-commit-history.test.ts); memory-driver (notransaction) keeps the direct-fallthrough path with documented weaker guarantees.engine-ambient-transaction.test.tspatterns.Refs
ADR-0067 (Decision-2 is the unmet load-bearing piece; status line annotated 2026-07-16), ADR-0034 (ambient tx), ADR-0094 (one-authority + side-effect healing precedent). Code:
metadata-protocol/src/protocol.ts:4562-4794,metadata-protocol/src/sys-metadata-repository.ts:238-243,577-636,objectql/src/engine.ts:2869-2896,driver-sql/src/sql-driver.ts:1400-1458.