Decisions
Architecture decision records: the choices that are expensive to revisit, each
with the alternative that was rejected and what rejecting it cost.
An ADR here is not a design doc. It exists when a reader would otherwise
reasonably conclude the code is wrong — a layer holding one module, a store
port with no delete_entity, a vector table with no index. Every one of those
looks like an oversight and is a decision, and the ADR is what tells them
apart.
Conventions
Bodies carry no counts and no file tables. Numbers decay the next time
anyone touches the tree, so they live in the commit message, which is
immutable and correctly scoped to a moment.
Bodies are immutable records. A decision that changes gets a new ADR and
a "Superseded by" pointer on the old one's Status; a Decision section is never
rewritten to match what the code does now.
Numbers are allocated at merge, not at drafting. Parallel branches
routinely draft the same next number. This directory once carried eight files
numbered 0007-* for exactly that reason, and renumbering them left seven
titles and 43 inbound links wrong for several slices — which is why the site
builds with mkdocs --strict, so a citation to a missing page is now a build
failure rather than a silent one.
The records
| ADR | Settles |
|---|---|
| 0001 · Event log schema and granularity | What is persisted, at what granularity, owned by which aggregate. The one irreversible decision here — a log already written cannot be refactored. |
| 0002 · Two store ports | Why GraphStore and VectorStore are separate, why neither has delete_entity, and the alias surface that makes the absence safe. |
| 0003 · Blocking keys as nodes | Blocking keys are Neo4j nodes rather than a list property — settled by measurement, with the cheaper-looking alternative recorded as already tried. |
| 0004 · Consolidation emits events | Consolidation decides and emits; a projection writes. What collapsing the two would cost in auditability. |
| 0005 · Temporal inference on read | Inferred temporal edges are computed on read and never emitted into DocumentExtracted. |
| 0006 · The public surface is gated | __all__ is the whole promise, held by three tests each blind to what the other two catch. |
0007 · composition is the only top layer |
Why a layer holds one module, and why build_graph writes without a log. |
| 0008 · The two non-store ports | Cache and LlmProvider: what each promises, and what an adapter is expected to absorb. |
| 0009 · The extraction fold resolves through aliases | The fold's half of 0002's contract: resolve before write, and why a collapsed edge is deleted rather than upserted. |
| 0010 · One total order for preference | Which mapping of a thing survives, decided by one total order rather than three tie-breaks that disagree. |
| 0011 · Domain schemas prompt but do not constrain | A schema shapes the prompt and validates nothing; an off-schema entity is not an error. |
| 0012 · No ANN index in a multi-tenant vector store | Why pgvector carries no hnsw or ivfflat index, and what one does to tenant_id filtering. |
| 0013 · Resilience behind the cache port | Retry, rate limiting and circuit breaking live in llm/ over Cache, not in the pipeline. |
| 0014 · Exemption lists are empty and must stay falsifiable | Every exemption list needs a test that its entries still match something — and an emptied exclusion is deleted rather than kept. |
| 0015 · Consolidation gets a composed entry point | Consolidator composes decide-and-emit with project-and-write; an empty-vs-empty neighbour comparison stops meaning zero. |
0016 · GraphStore is five capabilities |
An eighteen-method port becomes five composed protocols, because a fat interface pushed a test double into subclassing a real adapter. |
| 0017 · The embedding provider port | VectorStore had no way to be filled from inside the library. A third provider port, declaring the dimension it produces, checked against the store's before anything is embedded. |
| 0018 · A replay report carries its failures | A replay names the events it dropped and can scope its read to one tenant; failed is derived from failures so the two cannot disagree. |
| 0019 · Batch relationship writes are atomic | upsert_relationships is all-or-nothing. The two adapters had already disagreed about what a failure left behind, and nothing asserted it. |
| 0020 · The replay driver goes upstream | replay and StoreProjection were written here, reported upstream, and shipped in eventsource-py 0.12.0. Adopted and not re-exported — supersedes 0018. |
0021 · composition holds a second module |
retrieval joins build_graph on the top layer, because vector, graph and llm are siblings and no lower layer may hold all three. Amends 0007. |
| 0022 · The lexical channel is not BM25 | Why corpus statistics are undefined over entity names, why the channels fuse by rank rather than by a weighted score, and what reusing blocking keys costs in recall. |
| 0023 · The chunk corpus | Passages are retained, content-addressed rather than positional, and replaced a whole source at a time. Amends 0022's premise and not its decision. |
| 0024 · BM25 over the chunk corpus, scored in the domain | Scoring is a pure function in domain/; adapters supply only recall and corpus statistics, so the two adapters rank identically. Amends 0022's Status, not its Decision. |
| 0025 · Consolidation's substitution points are protocols | resolve's finder and adjudicator are typed against one-method protocols, so substituting them no longer means subclassing a class whose constructor demands collaborators you do not have. Amends 0015's typing, not its Decision. |
0026 · ChunkStore and Cache are capabilities too |
0016's argument applied to the two ports that still had the problem: ChunkStore had one first-party consumer using one of nine methods. Amends 0008 and 0023 in typing only. |
0027 · VectorStore is three capabilities |
The port 0026 left out, plus the two collaborators ports/graph_store.py told everyone to narrow and nobody had. Amends 0002 in typing only. |
| 0028 · A capability declares its own release | Every capability protocol inherits AsyncClosable, so async with is reachable through a port rather than only through the adapter class behind it. Amends 0002 in typing only. |
| 0029 · A chunk is not extracted alone | Each chunk's prompt names what earlier chunks found, and a chunk may be shown its own answer and asked what it missed. Both are prompt content, so 0011 stands and 0008 needed no widening. |
| 0030 · A domain schema may constrain, when asked | constrain_to_domain=True turns a domain's type ids into an enum in the decoded schema. Amends 0011, which stays the default; 0008 needed no widening. |
| 0031 · Extraction does not think | openai_compatible sends enable_thinking: false by default. 5.7x faster and two thirds fewer false positives, measured. Amends 0008 in consequences only. |
0032 · The id names are NewTypes |
EntityId, RelationshipId, TenantId and SourceId become distinct to a type checker and identical at runtime, so transposing (entity_id, tenant_id) is a gate failure. Amends 0002 and 0006 in typing only; 0001 needed nothing, because NewType has no wire representation. |
| 0033 · The compliance suites ship | The port compliance suites move into the package as redstring.testing, behind a test extra, so an adapter written elsewhere runs the same bodies this repo does. Amends 0006 with a second gated surface and displaces composition from the top of the import contract. |
| 0034 · Neighbours are compared by name | Entity ids are namespaced by source_id, so a Jaccard over neighbour ids scored every cross-document duplicate 0.0 and put HIGH_SIMILARITY out of reach. Amends 0015's disjoint-neighbourhood clause; 0009's namespacing stands and is the reason. |
| 0035 · Provenance is a value object | Five fields move off Entity onto a Provenance carrying a required observed_at, and LATEST is renamed for the question it can actually answer. The blocker was never a missing timestamp — it was that resolve received bare values. Amends 0001's payload shape; extends 0010 by composition. |
| 0036 · A merge resolves the canonical entity's fields | A merge decides description, external_ids and properties on the canonical entity only, recorded as a before/after pair on EntitiesMerged rather than recomputed on read. Strategy selection is a PropertyMergePolicy keyed by dotted path; UNION outside properties is refused at construction. Amends 0001's payload shape; closes what 0035 left deferred. |
| 0037 · One exception type for a dimension mismatch | Every composition entry point that refuses a mismatched embedding provider and store now raises DimensionMismatchError, never a bare ValueError; a half-configured pair keeps ValueError. The gate is introspective over composition's public surface, so a new entry point is covered by construction. |
| 0038 · The chunk's vector lives on the chunk | A chunk's embedding is a nullable column on StoredChunk, not a second store, read back by the existing ChunkReader methods and searched by a new SemanticCandidateSource capability. The adapter scores, a stated exception to 0024; the store declares its width at construction; composition embeds. Amends 0023 and 0026. |
| 0039 · Bounded concurrency over chunks | ExtractionPipeline and build_graph take a concurrency bound; chunks extract in wavefront batches with carryover folded in between them, and one CallLimiter bounds every call against the endpoint, gleaning and embedding included. concurrency=1 is byte-identical to before. Amends 0010's Consequences: order-independence is now depended on by concurrent extraction, not only by the fold. |
| 0040 · Overlap-aware name similarity | string_similarity is the maximum of Jaro-Winkler and a token overlap coefficient capped at CONTAINMENT_CEILING, so a title-qualified name ("Dr. Grant" against "Grant") reaches the adjudication band instead of being rejected by a metric that rewards a shared prefix. Widening LOW_SIMILARITY and adding a fourth feature are both recorded as rejected — the first catches most of a type-key block with it, the second moves every score in the corpus. |
| 0041 · The consolidation pass is decide-then-emit | resolve_many consolidates a corpus in one call as three phases — score and band concurrently, adjudicate behind a barrier in batches that span subject boundaries, then emit serially. The fan-out cannot be over resolve itself, because each merge changes the graph the next subject reads. Widens MergeAdjudicator and moves CallLimiter to a shared layer. |
| 0042 · Themes are recomputed, never stored | A thematic layer above the entity: communities are a function of the graph at an instant, so they are computed per call and written nowhere — no community event, no store, no projection. Clustering pages the existing capabilities rather than widening GraphStore, and the algorithm is a deterministic pure function in domain. Extends 0007/0021 with a third composition module; states where 0004 stops. |
| 0043 · A query is embedded differently from a document | EmbeddingProvider gains embed_query, and both adapters take a document and a query task prefix. Modern models are asymmetric and getting it wrong is silent — well-formed vectors, plausible scores, retrieval quietly below what the model can do. A prefixed corpus and an unprefixed one are not comparable, so the prefix is part of the model's identity: extends 0017's new-store rule and declines to fold the prefixes into model. |
| 0044 · A chunk id is derived, not supplied | StoredChunk.id becomes a computed_field over (source_id, text) instead of a caller-supplied str, so the two adapters' ON CONFLICT reasoning is a property of the type rather than an assumption. A model_validator lets a round-tripped id back in when it still agrees, because extra="forbid" alone broke event-log replay. Amends 0038, closes BACKLOG B97. |
| 0045 · A lexical-only retriever is a constructor | Retriever.lexical_only(graph=...) and ChunkRetriever.lexical_only(chunks=...) build a retriever with no embedding provider, defaulting to LEXICAL and refusing SEMANTIC/HYBRID by name. Optional constructor arguments were rejected because they make "lexical only" and "I forgot the provider" the same call, against 0017; exporting the blocking helpers was rejected because it pins consolidation's blocking scheme as public API. Closes B163. |
| 0046 · A chunk write reports what it added | ChunkWriter.upsert_many returns the number of rows it added rather than None, and ChunkReader gains existing_ids(chunk_ids, tenant_id) — both bounded by the caller's input, so no method becomes unusable past a corpus size. Content-addressed ids make a collapse a normal outcome, so it is made visible rather than prevented; the Postgres count needs xmax = 0, because DO UPDATE returns replacements too. Closes B159 and B161. |
| 0047 · Four adapter paths are stable | Neo4jGraphStore, PgVectorStore, LangChainLlmProvider and LangChainEmbeddingProvider keep their import paths across minor versions, without entering __all__ — exporting them would make import redstring pull in LangChain, neo4j and asyncpg, which is what the extras exist to avoid. Qualifies 0006's "any dotted path may change without notice" rather than amending __all__. A redstring.adapters shim was rejected: it composes nothing, so no layer may hold it. Closes BACKLOG B103. |