ADR 0025: Consolidation's two substitution points are protocols, not classes

Status

Accepted. Amends [0015 consolidation gets a composed entry

point](0015-consolidation-gets-a-composed-entry-point.md), whose Decision

stands: Consolidator is still the composed entry point, resolve still

takes the same four keyword arguments with the same defaults and the same

banding. What changes is the type two of those arguments are declared

against.

Why this is an ADR: it changes the public surface (__all__ gains two

names) and it adds plug-in protocols, both of which

.claude/rules/definition-of-done.md names.

Context

Consolidator.resolve documents two of its parameters as substitution points:

finder: Overrides the default built from this consolidator's stores.

Supply one to change the weights or the blocking.

adjudicator: Asked about the middle band, in batches.

Both were annotated against concrete classes โ€” CandidateFinder and

Adjudicator โ€” and both classes bind collaborators in their constructors.

CandidateFinder.__init__ requires a GraphStore and optionally a

VectorStore, a FeatureWeights and a flag; Adjudicator.__init__ requires

an LlmProvider.

So the two substitutions the docstring invites are exactly the two the

annotation obstructs. A caller whose blocking should come from a search index

they already run does not have a GraphStore to give the base class, and a

caller putting the ambiguous band to a human review queue does not have an

LlmProvider. Under mypy --strict โ€” which is how this repo's own gate runs

and what its py.typed marker promises downstream โ€” the only route is to

subclass a class, satisfy a constructor with collaborators that will never be

called, and override the one method actually wanted.

This was measured rather than argued. Assigning a duck-typed substitute to

each annotation:

x: CandidateFinder = SearchIndexCandidates([]) # error: incompatible types

x: CandidateSource = SearchIndexCandidates([]) # Success

Inviting a substitution and typing it against an implementation are different

promises, and the documentation was making the first while the code made the

second.

Decision

**Declare CandidateSource and MergeAdjudicator as runtime_checkable

protocols in redstring/consolidation/protocols.py, annotate both resolve

methods against them, and export them.** CandidateFinder and Adjudicator

are unchanged, remain the defaults, and satisfy the protocols structurally.

Each protocol has exactly one method, which is what makes them cheap enough to

be worth having at all: candidates and adjudicate.

They live in consolidation/, not in ports/. Both traffic in

ScoredCandidate, a consolidation type, so a port would have to import

upward through the layer contract. That is the layering telling the truth

about what these are โ€” not store or provider boundaries the whole library is

built on, but the two decisions within consolidation a caller might

reasonably own: which candidates to consider, and who settles the ambiguous

ones. The precedent is extraction/protocols.py, which holds Chunker for

the same reason.

**Two obligations are stated in the protocols rather than left to the

defaults' behaviour**, because they are properties a substitute can violate

without erroring:

CandidateFinder breaks score ties by ascending entity id as a string

precisely so two runs over one graph agree. A substitute sorting on score

alone leaves a cutoff inside a tie to be decided by whatever order its

backend returned โ€” which surfaces as an intermittently different merge, not

as an error.

aligned**, with None where it has no answer. None is not a formality: a

provider outage and a considered "not the same" are different facts, and

collapsing them turns an outage into a corpus that appears to hold no

duplicates.

Consequences

The docstring's offer becomes true, and the two named substitutions are

now writable as ordinary classes holding ordinary state.

The enforcement is the type checker, not the test suite, and that is worth

knowing before reading tests/unit/consolidation/test_substitution.py as

stronger evidence than it is. Python does not enforce annotations, so a

duck-typed substitute always ran; what those tests add beyond the two

isinstance assertions is that a substitute holding none of the defaults'

collaborators genuinely drives a merge, is genuinely consulted about the band,

and that its None is not read as a yes.

A third implementation of either protocol has no compliance suite. The

store ports each have one under tests/compliance/, and

.claude/rules/recurring-defects.md ยง1 is about exactly what happens without

one: two implementations of a contract diverge and nothing fails, because each

one's tests assert its own behaviour. The two obligations above โ€” a total

order, and positional alignment with None for no answer โ€” are the shared

claims such a suite would hold, and they are currently prose in a protocol

docstring. This is filed as B101 rather than built now: there is one

implementation of each, and a compliance suite written against a single

implementation is tuned to it rather than derived from the contract.

ScoredCandidate is now load-bearing on the public surface in a way it

was not. It was exported as part of 0015's closure, describing what the

default finder returned; it is now the input type a caller constructs when

supplying their own. Its shape is harder to change than it was.