ADR 0027: `VectorStore` is three capabilities, and the collaborators are narrowed to match
Status
Accepted. Finishes the sweep [0016 GraphStore is five
capabilities](0016-graph-store-is-five-capabilities.md) started and
[0026 ChunkStore and Cache are capabilities
too](0026-chunk-store-and-cache-are-capabilities-too.md) claimed to complete.
Amends 0002 two store ports in its typing only โ
no method is added, removed or respecified, and no adapter is touched. Amends
0016's judgement about CandidateFinder and 0026's Consequences. **Extended
by 0028**, which adds the
lifecycle member these three capabilities -- and every other -- inherit; the
split below is untouched.
Context
0026 opens by saying it applies 0016's argument "to the two remaining ports
that had the same problem", and names ChunkStore and Cache. VectorStore
appears in neither ADR, and it had the same problem the whole time. The claim
was not wrong about the ports it examined; it was wrong that they were the
remaining ones.
A port left flat, and a projection left wide. VectorProjection calls
upsert_many and nothing else. Its sibling ChunkProjection โ same package,
same job, folding one event into one store โ was narrowed to ChunkWriter in
0026 on exactly that reasoning, while VectorProjection kept the whole port.
Two projections written to the same shape, given different answers, one commit
apart.
A rule stated in a port and not applied to two of its own collaborators.
ports/graph_store.py says, as a claim about this codebase, that collaborators
should not depend on eighteen methods to call three and should "narrow the
annotation to the capability actually used". Retriever was typed against
GraphStore and VectorStore and reads through both. CandidateFinder was
typed against GraphStore under a docstring reading "Blocks and scores. Never
writes, never decides".
That last one is the finding worth stating plainly rather than counting
methods over. TenantPurge is alone in a protocol, and its own docstring says
why: to make "this collaborator can wipe a tenant" a visible fact about a
signature. A capability whose entire value is in being withheld stops having
any value the moment it is granted by default. It was granted to two
collaborators that never purge anything and to one whose stated contract is
that it never writes at all.
Decision
Compose VectorStore from three capability protocols, and narrow every
first-party collaborator to what it calls.
| Capability | Holds |
|---|---|
VectorWriter |
dimension, upsert, upsert_many |
VectorReader |
dimension, get, search |
VectorPurge |
delete, delete_by_tenant |
Every first-party consumer is covered exactly by one of the three, which is
what decided where the lines fell โ the split is by who calls what, the same
rule 0016 used to keep RelationshipStore whole rather than splitting it for
symmetry. get and search stay together because CandidateFinder reads the
subject's vector and then asks what is near it, and neither half serves it
alone.
dimension belongs to writing and to reading, not to purging. 0026 records
close ending up in both cache halves after mypy refuted a lifecycle
protocol of its own, and the same question was asked here with the opposite
starting guess. The answer came from the port rather than from a preference:
upsert, upsert_many and search are the three methods that accept or
return a vector, and they are exactly the three whose contract says
DimensionMismatchError. VectorPurge names ids only, so a caller who can
only delete has no vector length to agree about.
A VectorSearcher holding search alone was tried and dropped. The
attraction was symmetry with ChunkStore's LexicalCandidateSource, which
0026 calls the capability most worth having separately. It does not carry over:
lexical_candidates is separable because BM25 ranking genuinely needs recall
and statistics from any index at all, while search here has no caller that
does not also reach for get or dimension in the same breath. A capability
nobody can request is .claude/rules/recurring-defects.md ยง3 wearing a
Protocol.
CandidateFinder takes a ConsolidationGraph โ EntityReader,
AliasStore and RelationshipStore, composed. 0016 left it on the whole port,
reasoning that a collaborator spanning three capabilities is honestly typed by
the composed one; three of five is not five, and the two it does not span are
EntityWriter and TenantPurge. 0016 also declined a bespoke three-method
protocol here and said to revisit "if a caller ever needs a slice these five
cannot express". This is that revisit, and the form is what keeps it inside
0016's reasoning: naming a caller's combination of existing capabilities adds
no method and regroups nothing, so the port still describes the store. Inventing
a three-method interface would have started describing its callers.
Retriever takes an EntityReader and a VectorReader. It reads, and
that is now all its signature claims.
Consequences
Nothing changes for an adapter. VectorStore still names every method
through its bases, runtime_checkable still answers structurally, and
tests/unit/vector/test_compliance_coverage.py still derives get and
search by introspection, because inspect.getmembers and
typing.get_type_hints both walk the MRO. This was 0016's claim, it held for
0026, and it held again.
The public surface grows. The three vector capabilities plus
ConsolidationGraph. A caller cannot narrow an annotation to a type they may
not import, so this follows from 0006's closure gate the moment a narrowed
signature names one.
**ConsolidationGraph lives beside its consumer rather than in ports/,
and that placement is provisional.** It is a composition of capabilities the
port declares, so ports/graph_store.py is arguably its home; it is also a
statement about one caller, which is what consolidation/protocols.py already
holds for CandidateSource and MergeAdjudicator. It sits in
consolidation/candidates.py today. Moving it is a rename with no behavioural
component, and the argument for either home is worth having once rather than
inferring from where it happens to be.
**Amendment: that argument was had, and it settled on
consolidation/protocols.py.** The deciding fact is not the layering โ unlike
CandidateSource and MergeAdjudicator, ConsolidationGraph names only
types ports/ already declares, so it could sit there and compile. It is
what the port would become. A port module describes the store; a composition
describes one consumer's subset of it, and a ports/ module carrying those
accumulates one protocol per caller, which is what 0016 rejected under
"consumer-owned protocols everywhere". Declaring it beside the consumer that
shaped it keeps 0016's line โ the port still describes the store โ while
keeping the room 0016 left for "a slice these five cannot express".
consolidation/protocols.py therefore holds two kinds of thing, and its
docstring now says so: two substitution points a caller may replace, and one
narrowing that nobody substitutes. What they share is the direction they face
โ each states what consolidation needs rather than what a store offers.
The public name is unchanged: redstring.ConsolidationGraph, exported from
__all__ exactly as before. Nothing about the type, its bases or its
runtime_checkable behaviour moved with it.
**The narrowing is enforced by asserting the declarations, because nothing
else can.** This is the part of the sweep that had not been checked before and
is worth recording. The precedent tests from 0026 build a double implementing
one capability and drive a real consumer with it, which is the right shape and
is behaviourally silent: widening every annotation in this ADR back to the
composed ports leaves those tests green and the configured mypy run silent,
because the gate covers src/redstring and not tests/. Measured, not
assumed. So each new test module carries a class that reads the annotation
itself โ the projection's generic argument through __orig_bases__, the two
constructors' raw annotation strings. They cannot tell you a caller passes
something too narrow; they are what makes a reverted narrowing fail rather
than merely read differently.
0026's closing observation now has a second instance. It noted that
ChunkPurge and ChunkReader have no first-party caller, and that the type
system had made a previously invisible fact askable. VectorPurge is the same:
nothing in this library deletes a vector or wipes a tenant's embeddings, and
VectorProjection._truncate_read_models raises rather than doing it. Whether
the library's own replay path should be able to is now a question with a name.