ADR-0079 — The single canonical conversation writer

Context

Conversation and message state is written from many places that do not log to the same schema or the same table. A repo-wide audit (10-agent sweep, read against fresh origin/main) found 110 writer sites, 56 of which bypass the central path — via raw DynamoDB writes, hand-built Message object literals, or outbound sends that never log to a conversation at all. Gera stated the root cause in the 2026-07-06 standup, verbatim: "that's why it's important that everything goes through the same writer, so we always track what's coming out."

The symptom is invisible history, via two independent mechanisms that both read as "the thread is missing turns":

  1. Attribution / unlogged writes. Empirically confirmed on CAM-1330 (drain clog, Camellia Unit 607) by a read-only probe running the real deriveUnifiedTimeline: the handyman completed the job and texted "Tub drain was slow, cabled drain, no parts needed" with 2 completion photos captured on the WO row — yet the WO detail page shows no completion. Two gaps, both capture failures, not display filtering: (a) Clara's outbound question to the handyman that prompted his reply was never persisted to any conversation; (b) the photos are captured but hidden because the WO was never advanced to resolved. By contrast CAM-1346's tenant voice thread is complete, and the tie-based cross-WO exclusion on the shared handyman thread works correctly. So the fix is "every write goes through one writer," not "loosen the timeline filter."
  2. Identity fragmentation — a writer creates identity instead of resolving it, parking a conversation on a phantom Person (the Tamsin Fakewell thread; guard #3183 + heal #3187).

The largest defect class is orphaned sends — messages that reach a human but log to no conversation: vendor Telegram dispatch is an entirely silent channel (0 conversation writes), plus vendor quote emails, PM page/escalation emails, renewal PM notifies, MTM warnings, and the tenant-confirmation pmThread parallel store.

There are physically two writers today. src/lib/data/dynamo/conversation.ts and a byte-for-byte agents/clara/ mirror (used by the inbound Lambda funnel) are already drifting. "Single writer" is not physically true until that is collapsed.

Why now, and why this is not a new idea. The topic-stamping module (src/lib/domain/conversations/conversation-topic-stamping.ts) already names the end-state in its own header: the nightly classification cron is an explicit STOPGAP "until every workflow stamps topics inline as messages arrive. When that end-state lands, retire the Schedule + this batch path." The single canonical writer is that end-state.

Decision

Introduce one canonical writersrc/lib/domain/messaging/conversation-writer.ts — that is the only way any code creates or appends a conversation or message. Every write carries a consistent, validated schema at write time; bypass is a compile error, not a policy. The nightly topic job becomes a pure consumer that fills only the fields the writer intentionally leaves blank (regardingType on untagged free-text turns; the derived topics denorm) — never a parallel write path.

The writer composes the three real seams the 110 sites collapse to: saveConversation (sole CONV# meta writer), buildOutboundMessage (promoted to the sole Message factory), and saveNewMessages (hardened into the branded, validating sink).

The inheritance mechanism — a branded, unforgeable message

declare const STAMP: unique symbol;                       // NOT exported — the factory is the only mint
export type StampedMessage = Message & { readonly [STAMP]: true };

The sink becomes saveNewMessages(conversationId, messages: readonly StampedMessage[]), and — critically — _unsafe_saveConversation stops writing MSG# rows (R7): message persistence routes exclusively through the branded sink; ensureConversation passes messages: []. A bare Message literal is not assignable to StampedMessage, the brand symbol is un-exported, and there is no second door — so the ~15 inline-literal sites fail tsc until they route through buildMessage. A MessageSpec discriminated union on direction (inbound | outbound | audit) makes field-completeness structural per role. A new subsystem physically cannot reach DynamoDB without carrying the full schema; inheritance is by the type system. Full API in the write-design doc.

The provenance model (the red-team's unifying correction)

role / speaker / direction / pmAuthorId / source were 4+ overlapping axes. The rationalized model:

axis field level stored?
storage / LLM-replay vocabulary role message stored (unchanged)
in / out / audit directionOf(msg) helper message derived, never a column (D6)
who authored the content author: { kind; id? } message stored (new) — subsumes speaker + pmAuthorId
which code-path persisted it writerId (registry union) message stored (new) — the provenance keystone (R23)
what caused the send triggerActor message stored
language the composer resolved language? message stored (optional) — ADR-0089 pass-through provenance; the writer never branches on it
turn-scope (PM exchange unit) turnScope? message stored, all variants (R3)
how the thread came to exist source conversation stored

author.id is always a personId (R12). author (who authored) and writerId (which registered code-path wrote it) are distinct axes — never conflated (§0 over-axis lesson). Message.source stays rejected (a drift guard bans it).

Public write surface = two verbs (R18): writeMessage(target, spec) (append / ensure+append) and ensureConversation(write) (born-empty create — required because voice call-start creates a message-less conversation at ring time, which writeMessage structurally cannot express). Behind them, three internal seams (one pure factory + two physically distinct DDB writes) are the minimal decomposition preserving {compile-time enforceability, cheap append, meta/message separation}. Details in write-design §6.

The six decisions (post-red-team)

# Decision
D1 Mirror collapse via a shared cross-tree import (not an extracted package — that gives false assurance since the writer needs @/lib/data at the first hop and Temporal is reachable through it). The cross-tree import already ships in the Lambda today and doesn't drag Temporal. Enforcement corrected: the primary blocking per-PR gate is a metafile assertion ("no @temporalio/worker identifier in the inbound bundle") wired into lambda/inbound-processor/build.ts (which already emits dist/meta.json); a fast drift test pins a ban-list, not the fictional 4-import allowlist. Clara mirror bodies delete and re-export; the un-exported STAMP makes a byte-copy useless.
D2 source stays conversation-level; adopt a unified per-message author: {kind, id?} that subsumes speaker + pmAuthorId, legal inbound and outbound. Fixes a real defect: production already stamps pmAuthorId on an inbound role:'tenant' row (pm_query, conversation-manager.ts:2498) that the original schema would have forbidden. triggerActor stays orthogonal. Closes the multi-party gap (role has one inbound slot).
D3 subtopic: keep the column, add a named patch — it is a primary datum (the maintenance playbook key at conversation-manager.ts:4779), NOT derived. The original "writer-derived only" framing would have bricked it to null (regressing the /conversations list, search, tenant-detail). Sole writer = stampMaintenanceSubtopic(); the full save read-preserves it (R8). Fix the stale @derived tag.
D4 regarding: durable source-side retried tie carrying the create-time WO id, keyed to entity creation (a Temporal activity calling backstampRegarding). The original nightly self-heal could permanently mis-tie two issues into the wrong WO (an untied segment destroys the boundary → first-tie-wins locks the error → the bake alert never fires). The nightly sweep is demoted to a last-resort that refuses to guess (count + sendBakeAlert, never auto-tie).
D5 Audit rows carry no channel (structurally absent; the AuditSpec variant lacks it). The bigger "evict tool rows to a separate stream" move is rejectedmessages[] is the LLM replay buffer (buildClaudeMessages). directionOf(m)==='audit' becomes the single isAuditRow() read predicate, replacing ~8 scattered role checks. transferMarker is the named human-visible edge case.
D6 direction: a derived directionOf() helper, NOT a materialized column. It's a pure total function of role — a column would be a cache that can only drift (the D3 maxim) and the exact over-axis smell D2 rejects for Message.source. It also doesn't fix the "PM-as-tenant" problem it was sold on (that lives in role; the author axis fixes it). The pmThread stays on the claim — not folded into Messages.

The holistic-review composition/spec resolutions (turn-scope field, writeMessage idempotency/ordering/throw contract, the providerId backstamp patch, the embedded-message door closure, the author compat-reader migration, the D4↔D1 lazy-import requirement, and the adaptability follow-ups) are specified in write-design §5 and gated by R2 — a mechanical prod-row audit asserting every existing message row is expressible as exactly one spec variant before this ADR is accepted.

D4 implementation note (cluster 5, 2026-07-07) — what shipped vs. the Temporal follow-up

What landed:

D4 amendment (2026-07-10) — first-type-wins: the tie never relabels a deliberate topic

The tie machinery above was regardingType-blind, which left one relabel hole: the maestro (ADR-0084) writes topic-only tags (regardingType set, NO regardingId), so a deliberately-classified renewal row counted as "untied" to the walk and, on a multi-WO thread's intact boundary, the ≥24h reconcile swept it into the WO tie — and every tie writer's plain SET rewrote its regardingType to maintenance (DDB passed the attribute_not_exists(regardingId) condition; the type column had no guard). The renewal topic vanished silently and permanently. Two-part fix, both halves required:

Accepted trades: a maestro MIS-tag (a true maintenance row classified renewal) is permanently excluded from the WO tie — deliberate classification wins, mirroring applyTopicTags's never-overwrite discipline; and a create-time segment may still tie a topic-tagged row it contains (type preserved — ADR-0083's type-gated messageWorkOrderRefs keeps such a row out of WO-ref reads).

Follow-up (deliberately deferred): the Temporal signal re-drive. The D4 decision names "a Temporal activity calling backstampRegarding" as the durable mechanism. Two grounded blockers keep that out of cluster 5: (1) handleCreateWorkOrder's highest-volume lane is the inbound processor Lambda, which has no Temporal credsisTemporalConfigured() deliberately skips signaling there (work-orders/trigger.ts, the 2026-06-14 latency incident), so a retryRegardingTie signal added today would silently no-op in exactly the SMS lane that needs it; (2) the per-conversation maintenanceWorkflow's debounce loop would need a job-queue signal

Enforcement (no ambiguity, machine-checked)

Runtime layer (defense-in-depth — catches what slips through in prod over time). Compile-time + CI catch bypasses in code; this catches stream gaps, replays, manual DB edits, and retired-writer regressions in production. All three reuse a single shared classifyRow pure fn (R24) so the accept-gate audit, the eval, and the detector never diverge:

Entity classification (per ADR-0027)

This ADR proposes no new persisted entities. The new exported types (StampedMessage, MessageSpec variants, ConversationWrite, Author) are write-input DTOs and a compile-time brand — none are stored. The storage changes are fields, all canonical: Message.author (new, materialized, replacing speaker/pmAuthorId), Message.turnScope (new, R3), and Conversation.source/channel become required. direction is not stored (D6). Spine trace for every message is unchanged (via Conversation.personId, ADR-0032); author.id is a personId.

Migration (ordered clusters — detail in the write-design doc)

  1. Foundation — brand type, buildMessage, branded sink (close the embedded-message door, R7), facade, directionOf/authorOf compat readers, collapse the clara mirror. Fix the @derived subtopic tag here. (High: signature change recompiles both trees; mirror-collapse is the crux.) — DONE.
  2. Model-citizen appenders — the outbound-log family. (Low.) — DONE.
  3. The agent loopconversation-manager.ts inline literals + the turnScope move (R3); highest volume; ship with the agentic-loop proof harness. (High.) — DONE.
  4. Voice + transcript writers. (Medium.) — DONE.
  5. Retire direct-DDB bypasses behind the facade + lock with ESLint + drift test. (Medium.) — DONE.
  6. Orphaned sends — give each a canonical appender (vendor Telegram, quote/PM/renewal emails). The pmThread stays on the claim with a named carve-out — not folded into Messages (corrects the audit's original cluster 6). (Medium.) — DONE.
  7. Ops scripts + seeders schema-parity + final drift lock. (Low.) — DONE (UNSAFE_adoptMessages bridge deleted; the seed-script family migrated to spec writers; fence F1/F8 deletion + cast locks armed).

Completion note (2026-07-08). All 7 clusters are implemented and fenced (src/__tests__/conversation-single-writer.drift.test.ts F1–F8, 18/18 green; every CONVERSATION_WRITERS entry is seam: 'spec'). Deliberately remaining, post-cluster:

The author axis migration is a compat reader (authorOf(m), R10), not a mass backfill: speaker/pmAuthorId writes are banned immediately, old rows stay readable, the fields retire from the type. No drain-flip needed. Removal trigger: cluster 7 (with UNSAFE_adoptMessages deletion) — by then every live writer stamps author; a one-shot deterministic backfill (role/speaker/pmAuthorId → author, drain-then-flip per the spine-migration pattern) retires the fallback branch and the deprecated fields, and authorOf collapses to m.author.

Consequences

Alternatives considered