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 viaall_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
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).
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:
LINQ_PILOT_CLERK_IDSenv allow-list — instant override, no DB write required. Reserved for internal testing / incident response.all_users.sms_providercolumn — per-user policy.'linq'or'twilio'pins the user;NULLdefers to the account default.MESSAGING_PROVIDERenv var — account-wide default.unset→ Twilio.
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 ininbound-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
Migrationsupabase/migrations/20260827_01_all_users_sms_provider.sql adds:
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_deletedguard: never messages deleted accounts. Auto-flipssms_opt_in = falseif a deleted account receives a Linq message (defensive cleanup).- STOP/START/HELP/CFOSILVIA:
lib/messaging/opt-out-scan.tsimplements 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.
- 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)
- In the Linq sandbox dashboard, create a webhook subscription:
target_url:https://<staging-host>/api/messages/incoming/linq?version=2026-02-03subscribed_events:message.received,message.sent,message.delivered,message.read,message.failed,reaction.added,reaction.removed- NOTE:
reaction.added/reaction.removedrequire an ops flip on the existing subscription (add these two tosubscribed_eventsvia the Linq dashboard’s Update Subscription action). Until they’re added, the reaction-capture wiring introduced in migration20260918_01is 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_reactionsrows (bothWHERE clerk_id = $deleted_clerk_idand an age-based sweep forWHERE clerk_id IS NULL AND created_at < now() - interval '<retention>').silvia_sms_reactionshas no FK cascade back tosilvia_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. Thedelivery_servicecolumn has no such requirement (it lives on the existingsilvia_sms_logsrow, which the cron already deletes).
- NOTE:
- Copy the returned
signing_secret(whsec_...) into staging’sLINQ_WEBHOOK_SIGNING_SECRETenv var. This secret can never be retrieved later — losing it means deleting and recreating the subscription. - From the Linq dashboard “API Playground”, send a test message to a phone in your possession.
- Reply from the phone. Confirm a row lands in
silvia_sms_logswithuser_context.provider = 'linq',user_context.event_idmatching the Linq dashboard, anddirection = 'inbound'followed bydirection = 'outbound'with Silvia’s reply. - 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. - Set the tester’s
all_users.sms_provider = 'linq'(or add their clerk_id toLINQ_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'orLINQ_PILOT_CLERK_IDSincludes 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 = NULLvia 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
-
Onboarding flow —
GET /v3/available_numberat signup- New helper
lib/linq/onboarding.ts#enrollUserInLinqcentralizes the “give this user a Linq line” decision. Called from three places:- Clerk
user.createdwebhook (gated byLINQ_ONBOARD_NEW_USERS_MODE, defaultoff) - Admin ops endpoint
POST /api/ops/linq/enroll(manual trigger for test accounts,force: truebypasses the env gate) - Future backfill scripts
- Clerk
- Modes:
off— no Linq calls at signup (production default until go-live)discover— callavailable_number, persist assignment columns, but don’t setsms_provider. Users still go through Twilio; onboarding runs end-to-end for validation.enroll— same as discover PLUSsms_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.
- New helper
-
Contact card setup — one card per line in your pool
- Script
scripts/linq/setup-contact-cards.tsiteratesGET /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-runfor a plan-only pass.
- Script
-
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 newall_users.linq_contact_card_shared_atcolumn (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.
- After a successful Linq outbound in the inbound webhook, we call
-
Line-reputation monitoring —
phone_number.status_updatedwebhook- New handler
lib/linq/phone-number-status.tsdispatches this event out of the existing/api/messages/incoming/linqroute (single subscription, event-type dispatch). - Every event is persisted to
silvia_sms_logs.user_contextfor audit. - Degradations (ACTIVE→FLAGGED, HEALTHY→AT_RISK, AT_RISK→CRITICAL) fire a Slack alert to
#silvia-alertsvia the existingsendSlackAlerthelper. 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
dedupeSuffixoption) so two lines degrading at once both alert.
- New handler
-
Deterministic idempotency keys on every outbound
sendSilviaReplyinlib/messaging/outbound.tsnow passessilvia-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 explicitbody.from. We forwardlinq_assigned_numberthere. This is the only endpoint that enforces the pin unconditionally.POST /v3/messages(URL bodies ORforceManagedLine: true) — does not acceptfrom: “takes the recipients and the message and nothing else — nofrom. 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 (allforceManagedLine: true, all directprovider.sendMessage)POST /api/sms/research-complete— DR completion bubbles (viasendSilviaReply)POST /api/sms/token-limit-paused— token-cap notice (viasendSilviaReplyunder the hood, plus one direct send)lib/messaging/opt-in-greeting.ts— 3-bubble welcome sequence (all directprovider.sendMessage)app/api/messages/incoming/linq/route.ts— apology outbound + STOP/START/HELP/CFOSILVIA compliance replies (all directprovider.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
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:
- Short-circuits when
frommatches the storedlinq_assigned_numberAND the chat_id is in sync — steady-state common case, no DB round-trip. - Otherwise reads the current row, computes the delta.
- Compare-and-swap-updates
linq_assigned_number+linq_chat_id(new column added by20260925_01_all_users_linq_chat_id.sql) + clearslinq_assigned_vcf_urlwhen the line changes (contact card is line-bound). Predicate:WHERE linq_assigned_number = <expected>(orIS NULLwhen both caller and DB are NULL) — race-safe against concurrent sends.
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/chatsresponses lackfrom/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.
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:
/v3/chatsrejects URL-containing first messages with HTTP 400 (anti-spam). Radar / DR completions all containcfosilvia.comlinks, so first-touch there would require injecting a URL-free opener bubble before the link — real UX cost.- 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.
- Larger code surface — routing branch + first-touch opener injection + tests for both endpoint paths.
- Provider response parsing —
linq-provider.test.tsverifiesfrom,from_selection.reason,chat_id,previous_chat_idare extracted from all three/v3/messagesbranches (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.tsverifies self-heal FIRES whensupabase + clerkIdpresent +/v3/messagespath, and SKIPS on cold-lead paths (no supabase, no clerkId) and on/v3/chatspath. - Self-heal behavior —
lib/linq/__tests__/self-heal-assigned-line.test.tscovers 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.tssource-greps the Linq provider file to catch a future refactor that drops the import, unwires the!useExplicitFrom/supabase && clerkIdguards, drops theawait, or introduces a rogue call site outside the provider. The same test also validates callsite coverage: every directprovider.sendMessage(...)inapp/andlib/must pass bothsupabaseandclerkId(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.
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) — readssms_provider+linq_assigned_numberin one query and returns{ provider, fromE164, fromSource }. Use this at every outbound entry point in place ofgetProviderForClerkId.SendMessageInput.fromE164(lib/messaging/types.ts) +SendSilviaReplyInput.fromE164(lib/messaging/outbound.ts) — per-call sender-line pin. Threaded throughsendSilviaReplyon every chunk and every artifact bubble.makeLinqProvider(lib/messaging/providers/linq.ts) — resolvesinput.fromE164 ?? envFallbackFromNumberand writes it tobody.fromonly on thePOST /v3/chatspath (per the endpoint contract above).LINQ_FROM_NUMBERenv var is now the fallback for legacy call sites and cold-text lead paths (unknown-number-lead.ts) where no user row exists yet.
fromSource discriminator on the returned context surfaces the resolution provenance in ops logs:
assigned— user has alinq_assigned_number, per-user routing succeeded.unassigned— user is on Linq but the column is NULL (adapter falls back toLINQ_FROM_NUMBER, plus a[messaging] linq user has no linq_assigned_numberwarning so ops can find users the enroll cron missed).twilio— user is on Twilio,fromE164is intentionally unset.no_user— cold-text / lead paths with noclerk_id.
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.tslocks in the identity claim, save-contact CTA, STOP disclosure, absence of URLs, length bounds, and plain-string type. - Wire shape —
linq-provider.test.tsverifies field present on/v3/messages, absent on/v3/chats, override wins, no-silent-substitution semantics. - Wiring —
linq-continuation-message-wiring.test.tssource-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
Migrationsupabase/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:
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:- Contact cards — run the setup script against the sandbox pool:
Confirm it prints the CREATE / PATCH / unchanged plan. Re-run without
--dry-runto apply. - Onboarding via admin endpoint (enroll your own test clerk_id; set
STAGING_BASE_URLto your staging origin, referenced as<staging-origin>below):Confirm the response showsstatus: 'assigned'and query the DB to seelinq_assigned_numberpopulated on that row. - Onboarding via Clerk webhook — set
LINQ_ONBOARD_NEW_USERS_MODE=discoveron staging, sign up a fresh account, confirm the row lands withlinq_assigned_numberfilled butsms_providerstill NULL. - Contact-card share — set your own row
sms_provider = 'linq', text the sandbox line, confirm Silvia replies AND ashare_contact_cardrequest lands in the Linq dashboard’s request log. Text her again within 24h — confirm no second share (cooldown gate). Wait 24h+ or manually clearlinq_contact_card_shared_at, text again — confirm a fresh share. - 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.receivedevents 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:- Have Linq provision production lines (they told us they’d handle this).
- Run
scripts/linq/setup-contact-cards.tsagainst production once the pool is live (populateLINQ_CONTACT_CARD_IMAGE_URLfirst if you want an avatar). - Flip production’s
LINQ_ONBOARD_NEW_USERS_MODEfromofftodiscover— validate the flow on a real signup or two. - Flip production’s
LINQ_ONBOARD_NEW_USERS_MODEfromdiscovertoenroll— from this moment forward, every new signup goes through Linq. - Announce the migration to the Twilio user base via a one-shot SMS with the new Linq number. Bulk-migrate
sms_provideras users respond. - Monitor the
#silvia-alertsSlack channel forLINQ_LINE_DEGRADEDevents; 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=offso no new users get enrolled. Users already enrolled continue on Linq; roll them back individually withUPDATE all_users SET sms_provider = 'twilio' WHERE clerk_id = '...'. Explicit'twilio'(NOTNULL) becauseNULLdefers to theMESSAGING_PROVIDERenv var (see the routing precedence table earlier in this doc): if that variable is ever set tolinqaccount-wide, aNULLrollback 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
Everyall_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:
- 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>), thensupabase db pushto apply pending migration files.supabase migration upalone is a local-database command and will migrate your local shadow DB rather than the remote — always usedb pushfor remote environments. - MCP
apply_migration— call the tool from the environment-scoped namespace (user-silvia-staging-supabasefor staging oruser-silvia-production-supabasefor 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 ambientSUPABASE_PROJECT_REFenv resolution.
- Supabase CLI — link the intended project first (
- Run
NOTIFY pgrst, 'reload schema';against the target DB (via the MCPexecute_sqltool 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. - 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). - Trigger a smoke UPDATE from the app path that writes the new column and check
postgres_logs(Supabase MCPquery_logs,source = 'postgres_logs') for anycolumn ... does not existerrors.
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 foronboarding_sent_atper 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 forlinq_contact_card_shared_at(requires both42703/PGRST204error code AND the message references the column), emits a targeted warn naming the exact remediation command and skips the share entirely. Rationale: firingshareContactCard()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 inlib/linq/__tests__/contact-card-share.test.tsunder the “PostgREST schema-cache-miss detection” describe block, plus near-miss coverage that verifies unrelated42703/PGRST204errors still preserve fail-closed behavior.lib/messaging/first-text-greeting.ts::claimFirstTextGreetingSlot— on schema-cache miss specifically forhas_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.
(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):
- Extracts the reaction type (first marker wins)
- Strips ALL markers from the reply (never reaches the recipient)
- Fires
addReactionon the user’s trigger message AFTERsendSilviaReplysucceeds
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 astype: "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
MediaPartResponseintoNormalizedInboundMessage.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 thestartsWith('audio/')check - Mixed text + audio parts (rare but supported by Linq) — text goes to
body, audio goes tomedia[] 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:
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 asmessage.reply_to.NormalizedInboundMessage.parentProviderMessageId?: string | null— set on inbound when the webhook envelope includesdata.reply_to.message_id; stored with alinq_prefix so it matches the storage form oftwilio_message_sid.
Linq provider (lib/messaging/providers/linq.ts)
- Extracts
input.replyTo.messageId, strips thelinq_storage prefix (idempotent — bare ids pass through), and emits{ reply_to: { message_id, part_index? } }inside the outboundmessageobject. EmptymessageIdgracefully 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_idon the Linq webhook. - Prefixes with
linq_and populatesNormalizedInboundMessage.parentProviderMessageId. - Guards: null-object
reply_to, empty stringmessage_id, and missing field all resolve toparentProviderMessageId: null.
Inbound route (app/api/messages/incoming/linq/route.ts)
- Claim-row insert stamps
parent_provider_message_idon 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
replyToand forwards it toprovider.sendMessageon every chunk of a multi-chunk send. - Writes
parent_provider_message_idfrom the dedicatedinput.parentProviderMessageIdstorage anchor (falling back tonull) on the outbound audit row so the history walker can reconstruct the thread from either direction.parentProviderMessageIdis separate fromreplyToon purpose:replyTo.messageIdis the WIRE anchor (the message immediately above Silvia’s reply in the visual thread, always the user’s inbound per iMessage convention), whileparentProviderMessageIdis the STORAGE anchor (the earliest Silvia message the user’s thread walks back to). Conflating them intoreplyTo.messageId ?? nullwas 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 —
replyTois 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-completeendpoint to fire a threaded completion send using this samereplyToAPI. 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_idextraction,linq_prefix application, null/absent/empty guards.lib/messaging/__tests__/outbound.test.ts— provider receivesreplyTo, every chunk of a multi-part reply carries the same anchor, audit row storesparent_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).
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
smsExcludedToolsinapp/api/sms/incoming/route.ts(see theSCOPINGdocstring 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 atapp/api/sms/incoming/__tests__/excluded-tools.test.ts. - Webapp chat (
/api/claude) — excluded viaWEBAPP_EXCLUDED_TOOLSinlib/claude/chat-turn.ts(see theSCOPINGdocstring above the set). Rationale: the webapp already reaches the same DR backend today viaexecute_python+ thesilvia-deep-research-skillsskill (per the “Deep Research” block inapp/api/claude/config/system-prompt.ts, e.g. prod sessionsession_c678ee34on 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 atlib/claude/__tests__/webapp-excluded-tools.test.ts.
app/api/claude/config/system-prompt.ts so Claude knows both paths exist.
Flow:
- Validate context (
clerkIdrequired; DR API key from env; question non-empty). - 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/streamand parsed an SSE frame forsession_id. That endpoint has never existed in the deployed DR backend —/openapi.jsonlists 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.) - If this is an SMS turn (
sourceProviderMessageIdpresent), UPSERTdr_sms_notificationswithonConflict: research_session_id, ignoreDuplicates: true— Claude tool-loop retries produce exactly one row. - Capture
deep_research_startedPostHog event, source-tagged (smsvschat_claude). - Return JSON envelope with a canonical
ack_messagefor the model to paraphrase (keeps “I’ll text you back on this thread” contract stable across trigger paths).
{ 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):
X-Internal-API-Tokenheader equalsINTERNAL_API_TOKEN, OR- Session-ownership fallback:
session_idresolves to aresearch_sessionsrow whereclerk_id = userId AND status = 'completed'.
- 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_atcolumn added in migration20260905_02_dr_sms_notifications_send_lease.sql; TTL 2 minutes). Claim UPDATE is filtered onsent_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 whethersend_erroris 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” (VercelmaxDurationkill mid-send, OOM, uncaught rejection between claim and thesendSilviaReplytry/catch). If the winner is dead, nosent_atand nosend_errorare ever stamped and the completion SMS is lost (Bugbot PR #3747 round 5 “In-flight 200 drops completion retries”). 503 +Retry-Afteris safe in BOTH sub-cases: the retry after the lease TTL either hitssent_at IS NOT NULLearly-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. TheleaseStatelog field discriminates the two sub-cases for ops dashboards (prior_ambiguous_failurevswinner_in_flight_or_dead); theconcurrent_claimreason code from earlier iterations is retired. Success clears the lease alongside thesent_atstamp. Failure handling (Bugbot + CodeRabbit PR #3747 follow-up):- Compliance-gate skip (user missing, deleted, opted-out, no phone):
recordSendFailure(..., { releaseLease: true })clears the lease alongsidesend_errorso 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
isDefinitiveSendRejectionreads three shapes:.status(Twilio SDKRestException),.httpStatus(LinqApiErrorfromlib/linq/api-client.ts), and anHTTP <NNN>regex fallback on.messagefor the plainErrorthrown bylib/messaging/providers/linq.ts(Bugbot PR #3747 flagged that reading only.statusmisclassified 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-messageIdempotency-Keyon 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(nosend_errorstamp 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 tosendSMSfor Twilio too, as a log-only correlation id until Twilio adds native support). NOTE: an earlier fix used a compare-and-swap onsend_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.
- Compliance-gate skip (user missing, deleted, opted-out, no phone):
- Phone stored without
+prefix → normalized to E.164 (matchesradar-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 asend_errorstamped so the row shows the reason. getProviderForClerkIdre-resolves the CURRENT provider (handles Linq↔Twilio user migration between trigger and completion).
- 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_atstamp — 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 passforceManagedLine: trueso Linq routes them throughPOST /v3/messages(managed line-selection) uniformly. Without the pin, bubble 1 (URL-free) would route throughPOST /v3/chatson the branded line under prod’sLINQ_FROM_NUMBER="+12062619826"config, while bubble 2 (bare URL) would route throughPOST /v3/messageson 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 theforceManagedLinedocstring onSendSilviaReplyInput(lib/messaging/outbound.ts) andSendMessageInput(lib/messaging/types.ts).
- Bubble 1:
- 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 viagetProviderForClerkId).forceManagedLineis 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.
- 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
sendSilviaReplywithreplyTo: { 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: falseso the genericRESEARCH_POINTER_URLdoesn’t append (the session-scoped URL in the body is strictly better). The upstream ack turn ALSO sendsusedResearchTools: falseeven though it calledcreate_deep_research, because the sharedRESEARCH_ACK_POINTER_TOOLSlist inlib/messaging/research-tools.tsdeliberately excludescreate_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_countbefore the send; stampssent_at(withis('sent_at', null)race guard) after success. Provider send failure → 502 +send_errorstamped 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_researchon explicit “deep research / deep dive / full analysis” asks, not on quick lookups (which stay onquant_analyst/execute_python). - Paraphrase the tool’s
ack_messageinto one short sentence and end the reply with the<!--react:like-->marker so the trigger message gets a 👍 tapback. - Handle
success: falsegracefully (one-sentence “temporarily unavailable” without the reaction marker).
Behavioral notes
- Twilio does NOT trigger
create_deep_research— the tool is deliberately kept insmsExcludedToolson the Twilio path (see theSCOPINGblock inapp/api/sms/incoming/route.tsand the regression test inapp/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_notificationstable already handle the Twilio branch (flat completion SMS, noreply_to) correctly. - Webapp chat unchanged — the webapp already reaches the same DR backend via
execute_python+ thesilvia-deep-research-skillsskill (per the “Deep Research (one-time deep analysis)” block inapp/api/claude/config/system-prompt.ts, evidenced by prod sessionsession_c678ee34on 2026-09-08). The dedicatedcreate_deep_researchtool is filtered out of the webapp tool list viaWEBAPP_EXCLUDED_TOOLSinlib/claude/chat-turn.tsso 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-completeendpoint 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-Afterin 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 toalready_sent; end-to-end silently-dead-winner recovery via re-claim after TTL; expired lease re-claims; ceiling ofRetry-Afterat 1 second; PII redaction in log field), classifier coverage across all three provider error shapes (Twilio.status,LinqApiError.httpStatus, plain-ErrorHTTP <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_TOOLSpolicy guard (empty today,create_deep_researchabsent),shouldAppendResearchPointerbehavior 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
Configuration
- Env var:
LINQ_VOICE_FORWARDING_NUMBER=+18312154002(see.env.example). Decoupled fromTWILIO_PHONE_NUMBERso 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--clearexplicitly. - Linq API:
PUT /v3/phone_numbers/{id}with body{ "forwarding_number": "<E.164>" }. V3-exclusive, notPATCH(verified viaOPTIONSprobe — returnsAllow: PUT). Exposed viaupdatePhoneNumberin 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):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
TELand 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 --cleargets us back to the pre-forwarding state instantly. - iMessage/SMS traffic unaffected:
forwarding_numberis a voice-only setting. Inbound messages continue to flow throughPOST /api/messages/incoming/linqregardless of the forwarding value.
Compliance footer regime (SMS/RCS reduced-noise mode)
Historically Silvia appended"\n\n- Silvia\nReply STOP to opt-out" to every outbound SMS/RCS reply on Twilio and Linq (iMessage was already exempt via the channel === 'imessage' skip). That’s the safest legal posture but not what industry-standard conversational AI products do — ChatGPT SMS, Character.AI SMS, and every 10DLC-registered agent I’ve compared against treat the footer as an anchoring compliance record, not a per-message decoration.
What TCPA / CTIA actually require
- TCPA (federal, FCC-enforced): prior express consent + a reasonable opt-out mechanism. Does not require opt-out language on every single message — only that the mechanism exist and be honored.
- CTIA Short Code Monitoring Handbook / MMA guidelines (industry self-reg, carrier-enforced): recommends opt-out language “clearly and conspicuously.” Industry interpretation:
- First message in a campaign/relationship (this is the compliance record)
- Periodic reminders (~30 day cadence)
- Not required on every reply in an active conversation
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 byCOMPLIANCE_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 onsilvia_sms_logs (phone_number, direction='outbound', response_sent=true, message_content LIKE '%Reply STOP to opt-out') ORDER BY created_at DESC LIMIT 1. TheLIKEfilter is load-bearing — it excludes iMessage replies, radar / token-limit system sends, andskipComplianceFooteropt-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 isfirst_touch_plus_periodicAND 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
getLastOutboundTimestampreturnsnull, which routes to the APPEND branch in the resolver. A DB blip can never silently strip a footer.
Rollout runbook
- Merge the code with
COMPLIANCE_FOOTER_MODEunset in Vercel (default:every— zero behavior change on prod). - Set the recommended production values in staging Vercel env:
Restart deploy.
- Run the staging verification matrix below in full. Do not promote to prod until every check passes.
- Set the same env vars in prod Vercel env once staging validates. Restart deploy.
- 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 againstsilvia_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):
If any row disagrees with the expected column, do NOT promote to prod. Roll back to
COMPLIANCE_FOOTER_MODE=every and investigate resolveComplianceFooterDecision + getLastOutboundTimestamp unit tests.
Recommended production values
For production and staging, set both env vars in Vercel:- 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_DAYShave 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
getLastOutboundTimestampinlib/messaging/outbound.ts, which filterssilvia_sms_logsfor outbound rows whosemessage_contentends withReply 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 onceCOMPLIANCE_FOOTER_REMINDER_INTERVAL_DAYShave 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.
Linq dashboard concepts — Inbox vs. Contacts
Linq’s dashboard exposes two first-class surfaces that are easy to conflate:- 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/linqwebhook consumes these events. You cannot turn this off; there’s nothing to configure. - 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.
Silvia’s Linq Contacts sync
Silvia populates the Contacts tab automatically. Two paths:- Auto-create on enrollment. Every successful call to
enrollUserInLinq(Clerkuser.createdwebhook, admin/api/ops/linq/enrollendpoint, race-path upgrades) firessyncLinqContactBestEffortas a side effect. The helper reads plaintext names from Clerk (clerk.users.getUser), normalizes the user’s phone to E.164, dedupes againstGET /v2/contacts/find?phone_number=<E.164>, and creates the record viaPOST /v2/contacts. Fully best-effort — a Clerk lookup failure or Linq API blip logs a warning and never fails the enrollment. - 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_FILEenv var) at run time; the script enforces that the path is either outside the repo or matches the git-ignoredscripts/linq/roster*.jsonpattern, so production clerk_ids can never be committed by accident. Runs locally against prod.CLERK_SECRET_KEYoptional: 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.
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. LaterGETs, 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.
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
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:- 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(id73c0d1ca-ee7d-4ca3-904b-02b188245cf9). - After Linq confirms, send a probe iMessage through the line.
- Verify with the SQL above that
zdr_trueis non-zero for the recent window on that line. Expectfalseornullbefore the change (Linq stampsfalseon ZDR-aware event types when the line is not enabled, andnullon non-ZDR-aware or older event types), andtrueafter.
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.tscan grep the corpus without a decrypt-per-row on every retrieval. - Con: A full DB compromise would expose message content.
- Account-deletion completeness —
/api/account/deletescrubs the Linq V2 CRM contact immediately; the Silvia-Admin 30-day hard-delete cron purgessilvia_sms_logs,dr_sms_notifications, andsms_unknown_number_leadsrows. Tracked as PR B1 (Silvia-Web) + PR B2 (Silvia-Admin). - Retention policy — optional pg_cron for time-based purge of
silvia_sms_logsrows 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_logsretention governs the Silvia → Silvia’s own DB persistence path.
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:
- Product intent: the radar-generation extractor at
aws/radar/lambdas/processor/handler.py:344-372documentssummaryas"Brief summary for email subject"and falls back toanalysis[:200]when the model omits it. The 500 cap is a 2.5× safety margin over the ≤200-char product intent. - 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.
- 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 rendersfull_analysis(seeapp/(app)/radar/page.tsx:1191). - 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(akafull_analysis). See follow-up ticket in the PR description.
…\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.
Sanitization order
sanitizeRadarSummaryForEmail runs FIRST (provider-independent) with the following steps in order:
- XML tool-invocation leak — truncate at the first
</summary>closing tag. Recovers the intended brief summary from theresolve_analysis_bodyextractor bug that pulls the entire Anthropic tool-invocation stream intosummaryfor ~12 power-user prompts (~319 mega-radars/mo in production). - Fenced code blocks (
```ui-metric-grid `... `,```json `... `, etc.) — stripped entirely. - Orphan / unterminated fences — truncate.
- Markdown markers (headings, bold, italic, lists, links, images, strikethrough, inline code) — stripped, inner text kept.
- Whitespace normalization — collapse 3+ blank lines to 1, trim.
'', 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, anALERT TRIGGERED/RADAR UPDATEpill, the title, a body snippet, the date, and aView full analysis →CTA
/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, aDEEP RESEARCHpill, the sanitized question, the meta line, the completion date, and aView full report →CTA
/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) ORcompleted_atwithin 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_FORMATinresearch-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_MSis re-exported fromlib/radar/og-fetch.ts(single source of truth) so future tuning changes land in both surfaces or neither.
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:Your deep research is ready cards stacked in the user’s iMessage thread.
Root cause
ThedetectDeepResearchIntent 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.,resolveForcedDeepResearchToolChoicematched on THIS inbound and forcedtool_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.wasForcedByClassifieris populated byrunSilviaChatTurnfromBoolean(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_blockedPostHog event and the executor’sconsole.warnpayload carryquestion_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).
pg_advisory_xact_lock(hashtext(clerk_id)) inside a transaction wrapping check + kickoff) or (b) a client-generated idempotency key that the DR backend enforces via INSERT ... ON CONFLICT DO NOTHING on research_sessions(clerk_id, idempotency_key). Both are heavy lifts (Postgres transaction wrapping in create-deep-research.ts or DR-backend contract change) and belong in a follow-up.
Why both layers
Prompt-only is fragile — the model can ignore natural-language guidance. Executor-only lets the model write awkward text (“It’s running now, I’ll text you when it’s done”) before the executor refuses; that text still gets sent to the user and looks wrong. Together the prompt guard prevents the awkward text and the executor guard prevents the duplicate DB row + ECS task + completion SMS.Test coverage
lib/messaging/__tests__/dr-double-fire-prompt-guard.test.ts— 7 source-grep tests over the prompt language.app/api/claude/tools/executors/__tests__/create-deep-research.test.ts— “SMS Step-0 dedupe gate” describe block — 9 executor tests: refuses within window, DOES NOT dedupe outside window, DOES NOT dedupe on webapp turns, fails open on DB error, pins the window constant + the table name, PII-safety (F2: no question text in logs or analytics, ever), and three F3 bypass tests (wasForcedByClassifier=truebypasses;undefinedgates normally; literalfalsegates normally — pins the strict=== truecomparison semantics).
Deep Research: pre-LLM filler-tapback guard (2026-09-13 “Ok → duplicate DR” incident + broader UX win)
The incident
Shain’s prod line, 2026-09-13 07:44 UTC (24 hours after the first “Thanks → duplicate DR” incident above, and AFTER the two-layer fix from PR #3952 had shipped):- 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_MSwas 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):
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.- Provider gate —
normalized.serviceMUST be'imessage'or'rcs'. Plain SMS falls through to LLM (no tapback primitive). - Prior-outbound lookup — one indexed query on
silvia_sms_logs (clerk_id, direction='outbound', order by created_at desc, limit 1, maybeSingle). 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.- Fire
addReaction(providerMessageId, { operation: 'add', type: 'like' })(awaited). If the reaction API throws, log andreturn falsefrom the guard — the caller falls through to the LLM path (see the “Reaction failure ≠ silent Silvia” design note below). - Emit PostHog
sms_filler_tapback_no_reply_serverwithtoken,service,messaging_provider,inbound_length. - Return 200.
runSilviaChatTurnnever runs.
lib/messaging/filler-classifier.ts for the rationale):
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
addReactionthrows, we log andreturn falsefrommaybeFireFillerTapback. The caller seesfillerShortcutFired === falseand 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_TOKENSset (canonical membership; explicit non-membership of confirmation-shaped tokens likeyes/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, pluralshould we/shall we/want us to, case-insensitivity,\bword-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,maybeFireFillerTapbackcall site, early return BEFORErunSilviaChatTurn, provider gate, media-bearing gate (F1 below),isFillershort-circuit before DB query, prior-outbound query shape, threaded-parent lookup preference (F3 below) withclerk_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.userNotifiedflip +scheduleInboundVaultUpload()before returning), and — added in PR #3974 — theresponse_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 skippedscheduleInboundVaultUpload(). 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 theisMediaBearinggate at the call site —normalized.media.length > 0. Uses the RAW envelope count (not the derivedimages/droppedImageMimeTypes/transcriptionFailedflags fromprocessInboundMedia) because the raw count is a strict superset — any of those flags being truthy impliesnormalized.media.length > 0, but the raw count also catches (a) supported images whose DOWNLOAD failed insideprocessInboundMedia(never lands inimagesordroppedImageMimeTypes) and (b) other media types like stickers or vcards thatprocessInboundMediadrops 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 from6ato7bsince it now runs AFTER media processing (needs the media flags +effectiveBody). -
F2 — Footer hides prior-outbound questions. Bugbot flagged that
silvia_sms_logs.message_contentfor 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: applysanitizeHistoryMessage(priorOutboundRaw, 'outbound')before handing to the classifier. Same normalizer the history-load path uses; strips the chunk prefix, theCOMPLIANCE_FOOTER_REmatch, 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.parentProviderMessageIdwhen 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 — whenparentProviderMessageIdis set, query by exacttwilio_message_sidfirst (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 === 0branch returned 200 after firing the tapback WITHOUT callingscheduleInboundVaultUpload()or flippingsendState.userNotified. IfrunSilviaChatTurnhad 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: setsendState.userNotified = truethen callscheduleInboundVaultUpload()before returning, in that order (the closure’s docstring at 7a locks in the invariant “after()scheduled ⇒userNotified === true⇒ no Linq retry”). Also addedimagesScheduledForVault: images.lengthto 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).
sanitizeHistoryMessagestrips the compliance footer + trailing ”- Silvia” signature but was leaving the\n\nView details: <RESEARCH_POINTER_URL>suffix thatsendSilviaReplyappends whenusedResearchTools === true(seelib/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 inresearch?\n\nView details: …/researchlooks like it ends withresearch, 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: extendsanitizeHistoryMessageinlib/messaging/outbound.tswith aRESEARCH_POINTER_SUFFIX_REstrip 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_messageprefixed 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 onlyresponse_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 explicitnull) 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 === 0branch fires the tapback and returns 200 without updatingsilvia_sms_logsat all. Ops dashboards countingWHERE direction='inbound' AND response_sent=falsetherefore 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-wrappedsilvia_sms_logsupdate withresponse_sent: true,delivery_updated_at, and the marker-aware conditional spread, placed AFTERsendState.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.parentProviderMessageIdcopies verbatim from the untrustedreply_to.message_idin the Linq webhook payload. The threaded strategy queried bytwilio_message_sid+direction='outbound'only — noclerk_idfilter. 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 onclerk_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:- Linq’s pool picker silently excludes it from
new_best_numberresponses — no error thrown, just other lines take over new enrollments. - If ALL lines are at cap,
POST /v3/messagesfails outright for any first-touch chat. - Existing chats on a capped line keep working (no cap on messages per chat, only on distinct chats).
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).
Architecture
Real-time per-line counts are computed fromall_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: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 = truefiltered 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 areputation.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:
listPhoneNumbers()throws (Linq API down, network blip, auth misconfig) → log a warning, enroll unfiltered.listPhoneNumbers()returns zero lines → log a warning, enroll unfiltered (let Linq’s own error surface if the pool is truly empty).- 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 →pickAvailableLineWithReputationcatches that specific error, logsconsole.error(critical alert, escalate reputation-fix), and retries with an unfilteredgetAvailableNumber(). 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.
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'ORhasUnhealthyLines && !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_atgaps can be alerted on (leading indicator of a line being deprioritized by Linq’s picker before we see it inreputation.status).
Migrating existing Twilio users to Linq
TheenrollUserInLinq 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
- Precondition check — 4xx if the user doesn’t exist / has no phone / isn’t opted in / has ever texted STOP.
stop_recordedis a HARD stop: the STOP on Twilio carries over as consent revocation; migrating them would violate that. - Idempotency — returns
already_on_linq(200) with the currently-assigned line ifsms_provider = 'linq'already. No DB write, no pre-announce, no greeting re-send, no telemetry re-emit. - 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 with502 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. Seelib/messaging/migration-preannounce.tsfor the full rationale and MIGRATION_PREANNOUNCE_VARIANT v3 history for the device-neutral copy rewrite (2026-09-27).skip_preannounce: truebypasses this phase for ops scenarios where the user was already hand-texted out-of-band. - Post-wait consent re-check — right before the DB flip, re-reads BOTH
sms_unsubscribed_atANDsms_opt_infromall_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_atstamped, endpoint aborts with409 stop_arrived_during_preannounce). Alternatively they can toggle off SMS via the web settings UI (sms_opt_in = falsewithout an unsubscribe timestamp, endpoint aborts with409 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). - DB flip — delegates to
enrollUserInLinq({ mode: 'enroll', force: true })so the compound CAS, reputation-aware line selection, PostHogsms_providerperson-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 insideenrollUserInLinqso both ingress paths get it automatically. - 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. - Telemetry — emits
sms_provider_migrated_server(the flip audit event, now carryingpreannounce_sent: true|falsefor 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), andsms_migration_greeting_sent_server/sms_migration_greeting_failed_server(the greeting outcome). All distinct from the natural-signuplinq_enrollment_processedandsms_opt_in_greeting_sent_serverso the migration cohort is queryable without polluting the signup funnel.
Request
clerk_id(required) — must start withuser_.dry_run(optional, defaultfalse) — run every precondition check and return what WOULD happen, without writing anything, sending any pre-announce, or sending any greeting. Also emitssms_provider_migrated_serverwithdry_run: trueso a bulk-script preview leaves a PostHog audit trail. Filter dry-run rows out of production migration counts.skip_preannounce(optional, defaultfalse) — 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 carriespreannounce_sent: false+preannounce_skipped_reason: 'operator_skip'. Use sparingly.skip_greeting(optional, defaultfalse) — 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 carriespreannounce_sent: false+preannounce_skipped_reason: 'skipped_greeting'in this case (vs.'operator_skip'whenskip_preannounce: trueis set explicitly). To override this and force a pre-announce with no greeting, the operator would need to hand-text the user first and useskip_preannounce: true+skip_greeting: true— an unusual combination that also getspreannounce_skipped_reason: 'operator_skip'because explicit ops flags take precedence for the reason field.
Response shapes
200 migrated (the common path):skip_preannounce: true):
/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):
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):
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.
Recommended rollout cadence
Phased against the Linq pool’s 200-contact/line cap. WatchGET /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
pickAvailableLineWithReputationselector insideenrollUserInLinqhandles 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 runsPOST /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-outBecause 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:- Unknown-number iMessage claiming to be a service they use
- Says “save this contact, text me here from now on”
- 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)
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 bypassessendSilviaReply, so the standard footer machinery doesn’t run
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
.vcfURL 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_cardcall. 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 emittingPHOTO;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 byshare_contact_cardDID render the flag avatar correctly on the saved contact.
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-outCohort 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'ANDmigration_nudge_sent_at IS NULLsms_opt_in = trueANDsms_unsubscribed_at IS NULL(hard STOP compliance)COALESCE(account_deleted, false) = false(soft-delete exclusion)- Migration greeting sent ≥48h ago (read from
silvia_sms_logsuser_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)
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):
Watching the pilot / bulk run in PostHog
Key queries during a migration window:- Fire rate: count of
sms_provider_migrated_serverwheredry_run = falsegrouped by day. - Greeting delivery: ratio of
sms_migration_greeting_sent_servertosms_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_serverwhereshare_card_status = 'shared'— should trend at ~100%. A dip to'failed'signals Linq/share_contact_cardAPI instability; a dip to'skipped'withshare_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 againstsms_migration_greeting_failed_servervolume. - Reactive nudge volume (Mitigation 2): count of
sms_migration_twilio_nudge_sent_serverwheretrigger = '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 carriesreason ∈ {'no_prior_twilio_activity', 'lookup_error'}.no_prior_twilio_activitymeanswasFormerlyOnTwiliocould 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 — seeMIGRATION_TWILIO_NUDGE_SKIPPED_NATIVE_LINQdocstring intelemetry.tsfor 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. Ifreason = 'lookup_error'spikes,wasFormerlyOnTwilio’ssilvia_sms_logslookup 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 (proactiveshare_contact_card) drives more first-time saves up-front. - First-inbound-after-migration: JOIN
sms_provider_migrated_server(per user) to the next inboundsms_response_completed_server(same user,provider = 'linq') — proves the user found the new line and started using it. - Line load:
linq_line_utilizationview (see § Line capacity monitoring) — watch for any single line approachingworstLineStatus = 'critical'during the rollout.
References
- Linq Partner API reference
- Linq Zero-Day Retention guide
- Linq Security & Compliance — SOC 2 Type I + II + Trust Center
- Standard Webhooks specification
- ElevenLabs Twilio native integration
- Silvia’s current Twilio flow:
docs/sms/SMS-IMPLEMENTATION-SUMMARY.md - Twilio event streams (delivery status ingest):
docs/sms/TWILIO-EVENT-STREAMS-SETUP.md