ADR 0036: A merge resolves the canonical entity's fields
Status
Accepted.
Amends 0001 event log schema and granularity.
EntitiesMerged gains a field resolution and goes to event_version = 2;
MergeUndone gains its restoration. 0001's decisions stand — the granularity,
the aggregates, and the rule that every event declares its own version rather
than inheriting one.
0004 consolidation emits events
stands, and is exercised further: the resolution is decided once, in
ConsolidationService.merge, and recorded on EntitiesMerged rather than
recomputed by GraphProjection. This is the same rule 0004 already applies to
redirections, applied now to entity data.
0035 provenance is a value object
stands, and its deferred consequence is what this ADR closes. 0035 left
resolve and claims_for implemented and unreached, and its Consequences
said as much: "the merge-strategy names stay unexported... when consolidation
reaches them, the export becomes a decision someone takes on purpose." This is
that decision.
0002 two store ports stands — GraphStore
still has no delete_entity, which is why the effect of a merge on entity
data is a single before/after pair on the canonical entity rather than
anything touching the absorbed rows.
0006 the public surface is gated
stands, and is exercised: PropertyMergePolicy, PropertyMergeStrategy,
MergeableFields and PropertyResolution enter __all__ because
Consolidator.__init__'s merge_policy= parameter and EntitiesMerged's
signatures now mention them. ConsolidationService itself is not exported —
Consolidator, in composition/build_graph.py, is what the signature gate
actually sees.
Context
Consolidation absorbs duplicate entities but, until now, only ever discarded
what they knew: GraphProjection writes an alias for each absorbed entity and
nothing else, and the canonical entity's description, external_ids and
properties pass through a merge unchanged. domain/merge_strategy.py
already had four working strategies and a fifth that raises on purpose
(DEEP_MERGE, BACKLOG B28) — resolve and claims_for were fully typed and
fully tested, with no caller outside tests/ (BACKLOG B127).
Wiring them up needed three things resolve's signature alone could not
supply: a way to say which strategy applies to which field, a place to
record the decision so a projection applies it instead of recomputing it, and
an undo path that can put the pre-merge values back.
Decision 1: a merge decides fields only on the canonical entity
A merge does not touch the entities it absorbs. GraphStore has no
delete_entity, the projection writes an Alias per absorbed entity and
nothing else, and those rows survive unchanged. The whole effect of a merge
on entity data is therefore one before/after pair on the canonical entity —
description, external_ids and properties, the fields
domain.merge_strategy.MERGEABLE_FIELDS names. name, entity_type and
temporal are deliberately absent: preference between whole entities is
already ADR 0010's domain.preference, and re-deciding name here would
give one question two answers.
An earlier version of this design asked for every absorbed entity's original
values as well, reasoning that a UNION result cannot say who claimed what.
True, and not needed: nothing downstream has a row to put those values back
into, since the absorbed entities are never rewritten.
Decision 2: the decision is recorded, not recomputed
EntitiesMerged.resolution carries the complete post-merge value of all
three fields — a literal snapshot, not a diff, so a key omitted from after
is a key deleted and the projection replaces the fields wholesale. Recomputing
the resolution on read would need the pre-merge graph, which the projection
has already overwritten by the time it applies the event, and would let the
read side make a decision the write side is supposed to own. This is 0004's
rule applied to entity data exactly as redirections already applies it to
edges: the plan is computed once, before anything is emitted, and the fold
only replays it.
MergeUndone.restored_fields is the mirror image, for the same reason
redirections needs before: an omitted restoration is indistinguishable
from "nothing to restore," and undo has no other source for the pre-merge
values once the log is the only place they still exist.
Decision 3: strategy selection is a policy keyed by dotted path
PropertyMergePolicy holds a default strategy and per-path overrides —
description, properties, properties., external_ids..
strategy_for resolves exact path, then the field's own default, then the
policy default, in that order, and that order is the whole content of the
type. A single flat default would not let a caller keep the canonical
description while unioning one property key; a policy with no exact-path tier
would not let a caller override just that key without also overriding every
other key of the same field.
Decision 4: `UNION` outside `properties` is refused at construction
external_ids is dict[str, str] and description is str | None; a list
type-checks against neither. PropertyMergePolicy refuses UNION on any
field but properties when it is built, because the alternative failure is
worse: a pydantic error raised inside a fold, with the event already durable
and no way to make progress. Refusing at the point a caller wires up a
service is the only point at which the mistake is cheap. DEEP_MERGE is
not refused the same way — it still raises from resolve at plan time,
before any event exists, and encoding "which strategies are implemented" a
second time in the policy's validator would give that question two answers,
with the newer one nobody remembers to update.
Decision 5: a `None` description is silence; an explicit `None` property value is a claim
claims_for treats the two field shapes differently on purpose. properties
and external_ids are dicts: an entity whose dict lacks a key is skipped,
because treating absence as a claim would let an entity with no opinion
outvote one with an opinion under MOST_RECENTLY_OBSERVED merely by being
newer, while an entity whose dict holds an explicit None for a key is
kept, because it said something. description is a scalar field that always
exists on Entity, so there None means the entity is silent, not that it
claimed nothing — and is skipped. The asymmetry is real rather than an
inconsistency: a reader who has internalised the dict rule will expect the
opposite for description, which is why it is stated here rather than left
to be rediscovered from the two call sites.
Consequences
**EntitiesMerged goes to event_version = 2; MergeUndone stays at
event_version = 1.** resolution is optional on the type for replay
compatibility with events already in a caller's log, but every merge from
this decision forward writes one. Both events gained an optional field that
does not invalidate a pre-existing payload, so either could defensibly have
been bumped or left alone. The rule this branch follows: **bump when the new
field is the primary subject of the change** — a merge's resolution is the
decision this ADR adds, so EntitiesMerged's version number should say a
reader inspecting the log can now expect it — **and leave the version alone
when the field is a derived consequence of an already-versioned sibling** —
MergeUndone's restored_fields only exists because EntitiesMerged can now
carry a resolution to restore, so its presence is implied by reading the
merge it undoes rather than by MergeUndone's own shape changing in a way a
version number needs to advertise. This is a judgment call about which event
a schema change is "about", not a mechanical rule; the next asymmetric field
addition should make the same call explicitly rather than copying whichever
of the two it read first.
BACKLOG B127 is closed. resolve and claims_for now have a production
caller: ConsolidationService.merge reads the group's entities, calls
consolidation/planning.py's plan_properties, and emits the resolution on
EntitiesMerged; GraphProjection._apply_fields applies it from both the
merge and the undo handlers.
BACKLOG B28 is narrowed, not closed. DEEP_MERGE remains deferred for
its own reason, unrelated to the signature change: nested-dict semantics are
easy to get subtly wrong, and a wrong deep merge is effectively unrecoverable
because the pre-merge shape is not derivable from the result.
A merge policy has no per-call override on Consolidator.
ConsolidationService.merge takes a policy argument for a single call, but
nothing above it — Consolidator.merge and Consolidator.resolve — exposes
the same override yet. Tracked as BACKLOG B131 rather than closed here.