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"
- Syntax (strict): exactly three segments split on
:; segment 1 is the literalslot_equals; segments 2 and 3 must each fully match[a-z0-9_]+(non-empty, lowercase; no extra colons, whitespace, uppercase, or empty parts). Anything else is NOT a slot_equals guard and falls through to the registry lookup → the existing loud unknown-guard error. - Semantics:
str(ctx.slots.get(slot, "")).strip().lower() == value. Sincevalueis constrained lowercase-identifier at load, the normalization is total. - Load-time validation: accepted iff it parses AND
<slot>is declared inslot_typesAND — when the transition's own state has alocked_choicewhoseslotmatches —<value>is one of that choice set's values (kills the typo→silently-unreachable-branch failure codex flagged). - play_audio states are unchanged:
_PLAY_AUDIO_VALID_GUARDS(loader.py:686) still allows onlyaudio_done/audio_failed;slot_equalsis invalid there, deliberately. evaluate_guardsynthesizes the comparison fn before the registry lookup, so the F-282guard_evaluatedtrace event fires identically (slot-read tracker sees the read; unknown guards still raise before any event).- Nothing else in the guard registry changes; existing named guards are untouched.
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_issue → complete 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).
3.4b companion_link (v0.1, SHIPPED)
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-uncompilable — calendar_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.
Menu compilation (shared)
greetingstate: speech_act greet,greeting_line+ appended disclaimer texts;any_utterance→menu.menustate:locked_choicewithinline: true(required — the loader deprecation-warns without it and hard-fails underRIFF_REQUIRE_INLINE_FLAG=1, loader.py:304),slot: route, one choice per capability — value = capability id, label, aliases + dtmf digit prepended to aliases, collision-checked at pack level —required_slots: [route],allowed_tools: [update_slots, escalate_to_human],rules: {max_sentences: 2, max_questions: 1}.- One transition per capability:
when: slot_equals:route:<cap_id>→ the capability's entry state (or its consent gate, §5). - Escape hatch: the compiler always emits
escalation_requested→ the firsthandoffcapability if one exists, else an auto-generatedmenu_handoffescalated terminal. Guard choice matters: thechoice-no-escapelint (linter.py:844) recognizes only the recovery substrings in_CHOICE_RECOVERY_GUARDS(linter.py:181 —escalation_requested,no_match,denied,stalled, …);choice_retry_exhaustedis NOT in that set (and is registered in cc_tools, an import we shouldn't depend on), so the escape edge usesescalation_requested. Pack authors cannot produce a trap menu. routeis auto-added toslot_typesas{type: string}by the compiler.
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 ask→act 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
- disclaimer → sentence appended to the greeting line (spoken before the menu).
- consent_gate → a
confirmstate inserted in front of eachapplies_tocapability's entry: confirmed → capability start; denied →decline_tocapability's entry. The menu transition retargets to the gate. - screening_gate → an
actstate (required_tool: <tool>) spliced after the named stage inside an intake_form:all_state_slots_filled→ next stage;escalation_requested→ a dedicated blocked terminal (final_status: escalated,blocked_line). Phase B of the intake is reachable ONLY through the gate. - emergency routing is not a separate kind: it is a
handoffcapability with high-priority aliases, plus the industry matrix making it mandatory for trade industries. (Acknowledged codex point: the matrix checks presence, not that emergency utterances actually route there — routing quality is the alias set's job and an L1-scenario concern, v0.1.)
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
- Lawyer:
packs/legal_intake.pack.yamlcompiles to a flow that loads, lints with zero errors, passes riffc with zero error findings, and preserves the hand-builtflows/legal_intake.yamlintake-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 inflows/generated/for inspection; promotion is a deliberate one-command copy. - Locksmith: emergency
handoff+ quoteintake_form+message; compiles clean from the same atomic units. Industry matrix enforces the handoff. - Pet shop:
content(hours) +intake_form(grooming request) +message; compiles clean. - Full-suite regression diff vs clean HEAD: zero new failures (the validation bar).
8. Non-goals for v0 (explicitly deferred)
- Scheduler policy / calendar_booking compilation — gated on the scheduler handoff work (task #20); section reserved, shape pinned to that doc.
- caller_memory / returning-caller menu variants — needs verified-caller
gating (raw caller-ID variants leak "this number has an open matter"); will key
on the existing
caller_lookup_statusslot viaslot_equals, no new DSL. - Widgets importer + 12h refresh (SHIPPED, v0.1):
riff/business_pack/importers/space_channel_widgets.pymaps the numbered widgets export (menu.json + N-slug/widget.json) → pack; policy: FIXED-NUMBERED menus keep a degraded widget's slot with last-good text (vs the NOC importer's drop-stale). Live on 321-559-MARS.scripts/refresh-space-widgets.sh(crontab0 */12) re-fetches → recompiles (fail-closed: gate errors keep the previous flow) → prerenders only changed audio (--timeout 60: standard-ladder briefings render ~22s and Gemini's trailing turn-complete latency blew the 30s default once). No restart needed (mtime-cached loader, lookup-time manifest reads).- v0.1 spoken ladder: briefings speak
spoken.standard(fallbackspoken.summary), cleaned of feed truncation fragments (…/[…]) and capped at 550 chars on a sentence boundary for the TTS render budget. (The one-line summaries live callers heard as "it did nothing" were v0.) - v0.1 episode audio: a widget carrying
audio.url(mp3) — widget 5's UFO Files episode — is downloaded (30 MB cap), ffmpeg-transcoded to 24 kHz mono PCM16 capped at 600 s, cached by url-hash understatic_audio/.episode_cache/, and registered in the manifest as that state's prerendered WAV (voice markerepisode-audio; the caption "Today's episode: <title> — <show>" is the karaoke text). Any download/transcode failure falls back to the TTS text path — the refresh never breaks on a bad episode. - Karaoke stylesheet: the companion read-along styles live in
web/assets/companion-theme.css(served likemenu-theme.css) — restyle the subflow karaoke without touchingcompanion.html.
- v0.1 spoken ladder: briefings speak
- Importers (website/CRM → pack) — the pack is the contract they target.
NOC → packSHIPPED:riff/business_pack/importers/space_channel.pymaps noc_briefing.json (schema 0.3) → pack — healthy categories becomecontentcapabilities (stale/degraded DROPPED, fail-closed),priority_orderdrives menu order, DTMF on the first nine, plus a standingreport_bugmessage capability. Proven against a live feed snapshot (tests/business_pack/test_space_channel_importer.py). - Prerendered audio for content capabilities; commit-tier propagation
(
commit: truereserved); module imports from packs (verify_caller_v1, book_appointment_v1 composition) — v0.1 candidates.
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