ADR 0019: Batch relationship writes are atomic

Status

Accepted. Amends the consequences of

0009 the extraction fold resolves through aliases,

which recorded the previous, weaker promise as load-bearing.

0002 two store ports stands.

Context

GraphStore.upsert_relationships used to promise the weakest thing it could:

a MissingEntityError part-way through leaves earlier elements written.

The argument was that a caller replaying an event log converges on the same

final state anyway, because every element is individually idempotent, so

defining the failure state bought nothing that idempotency did not already

provide.

That argument is sound as far as it goes. What it missed is that the weak

promise was not what the adapters did.

The Neo4j adapter validates every endpoint in one query before it writes

anything — not out of any commitment to atomicity, but because round-tripping

per element would be the expensive way to write it. So it wrote nothing on a

dangling edge. The in-memory adapter looped over upsert_relationship and

wrote the prefix. Two adapters of one port, differing on the state a caller

observes after an error, with the port blessing only one of them.

Nothing failed. The compliance suite asserted that MissingEntityError was

raised and never what survived it, so the axis was untested in both directions

at once. This is the recurring shape rather than an oversight: a promise

nothing asserts is not a promise, and the adapter written second inherits

whatever the author assumed.

Decision

upsert_relationships is atomic. Either every element is written or none

is, and a failure leaves the store exactly as it was before the call.

Atomicity is scoped to the call. A failed batch does not disturb what an

earlier call wrote — rolling back further would be a worse bug than the one

being fixed, and it is asserted separately for that reason.

The in-memory adapter validates every endpoint before writing any element.

There is no rollback and none is needed: nothing is written until everything

is known good.

Consequences

The stronger contract cost one adapter two passes and the other nothing.

That asymmetry is why this direction was chosen over pinning the weak version:

the adapter that would find atomicity expensive already had it, so "pin the

weak contract" would have meant writing a test to permit a behaviour neither

adapter exhibited and no caller wanted.

A caller may now rely on the store being unchanged after the error. The

previous guidance — retry the whole batch, never skip an assumed-written

prefix — remains correct, and is now correct for a simpler reason.

ADR 0009's fold argument is unaffected and slightly strengthened. It

leaned on per-element idempotency so that a retry converges without a rollback

the GraphStore interface does not offer. That is still true; atomicity means

there is less to converge from. The partial application 0009 and the rebuild

how-to describe — entities written, then the relationship call refused — is a

property of the fold making two calls, not of one batch, and is unchanged.

**A future adapter over a store without multi-row transactions has to do the

validation pass itself.** This is a real cost and the honest one to name: the

port now requires something a backend may not give for free. It is bounded —

validate, then write — and the alternative was two adapters that already

disagreed.