# Framing Scale Enum SOT v1 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 schema v11 의 `framing_scale: enum["close","medium","wide","insert"]` LLM-emit required field 를 단일 production SOT 로 도입 + 새 helper module + 3 site regex / 2 canary keyword / 1 LLM-facing keyword inject / 1 validator cascade 전수 제거 + close × ref_usage fail-fast matrix.

**Architecture:** 4 layer atomic patch — (A) shot_staging v11 producer schema, (B) `app/core/framing_scale.py` single helper, (C) 4 consumer file (render_prompt_card / coordinator / detail_steps / scene_reference_service) helper read 전환, (D) scene_detail v24 prompt rewrite. close × ref_usage matrix 는 service-layer raise + caller-layer reraise pattern. AppError(`shot_staging.framing_scale_missing/_invalid`) + RefContractError(detail prefix `"close_ref_usage_violation: "`).

**Tech Stack:** Python 3 / FastAPI / Alembic / SQLAlchemy / pytest. Prompt: `prompts/_base/<module>/<version>/` file-fallback + DB-row override.

**Spec:** `docs/superpowers/specs/2026-05-15-framing-scale-enum-sot-v1-design.md` (commits `2077778 → a0cccbb → a9a47c1` APPROVED_FOR_PLAN).

---

## File Structure

### New files

| Path | Responsibility |
|---|---|
| `backend/app/core/framing_scale.py` | Helper module — `FRAMING_*` constants + `VALID_FRAMING_SCALES` frozenset + `get_framing_scale_or_raise(staging, where)` (단일 production reader, 2 AppError codes) |
| `prompts/_base/shot_staging/11.<TS>/system.md` | shot_staging v11 LLM instruction — v10 base + framing_scale 의미 정의 section (vocabulary 금지) |
| `prompts/_base/shot_staging/11.<TS>/schema.json` | shot_staging v11 schema — v10 base + `framing_scale` required enum field |
| `prompts/_base/scene_detail/24.<TS>/system.md` | scene_detail v24 — v23 base + `:155 + :159` `framing_scale_keywords` references → `framing_scale_source` rewrite (card enum 소비) |
| `prompts/_base/scene_detail/24.<TS>/<other files>` | v23 full-pack copy (parts/category/template etc.) |
| `backend/tests/test_framing_scale_helper.py` | Task 1 — 5 helper unit tests |
| `backend/tests/test_scene_reference_service_close_ref_usage_matrix.py` | Task 7 — 6 matrix unit tests (close+zoom_in_detail allow / close+exact_bg raise / close+atmosphere raise / close+empty raise / close+no_bytes early return / medium+any allow) |
| `backend/tests/test_coordinator_broad_except_reraise.py` | Task 6 — coordinator 2 broad except reraise tests |
| `backend/tests/test_render_prompt_card_framing_scale.py` | Task 3-4 — sentinel + helper read + validator cascade tests |

### Modified files (path + 변경 line)

| Path | 변경 line | Section |
|---|---|---|
| `backend/app/core/step_manifest.py` | `:599` `schema_version: 2 → 3` | Task 2 |
| `backend/app/core/version_registry.py` | `:33` shot_staging `2.3.0 → 2.4.0` / `:34` scene_detail_composer `1.23.0 → 1.24.0` / `:121-123` prompt_dependency `v10 → v11` / `:125-127` prompt_dependency `v23 → v24` | Task 2 + Task 8 |
| `backend/app/core/steps/render_prompt_card.py` | `:131-146` `_ID_CLOSE_FACE_*` 보존 (L-5 defer) / `:416-426` `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 삭제 (Task 4) / `:432-438` `_SPATIAL_FRAMING_WIDE_KEYWORDS` 삭제 (Task 4) / `:473-481` `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` 의 framing_scale_keywords 제거 (Task 5) / `:482-484` `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` 삭제 (Task 5) / `:546-557` `_INSERT_HINT_RE` + `_get_insert_hint_re()` 삭제 (Task 4) / `:560-570` `_derive_framing_scale` 폐기 + `_resolve_framing_scale_for_render` 신설 (Task 3) / `:749-772` `framing_scale_keywords` field inject 삭제 (Task 4) / `:955-961` + `:984` sentinel + helper consume (Task 3) / `:3282-3324` `_assert_primary_framing_rule_shape` cascade 정리 (Task 5) | Task 3 + 4 + 5 |
| `backend/app/services/scene_generation_coordinator.py` | `:71-81` `_CLOSE_FRAMING_RE` 정의 삭제 (Task 6) / `:352`, `:641`, `:1267` close read 3 callsite helper read 전환 (Task 6) / `:393`, `:678` broad except 2 site `except RefContractError: raise` 추가 (Task 6) | Task 6 |
| `backend/app/core/steps/detail_steps.py` | `:97-109` `_CLOSE_FRAMING_RE` 정의 삭제 (Task 5) / `:115` `SCENE_DETAIL_PROMPT_VERSION = "24.<TS>"` (Task 8) / `:158`, `:577`, `:1286`, `:1735`, `:2376`, `:2912` 6 callsite helper read 전환 (Task 5) / `:467`, `:1448`, `:1733` comment rewrite (Task 5) | Task 5 + 8 |
| `backend/app/services/scene_reference_service.py` | `:725` early return 보존 / `:796` 직후 close × ref_usage matrix block 추가 (Task 7) | Task 7 |
| `tests/.../g4_5a_primary_framing*.py` (정확 path = Task 9 grep) | `_SPATIAL_FRAMING_CLOSE_KEYWORDS` import/사용 제거 → staging.framing_scale read + fixture staging dict 에 framing_scale field 추가 | Task 9 |

---

## Pre-flight Setup

### P.1 Generate timestamps + verify clean state

- [ ] **P.1.1: Generate `<TS_SHOT_STAGING>` (UTC YYYYMMDDHHmm)**

```bash
date -u +'%Y%m%d%H%M'
```

기록: `<TS_SHOT_STAGING>` = result of date command at task 2 start. Plan task 진입 시점 timestamp.

- [ ] **P.1.2: Generate `<TS_SCENE_DETAIL>` (Task 8 진입 시 별도 timestamp)**

```bash
date -u +'%Y%m%d%H%M'
```

기록: `<TS_SCENE_DETAIL>` = result of date command at task 8 start.

- [ ] **P.1.3: Verify clean working state (path-limited git add 보호 — unrelated untracked 5 보존)**

Run: `git status --short`

Expected output 의 untracked 5:
```
?? .wave_1b_timestamp.txt
?? backend/db.sqlite
?? backend/tests/test_text_cleanup.py
?? docs/code-reviews/
?? error.log
```

**중요**: 본 plan 의 모든 commit step 에서 `git add .` / `git add -A` 절대 금지. 항상 `git add <explicit path>` only.

- [ ] **P.1.4: Baseline regression run (B1~B5 외 0 fail 확인)**

Run:
```bash
cd backend && .venv/bin/python -m pytest -q --tb=no 2>&1 | tail -30
```

Expected: `passed` count 3676+, `failed`/`error` count = B1~B5 외 0 (확인된 unrelated regression).

Baseline 확보 — Task 9 의 acceptance 비교 기준.

---

## Tasks

### Task 1: framing_scale helper module (subagent-friendly)

**Goal:** `backend/app/core/framing_scale.py` 신설 + 5 unit test PASS.

**Files:**
- Create: `backend/app/core/framing_scale.py`
- Create: `backend/tests/test_framing_scale_helper.py`

- [ ] **Step 1.1: Write failing tests**

Create `backend/tests/test_framing_scale_helper.py`:
```python
"""Unit tests for backend/app/core/framing_scale.py — framing_scale enum SOT v1.

Gate 4 (No Silent Fallback) 자가 검증 의무 — missing/invalid 시 AppError.
"""
import pytest

from app.core.errors import AppError
from app.core.framing_scale import (
    FRAMING_CLOSE,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
    FRAMING_INSERT,
    VALID_FRAMING_SCALES,
    get_framing_scale_or_raise,
)


def test_valid_framing_scales_frozenset_4_values():
    assert VALID_FRAMING_SCALES == frozenset({"close", "medium", "wide", "insert"})
    assert FRAMING_CLOSE == "close"
    assert FRAMING_MEDIUM == "medium"
    assert FRAMING_WIDE == "wide"
    assert FRAMING_INSERT == "insert"


def test_get_framing_scale_or_raise_returns_valid_enum():
    for value in ("close", "medium", "wide", "insert"):
        staging = {"framing_scale": value}
        assert get_framing_scale_or_raise(staging, where="test") == value


def test_get_framing_scale_or_raise_missing_staging_not_dict():
    with pytest.raises(AppError) as excinfo:
        get_framing_scale_or_raise(None, where="test.none")
    assert excinfo.value.code == "shot_staging.framing_scale_missing"
    assert "test.none" in excinfo.value.message
    assert excinfo.value.status_code == 422

    with pytest.raises(AppError) as excinfo:
        get_framing_scale_or_raise("not a dict", where="test.str")
    assert excinfo.value.code == "shot_staging.framing_scale_missing"


def test_get_framing_scale_or_raise_missing_key():
    with pytest.raises(AppError) as excinfo:
        get_framing_scale_or_raise({}, where="test.empty")
    assert excinfo.value.code == "shot_staging.framing_scale_missing"
    assert "test.empty" in excinfo.value.message
    assert "Legacy" in excinfo.value.message or "legacy" in excinfo.value.message


def test_get_framing_scale_or_raise_invalid_value():
    with pytest.raises(AppError) as excinfo:
        get_framing_scale_or_raise({"framing_scale": "huge"}, where="test.invalid")
    assert excinfo.value.code == "shot_staging.framing_scale_invalid"
    assert "test.invalid" in excinfo.value.message
    assert "huge" in excinfo.value.message
```

- [ ] **Step 1.2: Run tests to verify FAIL**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_framing_scale_helper.py -v 2>&1 | tail -20
```

Expected: 5 ERRORS — `ImportError: cannot import name 'FRAMING_CLOSE' from 'app.core.framing_scale'` (module 없음).

- [ ] **Step 1.3: Implement framing_scale.py**

Create `backend/app/core/framing_scale.py`:
```python
"""Framing scale enum SOT — sole production reader for shot_staging.framing_scale.

No regex fallback. Missing/invalid framing_scale → AppError (gate 4).

Audit reference: docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/
00-index.md §8.1 (2). Spec: docs/superpowers/specs/2026-05-15-framing-scale-enum-sot-v1-design.md.
"""
from typing import Mapping, Optional

from app.core.errors import AppError

FRAMING_CLOSE = "close"
FRAMING_MEDIUM = "medium"
FRAMING_WIDE = "wide"
FRAMING_INSERT = "insert"

VALID_FRAMING_SCALES = frozenset({
    FRAMING_CLOSE,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
    FRAMING_INSERT,
})


def get_framing_scale_or_raise(
    staging: Optional[Mapping[str, object]],
    *,
    where: str,
) -> str:
    """Read framing_scale enum from shot_staging output.

    Args:
        staging: shot_staging step manifest entry (single shot dict). None or
            non-mapping → AppError.
        where: caller context for error message (e.g. "render_prompt_card.derive",
            "coordinator.batch.close_check", "scene_reference_service.matrix S5_Shot2").

    Returns:
        One of {"close", "medium", "wide", "insert"}.

    Raises:
        AppError(code="shot_staging.framing_scale_missing") — staging not mapping
            or framing_scale key absent.
        AppError(code="shot_staging.framing_scale_invalid") — value outside
            VALID_FRAMING_SCALES.
    """
    if not isinstance(staging, Mapping):
        raise AppError(
            code="shot_staging.framing_scale_missing",
            message=(
                f"framing_scale read failed at {where}: staging is not a mapping "
                f"(got {type(staging).__name__})"
            ),
            status_code=422,
        )
    value = staging.get("framing_scale")
    if value is None:
        raise AppError(
            code="shot_staging.framing_scale_missing",
            message=(
                f"framing_scale read failed at {where}: key absent. Legacy "
                f"shot_staging cp likely — force re-run shot_staging step."
            ),
            status_code=422,
        )
    if value not in VALID_FRAMING_SCALES:
        raise AppError(
            code="shot_staging.framing_scale_invalid",
            message=(
                f"framing_scale invalid at {where}: got {value!r}, "
                f"expected one of {sorted(VALID_FRAMING_SCALES)}"
            ),
            status_code=422,
        )
    return value
```

- [ ] **Step 1.4: Run tests to verify PASS**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_framing_scale_helper.py -v 2>&1 | tail -15
```

Expected: `5 passed`.

- [ ] **Step 1.5: Commit Task 1**

```bash
git add backend/app/core/framing_scale.py backend/tests/test_framing_scale_helper.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 1 — app/core/framing_scale.py helper

shot_staging.framing_scale enum SOT single production reader. 4 enum constants
+ VALID_FRAMING_SCALES frozenset + get_framing_scale_or_raise(staging, where).
2 AppError codes (shot_staging.framing_scale_missing/_invalid). regex fallback 0.

5 unit tests PASS. Gate 4 (No Silent Fallback) 자가 검증 baseline.

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

---

### Task 2: shot_staging v11 prompt + manifest + version_registry sync (main session)

**Goal:** `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/` 신설 (schema + system.md) + manifest schema_version 2→3 + version_registry sync. legacy cp 자동 invalidation 검증.

**Files:**
- Create: `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/schema.json`
- Create: `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/system.md`
- Modify: `backend/app/core/step_manifest.py:599`
- Modify: `backend/app/core/version_registry.py:33, :121-123`

- [ ] **Step 2.1: Full-pack copy v10 → v11**

```bash
cp -r prompts/_base/shot_staging/10.202605141617 prompts/_base/shot_staging/11.<TS_SHOT_STAGING>
ls prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/
```

Expected: schema.json + system.md (v10 와 동일 파일 list).

- [ ] **Step 2.2: Edit schema.json — add `framing_scale` required enum**

Edit `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/schema.json`:

기존 `"properties": { "scene_index": ..., "shot_index": ..., "perspective": ...` 구조에서 `shot_index` 다음에 `framing_scale` 추가:

```json
"shot_index": {"type": "integer"},
"framing_scale": {
  "type": "string",
  "enum": ["close", "medium", "wide", "insert"],
  "description": "Primary framing scale of this shot. close = primary subject (face/hand/object) fills most of the frame. medium = upper body or mid-distance subject with some surrounding context. wide = full body or environment/layout visible. insert = isolated detail without surrounding context. Judge by camera distance and primary subject visibility (meaning, not vocabulary)."
},
"perspective": {"type": "string", ...
```

그리고 `"required": [...]` list 에서 `"shot_index"` 다음에 `"framing_scale"` 추가:

```json
"required": [
  "scene_index", "shot_index", "framing_scale",
  "perspective", "pov_character", "perception_mode",
  "camera_direction", "lighting_mood", "character_angles",
  "key_bg_elements", "frame_spatial_contract"
],
```

- [ ] **Step 2.3: Edit system.md — add framing_scale section (vocabulary 금지)**

Append to `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/system.md` (적절 위치, camera_direction 직전 또는 직후):

```markdown
## framing_scale (필수)

각 shot 의 primary framing scale 을 emit. enum 4 값:

- `close` — primary subject (face/hand/object) 가 frame 의 대부분을 차지. 배경/주변 entity 는 partial/soft/absent.
- `medium` — primary subject 의 상반신 또는 mid-distance. 배경 일부 visible (some surrounding context).
- `wide` — primary subject 의 full body 또는 environment/layout visible.
- `insert` — isolated detail without surrounding context. 단일 object 또는 partial element 만.

판단 기준 = camera 거리 + primary subject visibility (의미 기준). **특정 어휘 / camera 약어 / phrase list 기준 분류 금지** — gate 1/2 자가 검증.
```

- [ ] **Step 2.4: Edit step_manifest.py:599**

Edit `backend/app/core/step_manifest.py`:

```python
"shot_staging": {
    ...
    "schema_version": 3,  # 2026-05-15: framing_scale required enum 추가 (framing_scale enum SOT v1). 기존 cp default 2 → mismatch → invalidation.
},
```

- [ ] **Step 2.5: Edit version_registry.py:33 + :121-123**

Edit `backend/app/core/version_registry.py:33`:

```python
"shot_staging": "2.4.0",              # 2026-05-15 — v11 prompt + schema_version 3: framing_scale required enum (framing_scale enum SOT v1)
```

Edit `backend/app/core/version_registry.py:121-123`:

```python
"shot_staging": {
    "prompt_dependency": "shot_staging/v11",
    "updated_at": "2026-05-15",
},
```

- [ ] **Step 2.6: Run prompt_loader sanity test (load v11)**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/ -k 'shot_staging' -v 2>&1 | tail -20
```

Expected: 기존 shot_staging 관련 test 의 fail 가 framing_scale field 부재 / schema_version mismatch 이외 0. (실제 production cp 가 없으므로 schema_version mismatch 만 발견되어야 정상.)

추가 sanity:
```bash
cd backend && .venv/bin/python -c "
from app.modules.prompt_loader import load_schema, get_effective_source
src = get_effective_source('shot_staging', 'system')
print('effective_source:', src.get('candidates', {}).get('file'))
schema = load_schema('shot_staging', 'schema')
props = schema.get('properties', {}).get('shots', {}).get('items', {}).get('properties', {})
print('framing_scale in schema:', 'framing_scale' in props)
print('framing_scale enum:', props.get('framing_scale', {}).get('enum'))
"
```

Expected: `effective_source` 가 `prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/system.md` 경로 포함 + `framing_scale in schema: True` + enum 4 값 출력.

- [ ] **Step 2.7: Commit Task 2**

```bash
git add prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/ \
        backend/app/core/step_manifest.py \
        backend/app/core/version_registry.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 2 — shot_staging v11 + manifest + version_registry

v11 prompt new dir (schema.json + system.md, v10 base + framing_scale required
enum). step_manifest schema_version 2 → 3 (cp invalidation). version_registry
shot_staging 2.3.0 → 2.4.0 + prompt_dependency v10 → v11.

framing_scale enum: close / medium / wide / insert (의미 정의, vocabulary 금지
— gate 1/2 자가 검증).

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

---

### Task 3: render_prompt_card.py — helper + sentinel + consumer wiring (main session)

**Goal:** `_resolve_framing_scale_for_render(staging, shot_info)` 신설 + staging_not_applicable sentinel + 2 callsite consumer 전환. 단 regex source 정의는 Task 4 에서 삭제 (분리). validator cascade 는 Task 5.

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:560-570, :955-961, :984`
- Create: `backend/tests/test_render_prompt_card_framing_scale.py`

- [ ] **Step 3.1: Write failing tests**

Create `backend/tests/test_render_prompt_card_framing_scale.py`:
```python
"""Unit tests for render_prompt_card framing_scale helper consumption.

Spec §4.6 — _resolve_framing_scale_for_render(staging, shot_info):
- staging is None + staging_not_applicable=True → FRAMING_MEDIUM (sentinel)
- staging dict 정상 → get_framing_scale_or_raise(staging)
- staging is None + staging_not_applicable False → AppError (gate 4)
"""
import pytest

from app.core.errors import AppError
from app.core.framing_scale import FRAMING_CLOSE, FRAMING_MEDIUM
from app.core.steps.render_prompt_card import (
    _resolve_framing_scale_for_render,
)


def test_resolve_staging_not_applicable_returns_medium_sentinel():
    """staging is None + staging_not_applicable=True → FRAMING_MEDIUM deterministic."""
    result = _resolve_framing_scale_for_render(
        staging=None,
        shot_info={"staging_not_applicable": True, "camera_direction": "close-up of hand"},
    )
    assert result == FRAMING_MEDIUM


def test_resolve_normal_staging_returns_enum():
    """staging dict 정상 → helper read."""
    result = _resolve_framing_scale_for_render(
        staging={"framing_scale": "close"},
        shot_info={"camera_direction": "extreme close-up"},
    )
    assert result == FRAMING_CLOSE


def test_resolve_missing_staging_without_sentinel_raises():
    """staging is None + staging_not_applicable=False → AppError (gate 4)."""
    with pytest.raises(AppError) as excinfo:
        _resolve_framing_scale_for_render(
            staging=None,
            shot_info={"staging_not_applicable": False},
        )
    assert excinfo.value.code == "shot_staging.framing_scale_missing"


def test_resolve_missing_framing_scale_key_raises():
    """staging dict + framing_scale 누락 → AppError."""
    with pytest.raises(AppError) as excinfo:
        _resolve_framing_scale_for_render(
            staging={"camera_direction": "close-up"},
            shot_info={"camera_direction": "close-up"},
        )
    assert excinfo.value.code == "shot_staging.framing_scale_missing"
```

- [ ] **Step 3.2: Run tests to verify FAIL**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_render_prompt_card_framing_scale.py -v 2>&1 | tail -15
```

Expected: 4 ERRORS — `ImportError: cannot import name '_resolve_framing_scale_for_render'` (함수 없음).

- [ ] **Step 3.3: Implement `_resolve_framing_scale_for_render` in render_prompt_card.py**

Edit `backend/app/core/steps/render_prompt_card.py` — `_derive_framing_scale` 함수 (line 560-570) 를 다음으로 교체 (함수명 + signature 변경, regex import 보존 — Task 4 에서 제거):

```python
def _resolve_framing_scale_for_render(
    staging: Optional[Dict[str, Any]],
    shot_info: Optional[Dict[str, Any]],
) -> str:
    """staging_not_applicable mode 분기 + helper read.

    staging is None + shot_info.staging_not_applicable=True → FRAMING_MEDIUM
    (deterministic sentinel; helper raise 회피, R1-I1 의 not_applicable mode).
    그 외 → get_framing_scale_or_raise(staging, where="render_prompt_card.derive").
    """
    from app.core.framing_scale import (
        FRAMING_MEDIUM,
        get_framing_scale_or_raise,
    )
    if staging is None and (shot_info or {}).get("staging_not_applicable") is True:
        return FRAMING_MEDIUM
    return get_framing_scale_or_raise(
        staging, where="render_prompt_card.derive"
    )
```

- [ ] **Step 3.4: Replace 2 callsites of `_derive_framing_scale` in `build_render_strategy`**

Edit `backend/app/core/steps/render_prompt_card.py:961`:

기존:
```python
"framing_scale": _derive_framing_scale(shot_cam),
```

변경:
```python
"framing_scale": _resolve_framing_scale_for_render(staging, shot_info),
```

Edit `backend/app/core/steps/render_prompt_card.py:984-985`:

기존:
```python
framing = _derive_framing_scale(cam_dir)
return {
    "mode": RENDER_MODE_DIRECT,
    "primary_subject": primary,
    "framing_scale": framing,
    ...
```

변경:
```python
framing = _resolve_framing_scale_for_render(staging, shot_info)
return {
    "mode": RENDER_MODE_DIRECT,
    "primary_subject": primary,
    "framing_scale": framing,
    ...
```

- [ ] **Step 3.5: Run tests to verify PASS**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_render_prompt_card_framing_scale.py -v tests/test_framing_scale_helper.py -v 2>&1 | tail -20
```

Expected: 9 passed (5 helper + 4 sentinel/consumer).

전체 render_prompt_card test sanity:
```bash
cd backend && .venv/bin/python -m pytest tests/ -k 'render_prompt_card' --tb=no -q 2>&1 | tail -10
```

Expected: 기존 fail count + 0 (사전 baseline 과 동일 또는 더 적음). 단 일부 기존 test 는 still depend on `_derive_framing_scale` — Task 4/5 에서 cleanup.

- [ ] **Step 3.6: Commit Task 3**

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/test_render_prompt_card_framing_scale.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 3 — render_prompt_card helper consumer + sentinel

_derive_framing_scale(camera_direction) → _resolve_framing_scale_for_render(
staging, shot_info). staging is None + staging_not_applicable=True →
FRAMING_MEDIUM deterministic (R1-I1 not_applicable mode sentinel; helper
raise 회피). 그 외 → get_framing_scale_or_raise(staging).

2 callsite consumer 전환 (:961 + :984). regex source (_CLOSE_FRAMING_RE
import + _INSERT_HINT_RE + canary tuples) + validator cascade 정리 = Task
4/5 로 분리 (atomic patch 단계 분리).

4 sentinel test PASS.

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

---

### Task 4: render_prompt_card.py — regex sources + LLM keyword inject 삭제 (main session)

**Goal:** `_CLOSE_FRAMING_RE` import + `_INSERT_HINT_RE` + `_get_insert_hint_re()` + `_SPATIAL_FRAMING_CLOSE_KEYWORDS` + `_SPATIAL_FRAMING_WIDE_KEYWORDS` + `framing_scale_keywords` field inject 전수 삭제. validator cascade 는 Task 5.

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:416-438, :546-557, :562, :749-772`

- [ ] **Step 4.1: Delete `_SPATIAL_FRAMING_CLOSE_KEYWORDS` + `_SPATIAL_FRAMING_WIDE_KEYWORDS` (line 410-438)**

Edit `backend/app/core/steps/render_prompt_card.py` — line 410-438 (comment + 두 tuple 정의) 전체 삭제:

기존 (line 410-438):
```python
# G4.5a ground-truth: close-framing keywords for canary
# `g4_5a_primary_framing.py` **9-entry production-aligned** (RO-5 binding —
# production `_CLOSE_FRAMING_RE` reuse). LLM-facing card
# `framing_scale_keywords.close` 11-entry list 와 분리 (RO-5 strict). canary
# close-framing shot scope filter 가 본 9-entry 만 사용 — `손이`/`눈이`/`얼굴이`
# 등 LLM-facing keyword 는 canary scope filter 에 포함되지 않음.
_SPATIAL_FRAMING_CLOSE_KEYWORDS: Tuple[str, ...] = (
    "close-up",
    "CU",
    "MCU",
    "ECU",
    "XCU",
    "extreme close-up",
    "medium close-up",
    "클로즈업",
    "손가락이",
)

# G4.5a ground-truth: wide-framing keywords (LLM-facing reasoning hint).
# RO-4 binding — production `_derive_framing_scale()` enum 은 close/medium/insert
# 만, wide 미생성. 본 list 는 LLM hint only — wide-class shots 는 medium 으로
# 매핑되어 `wide_medium_rules` 적용. card-only classification.
_SPATIAL_FRAMING_WIDE_KEYWORDS: Tuple[str, ...] = (
    "wide shot",
    "establishing",
    "aerial",
    "전경",
    "전신",
)
```

변경: 모두 삭제 (empty).

- [ ] **Step 4.2: Delete `_INSERT_HINT_RE` + `_get_insert_hint_re()` (line 545-557)**

Edit `backend/app/core/steps/render_prompt_card.py` — line 545-557 전체 삭제:

기존:
```python
# insert / cutaway hint regex (lazy compile — Task 3 only).
_INSERT_HINT_RE: Optional[Any] = None


def _get_insert_hint_re():
    """Lazy compile the insert/cutaway hint regex (영어 + 한국어)."""
    global _INSERT_HINT_RE
    if _INSERT_HINT_RE is None:
        _INSERT_HINT_RE = _re_module.compile(
            r"\b(insert|cutaway|cut-?in)\b|인서트|컷어웨이",
            _re_module.IGNORECASE,
        )
    return _INSERT_HINT_RE
```

변경: 모두 삭제.

- [ ] **Step 4.3: Delete `_CLOSE_FRAMING_RE` import + `_derive_framing_scale` remnant (line 559-570 area)**

`_resolve_framing_scale_for_render` 는 Task 3 에서 이미 추가. 그 자리에서 옛 `_derive_framing_scale` 함수 (`from app.core.steps.detail_steps import _CLOSE_FRAMING_RE` 포함) 가 완전 폐기되었는지 verify:

Run:
```bash
grep -n "_derive_framing_scale\|_CLOSE_FRAMING_RE" backend/app/core/steps/render_prompt_card.py
```

Expected: 0 hits. (Task 3 에서 이미 함수 교체 완료.)

만약 import / call site 잔존 시 모두 제거.

- [ ] **Step 4.4: Delete `framing_scale_keywords` field inject (line 749-772)**

Edit `backend/app/core/steps/render_prompt_card.py:734-773` (RO-5 binding comment + framing_scale_keywords dict 전체):

기존 (`build_spatial_consistency_dict` 또는 그 helper 안 — 아래는 변경 대상 fragment만, surrounding context 는 production code 의 기존 구조 보존):
```python
        # (preceding dict keys preserved as-is)
        "framing_scale_keywords": {
            "_comment": (
                "LLM-facing reasoning hints — not the production framing_scale "
                "enum. card framing_scale is close/medium/insert from "
                "_derive_framing_scale(); wide is LLM hint only (RO-4 / RO-5 "
                "binding). Canary close-framing shot scope filter uses "
                "production _CLOSE_FRAMING_RE (9-entry alignment) — separate "
                "from this 11-entry LLM list."
            ),
            # RO-5 binding: 11-entry LLM-facing list.
            "close": [
                "close-up", "CU", "MCU", "ECU", "XCU", "extreme close-up",
                "클로즈업", "손가락이", "손이", "눈이", "얼굴이",
            ],
            # RO-4 binding: 5-entry LLM-facing wide hint.
            "wide": [
                "wide shot", "establishing", "aerial",
                "전경", "전신",
            ],
            "medium_default": (
                "medium / mid / two-shot — anything not matching close/wide "
                "keywords"
            ),
        },
        "close_framing_rules": {
            # (existing close_framing_rules content preserved as-is — L-5 defer)
```

변경: `"framing_scale_keywords": {...},` 4-key dict 전체 삭제. `"close_framing_rules": {...}` 는 보존 (L-5 defer).

위 (line 734-748 RO-5 comment block 또는 그 안 framing_scale 관련 comment) 도 정리:

기존:
```python
            "framing_scale ∈ {close, medium, insert} from "
            "`_derive_framing_scale()` (render_strategy.framing_scale, "
            "production enum). wide-class shots are mapped to medium and use "
            "`wide_medium_rules`. The LLM may *additionally* infer wide from "
            "prose keywords (`wide shot` / `전경` / `establishing` / `aerial`) "
            "for stricter checking — these are LLM-side reasoning hints, not "
            "the production framing_scale enum (RO-4 binding — card-only "
            "classification)."
```

변경:
```python
            "framing_scale ∈ {close, medium, wide, insert} from "
            "`shot_staging.framing_scale` enum (render_strategy.framing_scale, "
            "production SOT). LLM 은 enum 만 소비 — keyword 분류 금지."
```

- [ ] **Step 4.4b: Rewrite `self_check_steps[0]` — LLM-facing camera_direction-inferred phrase 제거**

`primary_framing_rule.self_check_steps` (line 747-754) 의 첫 항 (line 748) 이 LLM 에게 "camera_direction 에서 framing_scale 추론" 지시. v1 의 핵심 = production reader = staging enum SOT — 이 phrase 잔존 시 LLM 이 enum 무시 + camera_direction 추론 가능 (gate 2 위반).

기존 (line 747-754):
```python
"self_check_steps": [
    "identify framing_scale from camera_direction",
    "if close: verify only primary subject is fully visible; other "
    "entities are partial / soft bg / absent",
    "verify same character's full body + body-part close-up are not "
    "combined",
    "if close: verify two faces are not both rendered sharp",
],
```

변경 (line 748 만 rewrite, 나머지 3 step 보존):
```python
"self_check_steps": [
    "read framing_scale enum from render_strategy.framing_scale (do NOT infer from camera_direction)",
    "if close: verify only primary subject is fully visible; other "
    "entities are partial / soft bg / absent",
    "verify same character's full body + body-part close-up are not "
    "combined",
    "if close: verify two faces are not both rendered sharp",
],
```

- [ ] **Step 4.5: Run tests + grep residue verification**

Run:
```bash
grep -nE "_CLOSE_FRAMING_RE|_INSERT_HINT_RE|_SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS|framing_scale_keywords|identify framing_scale from camera_direction" backend/app/core/steps/render_prompt_card.py
```

Expected: 0 hits (모두 삭제됨 — `identify framing_scale from camera_direction` phrase 도 self_check_steps rewrite 후 0).

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/ -k 'render_prompt_card' --tb=line -q 2>&1 | tail -20
```

Expected: 일부 기존 test 는 `framing_scale_keywords` 또는 `_SPATIAL_FRAMING_CLOSE_KEYWORDS` import / assertion 으로 fail — Task 5 에서 cleanup. 현재 task 의 acceptance = grep residue 0 만.

- [ ] **Step 4.6: Commit Task 4**

```bash
git add backend/app/core/steps/render_prompt_card.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 4 — render_prompt_card regex/keyword/LLM inject 삭제

_CLOSE_FRAMING_RE import (Task 3 에서 이미 polish) + _INSERT_HINT_RE +
_get_insert_hint_re() (line 546-557) + _SPATIAL_FRAMING_CLOSE_KEYWORDS
(line 416-426) + _SPATIAL_FRAMING_WIDE_KEYWORDS (line 432-438) +
framing_scale_keywords field inject (line 749-772) 전수 삭제. RO-4/RO-5
binding comment 도 정리 (LLM 은 enum 만 소비).

grep residue 0. validator cascade 정리 = Task 5.

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

---

### Task 5: render_prompt_card.py — shape validator cascade 정리 + existing test update (main session)

**Goal:** `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` 의 `framing_scale_keywords` 제거 + `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` 폐기 + `_assert_primary_framing_rule_shape` cascade 정리. existing test 동기.

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:473-484, :3282-3324`
- Modify: existing test files (grep 후 발견)

- [ ] **Step 5.1: Edit `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` (line 473-480)**

Edit `backend/app/core/steps/render_prompt_card.py:473-480`:

기존:
```python
_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "applies_when",
    "framing_scale_keywords",
    "close_framing_rules",
    "wide_medium_rules",
    "self_check_steps",
    "rationale_summary",
})
```

변경:
```python
_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "applies_when",
    "close_framing_rules",
    "wide_medium_rules",
    "self_check_steps",
    "rationale_summary",
})
```

- [ ] **Step 5.2: Delete `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` (line 482-484)**

Edit `backend/app/core/steps/render_prompt_card.py:482-484`:

기존:
```python
_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "close", "wide", "medium_default",
})
```

변경: 전체 삭제.

- [ ] **Step 5.3: Edit `_assert_primary_framing_rule_shape` cascade (line 3282-3324)**

Edit `backend/app/core/steps/render_prompt_card.py:3282-3324`:

기존 (framing_scale_keywords nested validation block):
```python
    # framing_scale_keywords — 3 sub-key (close 11-entry RO-5 / wide /
    # medium_default).
    fsk = pf["framing_scale_keywords"]
    if not isinstance(fsk, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.framing_scale_keywords must be dict "
                f"{where}"
            ),
        )
    fsk_returned = frozenset(fsk.keys())
    if not (_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS <= fsk_returned):
        missing = sorted(
            _SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS - fsk_returned
        )
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.framing_scale_keywords missing required "
                f"sub-keys {missing} {where}"
            ),
        )
    # close 11-entry RO-5 binding.
    if not (isinstance(fsk["close"], list) and len(fsk["close"]) == 11):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.framing_scale_keywords.close must be "
                f"list with 11 entries {where}"
            ),
        )
    # close entries must be lowercase strings.
    if not all(isinstance(x, str) for x in fsk["close"]):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.framing_scale_keywords.close must have "
                f"all string entries {where}"
            ),
        )
```

변경: 전체 block 삭제 (framing_scale_keywords field 가 더 이상 card 에 없음).

검증: `_assert_primary_framing_rule_shape` 의 다른 cascade (applies_when / close_framing_rules / wide_medium_rules / self_check_steps / rationale_summary) 보존.

- [ ] **Step 5.4: Grep existing tests for `framing_scale_keywords` / `_SPATIAL_FRAMING_*_KEYWORDS` / `_INSERT_HINT_RE`**

Run:
```bash
grep -rnE "framing_scale_keywords|_SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS|_INSERT_HINT_RE|_get_insert_hint_re" backend/tests/ --include='*.py'
```

기록: 발견된 file:line 모두 list. 각 test 의 변경 의무:
- `framing_scale_keywords` assertion → 삭제 (field 가 더 이상 없음)
- `_SPATIAL_FRAMING_CLOSE_KEYWORDS` / `_SPATIAL_FRAMING_WIDE_KEYWORDS` import → 삭제
- `_INSERT_HINT_RE` / `_get_insert_hint_re` import → 삭제

- [ ] **Step 5.5: Update each existing test found (한 file 씩 처리)**

각 test file:
- import 삭제
- assertion 삭제 (framing_scale_keywords field count 등)
- 필요시 `framing_scale` enum-based assertion 으로 대체

(Step 5.4 의 grep 결과 list 가 plan 실행 시 적용 — 실제 file:line 은 plan task 실행 시점 의 grep 결과에서 결정.)

- [ ] **Step 5.6: Run tests verify PASS**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/ -k 'render_prompt_card or primary_framing or framing_scale' --tb=line -q 2>&1 | tail -20
```

Expected: 0 fail (`framing_scale_keywords` 관련 기존 test 모두 update 완료 + 신규 test 4 pass).

- [ ] **Step 5.7: Commit Task 5**

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/<modified test files>
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 5 — render_prompt_card validator cascade + test 정리

_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS 의 framing_scale_keywords 제거
(6 → 5 keys: applies_when / close_framing_rules / wide_medium_rules /
self_check_steps / rationale_summary). _SPATIAL_FRAMING_SCALE_KEYWORDS_
REQUIRED_SUBKEYS 폐기. _assert_primary_framing_rule_shape 의 framing_scale_
keywords nested cascade 삭제 (3282-3324).

existing test 정리: framing_scale_keywords field count / RO-5 binding /
_SPATIAL_FRAMING_*_KEYWORDS import / _INSERT_HINT_RE import 모두 삭제.

Gate 1/3 자가 검증 진척.

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

---

### Task 6: coordinator.py — close read 3 callsite + broad except 2 reraise (main session)

**Goal:** `_CLOSE_FRAMING_RE` 정의 + 3 close read callsite + 2 broad except reraise. `:1267` 인근은 close read 만 (validate_attached_refs propagate).

**Files:**
- Modify: `backend/app/services/scene_generation_coordinator.py:71-81, :352, :393, :641, :678, :1267`
- Create: `backend/tests/test_coordinator_broad_except_reraise.py`

- [ ] **Step 6.1: Write failing tests**

Create `backend/tests/test_coordinator_broad_except_reraise.py`:
**구현자 지시 (No Placeholders 의무)**: 본 test file 의 4 test 는 `backend/tests/services/test_scene_generation_coordinator.py` 의 기존 single/batch entry test 패턴을 **복제 + 적응**하여 작성한다. 본 plan 안에 synthetic skeleton (`...` placeholder) 작성 금지 — Step 6.1 진입 시 다음 단계 수행:

1. `backend/tests/services/test_scene_generation_coordinator.py` read — single + batch entry fixture / setup / monkeypatch 패턴 식별
2. 4 test 작성 (모두 RefContractError import = `from app.core.ref_contract_validator import RefContractError` + `FRAMING_CLOSE` import = `from app.core.framing_scale import FRAMING_CLOSE`):

   **test_single_path_reraises_ref_contract_error**:
   - 기존 single entry fixture 복제
   - `monkeypatch.setattr` 또는 `unittest.mock.patch` 로 `SceneReferenceService.build_prev_shot_background_ref` 가 `RefContractError("close_ref_usage_violation: synthetic")` raise 하도록 설정
   - coordinator single entry 호출 → `pytest.raises(RefContractError)` assertion
   - `"close_ref_usage_violation"` substring in `str(excinfo.value)` 검증

   **test_batch_path_reraises_ref_contract_error**:
   - 동일 패턴, batch entry 사용
   - batch path 의 `self._reference_svc.build_prev_shot_background_ref` 가 RefContractError raise → reraise 검증

   **test_single_path_swallows_other_exception**:
   - 동일 패턴, `side_effect=ValueError("synthetic non-contract")` 사용
   - coordinator single entry 호출 → exception **안 raise** (broad except 가 swallow + logger.warning + entity-only fallback)
   - return type / `_prev_shot_ref is None` / labeled_refs 안 prev_shot label 부재 검증

   **test_close_read_uses_framing_scale_helper**:
   - staging dict `{"framing_scale": "close", "camera_direction": "wide angle establishing"}` (camera_direction 의도적 wide-style — regex 시절 medium 처리)
   - coordinator entry 호출 → `_is_close_framing` 가 helper read 결과 (`framing_scale == FRAMING_CLOSE`) 따라 True
   - chain_bg skip 분기 진입 검증 (caplog 또는 mock spy 로 `"chain_bg ref SKIPPED — close framing"` 로그 발생 확인)

3. Run test verify FAIL → Step 6.3 implementation 후 verify PASS.

**Acceptance**: test 4 모두 concrete (no `...` placeholders) + 기존 test fixture/setup 패턴 복제 + RefContractError import path / framing_scale enum import 정확 + 각 test 의 assertion 명시.

- [ ] **Step 6.2: Run tests verify FAIL**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_coordinator_broad_except_reraise.py -v 2>&1 | tail -15
```

Expected: 4 FAIL (RefContractError reraise 안 됨 + helper read 안 됨).

- [ ] **Step 6.3: Add RefContractError import + helper import + delete `_CLOSE_FRAMING_RE`**

Edit `backend/app/services/scene_generation_coordinator.py` — top imports section:

Add (적절 위치):
```python
from app.core.framing_scale import (
    FRAMING_CLOSE,
    get_framing_scale_or_raise,
)
from app.core.ref_contract_validator import RefContractError
```

Delete line 71-81 (comment + `_CLOSE_FRAMING_RE` 정의):

기존:
```python
# Phase 9.1 + v12: shot_staging.camera_direction 텍스트(자연어)에서 close 계열
# framing 키워드 감지. wide chain_bg PNG 와 close 인물 합성 시점의 scale 충돌을
# 피하기 위해 chain_bg ref 주입을 skip 한다.
# detail_steps._CLOSE_FRAMING_RE 와 **동일 정의 유지 필수** (양 layer 판정 mismatch
# 시 합성에 wide ref 들어가는데 prompt 는 close 가정 → "허공 얼굴" 결함 재발).
_CLOSE_FRAMING_RE = re.compile(
    r"\b(ECU|XCU|extreme close[\-\s]?up|MCU|medium close[\-\s]?up|"
    r"close[\-\s]?up|CU)\b"
    r"|클로즈업|손가락이",
    re.IGNORECASE,
)
```

변경: 전체 삭제.

- [ ] **Step 6.4: Replace 3 close read callsite (:352, :641, :1267)**

Each callsite — staging variable mapping:

`:352` (in single path, staging accessed as `staging`):

기존:
```python
_is_close_framing = bool(_cam_dir and _CLOSE_FRAMING_RE.search(_cam_dir))
```

변경:
```python
_is_close_framing = (
    get_framing_scale_or_raise(staging, where="coordinator.single.close_check")
    == FRAMING_CLOSE
)
```

`:641` (batch path, staging accessed as `_stg`):

기존:
```python
_is_close_framing = bool(_cam_dir and _CLOSE_FRAMING_RE.search(_cam_dir))
```

변경:
```python
_is_close_framing = (
    get_framing_scale_or_raise(_stg, where="coordinator.batch.close_check")
    == FRAMING_CLOSE
)
```

`:1267` (validator pre-check, staging accessed as `_staging = ctx.get("staging") or {}`):

기존:
```python
_is_close_framing = bool(_cam_dir and _CLOSE_FRAMING_RE.search(_cam_dir))
```

변경:
```python
_is_close_framing = (
    get_framing_scale_or_raise(_staging, where="coordinator.validator_pre_check.close_check")
    == FRAMING_CLOSE
)
```

(Note: staging 변수가 빈 dict `{}` 인 경우 — `_staging = ctx.get("staging") or {}` 가 빈 dict 면 helper 가 `framing_scale_missing` raise. legacy cp 의 normal failure mode 일치.)

- [ ] **Step 6.5: Add `except RefContractError: raise` to 2 broad except sites (:393, :678)**

Edit `:393-401` (single path):

기존:
```python
        try:
            _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
        except Exception as exc:
            logger.warning(
                "Scene %d Shot %d: prev_shot_ref build failed (%s) — "
                "entity-only fallback",
                still_data.get("scene_index", 0),
                still_data.get("shot_index", 0),
                exc,
            )
            _prev_shot_ref = None
```

변경:
```python
        try:
            _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
        except RefContractError:
            raise  # gate 4 — close × ref_usage violation fail-fast
        except Exception as exc:
            logger.warning(
                "Scene %d Shot %d: prev_shot_ref build failed (%s) — "
                "entity-only fallback",
                still_data.get("scene_index", 0),
                still_data.get("shot_index", 0),
                exc,
            )
            _prev_shot_ref = None
```

Edit `:678-683` (batch path) — 동일 패턴 (변수명 `self._reference_svc.build_prev_shot_background_ref` + `_stg` etc.):

기존:
```python
            try:
                _prev_shot_ref = self._reference_svc.build_prev_shot_background_ref(...)
            except Exception as exc:
                logger.warning(
                    "Scene %d Shot %d: prev_shot_ref build failed (%s) — falling back to entity-only refs",
                    still_data.get("scene_index", 0), still_data.get("shot_index", 0), exc,
                )
                _prev_shot_ref = None
```

변경:
```python
            try:
                _prev_shot_ref = self._reference_svc.build_prev_shot_background_ref(...)
            except RefContractError:
                raise  # gate 4 — close × ref_usage violation fail-fast
            except Exception as exc:
                logger.warning(
                    "Scene %d Shot %d: prev_shot_ref build failed (%s) — falling back to entity-only refs",
                    still_data.get("scene_index", 0), still_data.get("shot_index", 0), exc,
                )
                _prev_shot_ref = None
```

`:1267` 인근 (`validate_attached_refs` 호출 :1282-1286) — broad except 없음. 변경 X (validate_attached_refs 가 직접 RefContractError propagate).

- [ ] **Step 6.6: Run tests verify PASS + grep residue**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_coordinator_broad_except_reraise.py -v 2>&1 | tail -20
```

Expected: 4 passed.

Run:
```bash
grep -n "_CLOSE_FRAMING_RE" backend/app/services/scene_generation_coordinator.py
```

Expected: 0 hits.

- [ ] **Step 6.7: Commit Task 6**

```bash
git add backend/app/services/scene_generation_coordinator.py \
        backend/tests/test_coordinator_broad_except_reraise.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 6 — coordinator regex 삭제 + 3 close + 2 reraise

_CLOSE_FRAMING_RE 정의 (line 71-81) 삭제. close read 3 callsite (:352 single
/ :641 batch / :1267 validator_pre_check) 모두 get_framing_scale_or_raise
read 로 전환. 2 broad except site (:393 single / :678 batch) 의 except
RefContractError: raise 추가 (gate 4 fail-fast). :1267 인근은 close read 만
— validate_attached_refs (:1282-1286) 가 RefContractError 직접 propagate.

import: from app.core.framing_scale import FRAMING_CLOSE,
get_framing_scale_or_raise + from app.core.ref_contract_validator import
RefContractError.

4 reraise/helper test PASS. grep residue 0.

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

---

### Task 7: scene_reference_service.py — close × ref_usage matrix (subagent-friendly)

**Goal:** `build_prev_shot_background_ref` 안 `:725` early return 보존 + `:796` ref_usage read 직후 close × ref_usage matrix check + RefContractError raise.

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:796`
- Create: `backend/tests/test_scene_reference_service_close_ref_usage_matrix.py`

- [ ] **Step 7.1: Write failing tests**

Create `backend/tests/test_scene_reference_service_close_ref_usage_matrix.py`:
```python
"""close × ref_usage matrix v1 — scene_reference_service.build_prev_shot_background_ref tests.

Spec §4.9: framing_scale=close + ref_usage != zoom_in_detail → RefContractError.
best_prev_bytes 존재 + attach 후보 있을 때만 enforce (early return 후).
"""
import pytest

from app.core.framing_scale import (
    FRAMING_CLOSE,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
)
from app.core.ref_contract_validator import RefContractError
from app.services.scene_reference_service import SceneReferenceService


def _make_svc():
    """Synthetic SceneReferenceService — minimal setup.

    Constructor signature: __init__(self, db: OrmSession, project_id: str).
    build_prev_shot_background_ref 는 본 test path 에서 DB 직접 사용 X
    (best_prev_bytes + dep_detail_map + staging argument 만 read), 따라서
    db=MagicMock() 로 충분. db.query() etc. 미호출 verified.
    """
    from unittest.mock import MagicMock
    return SceneReferenceService(db=MagicMock(), project_id="p_test")


def _make_still_data(scene_index=1, shot_index=2):
    return {
        "scene_index": scene_index,
        "shot_index": shot_index,
        "visible_entities_json": "[]",
    }


def _make_staging(framing_scale):
    return {"framing_scale": framing_scale, "camera_direction": "wide"}


def _make_dep_detail_map(scene_idx, shot_idx, ref_usage):
    key = f"{scene_idx}_{shot_idx}"
    return {key: {"ref_usage": ref_usage, "ignore_elements": "", "keep_elements": []}}


def test_close_zoom_in_detail_allow():
    """close + zoom_in_detail = allow (label 정상 생성)."""
    svc = _make_svc()
    still = _make_still_data(1, 2)
    staging = _make_staging(FRAMING_CLOSE)
    dep_detail_map = _make_dep_detail_map(1, 2, "zoom_in_detail")

    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"\x89PNG fake bytes",
        still_data=still,
        visible_entities=[],
        current_location_ids=[],
        dep_scene_id=None,
        stills=[],
        location_scene_history={},
        dep_detail_map=dep_detail_map,
        staging=staging,
        state_variant_sids=set(),
        entity_lookup={},
    )
    assert result is not None
    label, _bytes, _loc_id = result
    assert "SAME FRAME ZOOMED" in label  # zoom_in_detail label content


def test_close_exact_background_raises():
    svc = _make_svc()
    still = _make_still_data(1, 2)
    staging = _make_staging(FRAMING_CLOSE)
    dep_detail_map = _make_dep_detail_map(1, 2, "exact_background")

    with pytest.raises(RefContractError) as excinfo:
        svc.build_prev_shot_background_ref(
            best_prev_bytes=b"\x89PNG fake bytes",
            still_data=still,
            visible_entities=[],
            current_location_ids=[],
            dep_scene_id=None,
            stills=[],
            location_scene_history={},
            dep_detail_map=dep_detail_map,
            staging=staging,
            state_variant_sids=set(),
            entity_lookup={},
        )
    assert "close_ref_usage_violation" in str(excinfo.value)
    assert "exact_background" in str(excinfo.value)


def test_close_atmosphere_reference_raises():
    svc = _make_svc()
    still = _make_still_data(1, 2)
    staging = _make_staging(FRAMING_CLOSE)
    dep_detail_map = _make_dep_detail_map(1, 2, "atmosphere_reference")

    with pytest.raises(RefContractError) as excinfo:
        svc.build_prev_shot_background_ref(
            best_prev_bytes=b"\x89PNG fake bytes",
            still_data=still,
            visible_entities=[],
            current_location_ids=[],
            dep_scene_id=None,
            stills=[],
            location_scene_history={},
            dep_detail_map=dep_detail_map,
            staging=staging,
            state_variant_sids=set(),
            entity_lookup={},
        )
    assert "close_ref_usage_violation" in str(excinfo.value)
    assert "atmosphere_reference" in str(excinfo.value)


def test_close_empty_ref_usage_raises():
    """close + ref_usage='' (legacy / fallback) → RefContractError."""
    svc = _make_svc()
    still = _make_still_data(1, 2)
    staging = _make_staging(FRAMING_CLOSE)
    dep_detail_map = {}  # no entry → ref_usage = ""

    with pytest.raises(RefContractError) as excinfo:
        svc.build_prev_shot_background_ref(
            best_prev_bytes=b"\x89PNG fake bytes",
            still_data=still,
            visible_entities=[],
            current_location_ids=[],
            dep_scene_id=None,
            stills=[],
            location_scene_history={},
            dep_detail_map=dep_detail_map,
            staging=staging,
            state_variant_sids=set(),
            entity_lookup={},
        )
    assert "close_ref_usage_violation" in str(excinfo.value)


def test_close_no_prev_bytes_early_return_no_matrix_check():
    """close + best_prev_bytes=None → early return None, matrix not enforced."""
    svc = _make_svc()
    still = _make_still_data(1, 2)
    staging = _make_staging(FRAMING_CLOSE)
    # dep_detail_map 가 exact_background 라도 best_prev_bytes None 면 early return
    dep_detail_map = _make_dep_detail_map(1, 2, "exact_background")

    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=None,  # ← early return trigger
        still_data=still,
        visible_entities=[],
        current_location_ids=[],
        dep_scene_id=None,
        stills=[],
        location_scene_history={},
        dep_detail_map=dep_detail_map,
        staging=staging,
        state_variant_sids=set(),
        entity_lookup={},
    )
    assert result is None  # early return, matrix not enforced


def test_medium_any_ref_usage_allow():
    """framing_scale=medium 일 때 ref_usage 어떤 값이든 raise 안 함 (matrix close 만)."""
    svc = _make_svc()
    still = _make_still_data(1, 2)

    for ref_usage in ("zoom_in_detail", "exact_background", "atmosphere_reference", ""):
        staging = _make_staging(FRAMING_MEDIUM)
        dep_detail_map = _make_dep_detail_map(1, 2, ref_usage)

        # raise 안 해야 — close 만 matrix enforce
        try:
            svc.build_prev_shot_background_ref(
                best_prev_bytes=b"\x89PNG fake bytes",
                still_data=still,
                visible_entities=[],
                current_location_ids=[],
                dep_scene_id=None,
                stills=[],
                location_scene_history={},
                dep_detail_map=dep_detail_map,
                staging=staging,
                state_variant_sids=set(),
                entity_lookup={},
            )
        except RefContractError:
            pytest.fail(f"medium + {ref_usage!r} 가 RefContractError raise — close 만 enforce 의무")
```

- [ ] **Step 7.2: Run tests verify FAIL**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/test_scene_reference_service_close_ref_usage_matrix.py -v 2>&1 | tail -20
```

Expected: 6 tests — close + non-zoom 3 tests FAIL (RefContractError 안 raise), 그 외 (close+zoom_in_detail / close+early_return / medium*4) test 는 PASS 또는 setup error.

- [ ] **Step 7.3: Add close × ref_usage matrix block in build_prev_shot_background_ref**

Edit `backend/app/services/scene_reference_service.py:796` — line 796 의 `ref_usage = dep_info.get("ref_usage", "")` 직후 (line 800 직전) matrix block 추가:

기존 (line 796-800):
```python
        # ref_usage 기반 라벨 구성
        dep_key = f"{still_data.get('scene_index', 0)}_{still_data.get('shot_index', 0)}"
        dep_info = dep_detail_map.get(dep_key, {})
        ref_usage = dep_info.get("ref_usage", "")
        ignore = dep_info.get("ignore_elements", "")
```

변경 (직후 matrix block 추가):
```python
        # ref_usage 기반 라벨 구성
        dep_key = f"{still_data.get('scene_index', 0)}_{still_data.get('shot_index', 0)}"
        dep_info = dep_detail_map.get(dep_key, {})
        ref_usage = dep_info.get("ref_usage", "")
        ignore = dep_info.get("ignore_elements", "")

        # close × ref_usage matrix v1 (framing_scale enum SOT v1 / spec §4.9)
        # — Gate 4 (No Silent Fallback). framing_scale=close + ref_usage !=
        # zoom_in_detail → RefContractError. best_prev_bytes 존재 + attach
        # 후보 있을 때만 enforce (early return :725 후).
        from app.core.framing_scale import (
            FRAMING_CLOSE,
            get_framing_scale_or_raise,
        )
        from app.core.ref_contract_validator import RefContractError

        _framing_scale = get_framing_scale_or_raise(
            staging,
            where=(
                f"scene_reference_service.matrix "
                f"S{still_data.get('scene_index')}_Shot{still_data.get('shot_index')}"
            ),
        )
        if _framing_scale == FRAMING_CLOSE and ref_usage != "zoom_in_detail":
            raise RefContractError(
                f"close_ref_usage_violation: close framing requires "
                f"ref_usage='zoom_in_detail' but got {ref_usage!r} "
                f"(S{still_data.get('scene_index')}_"
                f"Shot{still_data.get('shot_index')}). Allowed: close + "
                f"zoom_in_detail only."
            )
```

- [ ] **Step 7.4: Existing test_scene_reference_service.py fixture sweep**

matrix block 도입 시 기존 `build_prev_shot_background_ref(... best_prev_bytes=b"PREV", staging=None ...)` 호출 site 가 `framing_scale_missing` AppError 로 대량 fail. **전수 sweep 의무**:

```bash
grep -n "build_prev_shot_background_ref\|staging=" backend/tests/services/test_scene_reference_service.py
```

발견된 call site 분류 + 각 line 별 best_prev_bytes 값 직접 확인 의무 (read 후 분류):

**Out of scope (`build_prev_shot_background_ref` 호출 아님)** — sweep 대상 X:
- `:415-423` `test_detect_state_variant_sids_none_staging_returns_empty` (`detect_state_variant_sids(... staging=None)` 호출 — matrix 영향 X)

**Early return path (best_prev_bytes=None)** — staging=None 그대로 허용 (early return 이 matrix check 보다 먼저):
- `:501` `test_prev_shot_ref_none_when_no_bytes` (`best_prev_bytes=None`)
- `:792` `test_prev_shot_ref_d5_none_when_no_bytes_3tuple_signature_unaffected` (`best_prev_bytes=None`)

**Matrix check 진입 (best_prev_bytes 존재) — staging 보강 의무**:
- `:519`, `:538`, `:557`, `:572`, `:588`, `:612`, `:640`, `:661`, `:683`, `:706`, `:727`, `:753+` 등 모두 (각 `best_prev_bytes=b"PREV"` 또는 동등 truthy bytes)
- 처리: `staging=None` → `staging={"framing_scale": "medium", ...}` (non-close 의도 보존 test 대다수) 또는 `staging={"framing_scale": "wide", ...}` 로 보강
- close-specific intent test 있으면: `staging={"framing_scale": "close", ...}` + `dep_detail_map` 의 ref_usage = "zoom_in_detail" 명시 (matrix allow path), 또는 의도적 violation test 라면 RefContractError expectation 추가

**판단 기준 명확화**: best_prev_bytes truthy + matrix check 진입 test = staging 보강 의무. best_prev_bytes None (early return) test = staging=None 유지. 본 plan 의 :612/:640/:661 분류 = `best_prev_bytes=b"PREV"` (read 직접 검증) → matrix check 진입 → staging 보강 대상 (이전 plan amend 4 의 분류 오류 정정).

- [ ] **Step 7.5: Run tests verify PASS (신규 matrix + existing sweep 둘 다)**

Step 7.4 의 existing fixture sweep 검증 의무 — 신규 matrix test 만 실행하면 sweep 결함 못 잡음. 두 file 동시 실행:

```bash
cd backend && .venv/bin/python -m pytest \
    tests/test_scene_reference_service_close_ref_usage_matrix.py \
    tests/services/test_scene_reference_service.py \
    -v 2>&1 | tail -30
```

Expected:
- 신규 matrix test (`test_scene_reference_service_close_ref_usage_matrix.py`): 6 passed
- 기존 service test (`tests/services/test_scene_reference_service.py`): Step 7.4 sweep 후 0 fail (baseline 과 동일 또는 더 좋음)

만약 기존 service test fail 발견 시 → Step 7.4 sweep 누락 site 보강 (each fail 의 staging=None call → matrix check 진입 여부 read + 보강) → re-run.

- [ ] **Step 7.6: Commit Task 7**

```bash
git add backend/app/services/scene_reference_service.py \
        backend/tests/test_scene_reference_service_close_ref_usage_matrix.py \
        backend/tests/services/test_scene_reference_service.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 7 — scene_reference_service close × ref_usage matrix

build_prev_shot_background_ref 안 :725 early return 보존 + :796 ref_usage
read 직후 matrix block 추가. framing_scale=close + ref_usage != zoom_in_detail
→ RefContractError raise (detail prefix "close_ref_usage_violation: ").

import: from app.core.framing_scale + from app.core.ref_contract_validator.
constructor 1-arg (detail) 기존 보존 — 신규 code 도입 X (code 고정
"ref_contract.violation"). violation 종류는 detail prefix 로 식별.

best_prev_bytes None 시 early return — matrix not enforced (attach 후보
부재). medium/wide/insert 모두 ref_usage 무관 allow.

6 unit test PASS (close+zoom_in_detail allow / close+exact_bg raise /
close+atmosphere raise / close+empty raise / close+no_bytes early /
medium+any allow).

existing test_scene_reference_service.py fixture sweep — matrix check
진입 site (best_prev_bytes truthy + staging=None) 모두 staging={"framing_
scale": "medium"|"wide", ...} 보강. early return path (best_prev_bytes
None) staging=None 유지. detect_state_variant_sids 호출 sweep 대상 X.
신규 matrix + existing service test 둘 다 PASS.

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

---

### Task 8: detail_steps.py — 6 callsite + SCENE_DETAIL_PROMPT_VERSION (main session)

**Goal:** `_CLOSE_FRAMING_RE` 정의 삭제 + 6 callsite helper read 전환 + `SCENE_DETAIL_PROMPT_VERSION` Task 9 와 함께 bump (Task 8 에서는 callsite + 정의 삭제만, version bump 는 Task 9 의 prompt rewrite 후 동기).

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:97-109, :158, :467, :577, :1286, :1448, :1733, :1735, :2376, :2912`

- [ ] **Step 8.1: Signature decision + stale test sweep scope 확정**

**전제 (HEAD `b80151a` 검증)**: Task 6 commit (b80151a) 후 `test_phase9_1_camera_consistency.py:336-449` 의 5 tests + `test_g3_2_close_skip_sentinel_drift.py:12, :276, :284` references 가 `ImportError: cannot import name '_CLOSE_FRAMING_RE'` 로 broken state. Task 8 sweep 의무 (Task 6 cleanup 누락 흡수).

**Decision 1 — `_build_phase2_prepend_blocks` signature 변경**:

production 의 caller / test fixture 양쪽 명시:

- 기존 (line 122-133): `camera_direction: str = ""` kwarg + line 158 `_CLOSE_FRAMING_RE.search(camera_direction)` 분기
- 신규 signature: `is_close_framing: bool = False` kwarg (camera_direction kwarg 제거)
- production caller (detail_steps 안 phase 2 prepend 진입 site) 가 helper 호출 후 bool 전달:
  ```python
  is_close = (
      get_framing_scale_or_raise(staging, where="detail_steps.phase2_prepend_caller")
      == FRAMING_CLOSE
  )
  _build_phase2_prepend_blocks(..., is_close_framing=is_close)
  ```
- 함수 내부 line 158 의 `is_close_framing = bool(camera_direction) and bool(_CLOSE_FRAMING_RE.search(camera_direction))` → 제거 (kwarg `is_close_framing` 그대로 사용).
- **사유 (사용자 권장)**: production caller 만 `get_framing_scale_or_raise` 호출 의무. helper/test 는 bool 받는 게 현실적 — test fixture 가 fake camera_direction 한국어 keyword 없이 `is_close_framing=True/False` 직접 명시 가능.

**Decision 2 — `verify_completion :1724-1740` 처리**:

기존: `staging_map[key].camera_direction` regex (line 1734-1735)

신규: helper read — `staging_map[key]` dict 자체를 `get_framing_scale_or_raise(stg, where="detail_steps.verify_completion")` 에 전달. enum 결과로 expected_close 결정. fixture 의 staging_map missing 시 framing_scale_missing AppError fail-fast (gate 4).

```python
# round 5 BLOCKING 4 후속 (framing_scale enum SOT v1 / spec §4.9 / plan Task 8):
# staging_map 의 framing_scale enum read (regex 폐기). staging missing
# 또는 framing_scale missing 시 AppError → expected validator 결정 불가.
stg = staging_map.get(staging_key) if isinstance(staging_map, dict) else None
expected_close = (
    get_framing_scale_or_raise(stg, where=f"detail_steps.verify_completion S{si}_Shot{shi}")
    == FRAMING_CLOSE
)
expected_validator = (
    OWNED_VALIDATOR_CLOSE_SKIP if expected_close
    else OWNED_VALIDATOR_FULL
)
```

test_g3_2_consumer_wiring.py:441 의 `test_verify_completion_clean_with_trivial_sentinel` fixture 보강 — `step._last_execute_result` 에 `staging_map` field 추가 + 그 안 staging entry 에 `framing_scale` field 명시 (`"close"` 또는 `"medium"`).

**Decision 3 — stale test sweep (Task 6 cleanup 누락 흡수)**:

다음 4 file 의 stale `_CLOSE_FRAMING_RE` reference 또는 camera_direction-only fixture 정리:

| File:line | 현재 | 변경 |
|---|---|---|
| `test_phase9_1_camera_consistency.py:336-345` `test_close_framing_re_detects_ecu` | `from app.services.scene_generation_coordinator import _CLOSE_FRAMING_RE` + 7 regex assert | **삭제** (regex 폐기 — test 의미 0. helper unit test `test_framing_scale_helper.py` 가 cover) |
| `test_phase9_1_camera_consistency.py:347-356` `test_close_framing_re_does_not_match_wide` | regex import + 6 negative assert | **삭제** |
| `test_phase9_1_camera_consistency.py:358-417` `test_close_framing_re_case_insensitive` | regex import + case-insensitive assert | **삭제** |
| `test_phase9_1_camera_consistency.py:419-440` `test_coordinator_skip_chain_bg_when_close_framing` | regex import + coordinator skip 분기 검증 (camera_direction 의 close keyword) | **Rewrite** — staging dict 의 `framing_scale="close"` fixture + helper-based coordinator skip 분기 검증 (monkeypatch SceneReferenceService) |
| `test_phase9_1_camera_consistency.py:442-449` `test_coordinator_no_chain_bg_skip_when_no_camera_direction` | regex import + camera_direction 빈 string 시 skip 안 함 검증 | **Rewrite** — staging `framing_scale="medium"` fixture + skip 안 함 검증 |
| `test_g3_2_close_skip_sentinel_drift.py:12, :276, :284` `verify_completion 가 module-level _CLOSE_FRAMING_RE 재사용` references | regex 재사용 doc + assert | **Rewrite** — helper read 검증 (staging_map fixture 에 framing_scale 추가 + verify_completion helper 호출 path) |
| `test_g3_2_consumer_wiring.py:90-110` `test_close_framing_skips_all_three_blocks` | parametrize camera_direction (5 keyword) + `_build_phase2_prepend_blocks(camera_direction=...)` 호출 | **Rewrite** — `_build_phase2_prepend_blocks(is_close_framing=True)` 직접 (parametrize 의미 X, 단일 True case) |
| `test_g3_2_consumer_wiring.py:441-459` `test_verify_completion_clean_with_trivial_sentinel` | `_last_execute_result` 만, staging_map 누락 | **보강** — staging_map fixture 추가 (`{"1_1": {"framing_scale": "medium"}}`) + helper read path PASS 검증 |

**Decision 4 — focused test suite 확장**:

`pytest -k detail_steps` 만으로 부족. Task 8 의 Step 8.6 focused suite 다음으로 확장:
- `tests/core/test_phase9_1_camera_consistency.py`
- `tests/integration/test_g3_2_consumer_wiring.py`
- `tests/unit/test_g3_2_close_skip_sentinel_drift.py`
- `tests/core/test_chain_bg_guide.py` (chain_bg + phase2 prepend cascade 회귀)
- 기존 `pytest -k 'detail_steps or scene_detail'` 도 유지

- [ ] **Step 8.2: Delete `_CLOSE_FRAMING_RE` 정의 + 인접 comment (line 97-109)**

Edit `backend/app/core/steps/detail_steps.py:97-109`:

기존:
```python
# Phase 9.2 + v12: close framing detection — scene_generation_coordinator의 동일
# 패턴 사본. 두 모듈은 독립 evaluation이라 패턴 중복 허용. 차이 발생 시
# scene_image_pipeline의 ref skip 결과와 scene_detail의 prompt block prepend
# 결과가 어긋나므로, 두 곳 동시 갱신 필수.
# v12 추가: 한국어 close framing 키워드. `손가락이` 만 단독 alternation 으로 유지
# (production 에서 거의 close-up 컨텍스트). `손이/눈이/얼굴이` 등 짧은 조사는
# 비-close 문장에도 등장하므로 false-positive 회피로 alternation 에서 제외.
_CLOSE_FRAMING_RE = re.compile(
    r"\b(ECU|XCU|extreme close[\-\s]?up|MCU|medium close[\-\s]?up|"
    r"close[\-\s]?up|CU)\b"
    r"|클로즈업|손가락이",
    re.IGNORECASE,
)
```

변경: 전체 삭제 + 위 빈 줄 정리.

- [ ] **Step 8.3: Add helper import to detail_steps.py**

Edit `backend/app/core/steps/detail_steps.py` — top imports section (적절 위치):

```python
from app.core.framing_scale import (
    FRAMING_CLOSE,
    get_framing_scale_or_raise,
)
```

- [ ] **Step 8.4: Replace 6 callsite + signature change (Step 8.1 Decision 1+2 적용)**

`:158` (in `_build_phase2_prepend_blocks`) — **signature change + body line 삭제**:

function signature (line 122-133):
- `camera_direction: str = ""` kwarg **제거**
- `is_close_framing: bool = False` kwarg **추가**

function body line 158:
기존:
```python
is_close_framing = bool(camera_direction) and bool(_CLOSE_FRAMING_RE.search(camera_direction))
```
**삭제** — kwarg `is_close_framing` 그대로 사용.

production caller — `_build_phase2_prepend_blocks(...)` 호출 site grep 의무 (`grep -n "_build_phase2_prepend_blocks(" backend/app/`). 발견된 caller 마다:
```python
is_close = (
    get_framing_scale_or_raise(staging, where="detail_steps.phase2_prepend_caller")
    == FRAMING_CLOSE
)
result = _build_phase2_prepend_blocks(..., is_close_framing=is_close)
```

`:1735` (in `verify_completion`) — **staging_map dict 통과 helper read** (Step 8.1 Decision 2):

기존 (line 1729-1736):
```python
stg = staging_map.get(staging_key) if isinstance(staging_map, dict) else None
cam_dir_now = ""
if isinstance(stg, dict):
    cam_dir_now = stg.get("camera_direction", "") or ""
expected_close = bool(cam_dir_now) and bool(
    _CLOSE_FRAMING_RE.search(cam_dir_now)
)
```

신규:
```python
stg = staging_map.get(staging_key) if isinstance(staging_map, dict) else None
expected_close = (
    get_framing_scale_or_raise(stg, where=f"detail_steps.verify_completion S{si}_Shot{shi}")
    == FRAMING_CLOSE
)
```

(staging_map fixture missing 시 → `framing_scale_missing` AppError → expected_validator 결정 불가 → gate 4 정합. test_g3_2_consumer_wiring.py:441 fixture 보강 Step 8.5 의무.)

`:577`, `:1286`, `:2376`, `:2912` — 동일 helper read pattern. 각 callsite 함수 context 의 staging variable access path (grep으로 확인):

```python
# example for each callsite:
is_close_framing = (
    get_framing_scale_or_raise(<staging_var>, where="detail_steps.<context>")
    == FRAMING_CLOSE
)
```

(`<staging_var>` = 함수 context 변수명 — `staging` / `_staging` / `_stg` / `ctx.get("staging")` / `shot_info.get("staging")`. plan 실행 시점 4 callsite read + 매핑 후 helper 호출.)

`:467`, `:1448`, `:1733` comment 안 `_CLOSE_FRAMING_RE` reference rewrite:

기존 (e.g. :467):
```python
      - is_close_framing: staging.camera_direction → _CLOSE_FRAMING_RE
```

변경:
```python
      - is_close_framing: staging.framing_scale == FRAMING_CLOSE (helper read)
```

(e.g. :1448, :1733 도 동일 — `_CLOSE_FRAMING_RE 재사용` → `framing_scale enum 소비`)

- [ ] **Step 8.5: Stale test sweep apply (Step 8.1 Decision 3 — Task 6 cleanup 누락 흡수)**

각 file 의 stale `_CLOSE_FRAMING_RE` reference 또는 camera_direction-only fixture 정리:

1. **`backend/tests/core/test_phase9_1_camera_consistency.py`** — **함수 단위 정확 명시 (range 삭제 X)**:
   - `test_close_framing_re_detects_ecu` (line 336-344) **삭제**
   - `test_close_framing_re_does_not_match_wide` (line 347-356) **삭제**
   - `test_close_framing_re_case_insensitive` (line 359-363) **삭제**
   - `test_loader_camera_meta_duplicate_shot_first_wins` (line 371-410) — **보존** (duplicate-shot loader test, regex 무관, framing_scale enum SOT 와 직접 관련 X)
   - `test_coordinator_skip_chain_bg_when_close_framing` (line 419-440) **rewrite**:
     - staging dict `{"framing_scale": "close", ...}` fixture
     - `unittest.mock.patch` 로 `SceneReferenceService.build_prev_shot_background_ref` mock
     - coordinator entry 호출 → chain_bg skip 분기 진입 검증 (caplog 또는 mock spy)
   - `test_coordinator_no_chain_bg_skip_when_no_camera_direction` (line 442-449) **rewrite**:
     - staging dict `{"framing_scale": "medium", ...}` fixture
     - coordinator entry 호출 → chain_bg skip 안 함 검증
   - 그 외 file 안 function (regex 미사용 의도된 test) — **보존**

   **중요**: range 단위 삭제 (e.g. `line 358-417 삭제`) 절대 금지 — duplicate-shot loader test 같은 unrelated function cascade 손실 위험. 함수 단위 (`def test_<name>` 시작 → 다음 `def` 또는 module-level 까지) 만 정확 삭제 / rewrite.

2. **`backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py`** — **staging_map fixture 전수 sweep**:

   ```bash
   rg -n "staging_map=\{|staging_map = " backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py
   ```

   발견된 8 위치 (line 38 helper signature + 113/134/148/173/196/220/243/267 fixture call) 모두 처리:

   - **helper signature** (line 38 `def _<helper>(..., staging_map=None)` 같은 fixture builder): 그대로 유지 — 각 호출 site 에서 fixture 명시
   - **각 fixture call site** (8개 또는 발견된 수): staging entry 에 `framing_scale` field 추가
     - `staging_map={"1_1": {"camera_direction": cam_dir}}` → `staging_map={"1_1": {"camera_direction": cam_dir, "framing_scale": "<close|medium|wide>"}}`
     - **분류 의무**:
       - close sentinel / OWNED_VALIDATOR_CLOSE_SKIP 기대 test → `"framing_scale": "close"`
       - full validator / OWNED_VALIDATOR_FULL 기대 test → `"framing_scale": "medium"` 또는 `"wide"` (test 의도 따라)
       - drift detection test (sentinel 와 staging 불일치 검증) → 의도된 mismatch 명시
   - **docstring + assertion rewrite**:
     - line 12 docstring "verify_completion 가 module-level _CLOSE_FRAMING_RE 를 재사용하는지" → "verify_completion 가 framing_scale enum SOT helper (`get_framing_scale_or_raise`) 를 사용하는지"
     - line 284 `"_CLOSE_FRAMING_RE 재사용"` assertion message → `"framing_scale enum helper 사용"`
     - regex assertion 있으면 helper-based assertion 으로 rewrite

3. **`backend/tests/integration/test_g3_2_consumer_wiring.py`**:
   - line 88-110 `test_close_framing_skips_all_three_blocks` rewrite:
     - parametrize 의 5 camera_direction keyword 제거 (의미 X — bool 직접)
     - `_build_phase2_prepend_blocks(..., is_close_framing=True)` 단일 호출 (camera_direction kwarg 폐기)
     - 3 block 모두 skip 검증
   - line 441-459 `test_verify_completion_clean_with_trivial_sentinel` 보강:
     - `step._last_execute_result` 에 `staging_map` field 추가:
       ```python
       step._last_execute_result = {
           "data": {
               "scenes": [
                   {"scene_index": 1, "_shot_index": 1,
                    "t2i_variations": [{
                        "t2i_prompt": "x",
                        "owned_validation": trivial_sentinel,
                    }]},
               ],
               "staging_map": {"1_1": {"framing_scale": "medium"}},
           },
           ...
       }
       ```
   - 그 외 staging_map 기반 test (verify_completion entry) 모두 staging 안 `framing_scale` field 보강

- [ ] **Step 8.6: Run focused test suite + grep residue verify (Step 8.1 Decision 4)**

Run grep residue:
```bash
grep -nE "_CLOSE_FRAMING_RE" backend/app/core/steps/detail_steps.py backend/tests/
```

Expected: 0 hits (production code + test 모두 정리됨).

Run focused suite — `pytest -k detail_steps` 만으로 부족, 다음 5 suite 모두 PASS 의무:
```bash
.venv/bin/python -m pytest \
    tests/ -k 'detail_steps or scene_detail or phase9_1_camera_consistency or g3_2_consumer_wiring or g3_2_close_skip_sentinel_drift or chain_bg_guide' \
    --tb=line -q 2>&1 | tail -25
```

Expected: 0 fail. legacy cp missing framing_scale 관련 일부 test 는 framing_scale_missing AppError 로 변경 — fixture update 적용 후 PASS.

특히 b80151a 의 5 ImportError fail (`test_phase9_1_camera_consistency.py`) 해소 verification:
```bash
.venv/bin/python -m pytest tests/core/test_phase9_1_camera_consistency.py --tb=line -q 2>&1 | tail -10
```

Expected: 0 fail (5 stale tests 삭제/rewrite 후).

- [ ] **Step 8.7: Commit Task 8**

```bash
git add backend/app/core/steps/detail_steps.py \
        backend/tests/core/test_phase9_1_camera_consistency.py \
        backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py \
        backend/tests/integration/test_g3_2_consumer_wiring.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 8 — detail_steps regex 삭제 + 6 callsite + signature + stale sweep

production:
- _CLOSE_FRAMING_RE 정의 + 인접 comment (line 97-109) 삭제.
- _build_phase2_prepend_blocks signature: camera_direction kwarg →
  is_close_framing bool kwarg (production caller 가 helper 호출 후 bool
  전달). function body line 158 의 regex 분기 삭제.
- verify_completion (:1735) staging_map dict 통과 helper read — fixture
  missing 시 framing_scale_missing AppError fail-fast (gate 4 정합).
- 5 callsite (:577 / :1286 / :2376 / :2912 / phase2_prepend caller) helper
  read 전환. 3 comment reference (:467 / :1448 / :1733) rewrite.

stale test sweep (Task 6 cleanup 누락 흡수, b80151a 의 5 ImportError fail
해소):
- test_phase9_1_camera_consistency.py: regex test 3 함수 단위 삭제
  (test_close_framing_re_detects_ecu 336-344 / _does_not_match_wide
  347-356 / _case_insensitive 359-363) + coordinator skip test 2
  rewrite (419-440 + 442-449, helper-based, staging framing_scale
  fixture). test_loader_camera_meta_duplicate_shot_first_wins (371-410)
  preserved (unrelated loader test).
- test_g3_2_close_skip_sentinel_drift.py: regex reference (:12/:276/:284)
  → helper-based rewrite.
- test_g3_2_consumer_wiring.py:90-110 close_framing_skips_all_three_blocks
  → is_close_framing=True bool 직접 (parametrize camera_direction 폐기).
- test_g3_2_consumer_wiring.py:441-459 staging_map fixture 보강.

focused suite (detail_steps + scene_detail + phase9_1 + g3_2_consumer_wiring
+ g3_2_close_skip + chain_bg_guide) 0 fail. grep residue 0 (production
code + test 모두). SCENE_DETAIL_PROMPT_VERSION bump 는 Task 9.

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

---

### Task 9: scene_detail v24 prompt + version sync (main session)

**Goal:** `prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/` 신설 + `:155 + :159` rewrite (framing_scale_keywords 본문 + 충돌 해결 prose) + version_registry + detail_steps.py:115 SCENE_DETAIL_PROMPT_VERSION sync.

**Files:**
- Create: `prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/` (full-pack)
- Modify: `prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/system.md:155 + :159`
- Modify: `backend/app/core/version_registry.py:34, :125-127`
- Modify: `backend/app/core/steps/detail_steps.py:115`

- [ ] **Step 9.1: Full-pack copy v23 → v24**

```bash
cp -r prompts/_base/scene_detail/23.202605141758 prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>
ls prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/
```

Expected: system.md + 기타 부속 file (v23 와 동일 list).

- [ ] **Step 9.2: Rewrite system.md `:155 + :159` — framing_scale_keywords → framing_scale_source**

Edit `prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/system.md` — **2 위치 rewrite 의무** (line 155 본문 + line 159 충돌 해결 prose). v23 원본 둘 다 `framing_scale_keywords` reference 보유 → v24 copy 시 둘 다 rewrite 안 하면 Gate 2 fail.

**Line 155** — primary_framing_rule 본문:

기존:
```
- `primary_framing_rule.framing_scale_keywords`: close 판정 키워드 = `close-up`, `CU`, `MCU`, `ECU`, `XCU`, `extreme close-up`, `클로즈업`, `손가락이`, `손이`, `눈이`, `얼굴이` (총 11 entries; freeze-frame `정지 컷` 류는 무관). wide = `wide shot`, `establishing`, `aerial`, `전경`, `전신`. 그 외 medium default.
```

변경:
```
- `primary_framing_rule.framing_scale_source`: framing_scale 은 `[RenderPromptCard v1].render_strategy.framing_scale` enum (`close / medium / wide / insert`) 만 소비. shot_staging 이 emit. **keyword 분류 / 패턴 매칭 / phrase list 기반 추론 금지** — production 의 단일 SOT.
```

**Line 159** — 충돌 해결 prose (Gate 2 trigger 위험 — `framing_scale_keywords 11 entries` reference):

기존:
```
**충돌 해결**: 본 섹션 prose 는 reasoning summary; 구체 token list (framing_scale_keywords 11 entries / core_principles 4 sub-rules / shared anchor 표현) 는 모두 card sub-field 가 single source. prose 와 sub-field 가 충돌하면 sub-field 우선.
```

변경:
```
**충돌 해결**: 본 섹션 prose 는 reasoning summary; 구체 enum 값 (framing_scale_source = `[RenderPromptCard v1].render_strategy.framing_scale` enum / core_principles 4 sub-rules / shared anchor 표현) 는 모두 card sub-field 가 single source. prose 와 sub-field 가 충돌하면 sub-field 우선.
```

**Verification (Step 9.2 후)**:
```bash
grep -nE "framing_scale_keywords|close 판정 키워드|11 entries" prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/system.md
```

Expected: 0 hits (둘 다 rewrite 완료).

- [ ] **Step 9.3: Edit version_registry.py:34 + :125-127**

Edit `backend/app/core/version_registry.py:34`:

```python
"scene_detail_composer": "1.24.0",    # 2026-05-15 — framing_scale enum SOT v1: v24 prompt (framing_scale_keywords 폐기, card enum 소비 rewrite). schema_version 9 유지.
```

Edit `backend/app/core/version_registry.py:125-127`:

```python
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v24",
    "updated_at": "2026-05-15",
},
```

- [ ] **Step 9.4: Edit detail_steps.py:115**

Edit `backend/app/core/steps/detail_steps.py:115`:

```python
SCENE_DETAIL_PROMPT_VERSION = "24.<TS_SCENE_DETAIL>"  # 2026-05-15 (framing_scale enum SOT v1) — v24 prompt: framing_scale_keywords 폐기, card enum 소비 rewrite. SCHEMA_VERSION 9 유지.
```

(SCENE_DETAIL_SCHEMA_VERSION:114 = 9 유지, comment 만 정리 가능.)

- [ ] **Step 9.5: Run prompt_loader sanity test (load v24)**

Run:
```bash
cd backend && .venv/bin/python -c "
from app.modules.prompt_loader import get_effective_source
src = get_effective_source('scene_detail', 'system')
print('effective_source:', src.get('candidates', {}).get('file'))
"
```

Expected: `effective_source` 가 `prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/system.md` 경로 포함.

- [ ] **Step 9.6: Commit Task 9**

```bash
git add prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/ \
        backend/app/core/version_registry.py \
        backend/app/core/steps/detail_steps.py
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 9 — scene_detail v24 prompt + version sync

v24 prompt new dir (v23 base + system.md :155 + :159 rewrite). line 155
framing_scale_keywords 11+5 entry list → framing_scale_source "card enum
만 소비, keyword 분류 / 패턴 매칭 / phrase list 추론 금지". line 159
충돌 해결 prose 안 "framing_scale_keywords 11 entries" reference →
"framing_scale_source enum / card sub-field" reference. close_framing_rules
/ wide_medium_rules / close_framing_face_phrasing 보존 (L-5 / body_part_
focus defer).

version_registry: scene_detail_composer 1.23.0 → 1.24.0 + prompt_dependency
v23 → v24. detail_steps SCENE_DETAIL_PROMPT_VERSION "23..." → "24.<TS>".
SCENE_DETAIL_SCHEMA_VERSION 9 유지.

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

---

### Task 10: Canary G4.5a + fixture sweep (main session)

**Goal:** canary G4.5a primary_framing 의 `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 사용 제거 + production enum read 전환 + canary fixture 의 staging dict 에 `framing_scale` field 추가.

**Files:**
- Modify: `tests/.../g4_5a_primary_framing*.py` (정확 path = grep)
- Modify: canary fixture files (staging dict 의 framing_scale field 추가)

- [ ] **Step 10.1: Grep canary test path**

Run:
```bash
grep -rln "g4_5a_primary_framing\|_SPATIAL_FRAMING_CLOSE_KEYWORDS\|_SPATIAL_FRAMING_WIDE_KEYWORDS" backend/tests/
```

기록: 발견된 file:line list.

- [ ] **Step 10.2: Edit each canary test — replace keyword usage with enum read**

각 file:
- `from app.core.steps.render_prompt_card import _SPATIAL_FRAMING_CLOSE_KEYWORDS` 류 import → 삭제
- 신규 import 추가:
  ```python
  from app.core.framing_scale import (
      FRAMING_CLOSE,
      FRAMING_WIDE,
      get_framing_scale_or_raise,
  )
  ```
- canary scope filter — **`.get()` silent fallback 금지** (gate 4 자가 검증). helper read 의무:
  ```python
  scale = get_framing_scale_or_raise(
      staging,
      where="g4_5a_primary_framing.canary_filter",
  )
  ```
  filter:
  ```python
  scale == FRAMING_CLOSE  # close scope filter
  scale == FRAMING_WIDE   # wide scope filter
  ```
- **Why**: fixture 의 staging dict 에 framing_scale field 누락 시 `.get()` 는 None 으로 silent miss (canary 대상 누락). 본 area 의 핵심 = missing/invalid fail-fast. canary 도 동일 contract — helper 가 framing_scale_missing AppError 로 fixture 누락 explicit 발견.

- [ ] **Step 10.3: Sweep canary fixtures — staging dict 의 framing_scale field 추가 + g4_6 fixture 분류**

Run:
```bash
grep -rln "staging.*camera_direction\|camera_direction.*staging\|framing_scale_keywords\|identify framing_scale from camera_direction" backend/tests/fixtures/ backend/tests/data/ 2>/dev/null
```

**분류 의무**:

1. **Current card fixture (운영 snapshot)** — staging dict 보유 + production card 와 동기 의무:
   - staging dict 에 `"framing_scale": "<inferred value>"` field 추가. value = 기존 camera_direction prose 와 일치 (close 키워드 있으면 `"close"`, wide 키워드 있으면 `"wide"`, 그 외 `"medium"`)
   - card 안 `framing_scale_keywords` field 가 있으면 삭제 + `framing_scale_source` 또는 enum reference 로 rewrite
   - `self_check_steps[0]` 의 `"identify framing_scale from camera_direction"` 가 있으면 `"read framing_scale enum from render_strategy.framing_scale (do NOT infer from camera_direction)"` 로 rewrite

2. **Archival snapshot fixture (이력 보존)** — `backend/tests/fixtures/g4_6/*.json` 같은 historical card snapshot:
   - `s2_shot4_scene_detail.json` / `s1_shot5_scene_detail.json` 의 `framing_scale_keywords` + `identify framing_scale from camera_direction` 잔존 가능
   - 분류 결정 의무 — **archival (snapshot 시점 의도 보존)** vs **current (production card 와 동기)**:
     - archival 결정 시: fixture 헤더 / metadata 에 `"_archival_note": "pre-framing_scale_enum_sot_v1 snapshot — do not sweep"` 명시 (Gate 11 grep skip 기준 명시)
     - **`_archival_note` 위치 결정 의무** — g4_6 fixture top-level structure = `{"source": "scene_detail.manifest.json", "shot": {...}}` (2 key only). 옵션:
       - **권장 (a)**: top-level 에 `"_archival_note": "..."` 추가 (3rd key). 가장 visible + 가장 덜 침습적.
       - **옵션 (b)**: `shot._archival_note` (nested). top-level scope 보존, 단 less visible.
       - 실행자 의무: fixture loader test (e.g. `test_g4_6_*`) 의 key strictness 확인 (`assert set(data.keys()) == {"source", "shot"}` 같은 strict assertion 있으면 (b) 채택). 그 외 (a) 채택.
     - current 결정 시: 위 (1) 의 sweep 적용
   - 본 plan 기본 권장 = `g4_6/*.json` 은 **archival** (G4.6 round 캡처 시점 snapshot, area B/C 시기 — framing_scale enum SOT 도입 전). archival metadata 추가 후 Gate 11 grep exclusion.

3. **누락 risk**: 모든 fixture 전수 update — 누락 시 Task 11 의 regression 에서 `framing_scale_missing` AppError 발생 (archival 분류 fixture 제외).

- [ ] **Step 10.4: Run canary + render_prompt_card + integration test PASS**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/ -k 'g4_5a or primary_framing or render_prompt_card' --tb=line -q 2>&1 | tail -20
```

Expected: 0 fail.

- [ ] **Step 10.5: Commit Task 10**

```bash
git add backend/tests/<canary + fixture files>
git commit -m "$(cat <<'EOF'
feat(framing-scale-enum-sot-v1): Task 10 — canary G4.5a + fixture sweep

canary G4.5a primary_framing tests 의 _SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS
사용 제거 → staging.framing_scale enum read (production SOT 정렬). canary
scope filter = framing_scale == FRAMING_CLOSE shot 만.

fixture sweep — 모든 staging dict 에 framing_scale field 추가 (close/medium/
wide/insert 분류 의무). camera_direction prose 기반 close 키워드 매칭 →
명시적 enum value.

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

---

### Task 11: Gate 1-4 자가 검증 + full regression + Codex review (main session)

**Goal:** 4 gate 자가 검증 + full regression baseline 비교 (B1~B5 외 0 fail) + Codex external review iter.

- [ ] **Step 11.1: Gate 1 — Semantic Regex Ban verification**

Run:
```bash
grep -rnE "_CLOSE_FRAMING_RE|_INSERT_HINT_RE|_SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS|_get_insert_hint_re|framing_scale_keywords|identify framing_scale from camera_direction" backend/app/ --include='*.py'
```

Expected: 0 hits (모든 정의/사용/import 삭제됨).

Run:
```bash
grep -rnE "_NOUNS|_TOKENS|_KEYWORDS|_PHRASES" backend/app/ --include='*.py' | grep -v "test_\|_test"
```

기록: 결과 diff vs baseline (본 area 추가 0). 새 identifier 0 verification.

- [ ] **Step 11.2: Gate 2 — Prompt Closed-List Ban verification**

Run:
```bash
grep -nE "close-up|MCU|ECU|XCU|extreme close-up|establishing|cutaway|클로즈업|손가락이" prompts/_base/shot_staging/11.<TS_SHOT_STAGING>/system.md
```

Expected: 0 hits (vocabulary 모두 제거).

Run:
```bash
grep -nE "framing_scale_keywords|close 판정 키워드|11 entries" prompts/_base/scene_detail/24.<TS_SCENE_DETAIL>/system.md
```

Expected: 0 hits (line 155 본문 + line 159 충돌 해결 prose 둘 다 rewrite 완료).

- [ ] **Step 11.3: Gate 3 — Structured SOT Required verification**

Run:
```bash
grep -rn 'staging.get("framing_scale"' backend/app/ --include='*.py'
```

Expected: 0 hits (silent fallback `.get(...)` 사용 X — 모두 helper read).

Run:
```bash
grep -rn 'get_framing_scale_or_raise\|_resolve_framing_scale_for_render' backend/app/ --include='*.py' | wc -l
```

Expected: 매우 많은 hits (4 consumer 모두 helper 호출).

- [ ] **Step 11.4: Gate 4 — No Silent Fallback verification**

Run:
```bash
grep -rn 'except RefContractError' backend/app/services/scene_generation_coordinator.py
```

Expected: 2 hits (`:393`, `:678` 인근 broad except 직전 reraise).

Run:
```bash
grep -n "framing_scale_missing\|framing_scale_invalid" backend/app/core/framing_scale.py
```

Expected: 2 hits (각 AppError code).

- [ ] **Step 11.5: Full regression — B1~B5 외 0 fail**

Run:
```bash
cd backend && .venv/bin/python -m pytest -q --tb=no 2>&1 | tail -30
```

Expected:
- `passed` count: baseline (P.1.4) 와 동일 또는 더 많음
- `failed` + `error` count: B1~B5 (`test_pipeline_v3_e2e.py 11 errors`, `test_evidence_consumer_wiring.py 1 fail`, `test_analysis_dispatch_service.py 1 fail`, `test_area_d_next_shot_dependency_t2i.py 2 fail`, `test_text_cleanup.py 3 fail`) 외 0

본 area 영향 fail (`framing_scale` / `render_prompt_card` / `coordinator` / `scene_reference_service` / canary) = 0.

만약 새 fail 발생 시 → Task 별 retro + amend.

- [ ] **Step 11.6: Codex external review dispatch**

Run:
```bash
codex review --branch main --base 91d1dfd
```

(또는 spec/plan 첨부 형식으로 Codex CLI 호출. plan 실행 시점의 codex setup 에 따라.)

review scope: 본 area 의 모든 commit (Task 1-10 의 commit hash) + spec + plan.

Expected verdict: APPROVED 또는 APPROVED_WITH_MINOR.

NEEDS_REVISION 시 → Codex 지적 사항 흡수 + amend commit 추가.

- [ ] **Step 11.7: Codex review absorb (if needed)**

NEEDS_REVISION 시 — Codex 지적 file:line 직접 검증 (factual drift 정정 의무, Hygiene v1 함정 carry). amend commit 추가 + re-review.

ANY review iter 후 commit message:
```
feat(framing-scale-enum-sot-v1): Codex review iter N fix — <summary>
```

---

### Task 12: Push + routing map §6 update + closure memo (main session)

**Goal:** origin/main push (path-limited) + routing map §6 prerequisite 충족 표시 + closure memo write.

**Files:**
- Modify: `docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md:§6`
- Create/Update: external memory `session_<DATE>_framing_scale_enum_sot_v1_closure.md`

- [ ] **Step 12.1: Push origin/main**

Run:
```bash
git log --oneline -15
git status --short  # verify unrelated untracked 5 보호
git push origin main
```

Expected: push 성공 + remote == local main.

- [ ] **Step 12.2: Update routing map §6 prerequisite 충족**

Edit `docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md` — §6 의 close × ref_usage attach policy prerequisite section:

추가 sentence (적절 위치):
```
**Prerequisite 충족 (2026-05-15)**: framing_scale enum SOT v1 area closure
(commit `<final commit hash>`, 2026-05-15) 로 #1/#2 atomic patch 완료.
shot_staging.framing_scale enum required + helper module + close × ref_usage
fail-fast matrix 운영. 본 §6 area (close × ref_usage attach policy spec)
unblocked.
```

- [ ] **Step 12.3: Commit routing map update**

```bash
git add docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md
git commit -m "$(cat <<'EOF'
docs(routing-map): §6 prerequisite 충족 — framing_scale enum SOT v1 closure

framing_scale enum SOT v1 (2026-05-15) closure 로 §6 close × ref_usage
attach policy spec 의 prerequisite (#1/#2 atomic patch) 충족. unblocked.

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

- [ ] **Step 12.4: Write closure memo (external memory)**

Write to `~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_<DATE>_framing_scale_enum_sot_v1_closure.md`:

내용 — 본 area 종합 (commit list + 함정 + 다음 진입점 + B1~B5 carry).

`MEMORY.md` index update.

---

## Verification Strategy

### Gate 1 — Semantic Regex Ban (자가 검증)

- 새 `_NOUNS / _TOKENS / _KEYWORDS / _PHRASES / _RE` open-world classifier identifier 추가 = 0
- 기존 `_CLOSE_FRAMING_RE / _INSERT_HINT_RE / _SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS / _get_insert_hint_re` 모두 삭제
- grep residue = 0 (Step 11.1)

### Gate 2 — Prompt Closed-List Ban

- shot_staging v11 system.md + scene_detail v24 system.md 의 closed keyword list = 0
- vocabulary examples (`close-up / MCU / ECU / XCU / establishing / cutaway / 클로즈업 / 손가락이`) 모두 제거 (Step 11.2)

### Gate 3 — Structured SOT Required

- shot_staging schema v11 의 `framing_scale` = enum required (additionalProperties: false, required list 포함)
- 4 consumer (render_prompt_card / coordinator / detail_steps / scene_reference_service) 모두 `get_framing_scale_or_raise` 또는 `_resolve_framing_scale_for_render` 호출
- `staging.get("framing_scale", ...)` 류 fallback = 0 (Step 11.3)

### Gate 4 — No Silent Fallback

- legacy shot_staging cp missing framing_scale = `AppError(code="shot_staging.framing_scale_missing")` fail-fast
- enum 밖 값 = `AppError(code="shot_staging.framing_scale_invalid")` fail-fast
- close × ref_usage 위반 = `RefContractError("close_ref_usage_violation: ...")` fail-fast
- coordinator broad except 2 site (`:393`, `:678`) 모두 `except RefContractError: raise` reraise (Step 11.4)
- regex fallback = 0

---

## Risks + Mitigations

| Risk | Mitigation |
|---|---|
| legacy shot_staging cp 의 production force re-run 누락 (operation 의무) | Task 2 step_runner schema_version 2 → 3 mismatch auto-invalidation 신뢰. Task 12 closure memo 에 force migration command (`run shot_staging --force`) 운영 runbook 명시 |
| canary fixture sweep 의 누락 (staging dict 의 framing_scale field 미추가) | Task 10 Step 10.3 의 grep 전수 + fixture 별 framing_scale 추가. Task 11 의 full regression 에서 누락 시 framing_scale_missing AppError 로 발견 → 추가 fix |
| validator cascade 의 hidden golden fixture fail | Task 5 Step 5.4 grep + 각 file update. integration test PASS 후만 commit |
| `_resolve_framing_scale_for_render` 의 function-local import vs module-top | Task 3 Step 3.3 function-local import 채택 (`from app.core.framing_scale import ...` 함수 안). 이미 render_prompt_card 가 frame_spatial_contract 등 function-local import 사용 — 일관성. circular import 회피 |
| detail_steps 6 callsite 의 staging access path 불일치 | Task 8 Step 8.4 의무 — 각 callsite 함수 context 별 staging 변수명 (`staging` / `_staging` / `_stg` / `ctx.get("staging")` / `shot_info.get("staging")`) 정확 확인 후 매핑. plan 실행 시점 read 의무 |
| coordinator broad except 2 reraise 의 silent miss (RefContractError 가 broader except 가 swallow) | Task 6 Step 6.5 의 `except RefContractError: raise` 가 broader `except Exception` 보다 앞에 위치 — Python exception 순서 의무. test (Step 6.1) 가 verify |
| Codex factual drift 정정 의무 (subagent dispatch 시) | 각 subagent prompt 안에 "Codex 결과 무비판 흡수 금지, file:line 재검증 후 confirm" 명시. memory feedback_subagent_model_opus + Hygiene v1 함정 carry |
| atomic patch 의 partial migration 위험 (Task 별 commit 사이 broken state) | 본 plan 의 commit 순서 = TDD red→green→commit 의무 (각 task 안 PASS 후만 commit). 단 task 간 broken state 가능 — Task 11 의 full regression PASS 가 atomic patch closure 보장 |
| path-limited git add 누락 (`git add .` 사고) | Bash command 매 commit 마다 explicit path list. CLAUDE.md + memory 함정 carry — never `git add .` |
| 본 area 의 shot_staging module DB row 부재 (PENDING) | prompt_loader file fallback 만 사용 (Hygiene v1 closure 의 DB row 실측 결과). production DB 측정 의무는 별도 area. Task 12 closure memo 에 PENDING 명시 |

---

## Subagent Dispatch 결정

본 plan 의 subagent-friendly task 후보 (model=opus 강제, [[feedback_subagent_model_opus]]):

- **Task 1** (framing_scale.py helper) — isolated new module + 5 unit test. subagent-friendly.
- **Task 7** (scene_reference_service close × ref_usage matrix) — isolated change in single file + 6 unit test. subagent-friendly.

main session 의무 task:

- **Task 2** (shot_staging v11 prompt + manifest + version_registry) — TS 결정 + manifest:599 + version_registry:33+:121-123 4-point sync 정확성 의무
- **Task 3 + 4 + 5** (render_prompt_card 다중 변경) — 큰 file + validator cascade + line range 정확. main session 권장
- **Task 6** (coordinator regex + 3 callsite + 2 broad except) — line range + staging access path 정확
- **Task 8** (detail_steps 6 callsite) — 6 callsite 의 staging access path 함수 context 별 매핑
- **Task 9** (scene_detail v24 prompt + version sync) — TS 결정 + 4-point sync
- **Task 10** (canary + fixture sweep) — grep 전수 + fixture update
- **Task 11** (gate 검증 + regression + Codex review) — review iter 의무
- **Task 12** (push + routing map + closure memo) — final ordering

subagent dispatch 시 prompt 의무 항목:
- spec file path 명시 (`docs/superpowers/specs/2026-05-15-framing-scale-enum-sot-v1-design.md`)
- plan task ID + step ID 명시
- closed area Do-Not-Touch (5) 명시
- 4 gate 자가 검증 의무
- file:line 재검증 의무 (Codex factual drift 정정 carry)
- TDD 의무 (Red commit 0 정책 — Area D-min carry)
- path-limited git add 의무

---

## Commit Sequence + Convention

각 task 의 commit message format:
```
feat(framing-scale-enum-sot-v1): Task N — <component summary>

<2-3 sentence summary of what + why>
<verification result: N test PASS / grep residue 0 / etc.>

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

amend / fix-up commit (Codex review iter):
```
feat(framing-scale-enum-sot-v1): Codex review iter N fix — <summary>
```

push 는 Task 12 만 (intermediate commits = local). single atomic PR-equivalent push.

---

## Closure Criteria (spec §10 매핑)

- [ ] Gate 1 (Semantic Regex Ban) 자가 검증 PASS — Step 11.1
- [ ] Gate 2 (Prompt Closed-List Ban) 자가 검증 PASS — Step 11.2
- [ ] Gate 3 (Structured SOT Required) 자가 검증 PASS — Step 11.3
- [ ] Gate 4 (No Silent Fallback) 자가 검증 PASS — Step 11.4
- [ ] focused test (framing_scale helper + 4 consumer + canary) PASS — Tasks 1, 3, 6, 7, 10
- [ ] full regression: unrelated B1~B5 외 0 fail — Step 11.5
- [ ] shape validator cascade test PASS (validator constants 정리 후 회귀) — Task 5
- [ ] staging_not_applicable mode synthetic test PASS — Task 3 (test 3.1 의 `test_resolve_staging_not_applicable_returns_medium_sentinel`)
- [ ] close × ref_usage matrix 6 unit test PASS — Task 7 (close+zoom_in_detail allow / close+exact_bg raise / close+atmosphere raise / close+empty raise / close+no_bytes early return / medium+any allow)
- [ ] coordinator broad except 2 site (`:393`, `:678`) reraise synthetic test PASS — Task 6
- [ ] legacy shot_staging cp force re-run synthetic test PASS — Task 11 regression
- [ ] G4.5a primary_framing canary PASS (production enum read) — Task 10
- [ ] Codex review APPROVED 또는 APPROVED_WITH_MINOR — Step 11.6
- [ ] push origin/main 완료 — Step 12.1
- [ ] routing map §6 prerequisite 충족 표시 update — Step 12.2-12.3
- [ ] closure memo write + MEMORY.md index update — Step 12.4

---

## Related Documents

- **Spec**: `docs/superpowers/specs/2026-05-15-framing-scale-enum-sot-v1-design.md` (commits `2077778 → a0cccbb → a9a47c1`)
- **Audit**: `docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/00-index.md` §7 + §8.1 (2) + `01-code-side-regex-audit.md` §4 #1+#2 + `03-llm-side-heuristic-audit.md` §4 L-4
- **Routing Map**: `docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md` §6 (본 area 의 follow-up unblock)
- **Closed areas (Do-Not-Touch)**: `keep_elements.py` (5f3da38) / `frame_spatial_contract.py` (2885f8c) / `semantic_contract_router.py` (Patch B-min)
- **Memory carry**:
  - `feedback_llm_based_judgment` (4 gate)
  - `feedback_subagent_model_opus` (subagent dispatch model=opus 강제)
  - `session_20260515_prompt_hygiene_v1_closure` (B1~B5 backlog + 함정 6)
- **B1~B5 unrelated regression backlog** (본 area 영향 외, 별도 처리)
