Field Stall Policy Design for Schema-Driven Intake
Status: design brief for Fable 5 review
Date: 2026-07-05
Related: docs/codex/2026-07-04-progressive-web-intake-design.md,
docs/space-profile-intake-runbook.md,
tests/test_shift_left_contract.py,
tests/test_flows_strict_safety_gate.py
Short Answer
Yes: the shift-left move is to make every voice-collected intake field declare what should happen when the caller is silent, unclear, or stalls. That policy should live in the intake schema or Business Pack field definition, and the compiler should fail at build time if a field can silently fall through into a side effect without an explicit policy.
This is flow/FSM behavior, not preflow behavior.
- Preflow prepares facts, content, static audio, and assets before a call.
- Flow/FSM decides what the live call does next.
- Postcall/debug reconstructs what happened after the call.
Field stall policy belongs to the Flow/FSM compiler and its build-time rules. Preflow may later consume the same schema to prepare prompts or static audio, but it should not decide runtime safety.
Problem This Solves
The latest Space Channel intake call proved the option 0 route worked:
0 -> name -> SMS consent -> verify code -> interests -> save -> confirm -> menu
The confusing part was not route recognition. The concern was structural:
space_channel_widgets currently allows this generated edge:
intake_interests_ask --stalled--> intake_save
For Space Channel, that is low risk. Interests are optional preferences. If the caller gives no interests, saving name plus verified phone is still useful.
For future commercial flows, the same pattern can be wrong:
plumber issue ask --stalled--> create job
legal deadline ask --stalled--> save case as complete
electrical safety ask --stalled--> schedule normal visit
The system should know, field by field, whether silence means "skip it", "mark unknown", "retry", "handoff", or "block the next action".
Design Goal
Make field-level silence behavior:
- Explicit in the schema.
- Compiled into deterministic FSM states and edges.
- Validated at build time.
- Visible in postcall/debug reports.
- Reusable across Space Channel, law, plumbing, electrical, property management, and future flows.
The goal is not to make every field required. The goal is to make every field's failure behavior intentional.
Proposed Field Shape
Add a voice block to schema-driven intake fields:
fields:
- id: issue
label: Issue
kind: textarea
slot: plumbing_issue
prompt: Tell me what is going on with the plumbing.
voice:
collect: true
stall_policy: mark_unknown
max_reprompts: 1
blocks:
- booking
For the existing code-side registry shape, the same structure can be represented as a Python dict:
{
"id": "issue",
"label": "Issue",
"kind": "textarea",
"slot": "plumbing_issue",
"prompt": "Tell me what is going on with the plumbing.",
"voice": {
"collect": True,
"stall_policy": "mark_unknown",
"max_reprompts": 1,
"blocks": ["booking"],
},
}
Stall Policies
| Policy | Meaning | Compiled behavior | Example |
|---|---|---|---|
required |
Caller must answer before the flow can continue normally. | Reprompt up to max_reprompts; if still missing, route to incomplete/handoff. No direct write/booking. |
Caller name, callback phone, service address for dispatch. |
skip_optional |
Field is nice to have. Blank is acceptable. | On stall, set field status to skipped, continue, and include "not provided" in the summary. |
Space Channel interests, optional email, points enrollment. |
mark_unknown |
Field matters, but the call can continue if explicitly marked unknown. | On stall, set field status to unknown, keep the stored value empty, continue, and read it back as unknown. |
Plumbing issue details, legal deadline if caller does not know, property access notes. |
web_only |
Do not ask for this field over voice. | Field appears on the intake page only. No voice stall path. | Email spelling, photos, documents, payment handoff, account setup. |
Build-Time Rules
The compiler and tests should enforce these rules before a flow can ship:
-
Every schema-driven
intake_form.fields[]entry withslotandpromptmust declarevoice.stall_policy, unless it is explicitlyweb_only. -
consent: truefields are handled by the deterministic SMS consent gate and must not declare arbitrary stall behavior. -
identity: truefields must explicitly declarevoice.stall_policy: required; the compiler must not hide this behind a default. -
A
stallededge from anaskstate into a side-effectingactstate is illegal. The compiler records the field status first, then routes through a non-side-effect bridge before save/submit. -
For
required, a missing field must block configured downstream actions such asbooking,dispatch,case_submit,payment, orprofile_complete. -
For
skip_optionalandmark_unknown, the generated readback must say what happened:
I have David, texting is allowed, interests not provided. Did I get that right?
I have Dana, address 123 Main, issue unknown. Did I get that right?
- The postcall analyzer must display field policy outcomes:
field=interests policy=skip_optional outcome=skipped source=stalled
field=issue policy=mark_unknown outcome=unknown source=stalled
field=opposing_party policy=required outcome=blocked source=stalled
Runtime Patterns
Optional Preference
Use when the answer improves personalization but does not affect safety, scheduling, or legal obligations.
Ask: What space topics interest you?
Caller: [silence]
FSM: mark interests skipped
FSM: save profile
FSM: confirm: interests not provided
Schema:
voice:
collect: true
stall_policy: skip_optional
max_reprompts: 0
Unknown But Continue
Use when the detail matters, but "unknown" is a valid operational value.
Ask: What is going on with the plumbing?
Caller: [silence]
Agent: I did not catch the issue. I can mark it as unknown for now.
FSM: set issue status=unknown
FSM: store issue value as empty
FSM: continue, but block auto-booking if configured
Schema:
voice:
collect: true
stall_policy: mark_unknown
max_reprompts: 1
blocks: [booking]
High-Risk Incomplete
Use when a missing answer changes whether RIFF is allowed to continue.
Ask: Is there a court date or deadline?
Caller: [silence]
Agent: I do not want to miss an important deadline. I can text you the form or
have someone follow up.
FSM: mark intake incomplete
FSM: no attorney scheduling or case submission yet
Schema:
voice:
collect: true
stall_policy: required
max_reprompts: 1
blocks: [case_submit]
on_exhausted: handoff
Web Only
Use when voice is a bad medium or the data should go through a secure handoff.
Do not ask for email spelling, documents, card number, or password over voice.
Text a verified link instead.
Schema:
voice:
collect: false
stall_policy: web_only
Vertical Examples
Space Channel
| Field | Policy | Why |
|---|---|---|
name |
required |
Needed for greeting and profile identity. |
sms_consent |
consent gate | Deterministic yes/no, not a normal field stall. |
interests |
skip_optional |
Useful for personalization, but blank is safe. |
email |
web_only |
Hard to spell over the phone. |
points_opt_in |
web_only or skip_optional |
Optional relationship module. |
Recommended behavior: Space can keep saving a profile when interests are blank, but the confirm line should explicitly say "interests not provided."
Plumbing
| Field | Policy | Why |
|---|---|---|
name |
required |
Needed for follow-up. |
callback_phone |
required |
Needed if call drops. |
service_address |
required |
Required before dispatch. |
issue |
mark_unknown |
A ticket can start with unknown issue, but auto-booking may be blocked. |
active_leak |
required |
Safety/urgency risk; use on_exhausted: handoff. |
photos |
web_only |
Better through the link. |
Recommended behavior: if active leak is unclear, route to emergency/human or incomplete intake. Do not silently schedule a normal visit.
Electrical
| Field | Policy | Why |
|---|---|---|
name |
required |
Needed for follow-up. |
service_address |
required |
Required before dispatch. |
safety_symptoms |
required |
Sparks, burning smell, shock risk; use on_exhausted: handoff. |
issue_details |
mark_unknown |
Unknown is acceptable, but should be visible. |
panel_photo |
web_only |
Safer and clearer through the link. |
Recommended behavior: unclear safety triage should not become a normal booking.
Law
| Field | Policy | Why |
|---|---|---|
name |
required |
Needed for intake identity. |
callback_phone |
required |
Needed for follow-up. |
matter_type |
mark_unknown |
Caller may not know legal category. |
deadline |
mark_unknown with warning |
Unknown can continue, but review should flag it. |
opposing_party |
required |
Conflict screening risk; use on_exhausted: handoff. |
documents |
web_only |
Secure upload through verified link. |
Recommended behavior: conflict-sensitive fields should block "complete" status until answered on web or reviewed by a human.
Compiler Plan After Approval
If Fable 5 approves the design, implement in this order:
- Extend
riff/business_pack/schema.pyto parse and validatefield.voice. - Add explicit
voice.stall_policyentries toriff/intake_schemas.pyand the fixture plumber pack. - Update
riff/business_pack/compile.pyso schema-driven intake emits the correct stall subgraph per policy. - Update
tests/test_shift_left_contract.pyto prove a second vertical cannot omit policies and that policy shapes compile generically. - Update
tests/test_flows_strict_safety_gate.pyso the currentspace_channel_widgetsedge exemption can be removed or reduced to an explicit field-policy proof. - Regenerate generated flows.
- Update postcall review to include per-field policy outcomes.
- Run:
.venv/bin/python -m pytest tests/test_shift_left_contract.py tests/test_flows_strict_safety_gate.py tests/business_pack/test_widgets_importer.py tests/business_pack/test_widgets_flow_sim.py -q
.venv/bin/python scripts/verify-menu-sweep.py space_channel_widgets
.venv/bin/python scripts/post_call_analyze.py --skip-whisper <recent-session-id>
Review Questions for Fable 5
-
Are the five policy names right, or should
requiredandhandoff_or_incompletebe collapsed into one policy with differentblocks? -
Should
identity: truefields be allowed to rely on a compiler default, or should importers always materializevoice.stall_policy: required? -
Should
skip_optionalbe allowed to save before confirm for low-risk profile fields, or should every stalled field route through a pre-save confirmation state? -
Is
mark_unknownoperationally safe for law and trades, provided the field can block booking/case submission? -
Should
blocksbe a free-form list in v0, or should the compiler validate it against a fixed enum such asbooking,dispatch,case_submit,payment, andprofile_complete?
Recommendation
Approve the design with one conservative rule:
No new schema-driven intake field may introduce a stalled path to a write or
booking unless its field policy explicitly declares the outcome and the compiled
edge records that outcome.
That gives RIFF the right scaling property: new flows are mostly pack/schema work, but dangerous ambiguity fails before live calls.
Fable 5 Review (2026-07-05)
Verdict: APPROVED, including the conservative closing rule verbatim. This
design correctly converts the one-off space_channel_widgets
intake_interests_ask --stalled--> intake_save safety-gate exemption (a
per-edge patch from the profile-intake build) into a declared, compiled,
build-time-enforced property. The layering ruling is right: stall behavior is
Flow/FSM compiler territory, not preflow. Answers to the five questions:
-
Collapse to four policies.
requiredandhandoff_or_incompletecompile to near-identical subgraphs (reprompt → exhausted → incomplete/ handoff → block actions); two names for one subgraph will drift. Keeprequiredwithblocks: [...]plus anon_exhausted: handoff | incompleteknob. Final set:required,skip_optional,mark_unknown,web_only. -
Materialize, don't default. Importers must write
voice.stall_policy: requiredontoidentity: truefields explicitly; the compiler ERRORS on an identity field with no policy. Compiled packs stay self-describing — a compiler default hides policy from pack diffs and reviews. -
Keep save-then-confirm for
skip_optional. The save-before-confirm ordering is a deliberate intake-wide rule (callers must never hear a confirmation for unpersisted data). A stalled optional field saving before the readback is safe precisely because rule 6 forces the readback to say "not provided" and the correction loop can amend. Do not add a pre-save confirmation state — one full voice turn of latency for zero risk. -
mark_unknownis safe for law/trades ONLY with double-sided blocks. Add build rule 8: every action named inblocksmust ALSO check field status server-side at tool dispatch (the booking/case_submit tool refuses when a blocking field is unknown/missing). Compiled-edge absence is one defense; tool-side verification is the one that survives future flow edits. -
Fixed enum in v0. Free-form
blocksstrings fail silently — a typo'd block never matches an action and reads as safety that isn't there. Validate againstbooking | dispatch | case_submit | payment | profile_complete, one registry, compiler error lists valid values.
Three sharpening notes:
- Drop
unknown_valuestring fabrication. Writing the literal stringunknowninto the slot conflates data with status: the intake page renders "unknown" in an editable field, downstream consumers can't distinguish a caller who said "unknown" from a stall, and readbacks should derive from status anyway. Persist a per-field status (answered | skipped | unknown | blocked) alongside the value (intake_answers gains a status column) and leave the value empty on stall. Rule 6/7 then read from status. - Clarify counter interplay.
max_repromptsgoverns the FIRST unanswered prompt; the existing interests "anything else?" loop caps (4 rounds, 2-silence exit) govern continuation after a successful first answer. State this in the compiler docs or the two counters will get cross-wired. web_onlyimpliescollect: false— makestall_policyauthoritative and have the compiler validate consistency rather than trusting two fields to agree.
Implementation order in "Compiler Plan After Approval" is right; step 5 (removing the space_channel_widgets edge exemption in favor of a field-policy proof) is the payoff that retires the temporary exemption entirely.
Implementation Note (2026-07-05)
Implemented after Fable 5 approval.
What landed:
riff/business_pack/schema.pyvalidatesfield.voice.stall_policyat build time. Policies are now the four approved values:required,skip_optional,mark_unknown, andweb_only.blocksis a fixed enum:booking | dispatch | case_submit | payment | profile_complete.- Identity fields must explicitly declare
voice.stall_policy: required; consent fields must not declare a voice stall policy. unknown_valueis rejected. Intake persistence now storesintake_answers.statusasanswered | skipped | unknown | blockedbeside the value.- The compiler emits status-setting transitions and a non-side-effect static
bridge for stalled optional fields. The Space Channel path is now:
intake_interests_ask --stalled--> intake_interests_skipped --> intake_save. intake_profile_savewrites per-field status and refusesprofile_completesubmit when a field whose policy blocksprofile_completeis notanswered.- The temporary strict-safety exemption for
space_channel_widgets:intake_interests_ask -> intake_savewas deleted.
Validation run:
.venv/bin/python -m pytest \
tests/test_profile_store.py \
tests/test_intake_api.py \
tests/test_intake_client.py \
tests/test_intake_tools.py \
tests/test_shift_left_contract.py \
tests/business_pack/test_intake_compile.py \
tests/business_pack/test_intake_compile_law_electric.py \
tests/business_pack/test_widgets_importer.py \
tests/business_pack/test_widgets_flow_sim.py \
tests/test_flows_strict_safety_gate.py -q
RIFF_REQUIRE_SAFETY_GATE=1 .venv/bin/python - <<'PY'
from riff.loader import load_flow
for path in [
"flows/space_channel_widgets.yaml",
"flows/legal_intake.yaml",
"flows/electrical_intake.yaml",
]:
load_flow(path)
print(path, "ok")
PY
.venv/bin/python scripts/verify-menu-sweep.py space_channel_widgets
Result: focused suite passed, strict-load passed for the three affected flows,
and Space Channel menu sweep remained 7/7.