← Choice Health 2026-07-05-welcome-module-design.md raw .md

Welcome Module (Intro State) — Design v1

Date: 2026-07-05 Status: Approved (operator direction), first slice queued Relationship: riff runtime feature. Authored-content side and pipeline context live in the precursor repo (domain-tree-intake-nouns-for-riff: docs/pipeline.md). Runtime pieces land in riff (pack schema + compiler + returning-caller hook); the pack-level welcome block is generated by the tree-intake importer, so verticals inherit it with zero per-vertical code.

1. Problem

Two defects at the most latency-sensitive moment of every call — pickup:

  1. Per-call TTS of the greeting is slow and wasteful. The caller's name is the only dynamic token, it never changes, and we re-synthesize it (plus the whole sentence around it) on every call.
  2. First-time and returning callers get the same front door. A returning, verified caller should not re-run the full intake; a first-time caller should be forwarded straight into it.

2. Decision (operator, 2026-07-05)

3. The module

3.1 Pack-level welcome block (attributes)

welcome:
  business_name: "Austin Plumbing"          # substituted into all lines
  first_call_line: "Thanks for calling {business}. Let's get your details
                    set up — it takes about a minute."
  returning_line_template: "Hi {name}, welcome back to {business}."
  returning_menu_hint: "Press 0 anytime to update your details."
  render_on: profile_save                   # when the personalized asset renders
  opportunistic_render: sms_verify_deadair  # bonus: render during code readback
  stt_roundtrip_qa: true                    # verify TTS didn't mangle the name
  fallback: generic                         # generic | live_tts when asset missing
  auth_choice_gate: false                   # optional: 0 verify now / 1 guest before menu

Every attribute has a default; the tree-intake importer emits the block from the business fields, so a vertical gets the module without authoring anything.

3.2 Compiled shape

call start
   │  CLID lookup (returning_caller hook)
   ├─ unknown caller ──► welcome_first (static generic audio)
   │                        └─► intake capability (the default route)
   │                             └─ profile saved ─► RENDER greeting asset
   └─ known+verified ──► welcome_back (play cached per-caller asset)
                            └─► main menu (profile intake on DTMF 0)

3.3 Asset lifecycle

Stage Behavior
Render On profile_save (post-intake), and opportunistically during the SMS verification dead air — the caller spends ~10s reading the code; that is free render time.
Key (voice_model, sha256(resolved_line_text)) in the existing static_audio manifest — the greeting is just another text asset; playback, barge-in, and caching need nothing new.
QA STT round-trip: synthesize → transcribe → fuzzy-compare the name. On mismatch (the "Nguyen problem"), do not ship the asset; fall back to generic. Greeting someone by the wrong name confidently is worse than not personalizing.
Invalidate Lazily on voice-model change or template change (key miss → re-render at next profile event). Nightly preflow-style job backfills all known profiles.
Delete With the profile. The asset is derived PII.

3.4 Sim-gate coverage (hard requirement applies)

4. Non-goals (v1)

5. Sequencing

  1. riff: pack schema accepts welcome: block; compiler emits the two states
    • CLID branch (behind the existing returning-caller hook).
  2. riff: render-on-profile-save job + static_audio registration + STT QA.
  3. Importer: emit default welcome block for all four verticals; regenerate; sim gate green (new personas + invariants).
  4. Precursor: none required for v1 (business_name flows from pack business fields). A future welcome override could join business_overrides.

6. v1.1 — Verified vs Guest sessions at the welcome gate (operator, 2026-07-05)

Returning-caller welcome grows a trust handshake:

  1. welcome_back plays the cached personalized greeting, then a second cached generic line: "I've just texted your follow-along link — when you're back at the menu, read me the six-digit code to unlock your profile — or say skip." The SMS (intake link + code) is sent automatically IFF the profile carries prior SMS consent from its original intake; without consent, no text, caller stays guest silently.
  2. Non-gating code acceptance — AT THE MENU (state-driven honesty): there is NO waiting state, and because the FSM is state-driven the code is only heard where the interception lives: the menu. The spoken copy says so ("when you're back at the menu"), never "anytime". While the session is unverified AND a code was issued this call, the menu accepts:
    • a spoken 6-digit code: match → "Access granted." → session_mode=verified → re-enter menu; mismatch → "That code didn't match — continuing in guest mode." → menu.
    • an explicit decline ("skip", "no thanks", "not now", "later", "guest", "guest mode", "no code"; not bare "no") → "Continuing in guest mode." → menu, and the unlock offer is not mentioned again this call (declined flag). The caller may also just ignore it.
  3. Guest mode (session_mode=guest, the default): read-only. Maps to the substrate's verify-before-write rule — profile mutations and saves are refused in voice with "proceeding in guest mode — say the code from your text anytime to unlock," and the web page stays locked exactly as the intake substrate already enforces. Verified mode unlocks both.
  4. First-time callers are unchanged (their verification lives inside the intake capability as today).

Sim coverage: returning-verified persona (reads the code mid-menu → asserts "Access granted" + a successful profile mutation), returning-guest persona (ignores the code → asserts mutation refusal line + intact flow), wrong-code persona (asserts guest continuation, no lockout loop).

Some flows, especially law-office flows, should not play the full menu immediately after texting the follow-along link. Set welcome.auth_choice_gate: true to make the returning-caller path:

welcome_back
  -> welcome_send_follow_along
  -> welcome_follow_along_sent
  -> welcome_auth_choice
       0 / "verify" -> welcome_verify_code -> welcome_unlock_granted -> menu
       1 / "skip"   -> welcome_unlock_guest -> menu

The caller-facing line is short: "For your security, I texted your follow-along link and a six-digit code. Press 0 to verify now, or press 1 to continue as a guest." The full menu is held until the caller either verifies or chooses guest mode. This is currently enabled for packs/legal_intake.pack.yaml and intentionally opt-in so cafe, trades, and space-channel flows keep the lower-friction v1.1 behavior until reviewed.