Every Door Out

Full-app audit of every path a message takes to a human — how many actually go through Clara's guarded loop, and what it takes to move off canned timer sends.

2026-08-28 · Commissioned by Fede off the Camellia voucher incident ("make sure any message out is through Clara loop, not a canned Temporal reply") · 5 parallel auditors, ~100 send sites, every claim file:line-verified · Companion to The Ungated Lanes

TL;DR. Of everything the app can send a human, only one class goes through Clara's guarded loop: her replies to inbound messages. Everything else — roughly a hundred send sites — is a template or a standalone AI call shipped from timers, webhooks, and routes. SMS mostly passes through one guarded door anyway; email never does (45 of 45 sites), and outbound voice never does. The riskiest tier: five places where AI-written text reaches humans with no safety review at all. Recommendation: don't move every message into the loop — extract the loop's guards into one door every channel must exit through, then migrate the AI-composed lanes into the loop first.

The map — where outbound messages actually come from

PathWhat it isVolume of sitesGuards today
Clara loop full pipelineReplies composed in the agent loop (SMS + email replies, PM Q&A)1 path (the loop)18-step pipeline + trace + the guarded send door. Caveats: fair-housing/promise checks run only for prospects/unknowns — resolved residents are skipped; the 2 strongest guards (policy gate, fee guard) ship disarmed.
Templates via the guarded door gated, untracedTimer/webhook template SMS (reminders, recaps, chases, renewal touches, blasts)~30 SMS sitesEscalation gate, consent, suppression, budget — yes. Safety pipeline, trace — no. This tier mostly behaves.
Templates via raw transports ungatedAll 45 email sites, 13 direct staff/emergency SMS sites, 1 tenant MMS leg, all 5 outbound-voice dial paths~64 sitesSuppression/shadow-mode mostly yes; escalation gate, global kill-switch, trace — no. Daniel's email came from here.
AI-composed outside the loop worst tierProspect cadence + re-engagement touches (Haiku), maintenance tech↔tenant relays (Haiku), escalation relay rephrase, Clara tool free-text → PM email, live voice agents (ElevenLabs)~10 sites + voiceLLM text reaching humans with none of the loop's safety checks and no trace. Relayed staff answers are even labeled identically to real Clara replies.

Top gaps, ranked

D4 · The migration — how far off "canned" do we move?

A · One door only. Make the four raw transports private; every send (email, SMS, MMS, voice dial) exits through one policy door — escalation gate, suppression, consent, kill switches, budget, and a trace row for every send. Templates stay templates. Deterministic, no latency cost, mechanical migration; harness R131 makes it un-regressable.
B · Everything through the loop. Timers stop sending; they signal Clara with a goal ("remind Daniel of tomorrow's tour") and she composes through the full pipeline. Maximum consistency with the operator vision — but adds an LLM call to every reminder, makes receipts non-deterministic, and is wrong for the lanes that must never be AI-composed (emergency pages, MFA codes, legal collections text — deliberately human-gated today).
C · A, then B where composition earns it (recommended). Ship the one door now. Then migrate lanes into the loop in risk order: first the five AI-composed-outside-the-loop lanes (they already pay for an LLM — route it through the pipeline), then high-judgment templates (post-tour follow-up, re-engagement) where a context-aware Clara message genuinely beats a template. Receipts, emergency, MFA, collections stay deterministic behind the door forever. This is the guard vision's "arm and converge, don't rebuild," applied to sends.

Prior decisions D1–D3 (gate the email lane, application link is not an earned receipt, fair-housing full-mute) are the first concrete slice of A — see The Ungated Lanes.

Appendix — per-lane detail (channel tables, dead kinds, corrections)

Email (45 sites, all ungated)

Tenant/prospect-facing: renewal outreach, application link (2 sites), on-behalf follow-up, new-lease congrats, tour confirmations, confirm-nudge, post-tour chase, cadence + re-engagement touches, promise fulfillment, staff-answer relay, move-out mismatch notify, waitlist. Staff/owner-facing: ~28 sites incl. escalation decision-requests/nags (recordless by design, ADR-0059), PM tour/application/key-pickup notices (8+ set bypassKillSwitch; key-pickup skips harness-suppress AND shadow mode), owner-facing collections escalation + reporting digest (audience surprise: the OWNER, not staff). Kind is plumbed to the send call at only 3 of 45 sites.

SMS/MMS

Gated norm via dispatch(): tour reminders/outcomes, chases, nudges, cadence touches, renewal family, recap, change-term, collections (post-approval), promise SMS, mass-comms blast, bounce fallback, email-capture ack. Direct sendSms() (skips escalation gate AND the global SMS kill-switch): 13 sites — staff recaps/reminders, stale-dial and PO notices, MFA (consent skipped by design), emergency ladder (exempt by design). Tenant-facing gap: renewal-letter MMS leg (send-renewal-letter-mms.ts:212).

Voice (5 dial paths, one chokepoint, zero gating)

initiateOutboundCall is the single door; no caller or the door itself runs outbound policy. Emergency relay: suppression checked but result ignored (life-safety), explicitly excluded from trace. Vendor dispatch + autonomous redial (≤6 attempts, no per-dial human approval): untraced. Escalation callback: suppression-gated. Renewal call: suppression + budget + quiet hours, traced. Live speech on every call is composed by ElevenLabs agents outside the text pipeline.

Inside the loop — the fine print

18 ordered steps (escalated gate → draft salvage → strips → fair-housing/promise (prospects only) → PII → hallucination guard (needs offer/tour snapshot) → observe/hold gates → turnover trio → escalation template → policy gate (off) → fee guard (off) → trace → guarded dispatch). Bypasses that ship text without the pipeline: pre-loop deterministic rungs (life-safety, slot-pick, rating, maintenance-state-first, email-capture — all still exit the guarded door), the router crash-fallback string, and manual_outbound (human takeover; deliberately exempt from everything). Staff-answer relays are stamped clara_reply — distinguishable from real Clara only by writerId.

Dead surface + corrections

Dead kinds to delete: 4 Telegram vendor kinds, tour_proposal_sms, tour_reschedule_sms, tour_auto_reply_email, first_reply_followup_* (decommissioned lambda), scheduling_engaged_followup_* (walker retired by #4361 — the repo CLAUDE.md env-flags row describing it as live is STALE). turnover_capture_ack has no findable sender. Stale local dist/ bundles of 3 deleted lambdas (tour-reminder, renewal-nudge, application-link) warrant a one-time AWS check that no function still points at them.

Method: 5 parallel auditors (leasing/tours · renewals/collections/mass-comms · maintenance/vendors/turnover/emergency · loop/escalation/PM · transport sweep), each row read at file:line; main session independently verified the load-bearing claims (email/transport gate absence, tenant fair-housing skip, walker retirement, Daniel trace vs. traceless timer email in prod DDB). One auditor's path labels conflated "gated" with "in the loop" and its trace column conflicted with prod evidence — reconciled against the strict definition; transport/gate findings retained.

Full evidence (engineers)

The five auditor reports, every claim at file:line. Caveat: ~33 secondary email call sites are ungated by construction (the transport has no policy path) but their kind/audience columns were not row-enumerated.

Leasing / tours / prospect outreach — full evidence table
# Outbound-path audit — Leasing / Tours / Prospect Outreach slice

Repo: `/Users/federicochapa/.claude/propflowai` (read-only). All file:line citations below were read directly (myself or via research subagents whose specific findings I independently spot-verified — `dispatch()`→`dispatchEnvelope()`→`applyOutboundPolicy()` chain, the escalated-thread gate's "applies to replies too" behavior, and the `tenant_email_capture_ack_sms` transport were all traced first-hand).

## Send-site table

| kind (or UNDECLARED) | audience | channel | trigger | composer | transport + file:line | applyOutboundPolicy? | escalated-thread aware? | suppression/consent checked? | emits AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `tour_confirmation_email` | prospect | email | inbound tour state machine, `src/lib/domain/leasing/tour/process-tour.ts:777,1333,1427` (booking/reply-confirm/reply-reschedule branches); exposed to Clara's tool set via `agents/clara/lib/agent/capabilities/leasing.ts` | template string, `agents/clara/lib/email/tour-email-actions.ts:150,183` (`buildTourReplyPlainText`/`buildTourReplyHtml`) | `sendPropertyEmail()` — `agents/clara/lib/email/tour-email-actions.ts:175` | no — `sendPropertyEmail` never calls it; only SMS `dispatch()` does | no | yes — ADR-0092 `checkSuppression`, `agents/clara/lib/email/inbox-client.ts:871,1101` / `src/lib/integrations/email/client.ts:988` | no | **CANNED** | Even though a Clara tool call can trigger it mid-turn, the email itself is template output that never enters the safety pipeline or gets traced. |
| `tour_confirmation_sms` | prospect + household fan-out | sms | voice call-ended webhook (`src/app/api/voice/call-ended/route.ts:222-223`) / post-transfer (`agents/clara/lib/voice/post-transfer.ts`) → `sendTourOutcomeSms()`, `src/lib/domain/leasing/tour/send-tour-outcome-sms.ts:120` | template, `buildTourOutcomeSms` (`send-tour-outcome-sms.ts:87-113`) | `dispatch()` — `send-tour-outcome-sms.ts:236` (principal), `:325` (fan-out) | yes — via `dispatch()`→`dispatchEnvelope()`→`applyOutboundPolicy()` (`agents/clara/lib/messaging/dispatch-envelope.ts:425`) | yes, but exempted — `earnedReceipt: true` set (`:243,332`), so it still delivers even on an escalated thread | yes — `dispatchEnvelope` consent step | no | **CANNED** | |
| `tour_proposal_sms` | — | — | **DEAD KIND** — producer route removed 2026-08-13 | — | — | — | — | — | — | **N/A** | Confirmed via `src/lib/domain/messaging/outbox/tour-outbox.ts:39-46` and `src/app/api/leasing/prospects/[id]/route.ts:99-101` comments; only survives in type unions/tests. |
| `tour_reschedule_sms` | — | — | **DEAD KIND** — same removal, same citation | — | — | — | — | — | — | **N/A** | |
| `tour_auto_reply_email` | — | — | **DEAD KIND** — removed PR #4978 / ADR-0119 §2.4, superseded by `lambda/inbound-processor` | — | — | — | — | — | — | **N/A** | `agents/clara/lib/email/tour-email-actions.ts:1-27` header documents the removal. |
| `tour_reminder_sms` | prospect + household | sms | Temporal activity `sendTourReminder`, `src/lib/temporal/activities/tour.ts:196` | template, `buildReminderSms` (`src/lib/domain/leasing/tour-reminder.ts`) | `dispatch()` — `tour.ts:361` (fan-out `:142`) | yes | yes, exempted — `earnedReceipt: true` (`tour.ts:369`) | yes | no | **CANNED** | |
| `tour_reminder_t1h_sms` | prospect + household | sms | Temporal activity `sendTourReminderT1h`, `tour.ts:473` | template, `buildReminderT1hSms` (`tour-reminder.ts`) | `dispatch()` — `tour.ts:639/657` | yes | yes, exempted (`earnedReceipt: true`, `tour.ts:666`) | yes | no | **CANNED** | |
| `application_link_email` | prospect | email | Temporal activity `sendApplicationLink` (`tour.ts:724`) → `sendPostTourApplicationLink()` (`src/lib/domain/leasing/send-application-link.ts:123`); voice variant `sendPostCallApplicationLink()` (`:1027`) | template, `buildApplicationLinkEmail` (`send-application-link.ts:515`) | `sendPropertyEmail()` — `send-application-link.ts:632` | **no** | **no** | yes — ADR-0092 `checkSuppression`, plus an ADR-0119 outbound-log conversation row (`resolveProspectConversationRecord`, `:624`) | no | **CANNED** | This is the incident kind from the brief. Confirmed: no trace, no escalation gate — but it does still check suppression and write a conversation row (not "no checks at all"). |
| `application_link_sms` | prospect | sms | same activity, SMS branch | template, `buildApplicationLinkSms` (`send-application-link.ts:419`) | `dispatch()` — `:486` (fan-out `:405`; post-call variant `:1094`) | yes | yes | yes | no | **CANNED** | |
| `tour_confirm_nudge_sms` | prospect | sms | Temporal activity `sendTourConfirmationNudge`, `src/lib/temporal/activities/tour-confirm-nudge.ts:283` (tourWorkflow `awaiting_confirm` phase) | template, `buildTourConfirmNudgeSms` (`src/lib/domain/leasing/tour-confirm-nudge.ts:23-40`) | `dispatch()` — `tour-confirm-nudge.ts:476` | yes | yes — no `earnedReceipt`, so an escalated thread WOULD suppress this one (it's a chase, not a receipt) | yes — explicit `checkSuppression` in-activity (`:407-416`) plus `dispatchEnvelope` consent | no | **CANNED** | |
| `tour_confirm_nudge_email` | prospect | email | same activity, email branch (`:503-590`) | template, `buildTourConfirmNudgeEmail` (`tour-confirm-nudge.ts` domain `:47-65`) | `sendProspectEmailTouch()` — `tour-confirm-nudge.ts:568` → `send-prospect-email-touch.ts:130` → MS Graph `sendReply` | **no** | **no** | yes — dual gate: activity `checkSuppression` + inbox client's own `category:'outreach'` suppression | no | **CANNED** | |
| `post_tour_chase_sms` | prospect + household | sms | Temporal activity `sendPostTourApplicationChase`, `src/lib/temporal/activities/post-tour-chase.ts:522` | template, `buildPostTourChaseSms` (`src/lib/domain/leasing/post-tour-chase.ts:29-41`) | `dispatch()` — `:506` (party), `:743` (principal) | yes | yes — no `earnedReceipt`, suppressible on escalation | yes — explicit `checkSuppression` + `dispatchEnvelope` | no | **CANNED** | |
| `post_tour_chase_email` | prospect | email | same activity, email branch (kind `:842`) | template, `buildPostTourChaseEmail` (`post-tour-chase.ts:47-58`) | `sendProspectEmailTouch()` — `:858` | **no** | **no** | yes (dual gate as above) | no | **CANNED** | |
| `inquiry_cadence_touch_sms` | prospect | sms | `outreach-trigger.ts:233` → Temporal `prospect-outreach-workflow.ts` → activity `src/lib/temporal/activities/prospect-outreach.ts` | **LLM (Haiku)** — `generateFollowUpMessage()`, `src/lib/domain/leasing/generate-followup-message.ts:32`, called at `prospect-outreach.ts:692` — a standalone model call, NOT the Clara loop | `dispatch()` — `prospect-outreach.ts:727` | yes | yes | yes — `checkSuppression` (`:521`) + channel/consent resolution (`resolveOutreachChannel`) + `dispatchEnvelope` | no | **CLARA-COMPOSED-BUT-OUTSIDE-LOOP** | Text is model-generated but never touches conversation-manager's safety pipeline (no fair-housing/promise-backing/hallucination-guard checks, no trace). |
| `inquiry_cadence_touch_email` | prospect | email | same activity, email branch | same `generateFollowUpMessage()` (`prospect-outreach.ts:841`) | `sendProspectEmailTouch()` — `:878` → `agents/clara/lib/email/inbox-client.ts:824` | **no** | **no** | yes — `checkSuppression` + `checkTouchBudget` inside `sendReply` (`inbox-client.ts:871,886`) | no | **CLARA-COMPOSED-BUT-OUTSIDE-LOOP** | Weaker gate than the SMS sibling — no escalated-thread check at all. |
| `reengagement_nudge_sms` | prospect | sms | `reengagement-trigger.ts:73` → Temporal `prospect-reengagement-workflow.ts` → activity `src/lib/temporal/activities/prospect-reengagement.ts` | **LLM (Haiku)** — same `generateFollowUpMessage()`, called `prospect-reengagement.ts:590` | `dispatch()` — `:613` | yes | yes | yes | no | **CLARA-COMPOSED-BUT-OUTSIDE-LOOP** | |
| `reengagement_nudge_email` | prospect | email | same activity, email branch | `generateFollowUpMessage()` (`:692`) | `sendProspectEmailTouch()` — `:725` | **no** | **no** | yes | no | **CLARA-COMPOSED-BUT-OUTSIDE-LOOP** | |
| `first_reply_followup_sms` / `first_reply_followup_email` | — | — | **UNKNOWN/DEAD** — no live send call site found repo-wide | — | — | — | — | — | — | **could not classify** | Only referenced in type unions, `kind-category.ts`, and tests. A decommissioned-Lambda snapshot exists (`.claude/decommission-snapshots/first-reply-followup-20260430T175858Z/`), consistent with a retired sender whose kind string was never cleaned up. |
| `scheduling_engaged_followup_sms` / `scheduling_engaged_followup_email` | — | — | **UNKNOWN/DEAD** — no live send call site found | — | — | — | — | — | — | **could not classify** | Comments in `prospect-outreach.ts:448` and `src/lib/domain/admin/arms-registry.ts:511` reference a `scheduling-engaged-walker.ts` file that does not exist in the repo. Possibly a stale/never-shipped kind, or a sender living outside this repo. |
| `voice_followup_sms` | prospect + household | sms | same voice call-ended/post-transfer path as `tour_confirmation_sms`, cancellation branch of `sendTourOutcomeSms` (`send-tour-outcome-sms.ts:219`, fan-out `:347`) | template, `buildTourOutcomeSms` | `dispatch()` — `:219`, `:347` | yes | yes, exempted (`earnedReceipt: true`) | yes | no | **CANNED** | |
| `on_behalf_followup_email` | prospect (on-behalf principal) | email | post-tour application-link processor, on-behalf loop-close branch — `send-application-link.ts:772` (kind), `:780` (send) | template (`:728-786`) | `sendPropertyEmail()` — `:780` | **no** | **no** | yes — `checkSuppression` | no | **CANNED** | |
| `on_behalf_followup_sms` | prospect (on-behalf principal) | sms | same processor, SMS branch — `:840` (kind), `:846` (dispatch) | template (`:798-855`) | `dispatch()` — `:846` | yes | yes — no `earnedReceipt`, suppressible | yes | no | **CANNED** | |
| `new_lease_approved_sms` | applicant/prospect | sms | `POST /api/new-lease-package-reviews/[id]/decision` route, PM-approve branch (human-triggered) | template — pre-composed **at review-creation time** by `buildApprovedApplicantCongratsSms()` (`src/lib/domain/leasing/new-lease/congrats-message.ts:44-64`); sent verbatim later, not re-rendered | `dispatch()` — `route.ts:402` | yes | yes | yes | no | **CANNED** | `on-application-approved.ts` itself sends nothing — its header states the review-item creation sends nothing; only the decision route's approve branch sends. |
| `new_lease_approved_email` | applicant/prospect | email | same route, email branch | template (subject/HTML re-rendered; plain-text body is the reviewer-approved draft verbatim) — `route.ts:432-455` | **no** | **no** | yes | no | **CANNED** | |
| `tenant_email_capture_ack_sms` | tenant | sms | pre-Clara deterministic inbound-SMS rung intercepting a tenant's reply to a mass-comms "what's your email?" ask — `agents/clara/lib/messaging/handle-tenant-email-capture.ts`, invoked from `agents/clara/lib/messaging/inbound-router.ts:289`, routed before reaching the agent loop | template — hardcoded ack strings, `handle-tenant-email-capture.ts:~370-372` | `dispatch(sender, reply, {isReply: true, ...})` — I traced this myself past what the subagent flagged as "unknown": the router's `{reply}` return is delivered by `agents/clara/lib/messaging/process-envelope.ts:358`, via `dispatch()`, not a bypassing synchronous TwiML response | **yes, in reply mode** — `isReply: true` skips only the CONSENT check + global SMS kill-switch (`dispatch-envelope.ts:207-217`); stress/test drops and the escalated-thread gate still run | **yes** — the escalated-thread gate explicitly "applies ALWAYS, replies included" (`dispatch-envelope.ts:257-263`) | **no**, deliberately — `isReply: true` skips the TCPA consent check for this send (comment: "tenant initiated; STOP via reply is their unsubscribe path") | no | **CANNED** | Production SMS webhook is `lambda/inbound-processor` (built bundle imports `inbound-dispatcher`), not a `src/app/api` route. |
| `bounce_fallback_sms` | prospect | sms | email-bounce webhook (`src/lib/integrations/email/webhook-processors.ts`) → `handleEmailBounce()`, `agents/clara/lib/email/ndr-bounce.ts:270` | template — deterministic re-render/flattening of the bounced email content, not LLM | `dispatch()` — `ndr-bounce.ts:418` | yes | yes — no `earnedReceipt`, suppressible | yes — hardened deliberately: `suppressionCategory:'outreach'`, no `isReply` set, so the full TCPA opt-out check runs even though this "re-delivers" an earned reply (comment `:426-431`: this is a first proactive text to a number never used for SMS) | no | **CANNED** | |
| `pm_tour_confirmation_email` (exempt) | PM/staff | email | `src/lib/domain/leasing/send-pm-tour-confirmation.ts:179` | template | `sendEmail()` direct (SendGrid) — `:179`, `internalRecipient: true` | no | no | **no** — explicitly `ADR-0092: PM recipient — not opt-out-suppression-checked` (`:180`) | no | **CANNED** (PM-facing) | |
| `pm_tour_cancellation_email` (exempt) | PM/staff | email | `send-pm-tour-cancellation.ts` | template | `sendEmail()` direct, same shape | no | no | no | no | **CANNED** (PM-facing) | |
| `pm_tour_update_email` (exempt) | PM/staff | email | `send-leasing-team-tour-update.ts` | template | `sendEmail()` direct, same shape | no | no | no | no | **CANNED** (PM-facing) | |
| **UNDECLARED** | PM/staff | email | virtual-tour call notification — `src/lib/domain/leasing/notify-staff-virtual-tour-call.ts:71` | template | `sendEmail()` direct — `:71`, **no `kind:` param at all** | unknown (no kind → touch-budget defaults to `outreach` grading per the brief's known-facts) | unknown | unknown — need `sendEmail()` internals traced further | no | **CANNED (likely)** | Staff-facing, not prospect/tenant, so lower severity than the incident kind, but it is a real undeclared send site under `src/lib/domain/leasing/`. |

## Findings

1. **Confirmed incident kind, `application_link_email`, is representative of a whole pattern, not a one-off.** Every leasing kind that has an email leg (`tour_confirmation_email`, `application_link_email`, `tour_confirm_nudge_email`, `post_tour_chase_email`, `inquiry_cadence_touch_email`, `reengagement_nudge_email`, `on_behalf_followup_email`, `new_lease_approved_email`) sends via `sendPropertyEmail()` or `sendProspectEmailTouch()` → Microsoft Graph, and **none of these call `applyOutboundPolicy` or get an escalated-thread check.** They all still run ADR-0092 suppression/consent, just a completely different gate. This is a systemic asymmetry: an escalated tenant/prospect thread can still receive proactive leasing email while SMS on the identical thread would be blocked.

2. **Two kinds are declared and graded in `kind-category.ts` but have zero live producers**: `first_reply_followup_sms/email` and `scheduling_engaged_followup_sms/email`. The former traces to a decommissioned Lambda snapshot; the latter references a file (`scheduling-engaged-walker.ts`) that doesn't exist anywhere in the repo.

3. **Three more kinds are dead but for a different reason** — the producing routes were deliberately deleted 2026-08-13/PR #4978 (`tour_proposal_sms`, `tour_reschedule_sms`, `tour_auto_reply_email`), documented in code comments, but the kind strings were never removed from the shared type unions or `kind-category.ts`. Not a bug, just stale surface area — worth a cleanup pass so a future grep doesn't assume they're live.

4. **No leasing/tour/prospect send site in this slice is CLARA-LOOP.** Nothing here composes inside `agents/clara/lib/agent/conversation-manager.ts` or emits an `AgentTrace` row — every kind is either a hardcoded template (Temporal activity / cron / webhook / route) or a standalone LLM call (`generateFollowUpMessage`) invoked from a Temporal activity, entirely outside the loop and its safety pipeline (no fair-housing check, no promise-backing check, no hallucination guard, no anomaly review gate on the cadence/reengagement LLM copy).

5. **Recordless-adjacent send**: `pm_tour_confirmation_email`/`pm_tour_cancellation_email`/`pm_tour_update_email` explicitly opt out of suppression checking (`internalRecipient: true`, "PM recipient — not opt-out-suppression-checked") — correct for an internal operator, but they're sent via direct `sendEmail()`, completely outside both `applyOutboundPolicy` and the ADR-0092 email suppression gate that every other email leg in this table gets. If a PM's email ever doubled as a real tenant/prospect address (edge case), nothing here would catch it.

6. **One real undeclared send site found**: `notify-staff-virtual-tour-call.ts:71` calls `sendEmail()` directly with no `kind:` parameter — under the brief's known facts this defaults to `outreach` touch-budget grading. Staff-facing, so lower stakes than a prospect-facing undeclared send, but still worth a kind assignment for audit trail cleanliness.

7. **`tenant_email_capture_ack_sms` is not the mystery it first looked like.** A subagent flagged its transport as unresolved; I traced it further to `agents/clara/lib/messaging/process-envelope.ts:358`, which calls `dispatch(sender, reply, {isReply: true, ...})` for every `routeInboundMessage` result. So it DOES pass through `applyOutboundPolicy` — but `isReply: true` deliberately skips the TCPA consent check and the global SMS kill-switch (by design, since it's a same-thread reply), while the escalated-thread gate and stress/test drops still apply.

8. **`earnedReceipt: true` is a real, deliberate, and consistently-applied exemption** — used on `tour_confirmation_sms`, `tour_reminder_sms`, `tour_reminder_t1h_sms`, and `voice_followup_sms` (all "you asked for this specific update" sends) so they still deliver even on an escalated thread. Chase/nudge/cadence kinds (`tour_confirm_nudge_*`, `post_tour_chase_*`, `on_behalf_followup_sms`, `inquiry_cadence_touch_*`, `reengagement_nudge_*`, `bounce_fallback_sms`) deliberately do NOT set it, so those correctly go silent on an escalated thread.

### Count by PATH
- **CANNED**: 25 rows (tour_confirmation_email, tour_confirmation_sms, tour_reminder_sms, tour_reminder_t1h_sms, application_link_email, application_link_sms, tour_confirm_nudge_sms, tour_confirm_nudge_email, post_tour_chase_sms, post_tour_chase_email, voice_followup_sms, on_behalf_followup_email, on_behalf_followup_sms, new_lease_approved_sms, new_lease_approved_email, tenant_email_capture_ack_sms, bounce_fallback_sms, pm_tour_confirmation_email, pm_tour_cancellation_email, pm_tour_update_email, plus the UNDECLARED staff notification — 21 declared + 1 undeclared, listed as 25 table rows counting fan-out variants noted inline)
- **CLARA-COMPOSED-BUT-OUTSIDE-LOOP**: 4 (inquiry_cadence_touch_sms, inquiry_cadence_touch_email, reengagement_nudge_sms, reengagement_nudge_email)
- **CLARA-LOOP**: 0
- **DEAD / no live sender**: 7 kind-slots (tour_proposal_sms, tour_reschedule_sms, tour_auto_reply_email, first_reply_followup_sms, first_reply_followup_email, scheduling_engaged_followup_sms, scheduling_engaged_followup_email)
- **Could not fully classify**: 1 undeclared send site (`notify-staff-virtual-tour-call.ts`) — transport confirmed, gates not fully traced
Renewals / collections / promises / mass comms — full evidence table
# Renewals / Collections / Promises / Mass Comms — Outbound Path Audit

Repo: `/Users/federicochapa/.claude/propflowai` (read-only, no edits made)

| kind (or UNDECLARED) | audience | channel | trigger | composer | transport fn + file:line | applyOutboundPolicy? | escalated-thread aware? | suppression/consent checked? | AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `renewal_outreach_sms` | tenant | SMS | Temporal activity `sendOutreachSms` — `src/lib/temporal/activities/renewal.ts:790-865` | template — `buildRenewalSmsBody()` | `dispatch()` `agents/clara/lib/messaging/dispatcher.ts:265` (call site `renewal.ts:840`) | yes — SMS always routes through `dispatchEnvelope`→`applyOutboundPolicy` | yes — conversation record resolved before send (`resolveOutreachConversationRecord`), so escalated-thread gate reads a real conversationId | yes — `checkSuppression` inside `dispatchEnvelope`, plus `isRenewalOutreachKind` opt-out scope | no | CANNED | — |
| `renewal_outreach_email` | tenant | email | Temporal activity `sendOutreachEmail` — `renewal.ts:1400-1441` | template — `buildRenewalEmailPlainText`/HTML ctx | `sendRenewalEmail()` → `sendPropertyEmail()` `src/lib/integrations/email/property-graph-sender.ts:208` | **no** — `property-graph-sender.ts` never calls `applyOutboundPolicy` (confirmed, matches brief's known fact) | **no** | yes — `checkSuppression` runs inside the SendGrid-fallback leg (`src/lib/integrations/email/client.ts:988`); Graph leg's own suppression is separate | no | CANNED | Email legs for this whole family never see the escalated-thread gate — a human-owned thread does not stop a renewal outreach email. |
| `renewal_letter_mms_sms` | tenant | SMS + MMS | Temporal activity — `src/lib/domain/leasing/renewal-orchestration/send-renewal-letter-mms.ts:130-212` | template — `buildRenewalSmsBody()`; PDF letter is a rendered doc, not LLM text | prose leg: `dispatch()` (`send-renewal-letter-mms.ts:159`); PDF leg: `sendMms()` (`:212`) | yes (prose leg, via `dispatchEnvelope`); MMS leg passes `isRenewalOutreach:true` explicitly since MMS isn't a templated `OutboundMessageKind` consumer of the standard table | yes (prose leg) | yes, both legs | no | CANNED | Two-message send; if the SMS leg is refused, MMS is skipped entirely (same gate, same phone). |
| `renewal_reminder_sms` / `renewal_expiry_nudge_sms` | tenant | SMS | Temporal activity `sendReminderSms` — `renewal.ts:1080-1170` | template — `buildRenewalNudgeSmsBody` / `buildRenewalSecondNudgeSmsBody` / `buildRenewalExpiryNudgeSmsBody` | `dispatch()` (`renewal.ts:1132`) | yes | yes | yes | no | CANNED | — |
| `renewal_expiry_nudge_sms` (2nd sender — MtM warning) | tenant | SMS | Cron scan `processRenewalAutoStart` → `sendScanMtmWarning` — `src/lib/domain/leasing/renewal-auto-start.ts:1652-1830` | template — `buildRenewalExpiryNudgeSmsBody()` | `dispatch()` (`renewal-auto-start.ts:1804`) | yes | yes | yes — plus its own dry-run + quiet-hours holds on top | no | CANNED | Two independent senders exist for this one kind. |
| `renewal_call` | tenant | voice (ElevenLabs) | Temporal activity `dispatchVoiceCall` — `renewal.ts:1776-1970`, dialing the dedicated "Renewal Outbound" ElevenLabs agent | **N/A at dial time** (scripted dial, dynamic vars only); once connected, **the in-call turns are composed by ElevenLabs' own agent/prompt**, not `conversation-manager.ts` | `initiateOutboundCall()` `src/lib/integrations/voice/initiate-outbound-call.ts` (dial), separate from `dispatch()`/envelope entirely | **no** — voice never goes through `dispatchEnvelope` | **no** — no envelope, no conversationId at dial time | manual `checkSuppression(..., 'voice', 'outreach', { isRenewalOutreach: true })` at `renewal.ts:1862`, done **outside** the standard door | no — transcript row is written post-hoc by the ElevenLabs `call-ended` webhook (`src/app/api/voice/call-ended/route.ts:775`), tagged `kind: 'renewal_call'`; that write is a conversation MSG row, not a `TRACE#` row | dial: CANNED; live conversation content: CLARA-COMPOSED-OUTSIDE-LOOP | **Biggest structural gap in this slice.** The renewal outbound (and inbound-transfer) voice agent is architecturally separate from the text `conversation-manager.ts` loop the brief defines as CLARA-LOOP — no fair-housing/hallucination-guard/promise-backing pipeline, no escalated-thread gate, no AgentTrace, on the entire outbound renewal call. Whether ElevenLabs' own agent has an equivalent safety layer is outside this grep and should be checked by a voice-focused pass. |
| `renewal_call_recap_sms` | tenant | SMS | fired from two places: ElevenLabs call-ended webhook, and Twilio call-status poll on the renewal route (busy/failed/no-answer/canceled) — `src/lib/domain/leasing/renewal-orchestration/recap-sms.ts` | template — `buildRenewalSmsBody()` + fixed "text me back" suffix | `dispatch()` (`recap-sms.ts:220-237`) | yes | yes (`conversation.id` already in hand) | yes | no | CANNED | — |
| `renewal_change_term_sms` | tenant | SMS | webhook route `PATCH /api/leases/[leaseId]/renewal` (agent-runtime SQS callback after Clara's async term-change tool re-prepares the AppFolio offer) — `src/app/api/leases/[leaseId]/renewal/route.ts:787-900`, fired from `:499` | template — `buildChangeTermConfirmationBody()` | `dispatch()` (`route.ts:881`) | yes | yes | yes | no | CANNED | Confirms a change Clara promised live in conversation, but the confirmation itself ships from an async callback route, not the loop. |
| `renewal_portal_link_sms` | tenant | SMS | **Clara tool call, mid-conversation** — `handle_send_portal_link_sms` → `handleSendPortalLinkSmsImpl` → `sendTenantSms()` — `agents/clara/lib/agent/tools/renewal/handlers.ts:1083-1149` | **hardcoded template string**, not LLM-composed (`` `Hi ${greetFirst}, here's the sign-in link…` ``) | `dispatch()` (`handlers.ts:322`) | yes | yes | yes | no (tool-call audit via `tracedClaraHandler`/`recordToolCall` — a different, separate audit row, not `TRACE#`) | **neither cleanly CANNED nor CLARA-LOOP** — flagged explicitly below | Body is a fixed template, invoked by Clara's tool-call decision mid-conversation, but it is dispatched directly from the tool handler and never passes through `conversation-manager.ts`'s post-generation safety pipeline (no fair-housing/hallucination-guard/promise-backing check on this specific string) and does not emit an `AgentTrace` row — only a tool-call audit row. Recommend classifying this as CANNED-BUT-LOOP-TRIGGERED, a fourth shape the brief's three buckets don't name cleanly. |
| `renewal_offer_recap_sms` | tenant | SMS | same shape — Clara tool `handle_send_offer_sms` → `handleSendOfferSmsImpl` → `sendTenantSms()` — `handlers.ts:1157-1223` | hardcoded template string (rent/term/lease-end computed server-side, not by the LLM) | `dispatch()` (`handlers.ts:322`) | yes | yes | yes | no | same hybrid as above | Same nuance as `renewal_portal_link_sms`. Also the ONLY entry point in the whole `renewal_portal_link_sms`/`renewal_offer_recap_sms` family — voice tools are "statically bound" to the same handlers per the file's own comment, so a tenant could also trigger this over a live renewal call, same gap. |
| `collections_reminder_sms` | tenant | SMS | HUMAN-GATED: composed by Temporal activity `runCollectionsChaseActivity` (`src/lib/temporal/activities/collections-chase.ts:1069-1268`), captured to a `/review` queue, then only actually sent when a PM clicks Approve → `POST /api/collections-dun-reviews/[id]/decision` → `textApprovedCollectionsDun()` — `src/lib/domain/leasing/collections/approved-dun-send-ops.ts:391-462` | template — `buildCollectionsSmsBody()` (also passes an **LLM legal-compliance audit**, `reviewCollectionsMessage()`, before it's even offered to the PM for review) | `dispatch()` (`approved-dun-send-ops.ts:462`) | yes | yes | yes — plus per-property quiet hours re-checked at send time | no | CANNED | Explicitly and deliberately human-blocked by design (Gera, 2026-08-06 quote in the code): the composing activity has no `dispatch` in its module graph at all — approval is the *only* path to a phone. |
| `promise_fulfillment_sms` | tenant (caller who received a Clara promise) | SMS | Temporal workflow `promiseWorkflow` (signalled on `call_ended` or 6h `quiet_period` after a conversation) → `runPromiseLedgerActivity` → `decideFulfillment`/`fulfill.ts` — 3 send sites: `src/lib/domain/promises/fulfill.ts:567` (unfulfillable-honest-text), `:797` (amounts follow-up), `:997` (main send_text) | template — `composeUnfulfillableCallerSms`/`composeAmountsFollowUpSms`/`composeFulfillmentSmsBody` (`src/lib/domain/promises/compose.ts`), pure functions, no LLM | `dispatch()` (`fulfill.ts:559/793/993`) | yes | yes (`conversationId` already resolved from the original conversation) | yes, **plus** a bespoke `guardLedgerText()` safety pass (PII strip, dollar-figure grounding against the promise's own recorded payload) that partially mirrors — but is NOT — the conversation-manager pipeline | no | CANNED (with its own separate, non-loop safety guard) | `extractPromises()` (LLM extraction of what Clara promised from the finished transcript) happens earlier in the same workflow but is not itself a send. |
| `promise_fulfillment_email` | tenant | email | same workflow, `send_email` branch — `fulfill.ts:1022-1070` | template — `composeFulfillmentEmailText()` | `sendEmail()` `src/lib/integrations/email/client.ts` | **no** — email path never calls `applyOutboundPolicy` | **no** | yes — `checkSuppression` inside `client.ts:988` | no | CANNED | Same email-bypasses-escalated-gate gap as `renewal_outreach_email`. |
| `mass_comms_blast_sms` | tenant/resident | SMS | Temporal activity `sendMassCommsBatch`-style loop — `src/lib/temporal/activities/mass-comms.ts:440-500`, kicked off from `POST /api/mass-sends/[id]` (PM approves a drafted blast) | **human-authored** — "the PM authored this body themselves, in one language" (code comment, `mass-comms.ts:490`) — not LLM, not a fixed template | `dispatch()` (`mass-comms.ts:487`) | yes | yes (per-recipient conversation resolved first) | yes — "Consent-gated send — NEVER skipConsentCheck" (code comment) | no | CANNED | `kind` isn't passed in `dispatch()` options directly — it rides the pre-built `record` and is defaulted via `kindFromRecord()` inside the envelope builder (`dispatcher.ts` — `kind: options?.kind ?? kindFromRecord(record)`). Still lands as `mass_comms_blast_sms` correctly; flagged because it's an indirect declaration, not a direct one. |
| `mass_comms_pm_notify_sms` | **PM (staff)**, not tenant | SMS | same blast completion flow, `deliverPmRecap()` — `mass-comms.ts:634-700` | template — hand-built recap string (e.g. "Done — sent your message to N residents") | **direct Twilio client call** `sendSms()` `src/lib/integrations/twilio/client.ts`, called with `{ internalRecipient: true }` — **bypasses `dispatch()`/`dispatchEnvelope` entirely** | no — never reaches the envelope path | n/a — internal recipient | n/a — exempt kind, staff | no | CANNED (raw-transport variant) | Correctly exempt since it's an internal PM recap, but worth naming: this is the only sender in the slice that skips the standard door altogether rather than being exempted *by* the door. |

## Findings

**Path counts (rows above, one per send site):**
- CANNED: 12 (renewal_outreach_sms, renewal_outreach_email, renewal_letter_mms_sms, renewal_reminder/expiry_nudge_sms ×2 senders, renewal_call_recap_sms, renewal_change_term_sms, collections_reminder_sms, promise_fulfillment_sms, promise_fulfillment_email, mass_comms_blast_sms, mass_comms_pm_notify_sms)
- CANNED-BUT-LOOP-TRIGGERED (a shape the brief's three buckets don't cleanly name — Clara's tool call decides to send, but the body is a hardcoded template dispatched outside the post-generation safety pipeline and without an AgentTrace row): 2 (renewal_portal_link_sms, renewal_offer_recap_sms)
- CLARA-COMPOSED-OUTSIDE-LOOP: 1 (renewal_call — the live voice conversation content itself, composed by a separate ElevenLabs agent, not `conversation-manager.ts`)
- Dial-only CANNED trigger with no AgentTrace/no envelope: 1 (dispatchVoiceCall itself, counted above under renewal_call's dial leg)
- CLARA-LOOP (full definition: composed in conversation-manager.ts + safety pipeline + AgentTrace): **0** — nothing in this entire slice (renewals/collections/promises/mass comms) goes through the actual Clara text loop and emits an AgentTrace row. Every tenant-facing send in this domain is either a template dispatched from a Temporal activity/API route, or a hardcoded string dispatched from a tool-call handler that sits outside the safety pipeline.

**Most surprising / most consequential:**

1. **No `AgentTrace` row exists anywhere in renewals, collections, promises, or mass comms.** Every send in this domain writes a `Conversation`/message row (via `dispatch()`'s ADR-0119 record) but never a `TRACE#` row — those are exclusively written by `conversation-manager.ts`. If "emits AgentTrace" is being used anywhere as a proxy for "went through Clara's loop," this entire slice fails that test even though most of it is well-gated by `dispatch()`'s policy/consent/suppression checks.

2. **All email sends in this slice (`renewal_outreach_email`, `promise_fulfillment_email`) skip the escalated-thread gate entirely.** Confirmed directly: `applyOutboundPolicy` is defined and called only inside `agents/clara/lib/messaging/dispatch-envelope.ts`, and neither `src/lib/integrations/email/property-graph-sender.ts` nor `src/lib/integrations/email/client.ts` calls it. A human who has taken over a thread by email can still have a renewal outreach or promise-fulfillment email land in that same thread.

3. **`renewal_call` — the entire outbound renewal voice call — sits outside every guardrail this audit checks for on the way in, and outside the standard door on the way out.** The dial (`dispatchVoiceCall`) never touches `dispatch()`/`dispatchEnvelope`/`applyOutboundPolicy`; it does a manual `checkSuppression` call instead. Once connected, the conversation itself is run by a dedicated ElevenLabs "Renewal Outbound" agent — a system separate from `conversation-manager.ts` — so whatever fair-housing/hallucination-guard/promise-backing protections exist on the text loop do not automatically apply to what gets said on that call. The transcript is only written after the fact, by the ElevenLabs `call-ended` webhook, as a plain conversation row (not a trace).

4. **`renewal_portal_link_sms` and `renewal_offer_recap_sms` are recordless-until-tool-call, hardcoded-template sends triggered by Clara's own tool-call decision but shipped outside the post-generation safety pipeline.** They're well-behaved on compliance (dispatch, suppression, escalated-thread, consent all apply) but the actual SMS text a resident receives from these two tools never passes fair-housing/hallucination-guard/promise-backing review — because it's a fixed template, that's low-risk today, but it means "any tool Clara can call mid-conversation" is not equivalent to "any text going out is safety-reviewed."

5. **Two independent senders exist for `renewal_expiry_nudge_sms`** (the reminder ladder in `renewal.ts` and the separate MtM-warning scan in `renewal-auto-start.ts`) — both templated, both correctly gated, but worth knowing there are two code paths that can produce the same kind.

6. **`collections_reminder_sms` is the one lane in this slice that is deliberately, structurally human-gated** — the composing Temporal activity has no `dispatch` import in its module graph at all; only PM approval via the review-queue route can put a dun on a phone. This is the cleanest lane in the audit.

7. **No sender in this slice was found to declare NO kind at all** (i.e., no UNDECLARED/recordless-with-no-kind tenant-facing sends were found among the 14 target kinds) — the one near-miss, `mass_comms_blast_sms`, does get its kind via record-derivation inside `dispatch()` rather than an explicit `kind` option, which is a minor indirection but not a gap.

8. **Recordless sends:** none found as genuinely unrecorded — every send site resolves a real conversation record (or `noConversationRecord` with a stated reason) before dispatching. The nearest thing to "recordless" is the renewal voice DIAL itself, whose only durable record is the post-call transcript webhook — if that webhook never fires (dropped call, ElevenLabs outage), the dial-out itself leaves no trace at all beyond `lease.voiceDispatch`/logs.

**Files read (paths for follow-up):**
`src/lib/domain/compliance/touch-budget/kind-category.ts`, `src/lib/temporal/activities/renewal.ts`, `src/lib/temporal/activities/renewal-auto-start.ts`, `src/lib/temporal/activities/renewal-cohort-walker.ts`, `src/lib/temporal/activities/collections-chase.ts`, `src/lib/temporal/activities/collections-walker.ts`, `src/lib/temporal/activities/mass-comms.ts`, `src/lib/temporal/activities/promise.ts`, `src/lib/temporal/activities/pm-action-reminder-renewal-bridge.ts`, `src/lib/domain/leasing/renewal-orchestration/{append-reminder-to-conversation,record-outreach-conversation,send-renewal-email,send-renewal-letter-mms,initiate-renewal-call,recap-sms}.ts`, `src/lib/domain/leasing/renewal-auto-start.ts`, `src/lib/domain/leasing/collections/approved-dun-send-ops.ts`, `src/lib/domain/promises/{fulfill,compose,extract-promises}.ts`, `src/app/api/leases/[leaseId]/renewal/route.ts`, `src/app/api/collections-dun-reviews/[id]/decision/route.ts`, `src/app/api/mass-sends/[id]/route.ts`, `src/app/api/voice/call-ended/route.ts`, `agents/clara/lib/agent/tools/renewal/handlers.ts`, `agents/clara/lib/messaging/{dispatcher,dispatch-envelope}.ts`, `src/lib/integrations/email/{client,property-graph-sender}.ts`, `src/lib/domain/wo-tool-log/clara-trace.ts`, `agents/clara/lib/data/dynamo/helpers.ts`.
Maintenance / vendors / turnover / emergency — full evidence table
Here is the deliverable.

# Outbound audit — MAINTENANCE / VENDORS / TURNOVER / EMERGENCY slice

| kind (or UNDECLARED) | audience | channel | trigger | composer | transport function + file:line | passes applyOutboundPolicy? | escalated-thread aware? | suppression/consent checked? | emits AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `maintenance_tenant_relay` (status relay) | tenant | SMS | Temporal activity `composeStatusRelayActivity` (`src/lib/temporal/activities/maintenance-comms.ts:523`) | **LLM outside loop** — `composeViaHaiku` direct Anthropic `messages.create` (`maintenance-comms.ts:271-289`); deterministic template fallback | `dispatch()` at `src/lib/domain/vendors/handle-vendor-completion.ts:1072` (`relayMessageToTenant`) | yes — `dispatch` → `dispatchEnvelope` → `applyOutboundPolicy` (`agents/clara/lib/messaging/dispatch-envelope.ts:425`) | **yes** — `recipientType: 'tenant'` (`handle-vendor-completion.ts:1074`); gate runs when `recipientType !== 'vendor'` (`dispatch-envelope.ts:311`) | yes — consent gate `dispatch-envelope.ts:132`; `suppressionCategory:'transactional'` (`handle-vendor-completion.ts:1078`) | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | Haiku rewrites the tech's words with none of the conversation-manager safety pipeline. Deliberately NOT quiet-hours-held (`maintenance-comms.ts:518-520`) |
| `maintenance_tenant_relay` (resident relay / nudge) | tenant | SMS | Temporal activity `relayToResidentActivity` (`maintenance-comms.ts:360`) | **LLM outside loop** — composed in `answerOrRelayActivity` via `composeViaHaiku` (`maintenance-comms.ts:343`), stashed in scratch, prefixed `Just following up — ` for the nudge (`maintenance-comms.ts:384`) | `dispatch()` at `handle-vendor-completion.ts:1072` | yes (same path) | yes — `recipientType:'tenant'` | yes (same) | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | |
| `maintenance_tenant_relay` (availability slots) | tenant | SMS | Temporal activity `composeAvailabilityRelayActivity` (`maintenance-comms.ts:574`) | template string — deliberately NO LLM (numbered list is index-bearing, `maintenance-comms.ts:567-570`) | `dispatch()` at `handle-vendor-completion.ts:1574` (`relayAvailabilityToTenant`) | yes | yes — `recipientType:'tenant'` (`:1576`) | yes — `suppressionCategory:'transactional'` (`:1579`) | no | CANNED | |
| `maintenance_schedule_confirmed` | tenant | SMS | inline on vendor slot acceptance, `handle-vendor-completion.ts:1633` | template string (`handle-vendor-completion.ts:1633`) | `dispatch()` at `handle-vendor-completion.ts:1680` | yes | yes — `recipientType:'tenant'` (`:1682`) | yes — `'transactional'` (`:1685`) | no | CANNED | **Known gap in code**: sends INLINE, not quiet-hours-held, unlike its sibling availability relay (`handle-vendor-completion.ts:1644-1650`) — an 11pm acceptance texts the tenant immediately |
| `maintenance_handyman_relay` | vendor / in-house tech | SMS | Temporal activities `answerOrRelayActivity` (answered branch, `maintenance-comms.ts:335`) and `forwardAnswerToTechActivity` (`:445`) | **LLM outside loop** — `composeViaHaiku` (`maintenance-comms.ts:434`); the answered branch uses `answerVendorQuestion` (also an LLM, `maintenance-comms.ts:330`) | `dispatch()` at `handle-vendor-completion.ts:1166` (`relayMessageToHandyman`) | yes | **no — deliberately exempt**: `recipientType: 'vendor'` (`handle-vendor-completion.ts:1173`), gate skips per `dispatch-envelope.ts:311` | yes — consent gate; no `suppressionCategory` passed here → defaults to `outreach` at the choke point | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | Anchors on the handyman's own thread, else `noConversationRecord` (`:1160`) — a property with no resolvable handyman thread sends **recordless** |
| `maintenance_reminder` | in-house tech | SMS | Temporal activity `sendReminderHeadupActivity` (`maintenance-comms.ts:657`) | template string (`maintenance-comms.ts:665`) | `dispatch()` via `relayMessageToHandyman` → `handle-vendor-completion.ts:1166` | yes | no — `recipientType:'vendor'` exempt | consent yes; no suppressionCategory | no | CANNED | |
| `maintenance_completion_checkin` | in-house tech | SMS | Temporal activity `sendCompletionCheckInActivity` (`maintenance-comms.ts:688`) | template string (`maintenance-comms.ts:701`) | `dispatch()` via `relayMessageToHandyman` → `handle-vendor-completion.ts:1166` | yes | no — vendor-exempt | consent yes; no suppressionCategory | no | CANNED | |
| `maintenance_rating_prompt` | tenant | SMS | Temporal activity `sendRatingPromptActivity` (`maintenance-comms.ts:617`), quiet-hours-held by the workflow | template string `buildRatingPromptBody` (`src/lib/domain/maintenance/rating.ts:86-92`) | `dispatch()` at `src/lib/domain/maintenance/rating.ts:662` | yes | **unknown/likely gated** — no `recipientType` passed at `rating.ts:662`, so omission fails SAFE (gated) per `dispatcher.ts` docblock on `recipientType` | consent yes | no | CANNED | |
| `wo_pms_confirmation_sms` | tenant | SMS | API route PATCH `/api/work-orders/[displayId]` (`src/app/api/work-orders/[displayId]/route.ts:522`) | template string `formatTenantConfirmationSms` (`src/lib/domain/maintenance/notify-tenant-of-pms-creation.ts:66-84`) | `dispatch()` at `notify-tenant-of-pms-creation.ts:185` | yes | unknown — no `recipientType`; fails safe (gated) | consent yes; no suppressionCategory declared (`:185-195`) | no | CANNED | Second SMS of the two-step PMS flow; the FIRST ack is the real Clara loop. Can send with `allowWithoutConversation` for sync-imported WOs (`:60`) |
| `wo_handyman_dispatch_sms` (page, plain SMS) | in-house tech | SMS | API route PATCH `/api/work-orders/[displayId]` → `dispatchToMaintenanceTech` (`route.ts:543`); also Temporal `sendHandymanPageActivity` quiet-hours release (`maintenance-comms.ts:783`) | template string `formatTechDispatchSms` (`src/lib/domain/maintenance/tech-sms-dispatch.ts:156`) | `dispatch()` at `src/lib/domain/maintenance/tech-sms-dispatch.ts:1073` | yes | no — `recipientType:'vendor'` exempt (`:1073`) | yes — `suppressionCategory:'transactional'` (`:1073`) | no | CANNED | Record is `noConversationRecord` when no vendor thread resolves (`tech-sms-dispatch.ts:857`) → **recordless send** |
| `wo_handyman_dispatch_sms` (page, MMS primary) | in-house tech | MMS | same | template string + photo URL (`tech-sms-dispatch.ts:1101`) | `dispatch()` at `tech-sms-dispatch.ts:1101` | yes | no — vendor-exempt | yes — `'transactional'` | no | CANNED | |
| `wo_handyman_dispatch_sms` (MMS→SMS fallback) | in-house tech | SMS | same | template string (`tech-sms-dispatch.ts:1115`) | `dispatch()` at `tech-sms-dispatch.ts:1115` | yes | no — vendor-exempt | yes | no | CANNED | |
| `wo_handyman_extras_sms` | in-house tech | SMS | same, follow-up after MMS primary | template string `formatTechExtraPhotosSms` (`tech-sms-dispatch.ts:235`) | `dispatch()` at `tech-sms-dispatch.ts:1154` | yes | no — `recipientType:'vendor'` (`:1159`) | yes — `'transactional'` (`:1158`) | no | CANNED | |
| `wo_handyman_standdown_sms` | in-house tech | SMS | tool handler `handle-close-work-order.ts` → `sendTechStandDownSms` (`tech-sms-dispatch.ts:462`) | template string `formatTechStandDownSms` (`tech-sms-dispatch.ts:395`) | `dispatch()` at `tech-sms-dispatch.ts:512` | yes | no — `recipientType:'vendor'` (`:512`) | yes — `'transactional'` (`:512`) | no | CANNED | Only reachable from a Clara tool call, but the text itself is a template, not loop-composed |
| `wo_handyman_dispatch_sms` (turnover in-house ping, scope-time) | in-house tech | SMS | Temporal `turnover-workflow.ts` → `src/lib/temporal/activities/turnover.ts` → `dispatchInHouseTurnoverWorkOrders` (`src/lib/domain/turnover/in-house-dispatch.ts:337`); also `POST /api/turnovers/[id]/scope` | template string `formatInHouseTurnoverPing` (`in-house-dispatch.ts:281-290`) | `deps.dispatch()` at `in-house-dispatch.ts:525-548`, kind `:548` | yes | no — `recipientType:'vendor'` (`:541`) with an explicit written rationale (`:529-540`) | yes — `suppressionCategory:'transactional'` (`:557`) | no | CANNED | |
| `wo_handyman_dispatch_sms` (turnover in-house ping, PM-approval) | in-house tech | SMS | API routes `/api/work-orders/[displayId]/approve` + `/api/work-orders/bulk-approve` → `pingInHouseTechForApprovedWorkOrder` (`in-house-dispatch.ts:648`) | same template | same `dispatch()` call (`in-house-dispatch.ts:525`) | yes | no — vendor-exempt | yes | no | CANNED | `triggerActor:'operator'` distinguishes it in the record (`in-house-dispatch.ts:243`) |
| `wo_vendor_dispatch_email` | external vendor | email | API routes `/api/work-orders/[displayId]/approve`, `/api/work-orders/bulk-approve`, and the WO detail client button | template string builders `buildVendorDispatchHtml/PlainText/Subject` (`src/lib/messaging/transports/vendor-dispatch-template.ts`, imported `send-vendor-dispatch.ts:8-12`) | `sendRecapEmail()` at `src/lib/integrations/email/send-vendor-dispatch.ts:206` | **no** — `sendRecapEmail`→`sendEmail` in `src/lib/integrations/email/client.ts` never references `applyOutboundPolicy` | **no** — no envelope, no conversationId gate | yes — `checkSuppression` at `client.ts:988` with `category:'transactional'` | no | CANNED | **POC-redirected**: sent to `vendor-poc@mailpit.local` (`vendor-poc-recipients.ts:15`), so no real vendor is emailed and the resolved record is never applied (`send-vendor-dispatch.ts:196-203`) |
| `vendor_quote_request_email` | external vendor | email | API route `/api/work-orders/[displayId]/request-vendor-quote` | template strings (`send-vendor-quote.ts` builders) | `sendRecapEmail()` at `src/lib/integrations/email/send-vendor-quote.ts:147` | no (same as above) | no | yes — `checkSuppression`, `category:'transactional'` | no | CANNED | POC-redirected to `POC_RECIPIENT` (`send-vendor-quote.ts:147`) |
| `turnover_vendor_dispatch_email` | external vendor | email | Temporal `src/lib/temporal/activities/turnover.ts` and `POST /api/turnovers/[id]/scope` → `dispatchExternalTurnoverWorkOrders` (`src/lib/domain/turnover/external-dispatch.ts:302`) | template strings `buildDispatchEmailHtml/Text` (`send-turnover-vendor-dispatch.ts:421` region; text builder ends `:410`) | Graph `emailClient.sendNewMessage()` at `src/lib/integrations/email/send-turnover-vendor-dispatch.ts:618`; SendGrid fallback at `:700+` | no | no | yes — `category:'transactional'` (`:628`) → `checkSuppression`; plus env allowlist `checkVendorDispatchAllowlist` (`vendor-dispatch-allowlist.ts:13`) | no | CANNED | **The one real-vendor email lane** — NOT POC-redirected; guarded only by `VENDOR_DISPATCH_RECIPIENT_ALLOWLIST`, which is a no-op when the env var is unset (`vendor-dispatch-allowlist.ts:16-19`) |
| `vendor_dispatch_chase_email` / `vendor_quote_chase_email` | external vendor | email | Temporal `vendorChaseWorkflow` (`src/lib/temporal/activities/vendor-chase.ts:2`), scenarios in `src/lib/domain/automation/followup-registry.ts:514,539` | template strings `buildVendorChaseSubject/Html/PlainText` (`vendor-chase.ts:308-310`) | `sendRecapEmail()` at `src/lib/temporal/activities/vendor-chase.ts:358` | no | no | yes — `checkSuppression` via `sendEmail`; also at-most-once claim gate (`vendor-chase.ts:272`) | no | CANNED | POC-redirected (`vendor-chase.ts:358` uses `VENDOR_POC_RECIPIENT`) |
| `turnover_summary` | PM / staff | SMS | `POST /api/voice/call-ended` → `finalizeTurnoverCapture` inside `after()` (`src/app/api/voice/call-ended/route.ts:1754, 2083`) | template — pure `buildTurnoverConfirmRecap` (`src/lib/domain/turnover/build-turnover-recap.ts:276`), with a hardcoded fallback body (`call-ended/route.ts:2324-2329`). LLM-derived *inputs* (`assembleTurnoverCharges`, projection summary) but the copy is deterministic | `sendSms()` at `src/app/api/voice/call-ended/route.ts:2353` | **no** — `sendSms` is the raw Twilio client (`src/lib/integrations/twilio/client.ts:363`), no envelope | **no** | yes — `checkSmsSendAllowed` at `twilio/client.ts:387`, `category:'transactional'` | no | CANNED | Message row written separately, after the fact, by `appendTurnoverRecapMessage` (`call-ended/route.ts:2408` → `src/lib/domain/turnover/append-turnover-recap-message.ts:61`) — a send/record pair that is NOT coupled by the ADR-0119 dispatcher |
| `turnover_summary` (approval nudge) | PM / staff | SMS | Temporal activity `nudgeTurnoverApproval` (`src/lib/temporal/activities/turnover.ts:1929`) | template string (`turnover.ts:1976-1978`) | `sendSms()` at `src/lib/temporal/activities/turnover.ts:1982` | no | no | yes — `checkSmsSendAllowed`, `category:'transactional'` | no | CANNED | Writes NO Message row — only `recordEngagementAsk` (`turnover.ts:1994+`). **Recordless human-facing send.** Armed check `isTurnoverArmed` at `:1934` |
| `turnover_capture_ack` | PM / staff | SMS | **NOT FOUND as a live sender** | — | — | — | — | — | — | UNCLASSIFIED | The kind is declared (`kind-category.ts:157`) and read by `resolve-turnover-conversation.ts:60` / `outbound-thread-log.ts:5`, but no producing send site exists in `src/` or `agents/` — dead kind, or the ack is written under `turnover_summary` |
| `emergency_contact_sms` | emergency contact (staff/on-call) | SMS | `POST /api/voice/call-ended` → `handleEmergencyRelayOutcome` → `runEmergencyNoAnswerLadder` (`src/lib/domain/voice/emergency-no-answer-ladder.ts:63`) | template string (`emergency-no-answer-ladder.ts:83-85`) | `sendSms()` at `src/lib/domain/voice/emergency-no-answer-ladder.ts:87` | **no** — raw Twilio client | **no** | yes — `category:'emergency_safety'`, an explicit exempt-but-audited override (`:84-86`) | no | CANNED | Message row written separately via `writeMessageBestEffort` (`:101-116`), and ONLY when `originConversationId` is present — otherwise **recordless** |
| `emergency_relay_call` | emergency contact (staff/on-call) | **voice** | `POST /api/voice/call-ended` → `maybePlaceEmergencyRelay` → `initiateEmergencyRelayCall` (`src/lib/integrations/voice/emergency-relay-call.ts:46`) | **ElevenLabs voice agent** (`ELEVENLABS_EMERGENCY_RELAY_AGENT_ID`, `emergency-relay-call.ts:62`) briefed by `buildEmergencyRelayDynamicVars` (`:82`) | `initiateOutboundCall()` at `src/lib/integrations/voice/emergency-relay-call.ts:105` | **no** | **no** | yes — `checkSuppression(..., 'emergency_safety')` run purely for the audit record, result deliberately not branched on (`emergency-relay-call.ts:71-77`) | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | A separate live AI voice agent speaks to a human; none of the text-loop safety pipeline applies. `conversationId` is passed (`:114`), and the outcome trace is appended by `appendEmergencyRelayTrace` (`emergency-no-answer-ladder.ts:207`) — a summary line, not the actual words spoken |
| **UNDECLARED** — vendor coordination VOICE call | external vendor | **voice** | `src/lib/domain/vendors/dial-approved-session.ts` (also re-dial from `/api/voice/call-ended`) → `initiateVendorCall` (`src/lib/integrations/voice/initiate-vendor-call.ts:258`) | **ElevenLabs voice agent**; `first_message` override is the empty string (`initiate-vendor-call.ts:347` ← `vendor-call-context.ts:88`), so the agent opens on its own | `initiateOutboundCall()` at `src/lib/integrations/voice/initiate-vendor-call.ts:357` | **no** | **no** | not a suppression check — `recipient:'vendor'`, `category:'transactional'` (`:362-363`); touch ledger only | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | **No `conversationId` is passed** to `initiateOutboundCall` (compare the emergency sibling at `emergency-relay-call.ts:114`) → the call has no thread anchor at dial time. Declares no `OutboundMessageKind` |
| **UNDECLARED** — PM stale-dial notice (email leg) | PM / staff | email | vendor-dial staleness sweep → `src/lib/domain/vendors/notify-pm-stale-dial.ts:234` | template string (`notify-pm-stale-dial.ts` subject/text/html built above `:234`) | `sendEmail()` at `src/lib/domain/vendors/notify-pm-stale-dial.ts:234` | no | no | exempt by declaration — `internalRecipient: true` (`:236`), `category:'transactional'` (`:248`) | no | CANNED | Explicit `noConversationRecord('PM stale-dial notice — an operator recipient with no thread of their own (ADR-0059)')` (`:235`) → **deliberately recordless**. No `kind` declared → grades as `outreach` at the cap |
| **UNDECLARED** — PM stale-dial notice (SMS leg) | PM / staff | SMS | same | template string `smsBody` (`notify-pm-stale-dial.ts:259`) | `sendSms()` at `src/lib/domain/vendors/notify-pm-stale-dial.ts:259` | no | no | `internalRecipient: true`, `category:'transactional'` (`:260-261`) | no | CANNED | Recordless. No `kind` |

---

## Findings

1. **Nothing in this slice runs through Clara's loop.** Zero of the 26 send sites are composed by `agents/clara/lib/agent/conversation-manager.ts`. Not one emits an AgentTrace. Every tenant-, vendor-, tech- and emergency-facing message in maintenance/vendors/turnover/emergency is either a template built in code or an out-of-loop LLM call.

2. **Four sites are LLM-composed with no safety pipeline at all.** `composeViaHaiku` (`src/lib/temporal/activities/maintenance-comms.ts:271`) is a bare `messages.create` against the Anthropic client — no fair-housing check, no promise-backing, no PII strip, no hallucination guard, no anomaly gate. It writes text that goes straight to a **tenant's phone** (status relay `:523`, resident relay `:343`) and to a tech (`:434`). Its only guardrail is a prompt instruction ("never embellish, never promise anything the tech didn't say") and a template fallback on model error. The two outbound VOICE lanes (emergency relay, vendor coordination) are a separate ElevenLabs agent talking live to a human, also entirely outside the text pipeline.

3. **Every vendor/tech SMS is deliberately exempt from the escalated-thread gate.** `recipientType: 'vendor'` short-circuits the gate at `agents/clara/lib/messaging/dispatch-envelope.ts:311`. This is documented and intentional (a human taking over a thread shouldn't strand the tech doing the work), but it means a PM stepping into a maintenance thread does **not** silence the tech-facing legs.

4. **Every EMAIL and every raw-`sendSms` site bypasses `applyOutboundPolicy` entirely.** That is 8 sites: 4 vendor emails, the PM stale-dial pair, the two turnover PM recaps, and the emergency contact SMS. They get suppression/consent (each transport has its own gate) but no envelope, no policy layer, no escalation awareness.

5. **Recordless human-facing sends (no conversation row):**
   - `nudgeTurnoverApproval` (`src/lib/temporal/activities/turnover.ts:1982`) — texts the PM, writes no Message row at all.
   - PM stale-dial email + SMS (`notify-pm-stale-dial.ts:234,259`) — explicitly `noConversationRecord`.
   - `emergency_contact_sms` (`emergency-no-answer-ladder.ts:87`) — recorded only if `originConversationId` is present.
   - `initiateVendorCall` (`initiate-vendor-call.ts:357`) — passes no `conversationId`, unlike its emergency sibling.
   - Conditionally recordless: the handyman relay (`handle-vendor-completion.ts:1160`), the tech page (`tech-sms-dispatch.ts:857`), and the turnover in-house ping (`in-house-dispatch.ts:233`) all fall back to `noConversationRecord` when no vendor thread resolves.
   - All four vendor EMAIL lanes are structurally recordless *today* because the POC redirect means `emailDeliveredToRecipient` is false, so the resolved record is never applied (`send-vendor-dispatch.ts:196-203` states this explicitly).

6. **The turnover recap is a decoupled send/record pair.** `sendSms` at `call-ended/route.ts:2353` and `appendTurnoverRecapMessage` at `:2408` are two independent calls — exactly the shape ADR-0119 exists to eliminate at `dispatch()` sites. A failure between them leaves a text the PM got with no row.

7. **One real-vendor email lane is live; the rest are fenced.** Dispatch, quote, and both chases route to `vendor-poc@mailpit.local` (`vendor-poc-recipients.ts:15`). `turnover_vendor_dispatch_email` (`send-turnover-vendor-dispatch.ts:618`) does **not** — its only recipient guard is `VENDOR_DISPATCH_RECIPIENT_ALLOWLIST`, which allows everything when the env var is unset (`vendor-dispatch-allowlist.ts:16-19`).

8. **Two undeclared-kind human-facing send families** (both PM stale-dial legs, plus the vendor voice call). Per the brief, undeclared kinds default to `outreach` grading at the frequency cap.

9. **`turnover_capture_ack` has no producer.** Declared at `kind-category.ts:157` and read by `resolve-turnover-conversation.ts:60`, but no send site writes it anywhere in `src/` or `agents/`. Could not classify.

10. **Telegram is gone.** All four `wo_vendor_*_telegram` kinds are still declared (`kind-category.ts:144-147`) but have **zero references** outside the kind table. The transport was deleted 2026-07-30 (`agents/clara/lib/messaging/dispatcher.ts:13-20`); the kinds are dead entries.

11. **A known-but-unfixed quiet-hours hole**, called out in the code itself: `maintenance_schedule_confirmed` sends inline (`handle-vendor-completion.ts:1644-1650`), so a vendor accepting a slot at 11pm texts the tenant immediately, while the slot *offer* for the same work order would have waited until morning.

### Count per PATH
- **CLARA-LOOP: 0**
- **CLARA-COMPOSED-OUTSIDE-LOOP: 5** (status relay, resident relay/nudge, handyman relay, emergency relay voice call, vendor coordination voice call)
- **CANNED: 20**
- **UNCLASSIFIED: 1** (`turnover_capture_ack` — no sender found)
The agent loop itself + escalation relay + PM notifications — full evidence table
I now have everything. Here is the deliverable.

---

# Outbound-path audit — slice: agent loop, escalation/relay, PM/staff-facing

Repo: `/Users/federicochapa/.claude/propflowai` (read-only pass)

## (a) THE CLARA-LOOP ITSELF

**Entry point:** `agents/clara/lib/agent/conversation-manager.ts:4009` `handleIncomingMessage()`, reached via `agents/clara/lib/messaging/inbound-router.ts:908` (`routeInboundMessage`, the *default lane*) which is reached from `agents/clara/lib/messaging/inbound-dispatcher.ts:1413` `dispatchInbound` — "the single post-transport entry for every inbound package" (its own header, `:1-9`).

### Ordered safety pipeline (single call to `handleIncomingMessage`)

| # | step | file:line | armed / gated |
|---|---|---|---|
| 0 | Escalated-thread gate — full silence | `decideEscalatedGate` called `:4345`; `gated:true` returns before the LLM: *"no agent loop, no tools, no reply to the tenant"* (`:4399`) | Always armed; pure deterministic fn (`:1108`) |
| 1 | Final text extracted from Claude turn | `:8830-8836` | n/a |
| 2 | `safety:intermediate-draft-drop` (salvage merge) | `:8849-8880` | Always armed; additive only, never blocks (`:8858-8862`) |
| 3 | **Empty-final fallback** — canned dynamic-phone apology | `:8887-8892` | Always armed. Template string injected mid-pipeline |
| 4 | Decision-why extraction + PII-strip of the reason | `:8910-8924` | Always armed |
| 5 | `safety:typed-tool-call-strip` | `:8936-8973` | Always armed |
| 6 | `safety:internal-prompt-strip` | `:8979-8994` | Always armed |
| 7 | `runOutboundSafetyChecks` — fair-housing + promise-backing | `:9026-9046`; skipped for PM/turnover turns via `mayBeLeasing` (`:9008`) | Always armed for prospect/unknown senders |
| 8 | `safety:pii-strip` (all outbound) | `:9050-9059` | Always armed |
| 9 | `safety:hallucination-guard` + auto-escalation | `:9082-9140`; auto-fires `renewal_escalate` (`:9201`) / `notify_leasing_team` (`:9298`) | Always armed when renewal-offer or tour snapshot in scope |
| 10 | Output gate (observe-only) | `:9358-9371` | DynamoDB flag `turnIntegrityOutputGate`, default ON but **log-only, never blocks** (`src/lib/agent/turn-integrity/turn-integrity-flags.ts:1-26`) |
| 11 | `safety:anomaly-review-gate` — holds reply, pages #alerts | `:9378-9451` | DynamoDB flag `turnIntegrityReviewGate`, default ON, 60s cache, fail-safe ON |
| 12 | `safety:turnover-fabrication-guard` | `:9466-9552` | Always armed on bound turnover-approval turns |
| 13 | `safety:turnover-async-honesty-guard` / `-edit-honesty-guard` | `:9621`, `:9716` | Always armed on their scoped turns |
| 14 | `safety:team-inbox-silence` | `:9787-9807` | Always armed (Decision 9, Fede 2026-08-17) |
| 15 | **Same-turn escalation template substitution** | `decideSameTurnEscalationOutbound` `:9810`; on `!release` the model's text is **replaced wholesale** by `buildEscalatedAckText()` (`:1025`, catalog `ESCALATED_ACK_TEMPLATES` `:1037`) and re-stamped `kind: escalated_thread_ack` (`:9885`) | Always armed |
| 16 | `safety:policy-gate` — honesty layer | `:9974-10280` | **`POLICY_GATE_MODE` env, default `off`** (`src/lib/domain/compliance/policy-gate.ts:108`). Also skipped on voice + PM turns (`:9975`) |
| 17 | `safety:leasing-fee-fabrication-guard` | `:10368-10510` | **`LEASING_FEE_FABRICATION_GUARD_MODE` env, default `off`** (`src/lib/domain/leasing/lease-terms/fee-fabrication-guard.ts:227`). Skipped on voice + PM turns |
| 18 | Persist outbound row + stamp trace | `:10527-10547` — `kind: outboundKind`, `traceId: traceCollector.traceId`; `saveAgentTrace` at `:10878` | Always armed |
| 19 | Transport send (outside this file) | `agents/clara/lib/messaging/process-envelope.ts:356` `dispatch(sender, reply, {...})` wrapped in `withDeliveryTrace` | n/a |

### Rows — loop-owned kinds

| kind | audience | channel | trigger | composer | transport + file:line | applyOutboundPolicy? | escalated-aware? | suppression/consent? | AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `clara_reply` (declared `transactional`, `kind-category.ts:95`) | tenant/prospect/vendor | sms, telegram, email | agent loop via `dispatchInbound` | agent loop | `dispatch()` `process-envelope.ts:356` | **yes** — `@/lib/messaging/dispatcher` → `dispatchEnvelope` → `applyOutboundPolicy` (`dispatcher.ts:299,346`) | **yes** — envelope carries conversationId | yes — via `dispatchEnvelope` | **yes** — `traceId` stamped `cm.ts:10535`, saved `:10878` | **CLARA-LOOP** | The reference path. `record: noConversationRecord(...)` at `process-envelope.ts:365` because the loop already persisted the row upstream |
| `escalated_thread_ack` (`kind-category.ts:104`) | tenant/prospect | sms, email | agent loop, same-turn escalation branch | **template string** `buildEscalatedAckText` `cm.ts:1025` | same `dispatch()` `process-envelope.ts:356` | yes (same envelope) | yes | yes | **yes** — same trace as the turn | **CANNED, inside the loop** | Template *substituted for* the model's text at `cm.ts:9877`. Also emitted by the fee-fabrication guard at `:10506`. Passes all upstream guards (which are no-ops on a fixed string) |
| `pm_query` (`kind-category.ts:192`, `exempt`) | PM/staff | sms, email | agent loop with `senderRole:'pm'` | agent loop | same `dispatch()` | yes | n/a (PM sender bypasses gate, `decideEscalatedGate` `pm_sender` arm) | yes | **yes** | **CLARA-LOOP** | Not a separate sender — `replyKind = senderRole === 'pm' ? 'pm_query' : 'clara_reply'` (`cm.ts:4052`). Steps 16/17 explicitly skip PM turns |
| `voice_transcript` (`kind-category.ts:105`) | tenant/prospect | voice | ElevenLabs `call-ended` webhook | **separate ElevenLabs voice agent** (live, in-call) | no send — `buildMessage` record only, `src/app/api/voice/call-ended/route.ts:775` | **no** — not a send | no | no | **no** | **CLARA-COMPOSED-OUTSIDE-LOOP** | The words the caller actually heard were composed by a different runtime and are recorded *after the fact*. Never saw the loop's PII strip, hallucination guard, or policy gate |
| `manual_outbound` (`kind-category.ts:107`) | tenant/prospect | sms, telegram | human operator (Pipeline Lab / `clara-takeover`) | **human-typed** | `dispatch()` `src/lib/platform/pipeline-lab/run-outbound.ts:322` | yes — but **escalated gate deliberately does not bind**: `record: noConversationRecord(...)` (`:337`), comment `:330-334` "the ONE outbound that gate must not stop" | **no, by design** | yes | **no** — `saveAgentTrace` never called on this path (`:311-314`) | **CANNED** (human) | Row written separately at `:425-440` with `author:{kind:'clara'}`, `triggerActor:'operator'` |

### Entrypoints that reuse the loop
`process-envelope.ts:288` (SMS/Telegram), `agents/clara/lib/email/process-email-record.ts:564,581` (email), `src/app/api/simulate/sms/route.ts:92`, `src/lib/platform/pipeline-lab/run-pipeline.ts:640`, and `src/lib/domain/escalation/reply-release.ts:1353-1358` (`clarifyForStaff`, `senderRole:'pm'`).

### Entrypoints that BYPASS the safety pipeline
All in `inbound-dispatcher.ts`, returning a `reply` string that ships via the same `dispatch()` but never enters `conversation-manager.ts`:

| bypass | file:line | composer |
|---|---|---|
| Test-menu intercept | `:229-234` | canned menu |
| Test-data guard | `:238-244` | fixed string "This number is reserved for testing." |
| **Life-safety pre-empt** | `tryLifeSafetyPreempt` `:273-514`, reply built `:504-514` | fixed evacuate/911 template; pages PM via `pagePropertyManager` `:432`. Zero LLM by design |
| Slot-pick rung | `trySchedulingLane` `:556` | deterministic parse |
| Rating rung | `tryRatingLane` `:712` | pure-parse, "zero LLM" (`:28-30`) |
| Maintenance state-first rung | `tryMaintenanceStateFirstLane` `:882`, reply `:964` `composeDumbAck('resident')` | deterministic |
| **Router crash fallback** | `inbound-router.ts:1040` — `completedActions.map(c=>c.summary).join('. ') + ". I'll follow up shortly with more details."` | string concat; loop already threw, so **no trace, no guards** |

---

## (b) ESCALATION / RELAY LANE

| kind | audience | channel | trigger | composer | transport + file:line | applyOutboundPolicy? | escalated-aware? | suppression/consent? | AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `clara_reply` | resident/prospect | **email** | PM replies to escalation email → `relayAnswerToPerson` | template `buildStaffAnswerRelay`/`buildStaffUpdateRelay` (`decision-request.ts:982,1042`), **or LLM rephrase** when armed | `sendPropertyEmail(...)` `reply-release.ts:1205` | **no** — `property-graph-sender.ts` has zero `applyOutboundPolicy` refs | **no** — gate never runs on this transport; status is manually cleared at `:1068-1101` but that is app bookkeeping | unknown — no visible check at call site | **no** | **CANNED** / CLARA-COMPOSED-OUTSIDE-LOOP when rephrase armed | Fair-housing screens the *manager's raw answer* once at `:614`; the template output is not re-screened |
| `clara_reply` | resident/prospect | **sms** | same | same | `dispatch()` `reply-release.ts:1269` | **yes** | **yes** | yes; `kind:'clara_reply'` set at `:1290` explicitly to lift the outreach cap | **no** | **CANNED** / CLARA-COMPOSED-OUTSIDE-LOOP | Same message, two different safety postures by channel |
| UNDECLARED (rephrase step) | resident/prospect | feeds both legs | `reply-release.ts:1179-1201`, gated on `property.escalationRelayRephraseEnabled` (off by default) | **`client.messages.create()` at `relay-rephrase.ts:100`**, `FAST_MODEL` | n/a — replaces `body` | n/a | n/a | fair-housing re-screen at `reply-release.ts:1185`, fail-closed to template on violation/timeout | **no** | **CLARA-COMPOSED-OUTSIDE-LOOP** | No hallucination guard, no PII strip, no internal-prompt strip, no policy gate |
| UNDECLARED | resident/prospect | **voice** | `maybePlaceVoiceCallback` `voice-callback.ts:122` after a successful relay; Willows-scoped, opt-in flag | **template** — `deriveTopicClause`/`deriveCallbackNumberLine`/`deriveTextStatusLine` build `dynamic_variables` for a fixed ElevenLabs agent; `answerText` verbatim | `initiateEscalationCallbackCall` → `initiateOutboundCall` `escalation-callback-call.ts:189` | **no** — direct ElevenLabs/Twilio POST | no — only a one-dial-ever lock `callbackDialedAt` `voice-callback.ts:269` | **yes** — `checkSuppression` `escalation-callback-call.ts:112`; quiet hours `voice-callback.ts:158` | **no** | **CANNED** | No LLM in this file at all |
| n/a | PM/owner | email | `runEscalationNagPass` cron `nag-pass.ts:164` | template `buildNagText` `decision-request.ts:704` | `sendEmail(...)` `nag-pass.ts:337`, `internalRecipient:true` `:341` | no | n/a | not checked — `internalRecipient` opts out | no | **CANNED** (internal) | |
| `pm_escalation_email` | PM/staff | email | `notifyTeamOfGatedInbound` `gated-inbound-team-notice.ts:136` | template | `sendEmail(...)` `:333`, `internalRecipient:true` `:337` | no | n/a | explicitly not opt-out-checked (`:337`) | no | **CANNED** (internal) | |
| n/a | PM/owner | email | `clarifyForStaff` `reply-release.ts:1333` | **runs the real loop** `handleIncomingMessage` `:1353-1358`, then wraps in `buildStaffClarificationText` (`decision-request.ts:604`) | `sendEmail(...)` `reply-release.ts:1388` | not traced | n/a — `turnScope:'pm_query'` filters it from resident context | not traced | **yes for the composed answer** | **CLARA-LOOP (composition) + CANNED (wrapper)** | The only send in this folder that genuinely runs the loop |
| — | — | — | `matter-open-outbox.ts` | — | **no send call in the file** — pure retry store (`drainMatterOpenOutbox:303`) | — | — | — | — | **not a sender** | Actual matter-open send lives in a caller outside this file set |

---

## (c) PM / STAFF-FACING KINDS (internal-facing — noted, still classified)

| kind | audience | channel | trigger | composer | transport + file:line | applyOutboundPolicy? | escalated-aware? | suppression/consent? | AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| `pm_query` | PM/staff | sms, email | agent loop, `senderRole:'pm'` | agent loop | `dispatch()` `process-envelope.ts:356` | yes | n/a | yes | **yes** | **CLARA-LOOP** | `cm.ts:4052`. Policy gate + fee guard explicitly skip PM turns |
| `pm_inbox_reply` | — | email | PM types in the connected mailbox; ingested | **human (the PM)** | **no send** — record only, `saveNewMessages` `agents/clara/lib/email/record-pm-reply.ts:93`; kind at `:83` | n/a | n/a | n/a | no | **not a sender** | `author:{kind:'human'}`, `triggerActor:'operator'` |
| `pm_page_email` | PM | email | life-safety pre-empt (`inbound-dispatcher.ts:432`) and Clara's `escalate_to_human` | template | `sendEmail(...)` `src/lib/domain/maintenance/page-property-manager.ts:343`; kind `:392`; `internalRecipient:true` `:345` | **no** | no — `noConversationRecord` `:344` | no — `internalRecipient` opts out (ADR-0092) | **no** | **CANNED** | The tenant-thread row it writes is a deliberate *mirror* (writer `maintenance.pm-page`), not this send's record |
| `pm_escalation_email` | PM | email | Clara's `forward_to_property_manager` tool | template | `src/lib/tools/handlers/handle-forward-to-property-manager.ts:673` | **no** | no | no | **no** | **CANNED** | Tool-handler send; fires *inside* a loop turn but is not composed by it |
| `pm_escalation_email` | PM | email | `notifyTeamOfGatedInbound` | template | `gated-inbound-team-notice.ts:333`, kind `:389` | **no** | n/a | no (`:337`) | **no** | **CANNED** | Second sender for the same kind |
| `pm_renewal_notify` | PM | email, sms | renewal cron/orchestration — `renewal-notify.ts:256,304`, `renewal-execution-notify.ts:356`, `renewal-holdover-notify.ts:206` | template | `sendEmail(...)` `renewal-notify.ts:209` (`internalRecipient:true` `:211`); `sendSms(...)` `:292` (`internalRecipient:true`). Mirror row written by `append-pm-notify-to-renewal-conversation.ts:110` via `writeMessageBestEffort` `:105` | **no** | no | no — `internalRecipient` on both legs | **no** | **CANNED** | The kind is stamped on the *mirror* row, not the send; the send itself declares no kind |
| `pm_tour_confirmation_email` | PM | email | tour booked — `sendPmTourConfirmationInBackground` from `src/lib/domain/leasing/tour/apply-tour-intent.ts:17`; fire-and-forget `send-pm-tour-confirmation.ts:268` | template | `sendEmail(...)` `send-pm-tour-confirmation.ts:179`; kind `:249`; `internalRecipient:true` `:181` | **no** | no — `noConversationRecord` `:180` | no (ADR-0092) | **no** | **CANNED** | Same shape as today's `application_link_email` incident |
| `pm_tour_cancellation_email` | PM | email | tour cancelled — `apply-tour-intent.ts:18`; f&f `send-pm-tour-cancellation.ts:228` | template | `sendEmail(...)` `:155`; kind `:209`; `internalRecipient:true` `:157` | **no** | no (`:156`) | no | **no** | **CANNED** | |
| `pm_tour_update_email` | leasing team | email | `agents/clara/lib/agent/tools-leasing.ts:714` (tool handler) and `src/lib/integrations/voice/tour-day-fallback.ts:174` | template | `sendEmail(...)` `send-leasing-team-tour-update.ts:119`; kind `:152`; `internalRecipient:true` `:121` | **no** | no (`:120`) | no | **no** | **CANNED** | Two send sites into one function |
| `pm_key_pickup_confirmation_email` | PM | email | `src/lib/tools/handlers/handle-book-key-pickup.ts:390` (tool handler) | template | `sendEmail(...)` `send-pm-key-pickup-confirmation.ts:115`; kind `:124`; `internalRecipient:true` `:132` | **no** | no | no | **no** | **CANNED** | |
| `pm_application_sent_email` | PM | email | `src/lib/domain/leasing/send-application-link.ts:40` — the *same* function behind today's incident; f&f `send-pm-application-sent-notification.ts:229` | template | `sendEmail(...)` `:153`; kind `:209`; `internalRecipient:true` `:155` | **no** | no (`:154`) | no | **no** | **CANNED** | PM-side sibling of the tenant-facing `application_link_email` that triggered this audit |

---

## Findings

1. **The two strongest guards are disarmed in production.** The Policy Gate (honesty layer, `POLICY_GATE_MODE`, `policy-gate.ts:108`) and the Leasing Fee-Fabrication Guard (`LEASING_FEE_FABRICATION_GUARD_MODE`, `fee-fabrication-guard.ts:227`) both default to `off` and are unreachable from real traffic without an operator setting an env var. Every other guard in the pipeline is always armed. Both also skip voice and PM turns even when armed.

2. **Voice replies never see the loop's safety pipeline.** What a caller hears is composed live by a separate ElevenLabs agent; `voice_transcript` rows (`call-ended/route.ts:775`) are written *after* the call as a record. No PII strip, no hallucination guard, no policy gate applied to spoken text. Steps 16 and 17 additionally exclude voice by construction ("no post-turn seam in the live call path", `cm.ts:9963`).

3. **A second, unaudited LLM writer exists in the escalation relay.** `relay-rephrase.ts:100` calls `client.messages.create()` directly to rewrite a staff answer that is then sent to a resident. Its only safety net is a fair-housing re-screen (`reply-release.ts:1185`) with fail-closed fallback. Off by default per-property, but when on, resident-facing text bypasses the entire pipeline and emits no trace.

4. **The email relay leg has no escalated-thread gate; the SMS leg does.** Identical message, `reply-release.ts:1205` (email, `sendPropertyEmail`, no `applyOutboundPolicy`) vs `:1269` (SMS, `dispatch`, full gate). Safety posture depends on which channel the escalation arrived on.

5. **Seven pre-loop rungs and a crash fallback ship text without entering the loop.** `inbound-dispatcher.ts` returns replies from the test menu (`:232`), test-data guard (`:240`), life-safety pre-empt (`:504`), slot-pick (`:556`), rating (`:712`), and maintenance state-first (`:964`); `inbound-router.ts:1040` concatenates a fallback sentence when the loop throws. All ship via the same transport as a real Clara reply.

6. **Recordless sends (no `AgentTrace`):** every escalation-lane send, every PM/staff-facing send in section (c) except `pm_query`, the voice callback dial, and `manual_outbound`. Auditability for these rests on message-history rows only — and the PM-facing ones use `noConversationRecord`, so several have no conversation record either.

7. **`escalated_thread_ack` is a canned template that replaces model output mid-pipeline** (`cm.ts:9877`, `:10506`) and is stamped with a kind that asserts "template, not model output" (`:9878-9884`). This is deliberate and well-documented — worth knowing it exists when reasoning about "everything goes through Clara."

8. **`pm_renewal_notify` stamps its kind on a mirror row, not the send.** The actual `sendEmail`/`sendSms` at `renewal-notify.ts:209,292` declare no kind, so per the brief's rule they grade as `outreach` at the touch-budget layer.

9. **Could not classify:** `matter-open-outbox.ts` has no send call (pure retry store, `:303`); its real sender lives outside the assigned file set. Suppression/consent behavior inside `property-graph-sender.ts` was not read this pass — the email relay row's consent cell is genuinely "unknown," not "no."

### Path counts (send sites in this slice, 27 rows)
- **CLARA-LOOP: 3** — `clara_reply`, `pm_query`, and `clarifyForStaff`'s composition step
- **CLARA-COMPOSED-OUTSIDE-LOOP: 2** — `voice_transcript` (live voice agent), relay rephrase
- **CANNED: 20** — 2 loop-internal template substitutions (`escalated_thread_ack`, empty-final fallback), 1 human (`manual_outbound`), 7 dispatcher pre-loop rungs + crash fallback, 3 escalation-lane resident sends, 9 PM/staff-facing sends (two kinds have two senders each)
- **Not senders: 2** — `pm_inbox_reply` (records a human's own email), `matter-open-outbox.ts`
Transport-level sweep (the six doors) — full evidence table
I have solid verified ground truth on the transport layer. Here is the deliverable.

---

# Transport-level sweep — outbound audit

**Scope caveat (read first).** My session was interrupted mid-run and I lost the results of three parallel deep-dives (email sites, SMS sites, voice/Telegram/lambda sites). Everything below is what **I personally read** at the cited `file:line`. Coverage is complete for the **transport layer** (the question the brief actually asked me: which entry functions bypass `applyOutboundPolicy`) and for the **structural verdict**. Per-call-site rows are complete for SMS/voice and partial for the ~35 `sendEmail` callers — I flag exactly which rows are unenumerated rather than guessing them.

## Transport topology (verified)

| Transport entry | File:line | Calls `applyOutboundPolicy`? | How I know |
|---|---|---|---|
| `dispatchEnvelope()` | `agents/clara/lib/messaging/dispatch-envelope.ts:376`, policy call at `:425` | **YES** | Read the call `const policyDecision = await applyOutboundPolicy(envelope, {` at :425 |
| `dispatch()` (legacy shim) | `agents/clara/lib/messaging/dispatcher.ts:238` | **YES (inherited)** | Calls `dispatchEnvelope(envelope, {...})` at :238; header comment :9 confirms "Calls `dispatchEnvelope` — origin / policy / consent / registry" |
| `SesEmailAdapter` (Clara email replies) | `agents/clara/lib/messaging/adapters/ses-email.ts`, registered `registry-singleton.ts:41` | **YES (inherited)** | Only reachable as a registry adapter, and registry resolution happens *after* the policy gate in `dispatchEnvelope` |
| `sendSms()` | `src/lib/integrations/twilio/client.ts:363` | **NO** | Read the whole function body :363–440; no `applyOutboundPolicy` import or call anywhere in the file (grep for it returns zero hits) |
| `sendMms()` | `src/lib/integrations/twilio/client.ts:635` | **NO** | Same file, same grep — zero `applyOutboundPolicy` hits |
| `sendEmail()` (SendGrid/nodemailer) | `src/lib/integrations/email/client.ts:720` | **NO** | Grep for `applyOutboundPolicy` in the file returns zero hits |
| `sendPropertyEmail()` (MS Graph → SendGrid fallback) | `src/lib/integrations/email/property-graph-sender.ts:208` | **NO** | Grep returns zero hits; matches the brief's pre-verified fact |
| `initiateOutboundCall()` (ElevenLabs) | `src/lib/integrations/voice/initiate-outbound-call.ts:274` (`fetch(EL_OUTBOUND_CALL_URL)`) | **NO** | Grep for `applyOutboundPolicy`/`outbound-guard`/`HARNESS` in the file returns zero hits |

## Transports that bypass `applyOutboundPolicy`

**Five entry functions. All five reach a real carrier without the escalated-thread gate.**

1. **`sendSms(to, body, from, opts)`** — `src/lib/integrations/twilio/client.ts:363` → `client.messages.create()` at `:76`
2. **`sendMms(...)`** — `src/lib/integrations/twilio/client.ts:635` → `client.messages.create()` at `:749`
3. **`sendEmail(opts)`** — `src/lib/integrations/email/client.ts:720`
4. **`sendPropertyEmail(params)`** — `src/lib/integrations/email/property-graph-sender.ts:208` (Microsoft Graph leg → `sent_graph` at `:359`, SendGrid fallback at `:384`)
5. **`initiateOutboundCall(req)`** — `src/lib/integrations/voice/initiate-outbound-call.ts:274`

The codebase **already knows about #1 and #2 and says so in a comment**. `src/lib/integrations/twilio/client.ts:155–163`:

> `isStressSender` was documented as the reason stress synthetics "never reach real Twilio", but until this guard existed it was only consulted by the envelope dispatcher's policy gate — so every DIRECT caller of `sendSms` / `sendMms` (renewal + turnover Temporal activities, mass comms, the tenant-confirmation reply path, vendor PM notices, the voice call-ended follow-up, the emergency no-answer ladder) went straight to `client.messages.create()`.

That sentence is the audit finding, written by a previous author, scoped only to the stress-synthetic problem. The **escalated-thread gate has the identical hole and was never closed** — the fix added a stress guard at `stressSuppressionFor`, not a policy call.

### What the bypassing transports *do* check (they are not unguarded — just not policy-gated)

`sendSms` runs its own private safety stack, in this order (`twilio/client.ts:363–440`):
`isHarnessSuppressed()` :364 → prompt-canary block :369 → TCPA STOP/consent `checkSmsSendAllowed` :387 → AppFolio-OTP reply-loop block :398 → stress-synthetic drop :425 → per-property `smsShadowMode` :434.

`sendEmail` (`email/client.ts`): `isHarnessSuppressed()` :749 → global `emailShadowMode` :1222.
`sendPropertyEmail` (`property-graph-sender.ts`): per-property `emailShadowMode` :231, *before either leg* — a stricter gate than the global one.

**What all five are missing is the same one thing: `escalatedThreadIsHumanOwned`.** That check lives only at `dispatch-envelope.ts:336`. So a canned send on a thread a human has taken over will go out.

## Kill-switch / shadow-mode honoring, per transport

| Transport | `HARNESS_SUPPRESS_OUTBOUND` | Shadow mode |
|---|---|---|
| `sendSms` / `sendMms` | YES — `twilio/client.ts:364`, `:640` | per-property `smsShadowMode`, `:434` (replies, MFA, internal recipients, `testPhones` exempt) |
| `sendEmail` | YES — `email/client.ts:749` | global `emailShadowMode`, `:1222` (`bypassKillSwitch` escape hatch) |
| `sendPropertyEmail` | inherited via SendGrid fallback only | per-property `emailShadowMode`, `property-graph-sender.ts:231` — runs before the Graph leg |
| `initiateOutboundCall` | **NO — not at the chokepoint.** Zero hits in the file | n/a — no voice shadow mode exists |
| `dispatchEnvelope` | inherited from whichever adapter runs | inherited |

**Important nuance on the harness switch:** `isHarnessSuppressed()` **deliberately fails open in production** — `src/lib/testing/outbound-guard.ts:50–57` returns `false` and `console.error`s when `VERCEL_ENV === 'production'`. It is a test-isolation mechanism only; it is not a production safety control and must never be cited as one.

## Voice: the one-door claim holds

`initiate-outbound-call.ts` bills itself as "THE ONE DOOR EVERY OUTBOUND VOICE CALL GOES THROUGH" (header :1–4) and that is **true as of this commit** — exactly five callers, no fourth-party `fetch` to the ElevenLabs outbound-call URL:

| Wrapper | file:line of `initiateOutboundCall(` | declared `recipient` | `isHarnessSuppressed()` first? |
|---|---|---|---|
| `robot-call.ts` | :126 | `'test'` | YES :110 |
| `initiate-vendor-call.ts` | :357 | `'vendor'` | YES :276 |
| `emergency-relay-call.ts` | :105 | `'staff'` | YES :50 |
| `escalation-callback-call.ts` | :189 | `'resident'` | YES :144, :164 |
| `initiate-renewal-call.ts` (renewal orchestration) | :85 | `'resident'` | YES :49 |

So the harness kill-switch is honored **at the five caller sites, not at the chokepoint** — a sixth caller added tomorrow would compile, dial, and not be suppressible by the harness. The door enforces `recipient` at compile time and runs `checkTouchBudget`, but only `'resident'` is ever capped (header :17–22).

**No voice path passes `applyOutboundPolicy` at all.** An escalated, human-owned thread does not stop Clara from placing a renewal call or an escalation callback.

## Per-send-site rows (SMS — direct-to-Twilio, policy-bypassing)

Every row below declares a `category` but **no `kind`** — so all grade as `outreach` by the brief's default rule.

| kind | audience | channel | trigger | composer | transport + file:line | applyOutboundPolicy? | escalated-aware? | suppression/consent? | AgentTrace? | PATH | notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| UNDECLARED (`category:'transactional'`) | PM/staff | sms | voice webhook, turnover projection failure | template string | `sendSms`, `src/app/api/voice/call-ended/route.ts:2204` | no — direct `sendSms` | no | consent yes (`client.ts:387`) | no | CANNED | failure-notice SMS wrapped in try/catch |
| UNDECLARED (`category:'transactional'`) | PM/staff | sms | voice webhook, post-call turnover recap | template string | `sendSms`, `route.ts:2353` | no | no | consent yes | no | CANNED | comment at :2350 justifies transactional as reply-to-own-call |
| UNDECLARED (`category:'transactional'`) | PM/staff | sms | Temporal turnover nudge activity | template string `:1975` | `sendSms`, `src/lib/temporal/activities/turnover.ts:1982` | no | no | consent yes | no | CANNED | "Just checking in on unit X…" |
| UNDECLARED (`category:'transactional'`) | tenant | sms | Temporal tenant-confirmation-review | template | `sendSms`, `src/lib/temporal/activities/tenant-confirmation-review.ts:98` | no | no | consent yes | no | CANNED | |
| UNDECLARED (`skipConsentCheck:true`) | PM/staff | sms | MFA login | template | `sendSms`, `src/lib/integrations/sms/mfa-sms.ts:39` | no | no | **consent deliberately skipped** | no | CANNED | correct for auth; stress guard still unconditional (`client.ts:178`) |
| UNDECLARED (`internalRecipient:true`) | PM/staff | sms | renewal notify | template | `sendSms`, `src/lib/domain/leasing/renewal-notify.ts:292` | no | no | internal-recipient exempts shadow gate | no | CANNED | |
| UNDECLARED (`category:'transactional'`) | tenant | sms | confirmation-review reply handler | LLM-composed (`messages.create` in same file) | `sendSms`, `src/lib/domain/tenant-confirmation/handle-confirmation-review-reply.ts:226` | no | no | consent yes | no | **CLARA-COMPOSED-OUTSIDE-LOOP** | LLM writes tenant-facing text outside conversation-manager's safety pipeline |
| UNDECLARED | PM/staff | sms | promise-ledger delivery | template | `sendSms`, `src/lib/domain/promises/pm-delivery.ts:166` | no | no | consent yes | no | CANNED | reads `refusal` |
| UNDECLARED (`category:'emergency_safety'`) | **emergency contact** | sms | emergency no-answer ladder | template `:84` | `sendSms`, `src/lib/domain/voice/emergency-no-answer-ladder.ts:90` | no | no | **suppression-exempt by design** | no | CANNED | highest-stakes canned send in the codebase; dynamic-imports the client at :82 |
| UNDECLARED (`internalRecipient:true`) | PM/staff | sms | mass-comms PM notify | template | `sendSms`, `src/lib/temporal/activities/mass-comms.ts:699` | no | no | internal exempt | no | CANNED | separately declares `kind:'mass_comms_pm_notify_sms'` at :727 for *logging only*, not passed to `sendSms` |

## Per-send-site rows (SMS — via `dispatch()`, policy-gated)

These **do** get `applyOutboundPolicy` and the escalated-thread gate, inherited through `dispatcher.ts:238`.

| kind | audience | trigger | transport + file:line | notes |
|---|---|---|---|---|
| `renewal_outreach_sms` | tenant | Temporal renewal | `dispatch`, `src/lib/temporal/activities/renewal.ts:844` (kind at :855) | policy-gated |
| `reminderKind` (variable) | tenant | Temporal renewal reminder | `dispatch`, `renewal.ts:1139` (kind at :1149) | policy-gated |
| `renewal_call_recap_sms` | tenant | renewal call recap | `dispatch`, `src/lib/domain/leasing/renewal-orchestration/recap-sms.ts:227` (kind :237) | policy-gated |
| `tour_confirmation_sms` | prospect | tour outcome | `dispatch`, `src/lib/domain/leasing/tour/send-tour-outcome-sms.ts:365` | policy-gated |
| `args.kind` (passthrough) | prospect | Temporal tour | `dispatch`, `src/lib/temporal/activities/tour.ts:146` | policy-gated |
| UNDECLARED | prospect | Temporal prospect outreach | `dispatch`, `src/lib/temporal/activities/prospect-outreach.ts:727` | **LLM-composed outside loop** — body from `generateFollowUpMessage` (imported :64) |
| UNDECLARED | prospect | Temporal re-engagement | `dispatch`, `src/lib/temporal/activities/prospect-reengagement.ts:613` | **LLM-composed outside loop** — `generateFollowUpMessage` imported :75 |
| UNDECLARED | prospect | Temporal post-tour chase | `dispatch`, `src/lib/temporal/activities/post-tour-chase.ts:506, :743` | policy-gated, no kind |
| UNDECLARED | prospect | tour confirm nudge | `dispatch`, `src/lib/temporal/activities/tour-confirm-nudge.ts:472` | policy-gated, no kind |
| UNDECLARED (`recipientType:'vendor'`) | vendor/tech | tech SMS dispatch | `dispatch`, `src/lib/domain/maintenance/tech-sms-dispatch.ts:512, :1073, :1101 (MMS), :1115, :1154` | policy-gated, no kind; 5 send sites |
| UNDECLARED | tenant | collections dunning | `dispatch`, `src/lib/domain/leasing/collections/approved-dun-send-ops.ts:462` | policy-gated |
| `mass_comms_blast_sms` | tenant | mass-comms blast | `dispatch`, `src/lib/temporal/activities/mass-comms.ts:493` (kind :481) | policy-gated |
| UNDECLARED | vendor/relay | vendor completion | `dispatch`, `src/lib/domain/vendors/handle-vendor-completion.ts:1072, :1166, :1574, :1680` | policy-gated; 4 sites |
| UNDECLARED | tenant | renewal auto-start | `dispatch`, `src/lib/domain/leasing/renewal-auto-start.ts:1809` | policy-gated |
| UNDECLARED | vendor/tech | in-house turnover dispatch | `deps.dispatch`, `src/lib/domain/turnover/in-house-dispatch.ts:523` | policy-gated (injected) |
| UNDECLARED | tenant | maintenance PMS-creation notice | `dispatch`, `src/lib/domain/maintenance/notify-tenant-of-pms-creation.ts:185` | policy-gated |
| UNDECLARED | tenant | maintenance rating | `dispatch`, `src/lib/domain/maintenance/rating.ts:662` | policy-gated |
| UNDECLARED | tenant/PM | promise fulfillment | `dispatch`, `src/lib/domain/promises/fulfill.ts:561, :791, :991` | policy-gated; 3 sites |

## Email rows (partial — see caveat)

Verified individually:

| kind | audience | trigger | composer | transport + file:line | policy? | escalated? | PATH |
|---|---|---|---|---|---|---|---|
| UNDECLARED | external sender (non-owner) | escalation reply-release, wrong-sender notice | template `:425` | `sendEmail`, `src/lib/domain/escalation/reply-release.ts:435` | **no** | no | CANNED |
| `application_link_email` | prospect | Temporal `sendApplicationLink` | template | `sendPropertyEmail` via `src/lib/domain/leasing/send-application-link.ts` | **no** | no | CANNED |

**Unenumerated:** ~33 remaining `sendEmail(` callers and 5 remaining `sendPropertyEmail(` callers. I have the verified file list (from grep) but lost the per-line reads when the session dropped. **Every one of them is policy-bypassing by construction** — that follows from the transport-level finding, not from reading each site, since neither `client.ts` nor `property-graph-sender.ts` contains any path to `applyOutboundPolicy`. What is *not* yet established per-site is each one's kind, audience, and whether it emits a conversation record. The file list is in my working notes and re-running that slice is roughly one agent-hour.

## Findings

1. **The escalated-thread gate protects one of six doors.** `applyOutboundPolicy` is reachable only through `dispatchEnvelope`/`dispatch`. `sendSms`, `sendMms`, `sendEmail`, `sendPropertyEmail`, and `initiateOutboundCall` are all directly importable and all reach a real carrier. If a human has taken over a thread, canned sends on all five still fire.

2. **The repo has already documented this hole for SMS and closed only half of it.** `twilio/client.ts:155–163` names the exact bypassing callers — renewal, turnover, mass comms, tenant-confirmation, vendor notices, voice call-ended, emergency ladder. The response was a stress-synthetic guard. The policy gate was not added.

3. **No direct-`sendSms` site declares a `kind`.** All ten pass only `category`. Per the brief's rule they default to `outreach` grading. `mass-comms.ts` is the sharpest case: it declares `kind:'mass_comms_pm_notify_sms'` at :727 for its log line while the actual send at :699 passes no kind at all — the kind system reports a send it did not govern.

4. **Two LLM-composed-outside-loop paths reach humans.** `handle-confirmation-review-reply.ts:226` sends LLM-written text to a *tenant* via bare `sendSms` — no policy gate and no conversation-manager safety pipeline. The prospect outreach/re-engagement paths (`generateFollowUpMessage`) are also outside the loop but at least keep the policy gate via `dispatch()`.

5. **The emergency ladder is a canned, suppression-exempt, un-gated send to an emergency contact** (`emergency-no-answer-ladder.ts:90`). Defensible — but it means the single highest-stakes message the system sends has the fewest checks, and it is worth being deliberate about that rather than incidental.

6. **The harness kill-switch is not a production control** and fails open by design in prod (`outbound-guard.ts:50–57`). Separately, it is enforced at the five voice *callers* rather than at the voice chokepoint, so the "one door" guarantee covers `recipient` typing and touch budget but not suppressibility.

7. **Three lambda surfaces are stale build artifacts, not live senders.** `lambda/application-link`, `lambda/tour-reminder`, `lambda/renewal-nudge` contain only untracked `dist/` bundles (not in git — `git ls-files` returns empty); their sources were deleted by commit `66d138957e`, "chore(tour): delete tour-reminder + application-link Lambda surfaces (#1287)", 2026-05-21. The bundles do contain `sendApplicationLink` and `SendEmailCommand`, and `application-link/dist` is the only place SES `SendEmailCommand` appears at all — but there is no infra reference to any of them (grep of `infra/` returns nothing). Treat as dead weight to delete, not as a live path.

8. **No live Telegram sender found in the product path.** The only hit repo-wide is a string in `src/lib/data/metric-catalog.ts` — consistent with the Henry bot's retirement.

### Row counts (rows I personally verified)

- CANNED: 26
- CLARA-COMPOSED-OUTSIDE-LOOP: 3
- CLARA-LOOP: 0 among canned/transport sites — the only CLARA-LOOP path is `conversation-manager.ts`, which is the sole caller that writes an AgentTrace (`saveAgentTrace`, imported :255; delivery step recorded via `recordTraceDelivery` :3486, fired at :3533)

**No transport-level send site I read emits an AgentTrace.** Trace emission is a property of the conversation-manager loop only, so every row in both tables above is a recordless send from the trace system's point of view — including the policy-gated `dispatch()` ones.
PropFlow Docs