# G4.6 Visual Failure Root-Cause Repair — Implementation Plan

본 문서는 G4.6 spec
(`docs/superpowers/specs/2026-05-06-g4.6-visual-failure-rootcause-design.md`,
2688 lines, RO-1 ~ RO-31 적용 완료) 의 implementation plan 이다. 사용자
binding 답습 — Wave A → Wave B 분리, deterministic 보호 1차, plan 후 단계별
구현 + dual review.

- 작성일: 2026-05-06
- 상태: **R1 Codex audit fix iter 4 — required_refs + malformed pair carry**: PRO-12 (required_refs _MISSING fail-fast) + PRO-13 (allowed_outlook_pairs entry no-id fail-fast) + PRO-14 (metadata sync RO-29~31). R2 Claude audit 생략 결정.
- main HEAD: `bb3beb6`
- 선행 spec: `docs/superpowers/specs/2026-05-06-g4.6-visual-failure-rootcause-design.md`
  (2688 lines, RO-1 ~ RO-31 적용)
- 목적: spec 의 9 root cause cluster (RC-A ~ RC-I) 를 9 phase 로 mechanical
  구현. Phase 0 spec freeze + fixtures → Phase 1 hotfix → Phase 2~5 Wave A
  → Phase 6~7 Wave B → Phase 8 production regeneration.

## Round Override (audit-driven, applied before body)

### Round 1 — Codex (R1) audit 결과 (2026-05-06)

R1 verdict: `NEEDS_REVISION` (B1+B2+B3+B4 + I1+I2+I3). 사용자 결정: R2 Claude
audit 생략 → PRO-1~7 inline 동기화 후 Phase 0 진입.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **PRO-1** | R1-B1 | **Phase 0 grep gate 정정 — spec body 의 RO 표 row false-positive 차단**. spec body 의 RO-23 / RO-27 row 에 `_split_into_clauses` / `_is_separate_sentence_descriptor` 단어가 의도적으로 남음 (RO 표 explanation). plan grep 이 spec 전체 file scan 시 false-positive. 정정: Phase 0 Task 0.1 grep 제거, 대신 (a) spec 의 ```python ... ``` 코드 블록 안에 helper definition (`def _split_into_clauses` / `def _is_separate_sentence_descriptor`) 0건 manual review, (b) Phase 0 verification gate "code block 안 잔재 0건 확인" 으로 명시. AppError positional 검사는 유지 (spec body 안 잔재 가능성 낮음). | §2.2 + §2.3 |
| **PRO-2** | R1-B2 | **shot_validator v4 파일명 정정 — `system.md` + `validator_schema.json` only**. 코드 검증: `shot_validator_step.py:64-65` 가 `load_prompt("shot_validator", "system")` + `load_schema("shot_validator", "validator_schema")` 호출. plan 의 `user_template.md` / `output_schema.json` 언급 잘못. 정정: §5.2 Files 표 + Task 3.1 prompt 작성 — `system.md` + `validator_schema.json` 두 file 만 신규. user_template 은 v3 carry (별도 파일 없음). schema 는 G4.6 신규 `character_ids` 필드 추가. | §5.2 + §5.3 (Task 3.1) |
| **PRO-3** | R1-B3 | **version_registry 정정 — `MODULE_VERSIONS["shot_validator"]` + `PROMPT_DEPENDENCIES["shot_validator"]`**. 코드 검증: `version_registry.py:30` `"shot_validator": "1.2.0"`, `:109` `"shot_validator": {"prompt_dependency": "shot_validator/v3"}`. plan 의 `"shot_validator_composer"` key 는 존재하지 않음 (`scene_detail_composer` 와 혼동). 정정: §5.3 Task 3.5 — `MODULE_VERSIONS["shot_validator"]: "1.2.0" → "1.4.0"` + `PROMPT_DEPENDENCIES["shot_validator"]["prompt_dependency"]: "shot_validator/v3" → "shot_validator/v4"`. 동일 패턴 적용: scene_detail v21 (`MODULE_VERSIONS["scene_detail_composer"]: "1.20.0" → "1.21.0"` + `PROMPT_DEPENDENCIES["scene_detail_composer"]["prompt_dependency"]: "scene_detail/v20" → "scene_detail/v21"`). | §5.3 (Task 3.5) + §6.3 (Task 4.5) |
| **PRO-4** | R1-B4 | **`visible_entities_validator.py` silent fallback 제거 — `_MISSING` sentinel + AppError fail-fast**. plan §6.3 Task 4.3 의 `or []` / `or {}` 패턴이 silent fallback 재도입 — `feedback_no_silent_fallback.md` 와 정면 충돌. 정정: required field (visible_entities / t2i_variations / render_prompt_card / asset_requirements / id_policy / allowed_outlook_pairs) 모두 `_MISSING = object()` sentinel 사용 + 부재/타입 mismatch 시 AppError fail-fast. 특히 `t2i_variations` 누락 또는 빈 list 는 contract violation (validator bypass 금지). optional field (description / shot_index 등 non-contract) 만 `or` 허용. | §6.3 (Task 4.3) |
| **PRO-5** | R1-I1 | **`_load_cp()` 절대경로 변경**. 코드 검증: `config.py:31` `projects_dir: str = str(PROJECT_ROOT / "projects")` (절대경로). 기존 step 들도 `Path(settings.projects_dir) / project_id / ...` 사용. plan `entity_protection.py` 의 `Path(f"projects/{project_id}/...")` 잘못 — relative path 가 process cwd 의존. 정정: `from app.core.config import settings` import + `Path(settings.projects_dir) / project_id / "checkpoints" / "episodes" / episode_id / step_id / "manifest.json"`. | §4.3 (Task 2.1) |
| **PRO-6** | R1-I2 | **`_collect_required_entity_ids` EntityEpisodeLink query 에 project_id filter 추가**. 코드 검증: `entity_episode_link.project_id` (`models/project.py:138` ForeignKey) 존재. plan query 가 episode_id 만 filter — cross-project leakage 위험. 정정: `.filter(EntityCanon.project_id == project_id, EntityEpisodeLink.project_id == project_id, EntityEpisodeLink.episode_id == episode_id, EntityEpisodeLink.t2i_appearance_count >= 1)`. EntityCanon.project_id + EntityEpisodeLink.project_id 둘 다 필터 (defense in depth). | §4.3 (Task 2.1) |
| **PRO-7** | R1-I3 | **plan header metadata 정정 — spec 2504 lines**. 기존 plan header `2484 lines` stale. 정정: 위 header 라인 + §1.1 의 spec 참조 라인. | header |
| **PRO-8** | R1-iter2-B2-carry | **PRO-2 carry — scene_detail / shot_dependency_t2i 파일명도 코드와 일치 정정**. 코드 검증: `detail_steps.py:753-754` `load_prompt("scene_detail", "system")` + `load_schema("scene_detail", "detail_schema")`. `shot_dependency_t2i_step.py:144-145` `load_prompt("shot_dependency_t2i", "system")` + `load_schema("shot_dependency_t2i", "schema")`. plan §6.2 의 `scene_detail/.../user_template.md` 잔재 + §8.2 의 `shot_dependency_t2i/.../output_schema.json` 잔재 모두 잘못. 정정: scene_detail v21 신규 파일 = `system.md` + `detail_schema.json` (user_template 별도 X). shot_dependency_t2i v6 신규 파일 = `system.md` + `schema.json` (output_schema 아님). | §6.2 + §8.2 |
| **PRO-9** | R1-iter2-I3-carry | **PRO-7 carry — plan body 첫 paragraph 의 spec line count stale 잔재 정정**. 기존 PRO-7 fix 가 §0 직후 status 라인은 정정했지만 line 4 paragraph (`2484 lines, RO-1 ~ RO-28 적용 완료`) 가 stale. 정정: `2484 lines` → `2504 lines`. | header |
| **PRO-10** | R1-iter3-PRO4-carry | **plan Task 4.3 source 3b allowed_outlook_pairs `_MISSING` fail-fast**. 현재 body 의 `id_policy.get("allowed_outlook_pairs", [])` 가 필드 누락 시 빈 배열로 통과. PRO-4 row 는 allowed_outlook_pairs 도 required field 라고 박음 — body 와 충돌. 정정: `id_policy.get("allowed_outlook_pairs", _MISSING)` + missing/type mismatch AppError fail-fast (`_require_field` 또는 inline _MISSING check). | §6.3 (Task 4.3) |
| **PRO-11** | R1-iter3-PRO4-grep-carry | **PRO-4 verification grep gate 강화 — `.get("allowed_outlook_pairs", [])` 패턴 검출**. 현재 §7.3 grep 은 `or []` / `or {}` 패턴만 검사 — `.get(field, [])` / `.get(field, {})` 형태의 silent fallback 은 통과. 정정: grep pattern 에 `\.get\(["'][^"']+["'],\s*(\[\]|\{\})\)` 추가 (default 빈 collection 제공 패턴 모두 검출). spec §3.5 sync 와 일관. | §7.3 (verification grep gate) |
| **PRO-12** | R1-iter4-B1 | **`required_refs` 도 _MISSING fail-fast (spec RO-30 carry)**. plan body source 3a 의 `required_refs = asset_req.get("required_refs", [])` 가 PRO-11 grep gate (required_refs 의 .get(...,[]) 금지 패턴) 와 충돌. G4 contract: `asset_requirements` 가 required dict 면 그 안 `required_refs` 도 schema 강제. 정정: `required_refs = asset_req.get("required_refs", _MISSING)` + missing 시 AppError. 빈 list 가능 (각 shot 마다 ref 0+ 가능), missing 불가. plan §6.3 Task 4.3 + spec §3.5 sync. | §6.3 (Task 4.3 source 3a) |
| **PRO-13** | R1-iter4-B2 | **`allowed_outlook_pairs` entry malformed silent pass 차단 (spec RO-31 carry)**. base derivation `pair.get("character_id") or pair.get("base_id") or composite_id ... or ""` 결과가 `""` 이면 `if base and base not in visible_bases:` 분기로 silent pass — schema 위반 entry 무시. 정정: base derivation 후 `if not base: AppError("entry missing all of character_id/base_id/composite_id")`. 추가로 `outlook_id` missing/type mismatch 도 fail-fast. plan §6.3 Task 4.3 + spec §3.5 sync. | §6.3 (Task 4.3 source 3b) |
| **PRO-14** | R1-iter4-I1 | **metadata + carry table sync — RO-29/30/31 추가**. plan header line 4 (`2504 lines, RO-1 ~ RO-28`) + line 13 + Phase 0 goal Task 0.1 (RO-28 까지) + §13 carry table 제목 (`Carry from Spec (RO-1 ~ RO-28)`) 모두 stale. 정정: 모든 RO 참조를 `RO-1 ~ RO-31` 로 업데이트. §13 table 에 RO-29 (silent fallback sync) / RO-30 (required_refs fail-fast) / RO-31 (malformed pair) row 추가 + plan task 매핑. spec line count 는 fix iter 5 적용 후 final 값 (현재 2680+). | header + §1.1 + §2.2 + §13 |

### 메타룰 (G4.5a carry)

- Round Override 표가 §1-§13 본문보다 우선
- Round 1 Override IDs prefix `PRO-N` (Plan Round Override). spec carry 는
  `RO-N` (1~28).
- silent fallback / regex post-processing / 새 top-level field — spec 메타룰
  답습 (`feedback_no_silent_fallback.md`, `feedback_no_regex_postprocessing.md`).
- LLM "visually critical" 플래그 도입 시 BLOCKING (spec RO 답습).

---

## 1. Overview

### 1.1 9-Phase summary

| Phase | Wave | Scope | 의존 | 추정 시간 |
|---|---|---|---|---|
| Phase 0 | — | spec freeze + fixtures + baseline 기록 | — | 1-2시간 |
| Phase 1 | Pre-Wave | RC-D — `prompt_service.py` substring routing fix | Phase 0 | 2-3시간 |
| Phase 2 | Wave A1 | RC-C — reference protection (`reference_pipeline_orchestrator.py`) | Phase 1 | 3-4시간 |
| Phase 3 | Wave A2 | RC-F + RC-G — `shot_validator` v4 prompt + step code | Phase 2 | 4-5시간 |
| Phase 4 | Wave A3 | RC-E + RC-H — `scene_detail` v21 prompt + contract validator | Phase 3 | 5-6시간 |
| Phase 5 | Wave A Exit | regression + canary + verification gate | Phase 4 | 2시간 |
| Phase 6 | Wave B1 | RC-A — `shot_dependency_t2i` v6 + zoom validator | Phase 5 | 4-5시간 |
| Phase 7 | Wave B2 | RC-B + RC-I — image prompt integration | Phase 6 | 3-4시간 |
| Phase 8 | Production | 4 defect regeneration + visual sign-off | Phase 7 | 30-60분 + 사용자 검토 |

총 추정: 25-35 시간 (구현) + production regen.

### 1.2 핵심 binding (spec carry)

- **Wave A → Wave B 순차** — Wave A 완전 종료 + push 후 Wave B 진입
- **5-field envelope contract 유지** — `render_strategy` / `id_policy` /
  `background_binding` / `continuity_elements_used` / `asset_requirements` 그대로
- **deterministic 보호 1차** — LLM "visually critical" flag 후속 carry
- **silent fallback 금지** — 모든 contract violation 에 fail-fast (`AppError`)
- **fixtures 우선** — spec §5 raw failure fixtures 가 Phase 0 에서 fix 됨

### 1.3 Non-goals

- shot_validator 전체 재설계 (type descriptor / violent freeze / characters=[]
  fail-fast 까지만 — spec §1.4)
- chain_bg group anchor selection 재구조화 (prompt hotfix 만 — spec §1.4)
- LLM "visually critical" 판단 플래그 (deterministic 보호로 1차 — spec §1.4)
- 새 top-level field 도입 (5-field envelope 유지 — spec §1.4)
- production regeneration 자동화 (Phase 8 은 manual force step 시퀀스)

---

## 2. Phase 0 — Spec Freeze + Fixtures + Baseline

### 2.1 Goal

- spec RO-1 ~ RO-31 모두 inline 동기화 확인 (final pre-impl audit, PRO-14 carry)
- Phase 1~7 의 모든 unit/integration/canary 가 의존하는 raw fixture 확정
- baseline pytest count + active prompt versions + main HEAD 기록

### 2.2 Tasks

**Task 0.1 — spec freeze 검증** (15분, PRO-1 정정):

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1

# (a) AppError positional 잔재 — code 패턴만 매치 (spec body prose 안전):
grep -nE 'raise\s+AppError\s*\(\s*f' docs/superpowers/specs/2026-05-06-g4.6-visual-failure-rootcause-design.md
# expected: 0 hits
```

**(b) Manual review** — spec 의 ```python ... ``` 코드 블록 안에 다음 helper
정의 0건:

- `def _split_into_clauses` (RO-23 가 제거 — code block 안 정의 없어야 함)
- `def _is_separate_sentence_descriptor` (RO-27 이 `_descriptor_has_anchored_id_in_window`
  안에 통합 — 별도 helper definition 없어야 함)

spec body prose / RO 표 row 안에는 단어 자체가 의도적으로 남음 (Override
explanation). manual review 시 ` ``` ` 코드 블록 안만 검사.

```bash
# 코드 블록 안 정의 검사 — markdown fence 안 'def _name' 패턴 (정확):
awk '
  /^```python/{in_code=1; next}
  /^```$/{in_code=0; next}
  in_code && /^def _split_into_clauses|^def _is_separate_sentence_descriptor/{
    print FILENAME":"NR": HELPER REDEFINED — "$0
  }
' docs/superpowers/specs/2026-05-06-g4.6-visual-failure-rootcause-design.md
# expected: 0 output lines
```

**Task 0.2 — Raw fixture capture** (30분):

PID `298d86d9` / EP `b6544514` 에서 spec §5.1 표의 12 fixture 모두 capture.

```bash
mkdir -p backend/tests/fixtures/g4_6/

# Step manifest fixtures (각 manifest 의 target shot entry 만 추출)
backend/.venv/bin/python scripts/g4_6_capture_fixtures.py \
  --pid 298d86d9-615b-4b87-8040-21144c0731c1 \
  --eid b6544514-babb-4781-95a2-6a0ff1da342f \
  --out backend/tests/fixtures/g4_6/
```

`scripts/g4_6_capture_fixtures.py` 신규 작성 — spec §5.1 의 12 fixture 표 따라
다음 12 file 생성:

| Fixture file | Content |
|---|---|
| `s1_shot3_shot4_dependency.json` | shot_dependency_t2i manifest, S1_Shot4 entry full (location_refs[].reason 포함) |
| `s1_shot4_image_asset.json` | DB ImageAsset row (still_id=bb45fefd...) — prompt_used + reference_image_ids |
| `s1_shot5_dependency.json` | shot_dependency_t2i, S1_Shot5 entry |
| `s1_shot5_scene_detail.json` | scene_detail manifest, S1_Shot5 entry — render_prompt_card + t2i_variations |
| `s2_shot4_shot_validator.json` | shot_validator manifest, S2_Shot4 entry — characters=[] visible_entities=None |
| `s2_shot4_scene_detail.json` | scene_detail, S2_Shot4 — visible_entities=["C08","L03"] + t2i_prompt with "young East Asian woman" |
| `s8_shot6_image_asset.json` | DB ImageAsset (S8_Shot6) — prompt_used + reference_image_ids |
| `c15_entity_canon.json` | DB EntityCanon C15 — name="검은 형상", stable_traits=["얼굴이 보이지 않음", ...] |
| `c08_entity_canon.json` | DB EntityCanon C08 — stable_traits=["red irises", "sharp pointed teeth", ...] |
| `c09_entity_canon.json` | DB EntityCanon C09 — name="한국인 여자" |
| `ref_low_freq_skip_pre_g4_6.json` | 17 UUID + resolved short_id (현재 baseline) |
| `ref_low_freq_skip_post_g4_6.json` | C08, C09, C15 만 제외된 expected post-fix (RO-25) |

각 fixture 는 immutable — Phase 별 테스트가 이 fixture 를 원본으로 사용.

**Task 0.3 — baseline 기록** (10분):

```bash
# 현재 pytest count
cd /Users/manta/Documents/Projects/TheRoad-I1
backend/.venv/bin/pytest backend/tests/ --collect-only -q | tail -3 > /tmp/g4_6_baseline_pytest.txt

# active prompt versions (version_registry)
grep -E 'scene_detail|shot_validator|shot_dependency_t2i|prompt_service' backend/app/core/version_registry.py > /tmp/g4_6_baseline_versions.txt

# main HEAD
git log --oneline -5 main > /tmp/g4_6_baseline_head.txt

# 현재 reference_image_ids (D2/D3 target shot)
backend/.venv/bin/python scripts/g4_6_baseline_refs.py \
  --pid 298d86d9-615b-4b87-8040-21144c0731c1 \
  --eid b6544514-babb-4781-95a2-6a0ff1da342f > /tmp/g4_6_baseline_refs.txt
```

baseline 결과 commit 으로 박지 않고 `/tmp/` 에만 보존. Phase 5/8 verification
에서 비교 source.

### 2.3 Verification gate

- 12 fixture file 모두 존재 + non-empty
- baseline 4 file `/tmp/g4_6_baseline_*.txt` 모두 생성
- spec 잔재 grep 0 hit

### 2.4 Commit policy

Phase 0 결과물:
- `scripts/g4_6_capture_fixtures.py` (신규)
- `scripts/g4_6_baseline_refs.py` (신규)
- `backend/tests/fixtures/g4_6/*.json` (12 file)

단일 commit: `chore(g4.6): freeze spec + capture raw fixtures + baseline scripts`

dual review 불필요 (mechanical fixture capture).

---

## 3. Phase 1 — Pre-Wave Hotfix: Reference Label Routing (RC-D)

### 3.1 Goal

`prompt_service.py:62` substring matching 버그 수정 — `"face" in label_lower`
가 `"face framing"` 같은 부정형 텍스트에서 false-positive 매치 → bg label 이
character 분기로 새는 D2 직접 cause 차단.

### 3.2 Files

- `backend/app/services/prompt_service.py:43-148` (resolve_ref_roles 분기)
- 호출자 검사: `backend/app/services/shot_reference_service.py`,
  `backend/app/services/scene_reference_service.py` (character ref label
  생성부 — 정확 token 사용 여부)

### 3.3 Tasks

**Task 1.1 — `_classify_label` pure helper 도입** (30분):

```python
# backend/app/services/prompt_service.py 신규 helper
_CHARACTER_REF_LABEL_PATTERNS = (
    "(character reference)",
    "character reference:",
    "(face reference)",
    "face reference:",
)

def _is_explicit_character_ref_label(label_lower: str) -> bool:
    """RC-D — substring 'face'/'character' false-positive 차단.

    Why: previous-shot ref label 에 'face framing' / 'face crop' 같은
    부정형 substring 이 있어도 character 분기가 매치되는 버그 차단 (D2 직접
    cause).
    """
    return any(p in label_lower for p in _CHARACTER_REF_LABEL_PATTERNS)


def _classify_label(label_lower: str) -> str:
    """label → branch ID. 분기 순서:
    1. outfit (정확 일치)
    2. wearing/outfit (substring)
    3. previous_shot_same_frame_zoomed (specific)
    4. previous_shot_same_room (specific)
    5. previous_shot_continuity (general)
    6. background_chain_ref (specific)
    7. character_ref (정확 token only — _is_explicit_character_ref_label)
    8. prop_ref
    9. background/location (general)
    10. fallback
    """
    if label_lower == "outfit appearance":
        return "outfit_ref_explicit"
    if "wearing" in label_lower or "outfit" in label_lower:
        return "outfit_ref_inline"
    if "previous shot" in label_lower and "same frame zoomed" in label_lower:
        return "previous_shot_same_frame_zoomed"
    if "previous shot" in label_lower and "same room" in label_lower:
        return "previous_shot_same_room"
    if ("previous shot" in label_lower or "previous scene" in label_lower
            or "continuity" in label_lower):
        return "previous_shot_continuity"
    if "background chain ref" in label_lower:
        return "background_chain_ref"
    if _is_explicit_character_ref_label(label_lower):
        return "character_ref"
    if "prop" in label_lower or "object" in label_lower:
        return "prop_ref"
    if "background" in label_lower or "location" in label_lower:
        return "background_general"
    return "fallback"
```

**Task 1.2 — `resolve_ref_roles` 분기 재배열** (45분):

```python
def resolve_ref_roles(labeled_refs: List[Tuple[str, Any]]) -> RefResolution:
    ref_roles: List[str] = []
    ref_instructions: List[str] = []
    for i, (label, _) in enumerate(labeled_refs, 1):
        label_lower = label.lower()
        branch = _classify_label(label_lower)

        if branch == "outfit_ref_explicit":
            ref_roles.append(f"Reference image {i}: standalone outfit/costume reference")
            ref_instructions.append(f"- dress the character in the outfit shown in image {i}")
        elif branch == "outfit_ref_inline":
            ref_roles.append(f"Reference image {i}: {label}")
            ref_instructions.append(
                f"- use image {i} as character appearance reference — "
                f"match the person's identity and outfit where visible in the scene"
            )
        elif branch == "previous_shot_same_frame_zoomed":
            ref_roles.append(
                f"Reference image {i}: PREVIOUS SHOT (SAME FRAME, zoomed-in reframing) — reuse the exact frame"
            )
            ref_instructions.extend([
                f"- use image {i} as the SAME frame with only reframing/cropping — the camera moved closer but the moment, subject, pose, and environment are identical",
                f"- do NOT add new subjects, do NOT duplicate body parts, do NOT change the pose shown in image {i}",
                f"- render the focused region (hand, wrist, face area, object, etc.) enlarged within the same frame context from image {i}",
            ])
            for sentence in label.split("."):
                sentence = sentence.strip()
                if not sentence:
                    continue
                if "keep" in sentence.lower() or "ignore" in sentence.lower():
                    ref_instructions.append(f"- from image {i}: {sentence}")
        elif branch == "previous_shot_same_room":
            ref_roles.append(
                f"Reference image {i}: BACKGROUND from a previous shot (SAME ROOM) — use as-is"
            )
            ref_instructions.extend([
                f"- use the background, furniture layout, walls, and lighting from image {i} as-is",
                f"- do NOT copy any standing/moving people from image {i}",
            ])
            for sentence in label.split("."):
                sentence = sentence.strip()
                if not sentence:
                    continue
                if "keep" in sentence.lower() or "ignore" in sentence.lower():
                    ref_instructions.append(f"- from image {i}: {sentence}")
        elif branch == "previous_shot_continuity":
            ref_roles.append(
                f"Reference image {i}: BACKGROUND/ENVIRONMENT from a previous shot at the same location"
            )
            ref_instructions.extend([
                f"- use ONLY the lighting, color palette, and environment mood from image {i}",
                f"- do NOT copy characters, people, or their appearances from image {i}",
                f"- do NOT copy the composition or character poses from image {i}",
            ])
        elif branch == "background_chain_ref":
            ref_roles.append(
                f"Reference image {i}: pre-rendered BACKGROUND chain reference — use as-is"
            )
            # Phase 7 Wave B Step 3 (RC-I) 에서 4 directive 추가 — 본 hotfix
            # 에서는 기존 2 directive 유지.
            ref_instructions.extend([
                f"- match the wall, floor, ceiling, furniture layout, and lighting exactly from image {i}",
                f"- do NOT copy any people from image {i}",
            ])
        elif branch == "character_ref":
            ref_roles.append(f"Reference image {i}: {label}")
            ref_instructions.append(
                f"- use image {i} as character appearance reference — "
                f"match the person's identity where visible in the scene"
            )
        elif branch == "prop_ref":
            ref_roles.append(f"Reference image {i}: {label}")
            ref_instructions.append(f"- include the object shown in image {i}")
        elif branch == "background_general":
            ref_roles.append(f"Reference image {i}: background/environment reference.")
            ref_instructions.append(f"- use the lighting, architecture, and environment mood from image {i}")
        else:  # fallback
            ref_roles.append(f"Reference image {i}: {label}")
            ref_instructions.append(f"- reference image {i}: {label}")

    # 공통 제약 (그대로 유지)
    ref_instructions.extend([
        "- do not copy poses or compositions from reference images",
        "- do not alter character identities where their face is visible in the scene",
        "- only render what the scene description asks for — "
          "if only a hand or wrist is described, do NOT add the character's face",
    ])
    return RefResolution(ref_roles=ref_roles, ref_instructions=ref_instructions)
```

**Task 1.3 — 호출자 character ref label 검증** (30분):

```bash
# character ref label 생성부 grep
grep -rn "character reference\|face reference\|(character\|(face" \
  backend/app/services/shot_reference_service.py \
  backend/app/services/scene_reference_service.py
```

label 생성부에서 `Image N (character reference): <name>` 또는 `Image N (face
reference): <name>` 형식 사용 확인. 미사용 시 정정.

**Task 1.4 — Unit tests** (45분):

`backend/tests/services/test_prompt_service_label_routing.py` (신규):

```python
import pytest
from app.services.prompt_service import (
    _classify_label,
    _is_explicit_character_ref_label,
    resolve_ref_roles,
)

# Phase 1 spec §5.3 답습 — parametrize 표
@pytest.mark.parametrize("label,expected_branch", [
    # G4.6 RC-D 회귀 케이스 (D2 root)
    (
        "previous shot at same location (SAME ROOM) — use this background as-is. "
        "Ignore the close-up face framing and the tight shoulder-level crop. "
        "Keep: ...",
        "previous_shot_same_room",
    ),
    (
        "previous shot at same location (SAME FRAME ZOOMED) — reuse this exact frame "
        "... Do NOT change the pose ...",
        "previous_shot_same_frame_zoomed",
    ),
    (
        "Image 2 (character reference): 한국인 남자 — Set in modern 대한민국 ...",
        "character_ref",
    ),
    ("Image 1 (face reference): 수리영", "character_ref"),
    ("pre-rendered BACKGROUND chain reference — use as-is", "background_chain_ref"),
    ("Image 3 (object reference): 휴대폰 — ...", "prop_ref"),
    ("standalone outfit/costume reference", "outfit_ref_explicit"),
    ("some generic ref text", "fallback"),
])
def test_classify_label(label, expected_branch):
    assert _classify_label(label.lower()) == expected_branch


def test_is_explicit_character_ref_label_negative_face_substring():
    """RC-D 핵심 — 'face framing' 가 character ref 로 매치되지 않음."""
    assert not _is_explicit_character_ref_label(
        "previous shot at same location (same room) — use this background as-is. "
        "ignore the close-up face framing and the tight shoulder-level crop."
    )


def test_resolve_ref_roles_d2_fixture_routes_to_bg():
    """D2 fixture S1_Shot5 ref label → bg 분기 PASS, character 분기 미매치."""
    label = (
        "previous shot at same location (SAME ROOM) — use this background as-is. "
        "Ignore the close-up face framing and the tight shoulder-level crop. "
        "Keep: the dense forest clearing, the cold dusk lighting, the shadowy "
        "foliage, the same man in panic"
    )
    res = resolve_ref_roles([(label, b"<bytes>")])
    # ref_roles 가 BACKGROUND 분기 출력
    assert any("BACKGROUND from a previous shot (SAME ROOM)" in r for r in res.ref_roles)
    # instructions 가 character ref 워딩 미포함
    assert not any("character appearance reference" in i for i in res.ref_instructions)
    # bg directive 포함
    assert any("use the background" in i for i in res.ref_instructions)
    assert any("do NOT copy any standing/moving people" in i for i in res.ref_instructions)
```

**Task 1.5 — Canary smoke** (20분):

`scripts/canary/g4_6_label_routing_face_substring_fix.py` (신규):

```python
"""G4.6 Pre-Wave hotfix canary — D2 fixture S1_Shot5 label 이 character ref
로 새지 않음 검증."""
from app.services.prompt_service import resolve_ref_roles
from pathlib import Path
import json

fixture = json.loads(Path("backend/tests/fixtures/g4_6/s1_shot5_dependency.json").read_text())
label = fixture["expected_dependency_t2i_label"]  # fixture 안 미리 박은 label

res = resolve_ref_roles([(label, b"<bytes>")])
assert any("BACKGROUND from a previous shot (SAME ROOM)" in r for r in res.ref_roles), \
    f"FAIL: bg branch missing — got {res.ref_roles}"
assert not any("character appearance reference" in i for i in res.ref_instructions), \
    f"FAIL: character branch leaked — got {res.ref_instructions}"
print("OK: g4_6 label routing hotfix — D2 fixture verified")
```

### 3.4 Verification gate

- `backend/.venv/bin/pytest backend/tests/services/test_prompt_service_label_routing.py -v` → 모두 PASS
- `backend/.venv/bin/python scripts/canary/g4_6_label_routing_face_substring_fix.py` → "OK" 출력
- 회귀 검사: `backend/.venv/bin/pytest backend/tests/services/` → baseline + new tests 모두 PASS
- AppError positional 사용 0건 (`grep "AppError(f" backend/app/services/prompt_service.py`)

### 3.5 Commit policy

단일 commit: `fix(prompt-service): G4.6 RC-D label routing — face substring false-positive 차단 (D2 root)`

dual review (Codex + Claude code-reviewer) — Pre-Wave hotfix 는 양쪽 Wave 의
선행이므로 audit 통과 후 push.

### 3.6 Risk + Rollback

- 회귀 위험: 기존 PID 의 force 재실행에서 분기 결정이 바뀌어 다른 결과. 사실상
  D2 fixture 패턴 외에는 분기 동일하게 유지 — 기존 정상 case 영향 없음.
- Rollback: `git revert <commit>` — 30 line code change, 단일 file.

---

## 4. Phase 2 — Wave A1: Reference Protection (RC-C)

### 4.1 Goal

`reference_pipeline_orchestrator.py:289` skip rule 보강 — variant 자체 / kind
keyword / scene_director.present_entity_ids / shot_validator.character_ids /
shot_director / EntityEpisodeLink 4-source cascade 보호.

### 4.2 Files

- `backend/app/services/reference_pipeline_orchestrator.py:275-310` (skip rule
  + helpers)
- `backend/app/core/entity_protection.py` (NEW — `_should_skip_low_freq` /
  `_has_protected_kind_keyword` / `_collect_required_entity_ids` 분리 module)

### 4.3 Tasks

**Task 2.1 — `entity_protection.py` 신규 module** (1시간):

`backend/app/core/entity_protection.py`:

```python
"""G4.6 Wave A1 RC-C — entity reference 보호 룰. low_freq_skip 의 4-source
cascade + kind keyword + variant 보호."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Iterable

from app.models.project import EntityCanon, EntityEpisodeLink

logger = logging.getLogger(__name__)

# RO-10 — entity_type=="character" 에만 적용. shadow 제외 (조명/배경 false-positive).
_PROTECTED_KIND_KEYWORDS_EN = frozenset({
    "silhouette", "yokai", "monster", "creature", "demon", "ghost", "spirit",
    "attacker", "assailant", "predator", "pursuer",
    "transformed", "transformation", "morphed", "mutated",
    "disguised", "obscured", "hidden", "masked",
    "half-yokai", "possessed",
})
_PROTECTED_KIND_KEYWORDS_KO = frozenset({
    "검은 형상", "검은 인영", "그림자", "실루엣",
    "요괴", "요괴화", "괴물", "악령", "유령", "혼령",
    "공격자", "포식자",
    "변신", "변형", "변모",
    "위장", "가린",
    "보이지 않는 얼굴",
})


def _has_protected_kind_keyword(e: dict) -> bool:
    """RO-10 — entity_type=="character" + name/stable_traits 매칭 only.

    description 매칭 X (조명/배경의 'shadow' false-positive 차단).
    """
    if e.get("entity_type") != "character":
        return False
    name = (e.get("name") or "").lower()
    traits_text = " ".join(str(t).lower() for t in (e.get("stable_traits") or []))
    haystack_lower = f"{name}\n{traits_text}"
    if any(kw in haystack_lower for kw in _PROTECTED_KIND_KEYWORDS_EN):
        return True
    name_ko = e.get("name") or ""
    traits_ko = " ".join(str(t) for t in (e.get("stable_traits") or []))
    haystack_ko = f"{name_ko}\n{traits_ko}"
    return any(kw in haystack_ko for kw in _PROTECTED_KIND_KEYWORDS_KO)


def _load_cp(project_id: str, episode_id: str, step_id: str) -> dict | None:
    """checkpoint manifest loader. 부재 시 None.

    PRO-5: settings.projects_dir 절대경로 사용 (process cwd 비의존).
    """
    from app.core.config import settings
    cp_path = (
        Path(settings.projects_dir)
        / project_id / "checkpoints" / "episodes" / episode_id / step_id
        / "manifest.json"
    )
    if not cp_path.exists():
        return None
    return json.loads(cp_path.read_text(encoding="utf-8"))


def _collect_required_entity_ids(project_id: str, episode_id: str, db) -> set[str]:
    """RO-3 — 4-source cascade primary + scene_detail rescue.

    Source priority:
    1. scene_director.present_entity_ids (primary)
    2. shot_validator.character_ids (RO-1 신규 schema)
    3. shot_director.character_id (shot-level entity)
    4. EntityEpisodeLink.t2i_appearance_count >= 1 (DB-derived fallback)
    5. scene_detail.visible_entities + asset_requirements.required_refs
       (force/retry rescue only)
    """
    short_ids: set[str] = set()

    # (1) scene_director
    director_cp = _load_cp(project_id, episode_id, "scene_director")
    if director_cp:
        for sc in director_cp.get("data", {}).get("scenes", []):
            for sid in sc.get("present_entity_ids", []) or []:
                short_ids.add(sid.split("O")[0])

    # (2) shot_validator.character_ids — Phase 3 후 schema 에 등장
    sv_cp = _load_cp(project_id, episode_id, "shot_validator")
    if sv_cp:
        for sc in sv_cp.get("data", {}).get("scenes", []):
            for sh in sc.get("shots", []):
                for sid in sh.get("character_ids", []) or []:
                    short_ids.add(sid.split("O")[0])

    # (3) shot_director — character_angles[].character_id
    sdir_cp = _load_cp(project_id, episode_id, "shot_director")
    if sdir_cp:
        for sh in sdir_cp.get("data", {}).get("shots", []):
            for ca in sh.get("character_angles", []) or []:
                cid = ca.get("character_id") or ca.get("character_short_id")
                if cid:
                    short_ids.add(cid.split("O")[0])

    # (4) EntityEpisodeLink.t2i_appearance_count
    # PRO-6: project_id filter 양쪽 (defense in depth — cross-project leakage 차단)
    rows = db.query(EntityCanon.short_id).join(
        EntityEpisodeLink, EntityEpisodeLink.canon_id == EntityCanon.id,
    ).filter(
        EntityCanon.project_id == project_id,
        EntityEpisodeLink.project_id == project_id,
        EntityEpisodeLink.episode_id == episode_id,
        EntityEpisodeLink.t2i_appearance_count >= 1,
    ).all()
    for r in rows:
        short_ids.add(r.short_id.split("O")[0])

    # (5) rescue: scene_detail
    sd_cp = _load_cp(project_id, episode_id, "scene_detail")
    if sd_cp:
        for sh in sd_cp.get("data", {}).get("scenes", []):
            for sid in sh.get("visible_entities", []) or []:
                short_ids.add(sid.split("O")[0])
            rpc = sh.get("render_prompt_card") or {}
            asset_req = rpc.get("asset_requirements") or {}
            for ref in asset_req.get("required_refs", []) or []:
                if not isinstance(ref, dict):
                    continue
                rid = ref.get("id")
                if rid:
                    short_ids.add(rid.split("O")[0])
    return short_ids


def should_skip_low_freq(
    e: dict, count: int, is_base_for_variant: bool,
    is_variant_self: bool, required_by_pipeline: bool,
) -> bool:
    """RO-3 + RO-10 + RO-12 + RO-1 carry — deterministic 보호 cascade.

    Skip 조건 모두 만족 시 True (skip), 하나라도 보호 발동 시 False.
    """
    etype = e.get("entity_type", "")
    if etype in ("location", "outlook"):
        return False
    if count > 1:
        return False
    if is_base_for_variant:
        return False
    if is_variant_self:
        return False
    if _has_protected_kind_keyword(e):
        return False
    if required_by_pipeline:
        return False
    return True
```

**Task 2.2 — `reference_pipeline_orchestrator.py` 호출 변경** (30분):

```python
# line 275~ 의 skip rule 부분 (기존)
_low_freq_skip_ids: set = set()
for e in entities:
    eid = e["id"]
    etype = e.get("entity_type", "")
    if etype in ("location", "outlook"):
        continue
    count = _t2i_count_map.get(eid, 0)
    is_base_for_variant = eid in _reverse_dep_ids
    if count <= 1 and not is_base_for_variant:
        _low_freq_skip_ids.add(eid)
        logger.info("Low-freq ref skip: %s (%s, t2i_count=%d)", e["name"], etype, count)
```

신규:

```python
from app.core.entity_protection import (
    _collect_required_entity_ids,
    _has_protected_kind_keyword,
    should_skip_low_freq,
)

required_short_ids = _collect_required_entity_ids(self._project_id, episode_id, self._db)
required_canon_uuids: set = set()
if required_short_ids:
    rows = self._db.query(EntityCanon.id).filter(
        EntityCanon.project_id == self._project_id,
        EntityCanon.short_id.in_(required_short_ids),
    ).all()
    required_canon_uuids = {r[0] for r in rows}

_low_freq_skip_ids: set = set()
for e in entities:
    eid = e["id"]
    etype = e.get("entity_type", "")
    if etype in ("location", "outlook"):
        continue
    count = _t2i_count_map.get(eid, 0)
    is_base_for_variant = eid in _reverse_dep_ids
    is_variant_self = self._is_variant_pole(eid, deps)  # NEW helper
    required = eid in required_canon_uuids
    if should_skip_low_freq(e, count, is_base_for_variant, is_variant_self, required):
        _low_freq_skip_ids.add(eid)
    logger.info(
        "Low-freq decision: %s (%s, t2i_count=%d, variant=%s, required=%s, kind=%s, skipped=%s)",
        e["name"], etype, count, is_variant_self, required,
        _has_protected_kind_keyword(e), eid in _low_freq_skip_ids,
    )
```

**Task 2.3 — `_is_variant_pole` helper** (30분):

`reference_pipeline_orchestrator.py` 안 method:

```python
def _is_variant_pole(self, canon_id: str, deps: dict) -> bool:
    """RO-1 — entity 가 RelationFact 의 variant pole (자식) 인지 검사.

    deps 는 visual_dependency_graph 의 출력 — `{base_id: {variant_ids...}}`.
    canon_id 가 variant 측이면 True.
    """
    for base_id, variant_ids in deps.items():
        if canon_id in variant_ids and canon_id != base_id:
            return True
    return False
```

**Task 2.4 — Unit tests** (1시간):

`backend/tests/core/test_entity_protection.py` (신규):

```python
import pytest
from app.core.entity_protection import (
    _has_protected_kind_keyword,
    should_skip_low_freq,
)


@pytest.fixture
def c08_yokai_entity():
    """C08 (요괴화) — kind keyword 매칭 fixture."""
    return {
        "id": "uuid-c08",
        "entity_type": "character",
        "name": "한국인 남자 (요괴화)",
        "stable_traits": ["Korean adult male", "red irises", "sharp pointed teeth",
                          "yokai-like facial features", "human-like face"],
        "description": "...",
    }


@pytest.fixture
def c15_silhouette_entity():
    return {
        "id": "uuid-c15",
        "entity_type": "character",
        "name": "검은 형상",
        "stable_traits": ["검은색 전신 실루엣", "인간형 윤곽", "얼굴이 보이지 않음"],
    }


def test_protected_kind_keyword_yokai_in_stable_traits(c08_yokai_entity):
    assert _has_protected_kind_keyword(c08_yokai_entity)


def test_protected_kind_keyword_silhouette_korean(c15_silhouette_entity):
    assert _has_protected_kind_keyword(c15_silhouette_entity)


def test_protected_kind_keyword_skip_non_character():
    """RO-10 — entity_type != 'character' 인 경우 protection 미적용."""
    location = {"entity_type": "location", "name": "shadow alley",
                "stable_traits": ["shadowed", "dark"]}
    assert not _has_protected_kind_keyword(location)


def test_protected_kind_keyword_shadow_excluded_from_description():
    """RO-10 — 'shadow' description 매칭 X (false-positive 차단)."""
    e = {"entity_type": "character", "name": "수리영",
         "stable_traits": ["young woman"],
         "description": "Walks through deep shadow at night"}  # description 의 shadow 무시
    assert not _has_protected_kind_keyword(e)


def test_should_skip_low_freq_protects_variant_self(c08_yokai_entity):
    """variant 자체 보호 (RO-1)."""
    skipped = should_skip_low_freq(
        c08_yokai_entity, count=1, is_base_for_variant=False,
        is_variant_self=True, required_by_pipeline=False,
    )
    assert skipped is False


def test_should_skip_low_freq_protects_silhouette_keyword(c15_silhouette_entity):
    """kind keyword 매칭 보호."""
    skipped = should_skip_low_freq(
        c15_silhouette_entity, count=1, is_base_for_variant=False,
        is_variant_self=False, required_by_pipeline=False,
    )
    assert skipped is False


def test_should_skip_low_freq_protects_required_by_pipeline():
    """RO-3 source cascade 보호."""
    e = {"entity_type": "character", "name": "한국인 여자", "stable_traits": []}
    skipped = should_skip_low_freq(
        e, count=1, is_base_for_variant=False,
        is_variant_self=False, required_by_pipeline=True,
    )
    assert skipped is False


def test_should_skip_low_freq_unprotected_low_freq_skipped():
    """count<=1 + 무변형 + 무키워드 + 무참조 → skip 정상."""
    e = {"entity_type": "character", "name": "Random Extra", "stable_traits": []}
    skipped = should_skip_low_freq(
        e, count=1, is_base_for_variant=False,
        is_variant_self=False, required_by_pipeline=False,
    )
    assert skipped is True


def test_should_skip_low_freq_count_above_threshold():
    e = {"entity_type": "character", "name": "Recurring", "stable_traits": []}
    skipped = should_skip_low_freq(
        e, count=5, is_base_for_variant=False,
        is_variant_self=False, required_by_pipeline=False,
    )
    assert skipped is False


def test_should_skip_low_freq_location_etype_returns_false():
    """RO-12 — location/outlook 은 helper 진입 시 False."""
    e = {"entity_type": "location", "name": "any"}
    assert not should_skip_low_freq(e, 0, False, False, False)
```

**Task 2.5 — Integration test** (`_collect_required_entity_ids`) (45분):

`backend/tests/core/test_entity_protection_integration.py` (신규):

```python
import json
import pytest
from pathlib import Path
from app.core.entity_protection import _collect_required_entity_ids


@pytest.fixture
def fixture_dir(tmp_path, monkeypatch):
    """RO-3 4-source cascade fixture — 임시 PID/EID 의 manifest 4종 박음."""
    pid = "test-pid-g4-6"
    eid = "test-eid-g4-6"
    base = tmp_path / "projects" / pid / "checkpoints" / "episodes" / eid

    # scene_director — present_entity_ids
    sd = base / "scene_director"
    sd.mkdir(parents=True)
    (sd / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [{"scene_index": 1, "present_entity_ids": ["C07", "C15", "L01"]}]}
    }))

    # shot_validator — character_ids (RO-1)
    sv = base / "shot_validator"
    sv.mkdir()
    (sv / "manifest.json").write_text(json.dumps({
        "data": {"scenes": [{"scene_index": 2, "shots": [
            {"shot_index": 4, "character_ids": ["C08", "C09"]},
        ]}]}
    }))

    monkeypatch.chdir(tmp_path)
    return pid, eid


def test_collect_required_unions_4_sources(fixture_dir, mock_db):
    pid, eid = fixture_dir
    ids = _collect_required_entity_ids(pid, eid, mock_db)
    # source 1 + 2 (3, 4 미설정)
    assert {"C07", "C08", "C09", "C15", "L01"} <= ids
```

### 4.4 Verification gate

- `backend/.venv/bin/pytest backend/tests/core/test_entity_protection*.py -v` → PASS
- `backend/.venv/bin/python scripts/canary/g4_6_low_freq_skip_protection.py` (Phase 4 까지 진행 후 D2/D3 fixture 로 검증)
- 회귀: `backend/.venv/bin/pytest backend/tests/services/test_reference_pipeline*.py` → PASS

### 4.5 Commit + dual review

단일 commit: `feat(reference-pipeline): G4.6 RC-C — variant/kind/required 보호 cascade 추가`

dual review (Codex + Claude). RC-C 는 D2/D3 root 의 핵심 fix — 보호 누락 시
회귀 위험 큼.

### 4.6 Risk + Rollback

- 회귀 위험: 기존 PID 재실행에서 ref 추가 생성 → composite_image_gen 도 재실행
  필요 (storage cost 증가). 단 D2/D3 의 force regen 시 필요한 보호.
- Rollback: `git revert` — 신규 module 1 + orchestrator patch.

---

## 5. Phase 3 — Wave A2: shot_validator v4 (RC-F + RC-G)

### 5.1 Goal

shot_validator v4 prompt + step code — type descriptor 매핑 + violent contact
freeze + characters/character_ids 분리 + failure partial 처리.

### 5.2 Files

- `prompts/_base/shot_validator/4.{YYYYMMDDHHmm}/system.md` (신규 — PRO-2)
- `prompts/_base/shot_validator/4.{YYYYMMDDHHmm}/validator_schema.json` (신규
  — character_ids 필드 추가, PRO-2)
- `backend/app/core/version_registry.py` (shot_validator → v4, PRO-3)
- `backend/app/core/steps/shot_validator_step.py` (3 code change)

(PRO-2 carry: 코드 검증 — `shot_validator_step.py:64-65` 가
`load_prompt("shot_validator", "system")` + `load_schema("shot_validator",
"validator_schema")` 만 호출. user_template.md / output_schema.json 은 loader
에 등록 안 됨 — 신규 파일 X.)

### 5.3 Tasks

**Task 3.1 — v4 prompt 작성** (1.5시간):

`prompts/_base/shot_validator/4.{YYYYMMDDHHmm}/system.md`:

v3 carry + spec §3.4.1/3.4.2/3.4.3 신규 3 섹션:

1. `## Type descriptor → entity ID 매핑 (G4.6 신규)` — RO-1 + RO-11
2. `## 폭력 접촉 freeze 규칙 (G4.6 신규)` — RC-G mid-impact 룰
3. `## characters / character_ids 검출 (G4.6 신규 — RO-19)` — visible-human-action fail-fast

`validator_schema.json` (PRO-2 — `output_schema.json` 아님) 변경 —
`character_ids: list[str]` 추가:

```json
{
  "type": "object",
  "properties": {
    "shots": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "shot_index": {"type": "integer"},
          "changed": {"type": "boolean"},
          "revised_description": {"type": "string"},
          "reason": {"type": "string"},
          "characters": {"type": "array", "items": {"type": "string"}},
          "character_ids": {"type": "array", "items": {"type": "string"}}
        },
        "required": ["shot_index", "changed", "revised_description", "reason"]
      }
    }
  },
  "required": ["shots"]
}
```

`character_ids` 는 optional (LLM 매핑 실패 시 빈 배열) — Phase 5 verification
gate 가 visible-human-action shot 에서 non-empty 검사 (RO-26).

**Task 3.2 — `shot_validator_step.py` (a) entity context inject** (45분):

spec §3.4.0 (a) 답습:

```python
# backend/app/core/steps/shot_validator_step.py 신규 helper
from app.core.entity_protection import _load_cp  # Phase 2 carry

def _build_entity_map_block(
    scene_index: int, db, project_id: str, episode_id: str,
) -> str:
    """RO-1 — scene_director.present_entity_ids 기반 entity map block.

    빈 entity ID list 면 block 자체 omit.
    """
    director_cp = _load_cp(project_id, episode_id, "scene_director")
    if not director_cp:
        return ""
    sc = next(
        (s for s in director_cp["data"].get("scenes", [])
         if s["scene_index"] == scene_index),
        None,
    )
    if not sc:
        return ""
    base_ids = {sid.split("O")[0] for sid in sc.get("present_entity_ids", []) or []}
    if not base_ids:
        return ""
    rows = db.query(EntityCanon.short_id, EntityCanon.name, EntityCanon.stable_traits).filter(
        EntityCanon.project_id == project_id,
        EntityCanon.short_id.in_(base_ids),
    ).all()
    if not rows:
        return ""
    lines = ["[Entity map for this scene]"]
    for r in rows:
        traits = (r.stable_traits or [])[:2]
        traits_str = f" — {', '.join(str(t) for t in traits)}" if traits else ""
        lines.append(f"- {r.short_id}: {r.name}{traits_str}")
    return "\n".join(lines) + "\n\n"


# user_prompt 조립 (line 104+ 변경)
entity_block = _build_entity_map_block(si, self._db, self.project_id, self.episode_id)
user_prompt = (
    f"[씬 {si}]\n"
    f"{scene_text}\n\n"
    f"{entity_block}"
    f"[Shots 검증 대상]\n{shots_block}\n\n"
    f"각 shot의 description이 '한 찰나' 원칙에 맞는지 검증하고, "
    f"위반 시 재작성하세요. character_ids 는 entity map 의 short_id 사용."
)
```

**Task 3.3 — `shot_validator_step.py` (b) merge 로직** (30분):

spec §3.4.0 (b) 답습:

```python
# line 226-238 변경
for sh in shots:
    rev = revisions.get(sh.get("shot_index"))
    new_sh = dict(sh)
    if rev:
        if rev.get("changed"):
            revised = rev.get("revised_description", "").strip()
            if revised and revised != sh.get("description", ""):
                new_sh["original_description"] = sh.get("description", "")
                new_sh["description"] = revised
                new_sh["validator_reason"] = rev.get("reason", "")
                changed += 1
        # RO-1 + RO-11: character_ids merge (changed=false 여도 적용)
        rev_char_ids = rev.get("character_ids")
        if isinstance(rev_char_ids, list):
            new_sh["character_ids"] = list(rev_char_ids)
        rev_chars = rev.get("characters")
        if isinstance(rev_chars, list):
            new_sh["characters"] = list(rev_chars)
    new_shots.append(new_sh)
```

**Task 3.4 — `shot_validator_step.py` (c) failure partial** (30분):

spec §3.4.0 (c) 답습:

```python
# line 97-107 변경
except Exception as exc:
    logger.warning("shot_validator: scene %s failed — %s", si, exc)
    failed_scenes += 1
    orig = next((x for x in scenes_input if x.get("scene_index") == si), None)
    if orig is not None:
        marked = dict(orig)
        marked["validator_status"] = "failed"
        marked["validator_failure_reason"] = str(exc)[:200]
        marked["shots"] = [
            {**sh, "validator_status": "failed_carry_original"}
            for sh in orig.get("shots", [])
        ]
        results.append(marked)
    else:
        logger.error("shot_validator: scene %s not found — skipping", si)
    continue
```

**Task 3.5 — `version_registry.py` v4 등록** (10분, PRO-3 정정):

```python
# backend/app/core/version_registry.py — 2 key 갱신 (PRO-3)
# (a) MODULE_VERSIONS["shot_validator"]: "1.2.0" → "1.4.0"
"shot_validator": "1.4.0",  # 2026-05-06 — v4 prompt (G4.6 RC-F+RC-G):
                            # type descriptor mapping + violent freeze +
                            # character_ids schema + failure partial

# (b) PROMPT_DEPENDENCIES["shot_validator"]["prompt_dependency"]:
#     "shot_validator/v3" → "shot_validator/v4"
"shot_validator": {
    "prompt_dependency": "shot_validator/v4",
    "updated_at": "2026-05-06",
},
```

(PRO-3 carry: 기존 코드의 key 는 `"shot_validator"` (직접) — 본 spec drafting
의 `"shot_validator_composer"` 는 `scene_detail_composer` 와 혼동. 정정.)

**Task 3.6 — Unit tests** (1.5시간 — generic fixture only, A-prime binding):

모든 fixture 는 시나리오 의존 token 0건 (`feedback_no_scenario_keywords.md`).
fixture 는 generic placeholder + entity_canon.name / descriptor / scene text /
revised expected substring 모두 fixture 가 expose. test 코드 안에 한국인 /
요괴 / 흡혈 / 살점 / 치아 / 이빨 / fang / vampire / 솟구치 / 뜯어내는 류 token
0건.

`backend/tests/core/steps/test_shot_validator_v4.py` (신규):

```python
import pytest
from app.core.steps.shot_validator_step import _build_entity_map_block


def test_entity_map_block_includes_short_id_name_traits(
    mock_director_cp_generic, mock_db_generic,
):
    """entity_canon 의 generic name + variant_label + stable_traits 그대로 carry."""
    fix = mock_director_cp_generic
    block = _build_entity_map_block(
        scene_index=2, db=mock_db_generic,
        project_id="test-pid", episode_id="test-eid",
    )
    assert "[Entity map for this scene]" in block
    # fixture 가 정의한 generic entity name + variant_label
    assert (
        f"{fix['c08_short_id']}: {fix['c08_name']} ({fix['c08_variant_label']})"
        in block
    )
    # stable_traits[0] (generic placeholder token)
    assert fix["c08_stable_traits"][0] in block
    assert f"{fix['c09_short_id']}: {fix['c09_name']}" in block


def test_entity_map_block_empty_when_no_present_entities(mock_director_cp_empty):
    block = _build_entity_map_block(
        scene_index=99, db=mock_db, project_id="test-pid", episode_id="test-eid",
    )
    assert block == ""


def test_shot_validator_v4_resolves_type_descriptor_to_short_id(
    scene_context_with_c07_generic, mock_db_generic,
):
    """RO-19 — type descriptor 매핑 (entity_canon 의 generic descriptor)."""
    fix = scene_context_with_c07_generic
    shot = run_shot_validator_v4(
        description=fix["description_using_c07_descriptor_token"],
        scene_index=1,
        scene_present_entity_ids=["C07", "L01"],
    )
    assert "C07" in shot["character_ids"]
    assert fix["c07_name"] in shot["characters"]


def test_shot_validator_v4_violent_freeze_rewrites_static_moment(
    scene_context_with_violent_action_generic,
):
    """RC-G — 정적 freeze 어휘 → mid-impact rewrite (generic fixture)."""
    fix = scene_context_with_violent_action_generic
    shot = run_shot_validator_v4(
        description=fix["static_contact_description_generic"],
        scene_index=2,
        scene_present_entity_ids=["C08", "C09"],
        scene_text=fix["scene_text_with_dynamic_action_generic"],
    )
    # 정적 substring (fixture 가 expose) 제거됨
    assert fix["static_substring_to_be_removed"] not in shot["revised_description"]
    # 동적 어휘 후보 (fixture expected_dynamic_substrings)
    assert any(
        kw in shot["revised_description"]
        for kw in fix["expected_dynamic_substrings"]
    )


def test_shot_validator_v4_visible_human_action_fail_fast_when_empty(
    scene_context_with_human_action_generic,
):
    """RO-26 — visible-human-action + character_ids=[] 더 이상 통과 안 함."""
    fix = scene_context_with_human_action_generic
    with pytest.raises(AppError, match="visible-human-action"):
        run_shot_validator_v4(
            description=fix["human_action_description_generic"],
            scene_index=99,
            scene_present_entity_ids=[],  # entity map 비어있음
        )


def test_shot_validator_v4_merge_character_ids_changed_false(
    scene_context_with_c07_generic,
):
    """RO-1 — changed=false 여도 character_ids merge (generic fixture)."""
    fix = scene_context_with_c07_generic
    rev = {
        "shot_index": 1, "changed": False, "revised_description": "",
        "reason": "",
        "character_ids": ["C07"],
        "characters": [fix["c07_name"]],  # generic entity name
    }
    sh = {"shot_index": 1, "description": "..."}
    new_sh = _merge_revision(sh, rev)
    assert new_sh["character_ids"] == ["C07"]
    assert new_sh["characters"] == [fix["c07_name"]]


def test_shot_validator_v4_failed_scene_marked():
    """RO-8 — LLM 실패 시 validator_status=failed 마킹."""
    scenes_input = [{"scene_index": 1, "shots": [{"shot_index": 1, "description": "..."}]}]
    results = run_validator_with_llm_failure(scenes_input)
    assert results[0]["validator_status"] == "failed"
    assert all(sh["validator_status"] == "failed_carry_original"
               for sh in results[0]["shots"])
```

**Task 3.7 — Canary** (20분):

`scripts/canary/g4_6_shot_validator_type_descriptor.py` (신규) — D3 fixture
S2_Shot4 description 으로 v4 가 character_ids=["C08","C09"] 추출하는지 검증.

### 5.4 Verification gate

- `backend/.venv/bin/pytest backend/tests/core/steps/test_shot_validator_v4.py -v` → PASS
- canary smoke OK
- 회귀: `pytest backend/tests/core/steps/test_shot_validator*.py` (v3 baseline 보존)
- AppError positional 사용 0건

### 5.5 Commit + dual review

단일 commit: `feat(shot-validator): G4.6 RC-F+RC-G v4 — type descriptor / violent freeze / character_ids schema`

dual review.

### 5.6 Risk + Rollback

- 회귀 위험: v4 prompt 가 type descriptor 매핑 시 잘못된 entity 추출 가능 →
  Phase 4 contract validator 가 catch.
- Rollback: version_registry → v3 + step code revert.

---

## 6. Phase 4 — Wave A3: scene_detail v21 + Contract Validator (RC-E + RC-H)

### 6.1 Goal

scene_detail v21 prompt — silhouette identity (Rule X) + variant trait inject
(Rule X-2) + ID-based representation 강제. detail_steps.py — entity_traits
block + visible_entities contract validator (3 source crosscheck).

### 6.2 Files

- `prompts/_base/scene_detail/21.{YYYYMMDDHHmm}/system.md` (신규 — PRO-8)
- `prompts/_base/scene_detail/21.{YYYYMMDDHHmm}/detail_schema.json` (신규
  — PRO-8: `load_schema("scene_detail", "detail_schema")` 검증, schema 자체
  변경은 G4.6 scope 외 — 단순 carry from v20)
- `backend/app/core/version_registry.py` (scene_detail → v21)

(PRO-8 carry: 코드 검증 — `detail_steps.py:753-754` 가 `load_prompt("scene_detail",
"system")` + `load_schema("scene_detail", "detail_schema")` 만 호출.
user_template / output_schema 등 별도 파일 X.)
- `backend/app/core/steps/detail_steps.py` (entity_traits block + validator
  hookup)
- `backend/app/core/visible_entities_validator.py` (NEW — contract validator
  분리 module)

### 6.3 Tasks

**Task 4.1 — v21 prompt 작성** (2시간):

v20 carry + spec §3.3 신규 4 변경:

1. silhouette 섹션 entity-aware policy (RO-15 carry, "jaw contour visible"
   제거)
2. Rule X — variant entity stable_traits 강제 inject
3. Rule X-2 — visible character ID-based representation 필수 (RO-2 supports)
4. Glossary cross-ref `[Entity stable_traits for this shot]` block 위치 명시

**Task 4.2 — `detail_steps.py` traits block** (1시간, Codex iter 1 B1 carry —
db 인자 제거, ctx prebuild map 사용):

spec §3.3 답습 — `_build_entity_traits_block(visible_entities, name_by_short_id,
traits_by_short_id)` helper. RenderPromptCard 직후 prepend. ThreadPool worker
안 SQLAlchemy session race 회피. prebuild 는 SceneContextLoader.
`_load_entity_canon_character_maps` (Task 4.4) 가 main thread 1회 query.

```python
# backend/app/core/steps/detail_steps.py
from app.core.errors import AppError

def _build_entity_traits_block(
    visible_entities: list,
    name_by_short_id: dict,
    traits_by_short_id: dict,
) -> str:
    """RC-H — visible_entities 안 character base 들의 stable_traits block.

    Codex iter 1 B1 carry — db 인자 제거, ctx prebuild map 만 사용.
    fail-fast: visible_entities 안 character base ID 가 prebuild map 에 없으면
    AppError (silent absorb 금지). Claude iter 1 I5 — `or []` silent fallback
    제거, traits_by_short_id 가 이미 _parse_traits 처리한 list 만 보유.
    """
    base_ids = {
        sid.split("O")[0] for sid in visible_entities
        if isinstance(sid, str) and sid.startswith("C")
    }
    if not base_ids:
        return ""
    missing = base_ids - set(name_by_short_id.keys())
    if missing:
        raise AppError(
            code="step.scene_detail.unknown_short_id",
            message=(
                f"visible_entities references unknown character short_id: "
                f"{sorted(missing)} — prebuild EntityCanon map 에 없음 "
                f"(entity_type='character' scoped). Fix: shot_validator "
                f"character_ids must reference existing entities."
            ),
            status_code=400,
        )
    lines = ["[Entity stable_traits for this shot]"]
    has_any = False
    for sid in sorted(base_ids):
        traits = traits_by_short_id.get(sid) or []
        if not traits:
            continue
        traits_str = ", ".join(str(t) for t in traits)
        lines.append(f"- {sid} ({name_by_short_id.get(sid, '')}): {traits_str}")
        has_any = True
    if not has_any:
        return ""
    return "\n".join(lines) + "\n\n"
```

**Task 4.4-prebuild — `scene_context_loader.py` main thread prebuild** (Codex
iter 1 B1):

```python
# backend/app/core/steps/scene_context_loader.py
def _load_entity_canon_character_maps(self) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
    """ThreadPool worker 안 self.db query 가 SQLAlchemy session thread-unsafe
    race 를 만든다. main thread 에서 prebuild → ctx 에 attach.

    project_id scoped + entity_type='character' filter — Claude iter 1 I2
    defensive consistency carry.
    """
    from app.core.entity_protection import _parse_traits
    from app.models.project import EntityCanon

    try:
        rows = self.runner.db.query(
            EntityCanon.short_id, EntityCanon.name, EntityCanon.stable_traits,
        ).filter(
            EntityCanon.project_id == self.runner.project_id,
            EntityCanon.entity_type == "character",
        ).all()
    except Exception as exc:
        logger.warning("...")
        return {}, {}

    name_by_short_id, traits_by_short_id = {}, {}
    for r in rows:
        sid = getattr(r, "short_id", None)
        if not isinstance(sid, str) or not sid.startswith("C"):
            continue
        name_by_short_id[sid] = r.name or ""
        traits_by_short_id[sid] = _parse_traits(r.stable_traits)
    return name_by_short_id, traits_by_short_id

# load_all() 끝부분에 호출 + ctx 에 attach
ctx.name_by_short_id, ctx.traits_by_short_id = (
    self._load_entity_canon_character_maps()
)
```

**Task 4.3 — `visible_entities_validator.py` 신규 module** (2시간, PRO-4 + A-prime carry):

spec §3.5 답습 — **ID coverage primary + dynamic entity_canon.name secondary**.
production code 에 시나리오 의존 keyword frozenset/tuple/list 0건
(`feedback_no_scenario_keywords.md`). required field 모두 `_MISSING` sentinel +
AppError fail-fast (PRO-4).

```python
"""G4.6 Wave A3 RC-E+RC-H — visible_entities ⊃ C##/C##O## contract.

ID coverage primary + dynamic entity_canon.name secondary (RO-2 강화):
- Source 1 Primary (ID coverage):
    - Forward: visible_entities 안 character base C## 모두 t2i_prompt 에
      C## 또는 C##O## 로 등장해야 함 (RC-E 핵심).
    - Reverse: t2i_prompt 안 사용된 ID 가 visible_entities 안에 있어야 함.
- Source 2 Secondary (dynamic entity_canon.name diagnostic):
    - DB project_id scoped fetch — entity_canon.name token 이 t2i_prompt 에
      등장하면 그 specific entity 의 ID candidates (base + 모든 allowed
      outlook composite) 가 same sentence + ±60 char window 안에 있어야 pass.
    - 다른 visible C## 가 window 안에 있어도 specific 매칭 안 되면 reject.
- Source 3a: render_prompt_card.asset_requirements.required_refs[].id +
    kind ∈ {character, character_outlook} → visible_entities 검증.
- Source 3b: render_prompt_card.id_policy.allowed_outlook_pairs[].
    character_id (legacy fallback: base_id / composite_id) → visible_entities.

A-prime binding:
- module 안에 nationality / ethnicity / 작품 고유명사 token (frozenset/tuple/
  list/regex literal) 0건 — `feedback_no_scenario_keywords.md` (RC-C carry).
- Source 1 의 ID regex `\\bC\\d{2,3}(?:O\\d{2,3})?\\b` 만 사용 (semantic
  keyword frozenset 금지와 충돌하지 않음 — universal ID structure parsing).
- Source 2 의 entity_canon.name 은 DB 에서 동적 fetch (project_id scoped) —
  다른 nationality / 시나리오 자동 적용.

PRO-4: required field 부재 / 타입 mismatch 시 fail-fast — silent `or []`/`or {}`
패턴 금지 (`feedback_no_silent_fallback.md`). optional field (description /
shot_index 등 non-contract) 만 nullable 허용.
"""
import logging
import re
from app.core.errors import AppError
from app.models.project import EntityCanon

logger = logging.getLogger(__name__)

_MISSING = object()

_ENTITY_ID_PATTERN = re.compile(r"\b(C\d{2,3}(?:O\d{2,3})?)\b")
_OUTLOOK_ID_SHORT = re.compile(r"^O\d{2,3}$")
_COMPOSITE_ID = re.compile(r"^C\d{2,3}O\d{2,3}$")
_ANCHOR_WINDOW = 60
_SENTENCE_BOUNDARY_CHARS = ".!?\n。"


def _require_field(d: dict, key: str, expected_type: type, shot_label: str):
    """PRO-4 — required field 부재 / 타입 mismatch fail-fast helper."""
    value = d.get(key, _MISSING)
    if value is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (PRO-4): required field '{key}' missing in shot. "
                f"Fix: scene_detail step output must include this field."
            ),
            status_code=400,
        )
    if not isinstance(value, expected_type):
        raise AppError(
            code="step.scene_detail.contract_violation_field_type",
            message=(
                f"{shot_label} (PRO-4): field '{key}' must be "
                f"{expected_type.__name__}, got {type(value).__name__}."
            ),
            status_code=400,
        )
    return value


def _entity_specific_id_in_window(
    prompt: str,
    name_pos: int,
    name_len: int,
    entity_id_candidates: set,
) -> bool:
    """RO-23 + RO-27 — name_pos 기준 ±60 char window + sentence boundary
    + specific entity ID 매칭. window 안에 검사 중인 name 의 specific entity
    의 ID (C## 또는 C##O##) 가 있어야만 True.

    핵심 (RO-23 false-positive 차단): 다른 visible C## 가 window 안에 있어도,
    검사 중인 name 의 specific entity 의 ID 가 아니면 reject.
    """
    start = max(0, name_pos - _ANCHOR_WINDOW)
    end = min(len(prompt), name_pos + name_len + _ANCHOR_WINDOW)
    window_text = prompt[start:end]
    for m in _ENTITY_ID_PATTERN.finditer(window_text):
        sid = m.group(0)
        if sid not in entity_id_candidates:
            continue
        id_abs_pos = start + m.start()
        a, b = sorted([id_abs_pos, name_pos])
        between = prompt[a:b]
        if any(ch in _SENTENCE_BOUNDARY_CHARS for ch in between):
            continue
        return True
    return False


def _outlook_id_candidates_for_base(
    base_id: str, allowed_outlook_pairs: list, shot_label: str,
) -> set:
    """base C## + 그 base 의 모든 allowed C##O## composite ID set.

    Pair 에서 candidate 도출 우선순위:
      1. `composite_id` (C##O## 형식) 있으면 직접 추가 (가장 신뢰).
      2. 없으면 `character_id` (또는 `base_id`) + `outlook_id` 합성.
         단 `outlook_id` 는 O## short id (e.g., "O11", "O123") 형태 강제.

    Why: production 데이터에서 outlook_id 가 short id (O##) 또는 UUID 둘 다
    가능. composite_id 가 있으면 그쪽이 truth (UUID outlook 도 정확 식별).
    composite_id 가 없을 때만 outlook_id 가 O## short id 라는 가정 — UUID /
    다른 형태면 fail-fast (silent absorb 금지).

    Raises:
        AppError: composite_id 가 C##O## 형식 위반 또는 (composite_id 부재 시)
            outlook_id 가 O## 형식 위반.
    """
    candidates = {base_id}
    for pair in allowed_outlook_pairs:
        if not isinstance(pair, dict):
            continue  # source 3b 의 PRO-4 가 fail-fast 잡음
        char_id = (
            pair.get("character_id")
            or pair.get("base_id")
            or (pair.get("composite_id") or "").split("O")[0]
            or ""
        )
        if char_id != base_id:
            continue

        # 우선순위 1: composite_id 직접 사용 (UUID outlook 도 정확 식별)
        composite_id = pair.get("composite_id")
        if composite_id:
            if not _COMPOSITE_ID.match(composite_id):
                raise AppError(
                    code="step.scene_detail.contract_violation_composite_id_format",
                    message=(
                        f"{shot_label}: allowed_outlook_pairs.composite_id "
                        f"'{composite_id}' must match C##O## (2-3 digits each). "
                        f"entry={pair!r}"
                    ),
                    status_code=400,
                )
            candidates.add(composite_id)
            continue

        # 우선순위 2: outlook_id (O## short id) 합성
        outlook_id = pair.get("outlook_id")
        if not outlook_id:
            continue  # source 3b PRO-13 fail-fast 가 잡음
        if not _OUTLOOK_ID_SHORT.match(outlook_id):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_id_format",
                message=(
                    f"{shot_label}: allowed_outlook_pairs.outlook_id "
                    f"'{outlook_id}' must match O## short id (O followed by "
                    f"2-3 digits). composite_id 부재 시 outlook_id 만으로 "
                    f"candidate 합성 — UUID / 다른 형태는 명시적으로 "
                    f"composite_id 필드로 표현하세요. entry={pair!r}"
                ),
                status_code=400,
            )
        candidates.add(f"{base_id}{outlook_id}")
    return candidates


def validate_visible_entities_contract(
    shot: dict,
    name_by_short_id: dict,
) -> None:
    """ID coverage primary + dynamic entity_canon.name secondary.

    silent bypass 없음 (RO-15 + PRO-4) — required field 모두 _MISSING fail-fast.

    Codex iter 1 B1 carry — db / project_id 인자 제거 (ThreadPool worker 안
    SQLAlchemy session race 회피). caller 가 main thread prebuild
    `name_by_short_id` map (entity_type='character' scoped) 을 전달.
    """
    scene_idx = shot.get("scene_index")
    shot_idx = shot.get("_shot_index") or shot.get("shot_index")
    shot_label = f"S{scene_idx}_Shot{shot_idx}"

    # PRO-4: required field 강제 검증 (fail-fast)
    visible_list = _require_field(shot, "visible_entities", list, shot_label)
    visible = set(visible_list)
    visible_bases = {
        sid.split("O")[0] for sid in visible if sid.startswith("C")
    }

    t2i_variations = _require_field(shot, "t2i_variations", list, shot_label)
    if not t2i_variations:
        raise AppError(
            code="step.scene_detail.contract_violation_empty_t2i",
            message=(
                f"{shot_label} (PRO-4): t2i_variations is empty. scene_detail "
                f"must produce at least one variation per shot."
            ),
            status_code=400,
        )

    rpc = _require_field(shot, "render_prompt_card", dict, shot_label)

    # asset_requirements / id_policy — both fail-fast missing/type
    asset_req = _require_field(
        {"asset_requirements": rpc.get("asset_requirements", _MISSING)},
        "asset_requirements", dict, shot_label,
    )
    id_policy = _require_field(
        {"id_policy": rpc.get("id_policy", _MISSING)},
        "id_policy", dict, shot_label,
    )

    # PRO-10: allowed_outlook_pairs 사전 추출 + missing/type fail-fast
    allowed_outlook_pairs = id_policy.get("allowed_outlook_pairs", _MISSING)
    if allowed_outlook_pairs is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (PRO-10): id_policy.allowed_outlook_pairs missing — "
                f"required field per RO-16 contract."
            ),
            status_code=400,
        )
    if not isinstance(allowed_outlook_pairs, list):
        raise AppError(
            code="step.scene_detail.contract_violation_outlook_pairs_type",
            message=f"{shot_label} (PRO-4): id_policy.allowed_outlook_pairs must be list.",
            status_code=400,
        )

    # Source 2 prep — entity_canon.name fetch (project_id scoped, single query)
    rows = []
    if visible_bases:
        rows = db.query(EntityCanon.short_id, EntityCanon.name).filter(
            EntityCanon.project_id == project_id,
            EntityCanon.short_id.in_(visible_bases),
        ).all()
    base_to_name: dict = {r.short_id: r.name for r in rows if r.name}

    # Source 1 — Primary ID coverage (forward + reverse)
    for vidx, variation in enumerate(t2i_variations):
        if not isinstance(variation, dict):
            raise AppError(
                code="step.scene_detail.contract_violation_variation_type",
                message=f"{shot_label} (PRO-4): t2i_variations entry must be dict.",
                status_code=400,
            )
        prompt = variation.get("t2i_prompt", _MISSING)
        if prompt is _MISSING or not isinstance(prompt, str):
            raise AppError(
                code="step.scene_detail.contract_violation_variation_prompt",
                message=(
                    f"{shot_label} (PRO-4): t2i_variations[].t2i_prompt missing "
                    f"or non-string."
                ),
                status_code=400,
            )

        used_ids = set(_ENTITY_ID_PATTERN.findall(prompt))
        used_bases = {sid.split("O")[0] for sid in used_ids}

        # Reverse: used IDs 의 base ⊆ visible_bases
        for sid in used_ids:
            base = sid.split("O")[0]
            if base not in visible_bases:
                raise AppError(
                    code="step.scene_detail.contract_violation_id_not_visible",
                    message=(
                        f"{shot_label} (Source 1, RO-2): t2i_variations[{vidx}]."
                        f"t2i_prompt uses '{sid}' (base={base}) but "
                        f"visible_entities={sorted(visible)} does not include it."
                    ),
                    status_code=400,
                )

        # Forward (RC-E primary): visible character base 가 prompt 에 어느
        # 형식으로든 (C## 또는 C##O##) 등장해야 함. descriptor only / 누락
        # 모두 fail-fast — Rule X-2 의 contract 강제.
        missing_bases = visible_bases - used_bases
        if missing_bases:
            missing_with_names = sorted(
                f"{b}({base_to_name.get(b, '?')})" for b in missing_bases
            )
            raise AppError(
                code="step.scene_detail.contract_violation_visible_id_not_in_prompt",
                message=(
                    f"{shot_label} (Source 1, RO-2): t2i_variations[{vidx}]."
                    f"t2i_prompt missing visible_entities character base IDs: "
                    f"{missing_with_names}. visible_entities={sorted(visible)}. "
                    f"Fix: t2i_prompt must reference each visible character base "
                    f"as C## or C##O## (Rule X-2). descriptor-only is insufficient."
                ),
                status_code=400,
            )

    # Source 2 — Secondary dynamic entity_canon.name diagnostic
    # 각 visible base 의 name token 이 t2i_prompt 에 등장하면 그 specific
    # entity 의 ID 가 same sentence + ±60 char window 안에 있어야 함.
    for vidx, variation in enumerate(t2i_variations):
        prompt = variation["t2i_prompt"]  # Source 1 에서 검증됨
        prompt_lower = prompt.lower()

        for base_id, name in base_to_name.items():
            if not name or len(name.strip()) < 2:
                continue
            entity_id_cands = _outlook_id_candidates_for_base(
                base_id, allowed_outlook_pairs, shot_label,
            )
            name_lower = name.lower()
            offset = 0
            while True:
                pos = prompt_lower.find(name_lower, offset)
                if pos == -1:
                    break
                if not _entity_specific_id_in_window(
                    prompt, pos, len(name_lower), entity_id_cands,
                ):
                    raise AppError(
                        code="step.scene_detail.contract_violation_entity_name_no_specific_id",
                        message=(
                            f"{shot_label} (Source 2, RO-23+RO-27): "
                            f"t2i_variations[{vidx}].t2i_prompt uses "
                            f"entity_canon.name '{name}' (base={base_id}) but "
                            f"no specific ID from {sorted(entity_id_cands)} "
                            f"found within ±{_ANCHOR_WINDOW} char window in "
                            f"same sentence. Other visible C## tokens do not "
                            f"satisfy — must be the specific entity's own ID."
                        ),
                        status_code=400,
                    )
                offset = pos + len(name_lower)

    # Source 3a — required_refs (RO-3 dict shape, RO-30 + PRO-12 fail-fast)
    required_refs = asset_req.get("required_refs", _MISSING)
    if required_refs is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (PRO-12): asset_requirements.required_refs missing — "
                f"required field. 빈 list 가능, missing 불가."
            ),
            status_code=400,
        )
    if not isinstance(required_refs, list):
        raise AppError(
            code="step.scene_detail.contract_violation_required_refs_type",
            message=f"{shot_label} (PRO-12): asset_requirements.required_refs must be list.",
            status_code=400,
        )
    for ref in required_refs:
        if not isinstance(ref, dict):
            # PRO-4 — non-dict entry 는 contract violation (silent skip 금지)
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_type",
                message=f"{shot_label} (PRO-4): required_refs entry must be dict.",
                status_code=400,
            )
        rid = ref.get("id")
        kind = ref.get("kind", "")
        if not rid:
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_no_id",
                message=f"{shot_label} (PRO-4): required_refs entry missing 'id'.",
                status_code=400,
            )
        base = rid.split("O")[0]
        if kind in ("character", "character_outlook") and base not in visible_bases:
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_not_visible",
                message=(
                    f"{shot_label} (Source 3a, RO-2): "
                    f"asset_requirements.required_refs has {kind} id={rid} but "
                    f"visible_entities={sorted(visible)} does not include base={base}."
                ),
                status_code=400,
            )

    # Source 3b — allowed_outlook_pairs (RO-16 character_id primary,
    #            RO-31 + PRO-13 fail-fast)
    for pair in allowed_outlook_pairs:
        if not isinstance(pair, dict):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_type",
                message=f"{shot_label} (PRO-4): allowed_outlook_pairs entry must be dict.",
                status_code=400,
            )
        base = (
            pair.get("character_id")
            or pair.get("base_id")
            or (pair.get("composite_id") or "").split("O")[0]
            or ""
        )
        # PRO-13: 모든 ID 후보 부재 시 fail-fast (silent pass 차단)
        if not base:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_no_id",
                message=(
                    f"{shot_label} (PRO-13): allowed_outlook_pairs entry missing "
                    f"all of character_id / base_id / composite_id. entry={pair!r}"
                ),
                status_code=400,
            )
        # PRO-13: outlook_id 검증 (missing/type mismatch fail-fast)
        outlook_id = pair.get("outlook_id", _MISSING)
        if outlook_id is _MISSING:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_no_outlook_id",
                message=(
                    f"{shot_label} (PRO-13): allowed_outlook_pairs entry missing "
                    f"'outlook_id' field. entry={pair!r}"
                ),
                status_code=400,
            )
        if not isinstance(outlook_id, str):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_outlook_id_type",
                message=(
                    f"{shot_label} (PRO-13): outlook_id must be str, "
                    f"got {type(outlook_id).__name__}."
                ),
                status_code=400,
            )
        if base not in visible_bases:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_not_visible",
                message=(
                    f"{shot_label} (Source 3b, RO-16): "
                    f"id_policy.allowed_outlook_pairs has character_id={base} "
                    f"but visible_entities={sorted(visible)} does not include it."
                ),
                status_code=400,
            )
```

**Task 4.4 — `detail_steps.py` upstream gate + validator hookup** (45분):

```python
# detail_steps.py 시작 부분 — RO-15 upstream validator gate
def _gate_upstream_validator(shot_validator_cp: dict, project_config: dict) -> None:
    failed = {sc["scene_index"] for sc in shot_validator_cp["data"].get("scenes", [])
              if sc.get("validator_status") == "failed"}
    if failed and not project_config.get("allow_failed_validator", False):
        raise AppError(
            code="step.upstream_validator_failed",
            message=(
                f"scene_detail blocked — shot_validator failed for scenes "
                f"{sorted(failed)}. Re-run shot_validator first or set "
                f"allow_failed_validator=true."
            ),
            status_code=400,
        )

# _post_process_shot_detail() 안 — validator hookup
from app.core.visible_entities_validator import validate_visible_entities_contract

def _post_process_shot_detail(self, shot: dict) -> dict:
    # ... 기존 처리 ...
    validate_visible_entities_contract(shot, self._db, self.project_id)  # 신규
    return shot
```

**Task 4.5 — `version_registry.py` v21 등록** (10분, PRO-3 carry):

```python
# backend/app/core/version_registry.py — 2 key 갱신
# (a) MODULE_VERSIONS["scene_detail_composer"]: "1.20.0" → "1.21.0"
"scene_detail_composer": "1.21.0",  # 2026-05-06 — v21 prompt (G4.6 RC-E+RC-H):
                                    # silhouette identity / variant trait inject /
                                    # ID-based representation 강제

# (b) PROMPT_DEPENDENCIES["scene_detail_composer"]["prompt_dependency"]:
#     "scene_detail/v20" → "scene_detail/v21"
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v21",
    "updated_at": "2026-05-06",
},
```

**Task 4.6 — Unit tests** (2시간 — generic fixture only, A-prime binding):

`backend/tests/core/test_visible_entities_validator.py` (신규) — spec §3.5
테스트 표 답습. **모든 fixture 는 generic placeholder** — entity_canon.name 은
"Adult Character A" / "Adult Character B" / "front-desk attendant" / "hooded
adult figure" 등, descriptor 는 "an adult figure in dark jacket" / "white
blouse" 등. 시나리오 의존 token (한국인 / 요괴 / fang / vampire / 살점 /
yokai / east asian / korean / etc.) 0건.

D3 root fixture (`backend/tests/fixtures/g4_6/s2_shot4_scene_detail.json`)
는 raw production capture 라 변경 X — unit test 에서는 사용 X. 대신 fixture
의 *구조* (descriptor only 패턴) 를 generic 으로 재구성한 synthesized fixture
를 사용.

```python
import pytest
from app.core.visible_entities_validator import (
    validate_visible_entities_contract, _build_entity_traits_block,
)


@pytest.fixture
def mock_db_with_c08(mock_db_factory):
    """generic — entity_canon.name='Adult Character A'."""
    return mock_db_factory({
        "C08": {"name": "Adult Character A", "stable_traits": []},
    })


@pytest.fixture
def mock_db_with_c08_c09(mock_db_factory):
    """generic — 2 entity."""
    return mock_db_factory({
        "C08": {"name": "Adult Character A", "stable_traits": []},
        "C09": {"name": "Adult Character B", "stable_traits": []},
    })


def _rpc_empty():
    """generic render_prompt_card with empty refs/pairs."""
    return {
        "asset_requirements": {"required_refs": []},
        "id_policy": {"allowed_outlook_pairs": []},
    }


def test_validate_pass_when_all_visible_ids_in_prompt(mock_db_with_c08_c09):
    """Source 1 forward + reverse — 모든 visible base 가 prompt 에 등장."""
    shot = {
        "scene_index": 1, "_shot_index": 1,
        "visible_entities": ["C08", "C09"],
        "t2i_variations": [{
            "t2i_prompt": "C08 standing beside C09, both looking at the door.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    validate_visible_entities_contract(shot, mock_db_with_c08_c09, "test-pid")


def test_validate_fail_when_visible_base_missing_in_prompt(mock_db_with_c08_c09):
    """Source 1 forward (RC-E primary) — visible 'C09' base 가 prompt 에 없음."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "C09", "L03"],
        "t2i_variations": [{
            "t2i_prompt": "C08O10 in dark jacket, an adult figure, hands raised.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(
        AppError, match="contract_violation_visible_id_not_in_prompt"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08_c09, "test-pid")


def test_validate_fail_when_used_id_not_in_visible(mock_db_with_c08):
    """Source 1 reverse — used 'C09' not in visible_entities."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "L03"],
        "t2i_variations": [{
            "t2i_prompt": "C08O10 standing. C09 walks in beside.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(AppError, match="contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_pass_when_entity_name_anchored_with_specific_id(mock_db_with_c08):
    """Source 2 — entity_canon.name + specific ID 가 same sentence + ±60 char window."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08O10 in dark jacket, Adult Character A, hands raised."
            ),
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {"character_id": "C08", "outlook_id": "O10"},
            ]},
        },
    }
    # PASS — C08O10 가 specific ID candidate 안 (base C08 + outlook O10)
    validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_entity_name_anchored_to_other_id(mock_db_with_c08_c09):
    """Source 2 — 다른 visible ID 가 window 안에 있어도 specific 매칭 안 되면 reject.

    검사 중인 name (Adult Character B = C09) 의 specific ID 가 window 안에 없음.
    C08O10 이 window 안에 있어도 reject (C09 의 candidate 가 아님).
    """
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "C09"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08O10 in dark jacket, Adult Character B walks in beside."
            ),
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(
        AppError, match="contract_violation_entity_name_no_specific_id"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08_c09, "test-pid")


def test_validate_fail_when_entity_name_outside_window(mock_db_with_c08):
    """Source 2 RO-23 — name 위치가 specific ID 로부터 ±60 char 밖."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": (
                "C08 sits at the desk watching the monitor flicker. The room is "
                "dim, the air is heavy, the floor cold. Across the table, "
                "Adult Character A walks in."
            ),
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(
        AppError, match="contract_violation_entity_name_no_specific_id"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_entity_name_in_separate_sentence(mock_db_with_c08):
    """Source 2 RO-27 — sentence boundary (period) 가 anchor 차단."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08 stands. Adult Character A turns away.",
        }],
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(
        AppError, match="contract_violation_entity_name_no_specific_id"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_required_refs_id_missing_from_visible(mock_db_with_c08):
    """Source 3a — required_refs 의 char base 가 visible 안에 없음."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": [
                {"kind": "character_outlook", "id": "C09O11"},
            ]},
            "id_policy": {"allowed_outlook_pairs": []},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_required_ref_not_visible"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_id_policy_character_id_missing_from_visible(
    mock_db_with_c08,
):
    """Source 3b RO-16 — allowed_outlook_pairs 의 character_id 가 visible 밖."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {"character_id": "C09", "outlook_id": "O11"},
            ]},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_outlook_pair_not_visible"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_with_legacy_base_id_shape(mock_db_with_c08):
    """Source 3b legacy fallback — base_id field 사용 시도."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {"base_id": "C09", "outlook_id": "O11"},
            ]},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_outlook_pair_not_visible"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_pass_when_outlook_uses_composite_id_field(mock_db_with_c08):
    """helper 우선순위 1 — composite_id 필드 사용 (UUID outlook_id 우회).

    outlook_id 가 UUID 형태라도 composite_id 가 정확한 C##O## 형태이면
    helper 가 composite_id 를 직접 candidate 로 사용 → Source 2 anchor 가능.
    """
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08O15 in white blouse, Adult Character A, smiling.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {
                    "character_id": "C08",
                    "composite_id": "C08O15",
                    "outlook_id": "abc-uuid-form",  # UUID — 무시됨
                },
            ]},
        },
    }
    # PASS — composite_id 가 우선순위 1 → C08O15 candidate → window anchor
    validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_composite_id_malformed_format(mock_db_with_c08):
    """helper format 강제 — composite_id 가 C##O## 형식 위반."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08 standing, Adult Character A walks beside.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {"character_id": "C08", "composite_id": "C08-O15", "outlook_id": "O15"},
            ]},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_composite_id_format"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_fail_when_outlook_id_uuid_no_composite(mock_db_with_c08):
    """helper format 강제 — composite_id 부재 + outlook_id 가 UUID/non-O##.

    composite_id 없이 outlook_id 만으로 candidate 합성하므로 — UUID 또는
    다른 형태는 명시적으로 composite_id 필드로 표현하라는 fail-fast.
    """
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{
            "t2i_prompt": "C08 standing, Adult Character A walks beside.",
        }],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": [
                {"character_id": "C08", "outlook_id": "abc-uuid-123"},
            ]},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_outlook_id_format"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_validate_no_bypass_for_failed_validator_status(mock_db):
    """RO-15 — validator_status='failed_carry_original' 도 contract enforce."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "validator_status": "failed_carry_original",
        "visible_entities": [],  # 빈 visible
        "t2i_variations": [{"t2i_prompt": "C09 walks in"}],  # C09 not visible
        "render_prompt_card": _rpc_empty(),
    }
    with pytest.raises(AppError, match="contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, mock_db, "test-pid")


def test_validate_required_refs_dict_shape_robust(mock_db_with_c08):
    """malformed entry → silent skip 금지, AppError fail-fast."""
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08"],
        "t2i_variations": [{"t2i_prompt": "C08 standing alone."}],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": ["malformed_string_entry"]},
            "id_policy": {"allowed_outlook_pairs": []},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_required_ref_type"
    ):
        validate_visible_entities_contract(shot, mock_db_with_c08, "test-pid")


def test_no_scenario_descriptor_constants_in_module():
    """A-prime grep gate — module source 안 nationality/ethnicity/작품 고유명사 0건."""
    import app.core.visible_entities_validator as vev_module
    import inspect
    src = inspect.getsource(vev_module)
    forbidden_pattern = re.compile(
        r"(?i)(korean|east asian|asian woman|asian man|한국인|일본인|중국인|"
        r"어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|"
        r"adult (male|female))"
    )
    matches = forbidden_pattern.findall(src)
    assert not matches, (
        f"visible_entities_validator.py contains scenario-derived descriptor "
        f"tokens: {matches}. A-prime binding violation — 모든 nationality/"
        f"ethnicity/작품 고유명사 production constant 금지."
    )
```

**Task 4.7 — Canary** (30분):

- `scripts/canary/g4_6_visible_entities_contract.py` — synthesized fixture 로
  4 path (Source 1 forward+reverse / Source 2 / Source 3a) fail-fast 동작 검증

silhouette_identity_preservation / variant_trait_inject canary 는 follow-up
(Wave A4 또는 별도 PR) — Phase 4 commit 안에 포함 X.

### 6.4 Verification gate

- `pytest backend/tests/core/test_visible_entities_validator.py -v` → PASS
- `pytest backend/tests/core/steps/test_detail_steps*.py` → PASS (회귀 0)
- 1 canary smoke OK (`g4_6_visible_entities_contract.py` 4/4 path)
- AppError positional 사용 0건
- **A-prime grep gate (시나리오 token 0건 강제, RC-C carry)** — 다음 영역에서
  nationality / ethnicity / 작품 고유명사 token 0건 검출 (대소문자 무관):

  ```bash
  # production code (Phase 4 새 module + 수정 영역)
  grep -niE "(korean|east asian|asian woman|asian man|한국인|일본인|중국인|어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|adult (male|female)|yokai|요괴|fang|vampire|흡혈)" \
    backend/app/core/visible_entities_validator.py \
    backend/app/core/steps/detail_steps.py
  # expected: 0 hits

  # v21 prompt (Phase 4 신규)
  grep -niE "(korean|east asian|asian woman|asian man|한국인|일본인|중국인|어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|adult (male|female)|yokai|요괴|fang|vampire|흡혈|red iris|sharp pointed teeth|날카로운 이빨|monstrous|괴물)" \
    prompts/_base/scene_detail/21.*/system.md
  # expected: 0 hits — Rule X 의 trait keyword 예시도 generic category 명만

  # authored Phase 4 tests (production fixture _제외_)
  # 본 file 의 docstring meta-reference 와 grep gate test 안 forbidden regex
  # literal 은 의도적 carve-out — `grep -v` 로 docstring(`#` 시작 줄) +
  # raw-string regex literal(r"…" / r'…' 시작 줄) 제외 후 0 hits 강제.
  grep -niE "(korean|east asian|asian woman|asian man|한국인|일본인|중국인|어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|adult (male|female)|yokai|요괴|fang|vampire|흡혈)" \
    backend/tests/core/test_visible_entities_validator.py \
    | grep -vE "^[^:]+:[0-9]+:\s*(#|r['\"])" \
    | grep -vE "scenario-derived descriptor|scenario-dependent|시나리오 의존 token"
  # expected: 0 hits — fixture 의 generic placeholder 만 통과

  # canary scripts (Phase 4 신규 — 1 commit 됨; silhouette / variant_trait 은
  # follow-up Wave A4 또는 별도 PR — plan §6.5 "1 canary smoke OK" 와 일치)
  grep -niE "(korean|east asian|asian woman|asian man|한국인|일본인|중국인|어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|adult (male|female)|yokai|요괴|fang|vampire|흡혈)" \
    scripts/canary/g4_6_visible_entities_contract.py
  # expected: 0 hits
  ```

  **추가 (Codex iter 1 M1 carry — v21 prompt 자체 grep)**: production code +
  v21 prompt + tests/canary 외에 v21 prompt 의 active body 도 별도 grep gate.
  v21 prompt 안 censorship pre-emption / scenario-derived violence vocabulary
  잔재 검출.

  ```bash
  grep -niE "(attacker|assailant|aggressor|predator|victim|prey|tearing flesh|ripped skin|spurting blood|broken knuckles|gaping wound|jagged gash|raw tissue|safety filter|안전 필터|회피 루틴|film previs|movie poster|aftermath 재작성)" \
    prompts/_base/scene_detail/21.*/system.md
  # expected: 0 hits — Codex iter 1 B2/B3 carry (violence palette + censor preemption 제거)
  ```

  **Audit history scope 외 (Phase 4 grep gate 적용 X)**:

  - **§3.4 Phase 3 historical / committed prompt excerpt** — 이미 commit 된
    영역 (Phase 3 RC-F/RC-G fix 의 binding source). 이번 Phase 4 patch scope
    가 아니므로 §3.4 prose / fixture 의 시나리오 어휘 잔재는 별도 lift 대상
    (G4.7+ scope).
  - **§1-2 defect-history prose** — D1~D4 결함 분석 (PID 298d86d9 raw capture
    분석). 결함 history 자체가 시나리오 사고 분석이므로 어휘 generic 화 시
    의미가 사라짐 — 보존.
  - Phase 0/1/2/3 의 raw production fixture
    (`backend/tests/fixtures/g4_6/*.json`) — raw capture, grep 대상 외.

  Phase 4 implementation gate 는 newly authored production code, v21 prompt
  body, authored tests, canary scripts 4 영역만 scan. 다음 audit (Codex /
  Claude) 가 §3.4 / §1-2 를 다시 잡지 않도록 본 Note 가 explicit scope
  declaration.

### 6.5 Commit + dual review

단일 commit: `feat(scene-detail): G4.6 RC-E+RC-H v21 — silhouette identity / variant trait / visible_entities contract`

dual review.

### 6.6 Risk + Rollback

- 회귀 위험: contract validator 가 너무 엄격해서 정상 prompt 도 fail 가능 →
  Phase 5 verification 에서 caught.
- Rollback: version_registry → v20 + detail_steps revert.

---

## 7. Phase 5 — Wave A Exit (Verification + Push)

### 7.1 Goal

Wave A 4 phase 완료 후 통합 verification + Wave A push.

### 7.2 Tasks

**Task 5.1 — Full pytest** (15분):

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
backend/.venv/bin/pytest backend/tests/ -v --tb=short
```

baseline 대비 신규 테스트 모두 PASS + 회귀 0.

**Task 5.2 — G4.6 specific 테스트 묶음** (10분):

```bash
backend/.venv/bin/pytest backend/tests/ -k "g4_6 or shot_validator_v4 or visible_entities or entity_protection or label_routing" -v
```

**Task 5.3 — 4 canary smoke** (15분):

```bash
backend/.venv/bin/python scripts/canary/g4_6_label_routing_face_substring_fix.py
backend/.venv/bin/python scripts/canary/g4_6_low_freq_skip_protection.py
backend/.venv/bin/python scripts/canary/g4_6_silhouette_identity_preservation.py
backend/.venv/bin/python scripts/canary/g4_6_variant_trait_inject.py
backend/.venv/bin/python scripts/canary/g4_6_shot_validator_type_descriptor.py
backend/.venv/bin/python scripts/canary/g4_6_visible_entities_contract.py
```

**Task 5.4 — Verification gate (RO-26)** (15분):

`scripts/g4_6_verification.py` (신규) — D2/D3 fixture 들이 Phase 1~4 적용
후 expected behavior 보이는지:

- D2 fixture S1_Shot5 — label routing 후 bg branch
- D3 fixture S2_Shot4 — character_ids 포함 + visible_entities contract fail-fast
- C08/C09/C15 entity — low_freq_skip 보호

**Task 5.5 — Wave A push** (5분):

Phase 1~4 의 4 commits 가 main 에 적용된 상태 → push.

```bash
git log --oneline main -10  # Phase 1~4 commits 확인
git push origin main
```

### 7.3 Verification gate

- `pytest backend/tests/` → 회귀 0 + 신규 ~30+ 테스트 통과
- 6 canary smoke 모두 OK
- `silent fallback grep`: `grep -rn "logger.warning.*fallback\|except.*pass" backend/app/services/reference_pipeline_orchestrator.py backend/app/core/steps/shot_validator_step.py` → 0 hits
- **PRO-4 + PRO-11 — silent fallback 패턴 검사 (`or []`/`or {}` + `.get(..., [])`/`.get(..., {})`)**:
  ```bash
  # (a) `or []` / `or {}` 패턴 — required field 우측 fallback
  grep -nE 'visible_entities[^)]*or\s*\[\]|t2i_variations[^)]*or\s*\[\]|render_prompt_card[^)]*or\s*\{\}|asset_requirements[^)]*or\s*\{\}|allowed_outlook_pairs[^)]*or\s*\[\]|id_policy[^)]*or\s*\{\}' \
    backend/app/core/visible_entities_validator.py
  # expected: 0 hits

  # (b) PRO-11 — .get(field, []) / .get(field, {}) 패턴 (PRO-4 grep 미커버)
  grep -nE '\.get\(\s*["'\'']*(visible_entities|t2i_variations|render_prompt_card|asset_requirements|required_refs|id_policy|allowed_outlook_pairs)["'\'']*\s*,\s*(\[\]|\{\})\s*\)' \
    backend/app/core/visible_entities_validator.py
  # expected: 0 hits — required field 는 모두 _MISSING sentinel default + AppError
  ```
- AppError signature: `grep -rn "AppError(f" backend/app/` → 0 hits
- `character_ids` non-empty primary gate: D3 fixture 의 character_ids non-empty 확인

### 7.4 Commit policy

Phase 5 자체는 verification — 신규 commit 없음 (단 verification 스크립트는
`scripts/g4_6_verification.py` 로 commit).

### 7.5 Risk + Rollback

verification 실패 시:
- 회귀 발견 → Phase 별 commit 단위로 git revert 가능
- canary 실패 → fix iter — Phase 별 별도 commit

---

## 8. Phase 6 — Wave B1: shot_dependency_t2i v6 + Zoom Validator (RC-A)

### 8.1 Goal

shot_dependency_t2i v6 prompt + step level zoom_in_detail validator (저장 전).

### 8.2 Files

- `prompts/_base/shot_dependency_t2i/6.{YYYYMMDDHHmm}/system.md` (신규 — PRO-8)
- `prompts/_base/shot_dependency_t2i/6.{YYYYMMDDHHmm}/schema.json` (신규
  — PRO-8: `load_schema("shot_dependency_t2i", "schema")` 검증, focus_transition
  필드 추가)
- `backend/app/core/version_registry.py` (shot_dependency_t2i → v6)

(PRO-8 carry: 코드 검증 — `shot_dependency_t2i_step.py:144-145` 가
`load_prompt("shot_dependency_t2i", "system")` + `load_schema("shot_dependency_t2i",
"schema")` 만 호출. `output_schema` / `validator_schema` 별도 파일 X.)
- `backend/app/core/zoom_validator.py` (NEW — `_validate_zoom_in_detail` +
  canonicalizer)
- `backend/app/core/steps/shot_dependency_t2i_step.py` (RO-18 validator
  hookup + RO-7 retry)

### 8.3 Tasks

**Task 6.1 — v6 prompt 작성** (1시간):

v5 carry + spec §4.1 신규:
1. body region check 룰
2. focus_transition schema (`previous_focus_region`, `current_focus_region`)
3. body region 매트릭스
4. focus_region canonicalizer 표 (RO-14)

`schema.json` (PRO-8 — `output_schema.json` 아님) — `location_refs[]` entry 에
두 필드 (`previous_focus_region`, `current_focus_region`) 추가.

**Task 6.2 — `zoom_validator.py` 신규 module** (1시간):

spec §4.1 답습:

```python
"""G4.6 Wave B1 RC-A — zoom_in_detail focus check."""
import logging
from app.core.errors import AppError

logger = logging.getLogger(__name__)


def canonicalize_focus_region(raw: str) -> str:
    """RO-14 / M3 — raw → canonical mapping."""
    if not raw:
        return ""
    s = raw.lower().strip()
    table = {
        "thigh": ("upper thigh", "bare thigh", "thigh", "leg"),
        "face": ("face", "face cu", "facial expression", "head"),
        "hand": ("hand", "palm", "fingers", "wrist", "knuckle", "fist"),
        "eye": ("eye", "eyes", "pupil", "iris", "gaze"),
        "wound": ("wound", "cut", "bite mark", "injury", "scar"),
        "wide_scene": ("wide", "establishing", "full scene", "wide shot",
                       "전경", "전신"),
        "mouth": ("mouth", "lip", "lips", "teeth"),
        "neck": ("neck", "throat"),
    }
    for canonical, raws in table.items():
        if s in raws:
            return canonical
    return s


def validate_zoom_in_detail(
    prev_focus_raw: str, current_focus_raw: str, ref_usage: str,
    *, scene_index: int, shot_index: int,
    ref_target_si: int, ref_target_shi: int,
) -> str:
    """RO-5 — fail-fast on focus mismatch."""
    if ref_usage != "zoom_in_detail":
        return ref_usage
    prev = canonicalize_focus_region(prev_focus_raw)
    cur = canonicalize_focus_region(current_focus_raw)
    if prev == cur and prev != "":
        return ref_usage
    raise AppError(
        code="step.shot_dependency_t2i.zoom_focus_mismatch",
        message=(
            f"S{scene_index}_Shot{shot_index} ref → "
            f"S{ref_target_si}_Shot{ref_target_shi}: ref_usage='zoom_in_detail' "
            f"but focus mismatch — previous={prev_focus_raw!r}/{prev!r}, "
            f"current={current_focus_raw!r}/{cur!r}. Re-classify ref_usage as "
            f"exact_background or atmosphere_reference and re-run "
            f"shot_dependency_t2i (LLM regen)."
        ),
        status_code=400,
    )
```

**Task 6.3 — `shot_dependency_t2i_step.py` RO-18 hookup** (45분):

spec §4.1 답습 — line 208~225 LLM result loop 에 validator 호출:

```python
from app.core.zoom_validator import validate_zoom_in_detail

# line 208 영역
for dep in result.get("dependencies", []):
    key = (dep.get("scene_index"), dep.get("shot_index"))
    loc_refs = dep.get("location_refs", [])
    validated_refs = []
    for ref in loc_refs[:1]:
        ref_key = (ref.get("scene_index"), ref.get("shot_index"))
        ref_idx = next((i for i, s in enumerate(shots)
                       if s["scene_index"] == ref_key[0] and s["shot_index"] == ref_key[1]), -1)
        cur_idx = next((i for i, s in enumerate(shots)
                       if s["scene_index"] == key[0] and s["shot_index"] == key[1]), -1)
        if not (0 <= ref_idx < cur_idx):
            logger.warning("shot_dependency_t2i: forward ref ... skipping")
            continue
        # RO-18 — zoom focus check (저장 전)
        ref["ref_usage"] = validate_zoom_in_detail(
            prev_focus_raw=ref.get("previous_focus_region", ""),
            current_focus_raw=ref.get("current_focus_region", ""),
            ref_usage=ref.get("ref_usage", ""),
            scene_index=key[0], shot_index=key[1],
            ref_target_si=ref_key[0], ref_target_shi=ref_key[1],
        )
        validated_refs.append(ref)
    llm_results[key] = {"location_refs": validated_refs}
```

**Task 6.4 — RO-7 silent fallback 제거** (45분):

spec §4.1 답습 — `:228` 의 `except` 분기를 retry + contract_violation 마킹:

```python
except Exception as e:
    logger.warning("shot_dependency_t2i: %s 1차 실패: %s — retry", loc_id, e)
    try:
        result = call_structured(..., retry_attempt=True)
        # ... 정상 처리 반복 (validate_zoom_in_detail 포함) ...
    except Exception as e2:
        logger.error("shot_dependency_t2i: %s 2차 실패 — contract violation", loc_id)
        failed += 1
        for s in shots:
            llm_results[(s["scene_index"], s["shot_index"])] = {
                "location_refs": [],
                "contract_status": "llm_failed",
                "contract_failure_reason": str(e2)[:200],
            }
        manifest_failures.append({
            "loc_id": loc_id,
            "shots": [(s["scene_index"], s["shot_index"]) for s in shots],
            "reason": str(e2)[:200],
        })
```

step result manifest 의 `data.contract_failures` field 신규.

**Task 6.5 — Unit tests** (1.5시간):

`backend/tests/core/test_zoom_validator.py` (신규):

```python
import pytest
from app.core.errors import AppError
from app.core.zoom_validator import canonicalize_focus_region, validate_zoom_in_detail


@pytest.mark.parametrize("raw,canonical", [
    ("upper thigh", "thigh"), ("bare thigh", "thigh"), ("leg", "thigh"),
    ("face CU", "face"), ("facial expression", "face"),
    ("hand", "hand"), ("palm", "hand"), ("fingers", "hand"),
    ("wide shot", "wide_scene"), ("전경", "wide_scene"),
    ("elbow", "elbow"),  # unknown — raw 보존
])
def test_canonicalize_focus_region(raw, canonical):
    assert canonicalize_focus_region(raw) == canonical


def test_validate_fail_fast_when_focus_differs():
    with pytest.raises(AppError, match="zoom_focus_mismatch"):
        validate_zoom_in_detail(
            prev_focus_raw="thigh", current_focus_raw="face",
            ref_usage="zoom_in_detail",
            scene_index=1, shot_index=4,
            ref_target_si=1, ref_target_shi=3,
        )


def test_validate_pass_when_focus_matches():
    result = validate_zoom_in_detail(
        prev_focus_raw="face CU", current_focus_raw="face",
        ref_usage="zoom_in_detail",
        scene_index=1, shot_index=2,
        ref_target_si=1, ref_target_shi=1,
    )
    assert result == "zoom_in_detail"


def test_validate_pass_when_ref_usage_not_zoom():
    result = validate_zoom_in_detail(
        prev_focus_raw="thigh", current_focus_raw="face",
        ref_usage="exact_background",  # not zoom
        scene_index=1, shot_index=4,
        ref_target_si=1, ref_target_shi=3,
    )
    assert result == "exact_background"
```

`backend/tests/core/steps/test_shot_dependency_t2i_v6.py` — D1 fixture
(`s1_shot3_shot4_dependency.json`) 로 step level 검증.

**Task 6.6 — Canary** (20분):

`scripts/canary/g4_6_zoom_in_detail_focus_mismatch.py` (RO-20 답습) — D1 fixture
S1_Shot4 (prev=thigh + current=face) → fail-fast.

### 8.4 Verification gate

- `pytest backend/tests/core/test_zoom_validator.py backend/tests/core/steps/test_shot_dependency_t2i_v6.py -v` → PASS
- canary smoke OK
- 회귀: `pytest backend/tests/core/steps/test_shot_dependency*.py` → PASS

### 8.5 Commit + dual review

단일 commit: `feat(shot-dependency-t2i): G4.6 RC-A v6 — focus_transition schema + zoom validator + RO-7 retry`

dual review.

---

## 9. Phase 7 — Wave B2: Image Prompt Integration (RC-B + RC-I)

### 9.1 Goal

scene_reference_service zoom label conditional + prompt_service chain_bg 4
directive.

### 9.2 Files

- `backend/app/services/scene_reference_service.py:680` (zoom label defensive
  invariant)
- `backend/app/services/prompt_service.py:120-131` (chain_bg 4 directive)

### 9.3 Tasks

**Task 7.1 — `scene_reference_service.py` RO-18 defensive assert** (30분):

spec §4.2 답습:

```python
# line 680
if ref_usage == "zoom_in_detail":
    # RO-18: invariant — shot_dependency_t2i_step 에서 검증 통과한 zoom.
    from app.core.zoom_validator import canonicalize_focus_region
    prev_canon = canonicalize_focus_region(dep_info.get("previous_focus_region", ""))
    cur_canon = canonicalize_focus_region(dep_info.get("current_focus_region", ""))
    assert prev_canon == cur_canon and prev_canon != "", (
        f"INVARIANT VIOLATION: zoom_in_detail at scene_reference_service "
        f"with focus mismatch ({prev_canon!r} != {cur_canon!r}) — "
        f"shot_dependency_t2i_step validator failed."
    )
    label = "previous shot at same location (SAME FRAME ZOOMED) — ..."
    # 'Do NOT change the pose' 는 prompt_service instructions 에 carry
    if ignore: ...
```

**Task 7.2 — `prompt_service.py` chain_bg 4 directive** (45분):

spec §4.3 답습:

```python
elif branch == "background_chain_ref":
    ref_roles.append(
        f"Reference image {i}: pre-rendered BACKGROUND chain reference — use as-is"
    )
    ref_instructions.extend([
        f"- match the wall, floor, ceiling, furniture layout, and lighting exactly from image {i}",
        f"- do NOT copy any people from image {i}",
        # G4.6 RC-I — 4 directive
        f"- match the lighting key/fill direction visible in image {i} onto the inserted subject — the subject's shadows must fall in the same direction as shadows already in image {i}",
        f"- include realistic contact shadow under the subject's feet on the same ground plane shown in image {i}, with shadow opacity matching image {i}'s ambient occlusion",
        f"- match the camera height, lens, and perspective implied by image {i} — the subject must be rendered as if photographed from the same camera position as image {i}",
        f"- the subject must occupy 3D space within image {i}'s geometry, not appear pasted in front of it as a 2D cutout",
    ])
```

**Task 7.3 — Unit tests** (45분):

`backend/tests/services/test_prompt_service_chain_bg.py` (신규):

```python
def test_chain_bg_includes_4_compositing_directives():
    label = "pre-rendered BACKGROUND chain reference — use as-is"
    res = resolve_ref_roles([(label, b"<bytes>")])
    expected_substrings = (
        "match the lighting key/fill direction",
        "contact shadow under the subject's feet",
        "match the camera height, lens, and perspective",
        "occupy 3D space within image",
    )
    for s in expected_substrings:
        assert any(s in i for i in res.ref_instructions), \
            f"missing directive: {s}"
```

`backend/tests/services/test_scene_reference_service_zoom_invariant.py` —
defensive assert 검증.

**Task 7.4 — Canary** (20분):

`scripts/canary/g4_6_chain_bg_compositing_directives.py` — D4 fixture (S8_Shot6
prompt) 가 4 directive 모두 포함하는지.

### 9.4 Verification gate

- `pytest backend/tests/services/test_prompt_service*.py backend/tests/services/test_scene_reference*.py -v` → PASS
- canary smoke OK

### 9.5 Commit + dual review

단일 commit: `feat(image-prompt): G4.6 RC-B+RC-I — zoom invariant + chain_bg 4 compositing directive`

dual review. **Wave B 종료 push** (Phase 6+7 commits 모두).

---

## 10. Phase 8 — Production Regeneration

### 10.1 Goal

Pre-Wave + Wave A + Wave B 적용 후 PID `298d86d9` 에서 4 defect shot 재생성
+ 사용자 visual sign-off.

### 10.2 Tasks

**Task 8.1 — ref_image_gen + composite refs** (10-15분):

```bash
# C08, C09, C15 base + O10, O11, O15 outlook composite
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9-615b-4b87-8040-21144c0731c1 \
  --eid b6544514-babb-4781-95a2-6a0ff1da342f \
  --step ref_image_gen --category image
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9-615b-4b87-8040-21144c0731c1 \
  --eid b6544514-babb-4781-95a2-6a0ff1da342f \
  --step composite_image_gen --category image
```

verification: DB 조회 — C08/C09/C15 ref_count >= 1.

**Task 8.2 — shot_validator (전체)** (5-10분):

```bash
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9... --eid b6544514... --step shot_validator --category analysis
```

verification:
- S1_Shot3, S1_Shot4, S1_Shot5 character_ids = ["C07"] 또는 ["C07", "C15"]
- S2_Shot4 character_ids = ["C08", "C09"]
- S2_Shot4 description mid-impact 변환됨

**Task 8.3 — scene_detail (전체)** (10-20분):

```bash
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9... --eid b6544514... --step scene_detail --category analysis
```

verification:
- S1_Shot5 t2i_prompt 에 C15 stable_traits ("얼굴이 보이지 않음" / "face fully obscured") 반영
- S2_Shot4 t2i_prompt 에 C08 yokai trait (red irises / sharp pointed teeth) inject + C09 ID 표현
- visible_entities contract validator 통과

**Task 8.4 — shot_dependency_t2i (전체)** (3-5분):

```bash
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9... --eid b6544514... --step shot_dependency_t2i --category analysis
```

verification: S1_Shot4 의 ref_usage 가 zoom_in_detail 이 아닌 exact_background
또는 unset (LLM 재추론 결과).

**Task 8.5 — scene_image_pipeline (target shots)** (5-10분):

```bash
# target shots: S1_Shot3, S1_Shot4, S1_Shot5, S2_Shot4, S8_Shot6
backend/.venv/bin/python scripts/force_step.py \
  --pid 298d86d9... --eid b6544514... --step scene_image_pipeline --category image
```

verification: 5 PNG 재생성. ImageAsset.prompt_used 가 RC-D fix + RC-I 4 directive
적용된 것.

**Task 8.6 — 사용자 visual sign-off**:

5 PNG 사용자 검토 — 4 defect 해결 확인:

- D1 (S1_Shot3 vs S1_Shot4): face CU 가 face 로 렌더 (thigh 중복 X)
- D2 (S1_Shot5): C15 가 face features 없는 silhouette
- D3 (S2_Shot4): violent contact 자세 + C08 yokai trait visible
- D4 (S8_Shot6): 인물이 3D 공간에 위치, cutout 인상 X

### 10.3 Verification gate (사용자 결정)

- 4 defect 모두 해결 → Phase 8 종료 + spec/plan archive
- 1+ defect 잔존 → fix iter (root cause 분석 → spec 업데이트 → plan iteration)

### 10.4 비용 / 시간

- ref_image_gen + composite: ~$2, 10-15분
- shot_validator: ~$2, 5-10분
- scene_detail: ~$3, 10-20분
- shot_dependency_t2i: ~$1, 3-5분
- scene_image_pipeline (target 5): ~$2, 5-10분
- **총: ~$10, 30-60분**

---

## 11. Cross-Phase Verification Gates

각 Phase 종료 시 공통 체크:

1. **Silent fallback 검사** — `grep -rn "logger.warning.*fallback\|except.*pass\b" backend/app/<changed-files>` → 0 hits 또는 정당한 위치만
2. **AppError signature** — `grep -rn "AppError(f" backend/app/` → 0 hits
3. **regex post-processing** — 신규 regex 사용처 검토 (`feedback_no_regex_postprocessing.md` — 의미 추출 X)
4. **5-field envelope 보존** — `grep -rn "render_strategy\|id_policy\|background_binding\|continuity_elements_used\|asset_requirements" backend/app/core/steps/render_prompt_card.py` → 5 field 전부 존재
5. **신규 top-level field** — 0 (사용자 binding)
6. **LLM "visually critical" flag** — 0 (사용자 binding)
7. **회귀 baseline pytest count** — 신규 test 추가 외 baseline 보존

---

## 12. Risks / Rollback Per Phase

| Phase | 주요 risk | rollback |
|---|---|---|
| Phase 0 | fixture 캡처 누락 | re-run capture script |
| Phase 1 | label routing 분기 변화로 기존 case 회귀 | revert single commit |
| Phase 2 | low_freq_skip 보호 너무 광범위 → ref 비용 증가 | _PROTECTED_KIND_KEYWORDS 좁히기 |
| Phase 3 | shot_validator v4 가 type descriptor 잘못 매핑 → contract 실패 | version_registry → v3 |
| Phase 4 | contract validator 너무 엄격 → 정상 prompt fail | source 2 window/sentence boundary 조정 (RO-23/RO-27 fix iter) |
| Phase 5 | verification 실패 | Phase 별 git revert |
| Phase 6 | zoom validator 가 정상 zoom 도 reject | canonicalizer table 확장 |
| Phase 7 | chain_bg 4 directive 가 LLM compliance 약함 | directive 워딩 강화 (fix iter) |
| Phase 8 | 1+ defect 잔존 | root cause 분석 → spec 업데이트 → plan iter |

---

## 13. Carry from Spec (RO-1 ~ RO-31, PRO-14)

본 plan 은 spec 의 모든 RO 결정 답습. 본 plan 에서 명시적 carry:

| Spec RO | Plan 적용 |
|---|---|
| RO-1 (entity context + character_ids) | Phase 3 Task 3.2/3.3 |
| RO-2 (visible_entities 3-source) | Phase 4 Task 4.3 |
| RO-3 (4-source cascade) | Phase 2 Task 2.1 (`_collect_required_entity_ids`) |
| RO-4 (focus_transition 두 필드) | Phase 6 Task 6.1 |
| RO-5 (fail-fast 단일화) | Phase 6 Task 6.2 |
| RO-6 (regen order) | Phase 8 |
| RO-7 (silent fallback 제거) | Phase 6 Task 6.4 |
| RO-8 (failure partial) | Phase 3 Task 3.4 |
| RO-9 (canary path) | scripts/canary/g4_6_*.py (전 phase) |
| RO-10 (shadow narrowing) | Phase 2 Task 2.1 |
| RO-11 (characters/character_ids 분리) | Phase 3 Task 3.3 |
| RO-12 (etype 인자) | Phase 2 Task 2.1 |
| RO-13 (status text) | header |
| RO-14 (canonicalizer) | Phase 6 Task 6.2 |
| RO-15 (validator_status no bypass) | Phase 4 Task 4.4 |
| RO-16 (allowed_outlook_pairs character_id) | Phase 4 Task 4.3 |
| RO-17 (descriptor same-clause) | (deprecated by RO-23) |
| RO-18 (zoom validator 위치) | Phase 6 Task 6.3 |
| RO-19 (character_ids 일관) | 전 phase |
| RO-20 (canary 이름 fail-fast) | Phase 6 Task 6.6 |
| RO-21 (low_freq vs outlook 분리) | Phase 5 Task 5.4 verification |
| RO-22 (AppError signature) | 전 phase |
| RO-23 (descriptor window) | Phase 4 Task 4.3 |
| RO-24 (canary 이름 일관) | Phase 6 Task 6.6 |
| RO-25 (fixture description) | Phase 0 Task 0.2 |
| RO-26 (verification gate primary) | Phase 5 Task 5.4 |
| RO-27 (sentence boundary 통합) | Phase 4 Task 4.3 (`_descriptor_has_anchored_id_in_window`) |
| RO-28 (stale 문구 제거) | spec carry — plan 영향 X |
| RO-29 (spec body silent fallback sync) | Phase 4 Task 4.3 (`_MISSING` sentinel + `_require_field` 도입) |
| RO-30 (required_refs _MISSING fail-fast) | Phase 4 Task 4.3 source 3a (PRO-12 carry) |
| RO-31 (allowed_outlook_pairs entry no-id fail-fast) | Phase 4 Task 4.3 source 3b (PRO-13 carry) |

---

본 plan 은 R1+R2 dual audit 를 위한 initial draft. 사용자 결정대로 R2 Claude
audit 생략 — 본 plan drafting 직후 Phase 0 진입 가능. Phase 0 종료 후 Phase 1
hotfix 부터 단계별 commit + dual review.
