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.

Why this exists. On 2026-07-07 two live calls broke at trust-layer seams: a verified caller's menu choice was destroyed by a re-firing passcode gate (space, 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

factvaluesdirectiononly writer
identitynone → profile match / spoken namerefine onlytransport prefill, then the caller's own answer
auth_levelguest → known → verifiedup only, never down mid-calltransport (known), verify tool (verified)
sms_consentunknown → granted / denieddurable either wayprofile-on-file ∨ this call's consent answer
selected_routenone → a menu choicetransient intentset 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.
journeywhere the call isforwardthe 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

  1. "Welcome to Space Channel Network Operations." — no name spoken: auth_level=guest, we don't know one.
  2. Menu plays. Caller presses 3.
  3. selected_route=webb_observatory is set — and consumed: Webb briefing plays.
  4. 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

  1. "Hi David, welcome back." auth_level=known (ANI match). Auth text sent once.
  2. Menu plays. Caller reads the four-digit code.
  3. 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.
  4. Caller presses 3. selected_route=webb_observatory.
  5. 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 surviveauth_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

  1. Welcome back + auth text, menu plays. Caller ignores the code and presses 3 immediately.
  2. 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

  1. "Welcome back to David Austin — Lawyer Intake." auth_level=known, and the profile says sms_consent=granted.
  2. Caller verifies with the code. auth_level=verified, profile prefilled.
  3. The consent question never renders — it gates on sms_consent==unknown, which is false.
  4. 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.
  5. 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

  1. First-call caller reaches the consent question, says no.
  2. 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

  1. 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).
  2. The follow-along page flips to ended and renders the same final ledger.
  3. Telemetry closes with a facts_final snapshot 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:

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)

idcaseassertskind
T1U1 guest routeroute set → consumed, no auth artifactsdeterministic walk
T2U2 verify then choose1× "Access granted"; route reaches Webb; only legal clearers touch _routedeterministic walk (replays live bug)
T2bU2b verify/choose raceboth facts survive the same epoch; route consumed nextdeterministic walk (live-bug timing)
T3U3 skip codeguest route honored; no hint repeatdeterministic walk
T4U4 consent on file0 consent asks when granted on filedeterministic walk (replays live bug)
T4bU4b mid-call grant durable0 re-asks after the in-call grantdeterministic walk (live-bug shape)
T7U4 identity precedencespoken answer persisted over profile prefilldeterministic walk
T5U5 denial sticks1 ask, denial durable, 0 sendsdeterministic walk
T6U6 end of callfinal ledger persisted once; unconsumed route loggeddeterministic walk
L1lintno transition clears _route unless its when consumes itstatic, every pack
I1invariantroute-resolved-then-dropped = 0 across the corpuseval 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.