ADR 0035: Provenance is a value object, and a strategy is named for the question it can answer
Status
Accepted.
Amends 0001 event log schema and granularity.
0001's decisions stand โ one coarse DocumentExtracted per extraction run,
owned by the Document aggregate, with an explicitly declared schema version.
What changes is the shape of the Entity inside that payload: five fields
move onto a nested value object and a required one joins them, so no payload
written against the old shape validates. 0001's Consequences carry the
version-bump obligation and the clean break.
0010 one total order for preference
stands, extended by composition. preference keeps its definition and
reads the same values, now through entity.provenance. The claim order
introduced here is a new and narrower order over a different thing โ one
property's claim rather than a whole entity โ composed the way
duplicate_preference composes: meaningful components, an id appended for
totality. It is not a second definition of 0010's order and must not become
one.
0006 the public surface is gated
stands, and is exercised: Provenance enters __all__ only because
Entity's signature now mentions it, which is 0006's closure rule working
exactly as described. The merge-strategy names stay unexported.
0002 two store ports stands โ no port method
changes; both adapters' row shapes change because the fields moved, and the
contract over them does not.
0004 consolidation emits events
stands, untouched, because this work deliberately does not wire property
merging into the merge path.
0005 temporal inference on read
stands, and is worth naming precisely because a new time field arrives:
TemporalExtent is when a fact held and observed_at is when this library was
told. Nothing infers one from the other.
Context
domain/merge_strategy.py refused the strategy then called LATEST with a
reason that read convincingly and was wrong in a way that mattered: that
timestamps were per entity rather than per property, so "the most recently
updated value" had no data behind it.
There were no per-entity timestamps either. Entity had no time field at all,
and the only record-time value anywhere in the library was Alias.merged_at.
TemporalExtent holds world time and cannot stand in for record time.
Naming the wrong obstacle pointed at the wrong fix. "Add a per-property
timestamp" is not the smallest honest change, and it is not the change that
makes the strategy answerable. The real blocker was the signature:
def resolve(strategy, *, canonical: Any, others: list[Any]) -> Any
resolve received bare values. Nothing about where a value came from, when,
or how confidently, survived the call. **Every strategy needing more than the
value itself was unanswerable by construction**, and no amount of new data on
Entity would have changed that while the call dropped it at the boundary.
The refused strategy was simply the first member of the enum to notice.
Decision 1: provenance is a value object on `Entity`, carrying a required `observed_at`
Entity gains a Provenance, and loses the fields that describe the
claiming rather than the thing claimed.
The seam is that distinction and nothing else. name, entity_type,
description, properties and temporal are claims about the thing; what
observed it, when, how, from where and how sure are claims about the
observation. normalized_name and blocking_keys stay on Entity despite
being derived, because they are derived from the thing rather than from the
observation.
The validators move with the fields, because they were invariants of the
observation all along and sat on Entity for want of somewhere better: the
confidence bound, and the rule that a model may only be recorded when the
method invoked one. A new validator joins them, requiring observed_at to be
timezone-aware โ modelled on Alias.merged_at, and for the same reason. A
naive datetime compared against an aware one raises at the moment of
comparison, which here means deep inside a merge rather than at the
construction site that could name the offending entity.
**observed_at is required, and that is the load-bearing part of this
decision.** An optional one would rebuild the original hole one level down: the
strategy would work for some callers and refuse for others, with no way to tell
which until it ran. Required makes the question answerable by construction โ no
None branch, no "unprovenanced" error class. The cost is that every
construction site supplies one and no previously written payload validates.
Both were accepted deliberately; there is no persisted log to migrate.
Relationship does not get a Provenance. Symmetry is tempting and would
be wrong: a relationship carries confidence and a source but no extraction
method and no model, so its provenance is a different shape. Sharing the type
would mean fields that are always absent, or a base class earning its keep on
two subclasses. The asymmetry is real, and the relationship-provenance gap is
tracked in BACKLOG.md on its own terms rather than papered over here.
Decision 2: a merge strategy is named for the question it can answer
LATEST becomes MOST_RECENTLY_OBSERVED, wire value included.
The rename is the point rather than incidental tidying. LATEST invites a
caller to believe the library knows when a property was last updated. It does
not and will not โ nothing here tracks a property's edit history, and this
decision does not add one. What is available is weaker and genuinely
answerable: of the entities asserting this property, which was observed most
recently, and what did it assert. A name that promises less is the difference
between a strategy and a misrepresentation.
resolve takes an ordered sequence of claims, canonical first. Positional
rather than a canonical=/others= pair because every strategy but the
canonical one treats them as one sequence, and two parameters force each to
re-splice them. PREFER_MERGED becomes implementable for free, which is the
signature change paying for itself: it was never hard, only ill-defined about
which absorbed entity when there are several, and "the first the merge
listed" is a rule the caller controls and a projection can replay.
The order behind MOST_RECENTLY_OBSERVED must be total, per 0010: the
moment two claims compare equal the winner is decided by arrival order, in a
durable replayable log, and two entities extracted in the same batch share an
instant exactly. Recency first, confidence second โ between two simultaneous
observations, prefer the surer one โ and the origin id last, carrying no
meaning at all and present solely so that no two claims from distinct entities
can tie. Totality is asserted as a property test rather than argued in a
comment, because 0010 is explicit that reading a > โ >= survivor as
equivalent is a claim about the order rather than an observation about the
diff.
DEEP_MERGE stays deferred, for a reason none of this touches: a wrong
deep merge is hard to undo because the pre-merge shape is not recoverable from
its result. It raises rather than falling back to the canonical value, which
would corrupt data while looking like it worked.
Consequences
No previously written DocumentExtracted payload validates. This is a
clean break, sanctioned because there is no persisted log. A library that
shipped a log would owe an upcaster here, and 0001's event_version plus an
upcaster is the escape route it describes.
So DocumentExtracted carries event_version = 2. "No persisted log" is
a claim about this repository, not about the consumers of a published library,
which is why the number moved even though nothing in the tree can encounter a
1. It cost a gate:
0001 Decision 3 asserted every
event's version was literally 1, and that assertion had to be split into the
half that was doing work (the version is declared, not inherited) and a
per-event table pinning the number. 0001's Consequences record the split.
The strategies are implemented and unreached. Consolidation still merges
edges and discards the absorbed entities' properties, so resolve has no
production caller after this decision, exactly as it had none before it.
Wiring it up needs a merged-properties payload on the merge event, a
projection that applies it, and an undo that restores the pre-merge values โ
which is a larger change, kept in BACKLOG.md. This is
recurring-defects.md ยง3 (code fully tested and never invoked passes every
gate this repository has) held deliberately open with its eyes open, and
stated plainly because the alternative is a reader concluding the feature
shipped.
Two orders now exist over overlapping data, and they must not converge.
0010's order ranks whole entities on fields a single property's claim does not
have; the claim order ranks claims on fields an entity-wide order has no
business consulting per property. A future edit that makes one call the other
is the defect 0010 exists to prevent, arriving from the side.
Reading a value now costs a hop. Callers write entity.provenance.confidence
rather than entity.confidence, and there is deliberately no forwarding
property: a second way to spell the same read is a second declaration site, and
the one that loses looks authoritative.
The merge-strategy names stay unexported. They have no production caller,
and exporting an uncalled capability is how a promise gets made by accident.
When consolidation reaches them, the export becomes a decision someone takes on
purpose rather than one already taken.