# dispatch_payload — CanonicalShot → unified payload → dispatch() → runner.
# Three capabilities meet here:
#   - payload_assembly      (build_dispatch_payload → build_unified_payload):
#     translate a CanonicalShot into the modality-keyed dispatch payload dict,
#     resolving prompt (author-or-deterministic) + refs (sheets-or-two-pass).
#   - dispatch_entry_point   (core/dispatch.py::dispatch): the CP-5 single
#     dispatch surface — validate, bootstrap runners, runner.run(), wrap a
#     GenerationReceipt, emit the receipts.jsonl audit line.
#   - modality_registry      (RunnerRegistry + register_default_runners): which
#     runner each modality string resolves to; THE fork point video_i2v vs
#     r2v_multi (the latter BYPASSES build_dispatch_payload's per-shot path via
#     StepRunner.execute_pass).
# Capability state/canonical/deprecated live in ssot_manifest.yaml — FK only here.
subsystem: dispatch_payload

capabilities:
  - id: payload_assembly
    risk_level: high
  - id: dispatch_entry_point
    risk_level: critical
  - id: modality_registry
    risk_level: high
  # ref_resolution capability declared in _capabilities.topo.yaml (FK seed); this file
  # references it via routes/divergences only. prompt_building declared in this file.
  - id: prompt_building       # FK — author_pass / deterministic template fork lives here
    risk_level: medium

entrypoints:
  - id: dispatch
    file: recoil/pipeline/core/dispatch.py::dispatch
    symbol: dispatch
    invoke: "from recoil.pipeline.core.dispatch import dispatch; dispatch(modality, payload, context=ctx)"
    kind: module
    summary: >
      CP-5 single generation entry point. Lazy-bootstraps runners per StepRunner
      (_ensure_bootstrap), _validate_payload (modality-keyed contract guard),
      injects aspect_ratio from Project SSOT, runs model-profile constraint check,
      calls runner.run(payload), wraps RunResult in a GenerationReceipt, and emits
      the receipts.jsonl audit line. Every generation call goes through here EXCEPT
      previs and the r2v_multi execute_pass body (see divergences).
    capabilities: [dispatch_entry_point, modality_registry]
    produces: [receipts_jsonl]
    risk_level: critical

  - id: audit_dispatch_cli
    file: recoil/pipeline/tools/audit_dispatch.py::main
    symbol: main
    invoke: "python3 -m recoil.pipeline.tools.audit_dispatch --project <slug> --episode ep_NNN"
    kind: cli
    summary: >
      Pre-spend payload audit. Calls build_dispatch_payload(..., dry_run=True) per
      modality (→ _build_audit_payload) to materialize the payload shape WITHOUT
      authoring prose or spending; the canonical dry-run payload surface. Does NOT
      call dispatch().
    capabilities: [payload_assembly]
    risk_level: medium

symbols:
  - id: build_dispatch_payload
    file: recoil/pipeline/_lib/dispatch_payload.py::build_dispatch_payload
    symbol: build_dispatch_payload
    role: >
      Thin signature-stable wrapper (reimpl 2026-05-25): branches dry_run →
      _build_audit_payload, else validates modality ∈ {video_i2v, r2v_multi},
      builds a PayloadContext, delegates to build_unified_payload. The junction
      every production payload (EpisodeRunner) and audit pass crosses.
    risk_level: high

  - id: build_unified_payload
    file: recoil/pipeline/_lib/dispatch_payload.py::build_unified_payload
    symbol: build_unified_payload
    role: >
      The real assembly body: resolves start_frame, calls _collect_reference_images
      (refs + manifest), drives _build_author_aware_prompt (prompt fork), and emits
      the modality-keyed payload dict. The single fan-in for refs+prompt+routing.
    risk_level: high

  - id: collect_reference_images
    file: recoil/pipeline/_lib/dispatch_payload.py::_collect_reference_images
    symbol: _collect_reference_images
    role: >
      VIDEO-surface ref owner (FK ref_resolution). Tries composite sheets first
      (_collect_sheet_refs → ref_resolver.resolve_sheet_asset per entity); on None,
      runs the two-pass angle collector
      (Pass 1 hero-per-char → location block → Pass 2 round-robin angles, cap 9)
      via resolve_character_bundle / resolve_location_refs. Gated by an
      assert_refs_complete pre-spend gate on every return.
    risk_level: high

  - id: primitive_from_payload_context
    file: recoil/pipeline/_lib/shot_primitive.py::primitive_from_payload_context
    symbol: primitive_from_payload_context
    role: >
      Shared PayloadContext → ShotPrimitive coercion used by BOTH the video path
      (_build_author_aware_prompt) and the board path (board_builder); the prompt
      builder's single shot-model input. A change here forks both surfaces.
    risk_level: high

  - id: dispatch_runner_run
    file: recoil/pipeline/core/dispatch.py::dispatch
    symbol: dispatch
    role: >
      runner = get_runner(modality); runner.run(payload). The modality→runner
      indirection — the point where video_i2v→VideoRunner.run diverges from
      r2v_multi→R2VMultiRunner.run (which delegates to execute_pass, bypassing
      this file's per-shot author/cap path for batch coverage).
    risk_level: critical

routes:
  - id: video_i2v_dispatch_path
    capability: dispatch_entry_point
    surface: video
    entry: recoil/pipeline/core/dispatch.py::dispatch
    resolves_via: recoil/pipeline/core/runners/video_runner.py::VideoRunner.run
    ref_kind: individual
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: r2v_multi_execute_pass_path
    capability: modality_registry
    surface: r2v
    entry: recoil/pipeline/core/dispatch.py::dispatch
    resolves_via: recoil/pipeline/core/runners/r2v_multi_runner.py::R2VMultiRunner.run
    ref_kind: individual
    gated_by: null
    # R2VMultiRunner.run unpacks the payload and calls StepRunner.execute_pass
    # directly — coverage-pass generation that does NOT re-enter build_dispatch_payload.
    fallback_route: null
    risk_level: high

  - id: composite_sheet_ref_path
    capability: ref_resolution
    surface: video
    entry: recoil/pipeline/_lib/dispatch_payload.py::_collect_reference_images
    resolves_via: recoil/core/ref_resolver.py::resolve_sheet_asset
    ref_kind: composite_sheet
    gated_by: use_composite_sheets
    fallback_route: two_pass_angle_ref_path
    risk_level: high

  - id: two_pass_angle_ref_path
    capability: ref_resolution
    surface: video
    entry: recoil/pipeline/_lib/dispatch_payload.py::_collect_reference_images
    resolves_via: recoil/core/ref_resolver.py::resolve_character_bundle
    ref_kind: individual
    gated_by: null
    fallback_route: null
    risk_level: high

  # prompt authoring: the authored-prose strategy path vs the deterministic-template floor.
  - id: author_strategy_route
    capability: prompt_building
    surface: video
    entry: recoil/pipeline/_lib/dispatch_payload.py::_build_author_aware_prompt
    resolves_via: recoil/pipeline/_lib/author_strategies.py::resolve_strategy
    ref_kind: authored_prose          # directed_prose / shot_spec via author_pass; ±world_state_pass
    gated_by: null
    fallback_route: deterministic_template_route
    risk_level: high

  - id: deterministic_template_route
    capability: prompt_building
    surface: video
    entry: recoil/pipeline/_lib/dispatch_payload.py::_build_author_aware_prompt
    resolves_via: recoil/pipeline/_lib/dispatch_payload.py::_build_deterministic_template_prompt
    ref_kind: deterministic_template  # the floor EVERY author failure converges onto (except the 3x breaker)
    gated_by: null
    fallback_route: null
    risk_level: high

flags:
  - id: use_composite_sheets
    defined: recoil/pipeline/_lib/dispatch_payload.py::composite_sheets_enabled
    default: false
    forks_capability: ref_resolution
    gates_routes: [composite_sheet_ref_path, board_location_sheet_route]
    summary: >
      Env RECOIL_USE_COMPOSITE_SHEETS=1 (wins) OR project_config.use_composite_sheets:true
      → video ref collection returns composite SHEETS, AND a LOCKED board's LOCATION ref
      becomes the composite SHEET (REC-213 C3, board_location_sheet_route); else two-pass
      angle refs / the sublocation plate. ONE public activation gate (composite_sheets_enabled)
      shared by both surfaces — the single activation SSOT (no board/video fork).

  - id: spatial_override
    defined: recoil/pipeline/_lib/dispatch_payload.py::_verify_spatial_pre_spend
    default: false
    forks_capability: payload_assembly
    gates_routes: []
    summary: >
      RECOIL_SPATIAL_OVERRIDE=1 downgrades REC-111 spatial pre-spend BLOCK→WARN
      (logged loudly) instead of raising DispatchPayloadError. r2v_multi only, when
      primitive.location_id is set.

  - id: world_state_pass
    defined: recoil/pipeline/_lib/dispatch_payload.py::_build_author_aware_prompt
    default: false
    forks_capability: prompt_building
    gates_routes: []
    summary: >
      RECOIL_WORLD_STATE_PASS=1 → _inject_world_state_settings clones the primitive
      with derived per-segment settings before author_pass; r2v_multi + shot_spec only.

  - id: eventbus_enabled
    defined: recoil/pipeline/core/dispatch.py::_emit_eventbus_hook
    default: false
    forks_capability: dispatch_entry_point
    gates_routes: []
    summary: >
      RECOIL_EVENTBUS_ENABLED=1 → dispatch() emits to recoil.api.eventbus.BUS after
      each receipt. Default OFF keeps the engine→api import edge out of the default
      env. No-op-on-failure; never blocks dispatch.

loops:
  - id: prose_author_verify_retry
    kind: retry
    capability: prompt_building
    trigger: "authored prose fails verify_authored_prose with a BLOCKING result"
    driver: recoil/pipeline/_lib/dispatch_payload.py::_build_author_aware_prompt
    bound:
      max: 3
      source: "max_attempts = 3 if provider cap is not None else 2"
    exit:
      - "verify passes (no blocking result) AND prompt within provider cap → bind + return"
      - "attempt >= max_attempts on verify BLOCK → deterministic('verify_block') fallback"
    retries_into: prose_author_verify_retry
    risk_level: medium

  - id: prompt_cap_reauthor_retry
    kind: retry
    capability: prompt_building
    trigger: "bound prompt length > provider max_prompt_chars cap"
    driver: recoil/pipeline/_lib/dispatch_payload.py::_build_author_aware_prompt
    bound:
      max: 3
      source: "max_attempts (same loop as verify retry; cap-present ⇒ 3)"
    exit:
      - "len(prompt_text) <= cap → break, return authored prompt"
      - "attempt >= max_attempts still over cap → raise PromptTooLongError"
    retries_into: prompt_cap_reauthor_retry
    risk_level: high

  - id: author_call_breaker
    kind: convergence
    capability: prompt_building
    trigger: "AuthorCallError (prose-author transport/auth failure) raised in the attempt loop"
    driver: recoil/pipeline/_lib/dispatch_payload.py::_reset_breaker_state
    bound:
      max: 3
      source: "module-global _consecutive_author_call_failures >= 3"
    exit:
      - "any non-author_call success path → _reset_breaker_state() zeroes the counter"
      - "3rd consecutive AuthorCallError → raise DispatchPayloadError (halt before further spend)"
      - "< 3 consecutive → deterministic('author_call') fallback for this shot"
    retries_into: author_call_breaker
    risk_level: high

artifacts:
  - id: receipts_jsonl
    path: "$RECOIL_ROOT/_dispatch_logs/receipts.jsonl"
    written_by:
      - recoil/pipeline/core/dispatch.py::_emit_receipt_jsonl
    read_by: []
    schema: recoil/pipeline/core/receipts.py::GenerationReceipt
    risk_level: medium

divergences:
  - id: modality_runner_fork
    capability: modality_registry
    forks: [video_i2v_dispatch_path, r2v_multi_execute_pass_path]
    on_flag: null
    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.
    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.
    reduced_tier: always
    risk_level: critical

  - id: video_ref_collection_fork
    capability: ref_resolution
    forks: [composite_sheet_ref_path, two_pass_angle_ref_path]
    on_flag: use_composite_sheets
    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.
    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.
    reduced_tier: always
    risk_level: high

  - id: prompt_authoring_fork
    capability: prompt_building
    forks: [author_strategy_route, deterministic_template_route]
    on_flag: null
    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.
    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.)
    reduced_tier: always
    risk_level: high
