ADR-0003: Ports and adapters as the top-level structure
- Status: accepted
- Date: 2026-08-01
- Deciders: Theurian maintainers
- Requirements: FR-S4, OSS-15, NFR-9, §4.4 of the brief
Context
Theurian starts as a local tool over SQLite and is expected to grow into a multi-tenant hosted service over PostgreSQL, an external vector database, and managed embeddings. It must also work today with no LLM API key at all.
Those are not compatible unless the substitution points are decided before the
code exists. Retrofitting a port onto a codebase where sqlite3.Connection has
already leaked into thirty call sites is a rewrite.
There is an opposite failure mode that is just as real: a codebase where every concept has an interface, a factory, and a registry, and nothing can be read end to end. §34 of the brief explicitly rules that out.
Decision
Ports and adapters, with the port set fixed in advance and deliberately small.
domain/holds entities, value objects, invariants, and ports. A port is atyping.Protocol.domain/imports nothing fromapplication/orinfrastructure/.application/holds use cases. It depends ondomain/only, and receives adapters by constructor injection.infrastructure/holds adapters. An adapter may importdomain/; nothing imports an adapter except a composition root.- Composition roots are
cli/,daemon/, andmcp/. They are the only places allowed to name a concrete adapter. - The port set is exactly these fourteen. Adding a port requires an ADR:
CanonicalStore, VectorStore, EmbeddingProvider, SummarizationProvider,
RerankingProvider, ReviewProvider, SpecificationProvider, SourceParser,
ObjectStore, AuthorizationProvider, SecretStore, DaemonManager,
and the two determinism ports Clock and IdGenerator.
Amended in Milestone 7, by the port-register CL (#140). The closed set is
ALL_PORTS. The register is the part this point never wrote down, and that omission — not the number — is what let the set drift."Exactly these fourteen" gives a count and a list but names no register: no statement of which collection the closed set is closed over. Two readings are available and they disagree — membership of
theurian.domain.ports.ALL_PORTS, or "declares atyping.Protocolunderdomain/ports/". Because the point never chose, a Protocol can be added underdomain/ports/, injected by a composition root, and never reach the test that is supposed to make adding a port an architecture decision. That is not hypothetical.McpClientConfigdid exactly that, andIndexStorereachedALL_PORTSonly in Milestone 6 — before which every check intests/unit/test_ports.pyhad been silently vacuous for it.The register is
ALL_PORTS, indomain/ports/__init__.py: the set of injected boundaries, each a substitution point a composition root wires an adapter into.test_port_set_is_closedcompares that tuple against a committed list, soALL_PORTSmembership is what "requires an ADR" actually gates. AProtocoldeclared underdomain/ports/but absent fromALL_PORTSis not covered by that test — so this amendment names each of them rather than leaving them to be rediscovered.The count is a measurement, not a decision:
ALL_PORTSheld 17 entries, against 19Protocolclasses declared underdomain/ports/, measured on 2026-09-03 ate2a950ef. The live claims aretest_port_set_is_closedwith itsEXPECTED_PORTSlist, and — for the table below —test_every_protocol_under_ports_is_registered_or_recorded_as_outside_itwith itsEXPECTED_OUTSIDE_THE_REGISTER; not this sentence. A number in prose is a snapshot, and the reason this point sat at "fourteen" while the register grew to seventeen is precisely that nothing recomputed it. That second figure reached 20 within the day, and the table below carries the declaration that moved it.Three names joined the list above; none left it.
IndexStore,ReviewFindingSourceandReviewFindingStoreare the delta between point 5's fourteen and the register — the set difference between{p.__name__ for p in ALL_PORTS}and the fourteen named above, computed both ways. The last two arrived with ADR-0029, which is the path this point asks for;IndexStoredid not, and reachedALL_PORTSonly in Milestone 6. The members themselves are not re-listed here —ALL_PORTSis the register, so re-listing it in prose would create exactly the second copy that drifts. This sentence is a live claim, not a snapshot:test_adr_0003_records_which_ports_joined_the_register_since_point_5_was_writtenrecomputes the delta from the register and from point 5's own list, and holds the names, the count word and "none left" against it together.The Protocols outside the register, and why each is outside it:
Outside ALL_PORTSStanding CanonicalReadSessionMostly a narrowing of CanonicalStore, not a second substitution point. Of its six members,list_items,get_itemandget_revisionare narrowed in from that port, and__enter__/__exit__add the handle lifetime it deliberately does not express. The sixth,get_item_exact, widens: it is the alias-free read T-21 needs andCanonicalStoredoes not offer, which is why the headline is hedged and not flat — and it is the precedentIndexBuildSessionthen followed, a session Protocol adding a member the port lacks. Injection is per consumer, not shared:ResultGate— the SEC-13 gate itself — takesstore_factory: Callable[[Path], CanonicalReadSession](application/retrieval_service.py), whileIndexBuildertakesCallable[[Path], IndexBuildSession](application/index_builder.py) — the widened row below.RetrievalService, which this row named through two revisions, takes nostore_factoryat all. NoCanonicalStoremethod returns either. What an operator substitutes is still aCanonicalStoreadapter, so this opens no boundaryALL_PORTSdoes not already governIndexBuildSessionA widening of CanonicalReadSessionby one method, not a second substitution point. It addslist_relationsbecause a relation'snoteis served verbatim on everyknowledge.getresponse, so SEC-11's build-time control has to read that channel too; it is declared separately rather than folded into the base so that every session-shaped collaborator is not obliged to answer a question only the index build asks. What an operator substitutes is still aCanonicalStoreadapter, so its standing isCanonicalReadSession's. It is also the demonstration this amendment's own pin was owed for: it landed outside the register in #329 between the amendment being written and its pin landing, and nothing went REDMcpClientConfigAn open question, recorded here rather than settled. It has a port's shape: SetupContext.mcp_configis constructor-injected, andcli/setup_commands.py— a composition root — namesClaudeCodeMcpConfigas its adapter. Yet it is absent fromALL_PORTSand unimported byports/__init__.py, sotest_port_set_is_closedhas never seen it. Whether it joins the register is itself a decision this point says requires an ADR, and this amendment does not take it; the decision is filed as #553. Trail: #140 (the CL that wrote this amendment)What the pin on that table does and does not hold. It checks the row names against the live difference, so a Protocol that leaves the register or joins it outside cannot pass unnamed. It does not read the Standing column: an empty or wrong reason is green. That column is a reviewer's to demand, not a test's — the reason a Protocol is deliberately outside is a judgement about substitution boundaries, and a test that scored it would be scoring prose it cannot evaluate. A row added without a real standing is the failure this leaves open, and it is left open knowingly.
- No dependency-injection framework. Composition roots wire objects with plain constructor calls, in one readable function per entry point.
- Every port ships a deterministic fake under
tests/fakes/. A port with no fake is not finished.
Clock and IdGenerator earn their place because ULIDs and timestamps are
inputs to the state hash (ADR-0007). Without controlling them, "same inputs
produce the same state hash" is not assertable.
Consequences
Positive
- SQLite → PostgreSQL is a new
CanonicalStoreadapter, not a migration of the application layer. - The whole test suite runs offline with no paid API key (OSS-15, NFR-10).
- Pre-1.0 dependencies are quarantined behind a port (ADR-0014).
- Determinism is testable, which is what makes ADR-0007 verifiable.
Negative
- One extra indirection between a use case and its storage. Accepted; the substitution requirement is real, not speculative.
- Protocol definitions and adapters must stay in sync; mypy strict mode is what makes that a compile-time concern rather than a runtime surprise.
Neutral
- Adapters may use their technology fully. The SQLite adapter writes SQLite SQL. The rule is containment, not abstraction of SQL itself.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Direct SQLite calls from the application layer | Fastest to Milestone 1, forecloses §4.4 entirely. |
| A repository interface per entity | Dozens of near-identical interfaces; a CanonicalStore façade per aggregate is the right granularity here. |
ABCs instead of Protocol |
Forces adapters to inherit from a domain class, inverting the dependency the ADR exists to protect. |
| A DI container | Runtime wiring errors instead of type errors, plus a dependency, to solve a problem three composition roots do not have. |
Compliance
[tool.ruff.lint.flake8-tidy-imports.banned-api]bans importingtheurian.infrastructureoutside composition roots.tests/unit/test_layering.pywalks the AST import graph and assertsdomain/imports neitherapplication/norinfrastructure/.- mypy strict mode verifies every adapter satisfies its Protocol.
tests/unit/test_ports.pypins the port set itself:test_port_set_is_closedcomparesALL_PORTSagainst a committed list, so adding a port is an architecture decision rather than a refactor. Nine further tests assert a property of each entry ofALL_PORTS— that is the population key, and it is what excludes the set-level tests named at the end of this bullet — six parametrised over it (test_port_is_a_protocol,test_port_is_runtime_checkable,test_port_documents_itself,test_port_declares_at_least_one_member,test_port_methods_are_annotated,test_port_has_no_implementation) and three looping over it (test_all_ports_is_exported_and_consistent,test_protocols_are_not_instantiable,test_typing_protocol_is_the_base). That all nine are driven by the tuple is what makes it the register point 5's amendment names: aProtocolunderdomain/ports/that never reachesALL_PORTSis not merely unlisted, it is unreached by every one of them. The module's remainingALL_PORTStests assert nothing per-entry and so are not in the nine:test_port_set_is_closedand the three pins below work on the set — the first two against a committed constant, the third against a baseline it derives from point 5's own list — andtest_determinism_ports_are_presentnamesClockandIdGeneratorspecifically. Named rather than counted: a count here is a fourth thing to keep in step, and the commit that added the third pin left the count behind.- Point 5's amendment is itself recomputed, by three pins in the same
module that hold the code and this record against each other.
test_every_protocol_under_ports_is_registered_or_recorded_as_outside_itderives both figures —len(ALL_PORTS), and aninspectwalk of every module-leveltyping.Protocoldeclared underdomain/ports/(a Protocol nested inside a class is not bound in module namespace and escapes the walk; measured, and accepted because no declaration here takes that shape) — and asserts the membership of the difference against a committedEXPECTED_OUTSIDE_THE_REGISTER, so a Protocol added outside the register fails naming itself rather than passing a count that happens to still add up.test_adr_0003_names_the_register_and_every_protocol_outside_itreads this ADR and requires the amendment to keep namingALL_PORTSas the register and to name exactly that live difference in its table.
The third,
test_adr_0003_records_which_ports_joined_the_register_since_point_5_was_written,
holds the register side the first two cannot reach: they are both about
the complement of ALL_PORTS. It derives the delta paragraph's claim from
both ends — the register from the code, point 5's fourteen from point 5's own
list, located as the block directly below the sentence introducing it — and
requires the names, the count word and the "none left" claim to move
together. This is the case test_port_set_is_closed is blind to: that pin
catches a port added to ALL_PORTS and not to EXPECTED_PORTS, and says
nothing when both move together, which is precisely what adding a port does.
Without this pin an eighteenth port lands green while this amendment goes on
saying three.
This closes the gap the amendment itself recorded as owed, and it opened
before the pin could land: IndexBuildSession was declared under
domain/ports/ and left out of ALL_PORTS by
#329, taking the declared
count from 19 to 20 while every check keyed to the register stayed green. The
prose pin went RED on its first run against that table, which is the whole
mechanism working once rather than an argument that it would.
Still owed, with the milestone that will satisfy it:
- No test asserts every port has a fake, and most do not have one. This
section claimed one did.
tests/fakes/defines six doubles —FrozenClock,FakeReviewFindingSource,SeededIdGenerator,FakeService,FakeMcpConfig,InMemoryWriter— and between them they satisfy four of the seventeen ports inALL_PORTS:Clock,IdGenerator,DaemonManagerandReviewFindingSource. The other two doubles stand in for things that are not in the register at all —FakeMcpConfigforMcpClientConfig, andInMemoryWriterforMigrationWriter, which is anapplication/Protocol rather than a port. Thirteen ports have no double. Measured on 2026-09-03 ate2a950ef.
The key matters more than the count here, so it is stated rather than
implied: each double is tested with isinstance against every
runtime-checkable port, not matched by name. That key reaches the four
register entries above and McpClientConfig, which is @runtime_checkable;
it does not reach MigrationWriter, which is not, and where isinstance
raises rather than answering. InMemoryWriter's attribution is therefore
read from the structure — its own docstring names the Protocol it
implements, and a conformance test asserts both it and the real adapter
satisfy it — not derived by the key stated here. A name search answers
differently and wrongly in both directions — git grep -w IndexStore hits
fakes/pages.py, which builds RetrieverPage helpers and defines no
IndexStore double, while git grep -w Clock misses FrozenClock
entirely.
The claim is also the wrong shape for this design: a port with one adapter and
no in-memory double is not a gap, and demanding a fake per port would produce
fakes nobody uses. What ADR-0003 actually wants is that application code is
testable without infrastructure, and neither a test nor this section states
that in a checkable form. Still owed, and no milestone currently owns it.
It was deferred to Milestone 6 on the expectation that new ports would force
the question. They did not force it. Milestone 6 landed
SummarizationProvider (ExtractiveSummarizer) with no double, and
Milestone 7's two ADR-0029 ports landed one between them —
FakeReviewFindingSource for ReviewFindingSource, nothing for
ReviewFindingStore. Each addition was decided on its own, which is the
behaviour a rule with no test produces. It still needs a decision about what
the property is before it can have one. Trail:
#140.