> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# LINQ MIGRATION

# Linq migration: provider abstraction, inbound webhook, per-user routing

Status: **Phase 2 (full end-to-end).** Twilio remains the account default; Linq is enabled per-user via `all_users.sms_provider = 'linq'` or the `LINQ_PILOT_CLERK_IDS` env allow-list. No existing Twilio user is affected until we explicitly flip them.

## Why

Silvia is migrating SMS/MMS/iMessage traffic off Twilio onto [Linq's Partner API](https://docs.linqapp.com/) to gain:

* **iMessage-first delivery** (blue bubbles) with automatic SMS/RCS fallback
* **A single unified API** for iMessage, RCS, SMS, WhatsApp (WhatsApp beta \~1–2 weeks out per Linq rep)
* **Better deliverability / line-reputation tooling** for consumer AI use cases
* **Native voice memo, reactions, typing indicators, message effects**

Comparable AI-agent products (heynoah, pika labs) are already on Linq. Photon is the closest peer; Linq's rep claims a meaningful share of Photon's listed customers have switched.

## The "skip porting" decision

Per Linq's founder (email 2026-08-27) and the precedent of heynoah, we are **not** porting Silvia's Twilio number. Rationale:

* Silvia's identity is her **name + contact card**, not a specific phone number.
* Porting is the highest-risk step in any migration and forecloses on parallel operation.
* Existing Twilio users keep their existing number and existing conversations — nothing changes for them until we opt-in-migrate them via a notification text ("Silvia now supports iMessage/WhatsApp — text her at +1XXX-XXX-XXXX").
* New signups get the Linq number from day one via the app UI (Phase 3 UI wiring).

Consequence: Silvia will run **two lines simultaneously and indefinitely** — Twilio for the legacy tail, Linq for iMessage/WhatsApp for new + migrated users. Both are supported end-to-end in this PR.

## Phased rollout (updated)

| Phase | What ships | Risk to prod | Status |
| - | - | - | - |
| **0 – Scaffolding** ([#3585](https://github.com/CFO-Silvia/Silvia-Web/pull/3585)) | `lib/messaging/*` interface + factory, Linq/Twilio adapters, `/api/messages/incoming/linq` route (verifies signature + logs, no AI). Signature-verification unit tests. | None — no existing call site touched. | ✅ Shipped |
| **1 – Sandbox roundtrip** | Point Linq sandbox at staging URL, send a manual test from Linq dashboard, confirm envelopes appear in `silvia_sms_logs`. | None — sandbox line only. | Pending signing secret from Linq dashboard |
| **2 – Full end-to-end (THIS PR)** | Linq webhook runs the full Silvia chat turn (Claude tool loop, media, voice memos, opt-in/opt-out, compliance footer). Outbound routes (`sms/radar-complete`, `sms/token-limit-paused`, `sms/opt-in`) route through the provider factory. New DB column `all_users.sms_provider`. Extracted shared helpers (`silvia-chat-turn`, `outbound`, `inbound-media`, `opt-out-scan`). | Bounded — no existing Twilio user is routed to Linq unless we set their column or add them to the pilot env allow-list. Twilio inbound webhook is untouched. | This PR |
| **3 – Production readiness** ([#3668](https://github.com/CFO-Silvia/Silvia-Web/pull/3668)) | `enrollUserInLinq()` helper wired into Clerk `user.created` (feature-flagged OFF), admin `POST /api/ops/linq/enroll` endpoint, contact-card setup script, share-card-on-first-reply flow, `phone_number.status_updated` webhook + Slack alerting, deterministic idempotency keys on every outbound. | None on merge — every new behavior gated by `LINQ_ONBOARD_NEW_USERS_MODE=off` or by `sms_provider` (unchanged for real users). | ✅ Shipped |
| **4 – Premium iMessage UX: read receipts, typing, reactions** ([#3741](https://github.com/CFO-Silvia/Silvia-Web/pull/3741)) | `markChatRead`/`startTyping`/`stopTyping`/`addReaction` API-client methods; typing keep-alive with 60s refresh wrapped around the Claude turn; prompt-marker (`<!--react:like-->`) approach for reactions (server strips the marker + fires the tapback). | None — every change gated on the Linq inbound route; all Linq API failures swallowed and logged, reply path can't be blocked. | ✅ Shipped |
| **5 – Voice memo verification + attachment primitives** | Test coverage for inbound voice memo flow (audio media parts through the existing transcription pipeline); observability warning on dropped Linq part types; `uploadAttachment` / `deleteAttachment` API-client methods for outbound files (PDFs, images, up to 100MB). No behavior changes on inbound. | None — inbound voice memos already worked (test-coverage-only); `uploadAttachment` has no call sites yet, added for Phase 6+ tools. | ✅ Shipped |
| **6a – Threading primitives** ([#3745](https://github.com/CFO-Silvia/Silvia-Web/pull/3745)) | New `parent_provider_message_id` column + partial index on `silvia_sms_logs`; `replyTo` on `SendMessageInput` + `NormalizedInboundMessage.parentProviderMessageId`; Linq provider serializes `reply_to.message_id` (+optional `part_index`) on outbound; parser lifts `envelope.data.reply_to.message_id` on inbound; Silvia's reply threads back to the user's inbound when the inbound was itself a reply, so the whole exchange renders as a threaded iMessage bubble on the recipient's device. Threaded inbound also prepends quoted context (bounded to 300 chars) to the effective body so Claude has the parent inline. | None — additive column, only wired into the Linq path; falls back to flat top-level send when no parent exists; every chunk of a multi-chunk reply carries the same anchor so nothing splits off. | ✅ Shipped |
| **6b – Async deep research SMS completion** (THIS PR) | New `dr_sms_notifications` handoff table + `create_deep_research` Claude tool executor re-enabled on **Linq SMS only** (Twilio path still excludes the tool per the Twilio-scoping guard added post-review; see the `SCOPING` block above `smsExcludedTools` in `app/api/sms/incoming/route.ts` and the regression test in `app/api/sms/incoming/__tests__/excluded-tools.test.ts`; webapp chat also excludes the tool via `WEBAPP_EXCLUDED_TOOLS` in `lib/claude/chat-turn.ts` since the webapp already reaches the same DR backend via `execute_python` + `silvia-deep-research-skills`, guarded by `lib/claude/__tests__/webapp-excluded-tools.test.ts`); SMS turns pass trigger anchors (`sourceProviderMessageId` / `parentProviderMessageId` / `messagingProvider`) into `ToolContext` so the executor freezes them into the handoff row; new `POST /api/sms/research-complete` (dual auth: `X-Internal-API-Token` OR session-ownership fallback) that the DR backend pings on run completion, sending a threaded SMS via `sendSilviaReply` with the Phase 6a `replyTo` + `parentProviderMessageId` anchors; per-row idempotency on `sent_at`. SMS system prompt teaches Silvia to call the tool + emit the `<!--react:like-->` reaction on trigger acks. | Bounded — additive table, gated on Linq path for threading (Twilio users get flat completion SMS via graceful degradation); compliance re-check on every send (`sms_opt_in` / `account_deleted` / `phone_number`); handoff-row write is non-fatal to the DR kickoff (email fallback still fires); the completion endpoint is idempotent so DR-backend retries never double-send. | This PR |
| **6c – Charts and files as companion bubbles** ([#4063](https://github.com/CFO-Silvia/Silvia-Web/pull/4063)) | Feature runs unconditionally for every Linq turn — the base `LINQ_PILOT_CLERK_IDS` cohort is the only rollout gate. The former `LINQ_ARTIFACT_ATTACH_CLERK_IDS` allow-list shipped in #4063 was removed in a follow-up PR once we decided premium-UX gating on top of the transport gate wasn't buying anything for the internal-only pilot. On every Linq turn: the SMS prompt carries a CHARTS & FILES section (render one matplotlib PNG via `execute_python` + `present_artifact` when a picture helps; never HTML/hyperframes). After the turn, `lib/messaging/turn-artifacts.ts` reads the `present_artifact` successes from `toolArtifacts`, allow-lists Linq-deliverable content types, enforces the 10 MB URL-media cap and max 5 per reply, and presigns each key from the uploads bucket. The text reply goes out exactly as before (no media on it); each file then goes out as its own caption + media bubble, threaded like the reply, only after the text reply succeeded, each in its own try/catch, never touching the webhook outcome. Audit: `artifacts_presented` / `artifacts_attachable` / `artifact_keys` / `artifacts_skipped` on the text reply's `user_context`; `artifact_bubble: true` + key on each bubble row (success or failure). No iMessage pin — images survive the SMS cascade as MMS. Twilio path untouched (route never takes this code path). | Low — text reply unchanged; any attachment failure is logged and dropped. Turn artifact resolution runs on every Linq turn but is a no-op when the model didn't call `present_artifact` (the common case). | ✅ Shipped |
| **6d – Inbound reactions + delivery-service telemetry** (migration `20260918_01`) | Two additive, analytics-only signals bundled in one PR because they share one Linq-webhook dispatch site. **(1) Inbound reactions**: new `silvia_sms_reactions` table + `reaction.added` / `reaction.removed` dispatch branch on the webhook + PostHog `sms_user_reaction_server` event (attributed to the target-message owner, not the reactor). Idempotent via UNIQUE(`reaction_event_id`). Ops action: add both events to the Linq webhook subscription in the Linq dashboard — until then the code path is dormant. **(2) Delivery-service split**: new nullable `delivery_service` column on `silvia_sms_logs` + `data.sender_handle.service` extraction on every `message.delivered` webhook (canonical field on our 2026-02-03 subscription; `data.from_handle.service` / `data.chat.owner_handle.service` / `data.service` retained as fallbacks for older payload shapes and version rollbacks) → update in place on the outbound row (keyed by `twilio_message_sid`) + PostHog `sms_message_delivered_server` event with `{ delivery_service }`. Preserves Linq's casing (iMessage / SMS / RCS / MMS) verbatim. Answers "of our sends, what % actually delivered as premium iMessage vs. fell back to carrier SMS?" without a cross-table SQL join. | Very low — both are ANALYTICS-ONLY. Silvia does not read reactions or delivery service before composing a reply. Reaction insert failure ACKs 200 (never triggers a Linq retry storm). Delivery-service stamp silently no-ops on race conditions and payload gaps. See migration `20260918_01_linq_reactions_and_delivery_service.sql` for the full design. | ✅ Shipped |
| 5.5 – UI flip for new signups | App onboarding shows the Linq number to new signups; `LINQ_ONBOARD_NEW_USERS_MODE` flipped to `enroll`. Existing users unchanged. | Low — inbound + outbound already work end-to-end. | Pending |
| 7 – Migration notification | One-shot Twilio SMS to existing opted-in users: "Silvia now supports iMessage — text her at +1... to switch". Bulk `sms_provider` update as users self-migrate. | Bounded — reversible; users choose. | After Phase 6 |
| 8 – Twilio deprecation | Once `<5%` of monthly traffic remains on Twilio, sunset the Twilio number with a farewell auto-reply pointing at the Linq number for a final \~90 days. | Communication-heavy; low technical risk. | Long tail |

## Cost model

Linq bills **per active contact per month**, banded in units of 200, at \$1.30/contact base (rate comes down at 1K and 10K bands — TBD).

Grounded in production usage as of 2026-08-27:

| Segment | Contacts | Est. monthly cost |
| - | - | - |
| Active in last 30 days (texts Silvia) | \~330 | \~\$520 (400 band) |
| Active in last 90 days | \~800 | \~\$1,040 (800 band, first "past 1K" tier applies at 1000) |
| Ever texted (`has_texted = true`) | \~2,100 | \~\$2,730 base — actual will be lower with tier discount |
| All opted-in | \~4,400 | \~\$5,720 base — actual will be lower with tier discount |

**Rollout budget target:** stay under \$1,500/mo through the pilot phase by keeping the 90-day active cohort as the migration ceiling. Grow to full opted-in only after the pricing conversation confirms the 1K+ tier rate.

## Architecture

```
lib/messaging/
  types.ts                    MessagingProvider interface, NormalizedInboundMessage
  linq-webhook.ts             Standard Webhooks HMAC-SHA256 signature verifier
  index.ts                    getMessagingProvider() + getProviderForClerkId() factory
                              (env allow-list beats DB column beats account default)
  silvia-chat-turn.ts         Shared Claude tool loop + system prompt (Phase 2)
  outbound.ts                 Shared chunker + compliance footer + provider send + log
  inbound-media.ts            Shared image download + voice-memo transcription
  opt-out-scan.ts             STOP/START/HELP/CFOSILVIA body scanner for Linq
  providers/
    twilio.ts                 Wraps lib/twilio.ts sendSMS — 0 behavior change
    linq.ts                   Hand-rolled fetch client for /v3/messages + /v3/chats
  __tests__/                  Unit tests for webhook verifier, opt-out scan,
                              outbound helper, and provider factory precedence

app/api/messages/incoming/linq/route.ts
  Standard Webhooks endpoint. Verifies signature, dedupes on the Linq
  message id (stored as `linq_<data.id>` in `silvia_sms_logs.twilio_message_sid`
  via a DB claim row backed by the partial unique index — the `webhook-id`
  header is used only for signature verification), runs the FULL Silvia flow:
  user lookup → compliance checks (STOP/HELP/opt-in) → media (image / voice
  memo) → Silvia chat turn → outbound reply via Linq. Logs every event to
  silvia_sms_logs.

app/api/sms/incoming/route.ts   [UNTOUCHED in this PR]
  Twilio inbound. Continues to use its inline implementation. Will migrate to
  the shared helpers in the follow-up cleanup PR — kept untouched here to
  eliminate regression risk on a mission-critical route.

app/api/sms/{radar-complete, token-limit-paused, opt-in}/route.ts
  Outbound. Uses getProviderForClerkId(supabase, clerkId) so a user whose
  sms_provider = 'linq' receives their radar / paywall / welcome SMS via
  iMessage, and everyone else keeps getting them via Twilio.
```

### Provider selection precedence

`resolveProviderForUser` in `lib/messaging/index.ts`, highest to lowest:

1. **`LINQ_PILOT_CLERK_IDS` env allow-list** — instant override, no DB write required. Reserved for internal testing / incident response.
2. **`all_users.sms_provider` column** — per-user policy. `'linq'` or `'twilio'` pins the user; `NULL` defers to the account default.
3. **`MESSAGING_PROVIDER` env var** — account-wide default. `unset` → Twilio.

On any DB lookup failure, the factory falls back to Twilio unconditionally, regardless of the `MESSAGING_PROVIDER` value. This matches `getProviderForClerkId`'s docstring: "Returns the Twilio provider on any lookup failure so a transient DB error never routes a user's outbound message through Linq unexpectedly." Note this is stronger than "the account default": even if `MESSAGING_PROVIDER=linq` is set globally, a query error still pins the affected send to Twilio.

### Why keep the Twilio route untouched?

Extracting the Twilio route's inline logic to shared helpers is a nice DRY win, but the Twilio inbound route is Silvia's most mission-critical endpoint and touches 4000+ users' opted-in conversations daily. This PR keeps it byte-for-byte identical and pays the duplication cost in `inbound-media.ts` / `silvia-chat-turn.ts` / etc. The cleanup PR (after Linq is proven in staging) will migrate Twilio's route to call the shared helpers behind a feature flag.

### Why hand-roll instead of `@linqapp/sdk`?

Adds zero third-party dependencies for the send + verify surface (\~50 lines). Trivially swappable behind the `MessagingProvider` interface if we adopt the SDK later.

## Environment variables

All Linq secrets are in `.env.local` only (gitignored). `.env.example` documents the placeholders.

| Var | Required when | Where secret lives |
| - | - | - |
| `LINQ_API_KEY` | Any Linq outbound send | `.env.local` locally; Vercel env in staging |
| `LINQ_WEBHOOK_SIGNING_SECRET` | Once the webhook subscription is created (Phase 1) | Same |
| `LINQ_FROM_NUMBER` | Once we provision a Linq line (Phase 2 sandbox test) | Same |
| `LINQ_PILOT_CLERK_IDS` | Pilot testing (this phase) | Same |
| `MESSAGING_PROVIDER` | Never in current plan — per-user routing supersedes | Env-only |
| `LINQ_API_BASE_URL` | Never in prod. Set only for local mock servers. | Env-only |

## DB schema change

Migration [`supabase/migrations/20260827_01_all_users_sms_provider.sql`](../../supabase/migrations/20260827_01_all_users_sms_provider.sql) adds:

```
all_users.sms_provider  text  NULL
  CHECK (sms_provider IS NULL OR sms_provider IN ('twilio', 'linq'))
```

Applied to staging. Values:

* `NULL` (default for all existing rows): defer to account default → Twilio
* `'twilio'`: pin to Twilio (explicit lock)
* `'linq'`: route to Linq for both inbound webhook responses and outbound sends

## Compliance parity — what the Linq route implements

The Twilio inbound webhook enforces a set of TCPA and Twilio-policy rules. The Linq route ports all of them:

* **TCPA opt-in gate**: only replies when `all_users.sms_opt_in = true`.
* **`account_deleted` guard**: never messages deleted accounts. Auto-flips `sms_opt_in = false` if a deleted account receives a Linq message (defensive cleanup).
* **STOP/START/HELP/CFOSILVIA**: `lib/messaging/opt-out-scan.ts` implements Linq's best-practice body scan (exact case-sensitive whole-message match for the keyword list; case-insensitive for "OPT OUT" variants; conservative conversational-intent patterns). Linq blocks server-side on future outbound to keyword-opted-out users with error 2024, so our scan is authoritative for our own DB state.
* **Compliance footer** on every non-compliance-flow reply: `\n\n- Silvia\nReply STOP to opt-out`.

Deliberately deferred:

* **T-Mobile 10k/day rate limit** (`checkRateLimit()`) — Linq's per-line iMessage limits are structurally different from Twilio's A2P 10DLC cap. A Linq-native rate model comes in the pricing conversation.
* **Vault upload of MMS images** — extract from the Twilio route in the cleanup PR so both routes share it.

## Setting up the sandbox webhook (Phase 1 checklist)

1. In the [Linq sandbox dashboard](https://app.linqapp.com), create a webhook subscription:
   * `target_url`: `https://<staging-host>/api/messages/incoming/linq?version=2026-02-03`
   * `subscribed_events`: `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `reaction.added`, `reaction.removed`
     * **NOTE**: `reaction.added` / `reaction.removed` require an ops flip on the existing subscription (add these two to `subscribed_events` via the Linq dashboard's Update Subscription action). Until they're added, the reaction-capture wiring introduced in migration `20260918_01` is dormant — see Phase 6d in the rollout table above.
     * **⚠️ ORDERING REQUIREMENT for the reaction events**: **Do NOT flip the reaction subscription on until the Silvia-Admin repo's account-hard-delete cron has been updated to also purge `silvia_sms_reactions` rows (both `WHERE clerk_id = $deleted_clerk_id` and an age-based sweep for `WHERE clerk_id IS NULL AND created_at < now() - interval '<retention>'`).** `silvia_sms_reactions` has no FK cascade back to `silvia_sms_logs` (see migration header for why — the "ack fast, correlate later" invariant precludes it), so account deletion here relies entirely on the Silvia-Admin cron. If reactions land before the cron ships, deleted-account rows persist indefinitely and orphan rows have no retention path. The `delivery_service` column has no such requirement (it lives on the existing `silvia_sms_logs` row, which the cron already deletes).
2. Copy the returned `signing_secret` (`whsec_...`) into staging's `LINQ_WEBHOOK_SIGNING_SECRET` env var. **This secret can never be retrieved later** — losing it means deleting and recreating the subscription.
3. From the Linq dashboard "API Playground", send a test message to a phone in your possession.
4. Reply from the phone. Confirm a row lands in `silvia_sms_logs` with `user_context.provider = 'linq'`, `user_context.event_id` matching the Linq dashboard, and `direction = 'inbound'` followed by `direction = 'outbound'` with Silvia's reply.
5. Trigger a manual redelivery from the Linq dashboard; confirm the second delivery returns `{ ok: true, duplicate: true }` and does not create a second log row.
6. Set the tester's `all_users.sms_provider = 'linq'` (or add their clerk\_id to `LINQ_PILOT_CLERK_IDS`) and trigger a radar completion — confirm the radar SMS is delivered via Linq, not Twilio.

## Rollback

* **This PR (Phase 2)**: no user is routed to Linq unless `sms_provider = 'linq'` or `LINQ_PILOT_CLERK_IDS` includes their clerk\_id. Rollback = `UPDATE all_users SET sms_provider = NULL WHERE …` and/or clear the env var. \~30 seconds.
* **Phase 3 (new-signup UI)**: rollback = revert the UI change and set new signups' `sms_provider = NULL` via backfill. Their inbound Linq messages still work; outbound goes back to Twilio.
* **Phase 4 (migration notification)**: users already switched can be flipped back via `sms_provider = 'twilio'`. The Twilio number is still active — reversible.

## Phase 3 — Production readiness (this PR)

Everything below ships **feature-flagged OFF by default** so it's live on staging AND main without any real user being enrolled into Linq. Flip the flags on cutover day (see "Go-live checklist" below).

### What ships in Phase 3

1. **Onboarding flow** — `GET /v3/available_number` at signup
   * New helper `lib/linq/onboarding.ts#enrollUserInLinq` centralizes the "give this user a Linq line" decision. Called from three places:
     * Clerk `user.created` webhook (gated by `LINQ_ONBOARD_NEW_USERS_MODE`, default `off`)
     * Admin ops endpoint `POST /api/ops/linq/enroll` (manual trigger for test accounts, `force: true` bypasses the env gate)
     * Future backfill scripts
   * Modes:
     * `off` — no Linq calls at signup (production default until go-live)
     * `discover` — call `available_number`, persist assignment columns, but don't set `sms_provider`. Users still go through Twilio; onboarding runs end-to-end for validation.
     * `enroll` — same as discover PLUS `sms_provider = 'linq'`. This is the cutover switch.
   * Wrapped in try/catch inside the Clerk webhook — a Linq outage at signup NEVER blocks a signup or fires a Svix retry storm.

2. **Contact card setup** — one card per line in your pool
   * Script `scripts/linq/setup-contact-cards.ts` iterates `GET /v3/phone_numbers`, and for each line: GET the existing card, compare to the desired shape, POST or PATCH only when necessary. Fully idempotent; safe to re-run whenever the desired card changes.
   * Run once at pool-bootstrap and again if the card is updated. Use `--dry-run` for a plan-only pass.

3. **Contact card share-on-first-reply** — via `POST /v3/chats/{chat_id}/share_contact_card`
   * After a successful Linq outbound in the inbound webhook, we call `shareContactCardIfNeeded()`. Once-per-day cooldown per user via the new `all_users.linq_contact_card_shared_at` column (no extra table).
   * Best-effort: any share failure is logged and swallowed; the timestamp is stamped BEFORE the share call so a flaky Linq endpoint can't cause us to spam-retry every reply.

4. **Line-reputation monitoring** — `phone_number.status_updated` webhook
   * New handler `lib/linq/phone-number-status.ts` dispatches this event out of the existing `/api/messages/incoming/linq` route (single subscription, event-type dispatch).
   * Every event is persisted to `silvia_sms_logs.user_context` for audit.
   * Degradations (ACTIVE→FLAGGED, HEALTHY→AT\_RISK, AT\_RISK→CRITICAL) fire a Slack alert to `#silvia-alerts` via the existing `sendSlackAlert` helper. Reputation-only degradations fire warning severity; a FLAGGED status or CRITICAL reputation fires error severity. Recoveries log but don't page.
   * Slack dedupe is per-line (via the new `dedupeSuffix` option) so two lines degrading at once both alert.

5. **Deterministic idempotency keys on every outbound**
   * `sendSilviaReply` in `lib/messaging/outbound.ts` now passes `silvia-reply:<provider>:<related_inbound_id>:<chunk_index>` so a webhook-level retry cannot double-send at the provider layer.
   * Compliance-flow sends in the Linq webhook (unknown user, apology, STOP/START/HELP replies) pass keys derived from the envelope `event_id`.

### New environment variables

| Var | Purpose | Default when unset |
| - | - | - |
| `LINQ_ONBOARD_NEW_USERS_MODE` | `off` / `discover` / `enroll` — gates the Clerk webhook's call to `enrollUserInLinq` | `off` (no Linq calls at signup) |
| `LINQ_CONTACT_CARD_IMAGE_URL` | Public HTTPS URL of Silvia's avatar PNG (used by the contact-card setup script; matches Linq's `image_url` field). Optional; the script also accepts the legacy `LINQ_CONTACT_CARD_PHOTO_URL` name for back-compat. | Card ships with no image. |

Every existing env var (`LINQ_API_KEY`, `LINQ_WEBHOOK_SIGNING_SECRET`, `LINQ_FROM_NUMBER`, `LINQ_PILOT_CLERK_IDS`) keeps its Phase 2 meaning — with one narrowed role: `LINQ_FROM_NUMBER` is now a **fallback**, not the primary sender line (see § Multi-line rollout below).

### Multi-line rollout (`fromE164` per-user routing)

Once the Linq pool holds more than one line, every outbound send needs to land on the recipient user's assigned line (`all_users.linq_assigned_number`), or replies land on the wrong physical number and iMessage-thread continuity breaks. How that pin actually reaches Linq depends on the endpoint:

* **`POST /v3/chats`** (branded-line, URL-free bodies) — pins the line via an explicit `body.from`. We forward `linq_assigned_number` there. This is the only endpoint that **enforces** the pin unconditionally.
* **`POST /v3/messages`** (URL bodies OR `forceManagedLine: true`) — [does not accept `from`](https://docs.linqapp.com/channel/imessage/guides/messaging/sending-messages): *"takes the recipients and the message and nothing else — no `from`. Linq resolves both the line and the chat: it reuses the chat those recipients are already in while that line can still send, **opens a new chat on the best available line when there isn't one**, and moves the recipients onto a fresh line when their current one can't send."* Per-user pinning on this endpoint is achieved through Linq's chat-reuse — but only ONCE a chat exists. See the open gap below.

#### Gap #1: first-touch pin — resolved via self-heal (Cursor Bugbot PR #4146, revised on PR #4292)

**Original gap:** chat-reuse on `/v3/messages` requires an existing chat between the assigned line and the recipient. `enrollUserInLinq` does **NOT** create that chat — it calls `getAvailableNumber()` (deliberately without `to`) as a pool load-balancer, writes the picked line to `linq_assigned_number`, and returns. The chat between that line and the recipient isn't created until the FIRST successful outbound (or the first inbound from the recipient).

That meant for a fresh enrolled user with no prior chat, a first-touch `/v3/messages` send would land on Linq's own "best available line" pick, not `linq_assigned_number`. Impacted first-touch categories (every direct `provider.sendMessage(...)` call plus every `sendSilviaReply(...)` on Linq):

* `POST /api/sms/radar-complete` — radar completion bubbles (all `forceManagedLine: true`, all direct `provider.sendMessage`)
* `POST /api/sms/research-complete` — DR completion bubbles (via `sendSilviaReply`)
* `POST /api/sms/token-limit-paused` — token-cap notice (via `sendSilviaReply` under the hood, plus one direct send)
* `lib/messaging/opt-in-greeting.ts` — 3-bubble welcome sequence (all direct `provider.sendMessage`)
* `app/api/messages/incoming/linq/route.ts` — apology outbound + STOP/START/HELP/CFOSILVIA compliance replies (all direct `provider.sendMessage`)
* URL-fallback branch in `lib/messaging/providers/linq.ts` — any body containing a URL where the branded-line path would have been chosen but for the URL

**Blast radius on the single-line pool: ZERO.** Every `linq_assigned_number` equalled the pool line. Divergence was arithmetically impossible.

**Blast radius on multi-line pool (N ≥ 2): real.** First-touch sends could bind the chat to a random pool line; the DB `linq_assigned_number` would become stale for that user forever (chat-reuse then pinning on the WRONG line on every subsequent send).

**Was:** hard gate on provisioning line #2.

**Fix (shipped):** self-heal from `POST /v3/messages` responses ([`lib/linq/self-heal-assigned-line.ts`](../../lib/linq/self-heal-assigned-line.ts)). Every response includes top-level `from` (the line the message actually went out on), `from_selection.reason`, and `chat_id`. After every successful Linq outbound, the **Linq provider adapter** ([`lib/messaging/providers/linq.ts`](../../lib/messaging/providers/linq.ts)) calls the self-heal helper, which:

1. Short-circuits when `from` matches the stored `linq_assigned_number` **AND** the chat\_id is in sync — steady-state common case, no DB round-trip.
2. Otherwise reads the current row, computes the delta.
3. Compare-and-swap-updates `linq_assigned_number` + `linq_chat_id` (new column added by [`20260925_01_all_users_linq_chat_id.sql`](../../supabase/migrations/20260925_01_all_users_linq_chat_id.sql)) + clears `linq_assigned_vcf_url` when the line changes (contact card is line-bound). Predicate: `WHERE linq_assigned_number = <expected>` (or `IS NULL` when both caller and DB are NULL) — race-safe against concurrent sends.

**Placement (revised on PR #4292):** self-heal runs INSIDE the Linq provider adapter's `sendMessage(...)` method, immediately after the response parse and before returning `SendMessageResult`. Cursor Bugbot correctly flagged the initial placement in `sendSilviaReply(...)` as high-severity: 4 first-touch categories (radar-complete, opt-in-greeting, incoming/linq compliance replies, token-limit-paused) call `provider.sendMessage(...)` DIRECTLY, bypassing `sendSilviaReply`. Provider-layer placement ensures self-heal fires for every `/v3/messages` outbound regardless of the caller. Gated on:

* `!useExplicitFrom` — `/v3/chats` responses lack `from` / `from_selection`, so nothing to reconcile.
* `input.supabase && input.clerkId` — cold-lead paths (`unknown-number-lead.ts`) legitimately omit both (no user row exists yet), so self-heal no-ops for them.

**Chat-binding protection (CodeRabbit MINOR on PR #4292):** the chat\_id write is gated by a `chatCanChange` predicate — only allowed when (a) line changed, (b) db value was NULL (first-populate), or (c) Linq reported `failover_flagged`. Same-line non-failover responses that disagree with an existing binding are IGNORED to keep the stored chat\_id stable against rare Linq server-side races. Additionally, first-populate CAS adds `.is('linq_chat_id', null)` so concurrent first-populates don't clobber each other.

**DB-fallback CAS predicate (CodeRabbit MINOR on PR #4292):** when the caller has no `expectedAssignedNumber` (e.g. `resolveOutboundContext` envForceLinq + error branch returns a Linq provider without `fromE164`), the helper falls back to the value it just read from the DB. Race-safety is preserved because the read + CAS happen in the same request.

**E.164 validation (CodeRabbit MINOR on PR #4292):** the provider parses `payload.from` with a strict `/^\+[1-9]\d{1,14}$/` regex, so malformed values (`+abc`, `+`, whitespace, missing plus) coerce to `undefined` and self-heal treats them as "no signal" rather than writing garbage into `all_users.linq_assigned_number`.

Result: `linq_assigned_number` becomes self-correcting within ONE send after enrollment — for **every** first-touch category. The 1-send drift window is acceptable — users check the widget infrequently, and the only user-visible surface (contact-card sharing) runs post-first-send.

**Architecture chosen over DB-tracked chat binding (Option A):** the alternative fix was to make `linq_chat_id` load-bearing and route first-touch through `/v3/chats` with explicit `from=linq_assigned_number` to force the line. Rejected because:

1. `/v3/chats` rejects URL-containing first messages with HTTP 400 (anti-spam). Radar / DR completions all contain `cfosilvia.com` links, so first-touch there would require injecting a URL-free opener bubble before the link — real UX cost.
2. If the pinned line degrades between assignment and first send, the branded-line path silently delivers to a bad line. Self-heal follows Linq's fresh pick automatically.
3. Larger code surface — routing branch + first-touch opener injection + tests for both endpoint paths.

**Multi-line rollout is now unblocked.** Adding line #2 to the pool is safe — the self-heal path will absorb any first-touch drift automatically.

Wired invariants:

* **Provider response parsing** — `linq-provider.test.ts` verifies `from`, `from_selection.reason`, `chat_id`, `previous_chat_id` are extracted from all three `/v3/messages` branches (`reused_active_chat` / `new_best_number` / `failover_flagged`), that non-E.164 / undocumented values coerce to undefined, and that missing fields don't crash the parser. Includes 13 additional cases covering the strict E.164 regex (rejects `+abc`, `+`, `+01234`, bare `+1`, whitespace variants, too-long numbers; accepts legitimate US/UK/toll-free shapes and the ITU-T 2-15 digit range).
* **Provider self-heal invocation** — `linq-provider.test.ts` verifies self-heal FIRES when `supabase + clerkId` present + `/v3/messages` path, and SKIPS on cold-lead paths (no supabase, no clerkId) and on `/v3/chats` path.
* **Self-heal behavior** — [`lib/linq/__tests__/self-heal-assigned-line.test.ts`](../../lib/linq/__tests__/self-heal-assigned-line.test.ts) covers the no-op guards (Twilio, steady-state), the CAS UPDATE cases (line mismatch, chat\_id first-populate, failover\_flagged both-fields), the VCF URL clear-on-line-change, the chat-binding protection (non-failover disagreement ignored, first-populate CAS predicate present, failover overwrite allowed), the DB-fallback CAS predicate, CAS-lost race safety, and PII discipline (last-4 digits only in logs).
* **Wiring** — [`lib/messaging/__tests__/self-heal-wiring.test.ts`](../../lib/messaging/__tests__/self-heal-wiring.test.ts) source-greps the Linq provider file to catch a future refactor that drops the import, unwires the `!useExplicitFrom` / `supabase && clerkId` guards, drops the `await`, or introduces a rogue call site outside the provider. The same test also validates **callsite coverage**: every direct `provider.sendMessage(...)` in `app/` and `lib/` must pass both `supabase` and `clerkId` (or be on the cold-lead allow-list). This is the durable regression fence against the Bugbot HIGH finding that would otherwise re-open every time a new sendMessage callsite is added.

Attempting to pass `fromE164` on the `/v3/messages` wire is NOT a fix — it's an undocumented field Linq would either ignore (silent no-op) or reject (broken outbound). Cursor Bugbot caught an earlier revision of this PR that tried it — thank you Bugbot.

The plumbing:

* **`resolveOutboundContext(supabase, clerkId)`** (`lib/messaging/index.ts`) — reads `sms_provider` + `linq_assigned_number` in one query and returns `{ provider, fromE164, fromSource }`. Use this at every outbound entry point in place of `getProviderForClerkId`.
* **`SendMessageInput.fromE164`** (`lib/messaging/types.ts`) + **`SendSilviaReplyInput.fromE164`** (`lib/messaging/outbound.ts`) — per-call sender-line pin. Threaded through `sendSilviaReply` on every chunk and every artifact bubble.
* **`makeLinqProvider`** (`lib/messaging/providers/linq.ts`) — resolves `input.fromE164 ?? envFallbackFromNumber` and writes it to `body.from` **only on the `POST /v3/chats` path** (per the endpoint contract above). `LINQ_FROM_NUMBER` env var is now the **fallback** for legacy call sites and cold-text lead paths (`unknown-number-lead.ts`) where no user row exists yet.

The `fromSource` discriminator on the returned context surfaces the resolution provenance in ops logs:

* `assigned` — user has a `linq_assigned_number`, per-user routing succeeded.
* `unassigned` — user is on Linq but the column is NULL (adapter falls back to `LINQ_FROM_NUMBER`, plus a `[messaging] linq user has no linq_assigned_number` warning so ops can find users the enroll cron missed).
* `twilio` — user is on Twilio, `fromE164` is intentionally unset.
* `no_user` — cold-text / lead paths with no `clerk_id`.

Wiring is asserted by `lib/messaging/__tests__/per-user-from-number-wiring.test.ts` — the source-grep test that catches a future refactor accidentally dropping `fromE164` at any call site.

**Twilio parity is deferred**: `TWILIO_PHONE_NUMBER` is still Silvia's one Twilio line; if Twilio ever multiplexes, add a `twilio_assigned_number` column and mirror the same pattern.

### `continuation_message` — failover-branch opener

`POST /v3/messages` accepts an optional `continuation_message: { text }` field that Linq applies ONLY on the `failover_flagged` branch — the exact scenario is: (1) a chat between the recipient and one of our pool lines already exists; (2) that line has been flagged and can no longer send (e.g. Apple's "not a Real Person" bounce); (3) Linq creates a NEW chat on a fresh best line and returns `from_selection.reason = 'failover_flagged'` + `previous_chat_id`; (4) if `continuation_message` was supplied, its text is sent as the SINGLE message on that new chat, INSTEAD of the caller's `message`. Otherwise the caller's message sends as-is from what looks like a stranger's number to the recipient.

**Canonical copy lives in [`lib/linq/continuation-message.ts`](../../lib/linq/continuation-message.ts).** Wired into the `/v3/messages` branch of `lib/messaging/providers/linq.ts`. Callers can override on a per-send basis via `SendMessageInput.continuationMessage` (rare — the generic canonical opener works for almost every context).

**The field is IGNORED on `/v3/chats`** — that endpoint doesn't accept it. The provider only attaches it on the `/v3/messages` branch.

**Why this ships now, before the second Linq line lands:** with today's single-line pool, failover is arithmetically unreachable (Linq has no fresh line to move to). Once we add a second line, the FIRST user whose chat migrates during a flagging event would otherwise receive whatever payload was pending (e.g. a net-worth chart) from a phone number they've never seen — guaranteed spam-flag from the recipient, which then flags the fresh line and cascades the problem. Setting the field now is a no-op today; it's active insurance the moment the pool grows to N ≥ 2.

Wired invariants:

* **Copy invariants** — [`lib/linq/__tests__/continuation-message.test.ts`](../../lib/linq/__tests__/continuation-message.test.ts) locks in the identity claim, save-contact CTA, STOP disclosure, absence of URLs, length bounds, and plain-string type.
* **Wire shape** — `linq-provider.test.ts` verifies field present on `/v3/messages`, absent on `/v3/chats`, override wins, no-silent-substitution semantics.
* **Wiring** — `linq-continuation-message-wiring.test.ts` source-greps the provider file to catch a future refactor that inlines the string literal, moves the attachment into the wrong endpoint branch, or swaps `??` for `||`.

### New DB schema

Migration [`supabase/migrations/20260902_02_all_users_linq_assignment.sql`](../../supabase/migrations/20260902_02_all_users_linq_assignment.sql) adds four nullable columns to `all_users`, plus [`20260925_01_all_users_linq_chat_id.sql`](../../supabase/migrations/20260925_01_all_users_linq_chat_id.sql) adds a fifth for the Gap #1 self-heal path:

```
linq_assigned_number         text        NULL   -- E.164 of the line Linq picked (self-heals from response `from`)
linq_assigned_vcf_url        text        NULL   -- signed .vcf URL (short-lived)
linq_assigned_at             timestamptz NULL   -- assignment / last self-heal time
linq_contact_card_shared_at  timestamptz NULL   -- last share-card call for this chat
linq_chat_id                 text        NULL   -- UUID of the bound Linq chat (Gap #1 self-heal)
```

Plus a `NOT VALID` consistency check (either fully unassigned or fully assigned) on the first two columns. `linq_chat_id` is intentionally NOT part of the check — a freshly-enrolled user has `linq_assigned_number` populated but `linq_chat_id = NULL` until first outbound, and adding it to the check would force chat creation at enrollment (defeating self-heal).

### Testing the Phase 3 flow in sandbox

Before go-live, run through this end-to-end on staging to prove the plumbing works:

1. **Contact cards** — run the setup script against the sandbox pool:
   ```
   npx dotenv -e .env.local -- npx tsx scripts/linq/setup-contact-cards.ts --dry-run
   ```
   Confirm it prints the CREATE / PATCH / unchanged plan. Re-run without `--dry-run` to apply.
2. **Onboarding via admin endpoint** (enroll your own test clerk\_id; set `STAGING_BASE_URL` to your staging origin, referenced as `<staging-origin>` below):
   ```
   curl -X POST "$STAGING_BASE_URL/api/ops/linq/enroll" \
     -H "Authorization: Bearer $CRON_SECRET" \
     -H "Content-Type: application/json" \
     -d '{"clerk_id":"user_YOUR_ID","mode":"discover"}'
   ```
   Confirm the response shows `status: 'assigned'` and query the DB to see `linq_assigned_number` populated on that row.
3. **Onboarding via Clerk webhook** — set `LINQ_ONBOARD_NEW_USERS_MODE=discover` on staging, sign up a fresh account, confirm the row lands with `linq_assigned_number` filled but `sms_provider` still NULL.
4. **Contact-card share** — set your own row `sms_provider = 'linq'`, text the sandbox line, confirm Silvia replies AND a `share_contact_card` request lands in the Linq dashboard's request log. Text her again within 24h — confirm no second share (cooldown gate). Wait 24h+ or manually clear `linq_contact_card_shared_at`, text again — confirm a fresh share.
5. **Reputation webhook** — the sandbox may not let you force a status change, but confirm the webhook dispatch works by looking at the code path (existing `message.received` events still route through the same endpoint, so if inbound is healthy, dispatch is healthy).

### Go-live checklist

When you're ready to actually route new users through Linq:

1. Have Linq provision production lines (they told us they'd handle this).
2. Run `scripts/linq/setup-contact-cards.ts` against production once the pool is live (populate `LINQ_CONTACT_CARD_IMAGE_URL` first if you want an avatar).
3. Flip production's `LINQ_ONBOARD_NEW_USERS_MODE` from `off` to `discover` — validate the flow on a real signup or two.
4. Flip production's `LINQ_ONBOARD_NEW_USERS_MODE` from `discover` to `enroll` — from this moment forward, every new signup goes through Linq.
5. Announce the migration to the Twilio user base via a one-shot SMS with the new Linq number. Bulk-migrate `sms_provider` as users respond.
6. Monitor the `#silvia-alerts` Slack channel for `LINQ_LINE_DEGRADED` events; file a Linq support ticket to swap any line that stays CRITICAL/FLAGGED > 1 hour.

### Rollback

* **Anywhere in Phase 3**: set `LINQ_ONBOARD_NEW_USERS_MODE=off` so no new users get enrolled. Users already enrolled continue on Linq; roll them back individually with `UPDATE all_users SET sms_provider = 'twilio' WHERE clerk_id = '...'`. Explicit `'twilio'` (NOT `NULL`) because `NULL` defers to the `MESSAGING_PROVIDER` env var (see the routing precedence table earlier in this doc): if that variable is ever set to `linq` account-wide, a `NULL` rollback silently fails to route the user back to Twilio. Pin the value to guarantee the rollback is deterministic regardless of env state.
* **All feature-flags default off**, so a git-only rollback (revert this PR) leaves prod in exactly the state it was before with respect to routing — no cleanup needed.

### Post-migration operational discipline: PostgREST schema-cache reload

Every `all_users`, `sms_unknown_number_leads`, or `silvia_sms_logs` migration that adds a new column MUST be followed by an explicit PostgREST schema-cache reload on the target environment(s). Otherwise PostgREST's in-memory schema cache — which is separate from Postgres's own schema catalog — keeps serving the pre-migration column list until its next auto-reload (typically minutes, occasionally hours).

In September 2026 this class of oversight silently broke every Linq pilot user's first-contact experience for hours: both `all_users.has_texted` (2025 baseline column) and `all_users.linq_contact_card_shared_at` (Aug 2026 column) hit `42703 column ... does not exist` errors from UPDATEs targeting them. The greeting flow's atomic claim in `claimFirstTextGreetingSlot` and the contact-card share flow's timestamp write in `shareContactCardIfNeeded` both returned failure, so pilot users got a normal Silvia reply but no greeting bubbles AND no contact-card popup. `has_texted` isn't even the new column — the cache went stale broadly, which is the failure mode this checklist prevents.

**Deploy checklist** for any migration touching a column read by hot-path inbound webhooks:

1. Apply the migration to the intended remote project. Two supported paths, pick the one that matches your workflow:
   * **Supabase CLI** — link the intended project first (`supabase link --project-ref <staging-or-prod-ref>`), then `supabase db push` to apply pending migration files. `supabase migration up` alone is a local-database command and will migrate your local shadow DB rather than the remote — always use `db push` for remote environments.
   * **MCP `apply_migration`** — call the tool from the environment-scoped namespace (`user-silvia-staging-supabase` for staging or `user-silvia-production-supabase` for production). Each namespace is linked to its specific project ref, so calling the right namespace guarantees the migration lands on the intended remote. Don't rely on ambient `SUPABASE_PROJECT_REF` env resolution.
2. Run `NOTIFY pgrst, 'reload schema';` against the target DB (via the MCP `execute_sql` tool for the same environment namespace, or the Supabase SQL editor for that project). This is a no-op if the cache is already fresh; there is no downside to running it.
3. Verify: run a `SELECT column_name FROM information_schema.columns WHERE table_name = '<table>'` and confirm the new column is present (this proves Postgres sees it; PostgREST is a separate layer).
4. Trigger a smoke UPDATE from the app path that writes the new column and check `postgres_logs` (Supabase MCP `query_logs`, `source = 'postgres_logs'`) for any `column ... does not exist` errors.

**Defensive retry** in code guards against the case where step 2 was missed OR the cache went stale for an unrelated reason. Three hot-path helpers already carry this pattern:

* `lib/messaging/unknown-number-lead.ts::upsertUnknownNumberLead` — retries the upsert with a narrower `.select()` projection when the RETURNING list references a column PostgREST doesn't know about (originally added for `onboarding_sent_at` per CodeRabbit PR #3822). This helper CAN safely retry because the write itself doesn't depend on the missing column.
* `lib/linq/contact-card-share.ts::shareContactCardIfNeeded` — on schema-cache miss specifically for `linq_contact_card_shared_at` (requires both `42703`/`PGRST204` error code AND the message references the column), emits a targeted warn naming the exact remediation command and **skips the share entirely**. Rationale: firing `shareContactCard()` without writing the timestamp would pop a fresh card on every subsequent inbound during the stale-cache window (unbounded per-inbound spam, not "one duplicate"). Missing card on this specific inbound = delayed by one cache-reload window; repeated cards = user-hostile spam. Locked in by unit tests in `lib/linq/__tests__/contact-card-share.test.ts` under the "PostgREST schema-cache-miss detection" describe block, plus near-miss coverage that verifies unrelated `42703`/`PGRST204` errors still preserve fail-closed behavior.
* `lib/messaging/first-text-greeting.ts::claimFirstTextGreetingSlot` — on schema-cache miss specifically for `has_texted` (same tightened check: recognized code AND column-name match), emits a targeted warn log naming the column and remediation command, and skips the greeting. Rationale is identical to the contact-card path: duplicate greeting bubbles = user-hostile, missing greeting = disappointing. The route already logs and skips on any claim error, so the user still gets a normal Silvia reply.

**Detection tightness matters.** The `(recognized code) AND (message contains column name)` check is deliberate — a loose "any 42703 error triggers the fallback" heuristic would silently mask unrelated fail-closed errors (permission failures, deadlocks, schema-cache misses on different columns) as if they were the specific pattern we're tolerating. CodeRabbit flagged the loose form on PR #3857; the tightened form and its near-miss regression tests were added in the same PR.

Any new hot-path helper that reads or writes an `all_users` column added after the baseline schema should adopt one of these two patterns (retry with narrower projection when write is symmetric-recoverable; skip-and-warn when the write depends on the missing column).

## Phase 4 — Premium iMessage UX foundation ([#3741](https://github.com/CFO-Silvia/Silvia-Web/pull/3741))

Ships the three primitives that make Silvia feel "alive" over iMessage: **read receipts, typing indicators, reactions**. All fire-and-forget from the Linq inbound webhook — a Linq API blip cannot delay or fail the reply.

### API-client methods (`lib/linq/api-client.ts`)

| Method | Endpoint | Behavior |
| - | - | - |
| `markChatRead(chatId)` | `POST /v3/chats/:id/read` | Fires immediately after claim; recipient sees ✅ Read within \~1s. iMessage-only (RCS/SMS 204 no-op). |
| `startTyping(chatId)` | `POST /v3/chats/:id/typing` | Bubble auto-clears after 85-90s; refresh every 60s via `typing-keepalive.ts`. |
| `stopTyping(chatId)` | `DELETE /v3/chats/:id/typing` | Explicit clear on error path (before apology send). Redundant on success (sendMessage auto-clears). |
| `addReaction(messageId, {…})` | `POST /v3/messages/:id/reactions` | Built-in tapback types (`love / like / dislike / laugh / emphasize / question`) or custom Unicode emoji. Discriminated union enforces `custom_emoji` iff `type: 'custom'`. |

### Reaction marker

Silvia opts into a tapback by emitting `<!--react:like-->` at the end of an ack reply. The server (in `lib/linq/reaction-marker.ts`):

1. Extracts the reaction type (first marker wins)
2. Strips ALL markers from the reply (never reaches the recipient)
3. Fires `addReaction` on the user's trigger message AFTER `sendSilviaReply` succeeds

Chose a prompt marker over a `react_to_message` tool because: (a) tool calls add round-trips to a path we want `<5s`; (b) Claude tends to over-use tools, and reactions on every reply reads as sarcastic. MVP scope is the six built-in tapbacks only.

## Phase 5 — Voice memo verification + attachment primitives (this PR)

Audit finding: **inbound voice memos already flow end-to-end through Linq today**. Linq wraps voice memos as `type: "media"` parts with an `audio/*` `mime_type`; the existing `processInboundMedia` helper (`lib/messaging/inbound-media.ts`) routes on the audio prefix into the OpenAI `gpt-4o-transcribe` pipeline. This PR is therefore a **hardening + primitives** delivery, not a wire-up.

### Voice memo test coverage

`lib/messaging/__tests__/parse-linq-inbound.test.ts` grows a new `voice memo inbound (audio media parts)` describe block covering:

* Normalization of an iMessage voice memo `MediaPartResponse` into `NormalizedInboundMessage.media[]`
* All 9 Linq-supported audio MIME types (`audio/mpeg`, `audio/x-m4a`, `audio/mp4`, `audio/aac`, `audio/x-caf`, `audio/wav`, `audio/aiff`, `audio/x-aiff`, `audio/amr`) passing the `startsWith('audio/')` check
* Mixed text + audio parts (rare but supported by Linq) — text goes to `body`, audio goes to `media[]`
* `auth: 'none'` on the media item (cdn.linqapp.com URLs are pre-signed; MUST NOT attach a bearer token or S3 rejects with double-auth 403)

### Dropped-part-type observability

`parseLinqInboundEvent` previously silently dropped any part type not in `(text | media)` — including future part types like `link`, `imessage_app`, `app_clip`, or a hypothetical first-class `voice_memo`. This PR adds a `console.warn` aggregated to one log line per envelope, formatted as:

```
[messaging] Linq inbound had 2 dropped part(s) event_id=... trace_id=... chat_id_tail=... types=imessage_app,link
```

Behavior for unknown types is unchanged (still dropped). This is observability-only so ops notices if Linq introduces something new we haven't wired.

### Attachment primitives (`lib/linq/api-client.ts`)

Two-step upload flow per Linq's docs:

| Method | Endpoint | Purpose |
| - | - | - |
| `createAttachment({filename, content_type, size_bytes})` | `POST /v3/attachments` | Returns `{ attachment_id, upload_url, download_url, expires_at, required_headers }`. Presigned URL valid 15 minutes. |
| `uploadAttachmentBytes({uploadUrl, requiredHeaders, body, signal?})` | PUT to presigned URL | S3-shaped upload. **MUST NOT** attach bearer auth (double-auth 403). Uses the exact `required_headers` returned in step 1. |
| `uploadAttachment({filename, content_type, size_bytes, body, signal?})` | Chains steps 1 + 2 | Convenience wrapper for the common case. Returns `{ attachmentId, downloadUrl }` ready to embed in a media part. |
| `deleteAttachment(attachmentId)` | `DELETE /v3/attachments/:id` | Housekeeping for one-shot attachments (avoids persistent-tier storage bloat). |

**File size limits**: URL-based media parts cap at 10MB; pre-uploaded attachments cap at 100MB. Rejected file types (per Linq): WebP, SVG, FLAC, OGG, executables.

**Attachment lifetime**: Persistent-tier stays until you `DELETE`. Ephemeral-tier auto-expires in 24-48h (opt-in via Linq support for regulated content).

No call sites yet — the primitives are added ahead of Phase 6+ tools that generate PDFs (financial statements) and images (charts, radar snapshots).

## Phase 6a — Threading primitives (this PR)

iMessage native inline-reply support end-to-end: when a user long-presses a Silvia message and hits Reply, both directions of that thread survive Silvia's storage and Silvia's own reply threads back to the same anchor. This is what makes multiple conversations feel like separate threads on the recipient's device instead of one flat SMS log.

### DB schema (`supabase/migrations/20260904_01_silvia_sms_logs_parent_message_id.sql`)

New column `silvia_sms_logs.parent_provider_message_id text NULL` plus a partial index. Column vs. `related_message_sid`:

| Column | Direction | Meaning |
| - | - | - |
| `related_message_sid` | Outbound row -> triggering inbound | "This Silvia reply was fired in response to that user inbound." Set on every outbound. |
| `parent_provider_message_id` | Inbound row -> earlier Silvia msg AND outbound row -> same earlier Silvia msg | "The user long-pressed and replied to that earlier Silvia message." Only set when native inline-reply is used. |

The two columns are orthogonal; a threaded exchange populates both. Partial index `idx_sms_logs_parent_provider_message_id (clerk_id, parent_provider_message_id, created_at DESC) WHERE parent_provider_message_id IS NOT NULL` keeps the index small (majority of rows have NULL) while supporting the threaded-history walk. It is a FILTER + ORDER acceleration, NOT an index-only scan: the walker also projects heap-only columns (`message_content`, `direction`, `twilio_message_sid`) so the planner still has to fetch each matching row. Adding those columns to an INCLUDE clause would make the walk index-only but the storage cost of duplicating `message_content` bytes into the index outweighs the small N-per-thread heap fetches this powers. See the migration header for the same rationale in code.

### Types (`lib/messaging/types.ts`)

* `SendMessageInput.replyTo?: { messageId: string; partIndex?: number }` — the outbound anchor. Twilio ignores it (SMS has no inline-reply primitive); Linq serializes it as `message.reply_to`.
* `NormalizedInboundMessage.parentProviderMessageId?: string | null` — set on inbound when the webhook envelope includes `data.reply_to.message_id`; stored with a `linq_` prefix so it matches the storage form of `twilio_message_sid`.

### Linq provider (`lib/messaging/providers/linq.ts`)

* Extracts `input.replyTo.messageId`, strips the `linq_` storage prefix (idempotent — bare ids pass through), and emits `{ reply_to: { message_id, part_index? } }` inside the outbound `message` object. Empty `messageId` gracefully degrades to a top-level send (avoids a Linq 400).
* Every chunk of a multi-part reply carries the same `replyTo`, so a >1590-char answer still renders as one thread bubble on the recipient's device.

### Inbound parser (`lib/messaging/index.ts`)

* Reads `envelope.data.reply_to?.message_id` on the Linq webhook.
* Prefixes with `linq_` and populates `NormalizedInboundMessage.parentProviderMessageId`.
* Guards: null-object `reply_to`, empty string `message_id`, and missing field all resolve to `parentProviderMessageId: null`.

### Inbound route (`app/api/messages/incoming/linq/route.ts`)

* Claim-row insert stamps `parent_provider_message_id` on the inbound row.
* When the inbound is threaded, does a bounded lookup of the parent's stored content (via `silvia_sms_logs.twilio_message_sid`), sanitizes it (strips compliance footer / chunk prefix / signature), and prepends a `[User is replying to your earlier message: "…"]` block (≤300 chars) to the effective body handed to Claude. This lets Claude see the parent as inline context even if it scrolled out of the 20-row history window.
* Calls `sendSilviaReply({ replyTo: { messageId: normalized.providerMessageId } })` when the inbound was threaded — Silvia's reply anchors to the **user's inbound** (iMessage convention: each reply anchors to the message immediately above it in the visual thread), so subsequent turns stay grouped in the same thread bubble.

### `sendSilviaReply` (`lib/messaging/outbound.ts`)

* Accepts `replyTo` and forwards it to `provider.sendMessage` on every chunk of a multi-chunk send.
* Writes `parent_provider_message_id` from the dedicated `input.parentProviderMessageId` storage anchor (falling back to `null`) on the outbound audit row so the history walker can reconstruct the thread from either direction. `parentProviderMessageId` is separate from `replyTo` on purpose: `replyTo.messageId` is the WIRE anchor (the message immediately above Silvia's reply in the visual thread, always the user's inbound per iMessage convention), while `parentProviderMessageId` is the STORAGE anchor (the earliest Silvia message the user's thread walks back to). Conflating them into `replyTo.messageId ?? null` was the exact audit-link-mismatch bug Bugbot flagged as PR #3745 B2 "Outbound parent id does not match thread root"; the split shape prevents its return.

### Behavioral notes

* **Twilio still works** — `replyTo` is silently ignored; Twilio users continue to see flat SMS.
* **Non-Linq inbound rows are unaffected** — the Twilio inbound path never writes to `parent_provider_message_id`.
* **Deep research follow-up (radar/DR completion)**: Phase 6a is the primitive; Phase 6b wires the `/api/sms/research-complete` endpoint to fire a threaded completion send using this same `replyTo` API. Until 6b ships, DR completion still goes as a top-level message.

### Test coverage

* `lib/messaging/__tests__/linq-provider.test.ts` — reply\_to serialization, storage-prefix stripping, part\_index forwarding, graceful empty-id degradation.
* `lib/messaging/__tests__/parse-linq-inbound.test.ts` — `reply_to.message_id` extraction, `linq_` prefix application, null/absent/empty guards.
* `lib/messaging/__tests__/outbound.test.ts` — provider receives `replyTo`, every chunk of a multi-part reply carries the same anchor, audit row stores `parent_provider_message_id`.

## Phase 6b — Async deep research SMS completion (this PR)

The last piece of the "feel like a real human" iMessage UX: a user texts "run a deep research on Tesla", Silvia acks and thumbs-up-taps their trigger, then 20-90 minutes later the completion SMS lands threaded to that same trigger — so the conversation stays in one iMessage bubble instead of scattering across the timeline. Fully out-of-band from any active turn (the DR run is async on a separate service).

### Handoff table (`supabase/migrations/20260904_02_dr_sms_notifications.sql` + `20260905_01_dr_sms_notifications_rls.sql` + `20260905_02_dr_sms_notifications_send_lease.sql`)

New `dr_sms_notifications` table with UNIQUE(`research_session_id`). One row per SMS-triggered DR run. Server-write-only (service-role bypasses RLS), no client policies, `anon`/`authenticated` GRANTs revoked so a permissive policy added by mistake in the future still can't grant read access. The RLS + REVOKE statements now live in BOTH the creation migration (`20260904_02_...`; backfilled per CodeRabbit PR #3747 Critical/Major so fresh deployments get RLS in a single migration and never have a CREATE-then-ALTER window) AND the follow-up migration (`20260905_01_...`; retained because `schema_migrations` in prod and staging already records it, removing it would create a divergence, and re-running the idempotent `ENABLE ROW LEVEL SECURITY` + `REVOKE` statements on already-migrated environments is a no-op). The follow-up's header remains the audit trail for the original emergency apply. Second follow-up migration `20260905_02_...` adds `send_lease_expires_at` (nullable timestamptz) + a partial index on active leases; used by the completion route as an expiring mutex to serialize concurrent send attempts (see the "Idempotency + compliance" section below and the migration header for the race being closed).

| Column | Purpose |
| - | - |
| `research_session_id` | Matches `research_sessions.session_id`. UNIQUE → the completion endpoint is idempotent on DR-backend retries. |
| `clerk_id` | Ownership check. Duplicated from trigger context so completion lookup avoids joining the 18k-row `research_sessions` table. |
| `source_provider_message_id` | BARE provider id (Linq raw uuid, Twilio SID) of the user inbound that triggered DR. Used as the WIRE `reply_to.message_id` on the completion send. |
| `parent_provider_message_id` | STORAGE-formatted id (`linq_<uuid>`) written into `silvia_sms_logs.parent_provider_message_id` on the completion row so the Phase 6a thread-walker matches. |
| `sent_at` | Idempotency slot. Non-null → completion already shipped; endpoint 200s without re-sending. |
| `send_error` | Failure trail (`account_deleted`, `sms_not_enabled`, `send_failed: <msg>`, …). |
| `send_attempt_count` | Bumped on every attempt. Diagnostic-only. |

Choice of a new table over columns on `research_sessions`: `research_sessions` is owned by a separate out-of-repo DR service, and per-notification threading anchors (frozen at trigger time) don't belong on the shared research row.

### `ToolContext` widening (`app/api/claude/types.ts`)

Three new optional fields plumbed from SMS callers through `runSilviaChatTurn` → `generateWithTools` → executor:

* `sourceProviderMessageId?: string` — WIRE anchor (bare uuid).
* `parentProviderMessageId?: string | null` — STORAGE anchor (linq-prefixed for Linq, raw SID for Twilio).
* `messagingProvider?: 'linq' | 'twilio'` — trigger-time provider (diagnostic; completion re-resolves the current provider).

Webapp / cron / test callers leave these unset — the executor's SMS-handoff branch is guarded on `sourceProviderMessageId` being present.

### Tool executor (`app/api/claude/tools/executors/create-deep-research.ts`)

Re-adds `create_deep_research` to the Claude tool registry after Phase 0's removal. Removed from `SMS_EXCLUDED_TOOLS` in `lib/messaging/silvia-chat-turn.ts` (Linq SMS path). **Deliberately KEPT** out of two other paths pending Linq-pilot data:

* **Twilio SMS** — excluded via `smsExcludedTools` in `app/api/sms/incoming/route.ts` (see the `SCOPING` docstring above the array). Rationale: 48,357 prod Twilio users would suddenly get unprompted completion SMS 20-40 min after a text, and Twilio has no inline-reply primitive to anchor the completion to the trigger visually. Regression test at `app/api/sms/incoming/__tests__/excluded-tools.test.ts`.
* **Webapp chat (`/api/claude`)** — excluded via `WEBAPP_EXCLUDED_TOOLS` in `lib/claude/chat-turn.ts` (see the `SCOPING` docstring above the set). Rationale: the webapp already reaches the same DR backend today via `execute_python` + the `silvia-deep-research-skills` skill (per the "Deep Research" block in `app/api/claude/config/system-prompt.ts`, e.g. prod session `session_c678ee34` on 2026-09-08). Advertising the dedicated tool alongside the existing skill path would give Claude a redundant shortcut with a terser canonical ack in place of the elaborate context-first response the skill pattern produces, drifting the webapp UX during the pilot. Regression test at `lib/claude/__tests__/webapp-excluded-tools.test.ts`.

When Linq data supports each wider rollout, remove the entry from the corresponding excluded set + delete the guard test + update this doc. Webapp re-enable also requires updating the "There is no dedicated research tool" language in the "Deep Research" block of `app/api/claude/config/system-prompt.ts` so Claude knows both paths exist.

Flow:

1. Validate context (`clerkId` required; DR API key from env; question non-empty).
2. POST to the DR backend (`SILVIA_DEEP_RESEARCH_API_URL`/`/api/analysis/dispatch`) with `{ question, clerk_id }` and read the synchronous JSON response `{ session_id, task_arn, status, status_url }`. The dispatch endpoint creates the Supabase session row + spawns the ECS Fargate worker task and returns immediately; the analysis then runs on the worker for 20-40 min. The /research page reads live progress via Supabase Realtime. (Historical note: an earlier version of this executor POSTed to `/api/analysis/stream` and parsed an SSE frame for `session_id`. That endpoint has never existed in the deployed DR backend — `/openapi.json` lists only `/api/analysis`, `/api/analysis/dispatch`, `/api/analysis/sessions`, `/api/analysis/status/{session_id}` — so every SMS DR trigger 404'd in \~7s and the user got "temporarily unavailable" back. Fix: call the endpoint that actually exists and parse plain JSON.)
3. If this is an SMS turn (`sourceProviderMessageId` present), UPSERT `dr_sms_notifications` with `onConflict: research_session_id, ignoreDuplicates: true` — Claude tool-loop retries produce exactly one row.
4. Capture `deep_research_started` PostHog event, source-tagged (`sms` vs `chat_claude`).
5. Return JSON envelope with a canonical `ack_message` for the model to paraphrase (keeps "I'll text you back on this thread" contract stable across trigger paths).

Every failure path returns `{ success: false, error }` — the executor never throws. Handoff-row write failure is non-fatal (DR is already accepted; email fallback still fires).

### Completion endpoint (`app/api/sms/research-complete/route.ts`)

`POST /api/sms/research-complete` with body `{ userId, session_id, question? }`.

Dual auth (matches `/api/email/scenario-complete`):

1. `X-Internal-API-Token` header equals `INTERNAL_API_TOKEN`, OR
2. Session-ownership fallback: `session_id` resolves to a `research_sessions` row where `clerk_id = userId AND status = 'completed'`.

Rationale for the fallback: the DR backend deploys separately, so a token-only gate would drop every SMS completion until the backend also ships the header.

Idempotency + compliance:

* Handoff row missing → 200 skipped (`no_sms_handoff`). Only SMS-triggered runs get a completion SMS.
* `sent_at IS NOT NULL` → 200 skipped (`already_sent`). Prevents double-send on DR-backend retries.
* Atomic in-progress claim before send via an EXPIRING LEASE (`send_lease_expires_at` column added in migration `20260905_02_dr_sms_notifications_send_lease.sql`; TTL 2 minutes). Claim UPDATE is filtered on `sent_at IS NULL AND (send_lease_expires_at IS NULL OR send_lease_expires_at < NOW())`, so exactly one concurrent caller matches at a time. **Loser branch always returns 503 + `Retry-After: <ttl_seconds_remaining>` when the lease is active**, regardless of whether `send_error` is set. Rationale: any 200 response on a losing claim tells the DR-backend caller "we're handling it" and typical backends treat that as terminal (no more retries). Two failure modes make that response wrong: (1) `send_error IS NOT NULL` + lease active means a prior winner failed ambiguously and is intentionally holding the lease. The winner will not itself retry, so the DR backend MUST retry after the lease expires (Bugbot PR #3747 round 4 "Held lease drops completion retries"). (2) `send_error IS NULL` + lease active is ambiguous between "winner genuinely in-flight" and "winner silently dead" (Vercel `maxDuration` kill mid-send, OOM, uncaught rejection between claim and the `sendSilviaReply` try/catch). If the winner is dead, no `sent_at` and no `send_error` are ever stamped and the completion SMS is lost (Bugbot PR #3747 round 5 "In-flight 200 drops completion retries"). 503 + `Retry-After` is safe in BOTH sub-cases: the retry after the lease TTL either hits `sent_at IS NOT NULL` early-return (`already_sent`, 200) if the winner succeeded during backoff, or re-claims the (now-expired) lease and re-sends if the winner died silently. The `leaseState` log field discriminates the two sub-cases for ops dashboards (`prior_ambiguous_failure` vs `winner_in_flight_or_dead`); the `concurrent_claim` reason code from earlier iterations is retired. Success clears the lease alongside the `sent_at` stamp. Failure handling (Bugbot + CodeRabbit PR #3747 follow-up):
  * **Compliance-gate skip** (user missing, deleted, opted-out, no phone): `recordSendFailure(..., { releaseLease: true })` clears the lease alongside `send_error` so a retry re-claims and hits the same gate immediately (200/skipped).
  * **Definitive provider rejection** (4xx from Twilio/Linq API: invalid number, blocked recipient). Classifier `isDefinitiveSendRejection` reads three shapes: `.status` (Twilio SDK `RestException`), `.httpStatus` (`LinqApiError` from `lib/linq/api-client.ts`), and an `HTTP <NNN>` regex fallback on `.message` for the plain `Error` thrown by `lib/messaging/providers/linq.ts` (Bugbot PR #3747 flagged that reading only `.status` misclassified every Linq 4xx as ambiguous). Result: `recordSendFailure(..., { releaseLease: true })` clears the lease so ops can manually correct the row without waiting for the TTL.
  * **Ambiguous provider failure** (5xx, timeout, network error): `recordSendFailure(..., { releaseLease: false })` HOLDS the lease. Twilio's Node SDK doesn't honor a per-message `Idempotency-Key` on the messages endpoint today, so the DB lease is the sole dedup mechanism for Twilio users; releasing on ambiguous failure would allow a fast retry to send a duplicate if the provider had actually accepted the first message and the response merely timed out. A DR-backend retry landing during the held-lease TTL gets 503 + `Retry-After: <ttl_seconds_remaining>` (the unified loser branch above) and waits until the TTL expires before re-claiming for a fresh send.
  * **Infrastructure failure after claim** (user-lookup DB blip, unexpected throw caught by the outer catch): explicit inline `releaseLeaseOnly` (no `send_error` stamp because nothing was attempted), lease cleared so a retry can re-claim right away.
  * The TTL only bounds recovery when the handler process dies between claim and either terminal state. Stable idempotency key `research-complete:<session_id>` also flows to Linq for provider-layer dedup as second line of defense (and is now propagated to `sendSMS` for Twilio too, as a log-only correlation id until Twilio adds native support). NOTE: an earlier fix used a compare-and-swap on `send_attempt_count`; Cursor Bugbot correctly flagged that as insufficient because counter-CAS is optimistic concurrency, not mutual exclusion (a retry that arrives after the first claim commits sees an advanced counter and can still claim the next slot while the first send is in flight). The lease replaces that.
* Phone stored without `+` prefix → normalized to E.164 (matches `radar-complete` / `token-limit-paused`) before the provider send so a legacy row does not burn the attempt.
* Handoff row `clerk_id ≠ userId` → 403. Defense-in-depth against a backend bug POST'ing the wrong userId.
* User missing / `account_deleted` / `sms_opt_in = false` / no phone → 200 skipped with a `send_error` stamped so the row shows the reason.
* `getProviderForClerkId` re-resolves the CURRENT provider (handles Linq↔Twilio user migration between trigger and completion).

Send:

* **Provider-aware shape** (link-preview split for Linq iMessage OG unfurl):
  * **Linq (2 bubbles)** — iMessage's OG unfurl only renders a preview card when the URL is the ENTIRE bubble body; inline URLs render as blue autolinks with no card. Fixed post-first-prod-DR (session `5db46371`) which shipped inline and got no card:
    * Bubble 1: `Your deep research is ready.` (self-contained; still reads sensibly if bubble 2 fails).
    * Bubble 2: bare URL `https://www.cfosilvia.com/research?session=<id>` on its own so iMessage renders the OG preview card underneath.
    * Ordering: bubble 2 fires AFTER the `sent_at` stamp — a bubble 2 failure can't undo idempotency and cause DR-backend retry to duplicate bubble 1. If bubble 2 fails, one fallback attempt fires with URL inline (`Your research: <url>`). If fallback also fails, response is still 200 — bubble 1 is the compliance-critical delivery, the URL is a UX enhancement.
    * Distinct idempotency keys per bubble (`research-complete-1:<id>`, `research-complete-2:<id>`, `research-complete-2-fallback:<id>`) so Linq's dedup layer doesn't collapse them.
    * **Endpoint pin (`forceManagedLine: true`)** — all three bubbles pass `forceManagedLine: true` so Linq routes them through `POST /v3/messages` (managed line-selection) uniformly. Without the pin, bubble 1 (URL-free) would route through `POST /v3/chats` on the branded line under prod's `LINQ_FROM_NUMBER="+12062619826"` config, while bubble 2 (bare URL) would route through `POST /v3/messages` on Linq's managed line. Linq's multi-line pool could then pick different physical lines for the two endpoints and split the sequence across two separate iMessage conversations on the recipient's device (replyTo threading only anchors WITHIN a conversation — a cross-line send lands as an unanchored fresh message in a different thread, silently defeating the whole split UX and delivering the URL from an unfamiliar sender). CodeRabbit PR #3817 flagged this. See the `forceManagedLine` docstring on `SendSilviaReplyInput` (`lib/messaging/outbound.ts`) and `SendMessageInput` (`lib/messaging/types.ts`).
  * **Twilio (1 bubble, legacy)** — `Your deep research is ready — tap through to view it: cfosilvia.com/research?session=<id>`. SMS is text-only (no OG unfurl layer), so splitting would just double per-message cost with zero UX upside. Path also applies during a Linq→Twilio user migration mid-DR (provider re-resolved at completion time via `getProviderForClerkId`). `forceManagedLine` is NOT set on this path — Twilio ignores it at the wire level (no equivalent multi-line pooling), but explicitly omitting it documents that the pin is Linq-only.
* `sendSilviaReply` with `replyTo: { messageId: <bare wire id> }` + `parentProviderMessageId: <linq_-prefixed>` when both anchors are present; else flat send. Both bubbles (on Linq) thread to the SAME trigger message (NOT to each other) so both render as replies-to-trigger.
* `usedResearchTools: false` so the generic `RESEARCH_POINTER_URL` doesn't append (the session-scoped URL in the body is strictly better). The upstream ack turn ALSO sends `usedResearchTools: false` even though it called `create_deep_research`, because the shared `RESEARCH_ACK_POINTER_TOOLS` list in `lib/messaging/research-tools.ts` deliberately excludes `create_deep_research` (Bugbot PR #3747 round 5 "Ack appends premature research link": the tool kicks off async work, doesn't return viewable results, so the ack must not link to a page with nothing new on it yet).
* Bumps `send_attempt_count` before the send; stamps `sent_at` (with `is('sent_at', null)` race guard) after success. Provider send failure → 502 + `send_error` stamped so the DR backend can retry.

### System prompt (`lib/messaging/silvia-chat-turn.ts`)

Added a `DEEP RESEARCH TRIGGERS` section teaching Silvia to:

* Trigger `create_deep_research` on explicit "deep research / deep dive / full analysis" asks, not on quick lookups (which stay on `quant_analyst` / `execute_python`).
* Paraphrase the tool's `ack_message` into one short sentence and end the reply with the `<!--react:like-->` marker so the trigger message gets a 👍 tapback.
* Handle `success: false` gracefully (one-sentence "temporarily unavailable" without the reaction marker).

### Behavioral notes

* **Twilio does NOT trigger `create_deep_research`** — the tool is deliberately kept in `smsExcludedTools` on the Twilio path (see the `SCOPING` block in `app/api/sms/incoming/route.ts` and the regression test in `app/api/sms/incoming/__tests__/excluded-tools.test.ts`), so the \~48,357 prod Twilio users receive **no** deep-research completion SMS from this flow. Rollout to Twilio pending Linq-pilot data; when flipped, the executor + completion endpoint + `dr_sms_notifications` table already handle the Twilio branch (flat completion SMS, no `reply_to`) correctly.
* **Webapp chat unchanged** — the webapp already reaches the same DR backend via `execute_python` + the `silvia-deep-research-skills` skill (per the "Deep Research (one-time deep analysis)" block in `app/api/claude/config/system-prompt.ts`, evidenced by prod session `session_c678ee34` on 2026-09-08). The dedicated `create_deep_research` tool is filtered out of the webapp tool list via `WEBAPP_EXCLUDED_TOOLS` in `lib/claude/chat-turn.ts` so the two paths don't compete; the webapp keeps its existing elaborate context-first response, and only SMS gets the terse ack + threaded completion.
* **Email completion is preserved on webapp** — the existing `/api/email/scenario-complete` endpoint still fires alongside every DR run; webapp-triggered runs deliver via email only, SMS-triggered runs deliver via BOTH email AND the new threaded SMS callback.

### Test coverage

* `app/api/claude/tools/executors/__tests__/create-deep-research.test.ts` (11 tests) — happy path SMS + webapp shape, config guards, validation, DR-backend failure paths (5xx, no session frame, AbortError), non-fatal handoff-row insert failure, PostHog event shape.
* `app/api/sms/research-complete/__tests__/route.test.ts` (40 tests): validation, dual-auth (token / ownership / wrong-token / ownership-filter enforcement), idempotency, compliance gates (`account_deleted` / `sms_not_enabled` / `no_phone_number` / cross-user handoff), happy path with threading + audit-row writes, graceful flat send when anchors absent, 502 + error stamping on provider send failure, atomic lease-claim behavior (concurrent claim loses -> 503 + `Retry-After` in BOTH sub-cases: prior-ambiguous-failure AND winner-in-flight-or-dead; post-failed-claim refetch honors LIVE null lease over stale snapshot; refetch DB blip falls back to snapshot for defence in depth; winner-completed-between-lookup-and-claim short-circuits to `already_sent`; end-to-end silently-dead-winner recovery via re-claim after TTL; expired lease re-claims; ceiling of `Retry-After` at 1 second; PII redaction in log field), classifier coverage across all three provider error shapes (Twilio `.status`, `LinqApiError.httpStatus`, plain-Error `HTTP <NNN>` message fallback; 5xx still HOLDS lease), infra-failure paths (user-lookup 500 releases lease, outer-catch release on unexpected throw), phone normalization (`+`-less rows).
* `lib/messaging/__tests__/research-tools.test.ts` (6 tests): `RESEARCH_ACK_POINTER_TOOLS` policy guard (empty today, `create_deep_research` absent), `shouldAppendResearchPointer` behavior across empty / DR-only / mixed-with-DR / unrelated-tool inputs. Locks the Bugbot PR #3747 round 5 "Ack appends premature research link" fix.

## Voice call forwarding (Linq → Twilio → ElevenLabs)

Linq is an iMessage/SMS platform and does not carry voice. Every Linq pool line must forward voice calls to a number that DOES carry voice — today that's our Twilio number (`+18312154002`), which ElevenLabs' native Twilio integration owns and routes to the voice agent.

### How it works

```
Caller dials Linq pool line (+1 206 261 9826)
   │
   ▼
Linq's carrier sees no voice handler → forwards to
   │
   ▼
Twilio number (+1 831 215 4002) — ElevenLabs' native
Twilio integration is bound to this line
   │
   ▼
ElevenLabs voice agent picks up + calls back to
POST /api/incoming-call-initial-webhook with the
ORIGINAL caller_id preserved through the forwarding hop
```

Caller ID preservation was verified live on 2026-09-09 against Linq's first pool line, confirming ElevenLabs recognizes the original caller (not the Linq forwarding line).

### Configuration

* **Env var**: `LINQ_VOICE_FORWARDING_NUMBER=+18312154002` (see `.env.example`). Decoupled from `TWILIO_PHONE_NUMBER` so voice forwarding stays put if SMS Twilio ever moves to a different line. **Leaving this unset does NOT clear pool forwarding** — the sync script aborts with an error to prevent accidentally taking voice calls down. Operators who want to clear must pass `--clear` explicitly.
* **Linq API**: `PUT /v3/phone_numbers/{id}` with body `{ "forwarding_number": "<E.164>" }`. V3-exclusive, not `PATCH` (verified via `OPTIONS` probe — returns `Allow: PUT`). Exposed via [`updatePhoneNumber`](../../lib/linq/api-client.ts) in the api-client.
* **Sync business logic**: lives in [`lib/linq/voice-forwarding.ts`](../../lib/linq/voice-forwarding.ts) (`resolveLinqVoiceForwardingTarget`, `syncLinqLineVoiceForwarding`, `syncLinqPoolVoiceForwarding`). The script is a thin CLI shell over that module; any behavioral change belongs in the library.

### Operational runbook

Run the sync script **whenever a new Linq pool line is provisioned** (before it goes live to users):

```bash theme={null}
# Preview the change (safe, no writes):
npx tsx scripts/sync-linq-voice-forwarding.ts --dry-run

# Apply for real:
npx tsx scripts/sync-linq-voice-forwarding.ts

# Emergency clear (voice calls will fail after this until you re-sync):
npx tsx scripts/sync-linq-voice-forwarding.ts --clear
```

Script is idempotent (skips lines already at the correct target) and continues on per-line failures so a single Linq API blip doesn't stall the whole pool sync. Non-zero exit if any line fails so a CI wrapper can gate on it.

### Why not a different architecture?

* **Import Linq numbers directly into ElevenLabs**: not possible — ElevenLabs supports Twilio, Exotel, and SIP trunks, not Linq.
* **Two separate numbers exposed to users (Linq for SMS, Twilio for voice)**: worse UX — users saving Silvia's contact card would see the Linq line as their primary `TEL` and dial it for voice, which would fail without forwarding.
* **Linq → SIP direct to `sip.rtc.elevenlabs.io`**: would require per-line SIP trunk auth in ElevenLabs and defeats the point of Linq's simple forwarding primitive. Reconsider only if pool growth demands per-line voice segregation, which is unlikely for the foreseeable future (voice scales by concurrency, not contact count).

### Cost + gotchas

* **Cost**: forwarded calls bill twice — Linq for origination + Twilio for termination. Trivial at low voice volume (voice is a small % of Silvia interactions vs. SMS).
* **STIR/SHAKEN**: modern spam-blocking uses caller ID attestation on the terminating carrier. If a specific downstream carrier ever flags forwarded calls, `sync-linq-voice-forwarding.ts --clear` gets us back to the pre-forwarding state instantly.
* **iMessage/SMS traffic unaffected**: `forwarding_number` is a voice-only setting. Inbound messages continue to flow through `POST /api/messages/incoming/linq` regardless of the forwarding value.

## Compliance footer regime (SMS/RCS reduced-noise mode)

Historically Silvia appended `"\n\n- Silvia\nReply STOP to opt-out"` to **every** outbound SMS/RCS reply on Twilio and Linq (iMessage was already exempt via the `channel === 'imessage'` skip). That's the safest legal posture but not what industry-standard conversational AI products do — ChatGPT SMS, Character.AI SMS, and every 10DLC-registered agent I've compared against treat the footer as an anchoring compliance record, not a per-message decoration.

### What TCPA / CTIA actually require

* **TCPA (federal, FCC-enforced)**: prior express consent + a reasonable opt-out mechanism. Does **not** require opt-out language on every single message — only that the mechanism exist and be honored.
* **CTIA Short Code Monitoring Handbook / MMA guidelines** (industry self-reg, carrier-enforced): recommends opt-out language "clearly and conspicuously." Industry interpretation:
  * First message in a campaign/relationship (this is the compliance record)
  * Periodic reminders (\~30 day cadence)
  * Not required on every reply in an active conversation

Silvia's opt-in confirmation SMS during signup already carries the full compliance footer (via the Twilio-specific opt-in path, not `sendSilviaReply`). Silvia's inbound STOP/UNSUBSCRIBE/OPTOUT/CANCEL/END/QUIT scanner (`lib/messaging/opt-out-scan.ts`) fires on every inbound regardless of footer presence. So the actual opt-in and opt-out mechanisms are decoupled from per-message footer decoration.

### Two regimes, env-driven

Selected by `COMPLIANCE_FOOTER_MODE` — see `.env.example` for the full docstring. Summary:

| Mode | Behavior on SMS/RCS | iMessage |
| - | - | - |
| `every` (default) | Append footer on every outbound. Preserves legacy Twilio + Linq behavior byte-for-byte. | Skip (always) |
| `first_touch_plus_periodic` | Append footer on (a) first outbound ever to this recipient, and (b) first outbound where more than 30 days have elapsed since the LAST footer-bearing outbound to this recipient (configurable via `COMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYS`; anchor is "last disclosure," not "last activity" — see the Rationale section below for details). Skip on all in-conversation replies. | Skip (always) |

Overrides always win regardless of mode:

* `skipComplianceFooter: true` (opt-in / HELP replies carry their own compliance copy)
* Body already ends with `COMPLIANCE_FOOTER` (dedup guard)
* `channel === 'imessage'` (app-to-app exemption)

### Implementation

* `resolveComplianceFooterDecision` (`lib/messaging/outbound.ts`) — pure decision function, exported for direct unit testing. Enforces the override order + mode fork + boundary conditions.
* `getLastOutboundTimestamp` (`lib/messaging/outbound.ts`) — indexed lookup on `silvia_sms_logs (phone_number, direction='outbound', response_sent=true, message_content LIKE '%Reply STOP to opt-out') ORDER BY created_at DESC LIMIT 1`. The `LIKE` filter is load-bearing — it excludes iMessage replies, radar / token-limit system sends, and `skipComplianceFooter` opt-in / HELP confirmations from counting as "compliance touches." Without it a run of exempt sends would silently strip the footer from the next carrier-delivered reply (Cursor Bugbot PR #3836 "Exempt outbounds suppress SMS footers"). Only invoked when mode is `first_touch_plus_periodic` AND no override suppresses (zero DB roundtrips in legacy mode).
* `getComplianceFooterMode` + `getComplianceFooterReminderIntervalDays` — env parsers. Both fail closed to the safer default on any parsing error (typo, wrong casing, non-numeric, whitespace) — deliberate so a config error never silently strips compliance.
* Fail-safe on DB lookup: any error from `getLastOutboundTimestamp` returns `null`, which routes to the APPEND branch in the resolver. A DB blip can never silently strip a footer.

### Rollout runbook

1. Merge the code with `COMPLIANCE_FOOTER_MODE` unset in Vercel (default: `every` — zero behavior change on prod).
2. Set the recommended production values in **staging** Vercel env:
   ```
   COMPLIANCE_FOOTER_MODE=first_touch_plus_periodic
   COMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYS=60
   ```
   Restart deploy.
3. Run the **staging verification matrix** below in full. Do not promote to prod until every check passes.
4. Set the same env vars in **prod** Vercel env once staging validates. Restart deploy.
5. **Rollback**: unset the env vars (or set `COMPLIANCE_FOOTER_MODE=every`). Behavior reverts to legacy on next deploy — no code change needed.

#### Staging verification matrix

Run all six cases against your staging test user (or a burner Linq/Twilio pilot account) before flipping prod. Cases 3–5 cover the periodic reminder boundary that the first-touch-only test in step 3 above misses; case 6 confirms the iMessage skip so a defect there is caught in staging, not in the wild.

For cases 3–5, seed the reference outbound row in staging DB rather than waiting real calendar days — one-off SQL against `silvia_sms_logs` updating the most recent compliance-footer-bearing row's `created_at` to the boundary offset is faster than the wall-clock alternative.

PostgreSQL does not support `UPDATE ... ORDER BY ... LIMIT` (that's MySQL syntax), so scope the update by primary key via a subquery. `silvia_sms_logs.id` is a `uuid` (see `supabase/migrations/20250100000000_baseline.sql` — `silvia_sms_logs_pkey`):

```sql theme={null}
UPDATE silvia_sms_logs
   SET created_at = NOW() - INTERVAL '<N days>'
 WHERE id = (
   SELECT id
     FROM silvia_sms_logs
    WHERE phone_number = '<E.164 without +>'
      AND direction = 'outbound'
      AND response_sent = true
      AND message_content LIKE '%Reply STOP to opt-out'
    ORDER BY created_at DESC
    LIMIT 1
 );
```

Then send a fresh outbound and assert the footer presence per the table:

| # | Setup | Action | Expected footer |
| - | - | - | - |
| 1 | Fresh SMS/RCS (green-bubble) recipient with no prior outbound row. | Send any reply. | **Present** (first-touch anchor). |
| 2 | Same SMS/RCS recipient, most-recent compliance-footer outbound is within the last 24 hours. | Send another reply. | **Absent** (in-window). |
| 3 | SMS/RCS recipient, seed the reference row to `NOW() - 59 days`. | Send a reply. | **Absent** (below the 60-day boundary). |
| 4 | SMS/RCS recipient, seed the reference row to `NOW() - 60 days` **exactly**. | Send a reply. | **Present** (boundary-inclusive: `elapsed >= 60d` triggers the periodic reminder). |
| 5 | SMS/RCS recipient, seed the reference row to `NOW() - 61 days`. | Send a reply. | **Present** (past the boundary). |
| 6 | iMessage (blue-bubble) recipient — either the recipient's Linq envelope carries `service: 'imessage'` or your own iOS device on iMessage. First-ever reply and any subsequent reply. | Send at least two replies, one fresh and one after seeding a stale reference row per cases 3–5. | **Absent** in both cases (iMessage exemption is unconditional). |

If any row disagrees with the expected column, do NOT promote to prod. Roll back to `COMPLIANCE_FOOTER_MODE=every` and investigate `resolveComplianceFooterDecision` + `getLastOutboundTimestamp` unit tests.

### Recommended production values

For **production and staging**, set both env vars in Vercel:

```
COMPLIANCE_FOOTER_MODE=first_touch_plus_periodic
COMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYS=60
```

⚠️  **Product-policy recommendation, NOT legal advice.** The rationale below reflects Silvia's internal product decisions informed by observed peer behavior. It is not a substitute for counsel review or carrier-specific 10DLC audit guidance — confirm both before shipping.

Rationale:

* **iMessage users skip the footer regardless of mode.** This is a product decision informed by (a) iMessage being an Apple app-to-app service rather than a 10DLC/carrier SMS channel, and (b) our review of peer iMessage AI agents (Instinct, Numi, Poke, Character.AI SMS) — none of which surface a STOP footer on iMessage. We do NOT treat this as a settled legal exemption; if counsel or a carrier flags it we will revisit.
* **SMS/RCS (green bubble) users** get disclosure on first-touch plus a periodic reminder after `COMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYS` have elapsed since the last outbound that *carried the footer* (see next bullet on anchor semantics). First-touch disclosure is Silvia's internal recommendation informed by widely observed 10DLC conversational-AI industry norms — we treat it as a practical default, not a settled TCPA/CTIA-published requirement. Confirm with counsel and your carrier's 10DLC audit posture before shipping. The 60-day default for the periodic reminder is likewise Silvia's internal recommendation, informed by observed peer product behavior — it is NOT a CTIA-published cadence. Anchoring well below the "every reply" fail-safe removes the "chatty compliance noise" UX problem for Android/RCS users.
* **Anchor semantics.** The "N days elapsed" check reads from `getLastOutboundTimestamp` in `lib/messaging/outbound.ts`, which filters `silvia_sms_logs` for outbound rows whose `message_content` ends with `Reply STOP to opt-out`. In other words, the interval is measured from the **last footer-bearing outbound**, not from the last outbound of any kind. A recipient in a daily-active conversation whose messages have all been below the footer threshold (e.g. footer suppressed by another gate, or the footer is naturally absent) will still see the footer re-appear once `COMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYS` have elapsed since the last footer-touch, even without a genuine "silence" window. This is intentional — the anchor is "time since last disclosure," which is the audit-relevant metric, not "time since last app-to-user activity."
* **The inbound STOP keyword scanner** (`lib/messaging/opt-out-scan.ts`) is the always-on primary opt-out mechanism. Users don't need to see the footer on every reply to know STOP works — the first-touch disclosure and periodic reminders anchor that knowledge.
* **Choosing the interval:**
  * `30`: most frequent periodic reminders. Safest internal posture; pick this if your Android/RCS cohort is small enough that footer noise isn't a UX concern, or if you want to minimize carrier-audit risk while the pilot is still small.
  * `60` **(recommended)**: middle of the range Silvia has validated internally. Best balance of UX cleanup and reminder cadence.
  * `90`: loosest end Silvia has considered. Not validated internally beyond this point — confirm with counsel and carrier guidance before shipping.

**Do NOT** go past 90 without a legal / carrier review, and do NOT skip the periodic reminder entirely (first-touch only). Either may be defensible in isolation, but both increase the risk profile for 10DLC carrier audits without a compensating internal review.

## Linq dashboard concepts — Inbox vs. Contacts

Linq's dashboard exposes two first-class surfaces that are easy to conflate:

1. **Inbox / Chats** — auto-populated by Linq whenever a phone number sends an inbound message to one of your lines. Silvia's `POST /api/messages/incoming/linq` webhook consumes these events. You cannot turn this off; there's nothing to configure.
2. **Contacts** — Linq's org-level CRM address book. **Not auto-populated.** Empty until you explicitly `POST /api/partner/v2/contacts`. This is what appears under the "Contacts" tab in the Linq dashboard sidebar.

Messaging works whether or not Contacts is populated. Populating it is cosmetic — it makes the "Contacts" tab useful for operators triaging chat threads (labeled name + phone entries instead of raw phone-number tails).

### Silvia's Linq Contacts sync

Silvia populates the Contacts tab automatically. Two paths:

1. **Auto-create on enrollment.** Every successful call to `enrollUserInLinq` (Clerk `user.created` webhook, admin `/api/ops/linq/enroll` endpoint, race-path upgrades) fires `syncLinqContactBestEffort` as a side effect. The helper reads plaintext names from Clerk (`clerk.users.getUser`), normalizes the user's phone to E.164, dedupes against `GET /v2/contacts/find?phone_number=<E.164>`, and creates the record via `POST /v2/contacts`. Fully best-effort — a Clerk lookup failure or Linq API blip logs a warning and never fails the enrollment.
2. **One-shot backfill script** (`scripts/linq/backfill-contacts.ts`) — for the existing pilot cohort at pool bootstrap, or any time you want to force-sync a specific list of users. Reads the roster from an operator-provided JSON file (`LINQ_BACKFILL_ROSTER_FILE` env var) at run time; the script *enforces* that the path is either outside the repo or matches the git-ignored `scripts/linq/roster*.json` pattern, so production clerk\_ids can never be committed by accident. Runs locally against prod. `CLERK_SECRET_KEY` optional: with it, names are fetched from Clerk; without it, contacts are created phone-only and can be enriched later by the auto-create hook on the next enrollment retrigger.

Idempotency: `ensureLinqContact` in `lib/linq/contact-sync.ts` uses `getContactByPhone` for dedup and `updateContact` (PATCH) to self-heal phone-only records once names become available. A second run against the same user is a no-op (or a name-only PATCH), never a duplicate row.

**V2 vs V3 auth.** Silvia's V3 endpoints use `Authorization: Bearer <token>`; the V2 `/contacts` endpoints use `X-LINQ-INTEGRATION-TOKEN: <token>` (same key value, different header). Handled inside `linqFetch` via the `authStyle` option — call sites don't need to think about it.

## Compliance & ZDR (SOC 2 Type 2 posture)

Linq's platform is [SOC 2 Type I + II certified](https://linqapp.com/s/security) — the only iMessage API on the market with Type II. Their 2026 SOC 2 Type II report + March 2026 bridge letter are available on request via [their Trust Center](https://security-report.linqapp.com/). User message data is encrypted on their side and Linq staff cannot access message contents. When onboarding an enterprise customer or auditor, request their SOC 2 report from the Trust Center for your vendor file.

Beyond Linq's own posture, Silvia's own integration has to hold up against two threat models: (1) Linq is breached and messages we sent through them leak; (2) Silvia is breached and every message we've ever routed is in our own DB. **Zero-Day Retention (ZDR)** is Linq's answer to (1). It's a per-line configuration that flips off content persistence at Linq: message and attachment content is stamped in the synchronous send response (outbound) or the first webhook (inbound), and then never written to Linq's database. Later `GET`s, list calls, and delivery-lifecycle webhooks return empty content. Full docs: [Zero-Day Retention](https://docs.linqapp.com/channel/imessage/guides/platform/zero-day-retention/).

### Silvia's code is already ZDR-compatible

An audit against the ZDR contract found that Silvia's integration was written in a ZDR-safe pattern by convention, not because ZDR was on the table when it was designed. Nothing had to change to enable ZDR on a line:

| ZDR requirement | Where Silvia already complies | File |
| - | - | - |
| Never call `GET /v3/messages/{id}` / list / thread later | We only `GET` `/available_number`, `/phone_numbers`, `/contact_card`, `/v2/contacts` — none carry message content | [`lib/linq/api-client.ts`](../../lib/linq/api-client.ts) |
| Download attachments immediately (URLs 404 after first fetch) | Voice memos transcribed and images base64-encoded inline in the webhook handler; URL discarded from the request scope before response | [`lib/messaging/inbound-media.ts`](../../lib/messaging/inbound-media.ts) |
| Never persist attachment URLs | `redactInboundMedia` strips the URL before insert; `silvia_sms_logs.user_context.media` stores only `{contentType, auth, mediaId (hash)}` | [`lib/messaging/inbound-media.ts`](../../lib/messaging/inbound-media.ts) |
| Outbound content from our own construct, not Linq's echo | Outbound `INSERT` uses the body we built; provider only reads back `providerMessageId` + `delivery_status` from the response | [`lib/messaging/providers/linq.ts`](../../lib/messaging/providers/linq.ts) |
| No poll / iMessage-app-card write paths (the two ZDR quirks) | Neither surface used; poll option resend + card `app` identity resupply requirements don't apply | — |

### Observability: `zero_retention` field

Linq stamps a `zero_retention: boolean` on every affected webhook event when the line has ZDR enabled. That is the [only source of truth](https://docs.linqapp.com/channel/imessage/guides/platform/zero-day-retention/#how-to-tell-a-line-is-zero-day-retention) — there is NO endpoint to query the setting directly ("treat this field as the source of truth, per event").

Silvia extracts and persists it into `silvia_sms_logs.user_context.zero_retention` for every claimed inbound. Three states:

* `true` — Linq did not persist this content on their side; Silvia's DB is the only long-lived copy.
* `false` — Linq persisted this content (still SOC 2 Type II encrypted, but a second copy exists).
* `null` — Linq did not send us the field on this event (older payload / non-ZDR-aware event type). Treat as unknown.

Every first-seen inbound also emits a `zdr=<true|false|unknown>` structured log line in the route handler for greppable audit. See [`app/api/messages/incoming/linq/route.ts`](../../app/api/messages/incoming/linq/route.ts) (search for `[Linq webhook] inbound claimed`).

### Auditing whether a line is on ZDR

```sql theme={null}
-- Rolling 24h ZDR mix, GROUPED BY MANAGED LINE + SERVICE.
--
-- `zero_retention` is a Linq PER-LINE setting, so audit queries MUST
-- group / filter by `to_line` (the destination managed line's E.164).
-- A naive service-only aggregate mixes ZDR-enabled and ZDR-disabled
-- lines together and hides drift on the ones you care about — that's
-- the whole point of the extra grouping key.
--
-- Expect: on a ZDR-enabled line, `zdr_true` dominates and both
-- `zdr_false` and `zdr_unknown` are zero. On a not-yet-enabled line,
-- `zdr_false` (or `zdr_unknown` for older events) dominates. Any
-- MIXED value on the same line row is drift and worth investigating.
SELECT
  user_context->>'to_line' AS to_line,
  user_context->>'service' AS service,
  SUM(CASE WHEN user_context->>'zero_retention' = 'true' THEN 1 ELSE 0 END) AS zdr_true,
  SUM(CASE WHEN user_context->>'zero_retention' = 'false' THEN 1 ELSE 0 END) AS zdr_false,
  -- `zdr_unknown` covers BOTH shapes that mean "no info":
  --   (a) key not present at all       — legacy rows before this PR
  --   (b) key present with jsonb null  — Linq did not send the field
  --                                      on this event (our code writes
  --                                      `zero_retention: normalized.zeroRetention ?? null`)
  -- The `->>` (text) accessor collapses both cases into a SQL NULL, so a
  -- single `IS NULL` on the text form catches them. The `->` (jsonb)
  -- accessor does NOT — jsonb null is a valid jsonb value distinct from
  -- SQL NULL, so `-> 'zero_retention' IS NULL` misses case (b) entirely
  -- (which is the common case today). CodeRabbit finding on PR #3865.
  SUM(CASE WHEN user_context->>'zero_retention' IS NULL THEN 1 ELSE 0 END) AS zdr_unknown
FROM silvia_sms_logs
WHERE user_context->>'provider' = 'linq'
  AND direction = 'inbound'
  AND created_at > now() - interval '24 hours'
GROUP BY 1, 2
ORDER BY 1, 2;
```

Per-line verification — check ONE line's ZDR mix after enabling it:

```sql theme={null}
-- Substitute the E.164 you enabled ZDR on. Expect `zdr_true` to be
-- non-zero and dominating for events after the enablement timestamp.
SELECT
  date_trunc('hour', created_at) AS hour,
  SUM(CASE WHEN user_context->>'zero_retention' = 'true'  THEN 1 ELSE 0 END) AS zdr_true,
  SUM(CASE WHEN user_context->>'zero_retention' = 'false' THEN 1 ELSE 0 END) AS zdr_false,
  -- See sibling query above: `->>` collapses missing-key AND jsonb-null
  -- into SQL NULL; `->` does not (jsonb null is a valid jsonb value).
  SUM(CASE WHEN user_context->>'zero_retention' IS NULL   THEN 1 ELSE 0 END) AS zdr_unknown
FROM silvia_sms_logs
WHERE user_context->>'provider' = 'linq'
  AND direction = 'inbound'
  AND user_context->>'to_line' = '+12062619826'  -- replace with the line under test
  AND created_at > now() - interval '24 hours'
GROUP BY 1
ORDER BY 1;
```

### Enabling ZDR on a Linq line

ZDR is toggled per line on Linq's side, not through a public API endpoint. To enable ZDR on Silvia's current + future pool lines:

1. Contact Linq support (or the Linq dashboard's phone-number settings) and request ZDR enablement on the line(s). Silvia's current line is `+12062619826` (id `73c0d1ca-ee7d-4ca3-904b-02b188245cf9`).
2. After Linq confirms, send a probe iMessage through the line.
3. Verify with the SQL above that `zdr_true` is non-zero for the recent window on that line. Expect `false` or `null` before the change (Linq stamps `false` on ZDR-aware event types when the line is not enabled, and `null` on non-ZDR-aware or older event types), and `true` after.

Once ZDR is on, no code changes are needed — the existing route already treats the field as advisory metadata and doesn't gate any behavior on it.

### What Silvia stores on its OWN side (not Linq's problem)

`silvia_sms_logs.message_content` is the full plaintext of every SMS/iMessage/RCS turn (both directions) that Silvia's tools search over for context retrieval. It is behind Postgres RLS + service-role auth, but it is NOT column-encrypted (unlike `first_name`, `last_name`, `email`, `access_token`). Trade-offs:

* **Pro**: `sms-history-search-tool.ts` can grep the corpus without a decrypt-per-row on every retrieval.
* **Con**: A full DB compromise would expose message content.

**Compensating controls covered in follow-up PRs**:

* **Account-deletion completeness** — `/api/account/delete` scrubs the Linq V2 CRM contact immediately; the Silvia-Admin 30-day hard-delete cron purges `silvia_sms_logs`, `dr_sms_notifications`, and `sms_unknown_number_leads` rows. Tracked as PR B1 (Silvia-Web) + PR B2 (Silvia-Admin).
* **Retention policy** — optional pg\_cron for time-based purge of `silvia_sms_logs` rows older than N months. Tracked as PR C.

### Silvia's outbound compliance vs. Linq's ZDR

The two are independent axes:

* ZDR governs the **Linq → Linq's own DB** persistence path.
* Silvia's outbound compliance footer (`COMPLIANCE_FOOTER_MODE`) governs the **Silvia → user** disclosure path (TCPA / CTIA).
* Silvia's `silvia_sms_logs` retention governs the **Silvia → Silvia's own DB** persistence path.

None of the three block the others. Enabling ZDR does not remove the compliance-footer requirement; scrubbing `silvia_sms_logs` on account delete does not require ZDR to be on. Design each independently.

## Radar SMS: provider-split summary cap

The radar completion endpoint (`app/api/sms/radar-complete/route.ts`) fires the "🔔 Radar Alert" outbound when a scheduled radar finishes on the AWS processor. It runs through the provider factory (Twilio for legacy users, Linq for pilot / `sms_provider='linq'` users) and — as of this migration — caps the radar SUMMARY body differently by provider. Prompt preview stays capped at 50 chars for both (it's a header, not a payload).

| Field | Twilio (SMS-only) | Linq (iMessage-first) |
| - | - | - |
| Prompt preview | 50 chars | 50 chars |
| Summary preview | **120 chars** (`SUMMARY_CAP_TWILIO`) with bare `...` | **500 chars** (`SUMMARY_CAP_LINQ`) with graceful `…\n\n(full analysis at the link below)` nudge |
| Delivery shape | Single bubble: `🔔 header + summary + inline "View details:" URL` | Two bubbles: (1) `🔔 header + summary` (no URL), (2) bare URL (triggers iMessage OG unfurl) |

### Why 500 for Linq and not "no cap"

The 500-char Linq cap was chosen against the 30-day production corpus (113,703 completed radars):

| Percentile | Length (chars) | Cumulative % ≤ cap |
| - | -: | -: |
| p50 | 176 | — |
| p75 | 212 | — |
| p90 | 248 | — |
| p95 | 273 | — |
| p99 | 338 | — |
| p99.5 | 396 | — |
| **500 (cap)** | **500** | **99.65%** |
| p99.9 | 5,583 | — |
| max | 14,398 | — |

Key facts driving the choice:

1. **Product intent**: the radar-generation extractor at `aws/radar/lambdas/processor/handler.py:344-372` documents `summary` as `"Brief summary for email subject"` and falls back to `analysis[:200]` when the model omits it. The 500 cap is a 2.5× safety margin over the ≤200-char product intent.
2. **99.65% coverage**: 113,304 of the 113,703 radars in the 30-day window are ≤500 chars and ship unchanged. Only 399 (0.35%) are truncated.
3. **iMessage UX**: bubbles above \~600 chars become multi-screen walls of scroll on iPhone. The `/radar?radar=<id>` URL card is the intended surface for detailed content — it renders `full_analysis` (see `app/(app)/radar/page.tsx:1191`).
4. **The 0.35% tail** is dominated by \~12 power-user accounts producing 3–14k char summaries against schema intent — those should be nudged toward putting deep content in `details` (aka `full_analysis`). See follow-up ticket in the PR description.

The truncation suffix `…\n\n(full analysis at the link below)` explicitly points users to bubble 2 (the bare-URL OG unfurl) rather than leaving a bare `...` that reads as a delivery bug. Compliance-safe (no CTA-alike claim, no footer required — Linq iMessage bubbles never carry the SMS compliance footer).

For SMS users, the 120 cap remains, because:

* SMS segments at 160 GSM-7 chars (70 with any Unicode / emoji — and the 🔔 header alone forces UCS-2, so the practical single-segment limit is 70). Longer summaries multi-segment.
* Per Linq's own [SMS best-practices docs](https://docs.linqapp.com/v2/faqs/sms-best-practices/), delivery reliability drops fast past \~6 segments (\~1,600 chars).
* 120 chars keeps the whole outbound (header + prompt preview + summary + `View details:` URL) safely inside \~2 segments even with UCS-2.

For Linq users in the SMS-fallback edge case (recipient not on iMessage AND not on RCS), Linq prices per-line rather than per-segment so there's no dollar cost hit — the 500 cap keeps the fallback delivery reliable even on a green-bubble path.

### Sanitization order

`sanitizeRadarSummaryForEmail` runs FIRST (provider-independent) with the following steps in order:

1. **XML tool-invocation leak** — truncate at the first `</summary>` closing tag. Recovers the intended brief summary from the `resolve_analysis_body` extractor bug that pulls the entire Anthropic tool-invocation stream into `summary` for \~12 power-user prompts (\~319 mega-radars/mo in production).
2. **Fenced code blocks** (`` ```ui-metric-grid `... ` `` , `` ```json `... ` `` , etc.) — stripped entirely.
3. **Orphan / unterminated fences** — truncate.
4. **Markdown markers** (headings, bold, italic, lists, links, images, strikethrough, inline code) — stripped, inner text kept.
5. **Whitespace normalization** — collapse 3+ blank lines to 1, trim.

If the sanitizer strips the summary to `''`, a fallback string `Your latest radar analysis is ready.` prevents the outbound body from having a blank line between the prompt and the CTA.

**The sanitizer must always run BEFORE any downstream slice / send** — otherwise a slice landing inside a code fence or XML block would ship unreadable garbage.

### Test coverage

`app/api/sms/radar-complete/__tests__/route.test.ts` locks the behavior in with three paired assertions:

* Linq under-cap: `does NOT truncate a Linq summary that fits under SUMMARY_CAP_LINQ=500` — mid-length summary (>120 but ≤500) present verbatim in bubble 1, no ellipsis of any shape.
* Linq over-cap: `DOES truncate a Linq summary above SUMMARY_CAP_LINQ=500 with a graceful "full analysis at the link" nudge` — long summary (>500) sliced at 500 with the graceful suffix, tail characters not present.
* XML leak: `rescues XML tool-invocation-leak mega-radars via the sanitizer` — leaked mega-radar reduced to intended brief by the sanitizer, no XML artifacts survive.
* Twilio describe: `DOES truncate the summary for Twilio users at 120 chars` — long summary sliced at 120 with bare `...` ellipsis, full body NOT present.

`lib/email/__tests__/sanitize-radar-summary.test.ts` covers the sanitizer's XML-leak handling with 6 focused assertions (basic truncation, case-insensitivity, false-positive guards, composition ordering).

The tests are complementary and must always be modified together — any one alone will pass under a broken implementation.

## Radar SMS: per-radar Open Graph preview cards (CFO-8027)

Bubble 2 (bare `/radar?radar=<id>` URL) triggers iMessage's on-device unfurler. Before Sep 2026 the URL always rendered the site-wide generic "Radar — Automated alerts…" preview card, regardless of which radar it linked to. After Sep 2026 it renders a **per-radar** card:

* title = radar's prompt / name
* description = sanitized summary, truncated to ≤200 chars
* image = 1200×630 PNG rendered on-demand at `/api/og/radar/[radarId]` showing the Silvia mark, an `ALERT TRIGGERED` / `RADAR UPDATE` pill, the title, a body snippet, the date, and a `View full analysis →` CTA

This applies to every third-party unfurl surface too (Slack, Discord, X, Facebook, LinkedIn) — the SMS-bubble path is the primary driver but the change is universal to the `/radar?radar=<id>` route.

The existing middleware unfurl-bot bypass (see `lib/middleware/unfurl-bots.ts`) already lets known crawler UAs through the sign-in redirect to reach the page; per-radar metadata rides that mechanism.

Full design + failure modes + safe-subset policy: [`docs/radar/PER-RADAR-OG-CARDS.md`](../radar/PER-RADAR-OG-CARDS.md).

## Deep Research: per-DR Open Graph preview cards

Bubble 2 of the DR completion sequence (bare `/research?session=<id>&utm_source=sms` URL — see `app/api/sms/research-complete/route.ts` line 869) triggers iMessage's on-device unfurler. Before this ship the URL always rendered the site-wide generic "Research — In-depth research reports…" preview card, regardless of which DR it linked to. Now it renders a **per-DR** card:

* title = the user's DR question (sanitized + truncated to ≤80 chars)
* description = a data-dense meta line — duration + source count (e.g. "27 min · 199 sources analyzed — tap for the full analysis.")
* image = 1200×630 PNG rendered on-demand at `/api/og/research/[sessionId]` showing the Silvia mark, a `DEEP RESEARCH` pill, the sanitized question, the meta line, the completion date, and a `View full report →` CTA

Same architecture as the radar OG (see the previous section) — server-component wrapper on `/research/page.tsx` hosts `generateMetadata`, `lib/research/og-fetch.ts` runs the safe-subset lookup, `/api/og/research/[sessionId]/route.tsx` renders the PNG on Vercel Edge. The `unfurl-bots` middleware already lets known crawler UAs through the sign-in redirect.

### Key differences from the radar OG

* **Stricter delivery gate.** DR questions can contain SSN benefits, dates of birth, and dollar-figure planning inputs (verified against the prod corpus). The gate requires either `dr_sms_notifications.sent_at IS NOT NULL` (proven SMS delivery) OR `completed_at` within a 2h grace window. Webapp-triggered DRs with no SMS handoff row that completed >2h ago do NOT get a per-DR card — they fall back to the generic Silvia card to keep the enumeration surface bounded.
* **Different description content.** Radar description repeats the summary; DR description carries the metadata line (duration + source count) so the two-line iMessage card body carries useful info instead of a truncated echo of the (already-title-truncated) question.
* **Session ID shape validation.** Matches `SESSION_ID_FORMAT` in `research-complete/route.ts` (`/^[A-Za-z0-9_-]{1,64}$/`) — deliberately re-uses the shape validator the SMS route already trusts.
* **Shared grace-window constant.** `OG_DELIVERY_GATE_GRACE_MS` is re-exported from `lib/radar/og-fetch.ts` (single source of truth) so future tuning changes land in both surfaces or neither.

Full design + failure modes + safe-subset policy: [`docs/research/PER-DR-OG-CARDS.md`](../research/PER-DR-OG-CARDS.md).

## Deep Research: double-fire guard (2026-09-12 "Thanks → duplicate DR" incident)

### The incident

Shain's prod line, 2026-09-13 02:21 UTC:

```
02:21:30  User:    "Trigger a deep research on projected my net worth over the next decade…"
                    → classifier match → forced tool_choice → DR #1 kicked off (session=0da9f59a)
02:21:40  Silvia:  "On it — this one takes a bit, I'll text you back on this thread when it's ready."
02:21:48  User:    "Thanks"
02:21:55  Silvia:  "You're welcome. It's running now, I'll text you the results here when it's done."
                    → tool_choice: auto → LLM re-invoked create_deep_research → DR #2 (session=5832acde)
02:51:16  DR #1 completion SMS delivered
02:54:05  DR #2 completion SMS delivered (duplicate result, duplicate iMessage unfurl card)
```

Two 20-40 min ECS Fargate DR runs fired on a single user intent. Same compute + tokens spent twice, same report generated twice, two completion SMS'es delivered with two `Your deep research is ready` cards stacked in the user's iMessage thread.

### Root cause

The `detectDeepResearchIntent` classifier CORRECTLY did NOT match `"Thanks"` (no anchor verb, no DR noun) — so `resolveForcedDeepResearchToolChoice` returned `undefined` and the LLM ran on `tool_choice: 'auto'`.

The Linq webhook loads 20 recent messages as chat history for the turn ([`route.ts:1619`](../../app/api/messages/incoming/linq/route.ts)), so the model saw:

> \[radar] → user: "Trigger a deep research…" → Silvia: "On it — this one takes a bit…" → user: "Thanks"

Combined with the DR-tilt language added in PR #3932 to fix Robert's dropped DR ("**When in doubt on a substantive topic, prefer the tool**. Over-serving is recoverable"), the model interpreted `"Thanks"` as an implicit confirmation to finally fire the DR it had just promised — a plausible read for a stateless model that has no way to know DR #1 actually spawned a `research_sessions` row and an ECS worker.

### The fix (two-layer defence)

**Layer 1 — prompt guard** ([`lib/messaging/silvia-chat-turn.ts` — DEEP RESEARCH TRIGGERS section](../../lib/messaging/silvia-chat-turn.ts)):

New bullet added right after the pronoun-only rule. Tells Claude to scan the immediately prior outbound in history for signs that DR was ALREADY promised (the `<!--react:like-->` marker, or any of "text you back", "text you when it's done", "takes a bit", "kicking off", "running now, I'll text"), enumerates the common filler follow-ups (`thanks`, `thx`, `ok`, `cool`, `sounds good`, `great`, `appreciate it`, `perfect`, `awesome`), and instructs the model to reply briefly and **end the turn without calling create\_deep\_research**. Carves out an explicit exception for a NEW DR trigger on a different topic (which the classifier would have already force-invoked upstream).

Pinned in place by [`lib/messaging/__tests__/dr-double-fire-prompt-guard.test.ts`](../../lib/messaging/__tests__/dr-double-fire-prompt-guard.test.ts) — source-grep regression guards over the guard's directive, its ack-recognition signals, the filler list, the "end your turn without calling create\_deep\_research" action clause, the exception carve-out, and the cross-reference to the executor backstop.

**Layer 2 — server-side dedupe gate** ([`app/api/claude/tools/executors/create-deep-research.ts` — Step 0](../../app/api/claude/tools/executors/create-deep-research.ts)):

New Step-0 check runs on SMS turns only (`sourceProviderMessageId` set). Before calling the DR backend's dispatch endpoint, look up `research_sessions` for the same `clerk_id` within `DR_SMS_DEDUPE_WINDOW_MS` (90s). If a row exists, refuse the kickoff and return:

> `success: false, error: "You already have a deep research kicked off about {N}s ago on this line — I'll text you back on this thread when it's ready. If you want a brand-new one on a different topic, tell me the new topic explicitly and I'll spin it up alongside."`

Claude paraphrases per the "if the tool returns `success: false`" branch already in the DR TRIGGERS prompt. Analytics event: `deep_research_sms_dedupe_blocked` (distinct from `deep_research_started`) so the base rate is observable in PostHog.

Design decisions:

* **SMS-only** — webapp DR triggers are explicit button clicks; blocking them on a background check would be wrong UX. The gate protects against LLM tool-choice ambiguity, not user error.
* **90s window** — classic double-fire pattern completes inside 30-60s. 90s gives a margin; legitimate parallel-DR-on-different-topic within 90s is vanishingly rare (user usually hasn't even seen the first ack yet).
* **Fail-open on DB error** — if the dedupe lookup itself errors, proceed with kickoff. Blocking a legit DR on infra blip is worse UX than the rare duplicate the guard would have caught; the prompt guard already reduces the base rate.
* **Explicit new-topic bypass** — when `context.wasForcedByClassifier === true` (i.e., `resolveForcedDeepResearchToolChoice` matched on THIS inbound and forced `tool_choice: create_deep_research`), the gate skips its check entirely. Matches the DEEP RESEARCH TRIGGERS prompt's "explicit NEW DR trigger on a DIFFERENT topic → proceed as normal" exception. Without the bypass, a legit "actually, deep dive on my crypto exposure" fired within 90s of a prior DR would be refused, contradicting the prompt. The classifier is precision-biased (false-positive costs a wasted 20-40 min run) so a match is high-confidence signal that the user actively wants THIS DR to run, even alongside an in-flight one. [`ToolContext.wasForcedByClassifier`](../../app/api/claude/types.ts) is populated by `runSilviaChatTurn` from `Boolean(forcedToolChoice)` on the SMS path and left undefined on webapp/cron paths (which don't run the classifier). CodeRabbit F3 finding on PR #3952.
* **No question text in logs or analytics** — the `deep_research_sms_dedupe_blocked` PostHog event and the executor's `console.warn` payload carry `question_length` / `questionLength` (integer) only, never the question string or a truncated preview. User research questions can contain account numbers, holdings, or other financial PII; the LLM strips identifiers per system prompt but we cannot guarantee it does, and warning-level logs get indexed / retained longer than debug logs. CodeRabbit F2 finding on PR #3952 (CWE-532 fail-closed posture).

**Known limitation — concurrency (F1 on PR #3952, deferred to CFO-8042)**:

The dedupe is a check-then-act sequence, not a transaction. Two distinct DR-shaped inbounds from the SAME user arriving inside the \~1-2s kickoff-dispatch window can both pass their dedupe check (each SELECT returns null before the other's kickoff completes), then both call the DR backend and spawn two runs. That race is real but requires sub-second human typing latency on TWO distinct DR triggers — essentially impossible in practice, and orthogonal to the "Thanks → LLM re-fires within a single turn" bug this PR is scoped to fix. The bulletproof fix is either (a) a Postgres advisory lock (`pg_advisory_xact_lock(hashtext(clerk_id))` inside a transaction wrapping check + kickoff) or (b) a client-generated idempotency key that the DR backend enforces via `INSERT ... ON CONFLICT DO NOTHING` on `research_sessions(clerk_id, idempotency_key)`. Both are heavy lifts (Postgres transaction wrapping in `create-deep-research.ts` or DR-backend contract change) and belong in a follow-up.

### Why both layers

Prompt-only is fragile — the model can ignore natural-language guidance. Executor-only lets the model write awkward text ("It's running now, I'll text you when it's done") before the executor refuses; that text still gets sent to the user and looks wrong. Together the prompt guard prevents the awkward text and the executor guard prevents the duplicate DB row + ECS task + completion SMS.

### Test coverage

* [`lib/messaging/__tests__/dr-double-fire-prompt-guard.test.ts`](../../lib/messaging/__tests__/dr-double-fire-prompt-guard.test.ts) — 7 source-grep tests over the prompt language.
* [`app/api/claude/tools/executors/__tests__/create-deep-research.test.ts` — "SMS Step-0 dedupe gate" describe block](../../app/api/claude/tools/executors/__tests__/create-deep-research.test.ts) — 9 executor tests: refuses within window, DOES NOT dedupe outside window, DOES NOT dedupe on webapp turns, fails open on DB error, pins the window constant + the table name, PII-safety (F2: no question text in logs or analytics, ever), and three F3 bypass tests (`wasForcedByClassifier=true` bypasses; `undefined` gates normally; literal `false` gates normally — pins the strict `=== true` comparison semantics).

## Deep Research: pre-LLM filler-tapback guard (2026-09-13 "Ok → duplicate DR" incident + broader UX win)

### The incident

Shain's prod line, 2026-09-13 07:44 UTC (24 hours after the first "Thanks → duplicate DR" incident above, and AFTER the two-layer fix from PR #3952 had shipped):

```
07:44:27  User:    "Trigger a deep research on projected my net worth over the next decade
                    considering how things are going rn maybe say 6% growth with income tapering off of course"
                    → classifier match → forced tool_choice → DR #1 kicked off (session=6485fc65)
07:44:35  Silvia:  "On it — this 6% version takes a bit, I'll text you back on this thread when it's ready."
07:51:01  User:    "Ok"     ← 6 minutes 34 seconds after the ack
07:51:06  →→→      LLM re-invoked create_deep_research → DR #2 (session=e5a907d1, question paraphrased)
07:51:10  Silvia:  "Running now, I'll text you the results here when it's done."
```

Both layers of PR #3952's fix missed:

* **Prompt guard failed** — the prior outbound contained TWO of the guard's DR-pending-ack signals ("text you back" and "takes a bit"), and "Ok" is in the guard's explicit filler list. The model overrode the guard anyway. Soft guidance can always be overridden.
* **Server-side dedupe failed** — the `DR_SMS_DEDUPE_WINDOW_MS` was set to 90 s based on the observed "Thanks" case timing (\~30-60 s ack-to-follow-up). This incident's ack-to-follow-up gap was 6 m 34 s = 4x the window. The gate never even fired.

### The user's insight

> "if a user types ok or thank you or stuff like that then silvia should just thumbs up the message. No need to even reply to the user. The thumbs up acknowledges the user like a normal human would"

This is the correct fix — and structurally better than any dedupe. **If there's no LLM turn on filler, there's zero chance for the model to autofire the tool a second time.** The whole class of bug disappears, not just this instance.

### The fix (structural — pre-LLM guard)

New pre-LLM guard in the Linq webhook handler ([`app/api/messages/incoming/linq/route.ts` — section 7b](../../app/api/messages/incoming/linq/route.ts)) fires a `'like'` tapback on any pure-filler inbound and returns 200 without running `runSilviaChatTurn` at all. (Originally landed as section `6a`; renumbered to `7b` in the F1 fix so it runs AFTER media processing — it now reads the `effectiveBody`, `images`, and media flags produced by section 7a.)

**Pipeline (in order — filler check runs before ANY DR work):**

1. `isFiller(effectiveBody)` — in-memory O(1) set lookup after a bounded normalize (lowercase, trim whitespace, strip trailing `.`/`!`, collapse internal spaces). Disqualifiers: empty, whitespace-only, contains newline, contains `?`, length > 32 chars.
2. Provider gate — `normalized.service` MUST be `'imessage'` or `'rcs'`. Plain SMS falls through to LLM (no tapback primitive).
3. Prior-outbound lookup — one indexed query on `silvia_sms_logs (clerk_id, direction='outbound', order by created_at desc, limit 1, maybeSingle)`.
4. `classifyForFillerTapback` — if the prior outbound ends with `?` OR contains any confirmation-prompt pattern (`should i`, `should we`, `shall i`, `shall we`, `want me to`, `want us to`, `do you want`, `would you like`, `let me know if`, `confirm`, `just to confirm`, `ok to (start|kick off|proceed|run|go)`, `ready to (start|kick off|proceed|run|go)`, `are you sure`), fall through to LLM.
5. Fire `addReaction(providerMessageId, { operation: 'add', type: 'like' })` (awaited). If the reaction API throws, log and `return false` from the guard — the caller falls through to the LLM path (see the "Reaction failure ≠ silent Silvia" design note below).
6. Emit PostHog `sms_filler_tapback_no_reply_server` with `token`, `service`, `messaging_provider`, `inbound_length`.
7. Return 200. `runSilviaChatTurn` never runs.

**Filler set (curated tight — see [`lib/messaging/filler-classifier.ts`](../../lib/messaging/filler-classifier.ts) for the rationale):**

```
ok, okay, thanks, thank you, thx, ty, cool, sounds good, great, awesome,
perfect, appreciate it, got it, gotcha, k, kk, will do, 👍, 👌, 🙏, 💯, 👊
```

**Deliberately EXCLUDED — confirmation-shaped:** `yes`, `yep`, `yup`, `y`, `sure`, `np`, `no problem`, `nice`, `understood`, `sounds like a plan`. A short reply to "Just to confirm — deep research on PayPal?" MUST reach the LLM so the tool fires; treating "yes" as filler would silently swallow the greenlight. The user flagged this exact concern when picking the scope.

**Question-check safety layer** — even filler-set tokens (like "ok") route to the LLM when the prior outbound was a question or confirmation prompt. This is the second line of defence for the confirmation-preservation contract: "Silvia: Want me to check your Q3 spending?" → user: "ok" → falls through to LLM, tool fires as intended.

**Belt-and-suspenders — marker-only LLM response** — if `runSilviaChatTurn` returns a response consisting solely of the reaction marker (`<!--react:like-->` with no body text), the handler fires the tapback and returns 200 WITHOUT sending an empty text bubble. Rare path (the filler guard prevents it for filler inbounds), but defensive.

### Design decisions

* **Structural before soft** — the pre-LLM guard IS the primary defence. The PR #3952 prompt guard and 90 s executor dedupe remain in place as belt-and-suspenders for the edge cases the exact-match filler set misses (fuzzy variants like "appreciate the help") and for Twilio users (no tapback primitive; text-ack path only).
* **Provider gate** — iMessage + RCS only, because plain SMS has no reaction primitive. Twilio users fall through to the normal LLM path (which still gets the belt-and-suspenders prompt guard).
* **Exact match, not fuzzy** — the filler set matches after normalization but requires EXACT membership. False positives (real user request silently swallowed) are strictly worse than false negatives (unnecessary text reply — the pre-guard baseline). Long messages that HAPPEN to start with a filler token ("ok can you also check my apple holdings") fall through to LLM.
* **Reaction failure ≠ silent Silvia** — if `addReaction` throws, we log and `return false` from `maybeFireFillerTapback`. The caller sees `fillerShortcutFired === false` and falls through to the LLM path so the user still gets a reply (text-only, since the tapback failed). Silent Silvia on filler — no tapback AND no text — is worse UX than "we couldn't deliver the tapback so we fell back to a text reply." The DR double-fire dedupe (90 s window + prompt guard) is the belt-and-suspenders that catches the pre-guard regression case if the LLM misfires on a filler follow-up while the reaction API is down. This shipped as part of PR [#3960](https://github.com/CFO-Silvia/Silvia-Web/pull/3960) after a CodeRabbit + Bugbot review found the original "return true on reaction failure" design meant Silvia would go completely silent on filler whenever the Linq reaction endpoint blipped.
* **Prior-outbound DB error → fall through to LLM** — safe default: if we can't confirm the safe-to-tapback precondition (no question/confirmation in the prior outbound), route to the normal LLM path.
* **Cost** — filler guard cuts one Anthropic tool-loop turn per filler inbound (\~2-4 s + full loop token bill). At even 5 % filler rate across the SMS surface this is meaningful cost + latency savings.

### Test coverage

* [`lib/messaging/__tests__/filler-classifier.test.ts`](../../lib/messaging/__tests__/filler-classifier.test.ts) — 51 tests across:
  * `FILLER_TOKENS` set (canonical membership; explicit non-membership of confirmation-shaped tokens like `yes`/`yep`/`sure`)
  * `normalizeFillerCandidate` (whitespace, case, trailing punctuation, `?` disqualifier, newline disqualifier, length cap, adversarial long-input protection)
  * `isFiller` (raw variants, punctuation-decorated variants, substantive-messages-that-start-with-filler negatives, multi-emoji negatives, greedy compound matching for `👍 thanks` / `okay 👍` / `will do thanks`)
  * `looksLikeQuestionOrConfirmationPrompt` (trailing `?`, phrasing patterns, plural `should we` / `shall we` / `want us to`, case-insensitivity, `\b` word-boundary correctness, non-firing on filler-set tokens in the outbound)
  * `classifyForFillerTapback` (verdict composition, exact 2026-09-13 incident replay, 2026-09-12 "Thanks" incident replay, emoji-thumbs replay, question-preceded confirmation preservation, multi-line prior-outbound edge case)

* [`app/api/messages/incoming/linq/__tests__/filler-tapback-guard.test.ts`](../../app/api/messages/incoming/linq/__tests__/filler-tapback-guard.test.ts) — 30 source-grep tests over the route wiring: classifier import path, `maybeFireFillerTapback` call site, early return BEFORE `runSilviaChatTurn`, provider gate, media-bearing gate (F1 below), `isFiller` short-circuit before DB query, prior-outbound query shape, threaded-parent lookup preference (F3 below) with `clerk_id`-scoping (PR #3974 CodeRabbit), sanitize-history-message import + application to prior outbound (F2 below), reaction call primitive (`add` + `like`), PostHog event fire + cross-file event constant existence, PII-safe log shape, DB-error fall-through, reaction-failure fall-through to LLM (see design note above), the marker-only belt-and-suspenders branch (empty text send skipped, correct log, correct typing cleanup, respects requested reaction type, and — per F4 below — `sendState.userNotified` flip + `scheduleInboundVaultUpload()` before returning), and — added in PR [#3974](https://github.com/CFO-Silvia/Silvia-Web/pull/3974) — the `response_sent = true` + `AUTO_OPT_IN:*` marker persistence on BOTH the filler-tapback and marker-only bookkeeping updates.

### Review round 2 — Bugbot + CodeRabbit findings on PR [#3960](https://github.com/CFO-Silvia/Silvia-Web/pull/3960) (2026-09-13)

Four medium-severity findings, all valid and fixed in the same PR:

* **F1 — Filler shortcut drops inbound images.** Both Cursor Bugbot and CodeRabbit flagged that the pre-LLM guard decided solely from `effectiveBody`, so a filler caption on an image (e.g. photo of a monthly statement with "thanks") short-circuited the LLM path AND skipped `scheduleInboundVaultUpload()`. Same for unsupported-image drops (user needs the "resend as JPEG" apology) and failed voice-note transcriptions ("couldn't hear that" apology). **Fix (prevention, not just upload):** added the `isMediaBearing` gate at the call site — `normalized.media.length > 0`. Uses the RAW envelope count (not the derived `images` / `droppedImageMimeTypes` / `transcriptionFailed` flags from `processInboundMedia`) because the raw count is a strict superset — any of those flags being truthy implies `normalized.media.length > 0`, but the raw count also catches (a) supported images whose DOWNLOAD failed inside `processInboundMedia` (never lands in `images` or `droppedImageMimeTypes`) and (b) other media types like stickers or vcards that `processInboundMedia` drops to a console log with no state change. Simpler + broader is the right shape for a safety gate. Any media presence falls through to the normal LLM path — semantically an image + "thanks" caption is not filler, the user attached the image intending it to be looked at. The section was renumbered from `6a` to `7b` since it now runs AFTER media processing (needs the media flags + `effectiveBody`).

* **F2 — Footer hides prior-outbound questions.** Bugbot flagged that `silvia_sms_logs.message_content` for RCS/SMS outbounds is stored with the compliance footer appended (`"\n\n- Silvia\nReply STOP to opt-out"`). The classifier's trailing-`?` question-check regex `/\?\s*$/` therefore missed real questions — a stored body like `"Deep research on PayPal?\n\n- Silvia\nReply STOP to opt-out"` ends with `"opt-out"`, not `"?"`. Concrete regression: Silvia asks "Deep research on PayPal?" → user replies "ok" → tapback fires → confirmation lost, LLM never sees the greenlight. **Fix:** apply `sanitizeHistoryMessage(priorOutboundRaw, 'outbound')` before handing to the classifier. Same normalizer the history-load path uses; strips the chunk prefix, the `COMPLIANCE_FOOTER_RE` match, and any trailing "- Silvia" sign-off. iMessage outbounds are stored without the footer and pass through unchanged.

* **F3 — Threaded replies ignore quoted parent.** Bugbot flagged that the prior-outbound lookup always returned Silvia's LATEST outbound, ignoring `normalized.parentProviderMessageId` when set. An iMessage inline-reply "ok" on an older "Should I check X?" question would be classified against a newer non-question outbound and swallowed as filler. **Fix:** two-strategy lookup — when `parentProviderMessageId` is set, query by exact `twilio_message_sid` first (threaded strategy); fall back to the latest-outbound query only if the threaded row is absent (parent was inbound or pruned). Same sanitization applied in both cases.

* **F4 — Marker-only path skips vault upload.** Both bots flagged that the belt-and-suspenders `replyText.length === 0` branch returned 200 after firing the tapback WITHOUT calling `scheduleInboundVaultUpload()` or flipping `sendState.userNotified`. If `runSilviaChatTurn` had processed inbound images and produced a marker-only response, the images would be dropped silently (no other code path persists them; 200 → no Linq retry). **Fix:** set `sendState.userNotified = true` then call `scheduleInboundVaultUpload()` before returning, in that order (the closure's docstring at 7a locks in the invariant "`after()` scheduled ⇒ `userNotified === true` ⇒ no Linq retry"). Also added `imagesScheduledForVault: images.length` to the observability log line so a "silent-Silvia-with-image" incident is diagnosable.

### Review round 3 — Bugbot + CodeRabbit findings on PR [#3974](https://github.com/CFO-Silvia/Silvia-Web/pull/3974) — main cherry-pick (2026-09-13)

Four findings surfaced on the main cherry-pick, all valid and fixed in the same PR:

* **F5 — Research pointer hides prior-outbound questions (Bugbot, MEDIUM).** `sanitizeHistoryMessage` strips the compliance footer + trailing "- Silvia" signature but was leaving the `\n\nView details: <RESEARCH_POINTER_URL>` suffix that `sendSilviaReply` appends when `usedResearchTools === true` (see `lib/messaging/outbound.ts` — pointer-insert block near line 786). The filler classifier's end-anchored `/\?\s*$/` question check therefore misses questions asked in a research-turn — a stored body ending in `research?\n\nView details: …/research` looks like it ends with `research`, not `?`. Concrete failure: Silvia's DR-status turn asks "Should I also check your PayPal for the same period?", user replies "ok", tapback fires, DR is never triggered. Same shape as F2 but with the pointer instead of the compliance footer. **Fix:** extend `sanitizeHistoryMessage` in `lib/messaging/outbound.ts` with a `RESEARCH_POINTER_SUFFIX_RE` strip step (case-insensitive, end-anchored, ordered AFTER the footer strip so the pointer sits at the tail before this regex fires). Also strips the pointer from Claude's history context — a cleanup win, since the pointer is UX scaffolding for the phone, not signal for the model.

* **F6 — Filler-tapback bookkeeping drops the AUTO\_OPT\_IN marker (CodeRabbit).** Every other terminal branch in the Linq route persists `error_message` prefixed with `${autoOptInAuditMarker}` when auto-opt-in fired earlier in the request (cap-gate at \~1213, happy-path post-send at \~2285). The filler-tapback bookkeeping wrote only `response_sent + delivery_updated_at`, silently dropping the marker whenever a just-opted-in user replied with pure filler on the same turn (rare but real — user STOP'd, later un-STOP'd via a two-way exchange, then acked with "ok"). Ops SQL-queries for the opt-in flip would come up empty for those rows. **Fix:** conditional spread inside the update patch — `...(autoOptInAuditMarker !== null ? { error_message: autoOptInAuditMarker } : {})`. Spread form (not explicit `null`) so the key is omitted entirely when the marker is absent, avoiding accidental clobbering of any earlier-step write.

* **F7 — Marker-only path skips the claim-row finalize contract (CodeRabbit + Bugbot).** The belt-and-suspenders `replyText.length === 0` branch fires the tapback and returns 200 without updating `silvia_sms_logs` at all. Ops dashboards counting `WHERE direction='inbound' AND response_sent=false` therefore flag every marker-only turn as an unhandled drop. Same AUTO\_OPT\_IN marker-loss risk as F6. **Fix:** mirror the filler-tapback bookkeeping pattern — try/catch-wrapped `silvia_sms_logs` update with `response_sent: true`, `delivery_updated_at`, and the marker-aware conditional spread, placed AFTER `sendState.userNotified = true` (rollback-safety) and BEFORE the 200 return. Suppress + log on failure with a `[Linq webhook] marker-only bookkeeping update failed …` prefix so ops can dashboard "how often does marker-only bookkeeping fail" as a single grep.

* **F8 — Threaded prior-outbound lookup ignores clerk\_id (CodeRabbit).** `normalized.parentProviderMessageId` copies verbatim from the untrusted `reply_to.message_id` in the Linq webhook payload. The threaded strategy queried by `twilio_message_sid` + `direction='outbound'` only — no `clerk_id` filter. Linq message IDs are globally unique so ID-collision isn't a practical concern, but a malformed/spoofed payload carrying another clerk's message id would return that other clerk's outbound row, feed a stranger's context into the filler classifier, and route THIS clerk's reply against the wrong prompt. The latest-outbound fallback below already filters on `clerk_id` — the threaded strategy was inconsistent. **Fix:** add `.eq('clerk_id', clerkId)` to the threaded query so a cross-clerk id gracefully returns no row → the code falls through to the clerk-scoped LATEST strategy (existing safe default).

## Line capacity monitoring (2026-09-25 Linq confirmation)

Linq enforces a **hard cap of 200 unique chat contacts per phone-number line**, confirmed by Ben @ Linq in a 2026-09-25 Slack thread ("200 contacts per line as a reminder"). Once a line hits the cap:

1. Linq's pool picker silently excludes it from `new_best_number` responses — no error thrown, just other lines take over new enrollments.
2. If ALL lines are at cap, `POST /v3/messages` fails outright for any first-touch chat.
3. Existing chats on a capped line keep working (no cap on messages per chat, only on distinct chats).

Linq's `GET /v3/phone_numbers` endpoint (`listPhoneNumbers()` in `lib/linq/api-client.ts`) returns `reputation.status` and `forwarding_number` but **no `contact_count` field** — so live per-line utilization must be computed on Silvia's side.

### Why utilization visibility matters right now

Ben also stated:

* Linq needs **\~14 days advance notice** to provision new lines (currently capacity-constrained: "we had some people buy all of our capacity and more").
* Silvia is scaling to **\~25 lines** to accommodate the 4,591-user Twilio → Linq migration + ongoing new signups + Pomp's Monday launch announcement traffic (target: 5,000 total contacts).

Without proactive visibility, we'd learn about a capacity crunch by observing enrollment failures — 14 days too late to react.

### Architecture

Real-time per-line counts are computed from `all_users` (truthful within 1 send post-PR #4292 self-heal), exposed via:

```
supabase/migrations/20260925_02_linq_line_utilization_view.sql
  → creates public.linq_line_utilization VIEW (real-time, not materialized)

lib/linq/pool-utilization.ts
  → getLinqPoolUtilization(supabase): reads the view
  → mergeProvisionedLines(rows, e164s): LEFT-JOIN empty provisioned lines
  → attachLineReputation(rows, phoneNumbers): populate reputation from Linq
  → getUnhealthyLineE164s(phoneNumbers): exclude-list builder for enrollment
  → classifyLineCapacity(rows, thresholds?, hardCap?): pure classifier
  → summarizePoolCapacity(alerts, hardCap?): pool-wide rollup (incl. reputationBreakdown)
  → getLinesNeedingAttention(alerts): filter+sort helper
  → LINQ_LINE_CONTACT_HARD_CAP = 200 (Linq-side constant)
  → DEFAULT_CAPACITY_THRESHOLDS = { watch: 160, warn: 180, critical: 190 }

app/api/ops/linq/pool-utilization/route.ts
  → GET /api/ops/linq/pool-utilization
  → Header auth: x-internal-api-token: $INTERNAL_API_TOKEN
  → Returns: { hardCap, thresholds, summary, lines[], durationMs }
  → Optional overrides: ?watch=&warn=&critical=
```

### Threshold model

All thresholds are **absolute contact counts**, not percentages — this way a future Linq cap raise doesn't silently move the alert boundaries.

| Status | userCount | Meaning |
| - | - | - |
| `ok` | 0–159 | Comfortable headroom (≥ 40 contacts). No action. |
| `watch` | 160–179 | Start planning. Still \~3-4 days of runway at typical launch enrollment velocity. |
| `warn` | 180–189 | **File the Linq provisioning ask here** if it isn't already open — 14-day lead time means acting now. |
| `critical` | 190–199 | 1–10 contacts left. Escalate on Slack; throttle enrollments if new lines aren't imminent. |
| `critical` | 200+ | At cap or over cap; no capacity remains on this line. Linq's pool picker will silently exclude it from `new_best_number`. Escalate on Slack; throttle enrollments if new lines aren't imminent. |

Pool-wide `needsProvisioning=true` triggers when any line is `warn`/`critical` OR when total pool headroom is less than one full line's worth (200 contacts) — because we can't fit another line's worth of users at that point.

### Usage

Ad-hoc ops check:

```bash theme={null}
curl -s https://silvia.ai/api/ops/linq/pool-utilization \
  -H "x-internal-api-token: $INTERNAL_API_TOKEN" | jq
```

Tighter alerting during launch week (override thresholds without redeploying):

```bash theme={null}
curl -s "https://silvia.ai/api/ops/linq/pool-utilization?watch=140&warn=170&critical=185" \
  -H "x-internal-api-token: $INTERNAL_API_TOKEN" | jq '.summary'
```

### What this view intentionally does NOT track

* **Twilio-only users.** They're not on Linq yet — they'll consume line capacity when migrated, but that's future demand, not current utilization. Filtered out via `sms_provider = 'linq'`.
* **Soft-deleted accounts.** `account_deleted = true` filtered out. **Caveat:** Linq may still count these as active chats until the chat is deleted server-side, which Silvia does NOT do (retention for opt-in audit trail). If we start hitting cap earlier than the DB view predicts, that's the likely culprit — coordinate with Linq team on their chat-lifecycle semantics.

## Reputation-aware enrollment (deprioritize AT\_RISK / CRITICAL lines)

Every Linq line carries a `reputation.status` — `HEALTHY`, `AT_RISK`, or `CRITICAL` — reported live via `GET /v3/phone_numbers` (`listPhoneNumbers()` in `lib/linq/api-client.ts`). Semantically:

| Status | Meaning | Silvia response |
| - | - | - |
| `HEALTHY` | Normal delivery trust. Safe to enroll new users. | Eligible for `getAvailableNumber()` selection. |
| `AT_RISK` | Degraded trust score. Existing chats still work; new users delivered via this line have a higher chance of dropped-to-spam messages. | **Excluded** from `getAvailableNumber({ exclude_from })`. |
| `CRITICAL` | Imminent block risk. Existing chats may auto-migrate on Linq's side. | **Excluded** from `getAvailableNumber({ exclude_from })`. |

### At enrollment (fresh-assignment path)

`enrollUserInLinq()` in `lib/linq/onboarding.ts` calls `listPhoneNumbers()` before `getAvailableNumber()` on the fresh-assignment path. Any line whose reputation is `AT_RISK` or `CRITICAL` is passed as `exclude_from` so Linq picks a `HEALTHY` line instead. **Existing users on flagged lines are NOT re-assigned** — Silvia's assignment is sticky (see the "assign once, keep forever" docstring in `onboarding.ts`).

Failure modes are all fail-open — enrollment MUST NOT block on a reputation-lookup failure:

1. `listPhoneNumbers()` throws (Linq API down, network blip, auth misconfig) → log a warning, enroll unfiltered.
2. `listPhoneNumbers()` returns zero lines → log a warning, enroll unfiltered (let Linq's own error surface if the pool is truly empty).
3. **Every eligible line covered by the exclude list** (all provisioned lines are `AT_RISK`/`CRITICAL`, OR the remaining HEALTHY lines are temporarily ineligible for capacity / provider-side reasons Silvia can't see): `getAvailableNumber({ exclude_from })` returns HTTP 400 → `pickAvailableLineWithReputation` catches that specific error, logs `console.error` (critical alert, escalate reputation-fix), and retries with an unfiltered `getAvailableNumber()`. Any other error (401 auth, 429 rate-limit, 5xx server, network) propagates — a silent retry on those would mask a real infra outage as a reputation-fallback.

Latency cost: **one extra Linq API round-trip** (\~150-400ms) on the fresh-assignment path only. The already-assigned short-circuit does NOT trigger a reputation lookup — re-assignment doesn't happen, so paying the round-trip on repeat webhooks would be wasted work.

### In the ops endpoint

`GET /api/ops/linq/pool-utilization` surfaces the current reputation for every line (via `attachLineReputation()` after the view read + `mergeProvisionedLines()`), plus a pool-wide `reputationBreakdown` and `hasUnhealthyLines` flag in the response summary. Example response fragment:

```json theme={null}
{
  "summary": {
    "reputationBreakdown": { "healthy": 23, "atRisk": 1, "critical": 0, "unknown": 1 },
    "hasUnhealthyLines": true
  },
  "lines": [
    { "lineE164": "+18885550100", "userCount": 187, "reputation": "AT_RISK", ... }
  ]
}
```

`reputation: null` (and `reputationBreakdown.unknown > 0`) is meaningful — it means either the Linq API call failed OR the line is in `all_users.linq_assigned_number` but no longer in Linq's provisioned list (decommissioned). Worth paging on separately from capacity.

### Future work

Not built yet (deferred to a follow-up PR when justified by usage):

* Scheduled Datadog check hitting this endpoint every 5 min → alert when `worstLineStatus == 'critical'` OR `hasUnhealthyLines && !HEALTHY-capacity-still-sufficient`.
* Cache the reputation snapshot for 60-120s in Redis so the enrollment path amortizes the Linq round-trip across bursts (only worth doing if we hit >100 enrollments/min).
* Per-line PostHog event stream so `latest_assignment_at` gaps can be alerted on (leading indicator of a line being deprioritized by Linq's picker before we see it in `reputation.status`).

## Migrating existing Twilio users to Linq

The `enrollUserInLinq` primitive (§ Phase 2) covers **new** signups going onto Linq. Migrating an **existing** Twilio-opted-in user to Linq is a separate flow — it has different compliance preconditions, needs a first-message from the new line so the user isn't surprised by an unknown iMessage sender, and needs distinct telemetry so the launch dashboard can slice "signup enrollment" from "ops-driven backfill migration".

`POST /api/ops/linq/migrate-user` is the ops primitive for this. Call it per-user from a hand-run `curl` (Max pilot) or a bulk script iterating a cohort list (Pro rollout).

### What it does

1. **Precondition check** — 4xx if the user doesn't exist / has no phone / isn't opted in / has ever texted STOP. `stop_recorded` is a HARD stop: the STOP on Twilio carries over as consent revocation; migrating them would violate that.
2. **Idempotency** — returns `already_on_linq` (200) with the currently-assigned line if `sms_provider = 'linq'` already. No DB write, no pre-announce, no greeting re-send, no telemetry re-emit.
3. **Twilio pre-announce (trust priming)** — sends a heads-up SMS from the OLD trusted Twilio line: *"Hey \[name], quick heads-up: I'm moving to a new number in a moment so I can send you PDFs, images, and richer replies. In about 10 seconds you'll get a message from that number, that's still me. Save that contact when it lands and text me there going forward. Same memory, same context."* Then waits `PREANNOUNCE_TO_GREETING_DELAY_MS` (10s) so the trusted SMS lands BEFORE the unknown-sender new-number message arrives. Without this priming, the new-number message in step 5 looks byte-for-byte like a common SMS impersonation scam ("Hi it's \[X], I got a new number, save this contact"). **FAIL-LOUD**: a Twilio send failure ABORTS the migration with `502 preannounce_failed`. No DB flip, no new-number greeting. Rationale: shipping an unprimed new-number message after a failed priming reproduces the scam-looking UX we added the pre-announce to prevent. Copy is deliberately generic ("a new number", not a specific E.164) — Linq's line-selection pool doesn't commit until step 4, so promising a specific number here creates a bait-and-switch failure mode if the assigned line differs. Copy is also deliberately device-neutral (no "iMessage" mention) — Linq is a multi-protocol service (iMessage / RCS / SMS/MMS by recipient capability), so saying "I'm upgrading Silvia to iMessage" is factually wrong for Android recipients and reads as "she's leaving my platform" from their POV. See `lib/messaging/migration-preannounce.ts` for the full rationale and MIGRATION\_PREANNOUNCE\_VARIANT v3 history for the device-neutral copy rewrite (2026-09-27). `skip_preannounce: true` bypasses this phase for ops scenarios where the user was already hand-texted out-of-band.
4. **Post-wait consent re-check** — right before the DB flip, re-reads BOTH `sms_unsubscribed_at` AND `sms_opt_in` from `all_users`. Sometimes as a direct reaction to the priming SMS (*"wait, no, don't upgrade me"*), a user can text STOP during the 10s delay (Twilio-STOP path → `sms_unsubscribed_at` stamped, endpoint aborts with `409 stop_arrived_during_preannounce`). Alternatively they can toggle off SMS via the web settings UI (`sms_opt_in = false` without an unsubscribe timestamp, endpoint aborts with `409 opt_in_revoked_during_preannounce`). Both paths abort mid-flight (no DB flip, no iMessage) — fresh consent revocation MUST be honored even though the pre-announce already landed. Distinct error codes so retry scripts and dashboards can distinguish carrier-STOP from web-toggle revocations. Fail-closed on DB errors (500 internal). CodeRabbit MINOR on PR #4334 (2026-09-27).
5. **DB flip** — delegates to `enrollUserInLinq({ mode: 'enroll', force: true })` so the compound CAS, reputation-aware line selection, PostHog `sms_provider` person-property stamp, and Linq CRM contact sync all run in the exact same code path the natural-signup enrollment uses. Anything that ever needs to happen at DB-flip time belongs inside `enrollUserInLinq` so both ingress paths get it automatically.
6. **Migration greeting** — sends the three-bubble migration copy (`lib/messaging/migration-greeting.ts`) from the newly-assigned Linq line. Distinct from the opt-in greeting because a returning user reading "Hey, I'm Silvia, your new CFO" would be confused. The migration bubbles are: (1) "quick heads-up: I've moved to this new number so I can send you PDFs, images, and richer replies. Save this contact as Silvia and text me here from now on", (2) "Speaking of richer replies, I can pull up anything from your finances on the spot, run a Monte Carlo simulation or deep research on a stock or your portfolio, alert you when something material moves, and put together spreadsheets, presentations, or any artifact you need", and (3) "same me, same memory. Nothing else changes. You can also call this number directly anytime." Copy is deliberately device-neutral (no "iMessage" mention) so Android recipients — who receive Linq traffic as RCS or SMS/MMS, not iMessage — get correct + non-alarming copy. Bubble 2 (capabilities refresh) was added in v5 after Shain flagged (2026-09-27) that migrated users under-utilize the fuller feature set (DR / MC / alerts / artifacts) — the migration is the right re-education moment. Bubble 2 tail is byte-for-byte identical to opt-in greeting bubble 2 so both cohorts hear the same value prop. See MIGRATION\_GREETING\_VARIANT v4/v5 history for the full rewrite rationale. Log-and-swallow on send failure — the DB flip already committed, so a greeting API blip does not unwind the migration.
7. **Telemetry** — emits `sms_provider_migrated_server` (the flip audit event, now carrying `preannounce_sent: true|false` for cohort slicing), `sms_migration_preannounce_sent_server` / `sms_migration_preannounce_failed_server` (the pre-announce outcome), `sms_migration_preannounce_stop_during_wait_server` (fresh-STOP abort), and `sms_migration_greeting_sent_server` / `sms_migration_greeting_failed_server` (the greeting outcome). All distinct from the natural-signup `linq_enrollment_processed` and `sms_opt_in_greeting_sent_server` so the migration cohort is queryable without polluting the signup funnel.

### Request

```
curl --request POST \
  --header "Authorization: Bearer $CRON_SECRET" \
  --header "Content-Type: application/json" \
  --data '{"clerk_id":"user_...","dry_run":false}' \
  https://staging.cfosilvia.com/api/ops/linq/migrate-user
```

Body fields:

* `clerk_id` (required) — must start with `user_`.
* `dry_run` (optional, default `false`) — run every precondition check and return what WOULD happen, without writing anything, sending any pre-announce, or sending any greeting. Also emits `sms_provider_migrated_server` with `dry_run: true` so a bulk-script preview leaves a PostHog audit trail. Filter dry-run rows out of production migration counts.
* `skip_preannounce` (optional, default `false`) — perform the DB flip + greeting WITHOUT the Twilio pre-announce. Escape hatch when ops has already hand-texted the user out-of-band and doesn't want a duplicate priming SMS. **Downgrades the UX** — user gets the iMessage cold, without priming. Response carries `preannounce_sent: false` + `preannounce_skipped_reason: 'operator_skip'`. Use sparingly.
* `skip_greeting` (optional, default `false`) — perform the DB flip but skip the greeting. Escape hatch when we've already sent a warm-up via Twilio and don't want a duplicate from Linq. **Also implicitly skips the Twilio pre-announce** — the pre-announce copy promises the recipient a message from a new number "in about 10 seconds", and sending that priming when no follow-up greeting will land leaves the user waiting for a message that never arrives (a broken promise, worse UX than the unprimed greeting the pre-announce exists to prevent). Response carries `preannounce_sent: false` + `preannounce_skipped_reason: 'skipped_greeting'` in this case (vs. `'operator_skip'` when `skip_preannounce: true` is set explicitly). To override this and force a pre-announce with no greeting, the operator would need to hand-text the user first and use `skip_preannounce: true` + `skip_greeting: true` — an unusual combination that also gets `preannounce_skipped_reason: 'operator_skip'` because explicit ops flags take precedence for the reason field.

### Response shapes

**200 migrated** (the common path):

```json theme={null}
{
  "ok": true,
  "action": "migrated",
  "clerk_id_tail": "1234",
  "assigned_line": "+12065551234",
  "previous_provider": "twilio",
  "preannounce_sent": true,
  "greeting_sent": true,
  "greeting_messages_sent": 3,
  "greeting_error_count": 0,
  "dry_run": false
}
```

**200 migrated with pre-announce skipped** (`skip_preannounce: true`):

```json theme={null}
{
  "ok": true,
  "action": "migrated",
  "clerk_id_tail": "1234",
  "assigned_line": "+12065551234",
  "previous_provider": "twilio",
  "preannounce_sent": false,
  "preannounce_skipped_reason": "operator_skip",
  "greeting_sent": true,
  "greeting_messages_sent": 3,
  "greeting_error_count": 0,
  "dry_run": false
}
```

**502 preannounce\_failed** — Twilio SMS didn't send; migration ABORTED with no DB changes:

```json theme={null}
{
  "ok": false,
  "error": "preannounce_failed",
  "detail": "twilio 429: rate limited",
  "clerk_id_tail": "1234"
}
```

Retryable — the user is unchanged. After ops investigates the Twilio-side cause (usually rate-limit or 5xx), re-running the endpoint sends a fresh pre-announce and proceeds.

**409 stop\_arrived\_during\_preannounce** — user texted STOP during the \~10s delay window:

```json theme={null}
{
  "ok": false,
  "error": "stop_arrived_during_preannounce",
  "clerk_id_tail": "1234",
  "unsubscribed_at": "2026-09-26T18:34:12Z"
}
```

The pre-announce landed but the DB flip did NOT run. User is still on Twilio and is opted out. If they later re-opt-in via `/sms-opt-in` (which clears `sms_unsubscribed_at`), migration can be re-attempted.

**409 opt\_in\_revoked\_during\_preannounce** — user toggled off SMS via the web settings UI during the \~10s delay window (`sms_opt_in` set to `false` without an accompanying Twilio STOP):

```json theme={null}
{
  "ok": false,
  "error": "opt_in_revoked_during_preannounce",
  "clerk_id_tail": "1234"
}
```

Same compliance semantics as `stop_arrived_during_preannounce` — the pre-announce landed but the DB flip did NOT run. User stays on Twilio and is opted out. Distinct error code so retry scripts / dashboards can distinguish carrier-STOP from web-toggle revocations at a glance. If they later re-opt-in via the web settings UI (which flips `sms_opt_in = true`), migration can be re-attempted.

**200 already\_on\_linq** (idempotent no-op):

```json theme={null}
{
  "ok": true,
  "action": "already_on_linq",
  "assigned_line": "+12065551234",
  "previous_provider": "linq",
  "greeting_sent": false
}
```

**200 would\_migrate** (`dry_run: true`):

```json theme={null}
{
  "ok": true,
  "action": "would_migrate",
  "previous_provider": "twilio",
  "dry_run": true,
  "assigned_line": null
}
```

`assigned_line` is intentionally `null` on dry-run: previewing the assignment would burn a line-pick from Linq's LRU rotation. For migration audits, "would migrate this user" is the useful signal; the assigned line only matters at commit time.

**4xx / 5xx**: see the JSDoc at the top of the route file (`app/api/ops/linq/migrate-user/route.ts`) for the full error taxonomy. Every error response is `Cache-Control: no-store` so ops scripts can safely retry.

### Recommended rollout cadence

Phased against the Linq pool's 200-contact/line cap. Watch `GET /api/ops/linq/pool-utilization` daily during rollout — the `worstLineStatus` field is the leading indicator.

* **Max pilot** — 5 hand-picked highly-engaged Max users, then the remaining \~24 in one batch. Total \~30. Fits comfortably in the current single-line \~180 free slots.
* **Pro monthly** — 200/day for \~4 days across at least 4 provisioned Linq lines. The `pickAvailableLineWithReputation` selector inside `enrollUserInLinq` handles per-line load balancing automatically (AT\_RISK / CRITICAL lines are excluded).
* **Pro other (annual/legacy)** — 200/day for \~2 days.
* **Free tier** — reconsider explicitly before running. Free-user reputation risk (spam-flags, low engagement) can quickly burn Linq lines that are working fine for paid cohorts.

### End-to-end user experience

For a migrated Max user (iPhone, opted in on Twilio) after ops runs `POST /api/ops/linq/migrate-user`:

**T = 0** — Server runs preconditions and dispatches the trust-priming pre-announce SMS on the OLD Twilio thread.

**T ≈ 2s** — User's iPhone shows a fresh SMS on the KNOWN Silvia (Twilio) thread:

> Hey Shain, quick heads-up: I'm moving to a new number in a moment so I can send you PDFs, images, and richer replies. In about 10 seconds you'll get a message from that number, that's still me. Save that contact when it lands and text me there going forward. Same memory, same context.
>
> \- Silvia
> Reply STOP to opt-out

Because it arrives on the thread they already trust, the user has 10 seconds to read it, brace for the incoming iMessage, and re-check the sender identity when it lands.

**T ≈ 12s** — Server flips `sms_provider` from `twilio` to `linq`, assigns a Linq line, syncs Linq CRM contact, stamps PostHog person property, and dispatches the migration greeting on the NEW iMessage thread:

> Hey Shain, quick heads-up: I've moved to this new number so I can send you PDFs, images, and richer replies. Save this contact as Silvia and text me here from now on.
>
> *(iOS "Silvia is offering their contact" banner fires between bubbles 1 and 2)*
>
> Speaking of richer replies, I can pull up anything from your finances on the spot, run a Monte Carlo simulation or deep research on a stock or your portfolio, alert you when something material moves, and put together spreadsheets, presentations, or any artifact you need.
>
> Same me, same memory. Nothing else changes. You can also call this number directly anytime.

Between bubble 1 and bubble 2, the server fires `POST /v3/chats/{chatId}/share_contact_card` proactively (via `shareContactCardIfNeeded`) using the chatId returned by bubble 1's send. iOS surfaces the response as a **native "Silvia is offering their contact" banner** at the top of the thread — first-party UI, one tap to save with the flag avatar correctly applied. Because of the priming SMS from T ≈ 2s, the user recognizes this as "the upgrade Silvia mentioned" rather than a cold impersonation attempt. See Mitigation 1 below for the empirical rationale that led to this design.

Bubble 2 (capabilities refresh) exists because migrated users tend to under-utilize Silvia's fuller feature set — many have only used her for basic Q\&A and don't know about deep research, Monte Carlo simulations, material-move alerts, or artifact generation. The migration is a rare re-engagement moment where the user is already reading their thread carefully (to save the new contact), so the marginal cost of adding a capabilities bubble is near-zero and the potential lift on DR/MC/artifact usage among the migrated cohort is meaningful. The bubble 2 tail is byte-for-byte identical to opt-in greeting bubble 2 so PostHog analytics can compare feature-adoption uplift across cohorts without copy-difference confounding the signal. Added in v5 (2026-09-27); v1-v4 were 2-bubble on the reasoning that migrated users already knew the capabilities.

**Three user behaviors** determine the next-day UX. Each is handled explicitly:

| User does… | Outcome (with all mitigations enabled) |
| - | - |
| **A.** Saves the contact + texts the new thread | Fully migrated; iMessage-native from here (photos, PDFs, tapbacks, calls). |
| **B.** Texts the OLD Twilio thread to double-check | Reply comes back on the OLD thread via Twilio (thread-consistent), AND a one-time Twilio-side nudge appends "Heads-up: I moved to a new number at {new_number}…". See Mitigation 2. |
| **C.** Ignores both messages entirely | 48h later, the follow-up cron sends the same nudge via Twilio on the OLD thread. See Mitigation 3. |
| **D.** Texts STOP on the priming thread during the 10s wait | Migration aborts (`409 stop_arrived_during_preannounce`) — DB flip does NOT run, no iMessage is sent. User is still on Twilio and opted out. If they later re-opt-in, migration can be re-attempted. |
| **E.** Toggles off SMS via the web settings UI during the 10s wait | Migration aborts (`409 opt_in_revoked_during_preannounce`) — same compliance semantics as D, distinct error code so ops can distinguish carrier-STOP from web-toggle revocations. |

### UX safety nets

Three mitigations layer on top of the base migration endpoint to make the transition friction-free even for users who don't recognize the new number:

#### Mitigation 0 — Twilio pre-announce (trust priming, PRE-flip)

The highest-leverage mitigation — because it upstream-fixes the root problem instead of catching downstream fallout. Before the DB flip, the endpoint sends a heads-up SMS from the OLD trusted Twilio thread announcing the imminent iMessage arrival, then waits \~10s so the priming SMS lands FIRST. Two ordered messages from two channels self-verify each other: the user has already been told about the incoming iMessage 10 seconds before it arrives, so they don't react to the unknown-sender iMessage as "wait, who is this?"

Without this priming, a cold iMessage from a new number claiming to be Silvia matches a common SMS impersonation scam pattern:

1. Unknown-number iMessage claiming to be a service they use
2. Says "save this contact, text me here from now on"
3. Follows up with an unsolicited iOS "sender is offering their contact" banner *(prompted by the sender via any of a number of vCard / contact-share APIs; the v3 code path uses Linq's `share_contact_card`)*

Priming from the KNOWN thread converts the iMessage from "cold, unknown, suspicious" into "the upgrade Silvia mentioned 10 seconds ago, expected, legit". Same pattern as Apple's "signed in on new device" flow, banks' "we're calling from XYZ" notification, and every well-designed number-change UX.

**Copy design** (`buildMigrationPreAnnounceBody`, `lib/messaging/migration-preannounce.ts`):

* Personalized `Hey [name]` when we have a name; clean fallback when we don't
* Explicit upgrade signal ("moving to a new number... so I can send you PDFs, images, and richer replies") so the follow-up is both expected AND value-framed. Deliberately device-neutral — no "iMessage" mention — so Android recipients (who get the new-number line as RCS or SMS/MMS, never iMessage) aren't given inaccurate copy that reads as "she's leaving my platform"
* Rough time expectation ("in about 10 seconds") without over-promising sub-second delivery
* Continuity reassurance ("Same memory, same context") mirrors the migration greeting language
* Compliance footer (`COMPLIANCE_FOOTER`) baked into the body — send path bypasses `sendSilviaReply`, so the standard footer machinery doesn't run

**Deliberate copy choice: NO specific new number.** A tempting version says "In \~10s you'll get an iMessage from +1XXX-XXX-XXXX" for user validation. That introduces a fatal bait-and-switch failure mode — `enrollUserInLinq` selects the Linq line via reputation-aware pool logic, which is not deterministic before commit-time. If we pre-announce line B and enroll assigns line A, the user gets an iMessage from a DIFFERENT number than promised (worse than no priming). Generic copy avoids the mismatch entirely; self-verification comes from ORDERING + CONTINUITY, not from a specific-number check.

**Fail-loud, not log-and-swallow**: unlike the migration greeting (which log-and-swallows because the DB flip already committed), a pre-announce send failure ABORTS the migration with `502 preannounce_failed`. No DB flip, no iMessage. Rationale: firing the iMessage after a failed priming reproduces the exact scam-looking cold arrival we added the pre-announce to prevent. Better to leave the user unmigrated and retryable than to ship an unprimed cold iMessage.

**Post-wait consent re-check**: sometimes AS A DIRECT REACTION to the priming SMS (*"wait, no, don't upgrade me"*), a user revokes consent during the 10s delay. The endpoint re-reads BOTH `sms_unsubscribed_at` AND `sms_opt_in` right before the DB flip and aborts with `409` if either signals revocation — the two paths are distinct to keep dashboards clean: `error: 'stop_arrived_during_preannounce'` when the user texted STOP via Twilio (`sms_unsubscribed_at` stamped by the STOP handler), and `error: 'opt_in_revoked_during_preannounce'` when the user toggled SMS off in web settings (`sms_opt_in = false` via `app/api/sms/opt-in DELETE`, which may not stamp the unsubscribed\_at column). Both cases emit `sms_migration_preannounce_stop_during_wait_server` with `revocation_reason ∈ {'sms_unsubscribed', 'sms_opt_in_false'}` for single-query cohort slicing. Fresh consent revocation must be honored even though we can't unsend an already-delivered SMS — rare but a real compliance event when it does happen.

**Escape hatch**: `skip_preannounce: true` in the request body skips this phase for ops scenarios where the user was already hand-texted out-of-band and doesn't want a duplicate priming SMS. Downgrades the UX (user gets the iMessage cold, without priming) so use sparingly. Response carries `preannounce_skipped_reason: 'operator_skip'` so telemetry can filter operator-skipped migrations out of the "primed" cohort.

**Telemetry**: `sms_migration_preannounce_sent_server` (success), `sms_migration_preannounce_failed_server` (send failure → migration abort), `sms_migration_preannounce_stop_during_wait_server` (STOP-during-wait abort). `sms_provider_migrated_server` also carries `preannounce_sent: true|false` (+ optional `preannounce_skipped_reason`) for single-query cohort slicing on the launch dashboard.

**TODO (follow-up PR)**: once the Twilio adapter (`lib/messaging/providers/twilio.ts`) grows MMS support (`mediaUrls` currently throws), we could attach a base64-embedded vCard to this Twilio pre-announce so the save happens on the TRUSTED thread BEFORE the iMessage lands. Note this only makes sense if we self-host the vCard with the photo pre-embedded as base64 (see Mitigation 1 empirical history for why Linq's URI-photo vCards don't render the avatar on save). Tracked as a follow-up because Twilio adapter MMS work has its own migration/testing surface (segment pricing, MMS-vs-SMS throttling, iMessage-recipient fallback semantics).

#### Mitigation 1 — proactive `share_contact_card` between bubbles

The single biggest lever POST-priming. Between bubble 1 (which establishes the chat and returns a `chatId`) and bubble 2 (the reassurance copy), `sendLinqMigrationGreeting` fires `shareContactCardIfNeeded({ providerChatId: bubble1ChatId, lastSharedAt: null })` proactively. Linq translates this into a `POST /v3/chats/{chatId}/share_contact_card`, and iOS surfaces it as a native **"Silvia is offering their contact"** banner at the top of the thread — first-party UI, one tap to save.

**Empirical history** (v1 → v2 → v3, tracked in `MIGRATION_GREETING_VARIANT`):

* **v1** attached the Linq-signed `.vcf` URL as bubble 1's media part so iMessage rendered an inline Contact Card bubble.
* **v2** kept the vCard attachment and stripped em dashes from the copy (2026-09-27 morning UX round).
* **v3** dropped the vCard attachment entirely in favor of the proactive `share_contact_card` call. Rationale from live-testing (2026-09-27, +17068771786): the inline Contact Card bubble rendered fine, but the saved contact had no avatar. Root cause is Linq's vCard generator emitting `PHOTO;VALUE=URI:<cdn-url>` rather than base64-embedded, which iOS Contacts silently drops on save (well-known Apple parser quirk). The same phone, same day, immediately after: the native "sender is offering their contact" banner triggered by `share_contact_card` DID render the flag avatar correctly on the saved contact.

**Cooldown + safety net + release compensation**: `shareContactCardIfNeeded` is atomic-CAS-gated on `all_users.linq_contact_card_shared_at` with a 24h cooldown (`SHARE_COOLDOWN_MS` in `lib/linq/contact-card-share.ts`). The proactive fire from migration composes cleanly with the pre-existing reactive share the inbound webhook already runs on first reply: if the proactive succeeds, the reactive is a fast cooldown-skip; if the proactive fails (Linq API blip, no chatId returned, etc.), the reactive acts as the safety net so the user still gets a save affordance on their first reply.

The safety-net story requires one extra step. `shareContactCardIfNeeded` stamps `linq_contact_card_shared_at` BEFORE the Linq API call so a flaky endpoint can't cause spam-retries within the day. On the reactive call path (inbound webhook) that stamp-and-forget is the correct trade-off. On the proactive migration path, though, an API failure that leaves the stamp in place would silently disable the reactive safety net for a full 24h — the reactive fire on first reply would hit `skipped: cooldown` and never re-try. To close this hole, `sendLinqMigrationGreeting` explicitly compensates on `{ status: 'failed', claimedAt }` by calling `releaseContactCardShareClaim`, which does a compound CAS `WHERE clerk_id = ? AND linq_contact_card_shared_at = <claimedAt>` to restore the pre-claim `NULL`. Concurrent producers (a second migration retry, a reactive share that succeeded before the compensator ran) are respected — the CAS returns `superseded` and their write stands. The compensator never throws; its outcome is logged at warn-volume so ops can grep `release=released|superseded|error` when diagnosing "why didn't the reactive share fire on first reply" incidents. Bugbot #64778a71 on PR #4320. The same PR also wraps the proactive share call in try/catch so a supabase-js network-layer throw during the atomic UPDATE can't skip bubble 2 or the audit-log write (Bugbot #76e0905b).

**Result contract**: `SendLinqMigrationGreetingResult.shareCardResult` is a discriminated union of `{ status: 'shared' }` / `{ status: 'skipped', reason }` / `{ status: 'failed', error }` / `null` (the last for the bubble-1-failed short-circuit). The route flattens this into three scalars for PostHog + the response body: `share_card_status` (one of `'shared' | 'skipped' | 'failed' | 'null'`), plus `share_card_skip_reason` and `share_card_error` when applicable.

**Observability**: `share_card_status` (+ `share_card_skip_reason` / `share_card_error` when applicable) on `sms_migration_greeting_sent_server` PostHog event, response payload, log line, and `silvia_sms_logs.user_context.share_card_result` (the full discriminated object).

**Migration note for dashboards**: PostHog events emitted before this deploy carry `vcard_attached`, `vcf_url_fetched`, `vcf_fetch_error`, and `vcf_line_mismatch`. These are frozen historical fields — the v3 emit shape uses `share_card_status` etc. instead. Dashboards querying the old keys will see zero volume after this deploy; migrate them to `share_card_status` or use PostHog's `properties.$defined` to combine both cohorts during the transition window.

#### Mitigation 2 — Reactive Twilio-side nudge (`app/api/sms/incoming/route.ts`)

When a migrated user (`sms_provider = 'linq'` AND had prior Twilio-SID activity in `silvia_sms_logs`) is still texting the OLD Twilio thread, the incoming Twilio handler tacks on a one-time-per-window Twilio-side nudge after the primary reply lands:

> Heads-up: I moved to a new number at +12062619826. Save that number as Silvia and text me there from now on for photos, PDFs, and richer replies.
>
> \- Silvia
> Reply STOP to opt-out

**Cohort gate**: the outer condition (`sms_provider = 'linq'` + non-empty `linq_assigned_number`) matches TWO cohorts — users we migrated FROM Twilio (via `POST /api/ops/linq/migrate-user`, manual DB flip during pilot, or any pre-endpoint path), AND users who signed up NATIVELY on Linq via the Clerk webhook (they were never on Twilio). Sending "Heads-up: I moved to a new number" to a native Linq user is factually wrong ("moved from what?"). Inner gate: `wasFormerlyOnTwilio({ supabase, clerkId })` — runs two parallel `silvia_sms_logs` lookups to find (a) the user's EARLIEST `linq_%`-prefixed SID row and (b) the user's EARLIEST non-null non-`linq_%` (Twilio-shaped) SID row, and returns `kind: 'yes'` iff the Twilio timestamp PREDATES the Linq timestamp. **Temporal ordering is the correct discriminator**: for a migrated user, their pre-migration Twilio history necessarily predates their earliest Linq row (which was written at migration time — greeting, or first post-flip outbound for `skip_greeting`); for a native Linq user, their earliest Linq row was written at Clerk-signup time, so any Twilio row they generate later is POST-Linq. This is robust to `skip_greeting: true` migrations, swallowed greeting-log inserts, users migrated BEFORE the endpoint existed (2 of 10 sampled prod `sms_provider='linq'` users on 2026-09-26), AND repeated Twilio inbounds from native users (an earlier `created_at` cutoff draft was defeated by a native user's second Twilio inbound >5 min after the first). Fail-closed on any error or missing Linq history. Cohort 2 short-circuits with `sms_migration_twilio_nudge_skipped_native_linq_server`.

**Rate limit**: compound CAS on `all_users.migration_nudge_sent_at` with a 30-day window (`MIGRATION_NUDGE_MIN_INTERVAL_DAYS`). First old-thread inbound after migration fires the nudge; subsequent inbounds within 30 days silently skip. Long-dormant users get periodic reminders (every 30 days) without daily spam.

**Release-CAS on failed send**: `tryClaimMigrationNudge` stamps `migration_nudge_sent_at` BEFORE the Twilio send. Without a compensating release, a failed send (Twilio 429/5xx) would silently lock the user out of the nudge for 30 days — and the cron view filters on `IS NULL`, so they'd never re-appear for a proactive retry either. `releaseMigrationNudgeClaim({ claimedAt, previousSentAt })` restores the pre-claim value via a compound CAS (`WHERE clerk_id = ? AND migration_nudge_sent_at = <claimedAt>`), only touching our own claim (never clobbering a concurrent producer's write). Called from both the reactive and proactive paths on `nudgeResult.sent === false`.

**Compliance footer**: `buildMigrationNudgeBody` always appends `COMPLIANCE_FOOTER` (`"\n\n- Silvia\nReply STOP to opt-out"`). Both nudge paths bypass `sendSilviaReply` (they use `provider.sendMessage` directly for the specific idempotency-key + logging shape they need), so the footer would NOT be appended by the standard `resolveComplianceFooterDecision` pipeline — it has to be baked into the body itself. Reactive nudges are Silvia-initiated addenda on the OLD Twilio thread; proactive cron nudges are 100% automated marketing SMS. Both need the TCPA/10DLC STOP-language per Twilio's carrier-network guidance for automated messages, even to opted-in users. The pre-footer body is \~145 chars; adding the 32-char footer takes the total to \~177 chars = 2 multipart SMS segments (GSM-7 budget: 160 chars stand-alone, 153 chars per multipart segment). Deliberate trade: compliance for the proactive path outweighs one extra segment on a nudge that fires at most once per 30 days per user. Modern iOS/Android silently concatenate multipart SMS, so the recipient sees ONE message.

**Race safety**: shares the CAS column with Mitigation 3 (the cron), so the two producers are effectively exactly-once per user across the whole system.

**Telemetry**: `sms_migration_twilio_nudge_sent_server` / `_failed_server` / `_skipped_rate_limit_server` / `_skipped_native_linq_server` events. `trigger: 'reactive_old_thread_inbound'` discriminates from the cron path without a JOIN.

#### Mitigation 3 — Proactive 48h follow-up cron (`app/api/cron/migration-followup`)

For users who go silent post-migration (didn't text either thread), a daily 17:00 UTC cron sweeps the `linq_migration_followup_candidates` view and sends the same nudge cold via Twilio on the OLD thread.

Eligibility (all must hold):

* `sms_provider = 'linq'` AND `migration_nudge_sent_at IS NULL`
* `sms_opt_in = true` AND `sms_unsubscribed_at IS NULL` (hard STOP compliance)
* `COALESCE(account_deleted, false) = false` (soft-delete exclusion)
* Migration greeting sent ≥48h ago (read from `silvia_sms_logs` `user_context->>type = 'migration_greeting'`) — the greeting-log JOIN intrinsically excludes native Linq users too (they have no such row)
* No inbound row with `twilio_message_sid LIKE 'linq_%'` (user hasn't texted the new line)

Reuses the same `tryClaimMigrationNudge` CAS as the reactive path — a race between cron sweep and old-thread inbound resolves to exactly one nudge.

**Timeout budget & batch cap**: exports `maxDuration = 300` (5 minutes, Vercel Pro ceiling) AND caps each run at `MAX_CANDIDATES_PER_RUN = 100` via `.order('greeting_created_at', ASC).limit(100)` on the candidate query. This is a critical safety net: without the cap, if Vercel kills the function AFTER `tryClaimMigrationNudge` writes `migration_nudge_sent_at = now()` but BEFORE the send + release finish, the claim stays set forever — the view predicates on `IS NULL`, so the user permanently drops off the candidate set with no future run ever picking them up. Silent lockout at exactly the moment ops is least likely to notice. Sequential per-user cost is \~1.5-2.5s (CAS + Twilio send at long-code 1/sec rate limit + log insert + optional release-CAS), so 100 users × 2.5s = 250s comfortably fits inside 300s. Rollover users stay in the view (their `migration_nudge_sent_at` is still NULL) and are drained on the next daily sweep — the `greeting_created_at ASC` ordering ensures OLDEST silent migrations are prioritized so long-waiting users can't be starved by a burst of same-day migrations.

**Response** shape (from a manual `curl` for staging validation):

```json theme={null}
{
  "ok": true,
  "processed": 12,
  "sent": 10,
  "skipped_rate_limit": 1,
  "failed": 1,
  "errors": [{ "clerk_id_tail": "abcd", "reason": "send_failed: twilio 429" }]
}
```

Per-user failures don't halt the sweep — the response body doubles as a per-run audit for cold outreach volume.

### Watching the pilot / bulk run in PostHog

Key queries during a migration window:

* **Fire rate**: count of `sms_provider_migrated_server` where `dry_run = false` grouped by day.
* **Greeting delivery**: ratio of `sms_migration_greeting_sent_server` to `sms_provider_migrated_server` — should trend at \~100% under normal Linq API health.
* **Proactive contact-card share rate** (Mitigation 1): count of `sms_migration_greeting_sent_server` where `share_card_status = 'shared'` — should trend at \~100%. A dip to `'failed'` signals Linq `/share_contact_card` API instability; a dip to `'skipped'` with `share_card_skip_reason = 'no_chat_id'` signals a Linq adapter regression (bubble 1 sends succeeding without returning a chatId); a dip to `'null'` signals bubble 1 send failures worth cross-checking against `sms_migration_greeting_failed_server` volume.
* **Reactive nudge volume** (Mitigation 2): count of `sms_migration_twilio_nudge_sent_server` where `trigger = 'reactive_old_thread_inbound'`. High volume = many users still texting the old thread (add more onboarding polish); low volume = they either adopted the new line quickly OR went silent (Mitigation 3 will pick them up).
* **Cohort-gate skip rate** (Mitigation 2): count of `sms_migration_twilio_nudge_skipped_native_linq_server`. Payload carries `reason ∈ {'no_prior_twilio_activity', 'lookup_error'}`. `no_prior_twilio_activity` means `wasFormerlyOnTwilio` could not establish pre-Linq Twilio activity — collapses three distinct cases into one signal (genuine native signup, purged-history migrated user, or the rare "flipped but no Linq activity yet" edge — see `MIGRATION_TWILIO_NUDGE_SKIPPED_NATIVE_LINQ` docstring in `telemetry.ts` for the breakdown). The event does NOT distinguish them, so treat a sustained non-zero rate as "worth investigating" rather than "N native users are texting the old number": possible causes include a stale marketing asset surfacing the Twilio number, an elevated GDPR-purge rate, or an enrollment-path regression producing orphaned cohort-1 rows. If `reason = 'lookup_error'` spikes, `wasFormerlyOnTwilio`'s `silvia_sms_logs` lookup is failing (fail-closed behavior is correct — no false-positive nudge — but investigate the DB signal).
* **Proactive nudge volume** (Mitigation 3): same event with `trigger = 'proactive_48h_cron'`. Ideally trends toward zero over time as Mitigation 1 (proactive `share_contact_card`) drives more first-time saves up-front.
* **First-inbound-after-migration**: JOIN `sms_provider_migrated_server` (per user) to the next inbound `sms_response_completed_server` (same user, `provider = 'linq'`) — proves the user found the new line and started using it.
* **Line load**: `linq_line_utilization` view (see § Line capacity monitoring) — watch for any single line approaching `worstLineStatus = 'critical'` during the rollout.

## References

* [Linq Partner API reference](https://docs.linqapp.com/)
* [Linq Zero-Day Retention guide](https://docs.linqapp.com/channel/imessage/guides/platform/zero-day-retention/)
* [Linq Security & Compliance](https://linqapp.com/s/security) — SOC 2 Type I + II + Trust Center
* [Standard Webhooks specification](https://www.standardwebhooks.com/)
* [ElevenLabs Twilio native integration](https://elevenlabs.io/docs/eleven-agents/phone-numbers/twilio-integration/native-integration)
* Silvia's current Twilio flow: [`docs/sms/SMS-IMPLEMENTATION-SUMMARY.md`](./SMS-IMPLEMENTATION-SUMMARY.md)
* Twilio event streams (delivery status ingest): [`docs/sms/TWILIO-EVENT-STREAMS-SETUP.md`](./TWILIO-EVENT-STREAMS-SETUP.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.