The feed identity domain shipped last week as 527 lines of pure composition logic: stdlib and Pydantic only, no database, no LLM, no network. Clean boundaries, strong tests, ports defined.
The adapters that wire it into Sabine? 1,286 lines. Plus 4,919 lines of tests. The adapter module is heavier than the domain it adapts.
That's not bloat. That's where the sharp edges live.
Two Adapters, Two Worlds
The domain defines two ports: an IdentityStore that reads and writes feed identities, and an IdentityComposer that names feeds when deterministic rules fail. PR #324 ships both adapters.
SupabaseIdentityStore (845 lines) reads and writes the calendar_feed_subscriptions table with conditional updates keyed on id and identity_version. Two behaviors carry the weight:
- read returns None only when no identity is stored. The three production fallback rows hold title NULL at identity_version = 1. A None there would leave the caller writing with expected_version = 0, which matches nothing, and those three feeds could never be named.
- write_if_unchanged never sends title for a fallback identity. The key is absent, not null, so a title already in the row isn't cleared. Without this, a feed the user sees as 'The Kids' would read 'Calendar feed' after the next sync.
ClaudeIdentityComposer wraps a small Haiku model with structured output. It exists for the feeds deterministic rules can't name: a calendar called 'Calendar' with varied event summaries. System prompt is 94 words, verbatim from the domain contract. Client timeout is 5.5 seconds, strictly below the domain's 6-second budget so which side fires first isn't a race.
The Service: Orchestration Without Escalation
ensure_identity (441 lines) decides whether to recompute at all. Every trigger—sync, import, chat—is fire-and-forget. A feed that can't be named keeps the name it has. Never raises into the caller except CancelledError.
The 24-hour floor prevents a model call per sync. One exception: the backfill job runs with the floor bypassed, spelled exactly as 'backfill'. A copy-pasted value on a sync hook would bill a model call per feed per sync. A typo on the real job would silently leave the floor in place and do nothing.
The service detects missing columns before the store exists. When the identity migration isn't applied, every feed's first sync would fail with an unexplained error and the actionable line—'apply the migration'—would never be reached. Now the row read classifies that failure separately and logs it once per process.
Three Audit Rounds, Eleven Fixes
The third TDD audit pass used four agent lenses: code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer. No human reviews on the PR, so every finding came from the agents or an independent read. Eleven fixes, 41 new tests.
HIGH: The composer was unreachable for exactly the feeds it exists to name. _has_no_signal abstained on extract_signals(inp).is_empty, which means 'the deterministic rules found no usable name'—the condition the composer exists for. The service skipped before composing in precisely that window. Verified by execution: a feed named 'Calendar' with varied summaries returned None through the service while compose_identity on the same input named it. With the flag off this is invisible. Now _has_no_material, pinned against compose's actual behavior.
MEDIUM: Round 2's 'every missing-column failure leaves a record' fix was below the production log level. The per-occurrence line was logger.debug, and both entry points run at INFO. After the latched notice the process said nothing for the rest of its life. Now WARNING.
MEDIUM: Float4 makes resolve churn or stall on every prefix-derived identity. compose._deterministic_candidate sets confidence=sig.prefix_share unrounded, and title_confidence is real. Verified on the live project: 5/6 comes back 0.833333, 5/7 comes back 0.714286. resolve uses strict >, so an unquantized candidate either wins every tie or loses every one. Now as_stored_confidence quantizes before comparison.
Why the Adapter Is Heavier
The domain says: given signals, compose a name. Given two identities, resolve which wins. Pure functions, no I/O, no failure modes except timeout.
The adapters say: the client raised. The migration isn't applied. A policy hid the row. Another writer won. The schema cache is stale. The timestamp can't be parsed. The API key is missing. The flag value isn't recognized. The response body isn't JSON. The code attribute is an int, not a string. The envelope has keys we don't know.
Every gate, every classification, every once-per-process latch, every timeout offset lives in the adapters because the domain doesn't know those worlds exist. That's not a bug. That's the forcing function working.
What's Next
T10 wires ensure_identity into the sync, import, and chat hooks. T11 builds the input from event samples and calendar lists. The composer flag stays off until the live eval—which counts composer_* evidence codes to gate the rollout—proves the adapters reach it.
The three production fallback rows—'The Kids', Ryan's personal feed, one more—will be the proof. If they get named, the adapters work. If they don't, we'll know before the flag ever turns on.