ADR 0028: A capability declares its own release
Status
Accepted. Extends the capability decomposition of
0016 GraphStore is five capabilities,
[0026 ChunkStore and Cache are capabilities
too](0026-chunk-store-and-cache-are-capabilities-too.md) and
[0027 VectorStore is three
capabilities](0027-vector-store-is-three-capabilities-and-so-is-every-collaborator.md)
with a member each of those three left to the adapters. All three stand:
no capability is split, merged or renamed, no method changes shape, and the
lines those ADRs drew are untouched. [0008 the two non-store
ports](0008-the-two-non-store-ports.md) stands and is deliberately not
extended — see the Decision. 0002 two store ports
is amended in its typing only: the ports gain a member every adapter behind
them already had.
Context
Four adapters own a driver, a connection pool or a client, and each grew
__aenter__/__aexit__ so a caller could not leak one by forgetting a
finally. The ports did not. The consequence is the one worth stating in a
decision record rather than a commit: **async with was reachable only by
naming the concrete adapter class.** A caller who had done everything this
library asks of them — depend on the port, let composition choose the
backend — could not write the safe form at all, while a caller who hard-coded
Neo4jGraphStore could. The abstraction was charging a correctness penalty
for being used.
Cache had half-answered the question in the permissive direction: it
declared close() and neither store port did, so the same adapter fleet
promised release through one port and not the others. That inconsistency is
what made this a decision rather than an omission — two answers were already
in the tree.
The third force is 0027's. Once collaborators are narrowed to capabilities, a
port-level member that lives only on the composed protocol is invisible to
every narrowed caller. A StoreProjection[ChunkWriter] holds the store as
completely as anything else does.
Decision
**AsyncClosable is a protocol declaring close, __aenter__ and
__aexit__, and every capability protocol inherits it.** Not a sibling
protocol adapters also satisfy, and not a member of the composed ports alone.
**mypy decided the shape, exactly as it decided close's placement in
Cache.* 0026 records a lifecycle protocol standing beside* the cache
halves being refuted within a minute. The same experiment was run again here
before choosing, because B107 had sketched the sibling form and it reads
well: a separate protocol that resource-owning adapters satisfy and in-memory
ones do not, checked structurally at a composition root. It fails for a
sharper reason than last time. A caller handed an EntityReader cannot narrow
back to a sibling AsyncClosable without a cast — Python has no intersection
type — so the sibling form is unreachable from precisely the position that
motivated the change. Inheritance makes the pair arrive through the MRO, which
is the mechanism every composed port here already runs on.
Releasing what you hold is a property of holding it. That is why this is a
base of each capability rather than a member of the composed port. It is 0026's
sentence about close, generalised once the block form asked the same
question, and 0027's dimension reasoning applied in the other direction: the
answer comes from what the methods say, not from a preference for the smallest
protocol. Every capability is a handle on one adapter; none of them is a
handle on less of it.
An adapter that owns nothing writes the pair and says so. The in-memory
adapters hold dictionaries the interpreter already owns, so "release what you
hold" is satisfied by doing nothing — and a no-op documented as a no-op is
honest, not apologetic. The alternative on offer was to keep those adapters
out of the promise, which is the same thing as not making the promise: a
caller cannot write one lifetime discipline against a port whose adapters
disagree about whether it has one.
**LlmProvider and EmbeddingProvider are excluded, and the exclusion is
asserted.** They are not store-shaped: their adapters hold an HTTP client
whose lifetime this library has never specified, and granting them the pair
before deciding what close() means there would put four no-op methods on an
adapter that genuinely does have something to release — the one case where the
no-op above stops being honest. Left unasserted, "not yet" and "deliberately
not" are the same state of the tree, which is
0014's
shape; so a test pins that those two ports are not closable, and extending
the decision to them means deleting it in the open.
Consequences
Nothing changes for a caller who already had a working lifetime. The four
resource-owning adapters satisfied this the day it landed; the composed ports
name every member through their bases; runtime_checkable still answers
structurally.
The public surface grows by one name. A caller cannot narrow an annotation
to a type they may not import, which is 0006's closure gate — and the moment a
signature says AsyncClosable, it is the same obligation VectorWriter
created.
**The claim is enforced at the protocol, and the subject set differs from its
sibling module's on purpose.** The precedent gate derives its subjects from
classes assigning an _owns_* flag, because close() as a signal would catch
components owning nothing. That derivation is wrong here in the opposite
direction: an adapter owning nothing must still declare the pair, since the
port does. So the gate derives "every class satisfying a capability" instead,
structurally, and a fifth adapter is caught by being written rather than by
someone remembering the file. Both derivations are correct for their own
claim, and that is the reusable part — **a derived subject set follows from
what is being claimed, not from what is convenient to detect.**
A no-op close() is a claim and is tested as one. An in-memory store must
survive its own block, because "drop everything on close" is the available
over-implementation and it is what MemoryCache — correctly, for expiring
state — already does. The two behaviours now differ deliberately rather than
by whichever class the next reader happens to open.
The question 0026 and 0027 each closed with has a third instance. Both
noted a capability with no first-party caller. This one adds the reverse: the
two provider ports are now the only ports whose adapters have a lifetime the
library declines to describe, and that is a question with a name rather than a
silence.