0150 — The conversation page is the system of record for everything Clara does: a send and its row cannot happen apart
- Status: Accepted (Gera, 2026-10-29, in chat: "that is the soul of everything; if something doesn't go through there, it almost feels like we're being hacked"). Implementation is staged in the parts named in §6; the status of each part is kept there, honestly, including the doors this ADR does not yet close.
- Date: 2026-10-30
- Deciders: Gera (the rule, repeated many times: everything Clara or Propflow sends or receives — texts, calls, emails, voicemails — shows on a conversation page, no exceptions; an unrecorded send is a security incident). Second reviewer: Astra (
codex exec -m gpt-6-astra), adversarial design read 2026-10-29; its findings and the ones this ADR rejects are in §7. - Extends: ADR-0079 (the single canonical conversation writer — how a row is written) and ADR-0119 (the outbound record as a construction invariant — whether a send writes a row; shipped for email only). Neither is superseded; both get an honest status amendment in this PR.
- Incident: 2026-10-30 00:08–00:10Z, a withdrawn test emergency on a customer line. The chain texted and called three on-call people; the stand-down told hop 0 to disregard and not hops 1–2 (
alertedAfterFollowupwas never read, #23607); a lane then texted hops 1–2 by hand through Twilio, and those two texts (SM6e06…,SMf958…) existed on no page. The daily capture-drift sweep would not have paged: its body match was keyed by text alone, so hop 0's identical row vouched for both (#23613). The texts were backfilled onto their hop pages through the canonical on-call writer on 2026-10-30 (msg_75e09153…,msg_14b10dce…).
1. Context — where "single writer" was overclaimed
Gera thought we already had a single writer. We half did:
- ADR-0079 made every conversation/message row go through one writer (
conversation-writer.ts, theCONVERSATION_WRITERSregistry, compile-timewriterIdclosure). Its status reads "Implemented — all 7 migration clusters complete". Its formal acceptance was gated on R2, a mechanical read-only audit of every production message row, which has never run. The script exists (scripts/_r2-prod-row-audit.ts); it is a full-table Scan, which the prod read-cost guard refuses to an agent session by design (ruling f9b294b8e), so a founder has to run it in their own terminal. The audit was run againstpropflow-stageon 2026-10-30 as a mechanical check of the script (§6). 0079 is about how a row is written, not about whether a send writes one. A send that writes no row is invisible to it. - ADR-0119 is the send↔record coupling — the door applies the caller's record in the same call as the send. It shipped for the email door (
sendEmailthrows without arecord). Its §9 staged the SMS half (27 sites, 19 files) and that stage never landed: on 2026-10-29SendSmsOptionshad norecordfield. Neither voice door (createOutboundCall,initiateOutboundCall) has one.
So on 2026-10-29: a text or a call could go out with no row, by construction, through the doors themselves. Measured over the previous 7 days (29 Twilio lines, 1,068 outbound texts, 435 outbound calls, the sweep's own classifier, read-only): the sweep flagged 11 texts; it labelled 967 as noise, among them 136 on-call chain texts (72 with no row anywhere) and 768 texts to registered tester lines (440 with no row); 21 outbound call legs to real numbers had no row by call SID.
2. Decision
- The conversation page is the system of record for everything Clara does. A text, a call, an email, a voicemail, in either direction, is not done until its row is on the page of the person it reached. A send with no page is a security incident, not a logging gap, and the alarm says so.
- Record at the boundary, in the same call as the act. Every send door — SMS/MMS, voice (Twilio REST and ElevenLabs), email — takes a
record: OutboundRecordand applies it against what actually went on the wire (applyOutboundRecord, provider ids stamped). A caller cannot send and forget to record, because the door records. A refusal lands no row (the phantom-row rule, ADR-0119 §1.1). A delivered send whose row fails is paged as an unrecorded send; it is never reported to the caller as a failed send, because the message is already at the carrier. - The set of callers that declare no record is closed and shrinking. A build-time fence walks the tree and fails on an undeclared door call, an alias of the door, or a stale ratchet row; the ratchet only shrinks; the field goes required at zero.
noConversationRecord(reason)survives only for the named exceptions in §5, and the fence counts them. - Raw provider calls outside the doors are fenced (the next part): any Twilio message/call create, any
api.twilio.comPOST, any SendGrid/SES send outside the door files fails the build. Harness scripts that play the other party (a robot tenant texting into our line) use one named helper that refuses aFromon any registered Propflow line. - A daily reconciliation alarm per channel, provider-side truth against rows: Twilio texts (the capture-drift sweep, now keyed by counterparty and watching on-call texts), email (the ledger sweep), outbound calls (the next part: Twilio call legs against rows carrying the call SID, parent/child legs included). Each alert names its source as far as the provider can (our line, direction, template), and reads as an incident.
- Emergencies send first and record in the same call. For the on-call chain the send is not held on the row (an emergency text must go even if the store is slow), but the row is written in the same call by the door and a miss pages. For every other channel the record's target is to be resolved before the wire (
{ ensure }), so a conversation that cannot be created refuses the send. Built for the SMS door only (§6, part 6b):sendSmsmints an{ ensure }target after its refusal checks and before the wire, and refuses (blocked) when it cannot. (A refusal the carrier returns after the mint leaves that thread with no row on it; accepted, since it is the person's own declared thread.) This narrows ADR-0119 D2 on one point: which thread is still the caller's decision (the door mints only the thread the caller declared), but a mint that fails now refuses a non-emergency send instead of never blocking it. Every other door still mints after the send, and the daily sweeps are the net under a process that dies between the two.
3. What this is not
- Not a parallel mechanism. It is 0079's writer plus 0119's seam, applied to the doors 0119 named and never reached.
- Not a per-site fix. The stand-down fix (#23607) is a bug fix; parts 2–5 are the structure that makes the next hand-send impossible to lose.
- Not a switch. There is no flag; the door records, everywhere, on merge.
4. Consequences
- A caller that today appends its own row after the send migrates that append into its
record(one row per send; duplicate-row risk is the migration's own test). The dispatcher (agents/clara/lib/messaging/dispatcher.ts) already applies the envelope record post-send and moves to the door last. - The sweep will page more, correctly: on-call chain texts with no row and broadcast texts recorded once now show. Every page is a real gap by this rule.
- A founder runs R2 against production once (§6); until then 0079 stays "implemented, not formally accepted".
5. Named exceptions (declared, counted, never silent)
A noConversationRecord(reason) is allowed only for a send that is not Clara talking to a person: a sign-in code to a staff user's own login (mfa-sms.ts, email-otp.ts), a canary probe that proves a gate refuses (suppression-probe), and the bake-alert email to the team's alert inbox. Astra is right that these are exceptions to "no exceptions" (§7); they are listed here so they are exceptions on purpose, counted by the fence, and never a door for anything else.
6. Staging and status (kept honest)
| Part | What | Status |
|---|---|---|
| 1 | Stand-down reaches every hop the chain contacted (everyoneContacted, reader returns the late list) |
merged #23607 |
| 2 | SMS door applies the record; ratchet fence over undeclared callers; miss alert worded as an incident | merged #23609 |
| 3 | Sweep keyed by counterparty; on-call exemption removed; alert as incident with by-source lines | merged #23613 |
| 3b | The sweep tells misfiled (a row on another person's page — the no-answer ladder's staff text on the resident's thread) from uncaptured (no row anywhere); direction joins the party key; a carrier keyword is noise inbound only; a text the carrier never delivered is not-delivered, never an incident; a failed property read marks the run degraded |
merged #23620 (before the sweep's first run) |
| 4 | This ADR; ADR-0079 and ADR-0119 status amendments | this PR |
| 5 | Emergency chain callers (textOnCallPerson, follow-ups, the no-answer ladder) migrate their appends into the door record; the text never waits on the hop-page create |
merged #23680 |
| 5b | The two turnover-recap sends in the call-ended route carry their record; the no-record reasons that cited §5 are renamed as OPEN GAPS (part 6b) |
merged #23732, #23746 |
| 6 (voice) | Both voice doors take record as REQUIRED (the type, plus a runtime refusal before the dial for an untyped caller); the zero-row voice-door fence retires |
merged #23748 |
| 6 (SMS) | Remaining SMS callers migrate (sign-in code and suppression probe as §5 exceptions #23751; integration-suite helper renamed #23771; direct staff texts #23773; mass-comms recap #23789; every staff text, persistStaffAskTurn retired #23793; the Clara reply adapter hands the envelope's record to the door and dispatch applies nothing after the send #23821); record goes REQUIRED on sendSms (the type, plus a refusal before anything is sent for an untyped caller) and the SMS door fence retires |
merged #23849 |
| 6 (MMS) | sendMms takes record as REQUIRED (the type, plus a refusal before anything is sent for an untyped caller); its one caller, the renewal letter's PDF leg, declares its own row on the renewal thread, with the MMS SID |
this part (open) |
| 6b | Pre-send target resolution ({ ensure } before the wire, refuse the send when the conversation cannot be created) for every non-emergency door. The { ensure } target carries its two mint settings (spineStampSource, writerId) so a sender's own kind of thread can be minted by the door |
SMS door: merged #23768; email and voice doors next |
| 7 | Voice doors take a record (Twilio REST createOutboundCall, ElevenLabs initiateOutboundCall), four callers declared, zero-row voice fence; the call-center dial carries the chain's notice as its one row |
merged #23690 |
| 8 | Raw-provider fence over src/agents/lambda/scripts (Twilio, SendGrid, SES, SMTP, ElevenLabs outbound-call) with a shrink-only ratchet of 45 files |
merged #23651 |
| 8b | Each ratchet row is an exact reach COUNT; the walk reads .js/.mjs/.cjs/.sh; the shared parser-backed stripper; word-anchored LLM receiver; Twilio Verify and Studio are sends | merged #23716 |
| 8c | One named other-party helper for harnesses (refuses any From that is not a registered tester); harnesses migrate one at a time | next |
| 9 | Outbound-call reconciliation sweep (Twilio call legs, parent/child, vs rows by call SID) | merged #23621 |
| R2 | Prod row audit (founder-run; decision raised on the decisions surface) — stage run 2026-10-30 as a mechanical check of the script | raised |
A row is not a record until it is stamped. A script run off the box (a founder's terminal, a lane's scratch) approving a held email logged CONVERSATION_EVENTS_QUEUE_URL not configured — saved rows with no topic and could not ask for a stamp (2026-10-30): the row landed, the topic/stamp request that the on-box path sends did not, so the page shows an unclassified turn. The door's contract covers this too — a writer that cannot ask for the stamp refuses or queues, never saves silently — and the conformance sweep is the net (part 6b, with pre-send target resolution).
Doors this ADR knows about and does not yet close (from Astra's read; each is a part to schedule, none is a reason to pretend the rule holds): TwiML <Message> and <Dial> responses (twilio/webhook/route.ts, office-hours-wrap.ts, connect-caller.ts, hunt-chain.ts, maintenance-menu-digit.ts); ElevenLabs platform transfers (transfer_to_number in agent configs); AppFolio-side texts and emails sent through the browser agent (l4-core.ts, guest-card replies); calendar invitations and updates (Google and Outlook providers); raw Graph/Gmail/SMTP sends in canary and harness scripts; the email dead-letter replay with a null record (email/client.ts resendDeadLetteredEmail); an unwired client.calls.create in caller-number-passthrough.ts; the inbound-processor Lambda's root handler.ts, which the walker roots must include.
7. The second reviewer's findings, and the ones rejected
Astra's design read (2026-10-29, 28 findings) is folded in as follows. Accepted and scheduled: partial-chunk sends must record the SIDs that landed (part 6); voice errors are not proof of no call — reconcile by provider inventory (part 9); TwiML, platform transfers, AppFolio transports, calendar and Graph/Gmail sends are doors (the list above); the walker must read every executable root, Lambda handlers included (part 8); the body match must be keyed by counterparty and the on-call exemption removed (#23613, shipped); the allowlist must shrink, not preserve (every ratchet is shrink-only); the miss alert must carry the send's recipient, operation and provider ids (part 6).
Rejected, with the reason:
- "Same call is not atomic; write a pending row before the send." Rejected for the emergency chain (a text must go even when the store is slow — §2.6) and deferred elsewhere: the touch ledger already reserves pre-send, and a pre-send conversation row that the send then fails would be the phantom row 0119 forbids. The reconciliation sweep is the net under process death; the pre-send
{ ensure }target resolution is what fails closed. - "Structurally no conversation contradicts no exceptions." Accepted as a statement, rejected as a change: §5 names the exceptions on purpose rather than forcing a sign-in code onto a page.
- "Email guarantees a row is false." Partly accepted — the dead-letter replay is listed above as an open door; the claim in the design note was too strong and is corrected here.
- "The seven raw SendGrid scripts." One of the seven is an interceptor, not a sender; the count is six real sends and they are part 8's.
8. Verification
- Every part ships a test that fails on main (named in each PR). The fences carry positive controls (sites that must be found) and mutation controls (a planted bypass must redden with the fence's headline).
- The proof of the whole is the alarm reading zero for a week on every channel after parts 5–9 land, with the exceptions in §5 the only
noConversationRecordsites left.