← Choice Health 2026-07-05-field-stall-policy-design-fable5.md raw .md

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.

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:

  1. Explicit in the schema.
  2. Compiled into deterministic FSM states and edges.
  3. Validated at build time.
  4. Visible in postcall/debug reports.
  5. 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:

  1. Every schema-driven intake_form.fields[] entry with slot and prompt must declare voice.stall_policy, unless it is explicitly web_only.

  2. consent: true fields are handled by the deterministic SMS consent gate and must not declare arbitrary stall behavior.

  3. identity: true fields must explicitly declare voice.stall_policy: required; the compiler must not hide this behind a default.

  4. A stalled edge from an ask state into a side-effecting act state is illegal. The compiler records the field status first, then routes through a non-side-effect bridge before save/submit.

  5. For required, a missing field must block configured downstream actions such as booking, dispatch, case_submit, payment, or profile_complete.

  6. For skip_optional and mark_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?
  1. 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:

  1. Extend riff/business_pack/schema.py to parse and validate field.voice.
  2. Add explicit voice.stall_policy entries to riff/intake_schemas.py and the fixture plumber pack.
  3. Update riff/business_pack/compile.py so schema-driven intake emits the correct stall subgraph per policy.
  4. Update tests/test_shift_left_contract.py to prove a second vertical cannot omit policies and that policy shapes compile generically.
  5. Update tests/test_flows_strict_safety_gate.py so the current space_channel_widgets edge exemption can be removed or reduced to an explicit field-policy proof.
  6. Regenerate generated flows.
  7. Update postcall review to include per-field policy outcomes.
  8. 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

  1. Are the five policy names right, or should required and handoff_or_incomplete be collapsed into one policy with different blocks?

  2. Should identity: true fields be allowed to rely on a compiler default, or should importers always materialize voice.stall_policy: required?

  3. Should skip_optional be allowed to save before confirm for low-risk profile fields, or should every stalled field route through a pre-save confirmation state?

  4. Is mark_unknown operationally safe for law and trades, provided the field can block booking/case submission?

  5. Should blocks be a free-form list in v0, or should the compiler validate it against a fixed enum such as booking, dispatch, case_submit, payment, and profile_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:

  1. Collapse to four policies. required and handoff_or_incomplete compile to near-identical subgraphs (reprompt → exhausted → incomplete/ handoff → block actions); two names for one subgraph will drift. Keep required with blocks: [...] plus an on_exhausted: handoff | incomplete knob. Final set: required, skip_optional, mark_unknown, web_only.

  2. Materialize, don't default. Importers must write voice.stall_policy: required onto identity: true fields 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.

  3. 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.

  4. mark_unknown is safe for law/trades ONLY with double-sided blocks. Add build rule 8: every action named in blocks must 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.

  5. Fixed enum in v0. Free-form blocks strings fail silently — a typo'd block never matches an action and reads as safety that isn't there. Validate against booking | dispatch | case_submit | payment | profile_complete, one registry, compiler error lists valid values.

Three sharpening notes:

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:

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.