← Choice Health call-plan-artifact-schema-v0.md raw .md

RIFF — Call-Plan Artifact & Trace Schema, v0

Status: draft for review — open decisions marked OD-n Date: 2026-07-01 Author: Fable 5 (design), reviewed by Claude Opus 4.8 + David Audience: implementing Claude (Opus 4.8) + David Depends on: the compiler reframe agreed 2026-07-01 (pre-flow = build, flow = artifact execution, post-flow = eval + canary; promotion = deploy) Pinned against two flows by design: space_channel_network_ops (zero Do holes, Choose-heavy) and austin_plumbing (Do-heavy, Collect-heavy — the product shape) Companion docs: docs/fable5-deterministic-flow-questions.md (the questions that produced this), docs/schema-ground-truth-flows.md (node-census fact sheet — Collect, not Do, carries the booking vertical; pin against three shapes)


1. Purpose and scope

This spec pins the two contracts everything else references:

  1. The compiled call-plan artifact — the immutable output of the pre-flow compiler (riffc). The runtime executes only this; it never reads flow YAML. The console renders it. Promotion machinery versions it.
  2. The trace event schema — the universal record of what happened. Emitted by the FSM engine (never by transports), consumed identically by the live console, all three sim tiers (L0/L1/L2), and the dreaming loop.

In scope: phone/booking-shaped flows (deterministic skeleton + typed holes). Space Channel, austin_plumbing, apartment_scheduler, dental_clinic, etc. Out of scope: the research web agent. It shares the trace schema only (§6) — its runtime is not artifact-shaped and must not be forced into this model.

Source flows stay YAML (human-authored). The compiled artifact is emitted as JSON, immutable, content-addressed. Examples below are shown in YAML for readability; the on-disk artifact is the JSON equivalent. (OD-1: confirm JSON for compiled form; recommendation is yes — no anchors/merge keys in an artifact, and it keeps runtime parsing trivial.)


2. Design invariants the schema encodes

These are the rules from the review, restated as properties the schema makes structural rather than disciplinary:


3. Artifact schema

3.1 Top level

artifact:
  schema_version: "0.1"
  artifact_id: "austin_plumbing@2026-07-01T18:22Z#a41f9c"   # flow_id @ built_at # short source hash
  flow_id: austin_plumbing
  tenant_id: cust_atx_plumbing_001        # tenancy unit: one artifact per tenant per flow
  source:
    flow_yaml_sha256: "9c1e…"
    includes: ["flows/subflows/austin_plumbing/*.yaml"]
    compiler: "riffc 0.1.0"
  built_at: "2026-07-01T18:22:04Z"
  status: candidate            # candidate | canary | promoted | retired
  eval:                        # promotion machinery metadata
    l2_replay_run: null        # id of the replay-eval run that gated this artifact
    canary:
      fraction: 0.0
      baseline: null           # artifact_id of the incumbent
      gates: [reprompt_rate, misroute_rate, time_to_route_p95, hangup_before_menu_rate]
  entry: greeting
  defaults:
    voice: umbriel_rp
    style: warm_professional
    watchdog_grace_ms: 1500    # audio watchdog = asset.duration_ms + this
    silence_alarm_ms: 2000     # trace stall_detected threshold
    choose_timeout_ms: 7000
  degraded:                    # I4 — mandatory, build fails if absent or unrendered
    unavailable: asset:degraded_unavailable    # "that option isn't available right now"
    error:       asset:degraded_error          # "something went wrong on our end"
    handoff:     asset:degraded_handoff        # "let me take your number and have someone call you"
  models:   { … }              # §3.3
  assets:   { … }              # §3.2
  nodes:    { … }              # §3.4

Notes:

3.2 Asset record

Every WAV the caller can ever hear is an entry here. Nodes reference assets by semantic id; the manifest pins the exact content hash (OD-2: nodes use semantic ids, manifest resolves to hashes at build — this replaces lookup_latest_by_state, whose "latest" resolution now happens in the compiler, once, not per call).

assets:
  greeting:
    sha256: "b31a…"
    uri: "static/austin_plumbing/greeting.b31a.wav"
    duration_ms: 4800
    voice: umbriel_rp
    style: warm_professional
    text_sha256: "77e0…"
    text_ref: "scripts/austin_plumbing/greeting.txt"   # resolved text, never a template (10ea83db class = compile error)
    rendered_at: "2026-07-01T18:20:11Z"
    cadence: static                     # static | {refresh: 15m, max_honest_age: 45m}
    spoken_timestamp: false             # true for volatile content: the audio discloses its own as-of time

  briefing.launches:                    # unfolded from parameterized source state (I6)
    sha256: "04dd…"
    uri: "static/space_channel/briefing.launches.04dd.wav"
    duration_ms: 24500
    cadence: {refresh: 15m, max_honest_age: 45m}
    spoken_timestamp: true              # "as of nine this morning, the Falcon launch is still go"

Compile rule: an asset whose rendered_at age exceeds max_honest_age at build time fails the build; one that exceeds it at runtime triggers degraded.unavailable locally plus a degraded trace event, and flips its freshness dot in the console (I4).

3.3 Models block

Pinned at build. In a deterministic flow this is small by construction — Collect states and the fallback classifier only.

models:
  collect_primary:
    provider: gemini
    model: gemini-2.5-flash-live       # pinned; changing it = new artifact
    role: collect                       # may own the turn, bounded by node max_turn_ms
  fallback_classifier:
    provider: anthropic
    model: claude-haiku-4-5
    role: classifier                    # text in → route|null out; budget enforced by node
    max_tokens: 8
  slot_tts:
    provider: piper
    voice: umbriel_rp                   # OD-3: voice continuity — call-time slot TTS must be
    role: slot_tts                      # perceptually continuous with prerendered Umbriel

3.4 Node types

Five types, matching the console vocabulary: say ▭, choose ◇, collect ▱, do ⬒, end ⬤.

say

nodes:
  greeting:
    type: say
    asset: asset:greeting
    barge_in: true
    dtmf_shortcut: main_menu          # digits during play resolve against this choose node (welcome direct-dial)
    on_done: {goto: main_menu}
    on_missing_asset: degraded.unavailable    # runtime-local fallback (I4); reaching this at build = fail

Engine behavior (not per-node config): on state_enter, push play(asset) immediately — the 0485ac08 class of bug ("DTMF transition must play audio immediately") is engine behavior, not YAML discipline. Watchdog fires at duration_ms + watchdog_grace_ms and advances with audio_done{source: watchdog} — a missed browser ack can no longer strand the caller (Q11, closed).

choose

  main_menu:
    type: choose
    grammar:
      recognizer: constrained          # constrained decode on the parallel server-side recognizer
      tokens:
        - {token: launches,  dtmf: "1", aliases: [launches, rocket launches, launch schedule], goto: briefing.launches}
        - {token: news,      dtmf: "2", aliases: [space news, news],                            goto: briefing.news}
        # … 3–7 …
        - {token: bug,       dtmf: "8", aliases: [report a bug, bug, problem],                  goto: collect_bug}
        - {token: goodbye,   dtmf: "9", aliases: [goodbye, bye, hang up],                       goto: sign_off}
        - {token: options,               aliases: [options, menu, help, what are my choices],   goto: menu_announce}
      reject:                           # the escape hatch — constrained decode MUST NOT force babble onto a token
        method: likelihood_ratio        # OD-4: constrained-vs-open score ratio, or explicit garbage model
        garbage_token: true
    thresholds:
      route: 0.85                       # ≥ route silently
      confirm: 0.55                     # [confirm, route) → one-word confirm; below → no_match ladder
    confirm:
      asset_pattern: asset:confirm.{token}    # prerendered per-token confirms — validated at build
      yes: {dtmf: "1", aliases: [yes, yeah, right, correct]}
      no:  {dtmf: "2", aliases: [no, nope]}
    fallback:                           # third rung only (I2)
      model: model:fallback_classifier
      budget_ms: 800
      on_null_or_timeout: no_match
    no_match:
      reprompt: asset:menu_short
      after_attempts: 2
      then: {goto: menu_announce}       # full menu; a booking flow would go to degraded.handoff instead
    timeout_ms: 7000
    on_timeout: no_match

The open-vocab Gemini transcript is telemetry only — it appears in the trace (heard_open) for debugging and dreaming, never in the routing decision.

collect

  collect_job:
    type: collect
    model: model:collect_primary
    prompt_ref: "prompts/austin_plumbing/collect_job@sha256:31ab…"
    slots: [job_type, urgency, callback_number]
    max_turn_ms: 20000                  # I1: the one place the model may own a turn — bounded
    max_turns: 4
    barge_in: true
    on_complete: {goto: check_calendar}
    on_timeout:  {goto: degraded.handoff}
    speculative: [check_calendar]       # OD-5: engine MAY start the named Do's fetch during collect,
                                        # discarding the result if routing diverges (latency win for booking)

do — the typed hole (I3)

  check_calendar:
    type: do
    tool: "tool:google_calendar.find_slots@v2"
    mic: closed                         # not configurable — the compiler rejects any other value
    hold: asset:hold_checking           # "one moment while I check the calendar" — plays immediately
    deadline_ms: 6000
    args_from: {slots: [job_type, urgency]}
    result:
      kind: enum_or_freeform
      enum_frames:                      # enumerable outcomes → prerendered variants
        slot_offered.morning:  asset:offer.morning
        slot_offered.afternoon: asset:offer.afternoon
        slot_offered.evening:  asset:offer.evening
        no_slots:              asset:no_slots_week
      freeform_frame:                   # only genuinely free-form values touch call-time TTS
        template: "The earliest opening is {slot_human}."
        tts: model:slot_tts
    on_success:  {goto: confirm_slot}
    on_failure:  {goto: degraded.handoff}     # always lands on a prerendered say
    on_deadline: {goto: degraded.handoff}

The model never sees the tool (I3): args_from maps FSM slots to tool args; the result re-enters as data and the FSM picks the frame. If the hold beat finishes before the tool returns, the engine loops a compiled filler beat or brief silence up to deadline_ms — never re-opens the mic.

end

  sign_off:
    type: end
    asset: asset:goodbye
    disposition: completed              # completed | abandoned | handoff

4. Example A — space_channel (excerpt)

Demonstrates: Choose-heavy, zero Do, parameterized unfolding (I6), volatile-content cadence.

artifact:
  artifact_id: "space_channel@2026-07-01T17:05Z#c7e2d1"
  flow_id: space_channel_network_ops
  tenant_id: internal_demo
  entry: greeting
  degraded: {unavailable: asset:degraded_unavailable, error: asset:degraded_error, handoff: asset:degraded_handoff}
  assets:
    greeting:            {duration_ms: 5000,  cadence: static}
    briefing.launches:   {duration_ms: 24500, cadence: {refresh: 15m, max_honest_age: 45m}, spoken_timestamp: true}
    briefing.news:       {duration_ms: 22100, cadence: {refresh: 6h,  max_honest_age: 24h}, spoken_timestamp: true}
    briefing.ufo:        {duration_ms: 26300, cadence: {refresh: 24h, max_honest_age: 72h}}
    # … wx, missions, network, radiation; confirm.{token} × 10; menu_short; menu_full; goodbye; degraded_* …
  nodes:
    greeting:      {type: say, asset: asset:greeting, dtmf_shortcut: main_menu, on_done: {goto: main_menu}}
    main_menu:     {type: choose, …}         # as §3.4; tokens 1–9 + options
    briefing.launches: {type: say, asset: asset:briefing.launches, barge_in: true, on_done: {goto: menu_reannounce}}
    # briefing.news … briefing.radiation — six compiled entries from ONE source state (I6)
    collect_bug:   {type: collect, model: model:collect_primary, slots: [description], on_complete: {goto: menu_reannounce}}
    menu_reannounce: {type: say, asset: asset:menu_short, on_done: {goto: main_menu}}
    sign_off:      {type: end, asset: asset:goodbye, disposition: completed}

What changed vs. today: report_space_content_audio no longer exists at runtime — the compiler unfolded it, so state-id-keyed dedup, slot-fill leakage, and "which topic am I?" (T8) are gone by construction. Launch data is just a 15-minute-cadence asset; the "volatile, never fetch on loop" special case is deleted.

5. Example B — austin_plumbing (excerpt)

Demonstrates: Collect-heavy, mid-call Do, speculative prefetch, handoff-shaped no-match.

artifact:
  artifact_id: "austin_plumbing@2026-07-01T18:22Z#a41f9c"
  tenant_id: cust_atx_plumbing_001
  entry: greeting
  nodes:
    greeting:     {type: say, asset: asset:greeting, on_done: {goto: intent}}
    intent:
      type: choose
      grammar:
        tokens:
          - {token: book,      dtmf: "1", aliases: [book, appointment, schedule, need a plumber], goto: collect_job}
          - {token: emergency, dtmf: "2", aliases: [emergency, burst pipe, flooding, leak],        goto: emergency_say}
          - {token: hours,     dtmf: "3", aliases: [hours, open, pricing, cost],                   goto: say_hours_pricing}
          - {token: human,     dtmf: "0", aliases: [person, human, operator, talk to someone],     goto: degraded.handoff}
      no_match: {reprompt: asset:intent_reprompt, after_attempts: 2, then: {goto: degraded.handoff}}
    collect_job:    {type: collect, …, speculative: [check_calendar], on_complete: {goto: check_calendar}}
    check_calendar: {type: do, …}            # as §3.4
    confirm_slot:
      type: choose
      grammar:
        tokens:
          - {token: yes, dtmf: "1", aliases: [yes, yeah, that works, book it], goto: book_it}
          - {token: no,  dtmf: "2", aliases: [no, different time, later],      goto: check_calendar_next}
    book_it:      {type: do, tool: "tool:google_calendar.schedule_event@v2", hold: asset:hold_booking,
                   result: {kind: enum_or_freeform, enum_frames: {booked: asset:booked_confirm}},
                   on_success: {goto: sign_off}, on_failure: {goto: degraded.handoff}}
    sign_off:     {type: end, asset: asset:goodbye_booked, disposition: completed}

Note the asymmetry with Space Channel that the schema must carry, per-tenant: a content line's no-match ends at the full menu; a business line's no-match ends at handoff (take a number, text the owner) — losing a plumber's call is losing revenue. Same schema, different no_match.then.


6. Trace event schema

The universal contract. Emitted by the engine on the existing riff/events.py bus, keyed by call_id; transports and consoles subscribe — nothing transport-specific in the events themselves (I5). Live calls, L1 fault-injection sims, and L2 replays all emit this identically; the research web agent adopts this section only.

JSON Lines. Envelope on every event:

{"v":"0.1","call_id":"c_9f21","artifact":"austin_plumbing@2026-07-01T18:22Z#a41f9c",
 "seq":14,"t_ms":8140,"type":"route_decision", "...": "…"}

seq is a per-call monotonic integer (total order — event-ordering bugs were the whole T6 class, so ordering is explicit, never inferred from timestamps). t_ms is milliseconds since call_start.

type payload notes
call_start transport: telnyx|web|sim_l1|replay_l2, from_e164?, entry transport is a label here, nowhere else
state_enter node, node_type, params?
audio_pushed node, asset, sha256, duration_ms engine pushed the intent
audio_done node, asset, source: client|watchdog watchdog = the browser/leg never acked; this is the Q11 stall, now visible and non-fatal
barge_in node, at_ms_into_audio, via: dtmf|voice
route_decision node, via: dtmf|grammar|confirm|fallback, heard_constrained?, heard_open?, confidence?, token|null, decision: route|confirm|reprompt|fallback_invoked|reject, goto?, latency_ms heard_open is Gemini's transcript, telemetry only; heard_constrained is what routed
collect_turn node, turn, transcript_ref, slots_delta, model, latency_ms transcripts by ref, not inline (PII)
tool_start node, tool, args_ref, deadline_ms, speculative: bool
tool_result node, status: ok|error|deadline, latency_ms, result_kind, frame?, discarded: bool discarded = speculative fetch not used
degraded node, reason: missing_asset|stale_asset|tool_failure|tool_deadline, served: asset every I4 local fallback is loud
stall_detected node, silence_ms, hypothesis fired by the engine's silence alarm; the console's red row
call_end disposition, duration_ms, states_visited, reprompts, fallback_invocations the roll-up canary metrics read from

Sample trace — plumbing call with a Do and a watchdog save

{"seq":1, "t_ms":0,     "type":"call_start",    "transport":"telnyx","entry":"greeting"}
{"seq":2, "t_ms":10,    "type":"state_enter",   "node":"greeting","node_type":"say"}
{"seq":3, "t_ms":22,    "type":"audio_pushed",  "node":"greeting","asset":"greeting","duration_ms":4800}
{"seq":4, "t_ms":3100,  "type":"barge_in",      "node":"greeting","at_ms_into_audio":3078,"via":"voice"}
{"seq":5, "t_ms":3110,  "type":"state_enter",   "node":"intent","node_type":"choose"}
{"seq":6, "t_ms":4450,  "type":"route_decision","node":"intent","via":"grammar","heard_constrained":"book","heard_open":"yeah I need to book somebody","confidence":0.91,"token":"book","decision":"route","goto":"collect_job","latency_ms":180}
{"seq":7, "t_ms":4460,  "type":"state_enter",   "node":"collect_job","node_type":"collect"}
{"seq":8, "t_ms":4470,  "type":"tool_start",    "node":"check_calendar","tool":"google_calendar.find_slots@v2","speculative":true,"deadline_ms":6000}
{"seq":9, "t_ms":9900,  "type":"collect_turn",  "node":"collect_job","turn":1,"slots_delta":{"job_type":"water_heater","urgency":"this_week"},"model":"gemini-2.5-flash-live","latency_ms":1400}
{"seq":10,"t_ms":10200, "type":"tool_result",   "node":"check_calendar","status":"ok","latency_ms":730,"result_kind":"enum","frame":"slot_offered.morning","discarded":false}
{"seq":11,"t_ms":10210, "type":"state_enter",   "node":"check_calendar","node_type":"do"}
{"seq":12,"t_ms":10230, "type":"audio_pushed",  "node":"check_calendar","asset":"offer.morning","duration_ms":6200}
{"seq":13,"t_ms":17950, "type":"audio_done",    "node":"check_calendar","asset":"offer.morning","source":"watchdog"}
{"seq":14,"t_ms":17960, "type":"state_enter",   "node":"confirm_slot","node_type":"choose"}

Row 13 is the ee7872db/loop-back-stall class of bug, converted from "caller stranded, read the logs" to "one amber row, call proceeds."


7. Compile-time validation (the L0 suite)

riffc fails the build (blocks promotion — never kills a running artifact, I4) if any of:

  1. Any goto targets a nonexistent node; any node unreachable from entry; no reachable end.
  2. Any say/end/hold/confirm/reprompt/frame asset missing from the manifest, unrendered on disk, hash-mismatched, or older than its max_honest_age.
  3. Any asset text containing an unresolved {{…}} template (the 10ea83db class).
  4. degraded.* assets absent or unrendered (the fallback that can 404 is the same bug one level down).
  5. Any choose token lacking a dtmf or alias set; duplicate DTMF digits; any confirm.asset_pattern expansion missing; thresholds not 0 < confirm < route ≤ 1; no reject block.
  6. Any do with mic != closed, no deadline_ms, no hold, or an on_failure/on_deadline that doesn't land on a say/end.
  7. Any collect without a pinned model, max_turn_ms, or max_turns.
  8. Any node without a derivable bounded clock (I1) — e.g., a say referencing an asset with no duration_ms.
  9. Loop-back edges that re-enter a choose without an announce say on the path (the re-announced-menu rule).

This list is the pre-flow validation tier; L1 (fault-injection event-loop sim) and L2 (trace replay) are separate specs but consume §6 unchanged.


8. Open decisions

id decision recommendation / notes
OD-1 Compiled artifact format JSON, immutable, content-addressed; source stays YAML. Confirm no runtime YAML parsing.
OD-2 Asset addressing Semantic id in nodes, exact sha256 pinned in manifest at build. Retires lookup_latest_by_state from the call path (keep it as a compiler helper).
OD-3 Slot-TTS voice continuity Call-time Piper must be perceptually continuous with prerendered Umbriel. If it isn't, restructure freeform frames so the TTS fragment is short and sandwiched between prerendered frames. Needs a listening test.
OD-4 Grammar mechanism + reject scoring mlx-whisper: true constrained decode (logit masking) vs. initial_prompt/hotword biasing. If only biasing is available, the likelihood-ratio reject becomes mandatory, not optional. Empirical: tune route/confirm thresholds on recorded triage audio (the "network→work" call is test case #1).
OD-5 Speculative Do Ship in schema now, engine may ignore in v0. Rule: speculative results are used only if the FSM arrives at that Do with unchanged args; otherwise discarded: true.
OD-6 Trace transport & auth One WS topic per call_id off the engine bus; short-lived signed URLs for external observers (the texted-link observer, later supervisor barge-in). Design the addressing now, build nothing yet.
OD-7 Node library / multi-tenant reuse Booking flows will share 80% shape. Likely a source-level template ("booking-flow blueprint") the compiler instantiates per tenant — keep out of v0 artifact schema, revisit after two real tenant flows exist.
OD-8 PII in traces transcript_ref/args_ref point into a separate store with its own retention; trace stream itself stays PII-light so observer links are safe to share. Matters for the sellable product (and your HIPAA instincts apply even though RIFF isn't covered).

9. Handoff notes for the implementing Claude

Suggested order, each step shippable alone:

  1. Emit the trace from the engine bus (riff/events.py → §6 events, per-call topic). Both transports and the map subscribe. This is the Q10/Q11 "one contract" fix and unblocks the console regardless of compiler progress.
  2. Write riffc v0 as validation-only: read the existing flow YAML + static-audio dir, emit the manifest JSON + the §7 L0 report. Don't change the runtime yet — just make the compile errors visible. (It will immediately flag real issues; that's the point.)
  3. Constrain choice_stt.py (OD-4) and emit route_decision with both heard_constrained and heard_open. Confirm the tee on the Telnyx leg.
  4. Switch the runtime to execute the artifact (assets by pinned hash, watchdog advance, engine-pushed audio intents). Retire lookup_latest_by_state from the hot path.
  5. Do-node runtime against austin_plumbing (hold beat, deadline, enum frames), then speculative prefetch (OD-5) as a follow-up.

The console mockup (next artifact after this) renders: the §5 plumbing manifest as keypad + route-table detail, and the §6 sample trace in the trace panel — hand-written JSON first, no live wiring.


Implementation status (running log)