# Recoil engine — Claude-Design Atlas brief (AUTO-GENERATED — do not hand-edit)

Generated by `build_topology.py` from the topology v2 SSOT (`recoil/architecture/topology/`). The Claude-Design Atlas consumes the files below as a generated projection — it must NEVER hand-copy node data (that re-forks the graph). Regenerate with `build_topology.py --write`.

## Atlas bundle — which generated file feeds which tab

| Atlas tab | Source (`generated/`) | Altitude |
|---|---|---|
| Investor glance | `topology.investor.md` | phase spine, no symbols |
| Design brief (this doc) | `topology.design_brief.md` | capability + divergences, narrative |
| Technical flowchart | `topology.engineering.md` | per-route + Mermaid fork map |
| Teardown / interactive | `atlas.render.json` (built by tools/build_atlas_graph.py from topology.full.json + _render_overlay.yaml) | all render nodes |

## Pipeline 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. Feeds both board and video ref resolution. (Currently fragmented across three promotion writers — see ref_promotion_three_writers divergence.)
- **Scene Assembly** — Shots are clustered into scenes/beats/takes by the grouping strategy and persisted as the scene SSOT (BATCH/ONER ids). Board + video both re-read these persisted scenes.
- **Storyboard** — Storyboard generation: a pencil-tier strip is proposed + story-gated, then (on approval) a full-size photoreal finish. Board CHAR/prop refs are per-kind individual; the LOCATION ref is the composite SHEET when locked + use_composite_sheets on (REC-213 C3, same resolve_sheet_asset route as video — ref_system_split).
- **Payload Dispatch** — Typed payload assembly + the single dispatch entry point: refs collected (composite sheets for video), prompt authored, payload validated, modality runner resolved. Emits the receipt log.
- **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.

## Coverage

- 16 of 27 capabilities mapped · 21 entrypoints · 17 feedback loops · 19 architectural divergences.
- In-flight (not yet modeled): atom_read_model, board_read_model, composition_manifest, director_notes_ledger, dispatch_dashboard_render, per_atom_regeneration, plan_overrides, scene_active_body_persistence, scene_version_manifest_writer, scene_version_read_model, script_edit_snapshot.

## Load-bearing divergences (forks a redesign must not silently break)

Each is a capability that forks by surface/flag; the **invariant** is the property that must hold across both routes. Full route detail is in the technical/teardown tabs.

### board_two_stage_split  ·  capability: prompt_building
- risk: An engineer wiring "render the board" could skip the pencil approval gate and dispatch a full-size finish directly, bypassing StoryGate + human approval and the spend gate (preferred_board_artifact picks photoreal only after approval).

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

- routes: `board/pencil_halfsize` vs `board/photoreal_fullsize`

### modality_runner_fork  ·  capability: modality_registry
- risk: An engineer adding a new video sub-modality could register a parallel runner or call execute_pass directly, splitting the dispatch surface and dropping the receipts.jsonl audit line + aspect_ratio/model-profile guards silently.

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

- routes: `video/individual` vs `r2v/individual`

### video_ref_collection_fork  ·  capability: ref_resolution
- risk: With sheets enabled the broken pool-scan fallback never fires, so the resolver bug stays invisible in production; with sheets disabled the same shot silently degrades to angle refs the broken resolver may not find. Two ref shapes for one surface, switched by an env var / project config.

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

- routes: `video/composite_sheet` vs `video/individual`

### prompt_authoring_fork  ·  capability: prompt_building
- risk: Authored prose forks (directed_prose vs shot_spec, ±world_state_pass) reach different verify/cap loops; a strategy added without a deterministic floor would let an author outage block production instead of degrading, or let the breaker counter leak and false-halt a healthy run. (forks listed are the two ref routes the prompt is bound against — the authoring fork rides the same _build_author_aware_prompt junction; the load-bearing record is the invariant.)

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

- routes: `video/authored_prose` vs `video/deterministic_template`

### fallback_declared_not_wired  ·  capability: worksurface_execution
- risk: REC-122 — the old "retrying on fallback" log wording claimed a dispatch that never happened, sending 2026-06-09 EP001 debugging to fal for requests that were never made. provider_strategy.json declares fallback=fal for every seeddance entry; an engineer trusting the strategy file assumes fal coverage on flora failure that does not exist.

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

- routes: `video/provider_primary` vs `video/provider_fallback`

### seedance_provider_three_way  ·  capability: worksurface_execution
- risk: Same payload, three transports with different ref-upload + cost models; only Flora has orphan-recovery wiring (flora_orphan_recovery loop is _is_flora_recovery_candidate-gated, so a fal/atlas timeout has NO recovery). A silent override flips the whole run's provider, cost model, and recovery behavior with no payload-visible signal.

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

- routes: `video/provider_primary` vs `video/provider_fallback` vs `video/provider_override`

### payload_hints_dict_vs_typed  ·  capability: typed_payload
- risk: typed_payload is transitioning (expires 2026-06-30) and rides seed/tier to Flora via extra='allow'; untyped keys that bypass StepRunnerHints.model_fields silently vanish at the StepRunner→adapter seam with no validation error.

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

- routes: `video/i2v_t2v_v2v` vs `r2v/composite_sheet`

### multi_shot_native_vs_sequential  ·  capability: modality_registry
- risk: The native kling_rest branch is retired to a fail-loud raise: a caller targeting the native Kling multi-shot path gets NotImplementedError instead of a silent shot-drop. The working route is the sequential fallback. CP-3 revisits native multi-shot once UnifiedVideoPayload.hints['multi_shots'] is wired through KlingAdapter.

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

- routes: `video/i2v_t2v_v2v` vs `video/endpoint_table`

### action_inference_per_adapter  ·  capability: worksurface_execution
- risk: Three separate shape→action mappers over one UnifiedVideoPayload; a field added to the payload (e.g. a new ref slot) must be taught to every adapter's inferer or it silently routes to the wrong endpoint. Divergent f2v support means the same payload yields different actions on flora vs fal.

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

- routes: `video/i2v_t2v_v2v` vs `image/individual`

### grouping_strategy_fork  ·  capability: orchestration
- risk: An engineer adding a grouping strategy or rerolling under the wrong --grouping can mismatch the selector→scene mapping (CONT→BATCH vs ONER→ONER), feed the wrong persisted beat to dispatch, or silently flip modality (r2v_multi↔video_i2v) and lose the [Xs-Ys] batch timestamp annotations.

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

- routes: `coverage/coverage_pass` vs `continuity/batch` vs `oner/oner`

### batch_modality_fork  ·  capability: scene_beat_take
- risk: single_batch_from_shots deliberately DEFEATS the below_threshold/heuristic splitting (a coverage pass is one billed Flora run regardless of shot count), so the two clusterers diverge on whether to fall back. Mixing them — using cluster_shots_into_batches where a deliberate pass-as-batch was intended, or vice versa — silently changes batching, billing, and modality.

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

- routes: `continuity/batch` vs `continuity/per_shot_i2v`

### scene_version_append  ·  capability: orchestration
- risk: A re-derivation writer that OVERWRITES an existing version's shot structure, or any code that moves the pointer outside conform/revert, silently destroys approved shot structure — the exact loss this design prevents.

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

- routes: `coverage/coverage_pass` vs `reroll/persisted_scene_id`

### builder_dispatch_table  ·  capability: prompt_building
- risk: The table aliases distinct models onto shared builders (happy-horse-* → build_wan_*, gpt-image-2 → build_previs_prompt) whose token syntax differs (HappyHorse uses character1/character2 positional, not @Image1). An engineer adding a model by reusing a builder slot can ship a syntactically-wrong prompt that passes get_builder and only fails at the provider. The build_seeddance_i2v_multishot_prompt key is intentionally OMITTED — a tools/ import references a non-existent symbol (single-d "seedance" typo).

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

- routes: `video/composite_sheet` vs `video/composite_sheet` vs `storyboard/individual` vs `previz/individual`

### prompt_length_split  ·  capability: prompt_building
- risk: REC-123: Flora's preprocessing silently REPLACES any >2500-char prompt with '. _ .' (blankPrompt) and bills the run with no prompt. flora.py fails loud to prevent the silent burn — but if _enforce_prompt_length truncates a seedance prompt to a bible max_chars that is LARGER than 2500, the run reaches flora.py over-length and aborts; if smaller, the cinema tail is lopped. The two limits live in different files and can drift apart unnoticed.

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

- routes: `video/composite_sheet` vs `video/composite_sheet`

### name_binding_modality_fork  ·  capability: prompt_building
- risk: A2 content-policy leak: single-shot i2v/r2v had never run through the name-strip ("Option C") path. Mixing the two contracts — emitting @ImageN tokens on the i2v path, or leaving a character name un-stripped on either — fails the bind assertion at build time, but a builder change that routes i2v prose through the r2v binder would inject illegal @ImageN tokens that the provider mis-maps.

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

- routes: `r2v/composite_sheet` vs `video/composite_sheet`

### ref_system_split  ·  capability: ref_resolution
- risk: PR#135 — the board path fed individual plates while video uses composite SHEETS; an engineer extending board refs could not see the split, fed the wrong location ref, and mis-injected the debt_counter WARDROBE item as a prop.
- 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.
- routes: `board/individual` vs `video/composite_sheet`

### board_reroll_story_gate_fork  ·  capability: orchestration
- risk: An engineer enabling story_gate_mode='enforce' expecting stricter boards instead crashes the build (raises in StoryGate.__init__ and in build_with_auto_reroll's mode check). And board generation silently has NO auto-reroll whenever mode is 'off' (the default) — HARD board failures pass through un-rerolled.
- 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.
- routes: `board/board_rerolled` vs `board/board`

### ref_promotion_three_writers  ·  capability: ref_promotion
- risk: ssot_manifest marks ref_promotion canonical:null KNOWN-BROKEN. THREE incompatible mechanisms (asset_ops.set_hero copies, ref_image_ops.promote_grid copies w/ dual names, prep_character_angles renames) all promote toward subject top-level with non-conformant names; production resolver does not read the pool nor the top level it expects → promoted refs are silently orphaned. An operator promoting a hero sees success but production never picks it up.
- 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).
- routes: `promotion/top_level_subject_dir` vs `production/base_pool_dir`

### location_sheets_path_split  ·  capability: project_path_construction
- risk: RESOLVED by C1+C2: the per-kind difference is intentional and SSOT'd in ProjectPaths.sheet_path. Note loc composite sheets (sheets/) still live under a different parent than the loc registry (base/location.json) — a layout fact, no longer a code-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.
- routes: `char/char_base_sheets` vs `loc/loc_sheets`
