RIFF · session facts · use-case-first specification · 2026-07-07 · spec twin: docs/session-facts-proposal.md
Session Facts — End-Flow Behavior, Case by Case
What a caller experiences from dial to hangup, at every trust layer — written before
the implementation, so every case below is also an acceptance test. Test IDs (T1…)
map 1:1 to tests/test_session_facts_endflow.py.
v3:GHthPzo8…), and a caller
with consent on file was asked for consent three more times after it was saved (law,
v3:Pxu2-…). The rules here make both impossible by construction.
The five facts every call carries
| fact | values | direction | only writer |
|---|---|---|---|
| identity | none → profile match / spoken name | refine only | transport prefill, then the caller's own answer |
| auth_level | guest → known → verified | up only, never down mid-call | transport (known), verify tool (verified) |
| sms_consent | unknown → granted / denied | durable either way | profile-on-file ∨ this call's consent answer |
| selected_route | none → a menu choice | transient intent | set by router; exactly three legal clearers — its consumer (route played), a newer choice (fresh selection supersedes), or hangup finalization (discarded + logged). Anything else clearing it is a contract violation. |
| journey | where the call is | forward | the FSM itself |
The follow-along page, the voice script, and telemetry are three read-only views of this one ledger. None of them owns state, so none of them can disagree.
Case U1 — First call, unknown number (space channel) T1
- "Welcome to Space Channel Network Operations." — no name spoken: auth_level=guest, we don't know one.
- Menu plays. Caller presses 3.
- selected_route=webb_observatory is set — and consumed: Webb briefing plays.
- Caller hangs up. Nothing personal saved; telemetry shows the route set → consumed pair.
Pass: route reaches its capability in one hop. Never: an unlock hint or auth text — there is no profile to unlock.
Case U2 — Returning caller verifies mid-menu (the space live-bug, specified correct) T2
- "Hi David, welcome back." auth_level=known (ANI match). Auth text sent once.
- Menu plays. Caller reads the four-digit code.
- auth_level: known→verified. "Access granted." plays exactly once — it announces the change. The menu resumes with a short re-prompt, not the full 24-second replay.
- Caller presses 3. selected_route=webb_observatory.
- Webb plays. The verified flag is a wristband now, not a bouncer: with a matched route in the epoch, gate edges are not even candidates.
Pass: exactly one "Access granted"; route consumed by cap_webb_observatory__say.
Never: a second "Access granted"; the route cleared by any state that isn't a legal clearer.
Case U2b — the race: choice lands during the verification turn T2b
The live bug's exact timing: the caller presses 3 while the "Access granted" acknowledgement is still playing (or in the same decision epoch as the verify result). Contract: both facts survive — auth_level=verified AND selected_route=webb_observatory; the route is consumed next. The ack and the choice are not competitors, because the ack has no transitions to win. Never: the verify result re-entering the gate after the choice is matched.
Case U3 — Returning caller skips the code T3
- Welcome back + auth text, menu plays. Caller ignores the code and presses 3 immediately.
- Webb plays in guest mode. auth_level=known stays; the unlock hint does not repeat between menu visits.
Pass: choice honored without verification. Verification is an offer, never a toll gate for public content.
Case U4 — Law intake, consent already on file (the law live-bug, specified correct) T4
- "Welcome back to David Austin — Lawyer Intake." auth_level=known, and the profile says sms_consent=granted.
- Caller verifies with the code. auth_level=verified, profile prefilled.
- The consent question never renders — it gates on sms_consent==unknown, which is false.
- The name question never renders either (identity is prefilled) — but if the flow does ask, the spoken answer wins: a caller who says "Gavin" is Gavin, and the save must record what was said.
- Intake proceeds straight to the matter. At hangup the ledger is persisted once, by one writer.
Pass: zero consent asks; spoken identity beats profile prefill when both exist. Never: asking a question whose fact is already resolved; saving profile data over a direct spoken answer.
Case U4b — Consent granted mid-call is immediately durable T4b
The law bug's actual shape: a caller with sms_consent=unknown is asked once, says yes, and the fact flips to granted at that moment. From the next turn on, the consent question can never render again in this call — no matter what states the flow revisits. Never: a consent re-ask in any turn after the grant was recorded (live call had three).
Case U5 — Consent denied sticks T5
- First-call caller reaches the consent question, says no.
- sms_consent=denied — durable. No re-ask this call; the profile remembers, so the next call doesn't re-ask either. No text is ever sent (send tools check the fact, not a slot).
Pass: one ask, one answer, zero sends. Denial is as durable as consent.
Case U6 — End of call: the ledger is what survives T6
- At hangup — graceful or mid-sentence — the session facts are the single thing persisted: identity as spoken/confirmed, consent as resolved, journey position, any unconsumed route (discarded, logged).
- The follow-along page flips to ended and renders the same final ledger.
- Telemetry closes with a
facts_finalsnapshot so any later question — "what did we know when the call ended?" — has one answer.
When re-asking IS allowed
"Never re-ask a resolved fact" has exactly three exceptions, and each must be visible in telemetry with its reason:
- Caller correction — the caller volunteers a contradiction ("actually, it's Gavin"): the fact refines, one confirmation is allowed. slot_write.kind=correction
- Low confidence — the answer's transcription is flagged uncertain: one clarify re-ask. slot_write.kind=clarify
- Failed save — the write to the ledger errored: re-ask after telling the caller why. slot_write.kind=retry
An unmarked re-ask of a resolved fact is a defect by definition — that is what the invariant counts.
The two telemetry lanes that prove all of this
slot_write — every write to a fact or control slot:
{t, slot, old→new, writer, kind: tool | sets | clears | prefill}, including
compiler-expanded clears: lists (the event names the edge that ran).
transition_verdict — per decision: every candidate edge with its guard result and
the winner: "edge 0 matched; edges 3–11 shadowed."
With these two lanes, each Never-clause above is a one-line assertion over the event stream — in the simulator and on live calls alike.
Test matrix (doc is the spec; tests cite the case)
| id | case | asserts | kind |
|---|---|---|---|
| T1 | U1 guest route | route set → consumed, no auth artifacts | deterministic walk |
| T2 | U2 verify then choose | 1× "Access granted"; route reaches Webb; only legal clearers touch _route | deterministic walk (replays live bug) |
| T2b | U2b verify/choose race | both facts survive the same epoch; route consumed next | deterministic walk (live-bug timing) |
| T3 | U3 skip code | guest route honored; no hint repeat | deterministic walk |
| T4 | U4 consent on file | 0 consent asks when granted on file | deterministic walk (replays live bug) |
| T4b | U4b mid-call grant durable | 0 re-asks after the in-call grant | deterministic walk (live-bug shape) |
| T7 | U4 identity precedence | spoken answer persisted over profile prefill | deterministic walk |
| T5 | U5 denial sticks | 1 ask, denial durable, 0 sends | deterministic walk |
| T6 | U6 end of call | final ledger persisted once; unconsumed route logged | deterministic walk |
| L1 | lint | no transition clears _route unless its when consumes it | static, every pack |
| I1 | invariant | route-resolved-then-dropped = 0 across the corpus | eval sweep |
Change discipline: this page is the contract. Implementation PRs cite the case they satisfy; a behavior change without a case change here is a bug by definition.