--- id: suite-tx-commit-atomicity-rows name: Contract-suite rows — outbox/keyed-publish tx commit-atomicity + with_tx panic probe status: completed depends_on: [] scope: moderate risk: low impact: phase level: implementation tags: [wave-5, contract-suite, tx-seam] --- ## Description Add the tx-seam commit-atomicity rows to `alkstore-contract-suite/src/properties.rs` and wire them into both engines' suite targets, discharging three backlog rows (core-contract.md §Verification backlog): "`outbox_enqueue_tx` commit-atomicity on both engines" (ADR-014), "`publish_with_key_tx` commit-atomicity on both engines" (ADR-015 §2), and the N-5 `with_tx` panic-disposition probe (the waves-1–2 general review's ordered wave-5 add — the panic path "can only be fully pinned once a real engine's handle exists"). Rows to add: - **`outbox_enqueue_tx_commit_atomicity`** — rollback drops the backing-queue job row together with the business write (no ghost job; `run_once` afterwards claims nothing); commit makes the job claimable by `run_once` exactly when the business write commits (drive a real delivery through the `Delivery` closure); the stamped opts are the outbox's derived 60/5/5 set, `get_job`-visible and identical on both engines (ADR-014 §1, ADR-010 §3a). The reserved/empty outbox-name validation legs already pin in the exemplar row — do not duplicate them. - **`publish_with_key_tx_commit_atomicity`** — rollback drops the keyed event row with the business write (no ghost event); commit makes it visible to `read_since` (and a subscriber attach); the key round-trips on the committed event. The empty-`Some`-key `InvalidName` leg already pins in the exemplar — do not duplicate. - **`with_tx_panicking_closure_rolls_back`** — a closure that panics mid-flight ⇒ rollback with no residue (the uniform no-ghosts list: job/event/notify/offset — ADR-021 §4's drop = rollback through the panic path), and the store remains usable afterward (the SQLite writer slot replenished — the wave-3 stranding fix's property, now pinned at contract level). Mechanics hint: the panic crosses an await inside `with_tx`'s own future, so drive it via `tokio::spawn` + `JoinError::is_panic()` (a `catch_unwind` around an async closure does not see the panic point); assert the panic surfaced *and* the post-panic state. This is N-5's probe against real engines for the first time — the SQLite handle-on-lease Drop impl and the pg pooled-object discard arm must both survive it. ## Acceptance Criteria - [x] Three rows exist, version-stamped (ADR-014 §1, ADR-015 §2, ADR-021 §4, ADR-007 as applicable) - [x] Rows wired into both engines' `contract_suite.rs` targets - [x] SQLite column green server-less; pg column green against the harness server - [x] The panic probe passes on both engines (no writer-slot stranding, no pooled-object leak) — if an engine fails here, that is a real defect: stop, record, fix engine-side before completing the task - [x] `cargo test -p alkstore-sqlite -p alkstore-postgres` green (pg rows skip cleanly server-less); clippy `-D warnings`; fmt clean ## References - docs/architecture/core-contract.md §Verification backlog (outbox tx commit-atomicity; keyed-publish tx commit-atomicity) - docs/architecture/decisions/014-outbox-tx-enqueue.md §1 - docs/architecture/decisions/015-streams-depth.md §2 - docs/architecture/decisions/021-tx-reads-and-value-shape-fixes.md §4 - docs/reviews/001-waves-1-2-general-review.md (N-5) - tasks/sqlite-engine-seam-tx.md (the Drop impl N-5's probe exercises) ## Notes Decisions of record beyond the task description: - **The 60/5/5 stamp inspection rides the delivery handle's `job()`.** ADR-014 §1's backlog wording says the stamped opts are "`get_job`-visible", but the derived backing queue is engine-internal: it is reserved-prefixed, so no `get_job`/`get_job_tx` call can target it through the contract surface (the exemplar row itself pins `get_job_tx(reserved queue)` → `ReservedName`; the engine-side stamp tests use raw row probes, which the suite may not). The row therefore asserts the 60/5/5 set on the `Delivery` closure's `JobHandle` (`job()` — `visibility_timeout_s`/`max_attempts`/`backoff_base_s`/ `dead_letter_retention_s`) — the only contract-visible inspection of backing-queue rows. The delivery payload-exactness and the `__alkstore_outbox:{name}` derived-name pin ride the same snapshot. - **The panic row's notify leg is a windowed silence, not a full cross-engine pin.** The row drives all four write kinds (job/event/notify/offset) through the panicking tx, but a rolled-back notify's no-residue is only observable as listener silence, and SQLite's wake overtriggers on *any* commit (the notify module's documented coalescing posture), so silence is asserted in a window that precedes every post-panic committed write — a delivery-shaped wake there is a genuine defect. The fully cross-engine notify-ghost pin rides the engines' own rollback-ghosts tests (SQLite's raw notify-count probe, pg's listener-silence twin); the suite's existing `drop_rollback_leaves_no_ghosts` row makes the same shape of call (drives the observable three kinds). - **The panic-probe closure's tail routes through a `#[cold]` helper typed `Error`** (`mid_flight_panic`) — a bare `Err(panic!(...))` tail is unreachable-call-shaped and trips the `unreachable_code` lint under `-D warnings`; `catch_unwind` around an async closure cannot see the panic point anyway (the mechanics hint), so the typed diverger costs nothing. - **The post-panic convergence loop is the suite-side bounded wait** for the rollback to land (pg's detached rollback task; SQLite's rolls back synchronously in the unwind): state-outcome assertions only, each iteration's `begin_tx` doubling as the seam-still-grants probe (the writer-slot/pool replenish property), the deadline a safety bound like `await_fires`' — no timing-value assertion. ## Summary Landed the three tx-seam commit-atomicity rows in `alkstore-contract-suite/src/properties.rs`, each version-stamped per the suite convention: - `outbox_enqueue_tx_commit_atomicity` — rollback drops the backing-queue job with the business write (business id `get_job_tx`- gone in a fresh tx; `run_once` afterwards claims nothing); commit makes the job claimable by `run_once` exactly when the business write commits (real delivery through the `RecordingDelivery` closure: job id identity, derived `__alkstore_outbox:mail` queue, exact payload, 60/5/5 stamps, consumed-after-ack) (ADR-014 §1, ADR-010 §3a, ADR-021 §4, ADR-007). - `publish_with_key_tx_commit_atomicity` — rollback drops the keyed event with the business write (both streams empty on fresh reads, key round-tripping inside the tx pre-drop); commit makes it visible to `read_since` and to a post-commit subscriber attach with the key round-tripping on both (ADR-015 §2/§4, ADR-021 §4, ADR-007). - `with_tx_panicking_closure_rolls_back` — N-5's probe against real engines for the first time: spawned-task `with_tx` closure writes enqueue/publish/notify/offset then panics mid-flight; the panic surfaces via `JoinError::is_panic()`; the no-ghost reads converge with `begin_tx` granting every iteration; the pre-panic listener hears nothing on the notified channel; the ghost never becomes claimable work; a fresh `with_tx` commits through the same seam — both engines green (no writer-slot stranding, no pooled-object leak) (ADR-007, ADR-021 §4). Also: the module-doc determinism/orchestration note gained the panic probe's admission sentence (spawned-task mechanics, `catch_unwind` asymmetry — the runner-driving extension's sibling; ADR-017 §2 class 4), `RecordingDelivery` + the `#[cold]` diverger are suite-local helpers, and the rows are exported from the suite lib and wired into `alkstore-sqlite/tests/contract_suite.rs` (three direct tokio tests) and `alkstore-postgres/tests/contract_suite.rs` (three `harness_row!`s). Verified: sqlite suite 19/19; pg suite 19/19 vs the harness server (`pglo-poc` :15432, `postgres/poc`/`blobs`) twice consecutively, plus solo re-runs of each new row per engine (determinism); workspace build/test green; clippy `-D warnings` clean; fmt clean.