# execution/ layer — StepRunner emits ONE unified payload; VideoModelClient drives
# submit/poll; the provider adapter registry routes (model_id, payload) → adapter+tier.
# THE forcing case here is provider routing: the SAME model_id (seeddance-2.0) reaches
# flora / fal / atlas by strategy + env, and a "fallback" that is DECLARED but NOT WIRED
# (resolve_fallback returns an adapter; _finalize_failed logs + fails — REC-122).
#
# Pipeline boundary: dispatch()/runners (dispatch_entry_point capability, modality_registry)
# call into StepRunner. StepRunner.execute_* build a unified dict payload, hand it to
# VideoModelClient (video/r2v) or resolve_adapter().direct_submit_image (image/previz),
# then poll + save + gate. Provider quirks (base64 vs file vs URL, action/endpoint forks)
# live IN the adapters, never in StepRunner.
#
# Capability state/canonical/deprecated live in ssot_manifest.yaml — FK by id only:
#   worksurface_execution (canonical FloraAdapter), typed_payload (transitioning,
#   expires 2026-06-30, StepRunnerHints), dispatch_entry_point, modality_registry.
subsystem: execution_providers

capabilities:
  - id: worksurface_execution        # FloraAdapter — decided execution target
    risk_level: high
  - id: typed_payload                # StepRunnerHints — transitioning, expires 2026-06-30
    reduced_tier: always
    risk_level: high
  # dispatch_entry_point + modality_registry capabilities declared in
  # dispatch_payload.topo.yaml (their canonical home); referenced here via routes.

entrypoints:
  # generate_cli entrypoint declared once in orchestration.topo.yaml (canonical live entry).
  - id: step_runner
    file: recoil/execution/step_runner.py::StepRunner
    symbol: StepRunner
    invoke: "from recoil.execution.step_runner import StepRunner"
    kind: module
    summary: >
      Single source of truth for all generation execution. Synchronous, thread-safe
      (store mutations via ExecutionStore lock). execute_video (I2V/T2V/V2V),
      execute_pass (SeedDance R2V coverage), execute_keyframe (image t2i/i2i/is2i + gate
      retry loop), execute_previz, execute_wan_r2v, execute_multi_shot. Emits ONE unified
      dict payload; provider quirks live in adapters.
    capabilities: [typed_payload, worksurface_execution]
    risk_level: high

  - id: video_model_client
    file: recoil/execution/video_model_client.py::VideoModelClient
    symbol: VideoModelClient
    invoke: "from recoil.execution.video_model_client import VideoModelClient"
    kind: module
    summary: >
      Thin client atop the adapter registry. dict → UnifiedVideoPayload, resolve_adapter,
      submit/poll/finalize, observability rows on every terminal event. On failure it LOGS the
      configured fallback but does NOT auto-dispatch it (see fallback_declared_not_wired).
      Returns a core.jobs.Job.
    capabilities: [worksurface_execution, typed_payload]
    risk_level: high

routes:
  - id: video_dispatch_path
    capability: worksurface_execution
    surface: video
    entry: recoil/execution/step_runner.py::StepRunner.execute_video
    resolves_via: recoil/execution/video_model_client.py::VideoModelClient.submit
    ref_kind: i2v_t2v_v2v
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: pass_r2v_path
    capability: worksurface_execution
    surface: r2v
    entry: recoil/execution/step_runner.py::StepRunner.execute_pass
    resolves_via: recoil/execution/video_model_client.py::VideoModelClient.submit
    ref_kind: composite_sheet        # SeedDance R2V coverage; @Image1..9 / @Video1..3
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: keyframe_image_path
    capability: worksurface_execution
    surface: image
    entry: recoil/execution/step_runner.py::StepRunner.execute_keyframe
    resolves_via: recoil/execution/providers/registry.py::resolve_adapter
    ref_kind: individual             # direct_submit_image — bypasses VideoModelClient submit/poll
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: previz_image_path
    capability: worksurface_execution
    surface: previz
    entry: recoil/execution/step_runner.py::StepRunner.execute_previz
    resolves_via: recoil/execution/providers/registry.py::resolve_adapter
    ref_kind: individual             # 8-slot Google multimodal; direct_submit_image
    gated_by: null
    fallback_route: null
    risk_level: medium

  # ── Provider routes for the SAME video payload (the routing fork) ──────────────
  - id: seedance_via_flora
    capability: worksurface_execution
    surface: video
    entry: recoil/execution/providers/registry.py::resolve_adapter
    resolves_via: recoil/execution/providers/flora.py::FloraAdapter.build_submit
    ref_kind: provider_primary       # provider_strategy.json seeddance-2.0 primary=flora
    gated_by: null
    fallback_route: seedance_via_fal
    risk_level: high

  - id: seedance_via_fal
    capability: worksurface_execution
    surface: video
    entry: recoil/execution/providers/registry.py::resolve_fallback
    resolves_via: recoil/execution/providers/fal.py::FalAdapter.build_submit
    ref_kind: provider_fallback      # DECLARED fallback — NOT inline-wired (REC-122)
    gated_by: null
    fallback_route: null
    risk_level: critical

  - id: seedance_via_atlas
    capability: worksurface_execution
    surface: video
    entry: recoil/execution/providers/registry.py::resolve_adapter
    resolves_via: recoil/execution/providers/atlas.py::AtlasAdapter.build_submit
    ref_kind: provider_override      # RECOIL_PROVIDER_OVERRIDE=atlas forces atlas for ALL models
    gated_by: provider_override
    fallback_route: null
    risk_level: high

  - id: kling_video_path
    capability: worksurface_execution
    surface: video
    entry: recoil/execution/providers/registry.py::resolve_adapter
    resolves_via: recoil/execution/providers/kling.py::KlingAdapter.build_submit
    ref_kind: endpoint_table         # _resolve_endpoint forks i2v/t2v/o3-ref/v2v-edit by hints+model_id
    gated_by: null
    fallback_route: null
    risk_level: high

loops:
  - id: poll_until_terminal
    kind: poll
    capability: worksurface_execution
    trigger: "job submitted; status not in {COMPLETED, FAILED, CANCELLED}"
    driver: recoil/execution/video_model_client.py::VideoModelClient.wait_for_job
    bound:
      max: 1800                      # timeout_s; 720 for wan-*, 900 for execute_pass r2v
      source: "step_runner timeout_s arg (model-dependent)"
    exit: ["status==COMPLETED → finalize", "status==FAILED → finalize_failed", "timeout → TIMEOUT + best-effort cancel"]
    reads_artifact: observability_log
    risk_level: high

  - id: flora_orphan_recovery
    kind: poll
    capability: worksurface_execution
    trigger: >
      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_*).
    driver: recoil/execution/video_model_client.py::VideoModelClient._recover_orphaned_run
    bound:
      max: 1200                      # RECOVERY_DEADLINE_S; RECOIL_ORPHAN_RECOVERY_S override
      source: "_orphan_recovery_deadline_s()"
    exit: ["COMPLETED+video_url → finalize_completed", "terminal FAILED → finalize_failed", "deadline → ORPHANED (recoverable server-side)"]
    retries_into: poll_until_terminal
    risk_level: high

  - id: keyframe_gate_retry
    kind: retry
    capability: worksurface_execution
    trigger: "keyframe gate verdict not passed AND verdict.retriable AND attempt < max_gate_retries"
    driver: recoil/execution/step_runner.py::StepRunner.execute_keyframe
    bound:
      max: 3                         # max_gate_retries default
      source: "execute_keyframe(max_gate_retries=3)"
    exit: ["all gates pass → keyframe_generated", "non-retriable/semantic → keyframe_semantic_failed", "no feedback fix → icu_escalated", "retries exhausted → keyframe_mechanical_failed"]
    risk_level: high

  - id: keyframe_feedback_retry
    kind: strategy
    capability: worksurface_execution
    trigger: "keyframe gate failed AND a retry attempt remains — FeedbackAgent.diagnose() consulted before re-render"
    driver: recoil/execution/feedback/agent.py::FeedbackAgent
    bound:
      max: 3                         # piggybacks keyframe_gate_retry's max_gate_retries
      source: "execute_keyframe gate loop"
    exit: ["fix applied → continue retry (negative-prompt / ref-prune)", "no fix → autopsy + icu_escalated"]
    retries_into: keyframe_gate_retry
    risk_level: medium

flags:
  - id: provider_override
    defined: recoil/execution/providers/registry.py::resolve_adapter
    default: null                    # RECOIL_PROVIDER_OVERRIDE env var (unset)
    forks_capability: worksurface_execution
    gates_routes: [seedance_via_atlas]
    summary: >
      RECOIL_PROVIDER_OVERRIDE forces a single provider_id for ALL models (highest routing
      precedence), still capability-enforced. RECOIL_PROVIDER_MODE=test swaps live adapters
      for providers.testing.mock_* of the same Protocol.
    risk_level: high

  - id: provider_tier
    defined: recoil/execution/providers/registry.py::resolve_adapter
    default: "default"               # caller tier kwarg > RECOIL_PROVIDER_TIER_OVERRIDE > strategy
    forks_capability: worksurface_execution
    summary: >
      Tier is ALWAYS passed as a kwarg from StepRunner (CP-2 edit #7), NOT via env —
      RECOIL_PROVIDER_TIER_OVERRIDE races under ProductionLoop's ThreadPoolExecutor.
      execute_pass defaults tier='standard_720p'; execute_video reads hints['tier'].
    risk_level: medium

artifacts:
  - id: video_take_mp4
    path: renders/ep_NNN/*.mp4
    written_by: [step_runner]
    read_by: [step_runner]
    schema: "canonical video_naming grammar (build_filename)"
    risk_level: medium

  - id: video_sidecar_json
    path: renders/ep_NNN/*.mp4.json
    written_by: [step_runner]
    schema: "populate_sidecar SSOT (recoil/pipeline/_lib/sidecar.py) — refs_used/seed/cost/dispatch_path"
    risk_level: medium

  - id: seeddance_meta_yaml
    path: renders/ep_NNN/{shot_id}_meta.yaml
    written_by: [step_runner]
    schema: "SeedDance generation metadata (model in name, endpoint, prompt, cost, latency)"
    risk_level: low

  - id: keyframe_png
    path: prep/ep_NNN/*.png
    written_by: [step_runner]
    read_by: [step_runner]
    risk_level: low

  - id: boundary_frames
    path: renders/ep_NNN/boundary_frames/*_segNN.jpg
    written_by: [step_runner]
    read_by: [step_runner]
    schema: "ffmpeg -ss per-segment extraction for video identity gates"
    risk_level: low

  - id: observability_log
    path: _pipeline/state/visual/ (provider call rows)
    written_by: [video_model_client]
    schema: "recoil/execution/providers/observability.py::record_call — provider/model/tier/status/cost/latency"
    risk_level: medium

divergences:
  - id: fallback_declared_not_wired
    capability: worksurface_execution
    forks: [seedance_via_flora, seedance_via_fal]
    on_flag: null
    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.
    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.
    reduced_tier: always
    risk_level: critical

  - id: seedance_provider_three_way
    capability: worksurface_execution
    forks: [seedance_via_flora, seedance_via_fal, seedance_via_atlas]
    on_flag: provider_override
    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.
    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.
    reduced_tier: always
    risk_level: high

  - id: payload_hints_dict_vs_typed
    capability: typed_payload
    forks: [video_dispatch_path, pass_r2v_path]
    on_flag: null
    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).
    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.
    reduced_tier: always
    risk_level: high

  - id: multi_shot_native_vs_sequential
    capability: modality_registry
    forks: [video_dispatch_path, kling_video_path]
    on_flag: null
    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.
    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.
    reduced_tier: always
    risk_level: medium

  - id: action_inference_per_adapter
    capability: worksurface_execution
    forks: [video_dispatch_path, keyframe_image_path]
    on_flag: null
    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.
    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.
    reduced_tier: always
    risk_level: medium

symbols:
  - id: dict_to_unified
    file: recoil/execution/video_model_client.py::_dict_to_unified
    symbol: _dict_to_unified
    role: "The dict→UnifiedVideoPayload coercion seam — every StepRunner payload crosses here; the typed-vs-dict hints fork lives at this boundary."
    reduced_tier: always
    risk_level: high

  - id: resolve_adapter
    file: recoil/execution/providers/registry.py::resolve_adapter
    symbol: resolve_adapter
    role: "THE routing junction: (model_id, payload, tier) → (adapter, tier) under fixed precedence (override > exceptions > primary). Capability-enforces; raises ProviderCapabilityError loud."
    reduced_tier: always
    risk_level: high

  - id: resolve_fallback
    file: recoil/execution/providers/registry.py::resolve_fallback
    symbol: resolve_fallback
    role: "Returns the DECLARED fallback adapter — but its result is logged, not dispatched, by _finalize_failed (the fallback_declared_not_wired divergence)."
    reduced_tier: always
    risk_level: critical

  - id: submit_via_sentinel
    file: recoil/execution/video_model_client.py::VideoModelClient._submit_via
    symbol: _submit_via
    role: "Sentinel-URL interception: fal-sdk:// (Wan i2v/r2v) and googleapi:// (image/veo) bypass HTTP transport and call adapter.direct_subscribe_*/direct_submit_* instead — without this branch urllib crashes on dispatch."
    risk_level: high
