ADR 0002: Two store ports, and the absence of `delete_entity`

Status: accepted, slices 3-5 of the ring migration; scope extended through

slices 6-7 to cover the alias surface on GraphStore and the closure of

BACKLOG B34. Amended in typing only by

0027 VectorStore is three capabilities,

0028 a capability declares its own release

and

0032 the id names are NewTypes.

None of the three changes a method, an argument, or a stored shape; the

decision below stands as written.

The extension is deliberate rather than a later amendment. The no-delete_entity

argument below was always conditional on something keeping the merge fact

durable, and slices 6-7 supplied it: upsert_alias, remove_alias,

find_aliases and resolve_entity_ids on the port

(src/redstring/ports/graph_store.py), resolve-before-write in the extraction

fold (src/redstring/projections/graph.py), and an alias tier in

tests/compliance/graph_store.py. Read the alias methods as part of this

decision, not as news arriving after it — the ADR is not settled without them,

and B34 is closed by them. B32 remains open and is unaffected.

See also ADR 0009 The extraction fold resolves through aliases for the fold's

side of the obligation, ADR 0006 The public surface is gated for what

exporting AliasCycleError commits us to, and

docs/how-to/implement-a-store-adapter.md for what an adapter must now

implement.

**Not amended by, but cited from,

0043 a query is embedded differently from a document.**

Readers arrive here looking for the rule that *changing embedding model means a

new store, not an in-place change*, because

0017 attributes it to this page. **It

is not here.** That rule's home is src/redstring/ports/vector_store.py and

0017's own Decision; 0043 extends it to cover an embedding model's task prefix,

so changing a document_prefix on a populated store has the same consequence

as changing the model. Nothing this ADR decided is affected — the note exists

so the citation resolves to something rather than to eight hundred lines about

delete_entity.

Why this is an ADR: a port is the one interface that is expensive to

change, because every adapter and the compliance suite move with it. Three

adapters implement GraphStore today. The absent method in particular keeps

being proposed for symmetry, and the argument against it is not obvious from

reading the Protocol.

Context

Before the migration, persistence was SQLAlchemy models plus a service layer

that reached into them, and "the graph" was a 443-line Neo4j client with no

callers. Storage decisions and domain decisions were the same code.

Decision: two ports, `GraphStore` and `VectorStore`, not one

They are separated because their contracts differ in kind, not in degree.

GraphStore is exact: a write is visible to the next read, filters are

predicates, and results are sets. VectorStore is approximate by nature —

search returns the k nearest, and "nearest" is a property of an index that

a real backend is entitled to approximate.

Merging them would force one compliance suite to state both contracts, and the

weaker one wins: an exactness assertion that some adapters may fail is not a

contract. Keeping them apart let tests/compliance/vector_store.py grow a

separate recall tier, which states the weaker property honestly.

A capability flag was explicitly rejected. An is_approximate on the port

would let each adapter opt out of whichever assertions it fails, which is how

two adapters quietly stop being interchangeable while both "pass the suite".

Decision: there is no `delete_entity`, and there will not be one

This is the part that gets re-proposed. The reasoning, in the order it was

found:

  1. Nothing needs it. The obvious caller is a merge — but a merge does not

delete the absorbed entity. It records an Alias, and the store keeps that

alias.

The write is upsert_alias. _apply_merge in

src/redstring/projections/graph.py:155 calls it once per absorbed id,

before it touches any relationship redirection, carrying the absorbed

entity's name and normalized name across so the merge fact survives even

though the entity is no longer reachable as itself. Keyed on

(tenant_id, alias_entity_id) and idempotent, so redelivery of

EntitiesMerged rewrites the same row rather than accumulating rows —

which is the same at-least-once argument as reason 3 below, applied to the

thing a delete would otherwise have destroyed.

Keeping that row is only worth doing because two reads make it useful.

resolve_entity_ids is the load-bearing one: it maps a batch of ids to

their canonical ids, totally (an id that was never merged, or is not known

at all, maps to itself) and transitively. It has two production callers,

and they are the two places a stale id would do damage —

projections/graph.py:140, resolving both endpoints of every relationship

before an extraction fold writes it, and consolidation/candidates.py:170,

dropping candidates that have already been merged away so consolidation

does not propose a merge ConsolidationLog would refuse. find_aliases is

the other direction — the aliases absorbed directly into one canonical

id, ordered by alias id — and it is what makes a merge inspectable after

the fact: which ids a canonical entity swallowed, under what reason, at

what time. Today it is exercised by the compliance tier and by the

consolidation oracle rather than by library code, and that is fine; it is

the read that would otherwise require reconstructing history from the

event log.

So "nothing needs delete_entity" is not an absence of demand. The demand

exists, and it is served by a write that records the merge and two reads

that consult it. A delete would satisfy the same surface request by

destroying the input both reads run on.

  1. Deleting would break replay. DocumentExtracted folded after

EntitiesMerged writes the pre-merge endpoints back and silently reverts

the merge — in strict log order, with every event delivered exactly once, no

race required. The fix is that the extraction fold resolves each endpoint

through the alias table before writing, which requires the alias to still be

there. That was BACKLOG B34, and it is closed — by

upsert_alias/remove_alias keeping the merge fact durable on the port

(src/redstring/ports/graph_store.py:146,160) and by

_resolve_endpoints in src/redstring/projections/graph.py:139 putting

every edge's endpoints through resolve_entity_ids before the extraction

fold writes them. The mechanism is what closed it, so removing any of the

three re-opens it.

2a. Where the argument is pinned now

Reason 2 used to cite tests/unit/projections/test_known_gaps.py. **That file

was renamed, not removed**, and the argument it carried is now split across

three live files.

renamed file. It builds the same three-event sequence — DocumentExtracted

for doc-1 under one model, EntitiesMerged folding e1 into e0, then

DocumentExtracted for the same document under a newer model, writing the

pre-merge endpoints again — in strict order, each event delivered once.

What changed is the assertions, which were inverted rather than deleted:

where the file once asserted the merge was reverted, TestALaterExtractionCannotRevertAMerge

now asserts it survives (test_a_re_extraction_preserves_the_merge), that

the absorbed entity is still present and still resolves

(test_the_absorbed_entity_still_exists_and_resolves), and that replay

reproduces the corrected state exactly. TestAnEdgeThatCollapsesOntoOneEntity

covers the self-loop case in the same shape, and

TestTheEventStoreWasNeverTheProblem keeps the original diagnosis on the

record: the log always held both extractions and the merge, so the defect

was in the fold, never in the log. Keeping the sequence and flipping the

assertions is what makes the fix's scope checkable; a deletion would have

left the sequence untested and B34 closed on assertion alone.

PINNED scenarios include merge, dropping-merge, undo,

undo-of-dropping-merge and two-tenants — the merge-adjacent shapes an

alias delete would break — and each is checked against an independently

recorded oracle, wiped and replayed, and delivered twice.

the event list from KG_EVENT_TYPES by introspection and failing if any

event type no PINNED scenario emits. So the pinning above cannot be

quietly narrowed: dropping the merge scenarios makes that gate red rather

than shrinking the evidence unnoticed.

Citing a path is a commitment to keep the citation live — see the closing note

on citation hygiene.

2b. Two more reasons, found after the fix

Reason 2 is enough on its own, but implementing it surfaced two behaviours

that a delete would break independently. Both are consequences of the same

thing: the absorbed entity's alias row is not a record of history, it is an

input two later writes read.

Alias chains form, and resolution is transitive. ConsolidationLog

refuses to merge into an alias — which is what stops cycles — but it does

not refuse to merge a canonical entity away afterwards. So B -> A followed

by A -> C is a legal pair of merges, and B must resolve to C, through

A's row (src/redstring/ports/graph_store.py:181). Delete A when the

second merge absorbs it and the walk loses its middle hop: B resolves to

A, an entity that no longer stands for anything. Note the asymmetry with

find_aliases, which is deliberately direct — find_aliases(C) gives A

alone — because an undo asks what this merge absorbed. The two reads want

opposite things from the same rows, and both want the rows.

The compliance tier pins the transitive half three deep rather than two

(test_resolution_follows_a_chain_to_the_end, tests/compliance/graph_store.py:1441),

for the reason CLAUDE.md names: at depth two, "follow one hop" and "follow

to a fixed point" agree, so d -> c -> b -> a is what separates them.

An edge whose endpoints resolve together is deleted, not upserted. When

one merge absorbs both ends of a relationship, resolution collapses them onto

the same id, and Relationship rejects a self-loop outright — so there is no

value the resolver could return that means "and this one must go". _resolved

in src/redstring/projections/graph.py:139 deletes the edge in place and

drops it from the batch; it is the only path through the resolver that does

not end in an upsert. That decision is reachable only because the aliases are

still there to collapse the endpoints. Without them a re-extraction writes the

pre-merge edge back between two entities the merge already dissolved.

TestAnEdgeThatCollapsesOntoOneEntity in

tests/unit/projections/test_aliases_survive_re_extraction.py:214 pins it,

and it is worth reading for its second assertion: it checks report.failed == 0

alongside the absent edge, because a fold that tried to write the self-loop

would raise and the edge would be equally absent. Only the failure count tells

"deleted" from "poisoned". It also carries a control (test_the_merge_drops_the_edge)

so the re-extraction test cannot pass because the edge was never written.

  1. A delete-then-insert projection is not idempotent. Making the fold

delete a document's entities before reinserting them would make redelivery

destructive: the delete half of a redelivered event removes entities a later

event added. At-least-once is the normal bus guarantee.

delete_by_tenant covers bulk removal, which is what replay and test teardown

actually want. The consequence is recorded honestly rather than hidden: a

re-extraction that finds fewer entities than the previous run leaves the

dropped ones in the graph forever, so the graph converges on the union of every

run rather than on the latest one. That is BACKLOG B32, still open.

Decision: aliases are part of the port, not projection-side hygiene

The four alias methods could have lived above the port — a merge map held by

the projection, or a side table an adapter knew nothing about. They are on

GraphStore because a later write has to consult the merge fact, and only

the store is present at the moment that write happens.

That is the whole argument, and it is what turns the previous decision from a

preference into a constraint. "There is no delete_entity" is aspirational as

long as nothing depends on the absorbed row: a projection that quietly dropped

it would pass every test about entities and relationships, because the damage

only shows up in the next extraction fold. Put resolution on the port and the

dependency becomes structural — _resolve_endpoints cannot write an edge

without calling resolve_entity_ids, resolve_entity_ids cannot answer

without the alias rows, and an adapter that discards them fails the compliance

tier rather than a code review. The port docstring

(src/redstring/ports/graph_store.py:20-32) states this in the same terms,

deliberately: the no-delete rule and the alias surface are one decision written

in two places.

It is worth being explicit about what this is not, because the objection is

reasonable and will recur. It is not consolidation logic leaking downward. The

store decides nothing about whether two entities are the same — that is

ConsolidationLog on the write side, and the alias arrives as a fact that has

already happened, emitted by EntitiesMerged. What the store gains is

somewhere to put that fact and a way to read it back. The asymmetry is the

tell: there is no merge_entities on the port, only upsert_alias, which

records a decision taken elsewhere.

The alternative — keeping the map in the projection — fails on rebuild. A

projection is derived and disposable; wiping the store and replaying is the

supported operation (delete_by_tenant, above). A merge map living beside the

store would have to be wiped and rebuilt in step with it, by hand, and any

adapter or deployment that forgot would silently reinstate B34. Holding the

aliases in the store makes "the projection" one thing that replay restores

atomically.

Two consequences follow, and they are the subsections below: the port must say

what each method promises precisely enough that two adapters agree

(docs/how-to/implement-a-store-adapter.md is the implementer's side of it),

and resolve-before-write has to be stated as an obligation of the port rather

than a habit of the current callers.

ADR 0009 The extraction fold resolves through aliases

carries the fold's half of that contract.

The four methods

Each of the four carries one decision that an adapter could plausibly get

wrong, and the port states it rather than leaving it to be inferred

(src/redstring/ports/graph_store.py:146-200).

upsert_alias is idempotent and last-write-wins, keyed on

(tenant_id, alias_entity_id) — the absorbed id, not the canonical one. That

key is the store-side shape of ConsolidationLog's double-merge rule: an

entity has at most one canonical parent, and re-delivering EntitiesMerged

rewrites the same row rather than accumulating rows. The compliance tier pins

it from the other side (test_an_alias_is_keyed_by_the_absorbed_entity,

tests/compliance/graph_store.py:1465), asserting the earlier canonical no

longer lists the alias — keyed on the canonical instead, both rows would

survive and the assertion would fail.

The clause worth arguing for is that neither endpoint needs to exist. An

alias is a statement about ids, and requiring the entities would make the merge

fold depend on the extraction fold having already run — the exact ordering

assumption aliases exist to remove. This is a deliberate asymmetry with

upsert_relationship, which does raise MissingEntityError on a missing

endpoint: an edge is a claim about two things in the graph, an alias is a claim

about identity, and only the former is meaningless without its endpoints.

remove_alias returns bool rather than raising, and the return type is

the decision. _apply_unmerge in src/redstring/projections/graph.py:203

calls it once per unmerged id and ignores the result; under at-least-once

delivery the second copy of EntitiesUnmerged finds nothing to remove, and a

method that raised there would turn ordinary redelivery into a poisoned event.

Same reasoning as reason 3 above, applied to a removal rather than a write.

test_removing_an_alias_restores_the_identity

(tests/compliance/graph_store.py:1476) asserts both returns — True then

False — because an adapter returning a constant would otherwise satisfy the

half of the contract anyone thinks to check.

find_aliases is direct, not transitive, and ordered ascending by

alias_entity_id as its canonical lowercase hyphenated string. After

B -> A -> C, find_aliases(C) is [A] alone. That looks like a weaker

answer than resolution gives, and it is the answer the caller wants: an undo

asks what this merge absorbed, and a transitive result would make that

unanswerable. The ordering is contract rather than convenience for the same

reason find_entities's is — two adapters must agree — and

test_find_aliases_is_direct_and_ordered (tests/compliance/graph_store.py:1487)

inserts the higher id first so an adapter returning insertion order fails.

resolve_entity_ids is the load-bearing one, and it is batched, total and

transitive.

edge in a document; as a loop it is two round trips per edge.

never merged maps to itself, and so does an id this tenant has never seen.

Resolution answers "has this been merged away", not "does this exist" — the

existence question is get_entity's, and conflating them would put a

None in the map that every caller would have to branch on.

an alias, which is what stops cycles, but nothing stops a canonical entity

being merged away afterwards, so B -> A then A -> C is legal and B

must resolve to C. The compliance test walks three hops

(test_resolution_follows_a_chain_to_the_end,

tests/compliance/graph_store.py:1441): at two hops, "follow one hop" and

"follow to a fixed point" are the same function.

The two reads pull in opposite directions over the same rows — one deliberately

stops at the first hop, the other deliberately does not — which is why both are

on the port and neither is expressible as a filter over the other.

Both reads are also subject to the suite's standing obligations: mutation

isolation (test_find_aliases_returns_copies) and tenant isolation

(test_find_aliases_never_crosses_tenants,

test_resolve_entity_ids_never_crosses_tenants), enforced by introspection rather than

by anyone remembering — see the coverage-gate decision below.

test_deleting_a_tenant_takes_its_aliases closes the loop with

delete_by_tenant: aliases surviving a wipe would have a rebuild replay merges

over rows the replay did not create.

Resolve-before-write is a contract obligation

The port does not merely offer resolve_entity_ids. **A write that carries

entity ids which may predate a merge must resolve them first**, and that is

stated by the port rather than left as a habit of the current callers

(src/redstring/ports/graph_store.py:20-31: "resolve_entity_ids is the read

a fold makes before writing an edge"). A fold that writes such an edge without

resolving is a defect against this ADR, not a style preference.

Stating it at the port is the whole point. The alternative is that each caller

remembers — and the reason B34 existed is that the fold could not have

remembered: before the alias surface there was nothing to consult. Now there

is, so "did you resolve?" is a question with a definite answer at every call

site, and the two production call sites are the two places a stale id does

damage:

endpoints of every relationship in a DocumentExtracted, makes one

resolve_entity_ids call for the whole document, and rewrites the edges from

the returned map before _apply_extraction upserts them

(graph.py:112-117). This is the obligation's original case: extraction data

can predate a merge and does not know it.

exclude rather than to rewrite. A candidate that has already been merged

away cannot be merged again — ConsolidationLog refuses it — so proposing it

produces a candidate nobody can act on. One resolution call for the whole

block, and the comparison is == rather than is, pinned by

test_resolution_by_value_not_by_identity after a cosmic-ray mutant survived

the is spelling: both adapters happen to hand back the same UUID object,

so is passes everything here and returns an empty candidate list against

any adapter that rebuilds ids.

That second caller is what makes this an obligation of the port rather than a

detail of the extraction fold. Two layers that never import each other reached

the same conclusion from opposite directions — one rewrites stale ids, one

drops them — and the only thing they share is the port. A rule living in

projections/ would not have reached consolidation/.

**Not every write resolves, and the exceptions are the argument, not

counter-examples.** _apply_merge upserts redirection.after

(graph.py:186) and _apply_unmerge upserts restored_relationships

(graph.py:204) without resolving, deliberately: both carry endpoints computed

by the write model for this merge, so they are post-merge by construction and

resolving them would be a no-op at best. The obligation is scoped by whether the

ids could predate a merge the store already knows about, which is a property of

where the ids came from — and _apply_unmerge carries an in-code note saying

so, because the surrounding code makes it look as though ordering should matter

there. Widening the rule to "resolve everything, always" would be easier to

state and would hide that distinction; the cost of the narrower rule is that it

has to be written down, which is what this section is.

What the obligation buys is a structural dependency in place of an

aspirational one. _resolved cannot write an edge without calling

resolve_entity_ids; resolve_entity_ids cannot answer without the alias rows;

an adapter that drops those rows fails the compliance tier. That chain is what

turns "there is no delete_entity" from a preference into a constraint — see

the decision above, and

ADR 0009 The extraction fold resolves endpoints through the alias table

for the fold's half of the contract, including why a resolver

that collapses an edge's endpoints deletes it rather than upserting.

docs/how-to/implement-a-store-adapter.md is the implementer's side: an adapter

owes a correct, total, transitive resolve_entity_ids because callers above it

are contractually required to depend on one.

Termination is the adapter's job, not the write model's

A cycle in the alias table is unreachable through legal history. A cycle needs

some merge to name an entity that is already an alias as its canonical, and

ConsolidationLog refuses precisely that. So the walk in resolve_entity_ids

terminates — as an argument about the write model.

The port requires adapters to bound it anyway

(src/redstring/ports/graph_store.py:195-201). That is the

"bound any loop whose exit depends on adapter-supplied data" rule from

CLAUDE.md, and resolution is exactly its shape: the loop's exit condition

comes from rows the adapter read, not from anything the fold computed. A store

whose rows are corrupt — a bad migration, a hand-edited row, a partially

applied write — turns a walk that trusts them into a hang. **A hang is worse

than an error**: in CI it reads as infrastructure trouble and gets retried

rather than investigated, so the one signal that would name the corrupt tenant

is the one that never appears. AliasCycleError

(src/redstring/domain/exceptions.py:51) carries entity_id and tenant_id

for that reason; it is the cheap half of the trade, and it is public, so

ADR 0006's gates apply to it.

The two adapters bound the walk differently, and the difference is instructive

about what the port is actually asking for.

hop by hop with limit = len(aliases) + 1 and raises from the for/else.

The bound is the tenant's alias count rather than a visited set: a walk longer

than every alias in the tenant has necessarily revisited a node. The + 1 is

load-bearing and is pinned as its own test

(test_the_longest_legal_chain_still_resolves,

tests/unit/graph/test_memory_store.py) — a chain using every alias is the

worst legal history, and len(aliases) alone would reject it, converting the

longest correct case into a spurious error.

Python at all. The chain is a variable-length match, and Cypher's

relationship-uniqueness rule is what terminates it — a cycle simply yields no

chain end. The bound is therefore free, but the detection is not: an empty

result is indistinguishable from "not an alias" unless you ask separately, so

the query returns is_alias alongside canonical, and an id with an outgoing

alias edge and no chain end raises. Without that second column the adapter

would answer "resolves to itself" for a cycle — terminating, and silently

wrong.

Both are bounded; neither borrows the write model's guarantee. That is the

point of putting the requirement on the port rather than in a comment next to

ConsolidationLog: an adapter author reading the Protocol has no view of the

write model, and "this cannot happen" is not a property an adapter can verify

about data it was handed.

Two cycle lengths are tested, not one

(test_a_two_cycle_raises_rather_than_hanging and

test_a_three_cycle_raises_too, tests/unit/graph/test_memory_store.py:101,113).

The three-cycle is not redundant: the obvious cheap guard — comparing each hop

against the id you started from — catches A -> B -> A and misses

A -> B -> C -> A, so a suite with only the two-cycle admits an implementation

that hangs on the first real corruption. This is the CLAUDE.md table's shape

again, one row shorter: the shorter input makes a correct bound and a

self-referential check agree.

Decision: the compliance suite is the contract

tests/compliance/ is not a convenience. It is the only artifact that says

what a port means, and the migration produced repeated evidence that the

Protocol alone does not:

deduplicating before validating where in-memory validated everything, and

in-memory raising TypeError on an unhashable stored entity_type where

pgvector returned []. Both were invisible to the suite as written, for

input-shape reasons; both are now covered.

time. Two escaped the suite, and both escapes were port gaps rather than test

gaps: neighbors deduplicates by entity, so relationship state — type,

confidence, properties — was unobservable through the port entirely. That is

why get_relationships exists.

The alias surface was added under the same rule, and its tier in

tests/compliance/graph_store.py:1412-1557 is where the four methods' promises

are actually stated. Four of those tests are load-bearing in the sense this

section means — an adapter can pass everything else while failing them, and the

Protocol says nothing that would catch it:

(test_resolution_follows_a_chain_to_the_end, line 1441). d -> c -> b -> a,

asserting all four ids resolve to a. At two hops "follow one hop" and

"follow to a fixed point" agree, which is the CLAUDE.md failure shape

exactly: the shorter input makes the correct implementation and the wrong one

the same function.

(test_an_alias_is_keyed_by_the_absorbed_entity, line 1465). Two merges

absorb the same entity into different canonicals, and the test asserts both

the new resolution and find_aliases(first) == []. The second assertion is

the one that bites: an adapter keyed on the canonical id keeps both rows and

still resolves correctly, so checking resolution alone cannot see the defect.

line 1511), tampering with alias_name and merge_reason on every returned

row and re-reading. This is the habit four read methods shipped without in

slice 3; the alias reads did not repeat it.

(test_find_aliases_never_crosses_tenants, line 1525, and

test_resolve_entity_ids_never_crosses_tenants, line 1533). The second is

the dangerous direction and its docstring says so: a leak there does not

merely show one tenant another's data, it silently rewrites one tenant's edge

endpoints onto an entity that tenant has never heard of. It asserts the

negative and the positive in the same test, so an adapter that resolves

nothing at all cannot pass.

test_deleting_a_tenant_takes_its_aliases (line 1545) closes the tier against

delete_by_tenant: aliases surviving a wipe make a rebuild replay merges over

rows the replay never created, which is delete_by_tenant quietly ceasing to

be a reset.

Two structural properties that are easy to undo by accident

Two structural properties of the suite are load-bearing and easy to undo by

accident:

pytest fixture is per-test-function, not per-hypothesis-example, so a shared

store leaks state across examples and silently weakens every property test.

shipped without one, and each time a mutation run — not review, not the

property tests — found that a shallow copy passed everything. This is now

enforced by introspection over the Protocol in

tests/unit/graph/test_compliance_coverage.py. CLAUDE.md explains why a

written rule was not enough: it had already failed four times.

Decision: both ports carry the same coverage gate, and the vector one carries no legacy registry

The compliance suite states what a port means; a coverage gate states that

the suite still covers the port. GraphStore grew one in slice 3

(tests/unit/graph/test_compliance_coverage.py), and VectorStore now has the

same one (tests/unit/vector/test_compliance_coverage.py). Two ports, one

mechanism, deliberately — CLAUDE.md says to give every store port this gate,

and the reason it is a decision rather than a chore is in the next-to-last

bullet below.

**Both derive the read-method list from the Protocol by introspection, never

from a hand-kept list.** read_methods() walks the Protocol with

inspect.getmembers and counts a method as a read if its return annotation

mentions a domain type at any nesting depth — {VectorRecord, VectorMatch} in

tests/unit/vector/test_compliance_coverage.py::read_methods, and

{Entity, Relationship, Alias} in its GraphStore counterpart. The nesting is

what makes it usable rather than merely clever: the recursive _mentions

helper descends through typing.get_args, so list[Entity] and

dict[str, list[Entity]] both count while

delete_relationship() -> bool, delete and every upsert_* drop out

without anyone saying so, and a read method added tomorrow is in scope the day

it lands.

Deriving it is the decision, not an implementation detail. A hand-kept list

would need updating by the same person who forgot the test — the same failure

one level up, which is exactly how the four slice-3 read methods shipped

uncovered. Because the criterion is "hands a domain object back", it is also

the criterion that matters: those are precisely the methods that can leak a

mutable view of stored state or another tenant's rows, and a bool cannot.

Both ports annotate under if TYPE_CHECKING, so both modules pass a

_PORT_NAMESPACE into typing.get_type_hints to resolve the strings. And the

one thing introspection cannot infer is a new domain type on the port, so

that set alone is written down — Alias was added to the graph gate's set the

moment the port gained aliases, rather than after a mutation run found the

leak.

Both require the same two proofs per read method, under the same

conventional names: test_{method}_returns_copies and

test_{method}_never_crosses_tenants. Two proofs, because they fail in

different directions and neither implies the other. Mutation isolation is the

habit four read methods shipped without in slice 3 — behavioural tests cannot

see it, since handing back the live internal object is correct on every read

and wrong only afterwards. Tenant isolation is the one a behavioural test also

cannot see, because a leaking read returns data that is well-formed and

plausible; it is simply someone else's.

The convention is the mechanism, not a naming preference. Both gates check

coverage with hasattr(, convention.format(method=method)),

so a method whose two tests exist under those names is covered with **no entry

in either module**. Write them and neither gate needs editing at all; write

them under some other name and the gate fails and tells you what to add. That

is deliberately the cheapest path, because the alternative path — registering a

differently-named test — is the one this ADR is trying to keep closed (see the

asymmetry below).

The tenant half admits no exemptions. ISOLATION_EXEMPT exists on both

gates as a dict of method to reason: empty today, guarded by

test_exemptions_carry_a_reason so a blank string cannot stand in for an

argument, and by a staleness test so an entry cannot outlive the method it

excuses. There is no corresponding TENANT_EXEMPT on either port. Both

modules call the shared _uncovered helper with a literal {} for its exempt

argument on the tenant check —


    def test_every_read_method_declares_tenant_coverage(self):
        missing = _uncovered(TENANT_CONVENTION, {})

— which is the same helper, deliberately denied the escape hatch its isolation

sibling has. The reasoning is that the two failures are not comparable in kind.

A read that hands back stored state without copying might genuinely be unable

to leak — a method returning a frozen value, or rebuilding its result from

scratch — so "exempt, and here is why" is a sentence someone could honestly

write. A read that crosses tenants is a confidentiality bug in every case, so

there is no read for which "cannot leak" is arguable in advance rather than

proved by a test. An exemption slot exists to be filled; the way to make sure

one is never filled wrongly is not to build it.

That asymmetry is worth stating in the ADR rather than leaving in the code,

because the symmetric-looking fix is obvious and wrong. Adding TENANT_EXEMPT

to match ISOLATION_EXEMPT would read in a diff as tidying up an

inconsistency, and its first entry would be indistinguishable from its

existence.

Both are guarded against the two ways a gate goes quiet. A coverage gate is

itself code that can stop working without failing, and it fails silently in two

distinct ways: by having nothing to check, and by checking against a list that

has outlived what it described. Each port's gate carries a test for each.

Vacuity. test_the_port_has_read_methods_to_check asserts the

introspection actually found something. Everything else in both modules is a

statement of the form "no read method is uncovered", which is true of the empty

set — so a read_methods() that returned nothing would leave every other test

in the file green while checking nothing at all. That is not hypothetical

paranoia about a function that obviously works: read_methods() resolves

annotations through typing.get_type_hints with an explicitly supplied

_PORT_NAMESPACE, because both ports annotate under if TYPE_CHECKING. Rename

a domain type, or add a new one to a signature without adding it to that

namespace, and resolution is where it breaks — plausibly for the whole port at

once. The guard is the same instruction as exhaustive = true on the import

contract, which had to be broken on purpose before it counted as evidence: **a

check you have never seen fail is not yet a check.**

Staleness. The other way is an entry that outlives its subject. This is the

CLAUDE.md rule about exemption lists applied to a test module rather than to

pyproject.toml — an exemption naming a path or a method that no longer exists

matches nothing and passes, so a shrinking list stops shrinking and nobody is

told. The vector gate's test_the_exemption_list_does_not_outlive_the_port

subtracts read_methods() from ISOLATION_EXEMPT and fails on any remainder.

The graph gate's equivalent is named

test_the_registries_do_not_outlive_the_port because it has three lists to

cover rather than one — ISOLATION_COVERAGE, TENANT_COVERAGE and

ISOLATION_EXEMPT — and it loops over all three with the label in the failure

message. The names differ; the property is identical, and both are

ADR 0014 Exemption lists are empty and must stay falsifiable

applied per port.

Two details are worth keeping when this is copied to a third port. First, the

staleness check runs in both directions on the graph side: the test above

catches an entry for a method the port dropped, and

test_registered_tests_exist_on_the_compliance_class catches the opposite — a

registry entry naming a compliance test that has since been renamed away. One

direction alone leaves a registry that can be silently emptied by a rename,

which looks exactly like a registry that was never needed. Second,

test_exemptions_carry_a_reason rejects a blank or whitespace reason on both

sides. Both ISOLATION_EXEMPT dicts are empty today, so all three of these

tests currently iterate over nothing — which is precisely why the vacuity guard

above has to exist as a separate assertion about the port, not about the

exemptions. A test that loops over an empty dict proves nothing; a test that

asserts the port has reads to check proves the module is looking at something.

**The asymmetry between the two gates is the live invariant, and it is the

reason this decision is written down at all.** Everything above is symmetric:

same introspection, same two conventional names, same vacuity and staleness

guards. The one place the two modules differ is that the graph gate has

hand-written registries and the vector gate has none — and that difference is

maintained deliberately, in the direction of the vector gate.

GraphStore's gate carries two of them, ISOLATION_COVERAGE and

TENANT_COVERAGE, mapping the same eight read methods to differently-named

tests that predate the convention (get_entity to

test_mutating_a_read_result_does_not_change_the_store, neighbors to

test_relationships_do_not_cross_tenants, and so on; several tenant entries

repeat, because one broad property test exercises every read under the wrong

tenant). They exist for exactly one reason, and the module's comment states it:

so that eight working, well-named tests did not have to be renamed to satisfy a

checker. They are closed to additions — _uncovered consults the convention

first and the registry only as a fallback, so a new read method is covered

without touching either dict.

They are tolerable only because they are checked in both directions.

test_registered_tests_exist_on_the_compliance_class asserts every registered

name still exists on GraphStoreCompliance, catching a rename that would

otherwise silently empty a registry into something that looks like a registry

nobody needed; test_the_registries_do_not_outlive_the_port asserts no entry

names a method the port has dropped. Both directions are required: a registry

checked in one direction only is an exemption list that can go stale in the

other, which is the CLAUDE.md rule this whole module is an application of.

**The vector gate has neither registry, because every test in

tests/compliance/vector_store.py was named to the convention from the start** —

test_get_returns_copies, test_search_returns_copies,

test_get_never_crosses_tenants, test_search_never_crosses_tenants (lines

647, 665, 325 and 348). Its _uncovered takes no registry argument at all;

there is no fallback path to consult, because there is nothing to fall back to.

The whole gate is derived: the method list from the Protocol, the test names

from the convention, and the only hand-written thing in the module is an empty

ISOLATION_EXEMPT.

Keeping it that way is the point of this note. **Adding a registry to the

vector gate is the erosion this ADR exists to make visible in review.** It

would arrive as the cheap fix the first time someone writes a

differently-named vector test — three lines, plainly modelled on the graph gate

that already sits beside it, and green. What it would actually do is convert a

mechanism with nothing hand-kept into one with a hand-kept part, and hand-kept

parts are exactly what failed four times in slice 3: a list that has to be

updated by the same person who forgot the test. The graph registries are a debt

carried for a specific, expired reason; copying them to a port that never

incurred it is copying the debt without the reason.

The failure mode is that this is invisible in a diff unless the reader knows

the absence was deliberate — a new registry reads as consistency with the

sibling module, not as a regression. So the absence is stated twice: the vector

module's docstring says "unlike its GraphStore counterpart there is no legacy

registry ... keep it that way", and this ADR is the second place, so a reviewer

who does not open the test file still has grounds to object. The correct

response to a differently-named vector test is to rename the test.

**The two vacuity guards are not equally strong, and the difference is a

deliberate concession on the graph side.** Both are named

test_the_port_has_read_methods_to_check and both carry the same docstring

("Guard the guard: a detector that finds nothing passes vacuously"), but the

assertions differ:


assert len(read_methods()) >= 8  # graph
assert read_methods() == {"get", "search"}  # vector

The exact form is strictly stronger, in a way that matters beyond vacuity. An

inequality only fails when the introspection finds too little; the exact set

fails on a wrong answer in either direction. That second direction is the one

worth having, because the ways read_methods() goes wrong are not all

subtractive. It resolves annotations through typing.get_type_hints with an

explicitly supplied _PORT_NAMESPACE (both ports annotate under

if TYPE_CHECKING), and it counts a method as a read if its return annotation

mentions a domain type at any nesting depth. Widen the domain-type set by one

entry too many, or change a method's annotation so it now mentions

VectorRecord, and the set grows. Under >= 8 that is silent; under `== {"get",

"search"}` it is a failing test that names the method.

The vector port can afford the exact form because it is small and closed.

VectorStore has two reads, and it is not expected to grow a third —

ADR 0012 No ANN index in a multi-tenant vector store

is part of why: the port

deliberately does not acquire index-shaped surface, so the read set is stable

by design rather than by luck. Writing the set out costs nothing and it will

not need editing.

GraphStore is in the opposite position. Its read surface has grown across the

migration — get_relationships came out of the slice 3 defect injection,

find_by_blocking_keys and get_relationships_for from batching work, and

find_aliases and resolve_entity_ids are the most recent additions in this

ADR — so an exact set would have to be edited on every legitimate addition.

A check that must be edited to stay green is one people edit reflexively,

and reflexive edits are how the check stops being read. Worse, the edit would

be indistinguishable in a diff from an edit made to accommodate an accidental

over-count: both look like "the port grew, update the number". The inequality

takes the weaker guarantee in exchange for never asking the author of a new

read method to touch this file at all, which is the same principle as the

naming convention — the cheapest path has to be the correct one.

So the asymmetry is not an inconsistency to tidy. **Neither direction of

"fixing" it is an improvement**: loosening the vector assertion to

len(...) >= 2 discards a real check for symmetry with a compromise, and

tightening the graph one to an exact set installs the maintenance burden the

inequality was chosen to avoid. Read >= 8 as the strongest form a growing

port can sustain, and == {"get", "search"} as the form a closed port should

keep. If VectorStore ever does gain a third read, the choice comes up again

on its merits — and the answer is still to edit the set, once, in the commit

that adds the method.

*The vector gate also guards the strategy*, and that is the part

introspection cannot reach.** Everything above answers "is every read method

covered?". TestTheMetadataStrategyReachesTheReservedKey answers a question no

amount of introspection can even ask: *is the covered method being fed input

its contract bites on?*

The port reserves exactly one metadata key. search filters on

entity_type, and nothing else in a record's metadata is interpreted. So the

compliance suite's properties over stored metadata are only worth anything if

the generator behind them draws that key — and for a while it could not.

metadata_dicts was built on property_dicts, whose keys come from

st.text(max_size=6) (tests/compliance/strategies.py:51-59), and

entity_type is eleven characters. **The only key the port reads was

undrawable.** Every property over stored metadata passed, at full example

count, while saying nothing at all about the filter.

That is where the slice 5 divergence lived: the in-memory adapter raised

TypeError: unhashable type: 'list' on a stored {"entity_type": ["person"]}

where pgvector returned []. Two adapters disagreeing about a legal record,

under a suite whose whole purpose is that they cannot.

The class pins the strategy from the outside, drawing 300 examples and

asserting over what came out (tests/unit/vector/test_compliance_coverage.py:122-176):

entity_type at all. This is the assertion that would have failed for the

whole period the blind spot existed.

sides of the filter, since a filter is only exercised by input it accepts

and input it rejects, and then the unhashable shapes specifically: some

drawn value must be a list or dict. That last assertion is the slice 5

bug written as a requirement on the generator. _entity_type_values

(strategies.py:226-233) is what satisfies it — a type name, or None, a

bool, an int, a small list, a small dict — and it is drawn about half

the time, so records with and without the key both occur.

constructs a VectorRecord. Widening the strategy in future cannot silently

start generating metadata no adapter is obliged to accept, which would turn

a strategy fix into spurious adapter failures.

The general rule is worth stating plainly, because it applies to every

property test in this repo and not just this one: **a generator that cannot

draw the interesting value does not fail — the properties over it simply go

quiet.** That is the same failure shape CLAUDE.md records for property

samplers and for exhaustive = true on the import contract: a check you have

never seen fail is not yet a check. Coverage tooling cannot see it either; the

lines execute, the assertions run, and the branch that matters is never

reached. Introspection can prove a method is uncovered. Nothing mechanical can

prove a covered method is being asked the question its contract is about, so

that has to be asserted by hand, once, next to the gate.

The strategy's own docstring records two further traps this class caught while

the fix was being written, both worth knowing before touching it: the NUL

filter must run on the finished mapping (filtering the base and then adding

the reserved key lets a NUL through the added value, since a nested dict's keys

come from st.text), and the base draw must be copied, not mutated

(hypothesis reuses drawn objects while shrinking, so writing into one leaks a

key into an unrelated example and makes a failure irreproducible). Both are the

kind of defect that makes a suite quietly weaker rather than red.

See docs/how-to/implement-a-store-adapter.md for what these gates mean when

you are adding an adapter rather than a method, and

ADR 0012 No ANN index in a multi-tenant vector store

for why the vector port's read surface is small

enough to pin exactly.

Consequences

its tenant-isolation test, and every adapter — deliberately expensive.

are exact. Whoever adds an approximate adapter must strengthen it first

(BACKLOG B10k).

retrofit costed in both adapters at the time the decision was taken.