# Area Frame Spatial Contract Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `shot_staging` LLM 이 필요한 shot 에만 emit 하는 gated `frame_spatial_contract` (object/null) 를 도입하고, `render_prompt_card` 가 deterministic constraint_id (`fsc_001~003`) 부여 + visible_entities cross-check, `scene_detail` 이 모든 variation 의 첫 문장에 contract 반영 + per-variation echo 강제 (1 retry).

**Architecture:** 4 layer (L1 shot_staging LLM 의미 판단 / L2 render_prompt_card code id+cross-check / L3 scene_detail LLM wording / L4 scene_detail code echo gate). 새 helper module `frame_spatial_contract.py` 가 5 함수 (find/validate_and_prepare/validate_echoes/phrase_diagnostic/_format_retry_hint) + 4 상수 caller 분리. 6 task 시퀀셜.

**Tech Stack:** Python 3.11, pytest, AppError (`backend/app/core/errors.py`), prompt_loader (`backend/app/modules/llm/prompt_loader.py`), JSON schema (provider 1차 차단), shot_staging.py orientation pattern mirror.

**Spec reference:** [`docs/superpowers/specs/2026-05-14-frame-spatial-contract-design.md`](../specs/2026-05-14-frame-spatial-contract-design.md)

**baseline HEAD:** `a9bf378` (spec amend 후).

**Subagent model policy:** 모든 subagent dispatch 는 `model=opus` (Opus 4.7). cheap-model 최적화 금지 ([[feedback_subagent_model_opus]] 정합).

**Naming lock:** 모든 code/test/commit/prompt 이름에 `frame_spatial_contract` 단일 표기. `screen_blocking` / `spatial_contract` 혼용 금지 (spec §1 비협상 #1). 단 legacy memo `[[next_session_screen_blocking]]` 의 이름은 변경 안 함.

---

## File Structure

### Production code

| 파일 | 변경 | Task |
|---|---|---|
| `backend/app/core/frame_spatial_contract.py` | **신규** — 5 함수 + 4 상수 helper module | T1 |
| `backend/app/modules/pipeline/shot_staging.py` | retry loop (`:203~`) 안 post-validation 추가 (orientation pattern mirror, try 외부 raise) | T3 |
| `backend/app/core/steps/render_prompt_card.py` | (a) `build_render_strategy()` inject (`:883~`) (b) `assert_card_shape()` inline nullable shape (`:2578~`) (c) `canonicalize_render_prompt_card()` constraints sort (`:2222~`) | T4 |
| `backend/app/core/steps/detail_steps.py` | `SCENE_DETAIL_SCHEMA_VERSION` 8→9 + `SCENE_DETAIL_PROMPT_VERSION = "23.<ts>"` + `_check_prompts` correction retry path 안 echo 검증 통합 (`:2568~`) | T5, T6 |
| `backend/app/core/step_manifest.py` | shot_staging entry (`:587~599`) 에 `schema_version` 추가 + scene_detail (`:704`) 8→9 | T2, T5 |
| `backend/app/core/version_registry.py` | shot_staging "2.2.0"→"2.3.0" + scene_detail_composer "1.22.0"→"1.23.0" | T2, T5 |

### Prompt pack (신규)

| 파일 | Task |
|---|---|
| `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/schema.json` | T2 |
| `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/system.md` | T2 |
| `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/detail_schema.json` | T5 |
| `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/system.md` | T5 |

### Tests

| 파일 | 변경 | Task |
|---|---|---|
| `backend/tests/unit/test_frame_spatial_contract.py` | **신규** — 15 tests (Group A) | T1 |
| `backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py` | **신규** — 4 tests (Group B) | T3 |
| `backend/tests/unit/test_render_prompt_card.py` | 추가 — 3 tests (Group C-1) | T4 |
| `backend/tests/unit/test_render_prompt_card_hash.py` | 추가 — 2 tests (Group C-2) | T4 |
| `backend/tests/unit/test_scene_detail_runner_retry.py` | regression 만 (새 test 추가 없음 — Group A 가 echo 검증 책임) | T6 |
| `backend/tests/test_prompt_versions.py` | 추가 — 5 tests (Group E) | T2, T5 |

---

## Task 1: `frame_spatial_contract.py` helper module + 15 unit tests

**Files:**
- Create: `backend/app/core/frame_spatial_contract.py`
- Create: `backend/tests/unit/test_frame_spatial_contract.py`

### Step 1.1: helper module skeleton + constants

- [ ] Create `backend/app/core/frame_spatial_contract.py` with constants and function stubs.

```python
"""frame_spatial_contract — opt-in shot 의 화면 좌표/방향 SOT helper.

Spec: docs/superpowers/specs/2026-05-14-frame-spatial-contract-design.md

5 함수 (caller 분리):
  - find_frame_spatial_contract_violations(batch_shots): shot_staging producer
    가 retry hint 형성용 violation list 반환. raise X.
  - validate_and_prepare(contract, visible_entities): render_prompt_card 가
    constraint_id 부여 + cross-check. invalid 시 AppError raise.
  - validate_echoes(card, result): scene_detail post-validation 이 per-
    variation echo set 검증. violations list 반환. raise X (caller retry 판단).
  - phrase_diagnostic(t2i_prompt, constraints): soft warning data 반환. raise X.
  - _format_retry_hint(fsc_violations): retry hint formatter (orientation
    pattern mirror).
"""
from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

from app.core.errors import AppError

# ── Constants ──────────────────────────────────────────────────────────────

ZONE_PHRASES: Dict[str, List[str]] = {
    "upper_left":    ["upper-left",    "top-left",      "upper left"],
    "upper_center":  ["upper-center",  "top-center",    "upper center"],
    "upper_right":   ["upper-right",   "top-right",     "upper right"],
    "middle_left":   ["middle-left",   "center-left",   "middle left",  "left side"],
    "middle_center": ["middle-center", "center",        "middle"],
    "middle_right":  ["middle-right",  "center-right",  "middle right", "right side"],
    "lower_left":    ["lower-left",    "bottom-left",   "lower left"],
    "lower_center":  ["lower-center",  "bottom-center", "lower center"],
    "lower_right":   ["lower-right",   "bottom-right",  "lower right"],
}

DEPTH_PHRASES: Dict[str, List[str]] = {
    "foreground": ["foreground", "front"],
    "midground":  ["midground", "middle ground"],
    "background": ["background", "back"],
}

_FSC_ID_PREFIX = "fsc_"
_FSC_MAX_CONSTRAINTS = 3

_VALID_REASON = frozenset([
    "movement_direction",
    "points_to_anchor",
    "looks_to_anchor",
    "shared_space_relation",
    "required_background_position",
    "primary_subject_isolation",
])
_VALID_TARGET_KIND = frozenset(["character", "prop", "background"])
_VALID_GESTURE_ACTION = frozenset(["none", "points_to", "reaches_for", "looks_toward", "moves_toward"])
_CHARACTER_ID_RE = re.compile(r"^C\d{2,3}$")
_PROP_ID_RE = re.compile(r"^P\d{2,3}$")


def find_frame_spatial_contract_violations(batch_shots: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """shot_staging producer: violation list 반환 (raise X)."""
    raise NotImplementedError


def validate_and_prepare(
    contract: Optional[Dict[str, Any]],
    visible_entities: List[str],
) -> Optional[Dict[str, Any]]:
    """render_prompt_card consumer: shape validate + constraint_id assign +
    visible_entities cross-check. invalid 시 raise AppError.

    visible_entities 는 SID list (C##, P##, P###...). build_render_prompt_card
    의 기존 signature 와 일관 (render_prompt_card.py:1020/3518 ↔ `List[str]`).
    내부에서 C##/P## prefix 로 character/prop set 자동 split.
    """
    raise NotImplementedError


def validate_echoes(
    card: Dict[str, Any],
    result: Dict[str, Any],
) -> List[Dict[str, Any]]:
    """scene_detail post-validation: per-variation echo set 검증. violation
    list 반환 (raise X). caller 가 retry 판단."""
    raise NotImplementedError


def phrase_diagnostic(
    t2i_prompt: str,
    constraints: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """soft warning data 반환 (raise X). v1 soft, v2 hard 승격 가능."""
    raise NotImplementedError


def _format_retry_hint(fsc_violations: List[Dict[str, Any]]) -> str:
    """retry hint formatter — shot_staging.py:_format_retry_hint orientation
    pattern mirror."""
    raise NotImplementedError
```

- [ ] Create `backend/tests/unit/test_frame_spatial_contract.py` skeleton.

```python
"""frame_spatial_contract helper module unit tests (Group A, 15 tests)."""
from __future__ import annotations

import pytest

from app.core import frame_spatial_contract as fsc
from app.core.errors import AppError


# ── Constants tests ────────────────────────────────────────────────────────


def test_constants_9_zones_3_depths_max_3():
    """ZONE_PHRASES 9 entry / DEPTH_PHRASES 3 entry / _FSC_MAX_CONSTRAINTS = 3."""
    assert len(fsc.ZONE_PHRASES) == 9
    assert len(fsc.DEPTH_PHRASES) == 3
    assert fsc._FSC_MAX_CONSTRAINTS == 3
    assert fsc._FSC_ID_PREFIX == "fsc_"
    assert all(isinstance(v, list) and len(v) >= 1 for v in fsc.ZONE_PHRASES.values())
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py::test_constants_9_zones_3_depths_max_3 -v`
- [ ] Expected: PASS

- [ ] **No commit yet** — Task 1 의 모든 sub-step 완료 후 Step 1.5 에서 단일 commit.

### Step 1.2: `find_frame_spatial_contract_violations` TDD

- [ ] Append to `test_frame_spatial_contract.py`:

```python
# ── find_frame_spatial_contract_violations tests ──────────────────────────

VALID_CONSTRAINT = {
    "target_kind": "character",
    "target_id": "C02",
    "label": "B",
    "screen_zone": "lower_right",
    "depth_plane": "foreground",
    "gesture_action": "points_to",
    "gesture_target_label": "entrance door",
}


def _shot_with_contract(contract):
    return {"scene_index": 1, "shot_index": 1, "frame_spatial_contract": contract}


def test_find_violations_null_contract_returns_empty():
    """null contract → violation 없음 (gating opt-in)."""
    shots = [_shot_with_contract(None)]
    assert fsc.find_frame_spatial_contract_violations(shots) == []


def test_find_violations_valid_1_to_3_constraints_returns_empty():
    """valid 1~3 constraints → violation 없음."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}],
    }
    shots = [_shot_with_contract(contract)]
    assert fsc.find_frame_spatial_contract_violations(shots) == []


def test_find_violations_more_than_3_constraints():
    """> 3 constraints → violation."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [VALID_CONSTRAINT] * 4,
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) == 1
    assert "constraints_max_3" in violations[0]["reason"]


def test_find_violations_invalid_enum_or_empty_label_or_wrong_target_id():
    """invalid enum / empty label / wrong target_id shape → violation."""
    contract_invalid_zone = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "screen_zone": "INVALID_ZONE"}],
    }
    contract_empty_label = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "label": ""}],
    }
    contract_wrong_target = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "target_kind": "character", "target_id": "P02"}],
    }
    for contract in [contract_invalid_zone, contract_empty_label, contract_wrong_target]:
        shots = [_shot_with_contract(contract)]
        assert len(fsc.find_frame_spatial_contract_violations(shots)) >= 1


def test_find_violations_gesture_action_not_none_requires_label():
    """gesture_action != 'none' + gesture_target_label = '' → violation."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "points_to", "gesture_target_label": ""}],
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) >= 1
    assert any("gesture_target_label" in v["reason"] for v in violations)


def test_find_violations_gesture_action_none_requires_empty_label():
    """gesture_action == 'none' + gesture_target_label != '' → violation."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "none", "gesture_target_label": "door"}],
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) >= 1
    assert any("gesture_target_label" in v["reason"] for v in violations)
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k find_violations`
- [ ] Expected: 6 FAIL with NotImplementedError

- [ ] Implement `find_frame_spatial_contract_violations` in `frame_spatial_contract.py`:

```python
def find_frame_spatial_contract_violations(batch_shots: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """shot_staging producer: violation list 반환 (raise X)."""
    out: List[Dict[str, Any]] = []
    for shot in batch_shots or []:
        si = shot.get("scene_index")
        shi = shot.get("shot_index")
        contract = shot.get("frame_spatial_contract")
        if contract is None:
            continue  # opt-in null OK
        if not isinstance(contract, dict):
            out.append({"scene_index": si, "shot_index": shi, "reason": "contract_not_dict"})
            continue

        reason = contract.get("reason")
        if reason not in _VALID_REASON:
            out.append({"scene_index": si, "shot_index": shi, "reason": "invalid_reason_enum",
                        "value": reason})

        constraints = contract.get("constraints")
        if not isinstance(constraints, list) or len(constraints) == 0:
            out.append({"scene_index": si, "shot_index": shi, "reason": "constraints_empty_or_not_list"})
            continue
        if len(constraints) > _FSC_MAX_CONSTRAINTS:
            out.append({"scene_index": si, "shot_index": shi,
                        "reason": "constraints_max_3_exceeded", "count": len(constraints)})

        for idx, c in enumerate(constraints):
            if not isinstance(c, dict):
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "constraint_not_dict"})
                continue

            tk = c.get("target_kind")
            tid = c.get("target_id", "")
            label = c.get("label", "")
            zone = c.get("screen_zone")
            depth = c.get("depth_plane")
            ga = c.get("gesture_action")
            gtl = c.get("gesture_target_label", "")

            if tk not in _VALID_TARGET_KIND:
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "invalid_target_kind", "value": tk})
            elif tk == "character" and not _CHARACTER_ID_RE.match(tid or ""):
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "target_kind_character_id_shape_mismatch", "target_id": tid})
            elif tk == "prop" and not _PROP_ID_RE.match(tid or ""):
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "target_kind_prop_id_shape_mismatch", "target_id": tid})
            elif tk == "background" and tid != "":
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "target_kind_background_id_nonempty", "target_id": tid})

            if not (isinstance(label, str) and label.strip()):
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "label_empty"})

            if zone not in ZONE_PHRASES:
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "invalid_screen_zone", "value": zone})

            if depth not in DEPTH_PHRASES:
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "invalid_depth_plane", "value": depth})

            if ga not in _VALID_GESTURE_ACTION:
                out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                            "reason": "invalid_gesture_action", "value": ga})
            else:
                if ga != "none" and not (isinstance(gtl, str) and gtl.strip()):
                    out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                                "reason": "gesture_target_label_empty_for_non_none_action"})
                if ga == "none" and (gtl or "").strip():
                    out.append({"scene_index": si, "shot_index": shi, "constraint_idx": idx,
                                "reason": "gesture_target_label_nonempty_for_none_action"})
    return out
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k find_violations`
- [ ] Expected: 6 PASS

- [ ] **No commit yet** — Task 1 final commit at Step 1.5.

### Step 1.3: `validate_and_prepare` TDD

- [ ] Append to `test_frame_spatial_contract.py`:

```python
# ── validate_and_prepare tests ────────────────────────────────────────────


def test_validate_and_prepare_null_returns_none():
    """null contract → None 반환."""
    assert fsc.validate_and_prepare(None, []) is None


def test_validate_and_prepare_assigns_deterministic_ids():
    """sort key 7-tuple 로 정렬 후 fsc_001/fsc_002 부여."""
    contract = {
        "reason": "shared_space_relation",
        "constraints": [
            {**VALID_CONSTRAINT, "target_id": "C03", "label": "C"},  # 두 번째 sort
            {**VALID_CONSTRAINT, "target_id": "C02", "label": "B"},  # 첫 번째 sort
        ],
    }
    visible = ["C02", "C03"]  # SID list (render_prompt_card.py:1020 일관)
    prepared = fsc.validate_and_prepare(contract, visible)
    assert prepared is not None
    assert prepared["constraints"][0]["target_id"] == "C02"
    assert prepared["constraints"][0]["constraint_id"] == "fsc_001"
    assert prepared["constraints"][1]["target_id"] == "C03"
    assert prepared["constraints"][1]["constraint_id"] == "fsc_002"


def test_validate_and_prepare_character_missing_in_visible_raises():
    """target_kind=character + target_id 가 visible_entities 에 없으면 raise."""
    contract = {"reason": "points_to_anchor", "constraints": [VALID_CONSTRAINT]}
    visible = ["C03"]  # C02 missing
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_cross_check_failed" in exc_info.value.code


def test_validate_and_prepare_prop_missing_in_visible_raises():
    """target_kind=prop + target_id 가 visible_entities 에 없으면 raise."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{**VALID_CONSTRAINT, "target_kind": "prop", "target_id": "P05",
                         "gesture_action": "none", "gesture_target_label": ""}],
    }
    visible = ["P03"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_cross_check_failed" in exc_info.value.code


def test_validate_and_prepare_background_label_only_check():
    """target_kind=background → target_id empty + label non-empty 만 검사 (semantic match 없음)."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{"target_kind": "background", "target_id": "", "label": "entrance door",
                         "screen_zone": "upper_center", "depth_plane": "background",
                         "gesture_action": "none", "gesture_target_label": ""}],
    }
    visible: list = []
    prepared = fsc.validate_and_prepare(contract, visible)
    assert prepared is not None
    assert prepared["constraints"][0]["constraint_id"] == "fsc_001"


def test_validate_and_prepare_duplicate_sort_key_raises():
    """같은 sort 7-tuple 의 두 constraint → duplicate_constraint raise."""
    duplicate = dict(VALID_CONSTRAINT)
    contract = {
        "reason": "points_to_anchor",
        "constraints": [duplicate, dict(duplicate)],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "duplicate_constraint" in str(exc_info.value)


def test_validate_and_prepare_invalid_screen_zone_raises():
    """consumer-side defensive: invalid screen_zone enum → fsc_invalid raise."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "screen_zone": "INVALID_ZONE"}],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_invalid" in exc_info.value.code
    assert "screen_zone" in exc_info.value.message


def test_validate_and_prepare_gesture_target_label_mismatch_raises():
    """consumer-side defensive: gesture_action != 'none' + gesture_target_label '' → fsc_invalid."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "points_to",
                         "gesture_target_label": ""}],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_invalid" in exc_info.value.code
    assert "gesture_target_label" in exc_info.value.message
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k validate_and_prepare`
- [ ] Expected: 6 FAIL

- [ ] Implement `validate_and_prepare`:

```python
def validate_and_prepare(
    contract: Optional[Dict[str, Any]],
    visible_entities: List[str],
) -> Optional[Dict[str, Any]]:
    """render_prompt_card consumer: shape validate + constraint_id assign +
    visible_entities cross-check. invalid 시 raise AppError.

    visible_entities 는 SID list (C##, P##, P###...). 기존 build_render_prompt_card
    signature 와 일관 (render_prompt_card.py:1020/3518).
    """
    if contract is None:
        return None

    # No Silent Fallback gate — visible_entities=None 은 producer-side bug
    # (build_id_policy convention at render_prompt_card.py:1061-1068 일관).
    if visible_entities is None:
        raise AppError(
            code="render_prompt_card.fsc_invalid",
            message=(
                "validate_and_prepare: visible_entities is None — caller must "
                "pass explicit [] for intentionally empty (matches build_id_policy "
                "convention at render_prompt_card.py:1061-1068)."
            ),
        )

    if not isinstance(contract, dict):
        raise AppError(
            code="render_prompt_card.fsc_invalid",
            message=f"frame_spatial_contract is {type(contract).__name__}, expected dict or None",
        )

    reason = contract.get("reason")
    constraints = contract.get("constraints") or []
    if reason not in _VALID_REASON:
        raise AppError(code="render_prompt_card.fsc_invalid",
                       message=f"invalid reason {reason!r}")
    if not isinstance(constraints, list) or not (1 <= len(constraints) <= _FSC_MAX_CONSTRAINTS):
        raise AppError(code="render_prompt_card.fsc_invalid",
                       message=f"constraints must be list of 1..{_FSC_MAX_CONSTRAINTS}")

    # sort by 7-tuple — LLM emit 순서 무관 deterministic id 부여
    def _sort_key(c: Dict[str, Any]):
        return (
            c.get("target_kind", ""),
            c.get("target_id", "") or "",
            c.get("label", "") or "",
            c.get("screen_zone", "") or "",
            c.get("depth_plane", "") or "",
            c.get("gesture_action", "") or "",
            c.get("gesture_target_label", "") or "",
        )

    sorted_constraints = sorted(constraints, key=_sort_key)

    # duplicate detection
    seen_keys: set = set()
    for c in sorted_constraints:
        k = _sort_key(c)
        if k in seen_keys:
            raise AppError(
                code="render_prompt_card.fsc_invalid",
                message=f"duplicate_constraint (same 7-tuple sort key): {k}",
            )
        seen_keys.add(k)

    visible_set = set(visible_entities)

    prepared_constraints = []
    for idx, c in enumerate(sorted_constraints):
        tk = c.get("target_kind")
        tid = c.get("target_id", "") or ""
        label = (c.get("label") or "").strip()
        zone = c.get("screen_zone")
        depth = c.get("depth_plane")
        ga = c.get("gesture_action")
        gtl = c.get("gesture_target_label", "")

        # shape validation (defensive consumer-side — stale/malformed cp 가
        # provider schema 우회해서 들어왔을 때도 fail-fast).
        if tk not in _VALID_TARGET_KIND:
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message=f"invalid target_kind {tk!r}")
        if not label:
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message="label is empty")
        if zone not in ZONE_PHRASES:
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message=f"invalid screen_zone {zone!r}")
        if depth not in DEPTH_PHRASES:
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message=f"invalid depth_plane {depth!r}")
        if ga not in _VALID_GESTURE_ACTION:
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message=f"invalid gesture_action {ga!r}")
        if ga != "none" and not (isinstance(gtl, str) and gtl.strip()):
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message="gesture_target_label is empty but gesture_action != 'none'")
        if ga == "none" and (gtl or "").strip():
            raise AppError(code="render_prompt_card.fsc_invalid",
                           message="gesture_target_label is non-empty but gesture_action == 'none'")

        # cross-check: visible_entities 는 SID list (C##/P## 모두 한 set).
        # target_id 형식 자체로 character/prop 구분되므로 set membership 만 검사.
        if tk == "character":
            if not _CHARACTER_ID_RE.match(tid):
                raise AppError(
                    code="render_prompt_card.fsc_invalid",
                    message=f"target_kind=character requires C## target_id (got {tid!r})",
                )
            if tid not in visible_set:
                raise AppError(
                    code="render_prompt_card.fsc_cross_check_failed",
                    message=(
                        f"target_id {tid!r} not in visible_entities ({sorted(visible_set)})"
                    ),
                )
        elif tk == "prop":
            if not _PROP_ID_RE.match(tid):
                raise AppError(
                    code="render_prompt_card.fsc_invalid",
                    message=f"target_kind=prop requires P## target_id (got {tid!r})",
                )
            if tid not in visible_set:
                raise AppError(
                    code="render_prompt_card.fsc_cross_check_failed",
                    message=(
                        f"target_id {tid!r} not in visible_entities ({sorted(visible_set)})"
                    ),
                )
        elif tk == "background":
            if tid != "":
                raise AppError(
                    code="render_prompt_card.fsc_invalid",
                    message=f"target_kind=background requires empty target_id (got {tid!r})",
                )

        prepared_constraints.append({
            "constraint_id": f"{_FSC_ID_PREFIX}{idx + 1:03d}",
            **c,
        })

    return {"reason": reason, "constraints": prepared_constraints}
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k validate_and_prepare`
- [ ] Expected: 8 PASS

- [ ] **No commit yet** — Task 1 final commit at Step 1.5.

### Step 1.4: `validate_echoes` TDD

- [ ] Append to `test_frame_spatial_contract.py`:

```python
# ── validate_echoes tests ─────────────────────────────────────────────────


def _card_with_contract(constraints):
    return {
        "render_strategy": {
            "frame_spatial_contract": {
                "reason": "points_to_anchor",
                "constraints": [{**c, "constraint_id": f"fsc_{i+1:03d}"}
                                for i, c in enumerate(constraints)],
            }
        }
    }


def _card_null():
    return {"render_strategy": {"frame_spatial_contract": None}}


def test_validate_echoes_null_contract_empty_echoes_pass():
    """null contract 면 각 variation echo = [] → violation 없음."""
    card = _card_null()
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": []},
        {"variant_label": "var_2", "applied_frame_spatial_constraint_ids": []},
    ]}
    assert fsc.validate_echoes(card, result) == []


def test_validate_echoes_null_contract_nonempty_echo_violation():
    """null contract 인데 echo 가 비어있지 않음 → violation."""
    card = _card_null()
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001"]},
    ]}
    violations = fsc.validate_echoes(card, result)
    assert len(violations) == 1
    assert violations[0]["extra"] == ["fsc_001"]


def test_validate_echoes_match_pass():
    """contract 있고 모든 variation echo set == injected set → violation 없음."""
    card = _card_with_contract([VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}])
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001", "fsc_002"]},
        {"variant_label": "var_2", "applied_frame_spatial_constraint_ids": ["fsc_002", "fsc_001"]},
    ]}
    assert fsc.validate_echoes(card, result) == []


def test_validate_echoes_missing_id_violation():
    """variation echo 에 injected id 누락 → violation list."""
    card = _card_with_contract([VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}])
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001"]},
    ]}
    violations = fsc.validate_echoes(card, result)
    assert len(violations) == 1
    assert violations[0]["missing"] == ["fsc_002"]
    assert violations[0]["variant_label"] == "var_1"
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k validate_echoes`
- [ ] Expected: 4 FAIL

- [ ] Implement `validate_echoes`:

```python
def validate_echoes(
    card: Dict[str, Any],
    result: Dict[str, Any],
) -> List[Dict[str, Any]]:
    """scene_detail post-validation: per-variation echo set 검증. violation
    list 반환 (raise X). caller 가 retry 판단."""
    rs = (card or {}).get("render_strategy") or {}
    contract = rs.get("frame_spatial_contract")
    if contract is None:
        injected_ids: set = set()
    else:
        injected_ids = {c["constraint_id"] for c in (contract.get("constraints") or [])}

    out: List[Dict[str, Any]] = []
    for v in (result or {}).get("t2i_variations", []) or []:
        echoed = set(v.get("applied_frame_spatial_constraint_ids") or [])
        if echoed != injected_ids:
            out.append({
                "variant_label": v.get("variant_label"),
                "injected_ids": sorted(injected_ids),
                "echoed_ids": sorted(echoed),
                "missing": sorted(injected_ids - echoed),
                "extra": sorted(echoed - injected_ids),
            })
    return out
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k validate_echoes`
- [ ] Expected: 4 PASS

- [ ] **No commit yet** — Task 1 final commit at Step 1.5.

### Step 1.5: `phrase_diagnostic` + `_format_retry_hint` TDD + Task 1 final commit

- [ ] Append to `test_frame_spatial_contract.py`:

```python
# ── phrase_diagnostic + _format_retry_hint tests ──────────────────────────


def test_phrase_diagnostic_warns_only_no_raise():
    """phrase diagnostic 은 warning data 반환만, raise 안 함."""
    constraint = {**VALID_CONSTRAINT, "constraint_id": "fsc_001"}
    # t2i_prompt 에 label / zone / depth phrase 모두 누락
    diag = fsc.phrase_diagnostic("a generic prompt with no spatial keywords", [constraint])
    assert isinstance(diag, list)
    assert len(diag) == 1
    # 모두 missing
    assert diag[0]["label_missing"] is True
    assert diag[0]["zone_missing"] is True
    assert diag[0]["depth_missing"] is True


def test_phrase_diagnostic_all_phrases_present_no_warning():
    """label / zone / depth phrase 모두 매칭되면 빈 diagnostic."""
    constraint = {**VALID_CONSTRAINT, "constraint_id": "fsc_001"}
    prompt = "B stands in the lower-right foreground area"
    diag = fsc.phrase_diagnostic(prompt, [constraint])
    assert diag == []
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v -k phrase_diagnostic`
- [ ] Expected: 2 FAIL

- [ ] Implement `phrase_diagnostic` + `_format_retry_hint`:

```python
def phrase_diagnostic(
    t2i_prompt: str,
    constraints: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """soft warning data 반환 (raise X). v1 soft, v2 hard 승격 가능."""
    out: List[Dict[str, Any]] = []
    prompt_lower = (t2i_prompt or "").lower()
    for c in constraints or []:
        label = (c.get("label") or "").lower()
        zone = c.get("screen_zone")
        depth = c.get("depth_plane")
        zone_variants = ZONE_PHRASES.get(zone, [])
        depth_variants = DEPTH_PHRASES.get(depth, [])
        label_present = bool(label) and label in prompt_lower
        zone_present = any(v in prompt_lower for v in zone_variants)
        depth_present = any(v in prompt_lower for v in depth_variants)
        if not (label_present and zone_present and depth_present):
            out.append({
                "constraint_id": c.get("constraint_id"),
                "label_missing": not label_present,
                "zone_missing": not zone_present,
                "depth_missing": not depth_present,
            })
    return out


def _format_retry_hint(fsc_violations: List[Dict[str, Any]]) -> str:
    """retry hint formatter — shot_staging.py:_format_retry_hint orientation
    pattern mirror."""
    lines = [
        "",
        "",
        "[재시도 — 직전 응답의 frame_spatial_contract 가 다음 위반을",
        " 포함했습니다. 수정해서 재출력하세요:]",
    ]
    for v in fsc_violations:
        si = v.get("scene_index")
        shi = v.get("shot_index")
        idx = v.get("constraint_idx")
        rsn = v.get("reason")
        loc = f"S{si} Shot{shi}"
        if idx is not None:
            loc += f" constraint[{idx}]"
        lines.append(f"  - {loc}: {rsn}")
    return "\n".join(lines)
```

- [ ] Run: `pytest backend/tests/unit/test_frame_spatial_contract.py -v`
- [ ] Expected: 15 PASS (all Group A)

- [ ] **Task 1 final commit** (Task 1 의 모든 sub-step 통합 — 1 commit per task 원칙):

```bash
git add backend/app/core/frame_spatial_contract.py backend/tests/unit/test_frame_spatial_contract.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): helper module + 15 unit tests

frame_spatial_contract.py 신규 — 5 함수 (find_frame_spatial_contract_
violations / validate_and_prepare / validate_echoes / phrase_diagnostic /
_format_retry_hint) + 4 상수 (ZONE_PHRASES 9 / DEPTH_PHRASES 3 /
_FSC_ID_PREFIX / _FSC_MAX_CONSTRAINTS). visible_entities 는 SID list
(render_prompt_card.py:1020 일관). sort key 7-tuple deterministic id
부여 + duplicate detection. consumer-side defensive: validate_and_prepare
가 enum (screen_zone/depth_plane/gesture_action) + gesture_target_label
조건 second-check (stale/malformed cp 차단). null contract → echo []
검증. phrase diagnostic v1 soft warning. orientation pattern mirror
retry hint. 15 unit tests PASS (Group A).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: shot_staging schema v10 + prompt v10 + version/manifest

**Files:**
- Create: `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/schema.json`
- Create: `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/system.md`
- Modify: `backend/app/core/version_registry.py`
- Modify: `backend/app/core/step_manifest.py`
- Modify: `backend/tests/test_prompt_versions.py`

### Step 2.1: Determine timestamp

- [ ] Run: `date '+%Y%m%d%H%M'`
- [ ] Use the output as `<YYYYMMDDHHmm>` for the rest of Task 2. Example: `202605141500`.
- [ ] Final directory: `prompts/_base/shot_staging/10.202605141500/` (replace timestamp with actual).

### Step 2.2: Create schema.json (v10)

- [ ] Create `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/schema.json` — v9 schema 의 `shots[]` item 에 `frame_spatial_contract` (object/null) optional field 추가. v9 의 모든 기존 field (`perspective`, `pov_character`, `perception_mode`, `camera_direction`, `lighting_mood`, `character_angles`, `key_bg_elements`) 는 verbatim carry. 다음을 그대로 사용:

```json
{
  "type": "object",
  "properties": {
    "shots": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "scene_index": {"type": "integer"},
          "shot_index": {"type": "integer"},
          "perspective": {"type": "string", "description": "Camera POV: subjective_pov, over_shoulder, observer, omniscient, object_pov, voyeur"},
          "pov_character": {"type": "string", "description": "For subjective POV: the character whose eyes we see through (must NOT appear in image). Empty string if not subjective."},
          "perception_mode": {"type": "string", "description": "How the scene is perceived: direct (normal vision), hallucination, dream, memory, reflection, through_device (CCTV/phone/binoculars), projection. Affects visual treatment."},
          "camera_direction": {"type": "string", "description": "Creative camera placement, framing, physical layout, and composition including POV (2-3 sentences, English)"},
          "lighting_mood": {"type": "string", "description": "Lighting technique and color mood (1 sentence, English)"},
          "character_angles": {
            "type": "array",
            "description": "Camera-relative angle and body pose for each visible character (exclude POV character)",
            "items": {
              "type": "object",
              "properties": {
                "character": {"type": "string", "description": "Character name (as registered)"},
                "angle": {"type": "string", "description": "Camera-relative direction: facing_camera, back_to_camera, profile_left, profile_right, three_quarter_left, three_quarter_right, over_shoulder, looking_away"},
                "body_pose": {"type": "string", "description": "Specific body posture in English (2-5 words). Must NOT use 'standing' alone — use action-grounded or state-grounded poses such as 'one foot on pedal', 'leaning against doorframe', 'crouching behind desk', 'slumping into chair', 'mid-stride paused'. See system prompt 'body_pose 다양화' for full guidance."},
                "gaze_target": {"type": "string", "description": "Where the character's eyes are looking: another character's name, an object name, 'camera', 'down', 'up', 'distant', 'closed', 'unconscious', 'dead', 'severely_injured'"}
              },
              "required": ["character", "angle", "body_pose", "gaze_target"],
              "additionalProperties": false
            }
          },
          "key_bg_elements": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "element": {"type": "string", "description": "Background element name"},
                "state": {"type": "string", "description": "State in this shot (open/closed, clean/dirty, on/off, etc.)"},
                "orientation": {"type": "string", "description": "Natural-language description tied to directionality_class. For content_surface: which face/side is visible to camera and what is on it. For reflective_surface: what is being reflected and how. For transparent_surface: surface state and what is seen through it. For directional_3d: which side faces camera. For non_directional: empty allowed. content_surface and reflective_surface MUST have a non-empty orientation."},
                "camera_use": {"type": "string", "description": "How this element is used in the shot (foreground frame, focal point, light source, etc.)"},
                "directionality_class": {
                  "type": "string",
                  "enum": ["content_surface", "reflective_surface", "transparent_surface", "directional_3d", "non_directional"],
                  "description": "Semantic classification of this element. content_surface = thin object that carries content on one face; which face is visible changes the meaning. reflective_surface = object whose surface reflects content; what is reflected determines meaning. transparent_surface = object the camera sees through; surface state and what lies beyond determine meaning. directional_3d = 3D object with multiple faces where the camera-facing side matters. non_directional = texture/surface/ambient element with no directional meaning. Judge by meaning, not by surface vocabulary or specific examples (non-exhaustive examples, do not classify by this list)."
                }
              },
              "required": ["element", "state", "orientation", "camera_use", "directionality_class"],
              "additionalProperties": false
            }
          },
          "frame_spatial_contract": {
            "oneOf": [
              {"type": "null"},
              {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "enum": ["movement_direction", "points_to_anchor", "looks_to_anchor",
                             "shared_space_relation", "required_background_position",
                             "primary_subject_isolation"],
                    "description": "Single primary trigger — see system prompt 'Frame Spatial Contract' section."
                  },
                  "constraints": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 3,
                    "items": {
                      "type": "object",
                      "properties": {
                        "target_kind": {"type": "string", "enum": ["character", "prop", "background"]},
                        "target_id": {"type": "string", "description": "character → C##, prop → P##, background → empty string"},
                        "label": {"type": "string", "minLength": 1, "description": "Human-readable label"},
                        "screen_zone": {"type": "string", "enum": [
                          "upper_left", "upper_center", "upper_right",
                          "middle_left", "middle_center", "middle_right",
                          "lower_left", "lower_center", "lower_right"]},
                        "depth_plane": {"type": "string", "enum": ["foreground", "midground", "background"]},
                        "gesture_action": {"type": "string", "enum": [
                          "none", "points_to", "reaches_for", "looks_toward", "moves_toward"]},
                        "gesture_target_label": {"type": "string",
                          "description": "non-empty when gesture_action != 'none', empty string when gesture_action == 'none'"}
                      },
                      "required": ["target_kind", "target_id", "label", "screen_zone",
                                   "depth_plane", "gesture_action", "gesture_target_label"],
                      "additionalProperties": false
                    }
                  }
                },
                "required": ["reason", "constraints"],
                "additionalProperties": false
              }
            ],
            "description": "Opt-in frame-space contract. null when not needed. See system prompt 'Frame Spatial Contract' section for emit gating."
          }
        },
        "required": ["scene_index", "shot_index", "perspective", "pov_character", "perception_mode", "camera_direction", "lighting_mood", "character_angles", "key_bg_elements", "frame_spatial_contract"],
        "additionalProperties": false
      }
    }
  },
  "required": ["shots"],
  "additionalProperties": false
}
```

### Step 2.3: Create system.md (v10)

- [ ] Copy `prompts/_base/shot_staging/9.202605121441/system.md` content verbatim 으로 `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/system.md` 에 저장.

- [ ] 새 파일 끝에 다음 section 추가:

````markdown

---

## Frame Spatial Contract (opt-in, 필요한 shot 만)

대부분의 shot 은 `frame_spatial_contract: null` 이다. **그러나 화면 공간 misunderstanding 이 이미지를 망칠 가능성이 큰 shot 에만 contract 를 emit 하라.** 모든 shot 에 emit 금지.

### 언제 emit 하는가 (positive cases)

다음 중 하나라도 핵심이면 emit:

1. **인물/물체가 화면 방향으로 이동** → reason="movement_direction"
   - 예: A가 화면 아래에서 위로 뛰어 올라간다
2. **인물이 anchor 를 가리킴** → reason="points_to_anchor"
   - 예: B가 화면 위쪽 문을 가리킨다
3. **인물의 시선이 anchor 로 향함** → reason="looks_to_anchor"
   - 예: C의 시선이 화면 좌측 창문에 향한다
4. **두 인물의 fg/bg 공간 관계** → reason="shared_space_relation"
   - 예: A는 전경에, B는 같은 공간 후경에 — 공유 anchor (bench/table/doorway) 명시
5. **배경/소품의 화면 위치가 필수** → reason="required_background_position"
   - 예: 휴대폰 앞면이 화면 중앙 전경에 위치해야 함
6. **close-up 에서 primary 인물만 보임** → reason="primary_subject_isolation"
   - 예: C에 close-up 인데 다른 인물 침입 방지

### 언제 emit 하지 않는가 (negative cases)

- 그냥 대화하는 투샷, 위치가 의미 없음 → null
- 감정 close-up 이지만 배경 위치 무관 → null
- 소품이 scene 에 있지만 이 shot 의 화면 방향 의미 없음 → null
- 모든 visible character 를 자동으로 screen anchor 화 → 금지 (null 또는 minimal)
- 모든 key_bg_elements 를 자동으로 screen anchor 화 → 금지

### 규칙

- **reason 은 단일 enum**: 복수 trigger 시 가장 image-failure 위험 큰 1개만 선택. reason array 금지.
- **constraints 최대 3개**: 4개 이상은 차단.
- **target_kind**:
  - `character` → `target_id` = `C##` (등록된 character)
  - `prop` → `target_id` = `P##` (등록된 prop)
  - `background` → `target_id` = `""` (empty), `label` 만 채움
- **label** 은 사람 읽기용 free-text (background 의 경우 의미 매칭 없음).
- **screen_zone** = 9 zone 중 하나 (`upper_left` ~ `lower_right`, 3x3 grid).
- **depth_plane** = `foreground` / `midground` / `background`.
- **gesture_action**:
  - `none` 이면 `gesture_target_label = ""` (empty)
  - `points_to` / `reaches_for` / `looks_toward` / `moves_toward` 이면 `gesture_target_label` non-empty (대상 NL label, 예: "entrance door")
- **constraint_id 는 emit 하지 않는다**: code 가 후처리에서 deterministic 부여.
- **자동 포함 금지**: character/prop/background 모두. 필요한 target 만 emit.

### 예시

**Case 1 — B가 문을 가리킴** (`points_to_anchor`):

```json
{
  "frame_spatial_contract": {
    "reason": "points_to_anchor",
    "constraints": [
      {
        "target_kind": "character",
        "target_id": "C02",
        "label": "B",
        "screen_zone": "lower_right",
        "depth_plane": "foreground",
        "gesture_action": "points_to",
        "gesture_target_label": "entrance door"
      },
      {
        "target_kind": "background",
        "target_id": "",
        "label": "entrance door",
        "screen_zone": "upper_center",
        "depth_plane": "background",
        "gesture_action": "none",
        "gesture_target_label": ""
      }
    ]
  }
}
```

**Case 2 — null (대부분의 shot)**:

```json
{ "frame_spatial_contract": null }
```
````

### Step 2.4: Update version_registry + step_manifest

- [ ] In `backend/app/core/version_registry.py`, find `"shot_staging": "2.2.0"` and change to `"2.3.0"`. Update the trailing comment to reference v10 schema.

```python
# version_registry.py:33 영역
"shot_staging": "2.3.0",              # 2026-05-14 — v10 prompt + schema: frame_spatial_contract optional/null field 추가
```

- [ ] Also update the matching `"prompt_dependency"` key for shot_staging if present (around line 121-122). Change `"shot_staging/v8"` → `"shot_staging/v10"`.

- [ ] In `backend/app/core/step_manifest.py` shot_staging entry (`:587~599`), add `"schema_version": 2` key. **2 가 정답** — `step_runner.py:1511` 의 `current_schema = self.manifest.get("schema_version", 1)` default 가 1 이므로, 기존 cp 도 schema=1 으로 처리됨. v10 도입 후 cp invalidation 을 위해 manifest 2 필요. Example diff:

```python
"shot_staging": {
    "label": "촬영 연출 (DP)",
    "category": "analysis",
    "order": 19.5,
    "default_model": "gpt",
    "provider": "openai",
    "depends_on": ["shot_validator", "shot_selection", "visual_world_rules", "entity_merge", "scene_camera_flow"],
    "fan_out": False,
    "applicability": "always",
    "step_type": "transform",
    "lifecycle": "active",
    "resume_sensitive": True,
    "schema_version": 2,  # 2026-05-14: frame_spatial_contract optional field 도입 (v10 prompt). 기존 cp default 1 → mismatch → invalidation.
},
```

### Step 2.5: Add 2 Group E tests for shot_staging v10

- [ ] Append to `backend/tests/test_prompt_versions.py`:

```python
import json
import re
from pathlib import Path


def test_shot_staging_v10_schema_has_frame_spatial_contract():
    """shot_staging v10 schema 의 shots[].items 에 frame_spatial_contract 필드 (oneOf null/object)."""
    base = Path("prompts/_base/shot_staging")
    v10_dirs = sorted([d for d in base.iterdir() if d.name.startswith("10.")])
    assert v10_dirs, "shot_staging v10 디렉토리 없음"
    schema_file = v10_dirs[-1] / "schema.json"
    schema = json.loads(schema_file.read_text())
    item_props = schema["properties"]["shots"]["items"]["properties"]
    assert "frame_spatial_contract" in item_props
    assert "oneOf" in item_props["frame_spatial_contract"]
    # constraints maxItems = 3
    one_of = item_props["frame_spatial_contract"]["oneOf"]
    object_branch = next(b for b in one_of if b.get("type") == "object")
    assert object_branch["properties"]["constraints"]["maxItems"] == 3
    # 6 reason enum
    reason_enum = object_branch["properties"]["reason"]["enum"]
    assert set(reason_enum) == {
        "movement_direction", "points_to_anchor", "looks_to_anchor",
        "shared_space_relation", "required_background_position", "primary_subject_isolation",
    }


def test_step_manifest_shot_staging_schema_version_2():
    """step_manifest.py shot_staging entry 에 schema_version == 2.
    default 1 이므로 2 가 cp invalidation 의 정답."""
    from app.core.step_manifest import STEP_DEFINITIONS
    assert STEP_DEFINITIONS["shot_staging"].get("schema_version") == 2, (
        "shot_staging schema_version = 2 필수 (step_runner default 1 과 mismatch)"
    )
```

- [ ] Adjust the import statement at the top of `test_prompt_versions.py` if `STEP_DEFINITIONS` isn't already exported under that name. Check the actual symbol in `backend/app/core/step_manifest.py` and use it.

- [ ] Run: `pytest backend/tests/test_prompt_versions.py -v -k "shot_staging_v10 or shot_staging_schema_version"`
- [ ] Expected: 2 PASS

### Step 2.6: Commit Task 2

- [ ] Commit:

```bash
git add prompts/_base/shot_staging/10.* backend/app/core/version_registry.py backend/app/core/step_manifest.py backend/tests/test_prompt_versions.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): shot_staging schema v10 + prompt v10

v9 + frame_spatial_contract (oneOf null/object) optional field 추가.
prompt v10: ## Frame Spatial Contract section (6 reason trigger / positive
+ negative cases / 규칙 / 예시). version_registry 2.2.0 → 2.3.0.
step_manifest shot_staging schema_version 추가 (cp invalidation).
2 Group E tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: shot_staging.py retry loop 통합 + 4 pipeline tests

**Files:**
- Create: `backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py`
- Modify: `backend/app/modules/pipeline/shot_staging.py:203~`

### Step 3.1: Pipeline test 신규 (4 tests, all initially failing)

- [ ] Create `backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py`:

```python
"""shot_staging.py retry loop 의 frame_spatial_contract post-validation 통합
검증 (Group B, 4 tests).

기존 orientation pattern 과 mirror — try 외부 raise, retry hint append.
"""
from __future__ import annotations

from unittest.mock import patch

import pytest

from app.core.errors import AppError, ShotStagingOrientationError
from app.modules.pipeline import shot_staging as shot_staging_module
from app.modules.pipeline.shot_staging import run_shot_staging


# ── Helpers ────────────────────────────────────────────────────────────────


def _minimal_inputs(num_shots: int = 1):
    """run_shot_staging 의 최소 입력 fixture."""
    shot_extract = {"scenes": [{"scene_index": 1, "shots": [
        {"shot_index": i, "description": f"Shot {i}", "characters": ["A"],
         "based_on_beat_title": "b1"} for i in range(num_shots)
    ]}]}
    shot_selection = {"scenes": [{"scene_index": 1,
                                   "selected_shot_indices": list(range(num_shots))}]}
    scene_save = {"segments": [{"scene_index": 1, "text": "Scene 1 text."}]}
    entity_merge = {"characters": [{"name": "A"}]}
    vwr = {"t2i_context": ""}
    return shot_extract, shot_selection, scene_save, entity_merge, vwr


def _valid_shot_with_fsc(scene_index=1, shot_index=0, contract=None):
    """LLM output 의 1 shot dict — orientation 통과 + frame_spatial_contract 채움."""
    return {
        "scene_index": scene_index,
        "shot_index": shot_index,
        "perspective": "observer",
        "pov_character": "",
        "perception_mode": "direct",
        "camera_direction": "Eye level wide shot",
        "lighting_mood": "Soft daylight",
        "character_angles": [],
        "key_bg_elements": [],
        "frame_spatial_contract": contract,
    }


_VALID_CONTRACT = {
    "reason": "points_to_anchor",
    "constraints": [{
        "target_kind": "character",
        "target_id": "C02",
        "label": "B",
        "screen_zone": "lower_right",
        "depth_plane": "foreground",
        "gesture_action": "points_to",
        "gesture_target_label": "door",
    }],
}

_INVALID_CONTRACT_MISSING_REASON = {
    "constraints": [{
        "target_kind": "character",
        "target_id": "C02",
        "label": "B",
        "screen_zone": "lower_right",
        "depth_plane": "foreground",
        "gesture_action": "points_to",
        "gesture_target_label": "door",
    }],
}


# ── Tests (Group B) ────────────────────────────────────────────────────────


def test_first_fsc_violation_then_valid_retry_succeeds(monkeypatch):
    """1st fsc violation → 2nd valid → success (retry 동작)."""
    call_count = {"n": 0}

    def fake_call(*args, **kwargs):
        call_count["n"] += 1
        if call_count["n"] == 1:
            return {"shots": [_valid_shot_with_fsc(contract=_INVALID_CONTRACT_MISSING_REASON)]}
        return {"shots": [_valid_shot_with_fsc(contract=_VALID_CONTRACT)]}

    monkeypatch.setattr(shot_staging_module, "call_structured", fake_call)
    monkeypatch.setattr(shot_staging_module, "load_prompt", lambda *a, **kw: "system_prompt_{t2i_context}")
    monkeypatch.setattr(shot_staging_module, "load_schema", lambda *a, **kw: {})

    se, ss, sv, em, vw = _minimal_inputs(num_shots=1)
    result = run_shot_staging(se, ss, sv, em, vw)
    assert call_count["n"] == 2
    assert result["total"] == 1


def test_three_fsc_violations_exhausted_raises_app_error(monkeypatch):
    """3 회 fsc violation → AppError(shot_staging.frame_spatial_contract_invalid)."""
    def fake_call(*args, **kwargs):
        return {"shots": [_valid_shot_with_fsc(contract=_INVALID_CONTRACT_MISSING_REASON)]}

    monkeypatch.setattr(shot_staging_module, "call_structured", fake_call)
    monkeypatch.setattr(shot_staging_module, "load_prompt", lambda *a, **kw: "{t2i_context}")
    monkeypatch.setattr(shot_staging_module, "load_schema", lambda *a, **kw: {})

    se, ss, sv, em, vw = _minimal_inputs(num_shots=1)
    with pytest.raises(AppError) as exc_info:
        run_shot_staging(se, ss, sv, em, vw)
    assert "frame_spatial_contract_invalid" in exc_info.value.code


def test_orientation_only_exhausted_keeps_legacy_typed_error(monkeypatch):
    """orientation only 소진 → ShotStagingOrientationError 유지 (legacy path 보존)."""
    bad_shot = _valid_shot_with_fsc(contract=None)
    bad_shot["key_bg_elements"] = [{
        "element": "mirror", "state": "clean",
        "orientation": "",  # content_surface 인데 비어있음 → orientation violation
        "camera_use": "reflection", "directionality_class": "reflective_surface",
    }]

    def fake_call(*args, **kwargs):
        return {"shots": [bad_shot]}

    monkeypatch.setattr(shot_staging_module, "call_structured", fake_call)
    monkeypatch.setattr(shot_staging_module, "load_prompt", lambda *a, **kw: "{t2i_context}")
    monkeypatch.setattr(shot_staging_module, "load_schema", lambda *a, **kw: {})

    se, ss, sv, em, vw = _minimal_inputs(num_shots=1)
    with pytest.raises(ShotStagingOrientationError):
        run_shot_staging(se, ss, sv, em, vw)


def test_provider_exception_breaks_no_retry(monkeypatch):
    """provider/call_structured exception 은 retry X, failed batch break 기존 패턴."""
    call_count = {"n": 0}

    def fake_call(*args, **kwargs):
        call_count["n"] += 1
        raise RuntimeError("provider failure")

    monkeypatch.setattr(shot_staging_module, "call_structured", fake_call)
    monkeypatch.setattr(shot_staging_module, "load_prompt", lambda *a, **kw: "{t2i_context}")
    monkeypatch.setattr(shot_staging_module, "load_schema", lambda *a, **kw: {})

    se, ss, sv, em, vw = _minimal_inputs(num_shots=1)
    result = run_shot_staging(se, ss, sv, em, vw)
    # provider exception 은 raise 안 됨 (기존 패턴) — failed_batches 만 증가
    assert result["failed_batches"] >= 1
    assert call_count["n"] == 1  # retry X
```

- [ ] Run: `pytest backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py -v`
- [ ] Expected: 4 FAIL (post-validation 아직 wiring 안 됨 — invalid contract 가 통과)

### Step 3.2: shot_staging.py retry loop 통합

- [ ] Edit `backend/app/modules/pipeline/shot_staging.py`:

  At the top of the file, after the existing `from app.core.errors import ShotStagingOrientationError` import (line 14), add:

```python
from app.core.errors import AppError, ShotStagingOrientationError
from app.core.frame_spatial_contract import (
    find_frame_spatial_contract_violations,
    _format_retry_hint as _format_fsc_retry_hint,
)
```

  In the retry loop (line 203~), after `violations = _find_orientation_violations(batch_shots)` (line 227) and before the `if not violations:` check (line 229), add fsc validation and merge logic. The full replacement of the retry-loop-body (line 225-250 approximately) should look like:

```python
            # ↓ try 외부 — validator raise 가 batch loop 위로 propagate.
            batch_shots = result.get("shots", [])
            orientation_violations = _find_orientation_violations(batch_shots)
            fsc_violations = find_frame_spatial_contract_violations(batch_shots)

            if not orientation_violations and not fsc_violations:
                all_results.extend(batch_shots)
                logger.info(
                    "shot_staging batch %d/%d attempt %d: %d shots ok",
                    batch_num, total_batches, attempt, len(batch_shots),
                )
                break

            if attempt < MAX_ATTEMPTS:
                logger.info(
                    "shot_staging batch %d/%d attempt %d: orient=%d fsc=%d, retry",
                    batch_num, total_batches, attempt,
                    len(orientation_violations), len(fsc_violations),
                )
                violations = orientation_violations  # for orientation retry hint
                fsc_v = fsc_violations  # for fsc retry hint
                continue

            # 소진 — raise 우선순위 (spec §8.1):
            if fsc_violations and not orientation_violations:
                raise AppError(
                    code="shot_staging.frame_spatial_contract_invalid",
                    message=(
                        f"shot_staging batch {batch_num}/{total_batches}: "
                        f"fsc post-validation failed after {MAX_ATTEMPTS} attempts"
                    ),
                    details={"fsc_violations": fsc_violations},
                )
            if fsc_violations and orientation_violations:
                raise AppError(
                    code="shot_staging.frame_spatial_contract_invalid",
                    message=(
                        f"shot_staging batch {batch_num}/{total_batches}: "
                        f"both orientation + fsc violations after {MAX_ATTEMPTS} attempts"
                    ),
                    details={
                        "orientation_violations": orientation_violations,
                        "fsc_violations": fsc_violations,
                    },
                )
            raise ShotStagingOrientationError(
                batch_num=batch_num,
                total_batches=total_batches,
                attempts=MAX_ATTEMPTS,
                violations=orientation_violations,
            )
```

  Update `_format_retry_hint(violations)` call at the start of the attempt loop. Find the line:

```python
            user_prompt = base_user_prompt if attempt == 1 else (
                base_user_prompt + _format_retry_hint(violations)
            )
```

  Replace with:

```python
            user_prompt = base_user_prompt if attempt == 1 else (
                base_user_prompt
                + _format_retry_hint(violations)
                + _format_fsc_retry_hint(fsc_v)
            )
```

  And initialize `fsc_v: List[Dict[str, Any]] = []` alongside `violations: List[Dict[str, Any]] = []` near the start of the batch loop.

### Step 3.3: Run all Group B tests + verify pass

- [ ] Run: `pytest backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py -v`
- [ ] Expected: 4 PASS

- [ ] Run broader regression: `pytest backend/tests/pipeline/test_shot_staging_orientation_validator.py -v`
- [ ] Expected: All existing orientation tests still PASS (no regression).

### Step 3.4: Commit Task 3

- [ ] Commit:

```bash
git add backend/app/modules/pipeline/shot_staging.py backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): shot_staging.py retry loop 통합

retry loop (try 외부) 에 fsc post-validation 추가. orientation pattern
mirror — list 반환 후 retry hint append. 소진 시 raise 우선순위:
fsc only → AppError(fsc_invalid), both → AppError(fsc_invalid)+detail.
orientation only → ShotStagingOrientationError (legacy 보존). provider
exception 은 기존 failed batch break (retry X). 4 pipeline tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: render_prompt_card inject + assert + canonicalize + 5 tests

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:883~` (build_render_strategy)
- Modify: `backend/app/core/steps/render_prompt_card.py:2578~` (assert_card_shape inline)
- Modify: `backend/app/core/steps/render_prompt_card.py:2222~` (canonicalize)
- Modify: `backend/tests/unit/test_render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card_hash.py`

### Step 4.1: 3 tests for build_render_strategy inject + cross-check (Group C-1)

- [ ] Append to `backend/tests/unit/test_render_prompt_card.py`:

```python
# ── Group C-1: frame_spatial_contract inject + cross-check ────────────────


def _fsc_contract_obj():
    return {
        "reason": "points_to_anchor",
        "constraints": [{
            "target_kind": "character", "target_id": "C02", "label": "B",
            "screen_zone": "lower_right", "depth_plane": "foreground",
            "gesture_action": "points_to", "gesture_target_label": "door",
        }],
    }


def test_build_render_strategy_carries_frame_spatial_contract():
    """staging 의 frame_spatial_contract 가 render_strategy.frame_spatial_contract 로 carry."""
    from app.core.steps.render_prompt_card import build_render_strategy
    staging = {
        "camera_direction": "Wide eye level",
        "lighting_mood": "soft",
        "key_bg_elements": [],
        "frame_spatial_contract": _fsc_contract_obj(),
    }
    shot_info = {"primary_subject": "B"}
    visible_entities = ["C02"]  # SID list (render_prompt_card.py:1020 일관)
    rs = build_render_strategy(
        seg={}, shot_info=shot_info, staging=staging,
        perception_mode="direct", visible_entities=visible_entities,
    )
    fsc = rs["frame_spatial_contract"]
    assert fsc is not None
    assert fsc["constraints"][0]["constraint_id"] == "fsc_001"


def test_build_render_strategy_null_contract_carries_null():
    """staging.frame_spatial_contract == None → render_strategy.frame_spatial_contract == None."""
    from app.core.steps.render_prompt_card import build_render_strategy
    staging = {
        "camera_direction": "Wide", "lighting_mood": "x",
        "key_bg_elements": [], "frame_spatial_contract": None,
    }
    rs = build_render_strategy(
        seg={}, shot_info={"primary_subject": ""}, staging=staging,
        perception_mode="direct", visible_entities=[],
    )
    assert rs["frame_spatial_contract"] is None


def test_build_render_strategy_cross_check_failure_raises():
    """visible_entities 에 없는 character_id → AppError."""
    from app.core.steps.render_prompt_card import build_render_strategy
    from app.core.errors import AppError
    staging = {
        "camera_direction": "Wide", "lighting_mood": "x", "key_bg_elements": [],
        "frame_spatial_contract": _fsc_contract_obj(),  # C02 사용
    }
    with pytest.raises(AppError) as exc_info:
        build_render_strategy(
            seg={}, shot_info={"primary_subject": ""}, staging=staging,
            perception_mode="direct", visible_entities=[],
        )
    assert "fsc_cross_check_failed" in exc_info.value.code
```

### Step 4.2: 2 tests for canonicalize hash stability (Group C-2)

- [ ] Append to `backend/tests/unit/test_render_prompt_card_hash.py`:

```python
# ── Group C-2: canonicalize frame_spatial_contract.constraints sort ───────


def test_canonicalize_sorts_fsc_constraints_by_constraint_id():
    """LLM emit 순서 다른 두 card 의 canonical payload 가 동일 (hash 안정)."""
    from app.core.steps.render_prompt_card import canonicalize_render_prompt_card
    c1 = {"constraint_id": "fsc_001", "target_kind": "character", "target_id": "C02",
          "label": "B", "screen_zone": "lower_right", "depth_plane": "foreground",
          "gesture_action": "points_to", "gesture_target_label": "door"}
    c2 = {"constraint_id": "fsc_002", "target_kind": "background", "target_id": "",
          "label": "door", "screen_zone": "upper_center", "depth_plane": "background",
          "gesture_action": "none", "gesture_target_label": ""}
    base_card = {
        "schema_version": "1.0",
        "shot_key": {"scene_index": 1, "shot_index": 1},
        "render_strategy": {
            "spatial_consistency": {},  # builder-static, present
            "frame_spatial_contract": {"reason": "points_to_anchor", "constraints": [c2, c1]},
        },
        "id_policy": {}, "background_binding": {},
        "continuity_elements_used": {}, "asset_requirements": {},
        "render_contracts": [],
    }
    canon = canonicalize_render_prompt_card(base_card)
    sorted_constraints = canon["render_strategy"]["frame_spatial_contract"]["constraints"]
    assert [c["constraint_id"] for c in sorted_constraints] == ["fsc_001", "fsc_002"]


def test_canonicalize_null_fsc_does_not_raise():
    """frame_spatial_contract == None → canonicalize OK (raise X)."""
    from app.core.steps.render_prompt_card import canonicalize_render_prompt_card
    card = {
        "schema_version": "1.0", "shot_key": {"scene_index": 1, "shot_index": 1},
        "render_strategy": {"spatial_consistency": {}, "frame_spatial_contract": None},
        "id_policy": {}, "background_binding": {},
        "continuity_elements_used": {}, "asset_requirements": {},
        "render_contracts": [],
    }
    canon = canonicalize_render_prompt_card(card)
    assert canon["render_strategy"]["frame_spatial_contract"] is None
```

- [ ] Run: `pytest backend/tests/unit/test_render_prompt_card.py::test_build_render_strategy_carries_frame_spatial_contract backend/tests/unit/test_render_prompt_card.py::test_build_render_strategy_null_contract_carries_null backend/tests/unit/test_render_prompt_card.py::test_build_render_strategy_cross_check_failure_raises backend/tests/unit/test_render_prompt_card_hash.py::test_canonicalize_sorts_fsc_constraints_by_constraint_id backend/tests/unit/test_render_prompt_card_hash.py::test_canonicalize_null_fsc_does_not_raise -v`
- [ ] Expected: 5 FAIL

### Step 4.3: Implement build_render_strategy inject

- [ ] Edit `backend/app/core/steps/render_prompt_card.py` `build_render_strategy()` signature and body. Add a new keyword argument `visible_entities` (default `None` for backwards compatibility with existing callers that pass it elsewhere — check call sites).

  Find:

```python
def build_render_strategy(
    *,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    staging: Optional[Dict[str, Any]],
    perception_mode: Optional[str],
) -> Dict[str, Any]:
```

  Replace with:

```python
def build_render_strategy(
    *,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    staging: Optional[Dict[str, Any]],
    perception_mode: Optional[str],
    visible_entities: Optional[List[str]] = None,
) -> Dict[str, Any]:
```

  After the lines that build `spatial_consistency` and `constraints` (line 927-928), before the `if staging is None:` branch, add:

```python
    # frame_spatial_contract carry + constraint_id assign + visible_entities cross-check.
    # visible_entities 는 SID list (render_prompt_card.py:1020 일관).
    from app.core.frame_spatial_contract import validate_and_prepare as _fsc_validate_and_prepare
    raw_fsc = (staging or {}).get("frame_spatial_contract") if staging is not None else None
    fsc_prepared = _fsc_validate_and_prepare(
        raw_fsc,
        visible_entities,
    )
```

  In **both** return dicts (`staging is None` branch and the normal branch), add `"frame_spatial_contract": fsc_prepared,` next to the existing `"spatial_consistency": spatial_consistency,` line.

- [ ] Update the caller `build_render_prompt_card` (further up/down the file) to pass `visible_entities` through. Search for the existing call site `build_render_strategy(` and update.

### Step 4.4: Implement assert_card_shape inline nullable check

- [ ] In `assert_card_shape()`, after line 2591 (`_assert_spatial_consistency_shape(rs["spatial_consistency"], where)`), add:

```python
    # frame_spatial_contract nullable shape check (object or None)
    if "frame_spatial_contract" not in rs:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy missing 'frame_spatial_contract' "
                f"(opt-in nullable field) {where}"
            ),
        )
    fsc = rs["frame_spatial_contract"]
    if fsc is not None:
        if not isinstance(fsc, dict):
            raise AppError(
                code="step.contract_violation",
                message=f"render_strategy.frame_spatial_contract must be dict or None, got {type(fsc).__name__} {where}",
            )
        if "reason" not in fsc or "constraints" not in fsc:
            raise AppError(
                code="step.contract_violation",
                message=f"render_strategy.frame_spatial_contract missing reason or constraints {where}",
            )
        if not isinstance(fsc["constraints"], list):
            raise AppError(
                code="step.contract_violation",
                message=f"render_strategy.frame_spatial_contract.constraints must be list {where}",
            )
```

### Step 4.5: Implement canonicalize sort

- [ ] In `canonicalize_render_prompt_card()`, after the `render_contracts` sort block (search for `render_contracts` near line ~2295), add:

```python
    rs = payload.get("render_strategy")
    if isinstance(rs, dict):
        fsc = rs.get("frame_spatial_contract")
        if isinstance(fsc, dict) and isinstance(fsc.get("constraints"), list):
            fsc["constraints"] = sorted(
                fsc["constraints"],
                key=lambda c: c.get("constraint_id", ""),
            )
```

  Also update the docstring `sort 대상:` list (line ~2231-2239) to add a bullet:

```python
      - render_strategy.frame_spatial_contract.constraints (dict list,
        key: constraint_id)  # 2026-05-14 — area-frame-spatial-contract
```

### Step 4.6: Run all Group C tests + verify pass

- [ ] Run: `pytest backend/tests/unit/test_render_prompt_card.py backend/tests/unit/test_render_prompt_card_hash.py -v -k "frame_spatial_contract or canonicalize_sorts_fsc or canonicalize_null_fsc"`
- [ ] Expected: 5 PASS

- [ ] Run regression: `pytest backend/tests/unit/test_render_prompt_card.py backend/tests/unit/test_render_prompt_card_hash.py -v`
- [ ] Expected: All existing tests still PASS (no regression).

### Step 4.7: Commit Task 4

- [ ] Commit:

```bash
git add backend/app/core/steps/render_prompt_card.py backend/tests/unit/test_render_prompt_card.py backend/tests/unit/test_render_prompt_card_hash.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): render_prompt_card inject + assert + sort

build_render_strategy: visible_entities cross-check + constraint_id assign
via validate_and_prepare. assert_card_shape: frame_spatial_contract
nullable shape strict (object or None). canonicalize_render_prompt_card:
constraints sort by constraint_id (LLM emit 순서 무관 hash 안정).
5 unit tests (3 inject + 2 canonicalize).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: scene_detail schema v23 + prompt v23 + version/manifest

**Files:**
- Create: `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/detail_schema.json`
- Create: `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/system.md`
- Modify: `backend/app/core/steps/detail_steps.py:110~111`
- Modify: `backend/app/core/step_manifest.py:704`
- Modify: `backend/app/core/version_registry.py`
- Modify: `backend/tests/test_prompt_versions.py`

### Step 5.1: Determine timestamp

- [ ] Run: `date '+%Y%m%d%H%M'`
- [ ] Final directory: `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/`.

### Step 5.2: Create detail_schema.json (v23)

- [ ] Copy `prompts/_base/scene_detail/22.202605122049/detail_schema.json` content verbatim to `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/detail_schema.json`.

- [ ] In the new file, add `applied_frame_spatial_constraint_ids` to each `t2i_variations[].items.properties` block AND its `required` array. Replacement diff:

  Find the `"required"` array inside `t2i_variations[].items` (currently `["variant_label", "camera_effect", "t2i_prompt", "outfit_assignments", "source_facts", "visual_inferences", "creative_decisions", "confidence"]`). Add `"applied_frame_spatial_constraint_ids"` to it.

  Add a new property entry inside `properties` (before the closing brace of the variation item):

```json
"applied_frame_spatial_constraint_ids": {
  "type": "array",
  "items": {"type": "string"},
  "description": "Echo of render_strategy.frame_spatial_contract.constraints[].constraint_id (fsc_NNN). Empty array when contract is null. MUST equal injected id set for every variation when contract is present."
}
```

### Step 5.3: Create system.md (v23)

- [ ] Copy `prompts/_base/scene_detail/22.202605122049/system.md` content verbatim to `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/system.md`.

- [ ] After the existing `## Spatial consistency (camera frame / fg-bg anchor / primary framing scale)` section, insert a new section:

````markdown

## Frame Spatial Contract (강제 반영)

`[RenderPromptCard v1]` 의 `render_strategy.frame_spatial_contract` 가 **null 이 아닌** 경우, **모든 `t2i_variations[]` item 의 `t2i_prompt` 첫 문장**은 contract 의 각 constraint 의 화면 좌표/방향을 반영해야 한다.

### Constraint 별 반영 규칙

각 constraint 는 다음 정보를 갖는다:

- `constraint_id` — code-assigned (예: `fsc_001`)
- `target_kind` — `character` / `prop` / `background`
- `target_id` — `C##` / `P##` / `""` (background)
- `label` — 사람 읽기용
- `screen_zone` — 9 zone (upper_left ~ lower_right)
- `depth_plane` — foreground / midground / background
- `gesture_action` — none / points_to / reaches_for / looks_toward / moves_toward
- `gesture_target_label` — gesture 가 향하는 대상 (none 이면 empty)

**첫 문장 작성 시**:

1. 각 constraint 의 `label` (또는 `target_id`) 을 wording 안에 포함.
2. 각 constraint 의 `screen_zone` / `depth_plane` 을 자연어로 표현 (예: "in the upper-center background").
3. `gesture_action != "none"` 이면 `gesture_target_label` 도 함께 표현 (예: "pointing toward the entrance door").

예시 (contract 가 `{points_to_anchor, [B/character/lower_right/foreground/points_to/entrance door, entrance door/background/upper_center/background/none/]}`):

> "B stands in the lower-right foreground, pointing toward the entrance door in the upper-center background. ..."

### Echo 요구

모든 variation 의 `applied_frame_spatial_constraint_ids` 에 contract 의 **모든** constraint_id 를 echo 해야 한다.

- contract 있음: 각 variation echo set = `{fsc_001, fsc_002, ...}` (injected set 과 정확 일치)
- contract null: 각 variation echo = `[]` (빈 array)

partial echo / 누락 / 추가 모두 금지. variation 별 wording 은 자유롭게 다르되 spatial constraint 반영은 동일.

### 충돌 시 우선순위

frame_spatial_contract 의 spatial 지시는 위 `## Spatial consistency` (camera_frame_rule / fg_bg_shared_anchor_rule / primary_framing_rule) 와 `## ID Policy` 와 충돌하지 않아야 한다. 만약 충돌이 발생하면:

- camera_frame_rule (geometric 제약) > contract 의 zone/depth 표현
- id_policy (body-part focus 등) > contract 의 label 표현

contract 자체가 위 제약과 모순되는 경우, 그건 shot_staging 의 잘못된 emit — scene_detail 은 합리적 wording 으로 정합 (e.g., "B in the lower-right foreground" 만 carry, zone 표기 안 함). 단 echo 는 그대로 유지.
````

### Step 5.4: Update detail_steps.py + step_manifest + version_registry

- [ ] Edit `backend/app/core/steps/detail_steps.py:110~111`:

```python
SCENE_DETAIL_SCHEMA_VERSION = 9  # 2026-05-14: t2i_variations[].applied_frame_spatial_constraint_ids required (area-frame-spatial-contract). 이전: 8 (D6 T5d).
SCENE_DETAIL_PROMPT_VERSION = "23.<YYYYMMDDHHmm>"  # 2026-05-14 (area-frame-spatial-contract) — v23 prompt: Frame Spatial Contract consumer section + per-variation echo.
```

  Replace `<YYYYMMDDHHmm>` with the actual timestamp used for the prompt directory.

- [ ] Edit `backend/app/core/step_manifest.py:704`:

```python
"schema_version": 9,
```

  Replace the existing `"schema_version": 8,`.

- [ ] Edit `backend/app/core/version_registry.py` scene_detail_composer entry:

```python
"scene_detail_composer": "1.23.0",    # 2026-05-14 — area-frame-spatial-contract: v23 prompt + schema_version 9 (per-variation echo applied_frame_spatial_constraint_ids).
```

### Step 5.5: Add 3 Group E tests for scene_detail v23

- [ ] Append to `backend/tests/test_prompt_versions.py`:

```python
def test_scene_detail_v23_schema_has_applied_fsc_ids():
    """scene_detail v23 schema 의 t2i_variations[] item 에 applied_frame_spatial_constraint_ids required."""
    # NOTE: REPO_ROOT 는 test_prompt_versions.py:5 에서 이미 정의됨 (Path(__file__).parent.parent.parent).
    # 상대 Path("prompts/_base/scene_detail") 은 pytest cwd=backend/ 환경에서 실패하므로 REPO_ROOT 절대경로 사용.
    base = REPO_ROOT / "prompts" / "_base" / "scene_detail"
    v23_dirs = sorted([d for d in base.iterdir() if d.name.startswith("23.")])
    assert v23_dirs, "scene_detail v23 디렉토리 없음"
    schema_file = v23_dirs[-1] / "detail_schema.json"
    schema = json.loads(schema_file.read_text())
    var_props = schema["properties"]["t2i_variations"]["items"]["properties"]
    var_required = schema["properties"]["t2i_variations"]["items"]["required"]
    assert "applied_frame_spatial_constraint_ids" in var_props
    assert "applied_frame_spatial_constraint_ids" in var_required
    assert var_props["applied_frame_spatial_constraint_ids"]["type"] == "array"
    assert var_props["applied_frame_spatial_constraint_ids"]["items"]["type"] == "string"


def test_step_manifest_scene_detail_schema_version_9():
    # NOTE: step_manifest.py 의 실제 export 이름은 STEP_MANIFEST (line 73). STEP_DEFINITIONS 는 typo.
    from app.core.step_manifest import STEP_MANIFEST
    assert STEP_MANIFEST["scene_detail"]["schema_version"] == 9


def test_detail_steps_constants_match():
    from app.core.steps.detail_steps import SCENE_DETAIL_SCHEMA_VERSION, SCENE_DETAIL_PROMPT_VERSION
    assert SCENE_DETAIL_SCHEMA_VERSION == 9
    assert SCENE_DETAIL_PROMPT_VERSION.startswith("23.")


def test_version_registry_scene_detail_composer_1_23_0():
    """version_registry.py:1 의 MODULE_VERSIONS dict 확인."""
    from app.core.version_registry import MODULE_VERSIONS
    assert MODULE_VERSIONS["scene_detail_composer"] == "1.23.0"
```

- [ ] Run: `pytest backend/tests/test_prompt_versions.py -v -k "scene_detail or step_manifest_scene or detail_steps_constants or version_registry_scene"`
- [ ] Expected: 4 PASS

### Step 5.6: Commit Task 5

- [ ] Commit:

```bash
git add prompts/_base/scene_detail/23.* backend/app/core/steps/detail_steps.py backend/app/core/step_manifest.py backend/app/core/version_registry.py backend/tests/test_prompt_versions.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): scene_detail schema v23 + prompt v23

v22 + applied_frame_spatial_constraint_ids required field 추가 (per-variation
echo). prompt v23: ## Frame Spatial Contract consumer section (강제 반영
규칙 + echo 요구 + 충돌 시 우선순위). SCENE_DETAIL_SCHEMA_VERSION 8→9.
SCENE_DETAIL_PROMPT_VERSION 23.<ts>. step_manifest scene_detail 9.
version_registry scene_detail_composer 1.23.0. 4 Group E tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: scene_detail post-validation echo + retry 통합 (regression only, no new tests)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:2568~`
- Modify: `backend/tests/unit/test_scene_detail_runner_retry.py`

### Step 6.1: echo 검증 책임 분리 — 새 unit test 없음

**의도 변경 (사용자 plan review carry)**: `_analyze_one` 는 `SceneDetailStep` method (`detail_steps.py:1834`) 로 직접 호출 시 ctx + card_by_shot 등 복잡 fixture 필요. echo 검증의 본질 책임은 helper unit (`validate_echoes`) 가 이미 Group A 4 test 로 cover (null match / null nonempty mismatch / contract match / contract missing).

따라서 본 Task 는:

- **새 echo-specific unit test 추가 없음** — Group A 의 validate_echoes test 가 echo 검증 책임.
- **detail_steps wiring 자체의 regression**: 기존 `test_scene_detail_runner_retry.py` 의 모든 test PASS 유지 — Step 6.3 에서 검증.
- **Codex review 의무 항목 (carry note)**: `_check_prompts` correction retry path 안 `validate_echoes` 통합이 올바른 위치에 있는지, retry hint merge 가 올바른지 review 시 필수 확인.

- [ ] No new test additions in this sub-step. Proceed to Step 6.2 (implementation).

### Step 6.2: detail_steps.py _check_prompts retry path 안 echo 검증 통합

- [ ] Edit `backend/app/core/steps/detail_steps.py`. At the top of the file, add:

```python
from app.core.frame_spatial_contract import (
    validate_echoes as _fsc_validate_echoes,
    phrase_diagnostic as _fsc_phrase_diagnostic,
)
```

  Around line 2568 (existing `_check_prompts(result)` block) — read lines 2560-2640 for context. The integrated retry flow should be:

```python
            result = call_structured(
                step="scene_detail",
                system_prompt=system,
                user_prompt=user_prompt,
                response_schema=schema,
                project_config=self.project_config,
                schema_name=f"scene_detail_{si}",
                opik_metadata=self.build_opik_metadata(extra_metadata={"scene_index": si}),
            )

            to_remove = _check_prompts(result)
            fsc_echo_violations = _fsc_validate_echoes(self.card_by_shot[(si, shot_idx)], result)
            # card_by_shot 명명은 file 의 실제 attribute 와 매칭 필요 — 주변 변수 참조하라.

            if to_remove or fsc_echo_violations:
                corrections = []
                if to_remove:
                    corrections.append(f"제거할 ID: {', '.join(sorted(to_remove))}")
                for v in fsc_echo_violations:
                    corrections.append(
                        f"variation '{v['variant_label']}': applied_frame_spatial_constraint_ids "
                        f"echo 가 contract id set 과 다릅니다. "
                        f"missing={v['missing']} extra={v['extra']} "
                        f"(injected={v['injected_ids']})"
                    )
                logger.warning("Scene %d: %s — retry", si, " / ".join(corrections))

                retry_prompt = (
                    f"[수정 요청]\n"
                    + "\n".join(f"- {c}" for c in corrections)
                    + "\n\n" + user_prompt
                )
                result = call_structured(
                    step="scene_detail",
                    system_prompt=system,
                    user_prompt=retry_prompt,
                    response_schema=schema,
                    project_config=self.project_config,
                    schema_name=f"scene_detail_{si}_retry",
                    opik_metadata=self.build_opik_metadata(extra_tags=["retry"], extra_metadata={"scene_index": si}),
                )

                # retry 후 재검사
                post_remove = _check_prompts(result)
                if post_remove:
                    # 기존 graceful fix (변형 치환 / 강제 제거) 그대로 유지
                    ...  # 기존 code carry

                post_fsc = _fsc_validate_echoes(self.card_by_shot[(si, shot_idx)], result)
                if post_fsc:
                    raise AppError(
                        code="scene_detail.frame_spatial_contract_echo_mismatch",
                        message=(
                            f"scene_detail S{si} shot{shot_idx}: per-variation echo mismatch "
                            f"after 1 retry. graceful fix 불가능."
                        ),
                        details={"violations": post_fsc},
                    )

            # phrase diagnostic — soft warning only
            rs = self.card_by_shot[(si, shot_idx)].get("render_strategy", {})
            fsc = rs.get("frame_spatial_contract")
            if fsc is not None:
                constraints = fsc.get("constraints", [])
                for var in result.get("t2i_variations", []):
                    diag = _fsc_phrase_diagnostic(var.get("t2i_prompt", ""), constraints)
                    for d in diag:
                        logger.warning(
                            "scene_detail S%d shot%d variation %s frame_spatial_contract "
                            "phrase diagnostic: %s",
                            si, shot_idx, var.get("variant_label"), d,
                        )
```

  **Important**: The exact variable name for the per-shot card lookup (above shown as `self.card_by_shot[(si, shot_idx)]`) must match the existing variable in `detail_steps.py`. Read the surrounding `_check_prompts` callsite to find the right reference. Do not invent a name.

### Step 6.3: Regression verification

- [ ] Run: `pytest backend/tests/unit/test_scene_detail_runner_retry.py -v`
- [ ] Expected: All existing tests still PASS (no regression — echo 통합이 기존 retry path 를 깨지 않음).

- [ ] Run helper unit Group A validate_echoes tests:

```bash
pytest backend/tests/unit/test_frame_spatial_contract.py -v -k validate_echoes
```

- [ ] Expected: 4 PASS — echo 검증 본질 책임은 helper unit 이 cover.

### Step 6.4: Commit Task 6

- [ ] Commit:

```bash
git add backend/app/core/steps/detail_steps.py backend/tests/unit/test_scene_detail_runner_retry.py
git commit -m "$(cat <<'EOF'
feat(area-frame-spatial-contract): scene_detail post-validation echo + retry

_check_prompts correction retry path 에 fsc_validate_echoes 통합 — 1 회
retry 후에도 echo set mismatch 면 AppError(echo_mismatch) raise. null
contract → 모든 variation echo = [] 강제. phrase_diagnostic soft warning
logging (raise 없음). regression only (echo 검증 책임은 Group A
validate_echoes 4 tests). Codex review 의무: detail_steps wiring 위치
수동 확인.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Final verification

- [ ] Run full Group A~E test suite:

```bash
pytest \
  backend/tests/unit/test_frame_spatial_contract.py \
  backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py \
  backend/tests/unit/test_render_prompt_card.py \
  backend/tests/unit/test_render_prompt_card_hash.py \
  backend/tests/unit/test_scene_detail_runner_retry.py \
  backend/tests/test_prompt_versions.py \
  -v
```

- [ ] Expected: 30 new tests PASS (Group A 15 + B 4 + C 5 + E 6) + scene_detail regression 0 (test_scene_detail_runner_retry.py 기존 test PASS 유지).

- [ ] Run broader regression:

```bash
pytest backend/tests/unit/ backend/tests/pipeline/ backend/tests/core/ -v --tb=short 2>&1 | tail -30
```

- [ ] Expected: 0 regression.

- [ ] Verify all 6 commits land in clean order on `main`:

```bash
git log --oneline a9bf378..HEAD
```

- [ ] Expected: 6 commits — `frame_spatial_contract` 으로 시작.

- [ ] **Do not push.** Stop and request Codex review per existing pattern (메모리 [[session_20260515_area_d_next_min_clean_rebuild]] §"Codex external review" 참조). 운영자 결정 후 `git push origin main`.

---

## Open carry (implementation 단계 결정)

- **C##/P## 정규식 형식**: 본 plan 은 `^C\d{2,3}$` / `^P\d{2,3}$` 채택 (2-3 digit). 기존 entity_canon naming 과 일치 확인 후 필요 시 fix-up commit. 만약 entity_canon 이 정확히 2-digit 만 사용한다면 `^C\d{2}$` / `^P\d{2}$` 로 narrow.
- **`<YYYYMMDDHHmm>` timestamp**: Task 2.1, Task 5.1 의 실제 timestamp 값을 commit message + detail_steps.py 의 PROMPT_VERSION 상수 + version_registry comment 에 모두 동일 반영.
- **shot_staging_step.py wrapper**: spec §1 비협상 #5 — variation 없음. `shot_staging.py` retry loop 만 변경.
- **카드 attribute name (`self.card_by_shot[...]`)**: Task 6.2 의 actual variable 은 file 의 실제 변수명 확인 후 사용. 추측 금지.

## References

- Spec: [docs/superpowers/specs/2026-05-14-frame-spatial-contract-design.md](../specs/2026-05-14-frame-spatial-contract-design.md)
- D-next-min predecessor: commit `5f3da38`
- Existing patterns:
  - `shot_staging.py:25~` (`_find_orientation_violations`) — fsc validator mirror
  - `shot_staging.py:45~` (`_format_retry_hint`) — fsc retry hint mirror
  - `errors.py:154` (`ShotStagingOrientationError`) — typed exception 대안 (carry note)
  - `detail_steps.py:2568~` (`_check_prompts` retry path) — fsc echo 검증 통합
