ADR-0079 — The single canonical conversation writer
- Status: Implemented — all 7 migration clusters complete (2026-07-08). Formal acceptance still gated on the R2 mechanical prod-row audit (write-design §5 "must-fix before accept"), which has not yet run; see the Migration section's completion note for the full remaining list.
- Date: 2026-07-07
- Deciders: Gera, Fede (product intent from Sean — Clara-as-middleman + "one thread, whole history")
- Anchors: ADR-0055 (topic taxonomy) · ADR-0073 (topic+subtopic) · ADR-0032 (spine construction invariant) · ADR-0059 (tenant-confirmation review artifact)
- Detailed design:
docs/planning/single-writer-write-design.md· Audit:docs/planning/single-writer-audit-findings.md - Provenance: Fable-5 design pass → 6-agent adversarial red-team (one per decision, all REFINE/SWITCH) → Fable-5 holistic review (cross-cutting composition/spec gaps). The decisions below are the post-red-team versions; the holistic resolutions (R2–R17) live in the design doc §5.
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":
- 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 toresolved. 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." - 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 writer — src/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 rejected — messages[] 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:
- Primary tie unchanged — the synchronous inline
backstampWorkOrderReportSegment, awaited at WO create (handleCreateWorkOrderstep 8.5 + the voice call-ended route). - Bounded source-side re-drive — the inline backstamp now retries itself with the
create-time WO id (3 attempts, [300, 1200]ms backoff —
BACKSTAMP_RETRY_ATTEMPTS/BACKSTAMP_RETRY_BACKOFF_MS). A retried tie carries the right id by construction, so it can never mis-tie. A FINAL failure logslogErrorwith the machine-scannableREGARDING_TIE_FAILED_MARKER(single_writer_regarding_tie_failed) — the R26single-writer-driftdetector (post-cluster observability work, not yet built) will scan for it; until then the marker is greppable in CloudWatch/Vercel logs by hand. Either way a tie that never landed is loud, never a swallowed warn. - Refuse-to-guess nightly reconcile —
classifyRecentConversationTopicsreconciles recency-window conversations with a boundworkOrderId: untied ≥24h rows are re-tied only when the puredecideRegardingReconcilewalk (which mirrorsselectReportSegment's boundary logic verbatim) terminates at an intact different-entity boundary AND the un-tied span carries no WO-creation evidence for a different WO (atool_resultproving another WO-scoped mutation succeeded inside the span means two consecutive failed-tie WOs are interleaved — stamping all rows to the CURRENT WO would mis-tie the earlier one's report segment). No boundary (walk hits conversation start or the cap), multi-WO evidence in the span, or a stale WO pointer ⇒ count, never tie —RecentTopicSweepResult.regardingReconciled/.regardingUnresolvable, withregardingUnresolvable > 0firingsendBakeAlert('single_writer_regarding_unresolvable')on apply runs. This neutralizes the two-issues-swept-into-one-WO corruption even when the retry loses.
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:
- (A) First-type-wins at every tie writer. A tie always ADDs
regardingId, butregardingType/regardingLabelland only when absent: the DDBstampMessagesRegardingSETs them viaif_not_exists(opt-inifNotExistsonupdateItemFields), the JSON store mirrors it, and the in-memory mirrors (applyTurnRegardingStamp,applyReportSegmentStamp, the reconcile's sweep mirror) guard the type+label pair withif (!m.regardingType). The voice call-ended contract survives: its type-onlymaintenancestamp is preserved-equal and the absent label still gains the displayId upgrade. - (B) The reconcile never sweeps a deliberate NON-maintenance topic.
decideRegardingReconcileskips rows withregardingType && regardingType !== 'maintenance'(after the multi-WO evidence scan; a topic tag is acontinue, never a boundary). Untagged and maintenance-tagged rows remain candidates, so genuine report rows still tie and the WO report stays complete.selectReportSegmentstays type-blind on purpose — voice topic-only rows MUST keep receiving the DDB tie; (A) guarantees no relabel on that path.
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 creds — isTemporalConfigured() 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
- replay-versioning care, disproportionate while (1) holds. When the Lambda-creds gap closes
(tracked with the maintenance-signal reconciler follow-up in
trigger.ts), add theretryRegardingTie {conversationId, workOrderId, displayId}signal + activity — lazy-importing the Temporal client per R9/#1505, never a static import from the handler path — and the in-handler retry demotes to first-attempt-only.
Enforcement (no ambiguity, machine-checked)
- Compile-time: branded sink (the only door that authors a MSG# row, R7) + discriminated-union
factory. Relocation carve-out (#5490):
relocateConversationRowsindynamo/conversation.tsalso PUTs MSG# rows — it re-keys rows that already exist onto a merge winner's partition, byte-for-byte, minting no msgId and no provenance. Relocation is not authoring, so the invariant survives as "one authoring door", not "one writer"; a guard written against the flat "only MSG#-row writer" sentence would be wrong. Any NEW writer that mints a message still belongs in the branded sink and nowhere else. - Drift fence
src/__tests__/conversation-single-writer.drift.test.ts(always-run "Drift guards" CI job) bans raw META/MSG writes, hand-builtMessageliterals, and unpaired sends outside the writer module. Seed/Migration exceptions carry{file, symbol, reason, owner, ticket}(keyed by symbol, not line — R14), seeded from the 110-site inventory — day-one green; deltas red. ThepmThreadwriters get a permanent named carve-out (not perpetual debt). - ESLint
no-restricted-importsblocks the raw DDB helpers outside the writer module. - Registry
src/lib/data/conversation-writers.registry.ts— typedCONVERSATION_WRITERSwith two-way closure (every seam call site registered with a non-emptyowner; every entry resolves), detected by an import-graph walk. This is the "track every new system" mechanism and the living inventory, so audit and enforcement never drift apart. - D1 metafile bundle assertion (primary) + import-closure ban-list drift test; re-validated when the D4 cluster lands (R9).
- Runtime backstop:
saveNewMessagesthrows in dev/test on a row missing role-required provenance or carrying a forbidden field. - Human gate:
CODEOWNERS(new) forces@gera-propflowapproval on the writer module, the registry, and the exception table; a single-writer angle is added to the claude-bot review prompt.
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:
writerId(R23) — every message stamps theCONVERSATION_WRITERSregistry id of the code-path that wrote it (required on the spec, typed as the registry union → unregistered id = compile error). A liveMSG#row with a missing/non-registrywriterIdis a bypass, detectable at read time. This is a distinct axis fromauthor(who authored the content).- Eval lane (R25) — a new non-domain tab on
/admin/dev/evals: a recurring schema-conformance eval (R2 turned continuous — scores conformance % over time, broken down bywriterId/domain/channel) plus a flow/playground eval that triggers each write path against the eval test objects and asserts it routed throughwriteMessagewith the full schema. Drives via existing harnesses (Pipeline Lab, Maestro executor), never a mock of our own sink. - Runtime drift → Agent Smith (R26) — a nightly
detector.single-writer-drift(defineDetector/runDetector) samples recent rows for missing/unregisteredwriterId+ schema nonconformance and fans afindingtosendBakeAlert(newBakeAlertKind 'single_writer_drift') →#agent-smith, registered inconfig/automations.toml. Same scheduled scan writes the eval rows — one path, two surfaces. DDB-Streams real-time is a follow-up if a leak ever slips the nightly.
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)
- Foundation — brand type,
buildMessage, branded sink (close the embedded-message door, R7), facade,directionOf/authorOfcompat readers, collapse the clara mirror. Fix the@derivedsubtopic tag here. (High: signature change recompiles both trees; mirror-collapse is the crux.) — DONE. - Model-citizen appenders — the
outbound-logfamily. (Low.) — DONE. - The agent loop —
conversation-manager.tsinline literals + theturnScopemove (R3); highest volume; ship with the agentic-loop proof harness. (High.) — DONE. - Voice + transcript writers. (Medium.) — DONE.
- Retire direct-DDB bypasses behind the facade + lock with ESLint + drift test. (Medium.) — DONE.
- 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.
- Ops scripts + seeders schema-parity + final drift lock. (Low.) — DONE
(
UNSAFE_adoptMessagesbridge 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:
- R2 accept-gate prod-row audit — the read-only scan asserting every existing prod message
row is expressible as exactly one spec variant (
classifyRowis the shared predicate, built; the scan itself has not run). Blocks flipping this ADR to Accepted. - R10 one-shot prod backfill (role/speaker/pmAuthorId → author) — until it runs, the
authorOfderive branch + the@deprecatedMessage fields stay (pinned by the F4 tripwire). - R25 recurring conformance lane + R26
single-writer-driftruntime detector — the observability follow-ups (write-design §5); not yet built. Until R26, theREGARDING_TIE_FAILED_MARKERis greppable by hand in CloudWatch/Vercel logs. - ADR-0059 successor — a first-class PM counterparty thread (the cluster-6 PM-facing notifies mirror onto the related entity's thread until then).
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
- One obvious path. Every engineer and every new subsystem asks one question — "inbound, outbound, or audit, and who authored it?" — and the types carry them the rest. No "clara version," no raw-DDB shortcut, no way to write a schema-incomplete row (now literally true — the embedded-message door is closed).
- The WO detail page becomes whole. Cluster 6 logs Clara's questions to the handyman and ends
the silent vendor-Telegram channel; combined with the WO status→
resolvedfix (sibling maintenance-box work), CAM-1330's proof photos and completion milestone surface. - The topic cron gets a retirement condition. Every message flows through the writer with full context, and D4's durable tie + refuse-to-guess reconcile guarantees no turn stays orphaned — so the nightly classification Schedule can be deleted once its untagged/unresolvable counts flatline, exactly as the stopgap's own header instructs.
- A real today-bug closes as a side effect (R17):
_unsafe_saveConversationcurrently derives topics with noSTICKY_TOPICSpreservation, so a full save can recomputetenant_confirmationaway; the writer's sticky-preserving derivation fixes it. - Costs. The
saveNewMessagessignature change + embedded-door closure forces a repo-wide (both-tree) recompile; the agent-loop migration (cluster 3) touches the highest-volume, emergent path and must be proven with replay harnesses; the clara mirror collapse must be re-validated against the Lambda bundle at cluster 1 and cluster 4 (R9). New guard surface: one drift test, one ESLint block, one registry, one CODEOWNERS — all seeded green, so ongoing cost is reviewing deltas.
Alternatives considered
- Runtime-only validation (assert provenance in the sink, keep
Message[]). Rejected: catches violations at test/runtime, not authoring time, and lets a new inline-literal writer ship. The brand makes the guarantee structural. - Extract the writer into a standalone package. Rejected (D1): the writer needs
@/lib/dataat the first hop and Temporal is reachable through the data layer, so a package boundary gives false assurance while costing monorepo infra this single-package repo lacks. The metafile bundle assertion fences the actual (deep-transitive) #1505 risk. - Keep the clara mirror, lockstep-test the bodies. Rejected: two independently-compiled
_unsafe_saveConversationbodies mean "single writer" is aspirational, and the mirror is already drifting (the REF-row-delete gap). Collapsing to one body closes the gap by construction. - Materialize
direction/ add aMessage.source/ widen theroleunion. Rejected (D2/D6): all three add an overlapping provenance axis with which-field-wins ambiguity; theauthoraxis + deriveddirectionOfcarry the semantics without redundant stored fields. - Loosen the timeline's tie-based selection so more handyman messages show. Rejected: the empirical CAM-1330 test proved the selection is correct — the gaps are unlogged writes and a stalled lifecycle, not over-filtering. Loosening would surface other WOs' messages on the wrong page.
- Fold the tenant-confirmation
pmThreadinto Messages (the audit's original cluster 6). Rejected (D5/D6): a claim-scoped review artifact that never traversed a transport shouldn't fabricate message shape; it stays on the claim with a named drift-fence carve-out.