ADR-0088 — A single durable conversation-turn-settled event stream
Amendment (2026-07-16, ADR-0094): the topic/grade reconciler sweeps this ADR planned to demote in PR-3 were deleted outright — the stream is the sole settle path for every consumer. Every mention below of the "kept reconcilers" as a live backstop (risk table, PR-1/PR-3 text, the
reconciler_caught_missed_conversationcounter) is historical.
- Status: Proposed (scoped 2026-07-14) — PR-0/1/2 shipped; PR-3 superseded by ADR-0094
- Date: 2026-07-14
- Deciders: Gera (owner); Fede
- Relates:
- ADR-0084 — inline topic-stamping (signal on turn-settle + 15-min reconciler). This ADR generalizes 0084's per-consumer trigger into one shared event and corrects 0084's now-false premise that "the inbound-processor Lambda cannot signal, so the reconciler is the primary SMS/email coverage." As of ADR-0068 (2026-06-24) the Lambda is a Temporal client and every inline signal fires from it; the reconciler is a backstop, not the primary path. 0084's classify-and-stamp logic is unchanged — only how the workflow gets triggered changes.
- ADR-0068 — established (RC-2) that the inbound-processor Lambda now carries
TEMPORAL_API_KEY, soisTemporalConfigured()returnstruein all prod runtimes. This ADR builds directly on that fact and moves the resulting Temporal dial off the reply hot path. - ADR-0079 — the single canonical conversation writer. This ADR is its event-side analog: a single canonical conversation-turn emitter + a single canonical signaler (the bridge), so post-turn reactions stop re-forking the topology the way pre-0079 writes did.
- ADR-0026 — the PII boundary. The event payload carries IDs + enums + timestamps only; message bodies never enter the stream (they already never enter workflow history).
inboundPackageWorkflow(src/lib/temporal/workflows/inbound-package-workflow.ts) — a shipped-but-unwired per-conversation, signal-driven accumulator whose own header says "the webhook signals this off the Lambda hot path… PR1 will wire it." It is the first intended consumer of this stream; this ADR gives it (and the topic-stamp + grade + maintenance consumers) one trigger to subscribe to instead of each wiring its own.
Context
What is actually true today (verified 2026-07-14 against live prod + code)
Every inbound text channel (SMS, email-SES, email-Outlook, Telegram) and the simulator converge on the ADR-0079 single writer and one settle tail in agents/clara/lib/agent/conversation-manager.ts (~5672):
if (conversation.propertyId && isTemporalConfigured()) {
await signalDirtyBitsChanged(...) // maintenance
const { onConversationTurnComplete } = await import('.../turn-complete-dispatch');
await onConversationTurnComplete(convId, { origin: 'text' }); // → topic-stamp + grade
}
isTemporalConfigured() returns true in the inbound-processor Lambda — lambda/inbound-processor/deploy.sh injects TEMPORAL_ADDRESS/TEMPORAL_NAMESPACE/TEMPORAL_API_KEY from SSM/Secrets Manager (the same secret the Fargate worker uses), verified in the live propflow-inbound-processor-prod env, and src/lib/temporal/is-configured.ts:21-26 carries the 2026-06-24 NOTE confirming it. So SMS/email/Telegram turns already signal Temporal inline, in real time — topic-stamp, grade, and maintenance-dirty-bits all fire from the Lambda. Voice settles on Vercel (call-ended).
The 15-minute reconcilers (conversation-topics-reconciler, conversation-grader-reconciler) are therefore a backstop for dropped signals, not the primary SMS/email coverage. A large body of code comments (both trigger headers, the conversation-manager tail note, the turn-complete-dispatch header, the #3702 grade files, and ADR-0084's own text) still assert the opposite — they were written against the pre-2026-06-24 world and copied forward.
The three real problems (none of them is latency)
The Temporal dial sits inside the reply hot path. Each settle awaits signal delivery (not result) before the turn completes, and the reply dispatches after. The creds fix removed the cause of the 2026-06-14 latency incident (a
localhost:7233mis-dial that hung every message 50-90s) but not the structural coupling: a genuine Temporal Cloud brownout would make that await hang in the hot path and re-create the incident, now across every live tenant SMS conversation at once. Every added consumer adds another synchronous dial to the turn.Delivery is best-effort/swallowed. A trigger logs-and-swallows on failure; a dropped signal silently defers to the ≤15-min reconciler. Maintenance has no reconciler at all — a dropped maintenance signal from the Lambda is simply lost (
trigger.tsdocuments this gap). There is no durable retry anywhere on the settle path.The topology is incoherent and re-forks per consumer. Each new "react to a settled turn" concern today ships its own trigger module, its own
isTemporalConfiguredgate, its own client, its own workflow, and — to be safe — its own reconciler Schedule. There is no single seam that says "a conversation turn settled," so the code carries three contradictory accounts of who signals from where, and the next ten consumers inherit that shape.
Why now
The founder intends to hang more post-turn reactions on this seam (escalation detection, SLA-breach, sentiment, the already-built inboundPackageWorkflow). Doing that on the current best-effort-signal-in-the-hot-path shape means every new consumer compounds problems 1-3. Landing a single durable event backbone first means each new consumer inherits durability, decoupling, ordering, and one-line registration for free — the event-side equivalent of the ADR-0079 single-writer win.
Decision
Introduce one durable, channel-agnostic conversation.turn_settled event stream that every runtime emits and a single bridge consumes.
1. One emitter
onConversationTurnComplete(conversationId, ctx) (src/lib/domain/conversations/turn-complete-dispatch.ts) keeps its name, signature, and both call sites (text tail + voice call-ended) — but its body becomes: build a ConversationTurnSettledEventV1 and SQS SendMessage it to a FIFO queue. Publish is intra-AWS (~20-50ms, IAM-authed) and — critically — cannot hang on Temporal. It stays awaited-for-delivery (the Lambda container-freeze contract) and best-effort (a publish failure logs + swallows; the reconciler backstop covers the residual crash window). The per-consumer isTemporalConfigured() gates disappear from the settle path.
2. One stream
propflow-conversation-events.fifo (+ -dlq.fifo, auto-alarmed via scripts/ensure-dlq-alarms.sh):
MessageGroupId = conversationId— per-conversation ordering, one in-flight per conversation.MessageDeduplicationId = eventId— content dedup (5-min window); duplicates are harmless anyway (below).maxReceiveCount 5 → DLQ, first-message alarm (fleet policy).
Event shape — IDs / enums / timestamps only (ADR-0026), versioned, pinned by a key-allowlist drift test:
interface ConversationTurnSettledEventV1 {
eventVersion: 1;
eventType: 'conversation.turn_settled';
conversationId: string;
propertyId: string;
origin: 'text' | 'voice';
channel: 'sms' | 'email' | 'voice' | 'telegram' | 'web';
emitter: 'vercel' | 'inbound-lambda' | 'worker';
occurredAt: string; // ISO
eventId: string; // `turn:${conversationId}:${occurredAtMs}`
flushMode?: 'debounced' | 'immediate'; // voice call-ended = immediate
}
3. One signaler — the bridge
A new thin lambda/conversation-events-bridge/ (SQS-FIFO-triggered, @temporalio/client only, its own metafile gate banning @temporalio/worker) is the sole runtime that turns events into Temporal signals. It walks a consumer registry — the exact CONSUMERS array relocated from turn-complete-dispatch.ts — and for each does signalWithStart(..., USE_EXISTING) (parallel, allSettled). The per-consumer workflows (conversationTopicStampWorkflow, conversationGraderWorkflow, and their debounce timers) are unchanged; they gain exactly one caller (the bridge) in place of two gated ones. A consumer failure NACKs the SQS record (partial-batch) → the whole event retries → duplicate signals to already-succeeded consumers are no-ops (USE_EXISTING + commutative debounce-reset).
Vercel (call-ended, simulator, dashboard) inbound-Lambda (SMS/email/telegram) worker (future settle sites)
└────────────────── emit() ──► propflow-conversation-events.fifo ──► conversation-events-bridge
(group = conversationId) │ signalWithStart × registry
├─► conversationTopicStampWorkflow
├─► conversationGraderWorkflow
└─► maintenance dirty-bits / future consumers
reply dispatched immediately — Temporal is OFF the reply hot path
Why this is the "single writer" for reactions
- One emit call in every runtime (the settle seam), replacing N gated inline signals — the coherence ADR-0079 gave writes.
- One signaler (the bridge) owning the Temporal contract, so a Cloud brownout backs up a queue with a DLQ alarm instead of hanging a tenant's text.
- Durable at-least-once with a DLQ, so delivery stops being best-effort and maintenance gains the backstop it never had.
- One-line to add a consumer, covering every channel in real time — the extensibility the founder asked for.
Alternatives considered
- B — Bless the status quo: keep signaling Temporal inline from every runtime (delete the stale gates only). $0, fastest signal. Rejected: keeps the Temporal dial in the reply hot path (problem 1 permanent — a Cloud brownout re-creates the incident), keeps delivery best-effort (problem 2 permanent — reconcilers can never retire), freezes the
@temporalioclient stack in the funnel bundle, and still makes "add a consumer" = trigger + gate + workflow + reconciler. It is approximately what prod does today; the migration below repairs the record whether or not we build the stream. - C — DynamoDB Streams off
saveConversation. Appealing (the write is the event, no dual-write). Rejected: wrong grain — "row written" ≠ "turn settled" (saveNewMessageswrites mid-turn for crash-safety; the final save writes again; voice writes turn-by-turn), so deriving one settled edge per turn means re-implementing turn semantics in a stream filter. Andpropflow-prodis single-table, so the stream is a firehose of every entity's writes; 24h retention < a long outage. - D — EventBridge bus. Native multi-target fan-out + archive/replay. Rejected: no FIFO/ordering (loses per-conversation serialization), weaker dedup, and its fan-out buys nothing because our fan-out lives in one registry the bridge walks (N EB targets = N DLQs to operate). Also the Lambda has no
events:PutEventsIAM today — an EventBridge path is an IAM + deploy change on top. SQS→EB is a mechanical later upgrade if a non-Temporal subscriber class ever appears.
Consequences
Easier: adding a post-turn reaction (one registry line, all channels, durable); reasoning about failure (queue-side, alarmed, never reply-side); retiring the reconcilers (a durable stream can carry a "caught-a-miss" counter that proves they're vestigial); shrinking Temporal's footprint in the inbound bundle over time.
Harder / costs: one new SQS FIFO + one new Lambda to operate (mitigated: it mirrors the existing agent-jobs.fifo/agent-runtime shape exactly — versioned rollback, DLQ alarm, path-filter CI); a ~0.5s worst-case (cold bridge) added before a consumer starts its work, invisible under the 90s-5m consumer debounces; the settle path now depends on SQS availability instead of Temporal availability (a strictly better dependency — intra-AWS, no cross-cloud gRPC).
Migration (ADR-0079 single-path: the new path replaces the old in the same PR, no parallel lane)
PR-0 — Truth repair + provisioning (no behavior change). Provision propflow-conversation-events.fifo + -dlq.fifo + DLQ alarm; scaffold + deploy the bridge Lambda (consuming the empty queue) with its own metafile gate + broad path filter + rollback script; fix the stale comments (both triggers, the conversation-manager tail, the #3702 grade files) so the cutover diffs against true docs; annotate ADR-0084 with the corrected premise pointing here + ADR-0068.
PR-1 — The cutover (single PR). onConversationTurnComplete body → SendMessage the event (awaited, one retry, log+swallow). Relocate the CONSUMERS registry to the bridge (name → signalFn). Delete topic-stamp-trigger.ts + grade-trigger.ts (their gate + lazy-import machinery is the old lane); the signalConversation* client fns survive unchanged with one caller (the bridge). Keep both reconciler Schedules as backstop, add a reconciler_caught_missed_conversation counter to each sweep (the retirement instrument). Proof: emitter unit tests (SQS mocked at the vendor edge only), a bridge registry drift test, an event-schema PII drift test, and a real preview-lane SMS receipt (no-mock-theater gate).
PR-1 landing order (the emit is OFF until the queue URL is injected — no self-heal). The emitConversationTurnSettled publisher warn-skips whenever CONVERSATION_EVENTS_QUEUE_URL is unset, so merging PR-1 without injecting that var leaves topic-stamp + grade on the ≤15-min reconciler indefinitely for every channel. Land in this order:
bash scripts/provision-conversation-events-queue.sh(queues + DLQ + alarm; idempotent).bash lambda/conversation-events-bridge/deploy.sh --env=preview→--env=prod.bash lambda/inbound-processor/deploy.sh --env=preview→--env=prod— the deploy.sh now setsCONVERSATION_EVENTS_QUEUE_URLlane-derived AND grantssqs:SendMessageon the conversation-events queues (both are in this PR).vercel env add CONVERSATION_EVENTS_QUEUE_URL preview+production— the voice/simulator emitter runtime. Lane-correct: preview →propflow-conversation-events-preview.fifo, prod →propflow-conversation-events.fifo(a preview emitter on the prod queue defeats open-question #1's lane separation).scripts/audit-preview-lane.shnow guards this for both inbound-processor Lambda lanes + the preview Vercel env (MISSING or cross-wire → error); only the prod Vercel value is unaudited (the auditor reads--environment previewonly, same scope as every other var it checks) — manual-verify that one.- Merge PR-1. Then watch
reconciler_caught_missed_conversationtrend to zero — that counter, not a merge-gate, is the post-cutover verification.
PR-2 — Maintenance folds onto the stream. Register maintenance-dirty-bits in the bridge (honoring flushMode:'immediate' for voice → signalCallEnded); delete the inline signalDirtyBitsChanged block. Maintenance gains a durable backstop it never had. With the turn path clean, tighten the inbound metafile gate toward a Temporal-free funnel (follow-up).
PR-3 — Backstop demotion (after ≥2 weeks bake at zero misses). Relax the reconcilers to hourly (they still cover the one hole a stream can't: a crash before the awaited publish, + historical backfill); retire fully only after a further clean month. Schedule paused/unpaused state stays the only gate (no arms). Superseded by ADR-0094 (2026-07-16): the reconcilers were deleted outright — no hourly demotion, no bake month. The stream (verified live per settled turn on all channels) is the SOLE path; the crash-before-publish drop and historical backfill are named accepted trades in ADR-0094. The reconciler_caught_missed_conversation counter is deleted with the sweeps.
Deferred cleanup — the channel default. PR-1's text site stamps channel = conversation.channel ?? 'sms'; the ?? 'sms' is purely descriptive today because no consumer branches on channel (dedup keys on eventId, ordering on conversationId). The moment a consumer DOES branch on channel (e.g. channel-specific grading or routing), that silent sms guess would mislabel a genuinely email-origin row whose channel stamp is missing. When that consumer lands, stop guessing: resolve channel authoritatively at the settle site (or make it a required, non-defaulted event field) rather than keep the fallback. Tracked here so it isn't rediscovered as a bug.
Rollback at any stage is one bridge-version rollback + a revert of PR-1 (which is why PR-1 is one PR, not two).
Extensibility model
A future consumer (escalation, SLA-breach, sentiment, inboundPackageWorkflow) does: (1) build its per-conversation workflow + client on the conversation-grade-* pattern (domain-named per temporal/README.md, IDs-only payload); (2) add one line to the bridge registry — instantly covering SMS/email/voice/Telegram/simulator, in real time, with durable at-least-once, DLQ alarming, and per-conversation ordering, because all of that lives in the stream, not the consumer; (3) no reconciler required (the stream is durable). A consumer may still ship a sweep for historical backfill, but it is no longer part of the delivery contract.
Risks & defusals
| Risk | Defusal |
|---|---|
| Dual-write gap (turn persisted, crash before publish) | Publish awaited in-handler (same guarantee level as today's awaited signal, but faster + can't hang on Temporal); residual crash window covered by the kept reconcilers — the one reason not to retire them outright (PR-3). |
| At-least-once duplicates | FIFO content-dedup (eventId) + every consumer is signalWithStart(USE_EXISTING) with commutative debounce-reset; a duplicate is a no-op. Side-effecting consumers keep their own claim rows (e.g. claimConversationRedAlert) inside their activities, where they already live. |
| Ordering | FIFO MessageGroupId = conversationId; cross-conversation order is irrelevant; a NACK re-delivers the group in order. |
| Debounce relocation | None — debounce stays in the consumer workflows (deterministic Temporal timers). The stream carries raw settle edges + flushMode; it never batches. |
| PII (ADR-0026) | Event = IDs/enums/timestamps only, pinned by a key-allowlist drift test; SQS payloads therefore carry no PII either. |
| Consumer slow/down | Its task queue backlogs; the bridge's signal still succeeds (accepted by the Temporal server, not the worker); other consumers unaffected. |
| One consumer's signal fails (its worker/workflow down, not just slow) | The bridge NACKs the whole record on any consumer failure (Promise.allSettled → anyFailed), so the record redelivers and re-signals ALL consumers. A HEALTHY consumer therefore eats up to 5× harmless debounce-reset re-signals and, once the poison record DLQs (~60 min: 5 × 720s), also loses that settle's real-time edge (it falls to its own reconciler). Accepted trade of one-stream-fan-out over per-consumer queues: a single sick consumer briefly demotes healthy consumers' real-time coverage for the affected events — expected at incident time, visible via reconciler_caught_missed_conversation, not a second bug. If per-consumer isolation ever matters, split to per-consumer DLQ/queues (future PR). |
| Maintenance consumer has no reconciler — the stream guarantees signal-delivery, not flush-completion (PR-2) | topic-stamp + grade each keep a reconciler that re-derives from DDB truth, so anything the stream can't deliver (or the workflow botches downstream of the signal) self-heals within ≤15 min. maintenance-dirty-bits has no such sweep, so PR-2 makes three properties load-bearing: (a) the queue URL must be present in EVERY settle runtime (inbound-processor Lambda + Vercel) or maintenance signals are lost outright — asserted pre-deploy by audit-preview-lane.sh, messaged honestly in deploy.sh; (b) the DLQ is the sole recovery path — a poison record pages propflow-conversation-events-dlq.fifo at ~60 min (5 × 720s) and the maintenance recovery action is a manual redrive, not self-heal (so bring-up must NOT run the provision script with --skip-alarms); (c) durability stops at the signal boundary — bridge.ts deletes the SQS message the instant signalWithStart resolves, so a workflow that dies post-delivery (canonically BAD_SEARCH_ATTRIBUTES → infinite WFT retry) is invisible to the DLQ/alarm stack. Consequence: do NOT flip SEARCH_ATTRS_REGISTERED to true until conversationId + traceId are registered on propflow-prod — for maintenance that flip is a silent-total-outage lever with nothing downstream to catch it (the fail-soft false default is the real backstop for the post-signal half). Only the Temporal stuck-workflow scanner / Cloud-UI catches this class, not the stream. |
| Voice-immediate dropped publish has no follow-up turn (PR-2) | A dropped text publish is doubly-covered — text WO-mutations still fire scheduleWorkOrderPipeline inline from the tool handlers (handle-add-note / handle-edit / close-work-order / handle-vendor-completion / approve) in addition to the settle emit. A voice post-call WO is minted via handleCreateWorkOrder (voice-postcall-intake.ts), which does NOT signal inline, so its flush rides solely on the terminal call-ended emit — no next turn to re-emit, no inline backstop, no reconciler. Still strictly stronger than the deleted inline path (the lossy window shrank from Vercel→Temporal signalWithStart to the more-available Vercel→SQS SendMessage; once in SQS the leg is durable), and accepted per the durability-over-latency posture — but it is the single non-self-healing corner, named here rather than folded under "same as text". |
| Bridge down / bad deploy | Events age in FIFO (4-day retention), DLQ after 5 receives, alarm pages; rollback is one call; the inbound funnel is completely unaffected. |
| Back-pressure (mass-comms burst) | SQS absorbs; bridge maxConcurrency capped (start 10) to protect Temporal RPS; per-conversation FIFO serialization spreads load. |
| Cold starts | Bridge cold start 1-2s with the client bundle — invisible under 90s+ debounces; no provisioned concurrency. |
| Fleet-scale cost | ~$0.15/mo at 5K turns/day, linear; Temporal action count unchanged from today. If the registry exceeds ~5 consumers, revisit collapsing N signals into 1 signal to a fan-out router workflow (deliberately not now). |
| Metafile gate | Inbound gate unchanged in PR-1, tightened in PR-2; the bridge gets its own gate (worker-SDK banned, client allowed). |
| Doc drift recurrence | PR-0's truth repair + this ADR + a drift test asserting the deleted triggers stay deleted (the legacy-field-elimination pattern). |
Open questions (forks to resolve during the build)
- Preview/prod shared Temporal namespace.
--env=prodand--env=previewLambdas point at the SAME Temporal namespace (env separation is at the DDB layer). Aturn_settledevent emitted from preview would land on the same bridge/queue. Decision needed: a separate preview queue + preview bridge, or anemitter/env tag the bridge filters on. (Recommend: separate preview queue, mirroring thepropflow-inbound-preview.fifopattern.) inboundPackageWorkflow— absorb now or later? It's built and unwired; wiring it as the first new stream consumer in PR-1 vs. a follow-up. (Recommend: follow-up — keep PR-1 to the like-for-like topic+grade cutover.)- Reconciler retirement aggressiveness. Hourly-forever (cheap, honest backstop for the crash window) vs. full retirement after a clean bake. (Recommend: hourly-forever unless the crash window is judged unacceptable.)
- Bridge as Lambda vs. worker long-poll loop. (Recommend: Lambda — platform-native, matches
agent-runtime; rejected the bespoke long-poll lifecycle.)