Skip to main content

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 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)

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: 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

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.

DB schema change

Migration supabase/migrations/20260827_01_all_users_sms_provider.sql adds:
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, 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

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: “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). 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) 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) + 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 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 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. 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 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 adds four nullable columns to all_users, plus 20260925_01_all_users_linq_chat_id.sql adds a fifth for the Gap #1 self-heal path:
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:
    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):
    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)

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)

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:
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: 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: 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). 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 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 in the api-client.
  • Sync business logic: lives in 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):
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.
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: 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:
    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):
Then send a fresh outbound and assert the footer presence per the table: 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. For production and staging, set both env vars in Vercel:
⚠️ 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 — 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. 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 GETs, list calls, and delivery-lifecycle webhooks return empty content. Full docs: 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:

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 — 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 (search for [Linq webhook] inbound claimed).

Auditing whether a line is on ZDR

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

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).

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): 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, 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.

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.

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

The incident

Shain’s prod line, 2026-09-13 02:21 UTC:
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), 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): 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 — 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): 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 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

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):
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) 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 for the rationale):
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 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 — 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 — 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 — 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 (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 — 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:

Threshold model

All thresholds are absolute contact counts, not percentages — this way a future Linq cap raise doesn’t silently move the alert boundaries. 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:
Tighter alerting during launch week (override thresholds without redeploying):

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:

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:
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

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):
200 migrated with pre-announce skipped (skip_preannounce: true):
502 preannounce_failed — Twilio SMS didn’t send; migration ABORTED with no DB changes:
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:
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):
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):
200 would_migrate (dry_run: true):
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. 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:

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):
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