ADR 0004: Consolidation emits events rather than writing

Status: accepted, slice 7 of the ring migration. Closed BACKLOG B40.

Why this is an ADR: the previous implementation resolved duplicates by

writing to the store directly, and it was deleted rather than ported. Someone

will eventually notice that ConsolidationService.merge does a lot of work to

produce an event that a projection then applies, and propose collapsing the

two. This records what that would cost.

Context

The deleted SimpleMerger/LLMMerger (recoverable from

ff36ec7:src/redstring/services/consolidation/) decided that "Ada" and "Ada

Lovelace" were the same entity inside extraction, and wrote the merged

result. Two consequences followed from that placement, and neither was a

performance concern:

made, what it scored, or what a model said about it. The graph simply had one

entity where the documents had two.

merge was permanent — and the merges most worth undoing are exactly the ones

a fuzzy-similarity threshold gets wrong.

Decision

ConsolidationService does not write to GraphStore or VectorStore. It

reads the graph to work out what a merge would do, records that as an

EntitiesMerged on the ConsolidationLog aggregate, and stops. A projection

applies it. Every method is the same shape: read, plan, emit.

Undo is a compensating event (MergeUndone), not a rollback. Its payload is

derived from the log, not from the caller — the caller names a merge, and the

aggregate replays what that merge did.

The invariants live on the aggregate, checked against replayed log state rather

than against the graph: no merging into an alias, no double-merge, and undo

must reference a real merge. Nothing enforced any of the three before.

Evidence that this holds

The slice's headline test is that **merge → undo reproduces the pre-merge graph

exactly*, over a diamond* graph containing all four cases at once — an edge

that moves, a self-loop collapse, a duplicate collapse, and an uninvolved edge

that must not move.

Three properties of that test are what make it evidence rather than

decoration, and all three were added because a weaker version passed against

broken code:

produced by the fold. An equivalence property whose two sides both run the

fold cannot distinguish a correct fold from one that does too little — slice

5b's replay-equivalence suite passed against a handler that never applied an

undo, one that never deleted a dropped edge, and one that never wrote

relationships at all.

that did nothing round-trips perfectly.

implementations agree with the right one.

Eight hand-applied mutants died against it. The ninth survived and was the

useful one: a comment in _apply_undo claimed an ordering was load-bearing and

it was not — a comment asserting a constraint that does not exist is how a

later reader comes to believe the fold resolves everywhere.

The cost, stated

The read happens before the aggregate is loaded, so the edge set a merge

plans against can be stale by the time the append happens. This is deliberate:

the read model is a projection and lags the log by construction, so no ordering

of the two steps makes the graph authoritative, and doing the read inside the

aggregate's window would widen the window without making it correct.

Staleness has three consequences and only the third matters. A redirection for

an edge that has since gone is harmless (both writes are idempotent). An edge

that appeared after the read is self-healing (the extraction fold resolves it

on the next DocumentExtracted). But if the canonical entity already carries

the same claim, **that resolution creates a permanent parallel edge rather than

fixing one**, and nothing repairs it — re-extraction is what produces the

duplicate.

That is BACKLOG B43, open, pinned in

tests/unit/consolidation/test_known_gaps.py, which asserts the wrong answer

on purpose. Only re-plan-on-version-conflict addresses it, because

plan_redirections is the only code that deduplicates and by definition it

never saw the late edge.

Consequences

does both halves for a caller who has no event store.

precisely the data needed to tune the two similarity thresholds, currently

inherited numbers with no measurement behind them on any real corpus.

and consolidation remain sibling layers that never import each other. The

cost is that nothing but a test spanning both can show they agree about what

a key is — and slice 7 found that they did not: mapping.py never set

blocking_keys, so every entity reached the store with None and

consolidation would have found no candidates in production. Silently, because

an empty candidate list is what "no duplicates" also looks like.