# TOPOLOGY_REDUCED — load every Recoil session  (AUTO-GENERATED — do not edit; `build_topology.py --write`)

## ⚠ DIVERGENCES — capabilities that FORK by surface/flag. Read before touching them.

| capability | fork | route A | route B | gate |
|---|---|---|---|---|
| prompt_building | board_two_stage_split | board → pencil_halfsize via build_and_dispatch_board | board → photoreal_fullsize via render_board_finish | — |
| modality_registry | modality_runner_fork | video → individual via VideoRunner.run | r2v → individual via R2VMultiRunner.run | — |
| ref_resolution | video_ref_collection_fork | video → composite_sheet via resolve_sheet_asset | video → individual via resolve_character_bundle | use_composite_sheets |
| prompt_building | prompt_authoring_fork | video → authored_prose via resolve_strategy | video → deterministic_template via _build_deterministic_template_prompt | — |
| worksurface_execution | fallback_declared_not_wired | video → provider_primary via FloraAdapter.build_submit | video → provider_fallback via FalAdapter.build_submit | — |
| worksurface_execution | seedance_provider_three_way | video → provider_primary via FloraAdapter.build_submit | video → provider_fallback via FalAdapter.build_submit  (+1 more routes) | provider_override |
| typed_payload | payload_hints_dict_vs_typed | video → i2v_t2v_v2v via VideoModelClient.submit | r2v → composite_sheet via VideoModelClient.submit | — |
| modality_registry | multi_shot_native_vs_sequential | video → i2v_t2v_v2v via VideoModelClient.submit | video → endpoint_table via KlingAdapter.build_submit | — |
| worksurface_execution | action_inference_per_adapter | video → i2v_t2v_v2v via VideoModelClient.submit | image → individual via resolve_adapter | — |
| orchestration | grouping_strategy_fork | coverage → coverage_pass via build_passes | continuity → batch via cluster_shots_into_batches  (+1 more routes) | grouping_strategy |
| scene_beat_take | batch_modality_fork | continuity → batch via cluster_shots_into_batches | continuity → per_shot_i2v via cluster_shots_into_batches | — |
| orchestration | scene_version_append | coverage → coverage_pass via build_passes | reroll → persisted_scene_id via verify_scene_grouping_metadata | — |
| prompt_building | builder_dispatch_table | video → composite_sheet via build_seeddance_i2v_prompt | video → composite_sheet via build_kling_i2v_prompt  (+2 more routes) | — |
| prompt_building | prompt_length_split | video → composite_sheet via build_seeddance_i2v_prompt | video → composite_sheet via build_kling_i2v_prompt | — |
| prompt_building | name_binding_modality_fork | r2v → composite_sheet via build_seeddance_r2v_prompt_multi | video → composite_sheet via build_seeddance_i2v_prompt | — |
| ref_resolution | ref_system_split | board → individual via resolve_character_bundle | video → composite_sheet via resolve_sheet_asset | use_composite_sheets |
| orchestration | board_reroll_story_gate_fork | board → board_rerolled via build_with_auto_reroll | board → board via build_and_dispatch_board | story_gate_mode |
| ref_promotion | ref_promotion_three_writers | promotion → top_level_subject_dir via set_hero | production → base_pool_dir via resolve_entity_refs | — |
| project_path_construction | location_sheets_path_split | char → char_base_sheets via get_character_sheets_dir | loc → loc_sheets via get_location_sheets_dir | — |

> **board_two_stage_split** invariant: render_board_finish MUST NOT run on a non-approved board (asserts board.status=='approved') and MUST feed the approved pencil PNG as the FIRST reference; the photoreal finish is FULL-size while the pencil is HALF-size (_apply_iteration_size). The finish is judged by NO StoryGate — only the pencil tier gates. Collapsing the two stages, or judging the finish, breaks the REC-149 contract.

> **modality_runner_fork** invariant: dispatch() MUST resolve the runner ONLY via get_runner(modality) from the RunnerRegistry bootstrapped by register_default_runners — no modality may get a second dispatch surface. r2v_multi legitimately bypasses build_dispatch_payload's per-shot author/cap path by delegating to StepRunner.execute_pass inside R2VMultiRunner.run; that bypass MUST stay behind the registry (i.e. still entered through dispatch()), never become a direct execute_pass call that skips _validate_payload + the receipt emit.

> **video_ref_collection_fork** invariant: _collect_reference_images MUST try the composite-sheet collector (_collect_sheet_refs → resolve_sheet_asset per entity) FIRST and fall back to the two-pass angle collector only on None; assert_refs_complete MUST run on BOTH return branches (sheet and angle). The sheet branch returning the per-kind sheet path currently MASKS the known-broken angle/pool resolver (ssot_manifest ref_resolution.current_broken_behavior) — unifying onto one canonical ref source (REC-213=C) must not silently drop the pre-spend gate.

> **prompt_authoring_fork** invariant: _build_author_aware_prompt MUST converge every failure mode onto the deterministic-template prompt (AuthorInputError/AuthorCallError/BindAssertion/ verify_block all → deterministic(...)), EXCEPT the systemic 3-consecutive author_call breaker which MUST raise and halt before further spend. A new strategy MUST NOT introduce an authored-prose path with no deterministic fallback, and MUST NOT bypass the _reset_breaker_state() accounting.

> **fallback_declared_not_wired** invariant: resolve_fallback() returning a (fb_adapter, fb_tier) MUST NOT be read as a live retry: VideoModelClient._finalize_failed logs the fallback but performs NO inline dispatch. Any code asserting "primary failed → fallback ran" is wrong; fallback retry must be caller-orchestrated by re-invoking submit() with the original dict.

> **seedance_provider_three_way** invariant: The SAME model_id (seeddance-2.0) routes to flora (strategy primary), fal, OR atlas (RECOIL_PROVIDER_OVERRIDE). resolve_adapter's precedence is override > capability_exceptions > primary ONLY — the declared "fallback" is a SEPARATE resolve_fallback() lookup that is logged, NOT auto-dispatched (see fallback_declared_not_wired). Provider-specific ref handling (Flora upload_local_refs vs fal fal-storage upload vs atlas CDN reuse) MUST stay inside each adapter's build_submit — StepRunner emits ONE unified payload and never branches by provider.

> **payload_hints_dict_vs_typed** invariant: StepRunnerHints is extra='allow' and _dict_to_unified passes a typed PayloadHints THROUGH but defensively COPIES a legacy dict. Every adapter read of payload.hints MUST go through coerce_to_dict() at the boundary; reading the wrong key silently drops fields (REC-38: tier read from 'provider_hints' instead of merged 'hints' → tier always None → UPGRADE_FAST_TO_PRO never reached VideoModelClient).

> **multi_shot_native_vs_sequential** invariant: execute_multi_shot routes to the native MultiShotPayload branch ONLY when model_profiles.get_api_pattern(model) is in {'kling_rest'}; every other model (Veo/SeedDance) falls through to _execute_sequential_shots (the canonical route). REC-235: the native branch is UNWIRED — KlingAdapter native multi-shot is not plumbed (VideoModelClient only accepts UnifiedVideoPayload-shaped dicts and drops shots/start_frame_bytes via _dict_to_unified) — so the kling_rest branch now RAISES NotImplementedError (fail-loud) instead of silently dispatching. The get_api_pattern / _MULTI_SHOT_PATTERNS routing seam is preserved as the future native wire-up entry point.

> **action_inference_per_adapter** invariant: Action (t2v/i2v/r2v/f2v and t2i/i2i/is2i) is inferred INDEPENDENTLY inside each adapter from payload shape — fal._infer_action, flora._infer_action, kling._resolve_endpoint — NOT decided by StepRunner. The promotion rules MUST agree on the shared payload contract (image→i2v, reference_images→r2v); flora additionally promotes image-model t2i→i2i/is2i and adds f2v (image+image_tail), which fal/kling video paths do not.

> **grouping_strategy_fork** invariant: The grouping STRATEGY chosen at run time forks BOTH the persisted scene-id namespace AND the dispatch modality, and the resulting {episode}_{scene_id}.json is the SSOT the board + r2v re-read. A scene persisted under one strategy MUST be re-derived (or --batch-targeted) under the SAME strategy: continuity scenes are named BATCH_NNN but carry grouping.strategy='continuity' (the CONT selector token), and the per-strategy ordinal must round-trip through verify_scene_grouping_metadata before any reroll spend.

> **batch_modality_fork** invariant: A continuity Batch with len(shots) < min_batch_size (3) is flagged below_threshold and MUST dispatch as per-shot video_i2v, not r2v_multi; the same is true for a 1-shot oner. cluster_shots_into_batches owns this decision — callers MUST NOT force a below-threshold batch onto r2v_multi.

> **scene_version_append** invariant: The sole RE-DERIVATION writer APPENDS a new structure-immutable ep_NNN_BATCH_NNN.v{N+1}.json body (a candidate version) in the per-batch ep_NNN_BATCH_NNN.versions.json manifest; it MUST NOT rewrite the shot STRUCTURE of any existing version and MUST NOT move active_version. In-place take/board STATUS updates to the ACTIVE version body remain allowed (the existing save_scene caller surface); only SceneVersionStore.conform/revert moves the pointer. Every prior version's structure stays intact and re-pointable.

> **builder_dispatch_table** invariant: EVERY production prompt MUST resolve through get_builder((model_id, modality)) against the single BUILDERS table (prompt_engine.py ~L7956-8042) — no parallel builder module, no direct-import that bypasses the (model,modality) key. get_builder raises KeyError (never silently substitutes) on an unregistered key. New (model,modality) pairs are added ONLY by appending to BUILDERS, kept in sync with provider_strategy.json (asserted by test_keys_match_provider_strategy_snapshot).

> **prompt_length_split** invariant: Prompt-side _enforce_prompt_length (prompt_engine.py L443-495) TRUNCATES at the bible's per-model max_chars (last sentence boundary); the Flora video adapter (recoil/execution/providers/flora.py L489-498, _FLORA_MAX_PROMPT_CHARS=2500) RAISES ValueError on any video prompt >2500 chars rather than truncating. These two guards MUST stay reconciled: if a model's bible max_chars is unset or >2500, prompt_engine emits an over-length prompt that flora.py hard-fails. The seedance builders must keep prompts under 2500 organically (boards+sheets carry the visual), never rely on back-truncation.

> **name_binding_modality_fork** invariant: bind_named_prose / _assert_bound_prompt (prompt_engine.py L5399-5530) forks by modality: 'r2v_multi' bound prose MUST carry @ImageN tokens, every one backed by the ref manifest; 'video_i2v' bound prose MUST NOT contain ANY @ImageN token (raises BindAssertionError). No bound prompt of either modality may leak a configured character proper noun, and beat count MUST equal len(timing_segments).

> **ref_system_split** invariant: for a LOCKED board with use_composite_sheets ENABLED, board_ref_path resolves the LOCATION sheet via the SAME recoil/core/ref_resolver.py::resolve_sheet_asset as video, gated by the SAME composite_sheets_enabled flag (REC-213 C3 — one activation SSOT, no board/video fork); board CHARACTER refs stay individual (resolve_character_bundle) by design — an intentional per-surface choice, not a silent fork.
> **board_reroll_story_gate_fork** invariant: build_with_auto_reroll MUST run only under story_gate_mode='shadow'; it RAISES BoardBuilderError on 'off' AND on 'enforce' (enforce ships in v1.1, NotImplementedError). The reroll loop is the ONLY path that consumes story-gate HARD verdicts + fix-notes — a board built via the single-build route bypasses gate-driven reroll entirely.
> **ref_promotion_three_writers** invariant: ref_promotion MUST land refs where the production resolver reads. Today the three promotion mechanisms (set_hero, promote_grid, prep_character_angles.process_intake) ALL write to assets/{cls}/{subject}/ top-level, while resolve_entity_refs (the production payload resolver) reads assets/{cls}/{subject}/base/pool/{kind}/ — so no promotion output reaches production. Consolidation = one promotion writer + one resolver onto base/pool via a manifest pointer (REC-76 follow-up).
> **location_sheets_path_split** invariant: the per-kind sheet layout (char assets/char/<n>/base/sheets/, loc assets/loc/<n>/sheets/ — loc intentionally drops base/) has exactly ONE home: ProjectPaths.sheet_path (REC-213 C1+C2). Reader (resolve_sheet_asset) and writer (_sheet_dest) both go through it, so the per-kind branch cannot drift across code paths.

## SCHEMA SSOT — bible data-models: duplication-prone fields

### Semantic Cluster Index
- semantic: associated_characters
  - BibleProp.associated_characters: list[str] — Character IDs that use this prop, not necessarily carriers.  [semantic: associated_characters]
  - LightingMotif.associated_characters: list[str] — Character IDs associated with this lighting motif.  [semantic: associated_characters]
- semantic: identity_lock
  - BibleCharacter.identity_invariants: Optional[IdentityInvariants] — Never-changing identity block for this character.  [semantic: identity_lock]
  - BibleCharacter.transients: Optional[list[str]] — Optional declared transient visual states.  [semantic: identity_lock]
  - IdentityInvariants.build: str — Never-changing canonical body build.  [semantic: identity_lock]
  - IdentityInvariants.eye_color: str — Never-changing canonical eye color.  [semantic: identity_lock]
  - IdentityInvariants.hair_color: str — Never-changing canonical hair color.  [semantic: identity_lock]
  - IdentityInvariants.skin_tone: str — Never-changing canonical skin tone.  [semantic: identity_lock]
- semantic: marks
  - CharacterPhase.distinguishing_marks: str — Mutable scars, injuries, or accumulated damage for this phase.  [semantic: marks]
  - IdentityInvariants.distinguishing: list[str] — Never-changing distinguishing identity traits.  [semantic: marks]
  - PhaseAppearance.notable_marks: list[str] — Structured notable marks visible in this phase.  [semantic: marks]
- semantic: prop_carrier
  - BibleProp.attached_to: Optional[str] — Character ID this prop is permanently attached to or worn by.  [semantic: prop_carrier]
  - BibleProp.carriable: bool — Whether the prop can be transiently carried by a character.  [semantic: prop_carrier]
  - BibleProp.is_permanent_attachment: bool — Whether the prop is baked into character identity refs.  [semantic: prop_carrier]
  - PhaseAppearance.visible_gear: list[str] — Gear visible on the character in this phase.  [semantic: prop_carrier]
  - WardrobePiece.state: Literal['worn', 'removed', 'damaged', 'torn'] — Current worn, removed, damaged, or torn state.  [semantic: prop_carrier]
- semantic: prop_state
  - BibleProp.initial_state: str — Initial state_id for the prop state machine.  [semantic: prop_state]
  - BibleProp.state_notes: str — Annotation notes; operative states live in states and transitions.  [semantic: prop_state]
  - BibleProp.states: dict[str, PropState | str] — Operative prop state machine keyed by state_id.  [semantic: prop_state]
  - BiblePropTransition.from_state: str — Source prop state; serialized with alias "from".  [semantic: prop_state]
  - BiblePropTransition.reversible: bool — Whether the transition can be reversed.  [semantic: prop_state]
  - BiblePropTransition.to: str — Destination prop state.  [semantic: prop_state]
  - BiblePropTransition.trigger_scene: Optional[str] — Optional scene that triggers the transition.  [semantic: prop_state]
  - PropState.visual_delta: str — Visual delta that distinguishes this prop state.  [semantic: prop_state]
- semantic: wardrobe_arc
  - BibleCharacter.wardrobe_arc_thesis: str — One-sentence thesis for wardrobe progression.  [semantic: wardrobe_arc]
  - BibleCharacter.wardrobe_arc_vision: str — Director free-text wardrobe vision notes.  [semantic: wardrobe_arc]
  - CharacterPhase.wardrobe_arc_carries: str — Wardrobe items carried forward unchanged from the prior phase.  [semantic: wardrobe_arc]
  - CharacterPhase.wardrobe_arc_delta: str — Structured wardrobe additions, removals, or modifications from the prior phase.  [semantic: wardrobe_arc]

## Entrypoints
- `board_cli` — THE board CLI surface.  →  `python3 recoil/pipeline/cli/generate.py <project> <episode> --storyboard <batch> [--auto-reroll --max-board-attempts N]`
- `build_and_dispatch_board` — Single-attempt pencil-tier board build+dispatch.  →  `from recoil.pipeline._lib.board_builder import build_and_dispatch_board`
- `render_board_finish` — Stage-2 (REC-149): render a FULL-SIZE photoreal finish for an already-APPROVED pencil board.  →  `from recoil.pipeline._lib.board_builder import render_board_finish`
- `run_overnight_cli` — Autonomous overnight production entry over EpisodeRunner — the unattended sibling of generate.py.  →  `python3 recoil/pipeline/cli/run_overnight.py <project> <ep> [...]`
- `dispatch_cli` — Major manual/test generation entry over dispatch()/StepRunner — single-shot i2v, r2v_multi, keyframe, talking-shot, ad-hoc refs.  →  `python3 recoil/pipeline/tools/dispatch_cli.py --project <p> --model <m> [--shot|--shots|--start-frame ...] [--ref-video --audio-url --generate-audio]`
- `generate_composite_sheet_cli` — THE writer for the composite-sheet ref system (sheets/sheet_v1.png) that the video path consumes via resolve_sheet_asset (the bundle sheet route).  →  `python3 recoil/pipeline/tools/generate_composite_sheet.py --project <p> --entity <id> ...`
- `breakdown_gate_cli` — Gate over the mention_ledger (Gate-A breakdown readiness) — the breakdown counterpart to the extractor; ratifies the per-episode scene mention ledger before board/r2v consume it.  →  `python3 recoil/pipeline/tools/breakdown_gate_cli.py --project <p> --episode <n>`
- `dispatch` — CP-5 single generation entry point.  →  `from recoil.pipeline.core.dispatch import dispatch; dispatch(modality, payload, context=ctx)`
- `audit_dispatch_cli` — Pre-spend payload audit.  →  `python3 -m recoil.pipeline.tools.audit_dispatch --project <slug> --episode ep_NNN`
- `step_runner` — Single source of truth for all generation execution.  →  `from recoil.execution.step_runner import StepRunner`
- `video_model_client` — Thin client atop the adapter registry.  →  `from recoil.execution.video_model_client import VideoModelClient`
- `generate_cli` — Live production entry.  →  `python3 recoil/pipeline/cli/generate.py <project> <ep> --grouping {coverage|continuity|oner|auto} [--pass <id>] [--batch EP{ep}_{CONT|ONER}_{ord}] [--new-take] [--dry-run]`
- `episode_runner_module` — Clusters shots into groups via the grouping registry, converts each Group to a Scene of Beats, runs Beats under an asyncio.Semaphore, performs stale-take recovery + phantom-success invalidation + current-ref fingerprint revalidation before dispatch, and persists each Scene through the guarded writer.  →  `from recoil.pipeline.orchestrator.episode_runner import EpisodeRunner; await EpisodeRunner(...).run_episode_batches(...)`
- `reroll_route` — HTTP reroll surface.  →  `POST /reroll {project, episode, batch_id: EP{ep}_{CONT|ONER}_{ord}}`
- `prompt_engine_smoke_cli` — __main__ smoke harness: loads RECOIL_ROOT/DEFAULT_PROJECT storyboard_ep_001.json + project_config.json + breakdown.json and prints compiled prompts.  →  `python -m recoil.pipeline._lib.prompt_engine`
- `collect_board_refs` — Board surface ref collector — char refs per-kind INDIVIDUAL (char bundle turn-views); prop refs individual prop-identity for NON-worn props (a WORN prop, bible attached_to a char in the shot, renders as PROSE on the carrier with NO standalone ref — REC-213 C4); the LOCATION ref is the composite SHEET (resolve_sheet_asset) when locked + use_composite_sheets on (REC-213 C3), else the sublocation plate.  →  `from recoil.pipeline._lib.board_builder import _collect_board_refs`
- `collect_reference_images_entry` — Video/r2v surface ref collector.  →  `from recoil.pipeline._lib.dispatch_payload import _collect_reference_images`
- `generate_cli_new_take` — Manual single-pass reroll.  →  `python3 recoil/pipeline/cli/generate.py --project P --episode N --pass PASS_X --new-take [--strategy NAME] [--seed N] [--make-primary]`
- `breakdown_extract_cli` — Extract/refresh the per-episode mention ledger via extract_mention_ledger; carries forward unchanged scenes by scene_hash.  →  `python3 recoil/pipeline/tools/breakdown_extract_cli.py --project <slug> --episode <N> [--dry-run]`
- `gen_sublocations_cli` — SOLE writer of base/location.json sublocation registry (post-2026-06-11 retirement of other writers); generates derived sublocation refs and reconciles source_sha256 to bible description hashes.  →  `python3 recoil/pipeline/tools/gen_sublocations.py --project <slug> --location <id> [--probe --dry-run --adjacency-confirm --restamp]`
- `prep_character_angles_cli` — ref_promotion mechanism #1 — process_intake copies a hero to assets/char/<slug>/hero.<ext> (subject top-level, NOT base/pool); then generates angles.  →  `python3 recoil/pipeline/tools/prep_character_angles.py  # Path A: rotate an intake/MJ hero into angle refs`

## Flags (fork behavior)
- `board_model_override` — forks `prompt_building` (default: gpt-image-2)
- `use_composite_sheets` — forks `ref_resolution` (default: False)
- `spatial_override` — forks `payload_assembly` (default: False)
- `world_state_pass` — forks `prompt_building` (default: False)
- `eventbus_enabled` — forks `dispatch_entry_point` (default: False)
- `provider_override` — forks `worksurface_execution` (default: None)
- `provider_tier` — forks `worksurface_execution` (default: default)
- `grouping_strategy` — forks `orchestration` (default: coverage)
- `force_new_take` — forks `scene_beat_take` (default: False)
- `skip_flash_enrichment` — forks `prompt_building` (default: False)
- `has_end_frame` — forks `prompt_building` (default: False)
- `story_gate_mode` — forks `orchestration` (default: off)

## Loops (retry / reroll / strategy)
| loop | kind | trigger | bound | primary exit |
|---|---|---|---|---|
| board_model_fallback_retry | retry | primary board dispatch RunResult is a refusal/422 (is_board_refusal) | 1 | fallback dispatch succeeds → proceed (sidecar fallback_from set) |
| prose_author_verify_retry | retry | authored prose fails verify_authored_prose with a BLOCKING result | 3 | verify passes (no blocking result) AND prompt within provider cap → bind + return |
| prompt_cap_reauthor_retry | retry | bound prompt length > provider max_prompt_chars cap | 3 | len(prompt_text) <= cap → break, return authored prompt |
| author_call_breaker | convergence | AuthorCallError (prose-author transport/auth failure) raised in the attempt loop | 3 | any non-author_call success path → _reset_breaker_state() zeroes the counter |
| poll_until_terminal | poll | job submitted; status not in {COMPLETED, FAILED, CANCELLED} | 1800 | status==COMPLETED → finalize |
| flora_orphan_recovery | poll | Flora-only: poll TIMEOUT while still RUNNING, OR FAILED with no provider error, OR COMPLETED with no video_url (_is_flora_recovery_candidate + _should_recover_*). | 1200 | COMPLETED+video_url → finalize_completed |
| keyframe_gate_retry | retry | keyframe gate verdict not passed AND verdict.retriable AND attempt < max_gate_retries | 3 | all gates pass → keyframe_generated |
| keyframe_feedback_retry | strategy | keyframe gate failed AND a retry attempt remains — FeedbackAgent.diagnose() consulted before re-render | 3 | fix applied → continue retry (negative-prompt / ref-prune) |
| beat_take_retry | retry | Take fails (or no primary_take yet) inside _dispatch_one_beat; loop while not beat.is_exhausted and not beat.approved and not strategy_exhausted. | 3 | beat.approved or beat.is_exhausted (max_takes reached → max_takes_reached) |
| strategy_retry | strategy | With a StrategyEngine injected, a failed Take is classified → StrategyEngine.select_and_apply → StrategyDiff mutates the next workflow. | 3 | strategy set exhausted (_strategy_exhausted) |
| dispatch_retry_backoff | retry | A dispatched shot fails; classify_failure decides retryable vs permanent, then queue with exponential backoff. | per-shot attempt count vs policy max | classify_failure → permanent (returns None) |
| flash_enrichment_lockterm_retry | retry | enrich_prompt: Flash output dropped a locked_term (missing != []) | 1 | all locked_terms present → return (enriched, 'v{version}') |
| beat_take_loop | retry | A Take fails (status != succeeded) and the Beat has no primary_take; loop attempts the next Take. | 3 | beat.primary_take set (a Take succeeded → primary selected) |
| strategy_selection_loop | strategy | A failed Take is classified by detect_failure_mode; StrategyEngine.select_and_apply picks the next strategy off the failure-mode escalation chain (learned reorder or static chain). | 6.0 | projected cumulative_retry_cost > max_retry_spend_usd for every remaining strategy → _escalate_to_human_diff |
| board_autoreroll_loop | reroll | A storyboard strip gets a HARD story-gate verdict that is rerollable (fix-notes present); fix-notes are fed back and the board is regenerated. | 3 | _attempt_is_clean → selected, status approved |
| phantom_recovery_loop | retry | invalidate_phantom_succeeded_takes finds a status=succeeded Take whose artifact is missing/evicted; _demote_take(phantom=True) demotes it and grants one extra take slot. | 2 | phantom_recovery_count >= _MAX_PHANTOM_RECOVERIES → grant_phantom_recovery returns False (no further extension) |
| budget_kill_switch | convergence | Before each non-dry-run Take attempt, _estimate_take_cost is reserved via would_exceed; over-cap raises BudgetExhaustedError (this is a HALT, not a retry — the outer bound on all loops above). | 50.0 | would_exceed(est) True (or est<=0 and spent>=budget) → BudgetExhaustedError → scene saved + halted |

## Phases (spine)
`script_breakdown → asset_prep → scene_assembly → storyboard → payload_dispatch → generation → review_reroll`
- `script_breakdown`: Episode script (ep_NNN.md) is broken down into the per-episode mention ledger — the scene→ location/sublocation/prop/wardrobe/character mentions that downstream ref resolution reads.
- `asset_prep`: Reference assets are produced + promoted: character angle sheets, composite ref SHEETS, and the sublocation/location registry.
- `scene_assembly`: Shots are clustered into scenes/beats/takes by the grouping strategy and persisted as the scene SSOT (BATCH/ONER ids).
- `storyboard`: Storyboard generation: a pencil-tier strip is proposed + story-gated, then (on approval) a full-size photoreal finish.
- `payload_dispatch`: Typed payload assembly + the single dispatch entry point: refs collected (composite sheets for video), prompt authored, payload validated, modality runner resolved.
- `generation`: Generation execution: StepRunner drives the provider adapters (Flora primary / fal / atlas) to render video takes + keyframes; polls to terminal; writes takes + sidecars.
- `review_reroll`: Takes are story-gated / human-reviewed; failures re-enter via the reroll surface + the strategy engine (structured retry under the budget guard) until approved or exhausted.

## Capabilities (canonical home + lifecycle — SSOT: ssot_manifest.yaml)
- `ref_resolution` → recoil/core/ref_resolver.py::resolve_reference_bundle  [transitioning]
   deprecated: ProjectPaths.resolve_ref, resolve_character_refs, resolve_location_refs, resolve_prop_refs
- `board_review_comments` → recoil/workspace/board_comments.py  [active]
- `payload_assembly` → recoil/pipeline/_lib/dispatch_payload.py::build_dispatch_payload  [active]
- `dispatch_entry_point` → recoil/pipeline/core/dispatch.py::dispatch  [active]
   deprecated: ProductionLoop, run_episode.py, ClientSequenceRunner
- `modality_registry` → recoil/pipeline/core/registry.py::RunnerRegistry  [active]
- `prompt_building` → recoil/pipeline/_lib/prompt_engine.py  [active]
- `worksurface_execution` → recoil/execution/providers/flora.py::FloraAdapter  [active]
- `typed_payload` → recoil/execution/providers/payload_hints.py::StepRunnerHints  [transitioning]
- `orchestration` → recoil/pipeline/orchestrator/episode_runner.py::EpisodeRunner.run_episode_batches  [active]
- `scene_beat_take` → recoil/pipeline/core/take.py  [active]
- `workflow_take_model` → recoil/pipeline/core/workflow.py  [active]
- `strategy_engine` → recoil/pipeline/orchestrator/strategy_registry.py::StrategyEngine  [active_with_known_wire_gaps]
- `budget_guard` → recoil/pipeline/_lib/budget_manager.py::BudgetGuard  [active]
- `output_layout` → recoil/core/paths.py::ProjectPaths  [active]
   deprecated: output/, sequences/
- `project_path_construction` → recoil/core/paths.py::ProjectPaths  [active]
   deprecated: project_refs_dir, project_output_dir, ProjectPaths.asset_kind_dir, ProjectPaths.sequences_dir
- `ref_promotion` → ?  [KNOWN-BROKEN]
