agenthropic

ADR-0008: CD-6 — Ports & adapters: the named port set

As-built update — 2026-07-30

Verdict: the principle holds; the named port set is smaller than drawn. Ports & adapters is the shape of the codebase — the pure parser and cost engine live in packages/core with no DB imports, and the whole ingest path is driven in tests by in-memory fakes that never touch the real ~/.claude/projects. What changed is which seams turned out to need naming.

The seams that exist:

Port Where Note
CorpusFs apps/server/src/corpus/fs-port.ts The JSONL reader — this ADR’s TokenReader/TokenSource, realized. Read-only by construction: it exposes no write, rename, unlink, chmod or open-for-write operation, so the live corpus cannot be perturbed even by a bug.
EventStorePort packages/shared/src/ports/event-store.ts The append-only substrate seam (append / readAll). The only formal port living in packages/shared; it compiles with no DB imports, satisfying WP-D1’s stated criterion.
RealtimeHub apps/server/src/realtime/hub.ts SSE fan-out (ADR-0007).
SubstrateProvider apps/server/src/api/substrate-provider.ts Read-only seam letting the cost-analysis endpoint reach the corpus on demand — not in the original set, because the need (compaction repricing and delegation savings want raw substrate, which DB rows cannot answer) only became visible once the cost engine was real.

The names in the diagram below that have no adapter:

Honest read: the “more interfaces and indirection for a solo owner to maintain” cost named below was paid down by not building the interfaces that had exactly one implementation and no test seam to gain. The four ports that survived are the four that a fake actually plugs into. The second-runtime portability claim is therefore unproven — plausible from the parser’s purity, but nothing has been ported.

As-built update — 2026-08-15

Verdict: holds; a fifth seam has since been named. RetentionPort (apps/server/src/retention/port.ts:42) joins the four listed above, and it was named for exactly the reason the 2026-07-30 amendment gives for the others: a fake plugs into it. It exposes a policy and a single run(options?) returning a report, with the SQLite-backed adapter in runner.ts, a clock injection point, and a dryRun mode that measures without deleting. The port file itself carries no database import, so WP-D1’s stated criterion holds for this seam too.

The seam is worth recording here rather than only in ADR-0012 because it is the clearest case in the codebase of a port carrying a policy distinction rather than only a driver distinction. Its report has a configured flag whose whole purpose is to keep two very different facts apart: “retention ran and found nothing to delete” and “retention is not configured at all.” Collapsing those into a single empty report would be the kind of plausible-looking summary this project refuses to produce. The library default policy makes every call a reported no-op, so wiring the port up anywhere does not, by itself, delete anything — what deletes is the policy handed to it, and since 2026-09-10 the composition root hands it the signed v1.0 policy (events 90 days, token_usage never, backup files 30 days behind a floor of 7; D3, signed 2026-09-08), run after each successful daily backup.

Nothing else in the port set has changed: Normalizer/Projection, AlertSink, HookSource, StoragePort, PricingProvider and CostEngine still have no named interface, and the second-runtime portability claim is still unproven — no non-Claude-Code adapter has been attempted.

(As built — 2026-08-09, recorded 2026-09-22: Normalizer and Projection do exist as separate stages — normalizeSession in apps/server/src/ingest/normalize-session.ts is a pure function from parser output to a NormalizedSession value, and projectSession in apps/server/src/ingest/project-session.ts writes that value in one transaction. Neither is a named port with a fake behind it, and neither reads events_raw, so the port-set count above is unchanged and the “reachable through a port” half of the second acceptance criterion is still unmet; the “never built as separate stages” wording in the 2026-07-30 update is what no longer holds.)

Context

The ingest/normalizer/cost pipeline (ADR-0004…0006) needs to be testable against fixtures without a live hook receiver or a real SQLite file, and it needs a path to a second runtime (Codex) without a core rewrite when that day comes. docs/ai/DESIGN.md §7 already identifies simple10’s ports/adapters storage and strategy-pattern agent classes as the cleanest, most portable structural reference among the six audited projects.

Decision

A named port set: HookSource, TokenReader/TokenSource, StoragePort, RealtimeHub, AlertSink, PricingProvider, CostEngine, plus an EventStore port and a pure Normalizer/Projection. simple10’s strategy-pattern agent classes are adopted as the per-runtime adapter (Claude Code now, Codex later).

 per-runtime adapter (simple10 strategy-pattern: Claude Code now, Codex later)
 ┌───────────────┐        ┌────────────────────┐
 │  HookSource   │        │ TokenReader/        │
 │  (loopback)   │        │ TokenSource (JSONL)  │
 └──────┬────────┘        └─────────┬───────────┘
        │                           │
        ▼                           ▼
              EventStore port (append-only events_raw, ADR-0004)
                          │
                          ▼
                 pure Normalizer  ──►  pure Projection
                          │
        ┌─────────────────┼───────────────────┬─────────────┐
        ▼                 ▼                    ▼             ▼
   StoragePort       RealtimeHub         PricingProvider   AlertSink
                                                │
                                                ▼
                                            CostEngine

Acceptance criteria

concept-analysis-v2.md §6 does not carry a CD-6-specific quantified numeric gate — this is an architectural/testability decision rather than a metric one. The nearest testable proxies, copied from adjacent canonical decisions this one enables:

Consequences

Alternatives considered