0110 — Stamp a grounded "why did Clara say this" rationale on every Clara reply

A note on ADR numbers. 19 numbers in docs/adr/ are currently used by two different documents (0079, 0094, 0095, 0101 among them), so a bare "ADR-00XX" can be ambiguous. Cross-references in this document link the exact file. 0110 was verified free against a same-operation fetch of main, and re-verified after each base update (one 011x file in the merged tree). Both 0108 (renewal-pipeline) and 0109 (voice-triage front-door) were claimed on main while this ADR was in review — the collision class this note warns about, twice.

Context

On 2026-07-20, reviewing Clara's leasing conversations, Sean asked why Clara offered a tour on Tuesday instead of the next available Monday. Nobody could answer. Fede's summary was "sometimes it's unexplainable what the LLM does", and the thread closed with a proposal:

Gera: One thing we could add, but it's a little overkill, is a field to every message context why it said what it said. Fede: If it doesn't add latency, that's fine. Gera: It doesn't, because it would be done, like, async. Fede: Yeah, I think we should. […] I think that will be very useful, actually, for me. Because sometimes I'm like, why the […] did you decide this? And it's impossible to know.

The value is triage speed. Gera named the three outcomes the field discriminates, and they map to three different fixes:

What the rationale reveals The actual fix
The prompt told her to do this Fix the prompt
Two inputs contradicted, she picked one Fix the data / the precedence rule
She made it up Upgrade the model

Today, distinguishing those three costs an engineer a manual dig, and only an admin can do it.

What exists and why it isn't enough. AgentTrace (src/lib/data/types.ts:4549) already captures a great deal per turn: the exact system prompt on the first claude_call (agents/clara/lib/agent/conversation-manager.ts:4178, default-ON in prod), every tool call and result, assistantText, token counts, and stopReason. Its own code comment states the motivation we are extending: "'Why did Clara do that?' is unanswerable without the prompt that produced the decision." Three gaps remain:

  1. Inputs, not self-explanation. Everything stored is an input (prompt, tool inputs, catalog size) or a raw output artifact. assistantText is incidental prose the model happened to emit next to a tool_use block — not a solicited, structured account of why this reply was chosen. A reader reconstructs the "why" by reading a 5–15KB prompt plus a step list.
  2. No Message ↔ trace link. Message has no traceId. Conversation.agentTraceId (src/lib/data/types.ts:4064) carries a stale doc comment claiming it was deleted in ADR-0006, but it does have a live writer — the voice path sets it at src/lib/integrations/voice/trace-persistence.ts:31. It is conversation-level and voice-only, so it cannot answer "which trace produced this message." For text turns the only correlation is AgentTrace.conversationId plus positional timestamp matching (src/app/api/conversations/[id]/trace/route.ts:52-63).
  3. Wrong audience and granularity. Traces are admin-only, turn-scoped, and fire-and-forget (conversation-manager.ts:6020) — a trace can simply be missing. The people asking "why did she say that?" are reading the conversation thread, not the trace viewer.

The trap this ADR exists to avoid. The literal version of the proposal — after the fact, ask a model "here is the response, why did you say this?" — produces a post-hoc rationalization. The explaining model did not see the system prompt, the tool results, or the retrieved facts, so it will invent a fluent, plausible, and unfalsifiable story. That is strictly worse than no field: it launders a hallucination into an audit surface and would send us to fix the wrong one of the three causes above. Grounding the pass in the turn's real trace is therefore not a refinement — it is the whole feature.

Decision

Add an optional, best-effort rationale to Clara-authored outbound message rows, generated by an asynchronous, trace-grounded LLM pass on the existing turn-settled event stream, and surfaced as one row in the message's existing click-to-expand details panel.

No new entity is introduced; this ADR adds fields to the canonical Message (src/lib/data/types.ts:3699), so the ADR-0027 entity-classification table is omitted.

D1 — Five fields on Message

/** ADR-0110 — the id of the AgentTrace whose turn produced this row … */
traceId?: string;
/** ADR-0110 — a short, trace-grounded account of WHY Clara sent this reply … */
rationale?: string | null;
/** ADR-0110 — the model that produced `rationale` … */
rationaleModel?: string;
/** ADR-0110 — when `rationale` was generated (ISO) … */
rationaleAt?: string;
/** ADR-0110 D5 — on a refusal, the short clause naming the evidence gap …
 *  (added by the 2026-07-23 output-polish PR) */
rationaleRefusalReason?: string | null;

D2 — Scope: keyed on traceId, not on authorship

The pass stamps a row only when all hold:

Why author.kind === 'clara' is NOT the scope key (it was, in an earlier draft of this ADR — two code facts broke it):

  1. Clara-authored ≠ LLM-chosen. The agent loop has at least one path that writes a Clara-authored outbound with no Claude call at all — the language-ask at conversation-manager.ts:3670-3690, whose own comment reads "trace id absent because no Claude call was made" and which sets _lastAgentResult.traceId = ''. A rationale on a deterministic template row is confabulation about a non-decision.
  2. Voice rows are Clara-authored too (api/voice/call-ended/route.ts:693) but their traces are built post-hoc from the ElevenLabs transcript (voice-trace-builder.ts:141-175): synthetic claude_call steps and no captured system prompt — the EL agent's prompt lives in ElevenLabs, not in the trace. The grounding evidence D3 requires simply is not there.

Keying on traceId makes the scope self-describing: a row is explainable iff the turn that produced it left real evidence. It also gives us the epoch bound for freetraceId stamping begins when PR-1 ships, so historical rows are out of scope by construction, and the first settle on a 200-message thread cannot trigger a paid explain-the-backlog sweep. (The topics rollout needed an explicit CLASSIFICATION_EPOCH for exactly this; here the link field is the epoch.)

Voice is deferred, not rejected. Explaining voice replies is a V2 with its own evidence assessment (what the EL payload can actually ground) — not a silent inclusion with thinner evidence and the same confident prose.

Also out of scope: inbound tenant messages (there is no "why" to explain), tool-audit rows, and system-triggered templated sends — those already carry triggerSource, which the UI renders as a deterministic "Sent because: A new work order was created" (ConversationThread.tsx:459-465). An LLM rationale on a row whose cause is already known deterministically is pure cost and pure risk.

The human/machine line, stated as a rule (Gera, 2026-07-22): rationale marks MACHINE choices only. Every human-side message — tenant, vendor, PM, and the subtle one, operator-typed outbound (Pipeline Lab's "Send as Clara", Message.kind: 'manual_outbound', which renders on Clara's side of the thread but is human-authored) — never carries a traceId and therefore can never be stamped, by construction rather than by filter. A human message is its own explanation; an AI-generated one owes you one.

D3 — Grounding (the load-bearing decision)

The activity MUST assemble its prompt from the turn's real inputs, not from the reply text alone:

  1. Resolve the turn's AgentTrace for the message.
  2. Feed the model: the captured systemPrompt, the ordered tool calls with their inputs and results, assistantText, and the inbound message — then the reply to be explained.
  3. Instruct it to explain from that evidence, cite which input drove the choice, and return the refusal sentinel (D5) when the evidence does not support an explanation.

Resolving the trace requires a link that does not exist today. Two options, and the ADR picks the first:

A real ordering race exists and the design must absorb it. The turn-settled event is emitted at conversation-manager.ts:5934before the trace is finalized (:6000) and before saveAgentTrace runs inside a fire-and-forget IIFE (:6020). The debounce (D4: 10s as of 2026-08-28, previously 2 min) covers this in the common case, but "usually covers it" plus "no reconciler" equals permanent gaps that concentrate exactly when the system is under load — which is exactly why the retryable-vs-refused split below exists rather than leaning on the debounce alone. So trace-absence is split into two outcomes:

What the pass never does is fall back to reply-text-only explanation. A missing rationale is honest; a confabulated one is not.

D4 — Async transport: the existing turn-settled stream

Reuse ADR-0088 end to end. No new cron, no new sweeper (CLAUDE.md:47, :108; the topics reconciler was deliberately deleted by ADR-0094 delete-conversation-reconcilers).

turn settles → onConversationTurnComplete (turn-complete-dispatch.ts:86)
  → SQS propflow-conversation-events.fifo (IDs/enums only, PII-fenced)
  → lambda/conversation-events-bridge/registry.ts  ← ONE new consumer entry
  → signalWithStart conversationRationaleWorkflow  (workflowId: conversation-rationale-<conversationId>)
  → debounce, then one activity
  → activity reads DDB + trace, calls Anthropic, patches each MSG# row

This satisfies Fede's only condition — zero added latency on the reply path. The reply is already sent before the event is emitted, and the emit is a ~20–50ms intra-AWS SQS publish that the agent loop awaits (conversation-manager.ts:5933-5950).

Three constraints are inherited and non-negotiable:

Task queue: new propflow-conversation-rationale, co-tenanted in the existing Fargate worker process via getRationaleWorkerRegistration() — the same "no infra change" pattern as conversation-topics-worker.ts:5-8.

Debounce: mirror the topics workflow — 10-second idle debounce, 60-second hard cap (cut from 2 min / 10 min on 2026-08-28, conversation-topic-stamp-workflow.ts — same owner call: don't hold a customer-visible "Explaining…" row on a wait bought for LLM-call batching savings that aren't worth it). The shorter window means the trace-save ordering race above (D4 intro) resolves via retry more often than via debounce absorption — already-designed-for, since the activity's nonRetryable:false + Temporal's 3 retries spread over minutes exist for exactly this case. Because the stream coalesces, the activity must work in the applyTopicStampPlan shape: query the conversation, select every qualifying row still lacking rationale, and explain each. It is not "one event, one message."

D5 — Failure and honesty policy

D6 — Persistence: targeted per-message patch

Messages are separate DDB items (PK=CONV#{convId}, SK=MSG#{timestamp}#{msgId}, conversation.ts:4-13), so this is a keyed UpdateItem, not a conversation rewrite. Follow the existing applyTopicStampPlan / backstampProviderId precedent (conversation-writer.ts:478-495, :729-751) exactly:

await updateItemFields(convPK(conversationId), convMsgSK(timestamp, msgId),
  { rationale, rationaleModel, rationaleAt },
  { preserveUpdatedAt: true,
    // First-stamp-wins keys on rationaleAt, NOT rationale: a refusal's
    // `rationale: null` becomes a DDB REMOVE (never a stored attribute), so
    // attribute_not_exists(rationale) would always pass on refused rows and
    // let a concurrent retry re-stamp + re-bill the LLM (PR-4 review fix).
    conditionExpression: 'attribute_exists(PK) AND attribute_not_exists(rationaleAt)' });

Three rules the implementation must not miss:

  1. Carry { conversationId, msgId, timestamp } in the plan. The SK needs both halves; findMessageByMsgId recovers timestamp via a full getConversation() (conversation-writer.ts:470-473), which would make every stamp an O(conversation) read.
  2. attribute_exists(PK) is mandatoryUpdateItem upserts by default and would mint a phantom MSG# row (conversation.ts:868-874).
  3. preserveUpdatedAt: trueMSG# rows are append-only history; a metadata patch is not an edit (conversation.ts:890-893).

saveConversation is append-only for messages and skips existing msgIds, so re-saving the conversation cannot be used to apply this.

D7 — PII boundary

The rationale is free text derived from tenant conversation content, so under ADR-0026 it must not appear in workflow inputs, signal payloads, or activity return values — Temporal Cloud workflow history is plaintext to anyone with namespace read access.

Containment: the field is one-way, operator-facing display data. Two invariants keep it on the inside of the product:

D8 — UI surface

One row in the existing per-message details panel — no new component, no dialog. buildMessageDetails (ConversationThread.tsx:361) already renders a two-block "show the logic" panel, and the click target already exists (the avatar doubles as the details button; ChatSurface.tsx:769-773).

Add a "Why" row to the Provenance block, as a sibling of the deterministic "Sent because":

Provenance
  Why       Offered Tuesday because the 24h notice window ruled out Monday
  Sent by   Clara
  Channel   SMS · Sent at 3:41 PM

Consequences

Easier. Every Clara reply becomes self-describing to whoever is already reading the thread — Fede's stated need. Triage moves from "dig through a 15KB prompt in the admin trace viewer" to "expand the message." The three failure classes become separable at a glance, and because rationaleModel is stamped, a model migration becomes measurable rather than vibes-based. Stamping traceId on the message row (D3) is independently useful: it makes every message one hop from its full trace and retires the fuzzy positional correlation.

Harder / accepted costs.

Follow-up implied. The eval-gate path map does not currently watch src/lib/domain/conversations/** — editing the classifier there does not fire the promptfoo sweep. If the rationale pass lands under that path, add it to .github/workflows/clara-behavior-evals.yml and to CLARA_PATH_MAP in src/__tests__/clara-eval-gate-path-filters.test.ts in the same PR. Two stale doc comments in src/lib/data/types.ts are worth one-line corrections: AgentTraceStep.systemPrompt (:4456-4460) still claims prod skips prompt capture, contradicting the live code, and Conversation.agentTraceId (:4064) claims the field was deleted in ADR-0006 while the voice path writes it live (trace-persistence.ts:31). Finally, voice rationale support is an explicit V2 (D2): it needs its own evidence assessment of what the ElevenLabs payload can ground before any voice row gets explained.

Drift guards + evals

Deliverable Where
Field pin, both trees new it() in src/__tests__/message-types-lockstep.drift.test.ts
Single-caller signal fn existing turn-settled-single-path.drift.test.ts (new consumer must comply)
Writer discipline existing F2/F6/F7 in conversation-single-writer.drift.test.ts
Generated schema refresh scripts/generate-entity-schemas.ts output committed
Pure prompt-build + parse unit tests src/__tests__/message-rationale.test.ts
Promptfoo config, provider pinned to production model/temp/max_tokens evals/promptfooconfig-message-rationale.yaml
Cases — one per failure class, incl. cases where refusal is REQUIRED evals/datasets/message-rationale.yaml
Prompt bridge importing the real builder (never a copy) evals/prompts/message-rationale.ts
Grader (LLM-as-judge; fairhousing + maintenance-judge are the in-repo precedents) evals/lib/assert-message-rationale.ts
run_eval registration evals/run-all.sh
Coverage detector (silent-degradation page) classification-observability.detector.ts sibling registration
Operator regeneration tool (dry-run default) scripts/backfill-message-rationales.ts

The eval's central assertion is groundedness: every causal claim in the rationale must be traceable to something in the supplied trace evidence, and an unsupported reply must produce the refusal sentinel rather than prose.

Suggested PR sequence

  1. PR-1traceId on Message (both trees + pin + generated schema), threaded through the OutboundSpec at buildMessage time (D3 — not a backstamp). Independently useful; unblocks D3 and defines the scope epoch (D2).
  2. PR-2rationale / rationaleModel / rationaleAt fields + the pure prompt-build/parse module (caps included) + unit tests. No wiring, nothing runs.
  3. PR-3 — eval dataset + grader green. Gates PR-4.
  4. PR-4 — workflow, activity, task queue, bridge-registry consumer, coverage detector registration.
  5. PR-5 — the UI "Why" row.
  6. PR-6 (as needed, post-ship)scripts/backfill-message-rationales.ts, the operator-run regeneration tool.

Alternatives considered

Ask the model post-hoc from the reply text alone (the literal proposal in the meeting). Cheapest and simplest, and rejected as the primary design: without the prompt and tool results the explaining model cannot know why the reply was chosen, so it produces fluent, unfalsifiable invention. It would have "explained" the Tuesday-vs-Monday bug with a confident story about availability. D3 keeps the async shape Gera specified but grounds it.

Have Clara emit the rationale inline during her turn. Most faithful — it is the actual deciding model with the actual context. Rejected on Fede's explicit condition: it adds output tokens to every reply on the latency-critical path. It also makes the rationale a hostage of the reply (a formatting slip corrupts a tenant-facing message), and self-reported reasoning from a model choosing its own answer is not notably more truthful than a grounded reconstruction.

Just improve the existing trace viewer. Zero new storage and no new LLM call. Rejected because it does not address the audience or granularity gaps: the people asking the question are reading the conversation thread, the surface is admin-only, and no amount of viewer polish converts a 15KB prompt into a one-line answer. The trace remains the ground truth this feature is derived from.

A new cron/sweeper to backfill rationales. Explicitly rejected — CLAUDE.md:47 and :108 name this as the wrong direction, and ADR-0094 delete-conversation-reconcilers deleted the equivalent topics reconciler. Coverage gaps are accepted instead.

Reuse the SQS propflow-agent-jobs.fifo lane. Rejected: that queue is the single named path for AppFolio browser-agent jobs, guarded by agent-name-handler-coverage.test.ts. Using it for LLM compute would abuse a single-purpose queue.