A merge resolves the canonical entity's fields

Status: approved, ready to plan

Closes: BACKLOG B127, and the "nothing calls the rest" half of B28

Related: ADR 0001, 0004, 0006, 0010, 0035; new ADR (draft 0036)

The problem, stated exactly

domain/merge_strategy.py implements four strategies over PropertyClaims,

with a total order and a claim constructor, and **no production code calls

any of it**. consolidation/service.py merges edges only: the canonical

entity keeps its own properties and the absorbed entities' are discarded.

That is .claude/rules/recurring-defects.md ยง3 โ€” code with no caller โ€” held

open deliberately and recorded as B127. This spec closes it by giving

resolve its one production caller, through the write side, so the decision

lands in the log rather than being recomputed on read.

Two corrections to B127's own framing

Both change the shape of the work, so they are recorded before the design

rather than discovered inside it.

The undo payload is one entity, not many

B127 says the absorbed entities' original values have to be in the log

"because a resolved value does not contain them", and names UNION as the

case that makes it obvious โ€” you cannot tell from [a, b] who said what.

You never need to. A merge does not touch the entities it absorbs.

GraphStore has no delete_entity (ADR 0002), GraphProjection._apply_merge

writes an Alias per absorbed entity and nothing else, and the absorbed rows

survive unchanged. The only entity whose data a merge changes is the

canonical one. So the record of what a merge did to entity data is exactly

RelationshipRedirection's shape โ€” a before and an after on one thing โ€”

and undo restores it by upserting before.

This is not a simplification taken for convenience; it is what the read model

already does. A payload carrying every absorbed entity's originals would be

carrying data no handler could use.

The obstacle to `PREFER_MERGED` and friends was never the timestamp

Already recorded in ADR 0035 and repeated here because it sets the scope: the

strategies resolve now because resolve takes claims rather than bare values.

Nothing in this spec adds a new time source, a per-property edit history, or

any data to Provenance. It wires an existing decision into an existing

pipeline.

Design

1. What a merge decides

Three targets on the canonical entity:

Target Type Shape
description `str \ None` scalar
external_ids dict[str, str] per key
properties dict[str, Any] per key

name, entity_type and temporal are out of scope. Preference between

whole entities is ADR 0010's domain.preference, which already orders on

name, description and temporal; re-deciding name per merge would put

two answers to one question in the codebase. description appears in both and

that is deliberate โ€” preference uses it to order entities, this resolves

its value, and neither reads the other's result.

2. Naming a target: a dotted path

One key space so a scalar field and a dict key share a single lookup.

Path Means
description the scalar field
properties the default for every key of properties
external_ids the default for every key of external_ids
properties.role that one key
external_ids.wikidata that one key

class PropertyMergePolicy(BaseModel, frozen=True):
    default: PropertyMergeStrategy = PropertyMergeStrategy.PREFER_CANONICAL
    overrides: Mapping[str, PropertyMergeStrategy] = {}

    def strategy_for(self, path: str) -> PropertyMergeStrategy: ...

strategy_for resolves exact path โ†’ field default โ†’ policy default, and

that order is the whole content of the type. A path with no dot has no field

default to fall back to, so it consults overrides once and then default.

An unknown top-level field in overrides is a construction-time error. A

policy naming properities.role (typo) would otherwise be silently inert โ€”

every merge would apply the default and nothing would say so. This is the

exemption-list rule from CLAUDE.md in a different costume: a mapping entry

that matches nothing must fail rather than pass.

3. `UNION` is refused outside `properties`, at construction time

external_ids is dict[str, str] and description is str | None. UNION

returns a list, which type-checks against neither, and pydantic would raise

at the moment the projection tried to upsert the entity โ€” inside a fold,

after the event is durably in the log, with no way to make progress.

So PropertyMergePolicy refuses at construction: UNION is legal only on

properties and properties.*. The failure then arrives when a caller wires

up a service, before any data has moved, which is the only point at which it

is cheap.

Refusing rather than coercing, for the same reason DEEP_MERGE raises rather

than falling back: writing a joined string into description because the

caller asked for UNION would corrupt data while looking like it worked.

4. Silence, and why `description` differs

claims_for skips an entity whose properties lack the key โ€” silence is not

an assertion, and treating it as one lets an entity with no opinion outvote

one with an opinion under MOST_RECENTLY_OBSERVED merely by being newer. An

explicit None value is a claim and is kept.

description has no such distinction: the field always exists, and None is

its absence. A None description is therefore silence and is skipped.

The asymmetry is real, it is not an inconsistency, and it must be stated where

it is implemented โ€” a reader who knows the dict rule will otherwise expect a

None description to win a recency contest.

external_ids values are str, so its keys follow the properties rule

unchanged: present means claimed.

5. Payload

In domain/consolidation.py, beside RelationshipRedirection:


class MergeableFields(BaseModel):
    """Exactly the fields a merge may decide."""

    description: str | None = None
    external_ids: dict[str, str] = {}
    properties: dict[str, Any] = {}


class PropertyResolution(BaseModel):
    """What a merge decided about one entity's fields, before and after."""

    entity_id: EntityId
    before: MergeableFields
    after: MergeableFields

EntitiesMerged gains resolution: PropertyResolution | None = None and goes

to event_version = 2.

Optional rather than required, and the reason is about who can derive it:

ConsolidationLog holds no entity data, so it cannot compute a resolution and

must be handed one. A parameter the aggregate cannot validate should not be

mandatory by type when its absence is a true state โ€” "this merge decided

nothing about fields" is exactly what a direct aggregate caller means. The

service always supplies one, which is what makes the path production code

rather than more ยง3 material.

resolution.entity_id must equal canonical_entity_id; validated on the

event, because a resolution naming an absorbed entity would have the

projection overwrite the wrong row and undo restore it, and nothing

downstream could tell.

MergeUndone gains restored_fields: MergeableFields | None = None, derived

by replay from MergeRecord exactly as restored_relationships already is

(ADR 0001 Decision 6). The aggregate stores the resolution on MergeRecord

when it folds EntitiesMerged, and hands before back when asked to undo.

6. Planning, on the write side

consolidation/planning.py gains a sibling to plan_redirections:


def plan_properties(
    *, policy: PropertyMergePolicy, canonical: Entity, others: Sequence[Entity]
) -> PropertyResolution

It walks the union of keys across the group for each dict field, calls

claims_for and then resolve per path, and assembles before from the

canonical entity as read.

after is the complete post-merge value of all three fields, not a diff.

The projection replaces them wholesale, so a key omitted from after would be

a key deleted โ€” which is why a resolution must be exhaustive over the union

rather than over what changed.

That makes an empty claim list unreachable for a dict key by construction: the

key is in the union because at least one entity has it, so at least one entity

claims it. The one path where claims_for can return [] is description,

when every entity in the group has None โ€” and after.description is then

None, which is that field's absence and the same value before held.

resolve is not called with an empty list; the guard is not defensive

programming but the only correct answer for a field nobody described.

ConsolidationService.merge reads the group's entities (one

get_entities call โ€” EntityReader.get_entities is already on

ConsolidationGraph, so no protocol changes), plans, and passes the

resolution to log.merge(). Read-plan-emit, the same shape every other method

there has, and no store write.

The canonical entity missing from that read raises MissingEntityError. It is

the same inconsistency _resolved_subject already names: a merge whose

canonical has no row means the log and the graph disagree, not that a read

missed.

7. Applying it

GraphProjection._apply_merge upserts the canonical entity with after;

_apply_undo upserts it with before. Both are literal snapshots rather

than recomputations, so applying either twice leaves the same state โ€” the

projection's idempotency requirement holds by construction rather than by

argument, and replay produces the identical entity row.

A canonical entity absent from the store on either path raises

MissingEntityError, which the module docstring already describes as a poison

event routed to the DLQ. Skipping silently would drop the decision with

nothing to notice.

No store adapter changes: description, external_ids and properties

already round-trip through both GraphStore implementations.

8. Public surface

ConsolidationService is not exported. Consolidator.__init__ (the

composed entry point, composition/build_graph.py) is what mentions

PropertyMergePolicy in its signature, and the signature gate walks the MRO,

so the policy enters __all__ and pulls PropertyMergeStrategy with it.

EntitiesMerged and MergeUndone are already exported, so

PropertyResolution and MergeableFields follow.

B127 predicted this ("the strategy names get exported at that point rather

than now"). ADR 0006 is exercised, not amended.

Out of scope

A wrong deep merge is unrecoverable because the pre-merge shape is not

derivable from its result, and this spec does not change that.

relationship's time. Redirections carry whole Relationships and undo

restores them verbatim, so adding observed_at here would ship another

unreached field, which is the defect this work exists to close.

the prompt and does not constrain; letting it decide merge semantics is a

different decision needing its own argument.

Against the existing ADRs

ADR Verdict
0001 event log schema and granularity Amended. Two payloads gain a field and EntitiesMerged goes to event_version = 2. Decision 6 (an undo carries its restoration) is extended to entity fields rather than changed.
0002 two store ports Stands. No port change; the absence of delete_entity is what makes the one-entity payload correct.
0004 consolidation emits events Stands, and is exercised for the first time. A projection now applies a decision it did not make, which is the arrangement 0004 exists to produce.
0006 the public surface is gated Stands, exercised. The policy's closure enters __all__ through the signature gate, exactly as 0035's did.
0010 one total order for preference Stands. _order_key composes with duplicate_preference rather than competing; nothing here reorders entities.
0011 domain schemas prompt but do not constrain Stands. Explicitly out of scope above.
0035 provenance is a value object Stands. This consumes Provenance; it adds nothing to it.
new (draft 0036) A merge resolves the canonical entity's fields, and the event carries before and after. Renumber against main at merge.

Verification

PropertyMergePolicy fallback tier, invert the None-description skip,

swap before for after in the undo handler. A green break is a finding

about the fixture, not a pass.

the expectation recorded independently of the projection rather than

produced by it โ€” the replay-equivalence lesson in CLAUDE.md: both sides of

a self-consistency property agree on the same wrong state when the fold does

too little.

entities, not two, so PREFER_MERGED and MOST_RECENTLY_OBSERVED are

distinguishable. Pinned EntityIds where an order's tie-break is under test,

never uuid4() โ€” that is the row CLAUDE.md gained from the previous

slice, and it fired while checking the work rather than while doing it.

the previous slice could not make.

Backlog

Deleted on completion: B127. B28 shrinks again to DEEP_MERGE alone

and loses its "nothing calls the rest" half. B128 is untouched and stays.