# Transact ops carry intent; transact performs the reads

A **transact op** reads nothing. `getAndUpdateOp`, `deleteOp`, `restoreOp`, and `getAndCheckOp` used to `getItem` at construction time, merge the caller's partial into what they read, and capture that item's `_u` as the **item condition**. They now capture only the key, the caller's partial or callback, and an optional **entity invariant**. `transact` validates the batch, reads every item it needs with consistent, concurrent reads, asks each op for its write, and submits. Op construction stays an `Effect` — `insertOp` still encodes and can still fail with `DecodeFailed` at the line that wrote the bad value — but it is free of side effects.

The reason is the staleness window. An op built at T and committed at T+5s guarded against five seconds of drift that nobody asked it to guard. Every unrelated write to that item in the interval turned a valid transaction into a `ConditionFailed`, and the failure rate scaled with how long the caller spent assembling the batch. `sync-replica.ts` proved the cost: it wrapped op construction and `transact` in a hand-rolled ten-attempt retry loop that re-ran the reads, reimplementing at the call site the work `transact` should own. Moving the read inside `transact` collapses the window to a single round trip. The `_u` guard stays, because on DynamoDB the read and the `TransactWriteItems` are still two calls; on SQLite, IDB, and Memory the read happens inside the real database transaction and the guard is provably redundant, but it is kept uniformly so the conformance suite pins one semantic.

Three consequences were accepted deliberately.

**A callback can no longer return `null`.** The veto vocabulary is now an **entity invariant**, passed as `getAndUpdateOp(key, update, { check })`: a predicate over the decoded domain value that `transact` evaluates in process against fresh data, failing the batch before submitting anything. `UpdateRefused` is deleted. This replaces `null` for a reason beyond taste — an invariant is re-evaluated on every caller retry, whereas a `null` decided against a stale read was frozen at construction time. `deleteOp`, `restoreOp`, and the plain `getAndUpdate` take the same option. The invariant rides in the options object beside `lastWriteWins` rather than in a `{ check, update }` second argument: distinguishing that object from a plain partial means testing for an `update` property, so an entity with a field of that name would have its updates silently misread. The invariant has to be a field on the writing op rather than a separate **check op**, because the **duplicate transaction target** rule forbids a check on an item the batch writes.

**One op can no longer feed another.** `sync-replica.ts` read a sequence value out of one op's callback via closure capture and put it into the next op's payload. With callbacks deferred, that value does not exist when the second op is built. The caller now performs that read itself with a plain `get` — visibly, in its own code — and re-validates it with `check`. This is the correct shape: ops in a `TransactWriteItems` are independent by construction, and an op whose input depends on another op's output is not a transaction participant but a sequenced program.

**Every op answers one request.** An op exposes a single operation — given the item `transact` read and the commit `_u`, produce the write — fulfilled by one match over the op kinds inside the entity surface. The merge, the primary-key rejection, the encoding, and the invariant all live in that one match rather than being smeared across eight constructors, and `transact` calls it uniformly, losing the branch on `operationKind` it needs today. The op holds that operation directly rather than naming its entity for `transact` to look up: the registry lookup would buy only a serializable op, which nothing needs, at the cost of a new accessor on `TableDefinition` and an unreachable "entity not registered" branch. Serializable ops are the one thing that would justify revisiting this — a client shipping an optimistic batch to a server to replay — and retrofitting it after adapters depend on the op type is expensive, so it is recorded here rather than left implicit.

**The immediate methods and the op constructors stop diverging.** `insert`, `getAndUpdate`, `delete`, and `restore` are now their own op constructor followed by a single-op runner that reads, applies, writes one **conditional put**, and broadcasts — the same story `transact` runs at higher arity. All validation and all domain logic is shared by construction. `getAndUpdate`'s retry loop stops rebuilding its op each attempt, because an op holding no data does not go stale; the runner re-reads instead. Execution deliberately does not merge: routing single writes through `TransactWriteItems` would double DynamoDB's write cost for one item and replace `ConditionFailed` with a positional report of length one, which ADR 0005 keeps distinct on purpose.

**Every writing op produces exactly one write.** A `deleteOp` on an item already tombstoned rewrites the tombstone and stamps a fresh `_u`; `restoreOp` on a live item does the same. An idempotent skip was tried and withdrawn: an op that contributes nothing to the batch also asserts nothing, so a redundant delete batched with another write would let that write commit while the delete's target was concurrently restored — the batch loses its atomic meaning. It also desynchronised the positional `TransactOutcome` report, since filtering empty writes shifted every later outcome away from the op it belonged to. One write per op costs a redundant sync event when two clients delete the same entity, which is the cheaper defect and an optimisation to revisit with a profile rather than a guess.

**An invariant and `lastWriteWins` are mutually exclusive.** `WriteOptions` is a union that makes the pair a compile error. `lastWriteWins` drops the `_u` **item condition**, and that condition is the only thing holding the value an **entity invariant** just judged; allowing both meant a concurrent write between the read and the commit could be clobbered on the strength of a check that no longer held. A silently ignored option was rejected in favour of an error the caller cannot miss.

**Errors moved and split.** `NoItemToUpdate`, `NoItemToCheck`, `ItemAlreadyExists`, and `CheckRefused` now surface from `transact` rather than from the op constructor. `TransactOutcome.status` replaces the single `failed` with `stale`, `refused`, and `missing`, because the caller — who now owns retry entirely, since `transact` has none — needs to distinguish "the `_u` moved, try again" from "your invariant is broken" from "your keys are wrong". Automatic retry inside `transact` was rejected: it would re-run caller callbacks, making purity an unwritten contract, and only the caller knows whether a retry is meaningful.

The `StdTableContract` gains one thing: `getItem` can be asked for a consistent read. `transact` then issues its reads concurrently under a modest bound, so a full batch does not open a hundred sockets at once. Sequential reads would have cost up to 100 round trips before the write, widening the very window this change closes; eventually-consistent reads would have captured a stale `_u` and put the failure rate straight back. Keeping the port at "read this item consistently" leaves _how_ an adapter fetches many of them behind its own door, where a coalescing optimisation or a real batch method can arrive later on the strength of a profile. The rule that no **entity surface** reads at op-construction time is enforced by a conformance test that builds every op kind against a contract whose reads throw, since the rule is otherwise invisible in a diff.

Rejected: an idempotent tombstone skip, and `check` alongside `lastWriteWins` (both above); a `{ check, update }` second argument in place of the `check` option (see above); a `getItems` batch read on the contract mapping to DynamoDB's `BatchGetItem` — its `UnprocessedKeys` response returns throttled keys for the caller to retry, and a throttled key is absent from the response exactly as a non-existent item is, so without a backoff loop inside the adapter a throttled read surfaces as a spurious `missing` outcome; three of the four adapters read inside their own transaction and would implement the batch as a loop, making it a port method shaped by one adapter rather than a portability constraint like the 100-item limit; and real batches are small enough (`sync-replica.ts` submits two ops) that concurrent reads finish in the same round trip. An entity-name lookup so ops could be pure data (see above); merging the immediate methods into `transact` (see above); failing a redundant delete, or writing it through (see above); field-level patch writes, which would need no read at all but reopen ADR 0002's finding that partial-merge semantics are unconformable across the four adapters; a deferred-value mechanism letting one op consume another's result (couples ops that DynamoDB treats as independent, and makes an op unreadable in isolation); plain non-`Effect` op constructors (relocates `insertOp`'s encoding failures away from the line holding the bad value, buying only the removal of a `yield*`); relaxing the duplicate-target rule so a **check op** could guard an item the batch writes (DynamoDB rejects the repeated target outright — the rule is not ours to relax); keeping a generic `failed` status and a `detail` string (forces callers to parse prose to make a retry decision).
