ADR 0026: `ChunkStore` and `Cache` are composed from capabilities, like `GraphStore`

Status

Accepted, and **amended by

0027**,

which corrects the claim below that ChunkStore and Cache were the two

remaining ports: VectorStore had the same problem and is named in neither

this ADR nor 0016, and **extended by

0028**, which moves this

ADR's close -- and the async with pair the ports had never declared -- onto

every capability through a shared AsyncClosable base, by the same mypy

arbitration recorded below. Applies [0016 GraphStore is five

capabilities](0016-graph-store-is-five-capabilities.md) to two of the three

ports that had the problem. Amends [0008 the two non-store

ports](0008-the-two-non-store-ports.md) and [0023 the chunk

corpus](0023-the-chunk-corpus.md) in their typing only: no method is added,

removed or respecified, and every adapter in the tree is untouched.

Amended by 0038: a

fifth capability, SemanticCandidateSource, joins the four ChunkStore

named here. No decision this ADR made is reversed.

Context

0016 argued that depending on eighteen methods in order to call three is the

interface-segregation complaint in its plainest form, and split GraphStore

accordingly. The argument was made about one port. It applies unchanged to two

others, and in one case the numbers are worse.

ChunkStore is nine methods with one first-party consumer.

ChunkProjection calls replace_source. That is the entire first-party use.

The other eight โ€” get, get_by_source, get_by_entity,

lexical_candidates, upsert_many, delete_by_source, delete_by_tenant โ€”

exist for library users, which is an excellent reason for the port to offer

them and no reason at all for the projection to depend on them. One of nine,

against 0016's three of eighteen.

The cost is the same currency 0016 measured: tests/compliance/chunk_store.py

is over a thousand lines, so an author writing a chunk store to serve only the

corpus-write path owed a read, rank and delete surface they would never call.

Cache is eight methods whose two consumers partition it exactly.

llm/circuit_breaker.py uses get, set, increment, delete;

llm/rate_limiter.py uses record_hit, count_hits, oldest_hit; both use

close. Neither touches the other's four. ports/cache.py has carried a

section heading reading "## Two capabilities, not one" since it was

written โ€” the analysis was done, and then expressed as prose above a single

flat protocol. Someone implementing a Cache to get distributed circuit

breaking across workers owed a sliding-window hit log regardless.

Nobody has yet been caught by either gap, because every adapter in this tree

implements its whole port. That is .claude/rules/recurring-defects.md ยง3

rather than a reason to wait: a rule holding only because nobody has tested it

is indistinguishable from no rule.

Decision

Compose both ports from capability protocols, and export the capabilities.

Port Capabilities
ChunkStore ChunkWriter, ChunkReader, LexicalCandidateSource, ChunkPurge
Cache KeyValueCache, HitWindow

Adapters continue to implement the composed port and the compliance suites

continue to run against it. Collaborators are narrowed to the capability they

call: ChunkProjection is now a StoreProjection[ChunkWriter],

CircuitBreaker holds a KeyValueCache, RateLimiter a HitWindow.

**close belongs to both cache halves rather than to a lifecycle protocol of

its own.** The first attempt gave it one, reasoning that neither consumer

called it; mypy refuted that immediately โ€” both forward it. Releasing what an

adapter holds is a property of holding one, so it belongs to every capability

rather than beside them.

LexicalCandidateSource is the capability most worth having separately,

and it is the one that is not merely a narrowing. Ranking needs recall and

corpus statistics and nothing else โ€” see

0024, which put the scorer in the

domain precisely so it depends on no store. A caller who can supply those from

an index that is not a chunk store at all can now say so in a type.

Consequences

Nothing changes for an adapter. Each composed protocol still names every

method through its bases, runtime_checkable still answers structurally, and

tests/unit/chunks/test_compliance_coverage.py still finds every read method,

because inspect.getmembers and typing.get_type_hints both walk the MRO.

This was 0016's claim and it held again.

The public surface grows by six names. 0016's five capabilities are

exported, so consistency requires these to be. A caller cannot narrow an

annotation to a type they may not import.

Each split is enforced by a test that would fail if it were reverted, and

the shape of those tests is the part worth copying. Each builds a double

implementing one capability and nothing of the other, subclassing nothing โ€”

a double built by subclassing the real adapter would satisfy the whole port

however the protocols were declared, and could not tell you the split held.

Each also asserts the real adapter still satisfies every capability, which

is what catches a split turning into a fork.

A capability with no consumer is now visible as such. ChunkPurge and

ChunkReader have no first-party caller at all. That was true before and

invisible; it is now written in the type system, and the next question โ€” should

the library's own retrieval path be using them โ€” is one somebody can now ask.