← Choice Health business-pack-schema-v0.md raw .md

RIFF Business Pack — Schema v0 (riff_business_pack_v0)

Status: codex-reviewed (session 019f2a3a, 25 findings folded — rev 2), implementing Date: 2026-07-03 Companions: docs/codex/2026-07-03-business-pack-import-architecture-fable5-brief.md (architecture brief), docs/codex/2026-07-03-business-pack-v0-design-feedback-fable5.md (design review this draft implements), docs/codex/2026-07-04-progressive-web-intake-design.md (progressive web intake surface) Acceptance target: flows/legal_intake.yaml (hand-built lawyer flow), plus locksmith and pet-shop packs proving generality.

0. What this is

A business pack is a small, strict YAML file that describes a business — not a flow. The pack compiler turns it into a normal RIFF flow YAML built from generic atomic units (greeting, menu, collect stages, confirm, act, terminals, guard-gated topologies). The generated flow then passes through every existing gate unchanged: riff/loader.py hard-rejects, the riff/flow_eval/linter.py rule set, and riff/riffc.py L0 checks.

source material → importer (later) → business pack → pack validator
                                          → pack compiler → flow YAML (+ provenance header)
                                          → loader + linter + riffc   (unchanged)
                                          → runtime                   (unchanged)

Shift-left thesis: a pet shop, a locksmith, and a law office differ only in their pack. Everything below the pack is shared, deterministic machinery. No per-business Python. No per-business prompt engineering. Bad packs fail at compile time, before any caller hears them.

1. The one core-runtime change: slot_equals parameterized guard

Today every menu route needs a hand-registered Python guard (hub_route_is_book — riff/demo_hub_tools.py:20; space_route_launches — the Space Channel hub). This is the single reason flows cannot be generated from data.

Change: a contained edit across exactly two files — riff/state_manager.py:: evaluate_guard (line 631) and the loader's guard validation (loader.py:1041-1059) — plus tests. (Codex: this is not "one change"; it is two resolution sites and a test surface. Correct; both are named here and nothing else moves.)

transitions:
  - to: cap_book_visit__start
    when: "slot_equals:route:book_visit"

2. Pack schema reference

schema_version: riff_business_pack_v0        # required, exactly this string

business:
  id: legal_intake            # required; [a-z0-9_]+; becomes flow_id
  name: RIFF Law Intake       # required; spoken + business_profile.business_name
  industry: legal             # required; drives the guardrail matrix (§5)
  timezone: America/Chicago   # required; compiled to business_profile.business_tz
                              # (the key the schedule_event loader check reads)
  phone: "+15122777529"       # optional; provenance only — routing stays in
                              # RIFF_DIALED_ROUTING_TABLE_JSON, the compiler never wires it
  hours: "Mon-Fri 9-5"        # optional → business_profile.store_hours

system_prompt: |              # required; the flow-level system prompt
  You are a legal intake assistant. ...

capabilities:                 # required, >=1; order = menu order
  - id: start_intake          # required; [a-z0-9_]+; unique
    kind: intake_form         # required; one of §3's kinds
    label: Start a new intake # required; companion/menu label
    aliases: [new case, legal problem]   # required, >=1; voice aliases (lowercase)
    dtmf: "2"                 # optional; single digit, unique across the menu.
                              # A leading "0" option is valid, then "1", "2", ...
    commit: false             # optional (default false); reserved — v0 accepts only false
    sms_verification: true     # optional intake_form prelude: consent to one SMS,
                              # send link+code, verify spoken code before stages
    # kind-specific fields — see §3

completion_slots: [menu_choice_served]  # optional; copied to flow completion_slots.
                              # Generated menu/informational flows can declare
                              # `menu_choice_served`; the compiler writes it when
                              # any menu choice is served while keeping `_route`
                              # transient for loop-safe routing.
                              # Use declared intake slots for problem-solving flows.

menu:                         # required
  greeting_line: >            # required; spoken first (disclaimers get appended — §5)
    Thank you for calling RIFF Law. ...
  prompt: >                   # required; the locked_choice prompt
    Say 1 to ... or 3 to leave a message.
  retry_prompt: ...           # optional
  exit:                       # optional; adds a goodbye choice + close terminal
    aliases: [goodbye, that's all, hang up]
    line: "Thanks for calling. Goodbye."

welcome:                      # optional returning-caller front door
  business_name: RIFF Law Intake
  first_call_line: "Thanks for calling {business}. Let's get your details set up."
  returning_line_template: "Hi {name}, welcome back to {business}."
  returning_menu_hint: "Press 0 from the menu to update your details."
  render_on: profile_save
  opportunistic_render: sms_verify_deadair
  stt_roundtrip_qa: true
  fallback: generic
  auth_choice_gate: false     # true: text link/code, then 0 verify or 1 guest before menu

slots:                        # required; EXACTLY the flow `slot_types` contract —
  caller_name: {type: string, semantic_type: name}      # copied through verbatim and
  callback_number: {type: string, semantic_type: phone} # validated by validate_semantic_types
  matter_area: {type: string}                           # (riff/slot_types.py:334) at pack-check
  short_description: {type: string}                     # time. No pack-local slot dialect,
  adverse_parties: {type: string}                       # no `pii:` flag (PII stays the
                                                        # two-whitelist model).

guardrails:                   # optional list; structural kinds (§5)
  - kind: disclaimer
    id: no_advice
    text: "... I can't give legal advice ..."
    placement: greeting       # v0: greeting only (appended to greeting_line)
  - kind: consent_gate
    id: intake_consent
    applies_to: [start_intake]
    prompt: "Before we start: ... Do you agree to continue?"
    decline_to: leave_message # capability id; the denial path MUST have somewhere safe to go
  - kind: screening_gate
    id: conflict_check
    applies_to: [start_intake]
    after_stage: identity     # stage id within the intake_form
    tool: record_issue
    blocked_line: "I'm sorry, we're unable to help with this matter by phone. ..."

companion:                    # optional
  privacy: phone_verified     # default: phone_verified when any slot has a PII
                              # semantic type (name/phone/email/address), else public
  public_slots: []            # default []; deny-by-default; checked by the existing
                              # companion_public_slot_violations (riff/surface.py:192)

defaults:                     # optional
  max_turns: 60               # default 60
  voice: {default: Umbriel, style: british}   # optional; flow `voice:` block
  llm:                        # default: fast → gemini-2.5-flash
    default_class: fast
    classes: {fast: gemini-2.5-flash}

Unknown top-level keys, unknown capability kinds, and unknown guardrail kinds are errors (strict-by-default; forward compatibility comes from schema_version bumps, not silent tolerance).

3. Capability kinds — the atomic units and their compiled topologies

State ids are namespaced cap_<id>__<part> so packs can never collide with imported modules. All emitted transitions use registered guards or slot_equals:.

3.1 intake_form

kind: intake_form
stages:                        # required, >=1, ordered
  - id: identity               # required; [a-z0-9_]+
    slots: [caller_name, callback_number]   # required; declared in `slots`
    prompt: "What's your name and the best callback number?"
  - id: adverse                # the conflict-check inputs — their own stage so the
    slots: [adverse_parties]   # screening gate can sit AFTER them and BEFORE matter
    prompt: "Who is on the other side of this matter? Say their names, or 'none'."
  - id: matter
    slots: [matter_area, short_description]
    prompt: "What's the issue about, and can you briefly describe what happened?"
confirm:                       # required
  line: "Let me read that back: {{slots.caller_name}}, about {{slots.matter_area}}."
  must_include: [caller_name, matter_area]
submit:                        # required
  tool: record_issue           # v0 allowlist: record_issue
success_line: "Thank you — the office will follow up using the number you gave. Goodbye."

(The legal pack's screening gate is after_stage: adverse — the gate needs the adverse parties collected first. Codex caught the earlier example gating after identity, which would have skipped adverse-party collection entirely.)

Compiles to: per-stage collect states (speech_act: collect, allowed_tools: [update_slots], required_slots, entry line, all_state_slots_filled forward edge) → optional screening-gate act state after its named stage (§5) → confirm state (confirm_order/deny_order; denied → last stage) → submit act state (required_tool) → shared complete terminal (final_status: complete, the success_line). This is exactly flows/legal_intake.yaml's collect_identity → collect_adverse → conflict_screen → collect_matter → fact_confirm → packet_submit → complete topology.

If sms_verification: true, the compiler inserts a reusable prelude before the first intake stage (and after any industry consent gate): prerendered SMS opt-in prompt → deterministic confirm_yesno choice → act state with required_tool: intake_send_verification_link → code collection using tutorial_verify_caller_code. The SMS contains a follow-along link plus a one-time code; the assistant never says the code aloud. On success, caller_phone is verified and the first intake stage runs with that phone already populated. On SMS send failure or verification failure, the generated flow returns to the menu or the standard handoff escape.

3.2 message

kind: message
slots: [short_description]     # required; declared in `slots`
prompt: "Go ahead with your message and I'll pass it to the office."

Compiles to: one collect state (allowed_tools: [update_slots]) → an act state with required_tool: record_issuecomplete terminal. (Codex: the hand-built leave_message lets the caller reach the terminal without record_issue ever firing; the compiled unit closes that by making the record step a required_tool act state.)

3.3 handoff

kind: handoff
line: "This sounds urgent — I'm connecting you to someone now. Stay on the line."

Compiles to: one close terminal, final_status: escalated. Locksmith lockouts, electrician burning-smell, after-hours emergencies. Also the automatic target for menu-retry exhaustion (§3.5).

3.4 content

kind: content
text: "We're open Monday to Friday, nine to five, at 12 Oak Street."
prerender: true    # v0.1: emit a static play_audio state (flow voice, no LLM)

Compiles to: one inform state speaking the text then any_utterance back to the menu — or, with prerender: true (SHIPPED, v0.1), a static state with play_audio whose audio_done/audio_failed edges return to the menu. Every return-to-menu edge clears _route — a stale value would instantly re-fire the same slot_equals route (content ↔ menu loop; regression-pinned in tests/business_pack/test_compile_extensions.py).

kind: companion_link
prompt: "Want me to text you a link so you can follow the call live? Say yes, or no."

Compiles to the proven companion topology (subflows/space_channel_network_ops/companion_link.yaml): deterministic yes/no (confirm_yesno choice module, _confirm_choice slot) → act state with required_tool: send_companion_link → prerendered sent/failed outcome states → menu (clearing _route + _confirm_choice). Pack-level companion.auto_send: true compiles to business_profile.auto_send_companion. menu.prerender: true additionally makes the greeting a static play_audio state (flow voice — no Gemini default-voice leak at call open).

3.5 calendar_booking — parsed, refused

Capability kinds have three states, not two: unknown (schema error), known-and-compilable (§3.1-3.4), and known-but-uncompilablecalendar_booking is the third. The parser accepts it and a reserved scheduler: section shaped exactly like BusinessSchedulePolicy/ServiceOverlay from docs/codex/2026-07-02-scheduler-combined-handoff-fable5-claude.md §11; the compiler then emits a blocking finding ("calendar_booking is reserved pending the scheduler policy work, task #20") and produces no flow output. No silent fallback: refusing loudly beats compiling a booking path that mints times.

4. Validation ownership — each fact checked once, at the lowest layer that sees it

Pack validator (python -m riff.business_pack check) — new, errors block compile:

rule detail
schema shape required sections, unknown keys/kinds are errors, id regexes
slot contract slots passes validate_semantic_types verbatim
referential integrity stage/message slots declared; applies_to/decline_to/after_stage resolve; confirm.must_include ⊆ slots collected by stages that precede the confirm (declaration alone isn't enough — loader template validation accepts any declared slot, loader.py:1075)
menu token collisions uniqueness across the union of ALL match surfaces: every capability's aliases + dtmf digits + exit aliases (case-folded); when dtmf digits are present they must be sequential in menu order, with optional leading 0 followed by 1, 2, ... (riffc L0-5 warns on numeric-alias/visible-order mismatch)
capability coverage every capability appears in the menu (it is the menu in v0)
industry ⇒ guardrail matrix legal ⇒ a consent_gate applying to every intake_form + a disclaimer; locksmith/electrical/plumbing/hvac ⇒ at least one handoff capability
consent decline safety decline_to must reference a message or handoff capability (denial always lands somewhere safe)
commit reserved commit: true is an error in v0 (reserved for the tier-COMMIT marriage)
booking reserved calendar_booking compiles to a blocking finding

Existing layers (run on generated output; NOT re-implemented in the pack layer): typed slots (riffc L0-10 error + ratchet), reachability/terminals (L0-1, no-success-terminal), choice grammar + DTMF collisions at flow level (L0-5), voice paths (L0-11), companion PII (companion-public-slot-unsafe error + runtime drop), reprompts (collect-state-no-reprompt), template slot references (loader). Note the loader safety gate covers only askact without confirmed (loader.py:1246) — it does NOT prove collect→act paths safe; confirmation before submit is enforced structurally by the compiler (intake_form requires a confirm block), with the heuristic no-confirmation lint as backstop only.

Compiler self-check (three gates, same compile call): after emitting, the compiler (1) loads its own output with load_flow, (2) runs lint_flow — any error-severity finding or any code in the flow_generator.py:157 blocking set fails the compile, and (3) runs riffc.compile_flow — any error Finding fails the compile. (Codex: riffc was in acceptance but missing from the compile gate; all three now run at compile time.)

5. Guardrails are topology, not text

Named contracts / accepted debts (codex findings kept as-is for hand-built parity, documented rather than hidden): (a) the screening gate's forward edge uses all_state_slots_filled on an act state with no required_slots — this fires after the required_tool runs, mirroring conflict_screen in the hand-built flow; it is a tool-completion convention, not a tool-success check (a real result contract needs the tool to write a posture slot — v0.1). (b) consent_gate and confirm both ride the global _order_confirmed/_order_denied bits from confirm_order/deny_order; they are cleared on transition, but two confirms in one flow sharing one bit is fragile — same debt the hand-built flow carries.

6. Provenance — what keeps the pack canonical

Generated YAML begins:

# GENERATED — do not edit. Edit packs/<name>.pack.yaml and recompile.
# generated-by: riff-business-pack-compiler v0
# pack: legal_intake sha256:<16-hex of canonical pack JSON>

The hash is computed over the canonical JSON of the parsed pack (yaml.safe_load → sorted-keys JSON → sha256, same approach as riff/choice_compiler), never over YAML text — so comments, anchors, key order, and multiline-scalar formatting churn cannot invalidate it. python -m riff.business_pack check --flows-dir flows/ verifies every pack's generated flow exists and its embedded hash matches the pack — a stale or hand-edited generated flow is an error. Hand-authored flows (no provenance header) are untouched by all of this.

7. Acceptance

  1. Lawyer: packs/legal_intake.pack.yaml compiles to a flow that loads, lints with zero errors, passes riffc with zero error findings, and preserves the hand-built flows/legal_intake.yaml intake-path topology: consent gate → identity → adverse → screening gate → matter → confirm → submit → complete, consent-denied → message, screening-blocked terminal, same PII posture (companion_public_slots: [], phone_verified). The front door is intentionally different: the hand-built flow opens with a free-form listen state; the generated flow opens with a deterministic locked_choice menu (that upgrade is the point — codex correctly flagged that calling this "same topology" was false, so the claim is now scoped to the intake path). The hand-built flow stays live on the LAW number until the operator promotes the generated one — compiler bring-up must not be coupled to replacing a production flow (codex), and another agent is actively working the live line today. Generated output lands in flows/generated/ for inspection; promotion is a deliberate one-command copy.
  2. Locksmith: emergency handoff + quote intake_form + message; compiles clean from the same atomic units. Industry matrix enforces the handoff.
  3. Pet shop: content (hours) + intake_form (grooming request) + message; compiles clean.
  4. Full-suite regression diff vs clean HEAD: zero new failures (the validation bar).

8. Non-goals for v0 (explicitly deferred)

9. File map

riff/business_pack/__init__.py
riff/business_pack/schema.py      # parse + validate → Pack dataclasses + Finding list
riff/business_pack/compile.py     # Pack → flow dict → YAML text (+ provenance, self-check)
riff/business_pack/__main__.py    # CLI: check | compile | verify-provenance
riff/state_manager.py             # slot_equals guard resolution in evaluate_guard
riff/loader.py                    # slot_equals load-time validation
packs/legal_intake.pack.yaml
packs/locksmith_austin.pack.yaml
packs/pet_shop.pack.yaml
flows/legal_intake.yaml           # hand-built; STAYS LIVE until operator promotes
flows/generated/legal_intake.yaml # generated candidate (promotion = deliberate copy)
flows/locksmith_austin.yaml       # generated (new id, safe to ship directly)
flows/pet_shop.yaml               # generated (new id, safe to ship directly)
tests/business_pack/test_slot_equals_guard.py
tests/business_pack/test_schema_validation.py
tests/business_pack/test_compile_legal.py     # topology vs fixture
tests/business_pack/test_generality.py        # locksmith + pet shop compile/lint/riffc clean
tests/business_pack/fixtures/legal_intake_handbuilt.yaml