# G3.2 Bg/Fg Ownership Contract Implementation Plan

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

**Goal:** `background_prompt` 가 chain_bg PNG 에 그린 환경 객체 list (`objects_owned_by_background`) 를 명시 contract 로 enumerate 하고, `scene_detail` 이 같은 객체를 redraw 하지 않도록 LLM judge + post-parse sentinel 로 차단.

**Architecture:** producer (background_prompt v4→v5) strict schema + normalize, consumer (scene_detail v14→v15) post-parse LLM judge + CP-only sentinel + hash drift detection. `owned_validation` 은 LLM 응답 schema 외 (post-parse 가 cp 에 삽입). DAG 재배치 (scene_detail 20→21.70) + scene_detail.depends_on 에 background_prompt 추가 + `_load_chain_bg_owned_by_shot()` 진입부 fail-fast (옛 v4 cp / partial v5 cp 차단).

**Tech Stack:** Python 3 / FastAPI / pytest / JSON Schema / GPT (background_prompt) / Gemini Pro (scene_detail) / GPT-mini (judge — `_PIPELINE_STEP_EXTENSIONS` 라우팅).

**Spec:** `docs/superpowers/specs/2026-05-03-g3.2-bg-fg-ownership-design.md` (HEAD `17f9088` — round 4 fix 적용)

**Round 5 Override (2026-05-04, BLOCKING 4 + IMPORTANT 3 + MINOR 3)** — 본 plan
의 기존 task 내용보다 **우선** 적용. 사용자 결정 BLOCKING 3 = A (`t2i_prompt_hash`
field 추가, t2i_review 는 sentinel 보존만, drift 는 next verify 에서 partial).

| 항목 | task 영향 |
|---|---|
| R5-B1. **owned 영어 canonical guard** | Task 2 의 `normalize_owned_list` + Task 7 의 `validate_bg_prompt_output` 에 CJK/Hangul/Kana 포함 entry reject 추가. 한국어 시나리오에서도 `["문", "창문"]` 통과 안 됨. test 추가. |
| R5-B2. **Task 18 NameError fix** | `camera_direction=...` 인자 변수 → `cam_dir_for_block or ""` 로 통일 (모든 sentinel build call site). |
| R5-B3. **t2i_prompt_hash 신설** (A 결정) | Task 3 에 `compute_t2i_prompt_hash()` 추가. Task 4 `build_owned_sentinel(t2i_prompt=...)` 시그니처 확장 + sentinel field 추가. Task 18 모든 sentinel build 에 `t2i_prompt=variation["t2i_prompt"]` 인자. Task 19 verify drift 검증 에 t2i_prompt_hash 추가. Task 19.5 `_user_edited` helper 도 검증 포함. |
| R5-B4. **verify regex 통일** | Task 19 의 local `_CLOSE_FRAMING_RE_LOCAL` 삭제. module-level `_CLOSE_FRAMING_RE` (`detail_steps.py:88`) import 후 재사용. 한국어 `클로즈업` / `손가락이` 등 생성 path 와 일치. |
| R5-I1. **scene-level legacy path 가드** | Task 18 진입부에 `if shot_info is None or shi is None: variation["owned_validation"] = build_owned_sentinel(owned=[], camera_direction="", t2i_prompt=variation["t2i_prompt"], is_close_framing=False, violations=[]) ; continue` — owned judge skip + trivial sentinel. NameError 방지. |
| R5-I2. **Task 12 docstring 정리** | docstring 의 "bg-on + cp 부재 → 빈 dict 진행 (gate 처리 별도)" 문구를 "bg-on + cp 부재 → AppError fail-fast" 로 정정. 코드는 round 4 patch 로 이미 정확. |
| R5-I3. **analysis-only 통합 테스트 강화** | Task 21 의 `test_dag_dep_is_background_prompt` (manifest dep grep 만) 를 dispatcher / StepRunner gate 까지 타는 통합 테스트로 교체 — bg on + category=analysis 호출 시 scene_detail 이 실제로 dep 미충족 block. |
| R5-M1. **Rule C retry 잔재 제거** | Task 14 Rule C 갱신 본문의 "post-parse 단계가 LLM judge 로 위반을 검증해 retry 시킨다" → "post-parse 단계가 LLM judge 로 위반을 검증하고 contract_violation status 로 marking 한다 (no retry)". |
| R5-M2. **default_model 잔재 정리** | spec 테스트 목록 의 `default_model="gpt-mini"` 표현 → `_PIPELINE_STEP_EXTENSIONS[...]["default"]` 로 통일 (이미 spec 적용). plan Task 10 은 정확. |
| R5-M3. **DB persistence 정확화** | spec 9.1 "DB 미반영" → `t2i_variations_json` 안에 sentinel 직렬화. plan 에 명시 항목 없음 — spec 만 정정. |

위 10 항목은 task 본문보다 **우선 적용**. 모순 시 Round 5 Override 따름.

---

**Round 4 Override (2026-05-04, BLOCKING/IMPORTANT 합본)** — 아래 항목은 본 plan
의 기존 task 내용보다 **우선** 적용. 사용자 결정 Q1=B / Q2=B / Q3=A 반영.

| 항목 | task 영향 |
|---|---|
| 1. **bg-on + bp_cp is None 도 fail-fast block** (BLOCKING 1) | Task 5 의 `test_bg_mode_on_none_cp_passes` 를 `test_bg_mode_on_none_cp_blocks` 로 교체. `assert_background_prompt_owned_contract` 의 두 번째 가드 (`bp_cp is None` 통과) 를 raise 로 교체. Task 12 의 `test_bg_on_no_cp_returns_empty` 도 `test_bg_on_no_cp_raises` 로 교체. Task 21 도 동일. |
| 2. **owned 영어 canonical 강제** (Q2=B) | Task 6 schema description + Rule 12 system.md 본문에 "items MUST be English canonical common nouns" 명시. 한국어 시나리오에서도 `["문"]` 금지 → `["door"]`. Task 9 judge prompt 도 영어 전제로 단순화 (multilingual 매칭 로직 불필요). Task 7 에 `test_normalize_preserves_english_canonical_for_korean_scenario` 추가. |
| 3. **chain_bg_owned_enabled toggle 제거** (Q3=A) | Task 16 의 `chain_bg_owned_enabled` 인자 / settings 추가 step 모두 삭제. `_build_phase2_prepend_blocks` 시그니처에서 `chain_bg_owned_enabled` 제거 — close framing 은 deterministic skip 만. Task 17 의 `_config_hash()` payload 에서 `chain_bg_owned_enabled` 제거. Task 21 wiring test 의 `chain_bg_owned_enabled=True/False` 인자 제거. |
| 4. **judge retry 제거** (Q1=B) | Task 18 의 "retry (max 2) / max 후" 문구 삭제. 실제 코드도 변경 없음 (1 회 judge → violations 시 즉시 status 마킹 — plan 이미 그렇게 작성됨). spec 만 정정 (완료). |
| 5. **verify_completion drift 3 종 검증** (BLOCKING 2) | Task 19 의 sentinel 검증 강화 — `validator` type 일치 + `owned_hash` 일치 + `camera_direction_hash` 일치 (close-skip variation 도). Task 19 의 `test_g3_2_close_skip_sentinel_drift` 외에 `test_verify_completion_validator_type_drift` / `test_verify_completion_camera_direction_hash_drift` 추가. |
| 6. **Task 13 file path 정정** (IMPORTANT 1) | dataclass 는 `backend/app/core/dto/scene_analysis.py:93` (line 103 `chain_bg_camera_meta_by_shot` 다음 줄). loader 는 import 만. Task 13 의 "scene_context_loader.py 의 SceneAnalysisContext dataclass" 표현은 잘못 — 실제는 `app/core/dto/scene_analysis.py`. |
| 7. **Task 6.5 신설 — bg_prompt_step cp entry build** (Critical missed) | `BackgroundPromptStep._execute()` 의 `_process()` 안 result dict 빌드 (line 292-299) 에 `"objects_owned_by_background": out["objects_owned_by_background"]` 추가. plan 이 SCHEMA bump 만 다루고 cp entry 까지 안 갔던 누락. Task 8 직전에 6.5 로 삽입. |
| 8. **Task 19.5 신설 — _user_edited reuse owned validation** (IMPORTANT 4) | `detail_steps.py:300+` 의 `_user_edited` 분기에 G3.1 evidence assert 다음에 owned_validation 검증 (validator type / owned_hash / camera_direction_hash) 추가. mismatch 시 reuse 거부 + fresh 재생성 (G3.1 패턴). |
| 9. **Task 21.5 신설 — consumer propagation tests** (MINOR 2) | t2i_review / scene_still_normalizer / scene_checkpoint_loaders 가 `owned_validation` 보존 검증 (deep copy 시 자동 보존, 명시적 strip 안 함). 3 케이스 fixture 기반 unit test. |
| 10. **judge step 등록 키는 `default`** (MINOR 1) | Task 10 plan 본문은 이미 `"default": "gpt-mini"` 로 정확. 단 `default_model` literal 표현 자취 검색 후 `default` 로 통일. |

위 10 항목은 task 본문보다 **우선 적용**. 모순 시 Round 4 Override 따름.

---

**Commit 정책:** G3.1 패턴 계승 = Task 마지막 단계 dual review (Codex + Claude) → fix loop → APPROVED 후 **단일 commit + push**. 각 Task 안의 commit step 없음.

**Prompt timestamp:** `202605032354` (background_prompt v5 / scene_detail v15 / scene_detail_owned_judge v1 모두 동일).

---

## File Structure

### Create (16 files)

**Prompts (8 files):**
- `prompts/_base/background_prompt/5.202605032354/system.md` — v4 + Rule 12 (objects_owned)
- `prompts/_base/background_prompt/5.202605032354/schema.json` — v4 + objects_owned_by_background
- `prompts/_base/background_prompt/5.202605032354/user_template.md` — v4 그대로 복사 (loader drift 차단)
- `prompts/_base/scene_detail/15.202605032354/system.md` — v14 + Rule C 갱신
- `prompts/_base/scene_detail/15.202605032354/detail_schema.json` — v14 그대로 복사 (LLM schema 변경 0)
- `prompts/_base/scene_detail_owned_judge/1.202605032354/system.md` — judge anchor/redraw 룰
- `prompts/_base/scene_detail_owned_judge/1.202605032354/user_template.md` — t2i_prompt + owned_list + camera_direction inject
- `prompts/_base/scene_detail_owned_judge/1.202605032354/schema.json` — violations object[]

**Code (1 file):**
- `backend/app/core/steps/_owned_helpers.py` — owned hash / sentinel build / shape validate / fail-fast

**Tests (7 files):**
- `backend/tests/unit/test_owned_helpers.py` — hash/sentinel/shape 단위 테스트
- `backend/tests/unit/test_background_prompt_owned_schema.py` — schema strict + normalize
- `backend/tests/unit/test_scene_context_owned_loader.py` — loader 매핑 + fail-fast
- `backend/tests/unit/test_scene_detail_owned_judge.py` — judge call routing
- `backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py` — close skip sentinel drift
- `backend/tests/unit/test_g3_2_judge_step_extension.py` — extension registration
- `backend/tests/integration/test_g3_2_consumer_wiring.py` — close skip 3 종 + non-close inject 3 종 + sentinel drift + contract_violation status + analysis-only block + bg-mode-off + schema isolation

### Modify (6 files)

- `backend/app/core/steps/background_prompt_step.py` — `SCHEMA_VERSION 1→2`, `PROMPT_VERSION 4.202604301033→5.202605032354`
- `backend/app/modules/pipeline/background_prompt.py` — `validate_bg_prompt_output()` (objects_owned 1+ entries 검증 + strip/dedupe normalize 후 저장)
- `backend/app/core/steps/scene_context_loader.py` — `_load_chain_bg_owned_by_shot()` 신설 + fail-fast + `chain_bg_owned_by_shot` 필드 ctx 에 추가
- `backend/app/core/steps/detail_steps.py` — `SCENE_DETAIL_SCHEMA_VERSION 5→6`, `_build_phase2_prepend_blocks` close 시 guide/camera/owned 3 종 skip + owned block inject + `_analyze_one` post-parse owned validator + sentinel + `verify_completion()` sentinel/hash 검증
- `backend/app/core/step_manifest.py` — scene_detail order 20→21.70 + depends_on += `background_prompt` + allow_partial_downstream=False + schema_version 5→6 / shot_dependency_t2i 20.5→21.71 / t2i_review 20.7→21.72 / background_prompt schema_version=2 명시
- `backend/app/modules/llm/llm_client.py` — `_PIPELINE_STEP_EXTENSIONS["scene_detail_owned_judge"] = {"label": "owned 객체 redraw 검사", "default": "gpt-mini", "category": "analysis_sub"}`

---

## Phase 1 — Foundation (helper module + unit tests)

### Task 1: `_owned_helpers.py` 모듈 스켈레톤

**Files:**
- Create: `backend/app/core/steps/_owned_helpers.py`

- [ ] **Step 1: helper 파일 생성 (상수 + 빈 시그니처)**

```python
# backend/app/core/steps/_owned_helpers.py
"""G3.2 owned validation helpers — single source.

producer-side normalize + consumer-side hash/sentinel build + shape validator
+ fail-fast guard (옛 v4 / partial v5 cp 차단).

post-parse 가 cp 에 삽입하는 sentinel:
  {schema_version, owned_hash, camera_direction_hash, validator, violations}

LLM 응답 schema 에는 들어가지 않음 (CP-only).
"""
from __future__ import annotations

import hashlib
import logging
from typing import Any, Dict, Iterable, List

from app.core.errors import AppError

logger = logging.getLogger(__name__)

OWNED_SENTINEL_SCHEMA_VERSION = 1
OWNED_VALIDATOR_FULL = "scene_detail_owned_objects.v1"
OWNED_VALIDATOR_CLOSE_SKIP = "scene_detail_owned_objects.v1.close_skip"
OWNED_MAX_LEN = 80  # background_prompt schema items maxLength 와 일치


def normalize_owned_list(items: Iterable[Any]) -> List[str]:
    raise NotImplementedError


def compute_owned_hash(owned: List[str]) -> str:
    raise NotImplementedError


def compute_camera_direction_hash(camera_direction: str) -> str:
    raise NotImplementedError


def compute_t2i_prompt_hash(t2i_prompt: str) -> str:
    raise NotImplementedError


def build_owned_sentinel(
    *,
    owned: List[str],
    camera_direction: str,
    t2i_prompt: str,
    is_close_framing: bool,
    violations: List[Dict[str, str]],
) -> Dict[str, Any]:
    raise NotImplementedError


def assert_owned_sentinel_shape(sentinel: Dict[str, Any], where: str = "") -> None:
    raise NotImplementedError


def assert_background_prompt_owned_contract(
    bp_cp: Dict[str, Any] | None,
    *,
    background_mode_on: bool,
    where: str = "",
) -> None:
    raise NotImplementedError
```

- [ ] **Step 2: import 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "from app.core.steps._owned_helpers import OWNED_SENTINEL_SCHEMA_VERSION, OWNED_VALIDATOR_FULL, OWNED_VALIDATOR_CLOSE_SKIP, normalize_owned_list, compute_owned_hash, compute_camera_direction_hash, compute_t2i_prompt_hash, build_owned_sentinel, assert_owned_sentinel_shape, assert_background_prompt_owned_contract; print('OK')"
```
Expected: `OK`

### Task 2: `normalize_owned_list` — strip / dedupe / drop empty / 정렬

**Files:**
- Modify: `backend/app/core/steps/_owned_helpers.py`
- Create: `backend/tests/unit/test_owned_helpers.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_owned_helpers.py
"""G3.2 _owned_helpers unit tests."""
from __future__ import annotations

import pytest

from app.core.steps._owned_helpers import (
    OWNED_SENTINEL_SCHEMA_VERSION,
    OWNED_VALIDATOR_FULL,
    OWNED_VALIDATOR_CLOSE_SKIP,
    normalize_owned_list,
)


class TestNormalizeOwnedList:
    def test_strips_whitespace(self) -> None:
        assert normalize_owned_list(["  door ", " window"]) == ["door", "window"]

    def test_dedupes_case_sensitive(self) -> None:
        # 대소문자 구분 — "TV" 와 "tv" 는 다른 객체로 보존 (한글/혼합 sources 안전).
        assert normalize_owned_list(["TV", "tv", "TV"]) == ["TV", "tv"]

    def test_drops_empty_and_whitespace_only(self) -> None:
        assert normalize_owned_list(["door", "", "  ", "window"]) == ["door", "window"]

    def test_drops_non_string(self) -> None:
        assert normalize_owned_list(["door", None, 42, "window"]) == ["door", "window"]

    def test_sorted_ascending(self) -> None:
        assert normalize_owned_list(["window", "door", "TV"]) == ["TV", "door", "window"]

    def test_empty_input(self) -> None:
        assert normalize_owned_list([]) == []

    def test_truncates_to_max_len(self) -> None:
        long_item = "a" * 200
        result = normalize_owned_list([long_item])
        assert len(result) == 1
        assert len(result[0]) <= 80

    # round 5 BLOCKING 1: owned 영어 canonical guard.
    def test_drops_korean_hangul_items(self) -> None:
        # 한국어 시나리오에서도 owned 는 항상 영어. ["문", "창문"] 통과 안 됨.
        result = normalize_owned_list(["door", "문", "window", "창문"])
        assert result == ["door", "window"]

    def test_drops_japanese_kana_items(self) -> None:
        result = normalize_owned_list(["door", "ドア", "テーブル", "table"])
        assert result == ["door", "table"]

    def test_drops_cjk_ideograph_items(self) -> None:
        result = normalize_owned_list(["door", "门", "桌子", "table"])
        assert result == ["door", "table"]

    def test_drops_mixed_korean_english(self) -> None:
        # "Korean도어" 같은 섞인 token 도 reject (영어 canonical 강제).
        result = normalize_owned_list(["door", "Korean도어", "window"])
        assert result == ["door", "window"]

    def test_pure_ascii_passes(self) -> None:
        result = normalize_owned_list(["TV", "AC", "wardrobe"])
        assert result == ["AC", "TV", "wardrobe"]
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestNormalizeOwnedList -v
```
Expected: 12 FAIL with `NotImplementedError` (round 5 BLOCKING 1: 5 신규 ASCII test 추가).

- [ ] **Step 3: 구현**

```python
def normalize_owned_list(items: Iterable[Any]) -> List[str]:
    """strip / dedupe (case-sensitive) / drop empty/non-string/non-ASCII / 정렬.

    저장 직전에 호출 — 단순 검증이 아니라 정규화 책임.
    case 보존 이유: 영어 canonical 안에서도 "TV" vs "tv" 같은 분리가 의도일 수 있음.

    round 5 BLOCKING 1: owned 영어 canonical guard. Hangul / CJK ideograph /
    Kana / 기타 non-ASCII 포함 entry 는 reject (한국어 시나리오에서도 영어 고정).
    ascii 만 통과.
    """
    seen: set = set()
    out: List[str] = []
    for it in items or []:
        if not isinstance(it, str):
            continue
        s = it.strip()
        if not s:
            continue
        # round 5 BLOCKING 1: ASCII only — non-ASCII 전체 reject (Hangul / CJK /
        # Kana 모두 차단).
        try:
            s.encode("ascii")
        except UnicodeEncodeError:
            continue
        if len(s) > OWNED_MAX_LEN:
            s = s[:OWNED_MAX_LEN]
        if s in seen:
            continue
        seen.add(s)
        out.append(s)
    out.sort()
    return out
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestNormalizeOwnedList -v
```
Expected: 12 PASS (round 5 BLOCKING 1: ASCII guard 5 신규 + 기존 7).

### Task 3: `compute_owned_hash` + `compute_camera_direction_hash` + `compute_t2i_prompt_hash` (round 5 BLOCKING 3)

**Files:**
- Modify: `backend/app/core/steps/_owned_helpers.py`
- Modify: `backend/tests/unit/test_owned_helpers.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# 추가 — backend/tests/unit/test_owned_helpers.py
from app.core.steps._owned_helpers import (
    compute_owned_hash,
    compute_camera_direction_hash,
)


class TestComputeOwnedHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_owned_hash(["door", "window"])
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_deterministic_same_input(self) -> None:
        h1 = compute_owned_hash(["door", "window"])
        h2 = compute_owned_hash(["door", "window"])
        assert h1 == h2

    def test_order_independent_via_sort(self) -> None:
        # input sort 가 normalize 책임 — 단 helper 자체는 받은 list 그대로 hash.
        # 따라서 caller 가 normalize_owned_list 통과한 list 를 넘긴다고 가정.
        h_sorted = compute_owned_hash(["TV", "door", "window"])
        # unsorted 입력은 다른 hash — caller 책임 명시.
        h_unsorted = compute_owned_hash(["window", "door", "TV"])
        assert h_sorted != h_unsorted

    def test_empty_list(self) -> None:
        h = compute_owned_hash([])
        assert isinstance(h, str)
        assert len(h) == 16


class TestComputeCameraDirectionHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_camera_direction_hash("eye-level wide shot of doorway")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_empty_string(self) -> None:
        h = compute_camera_direction_hash("")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_different_text_different_hash(self) -> None:
        h1 = compute_camera_direction_hash("eye-level wide shot")
        h2 = compute_camera_direction_hash("low angle close-up")
        assert h1 != h2


# round 5 BLOCKING 3: t2i_prompt drift detection.
from app.core.steps._owned_helpers import compute_t2i_prompt_hash


class TestComputeT2iPromptHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_t2i_prompt_hash("A figure stands by the door.")
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_empty_string(self) -> None:
        # variation["t2i_prompt"] or "" — None / 빈 문자열 모두 deterministic hash.
        h = compute_t2i_prompt_hash("")
        assert isinstance(h, str)
        assert len(h) == 16

    def test_exact_string_match(self) -> None:
        # whitespace / case / punctuation 변화 모두 다른 hash → drift 검출 보장.
        h1 = compute_t2i_prompt_hash("A figure stands by the door.")
        h2 = compute_t2i_prompt_hash("A figure stands by the door")  # 마지막 . 차이
        h3 = compute_t2i_prompt_hash("a figure stands by the door.")  # 첫 글자 case
        assert h1 != h2
        assert h1 != h3
        assert h2 != h3

    def test_deterministic(self) -> None:
        h1 = compute_t2i_prompt_hash("identical prompt")
        h2 = compute_t2i_prompt_hash("identical prompt")
        assert h1 == h2
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestComputeOwnedHash tests/unit/test_owned_helpers.py::TestComputeCameraDirectionHash tests/unit/test_owned_helpers.py::TestComputeT2iPromptHash -v
```
Expected: 11 FAIL (round 5 BLOCKING 3: TestComputeT2iPromptHash 4 신규 추가).

- [ ] **Step 3: 구현**

```python
def compute_owned_hash(owned: List[str]) -> str:
    """sorted+joined sha256[:16]. caller 가 normalize 통과한 list 를 넘긴다고 가정.

    빈 list 도 결정론적 hash — sentinel hash 가 항상 존재 보장.
    """
    payload = "|".join(owned).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()[:16]


def compute_camera_direction_hash(camera_direction: str) -> str:
    """camera_direction 텍스트 sha256[:16]. close framing 판정의 input 이라
    text 변경 시 sentinel drift 감지에 사용.
    """
    payload = (camera_direction or "").encode("utf-8")
    return hashlib.sha256(payload).hexdigest()[:16]


def compute_t2i_prompt_hash(t2i_prompt: str) -> str:
    """variation['t2i_prompt'] or "" exact string 의 sha256[:16] (round 5 BLOCKING 3).

    t2i_review 가 t2i_prompt 를 수정 후 sentinel 미갱신 하면 verify_completion 의
    drift 검증이 stale 로 partial 마킹. case / whitespace / punctuation 변화 모두
    다른 hash → drift 검출 보장.
    """
    payload = (t2i_prompt or "").encode("utf-8")
    return hashlib.sha256(payload).hexdigest()[:16]
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py -v
```
Expected: 23 PASS (12 normalize + 11 hash).

### Task 4: `build_owned_sentinel` + `assert_owned_sentinel_shape`

**Files:**
- Modify: `backend/app/core/steps/_owned_helpers.py`
- Modify: `backend/tests/unit/test_owned_helpers.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# 추가 — backend/tests/unit/test_owned_helpers.py
from app.core.steps._owned_helpers import (
    build_owned_sentinel,
    assert_owned_sentinel_shape,
)


class TestBuildOwnedSentinel:
    def test_full_sentinel_no_violations(self) -> None:
        # round 5 BLOCKING 3: t2i_prompt 인자 필수.
        s = build_owned_sentinel(
            owned=["door", "window"],
            camera_direction="eye-level wide shot",
            t2i_prompt="A figure stands by the door.",
            is_close_framing=False,
            violations=[],
        )
        assert s["schema_version"] == OWNED_SENTINEL_SCHEMA_VERSION
        assert s["validator"] == OWNED_VALIDATOR_FULL
        assert s["violations"] == []
        assert len(s["owned_hash"]) == 16
        assert len(s["camera_direction_hash"]) == 16
        assert len(s["t2i_prompt_hash"]) == 16

    def test_full_sentinel_with_violations(self) -> None:
        viols = [
            {"owned_object": "TV", "violating_phrase": "a TV in the corner",
             "reason": "redraw without anchor"}
        ]
        s = build_owned_sentinel(
            owned=["TV"], camera_direction="medium shot",
            t2i_prompt="A TV in the corner.",
            is_close_framing=False, violations=viols,
        )
        assert s["violations"] == viols

    def test_close_skip_sentinel_includes_all_hashes(self) -> None:
        # round 3 #4 + round 5 BLOCKING 3: close skip 도 모든 hash 포함.
        s = build_owned_sentinel(
            owned=["door"], camera_direction="extreme close-up",
            t2i_prompt="Focus on a hand.",
            is_close_framing=True, violations=[],
        )
        assert s["validator"] == OWNED_VALIDATOR_CLOSE_SKIP
        assert s["violations"] == []
        assert len(s["owned_hash"]) == 16
        assert len(s["camera_direction_hash"]) == 16
        assert len(s["t2i_prompt_hash"]) == 16

    def test_t2i_prompt_change_changes_hash(self) -> None:
        # round 5 BLOCKING 3: t2i_review 가 prompt 수정해도 caller 가
        # 새 hash 로 sentinel 재생성하면 drift 가 안 잡혀야 정상. 단 caller 가
        # sentinel 재생성을 안 하고 옛 sentinel 보존하면 verify 가 drift 잡음.
        s1 = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="Original prompt.",
            is_close_framing=False, violations=[],
        )
        s2 = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="Modified prompt.",
            is_close_framing=False, violations=[],
        )
        assert s1["t2i_prompt_hash"] != s2["t2i_prompt_hash"]


class TestAssertOwnedSentinelShape:
    def test_valid_full(self) -> None:
        s = build_owned_sentinel(
            owned=["door"], camera_direction="wide",
            t2i_prompt="prompt", is_close_framing=False, violations=[],
        )
        # noop 통과
        assert_owned_sentinel_shape(s)

    def test_valid_close_skip(self) -> None:
        s = build_owned_sentinel(
            owned=[], camera_direction="ECU",
            t2i_prompt="prompt", is_close_framing=True, violations=[],
        )
        assert_owned_sentinel_shape(s)

    def test_missing_field_raises(self) -> None:
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape({"schema_version": 1})
        assert exc_info.value.code == "step.contract_violation"

    def test_invalid_validator_raises(self) -> None:
        # round 7 MINOR 4: bad fixture 에 t2i_prompt_hash 포함 — required field
        # 모두 갖춘 상태에서 validator branch 만 트리거. shadow 차단.
        from app.core.errors import AppError
        bad = {
            "schema_version": 1,
            "t2i_prompt_hash": "abc",
            "owned_hash": "abc",
            "camera_direction_hash": "def",
            "validator": "unknown.v0",
            "violations": [],
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "validator" in exc_info.value.message  # branch 정확히 타는지

    def test_violations_must_be_list(self) -> None:
        # round 7 MINOR 4: bad fixture 에 t2i_prompt_hash 포함.
        from app.core.errors import AppError
        bad = {
            "schema_version": 1,
            "t2i_prompt_hash": "abc",
            "owned_hash": "abc",
            "camera_direction_hash": "def",
            "validator": OWNED_VALIDATOR_FULL,
            "violations": "not a list",
        }
        with pytest.raises(AppError) as exc_info:
            assert_owned_sentinel_shape(bad)
        assert exc_info.value.code == "step.contract_violation"
        assert "violations" in exc_info.value.message
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestBuildOwnedSentinel tests/unit/test_owned_helpers.py::TestAssertOwnedSentinelShape -v
```
Expected: 9 FAIL with `NotImplementedError` (round 5 BLOCKING 3:
test_t2i_prompt_change_changes_hash 1 추가).

- [ ] **Step 3: 구현**

```python
_REQUIRED_SENTINEL_FIELDS = (
    "schema_version", "t2i_prompt_hash", "owned_hash", "camera_direction_hash",
    "validator", "violations",
)
_VALID_VALIDATORS = (OWNED_VALIDATOR_FULL, OWNED_VALIDATOR_CLOSE_SKIP)


def build_owned_sentinel(
    *,
    owned: List[str],
    camera_direction: str,
    t2i_prompt: str,
    is_close_framing: bool,
    violations: List[Dict[str, str]],
) -> Dict[str, Any]:
    """post-parse 가 cp 에 삽입할 sentinel 생성.

    close framing 도 모든 hash 포함 (round 3 #4 + round 5 BLOCKING 3).
    drift 잡기 위함:
    - t2i_prompt 변경 → t2i_prompt_hash drift (t2i_review 수정 검출).
    - owned 변경 → owned_hash drift.
    - camera_direction 변경 → camera_direction_hash drift.
    """
    return {
        "schema_version": OWNED_SENTINEL_SCHEMA_VERSION,
        "t2i_prompt_hash": compute_t2i_prompt_hash(t2i_prompt),
        "owned_hash": compute_owned_hash(owned),
        "camera_direction_hash": compute_camera_direction_hash(camera_direction),
        "validator": (
            OWNED_VALIDATOR_CLOSE_SKIP if is_close_framing else OWNED_VALIDATOR_FULL
        ),
        "violations": list(violations or []),
    }


def assert_owned_sentinel_shape(sentinel: Dict[str, Any], where: str = "") -> None:
    """sentinel field/type/enum 검증. 위반 시 AppError(step.contract_violation).

    post-parse 직후 + verify_completion 둘 다에서 호출 — silent fallback 차단.
    """
    if not isinstance(sentinel, dict):
        raise AppError(
            code="step.contract_violation",
            message=f"owned_validation must be dict (got {type(sentinel).__name__}) {where}",
        )
    missing = [f for f in _REQUIRED_SENTINEL_FIELDS if f not in sentinel]
    if missing:
        raise AppError(
            code="step.contract_violation",
            message=f"owned_validation missing fields {missing} {where}",
        )
    if sentinel["validator"] not in _VALID_VALIDATORS:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"owned_validation.validator {sentinel['validator']!r} "
                f"not in {_VALID_VALIDATORS} {where}"
            ),
        )
    if not isinstance(sentinel["violations"], list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"owned_validation.violations must be list "
                f"(got {type(sentinel['violations']).__name__}) {where}"
            ),
        )
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py -v
```
Expected: 32 PASS (12 normalize + 11 hash + 9 sentinel build/shape).

### Task 5: `assert_background_prompt_owned_contract` — fail-fast 가드

**Files:**
- Modify: `backend/app/core/steps/_owned_helpers.py`
- Modify: `backend/tests/unit/test_owned_helpers.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# 추가 — backend/tests/unit/test_owned_helpers.py
from app.core.steps._owned_helpers import assert_background_prompt_owned_contract
from app.core.errors import AppError


class TestAssertBgPromptOwnedContract:
    def test_bg_mode_off_allows_none(self) -> None:
        # bg off 면 cp 부재 정상.
        assert_background_prompt_owned_contract(
            None, background_mode_on=False, where="t",
        )

    def test_bg_mode_on_none_cp_blocks(self) -> None:
        # round 4 BLOCKING 1: bg-on AND cp 부재 = silent fallback 차단.
        # step_runner gate 가 cp 존재 안 보므로 helper 책임.
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                None, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "missing" in exc_info.value.message.lower() or "None" in exc_info.value.message

    def test_bg_mode_on_old_v4_cp_blocks(self) -> None:
        old_cp = {
            "schema_version": 1,
            "data": {"backgrounds": {"bg1": {"status": "ok"}}},
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                old_cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message

    def test_bg_mode_on_v5_cp_with_owned_passes(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door", "window"],
                    },
                },
            },
        }
        assert_background_prompt_owned_contract(
            cp, background_mode_on=True, where="loader",
        )

    def test_bg_mode_on_v5_cp_partial_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door"],
                    },
                    "bg2": {"status": "ok"},  # owned 누락
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "bg2" in exc_info.value.message

    def test_bg_mode_on_v5_cp_failed_status_skipped(self) -> None:
        # status != ok 인 entry 는 owned 없어도 OK.
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door"],
                    },
                    "bg_failed": {"status": "failed"},  # status 가 ok 아님 → 통과
                },
            },
        }
        assert_background_prompt_owned_contract(
            cp, background_mode_on=True, where="loader",
        )

    def test_empty_owned_list_blocks(self) -> None:
        # background_prompt schema 가 minItems=1 강제. ok 인데 owned 빈 list 는
        # 강제 위반 (LLM 이 schema 우회한 케이스).
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {"status": "ok", "objects_owned_by_background": []},
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"

    # round 7 BLOCKING 1: 수동 편집 / 부분 산출 cp 가 한국어 owned 들고 있을 때
    # loader 경계에서 silent drop 되는 path 차단.
    def test_korean_only_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["문"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_mixed_korean_english_owned_blocks(self) -> None:
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["door", "문"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "non-ASCII" in exc_info.value.message

    def test_owned_all_whitespace_blocks_via_normalize(self) -> None:
        # normalize 후 빈 list → block (silent {} 차단).
        cp = {
            "schema_version": 2,
            "data": {
                "backgrounds": {
                    "bg1": {
                        "status": "ok",
                        "objects_owned_by_background": ["  ", "\t"],
                    },
                },
            },
        }
        with pytest.raises(AppError) as exc_info:
            assert_background_prompt_owned_contract(
                cp, background_mode_on=True, where="loader",
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "empty after normalize" in exc_info.value.message
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestAssertBgPromptOwnedContract -v
```
Expected: 10 FAIL (round 7 BLOCKING 1: 한국어/혼합/whitespace 3 신규).

- [ ] **Step 3: 구현**

```python
def assert_background_prompt_owned_contract(
    bp_cp: Dict[str, Any] | None,
    *,
    background_mode_on: bool,
    where: str = "",
) -> None:
    """Spec 8.7 critical gap (round 4 BLOCKING 1 강화 + round 7 BLOCKING 1) —
    옛 v4 / cp 부재 / partial v5 cp / 수동 편집 silent drop fail-fast.

    허용 path:
    - bg off → cp None / 옛 cp 모두 통과 (caller 가 빈 dict 리턴).
    - bg on + cp schema=2 + 모든 ok background entry 에 owned ASCII 1+ entries
      (normalize 후) → OK.

    차단 path (모두 raise):
    - bg on + cp None → block (round 4 BLOCKING 1 — silent {} 통과 차단).
    - bg on + cp schema<2 → block (옛 v4 cp 잔존).
    - bg on + cp ok 인데 어느 ok background entry 라도 owned 부재/빈 → block.
    - bg on + cp ok 인데 owned entry 에 non-ASCII 포함 → block (round 7 BLOCKING 1
      — 수동 편집 / 부분 산출 stale ["문"] silent drop 차단).
    - bg on + cp ok 인데 normalize 후 owned 가 빈 list → block (entry 가 모두
      whitespace 등으로 silent drop 되는 케이스 차단).
    """
    if not background_mode_on:
        return
    if bp_cp is None:
        # round 4 BLOCKING 1: bg-on + None 도 fail-fast.
        raise AppError(
            code="step.contract_violation",
            message=(
                f"{where}: background_mode is on but background_prompt cp is "
                "missing. force background_prompt 먼저 실행 필요."
            ),
        )
    schema_v = bp_cp.get("schema_version") or 0
    if schema_v < 2:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"{where}: background_prompt cp schema_version={schema_v} (<2) — "
                f"옛 v4 cp 잔존. force background_prompt 먼저 실행 필요."
            ),
        )
    backgrounds = (bp_cp.get("data", {}) or {}).get("backgrounds", {}) or {}
    for bid, entry in backgrounds.items():
        if not isinstance(entry, dict):
            continue
        if entry.get("status") != "ok":
            continue
        owned = entry.get("objects_owned_by_background")
        if not owned or not isinstance(owned, list):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"{where}: background_prompt[{bid}] missing or empty "
                    "objects_owned_by_background — partial v5 cp."
                ),
            )
        # round 7 BLOCKING 1: raw item ASCII 재검증 — 수동 편집 / 부분 산출
        # ["문"] silent drop 차단.
        for idx, item in enumerate(owned):
            if not isinstance(item, str):
                continue
            try:
                item.encode("ascii")
            except UnicodeEncodeError:
                raise AppError(
                    code="step.contract_violation",
                    message=(
                        f"{where}: background_prompt[{bid}].objects_owned_by_"
                        f"background[{idx}] {item!r} contains non-ASCII. owned "
                        "MUST be English canonical common nouns "
                        "(round 4 Q2=B / round 7 BLOCKING 1)."
                    ),
                )
        # round 7 BLOCKING 1: normalize 후 non-empty 검증 — entry 가 모두
        # whitespace / non-string 으로 silent drop 되는 케이스 차단.
        normalized = normalize_owned_list(owned)
        if not normalized:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"{where}: background_prompt[{bid}].objects_owned_by_"
                    "background empty after normalize — silent drop 차단."
                ),
            )
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py -v
```
Expected: 42 PASS (32 + 10 contract assert).

---

## Phase 2 — Producer (background_prompt v5)

### Task 6: v5 prompt 디렉토리 + schema/system/user_template 작성

**Files:**
- Create: `prompts/_base/background_prompt/5.202605032354/system.md`
- Create: `prompts/_base/background_prompt/5.202605032354/schema.json`
- Create: `prompts/_base/background_prompt/5.202605032354/user_template.md`

- [ ] **Step 1: v4 디렉토리 복사 (loader drift 차단 — round 3 #8)**

Run:
```bash
cp -r /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/background_prompt/4.202604301033 /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/background_prompt/5.202605032354
```

- [ ] **Step 2: schema.json 갱신 — `objects_owned_by_background` 추가**

Edit `prompts/_base/background_prompt/5.202605032354/schema.json` 전체 content 를 아래로 교체:

```json
{
  "type": "object",
  "properties": {
    "bg_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_]*$"},
    "t2i_prompt": {"type": "string", "minLength": 50},
    "ref_guide": {"type": "string"},
    "shot_guides": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "shot_id": {"type": "string"},
          "guide_text": {"type": "string"}
        },
        "required": ["shot_id", "guide_text"],
        "additionalProperties": false
      }
    },
    "objects_owned_by_background": {
      "type": "array",
      "minItems": 1,
      "items": {"type": "string", "minLength": 1, "maxLength": 80},
      "description": "PNG 에 그려진 환경 객체. **English canonical common nouns** (round 4 Q2=B — 한국어/일본어 시나리오에서도 영어 고정). 위치/형용사/상태 미포함. 가능한 singular form. acronym 자연 표기 (TV, AC) 허용. 예: ['door', 'window', 'TV', 'wardrobe']. scene_detail 이 redraw 하지 않도록 contract."
    }
  },
  "required": ["bg_id", "t2i_prompt", "ref_guide", "shot_guides", "objects_owned_by_background"],
  "additionalProperties": false
}
```

- [ ] **Step 3: system.md 끝부분에 Rule 12 추가**

Edit `prompts/_base/background_prompt/5.202605032354/system.md` — 파일 끝 (Rule 11 다음 줄) 에 아래 블록 append:

```markdown
12. **objects_owned_by_background** — t2i_prompt 에서 묘사한 환경 객체(문/창/가구/큰 prop)를 list 로 enumerate 한다. 이 list 는 scene_detail 이 같은 객체를 다시 그리지 않도록 contract 역할을 한다. **items MUST be English canonical common nouns** even when t2i_prompt body language is Korean/Japanese/etc (round 4 Q2=B). 위치/형용사/상태 미포함 — 객체 이름만. 가능한 singular form. acronym 자연 표기 (TV, AC) 허용. 인물·소품 캐릭터화 (의상·표정 등)는 포함하지 마라 (배경 객체만). 1 개 이상 필수. 예: `["door", "window", "TV", "wardrobe"]` / `["counter", "shelves", "lamp"]`. 한국어 시나리오에서도 `["문", "창문"]` 금지 — 항상 영어로.
```

- [ ] **Step 4: user_template.md 는 v4 그대로 — 변경 없음**

검증: `prompts/_base/background_prompt/5.202605032354/user_template.md` 가 v4 와 동일한지 diff 확인.

```bash
diff /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/background_prompt/4.202604301033/user_template.md /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/background_prompt/5.202605032354/user_template.md
```
Expected: empty diff

### Task 7: `validate_bg_prompt_output` — owned 검증 + normalize

**Files:**
- Modify: `backend/app/modules/pipeline/background_prompt.py`
- Create: `backend/tests/unit/test_background_prompt_owned_schema.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_background_prompt_owned_schema.py
"""G3.2 background_prompt v5 schema + normalize tests."""
from __future__ import annotations

import pytest

from app.modules.pipeline.background_prompt import (
    BackgroundPromptError,
    validate_bg_prompt_output,
)


def _base_output(**overrides):
    o = {
        "bg_id": "bg1",
        "t2i_prompt": "x" * 60,
        "ref_guide": "guide",
        "shot_guides": [{"shot_id": "S1_Shot1", "guide_text": "g"}],
        "objects_owned_by_background": ["door", "window"],
    }
    o.update(overrides)
    return o


class TestValidateBgPromptOwnedField:
    def test_valid_passes(self) -> None:
        out = _base_output()
        validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})
        # 성공 = exception 없음. 또한 normalize 가 in-place 적용:
        assert out["objects_owned_by_background"] == ["door", "window"]

    def test_normalizes_in_place_strip_dedupe_sort(self) -> None:
        out = _base_output(objects_owned_by_background=["  window ", "door", "door", "window"])
        validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})
        assert out["objects_owned_by_background"] == ["door", "window"]

    def test_missing_field_fails(self) -> None:
        out = _base_output()
        del out["objects_owned_by_background"]
        with pytest.raises(ValueError, match="objects_owned_by_background"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_empty_list_fails(self) -> None:
        out = _base_output(objects_owned_by_background=[])
        with pytest.raises(ValueError, match="objects_owned_by_background"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_normalize_drops_all_blank_then_fails(self) -> None:
        out = _base_output(objects_owned_by_background=["", "  ", None])
        with pytest.raises(ValueError, match="objects_owned_by_background"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_non_list_fails(self) -> None:
        out = _base_output(objects_owned_by_background="not a list")
        with pytest.raises(ValueError, match="objects_owned_by_background"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    # round 5 BLOCKING 1 + round 6 BLOCKING 3: 한국어 시나리오에서도 owned 영어
    # canonical 강제. raw list 에 non-ASCII entry 가 하나라도 있으면 raise.
    def test_korean_mixed_owned_raises_not_silent_drop(self) -> None:
        # round 6 BLOCKING 3: 한국어 entry 가 섞이면 silent drop 금지 → raise.
        out = _base_output(objects_owned_by_background=["문", "창문", "TV"])
        with pytest.raises(ValueError, match="non-ASCII"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_all_korean_owned_fails(self) -> None:
        out = _base_output(objects_owned_by_background=["문", "창문"])
        with pytest.raises(ValueError, match="non-ASCII"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_japanese_kana_owned_fails(self) -> None:
        out = _base_output(objects_owned_by_background=["ドア", "テーブル"])
        with pytest.raises(ValueError, match="non-ASCII"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_cjk_ideograph_owned_fails(self) -> None:
        out = _base_output(objects_owned_by_background=["门", "桌子"])
        with pytest.raises(ValueError, match="non-ASCII"):
            validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})

    def test_pure_ascii_passes(self) -> None:
        # 정상 path: 모두 영어.
        out = _base_output(objects_owned_by_background=["TV", "door", "window"])
        validate_bg_prompt_output(out, "bg1", {"S1_Shot1"})
        assert out["objects_owned_by_background"] == ["TV", "door", "window"]
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_background_prompt_owned_schema.py -v
```
Expected: 11 FAIL (round 5 BLOCKING 1 + round 6 BLOCKING 3: 4 한국어/CJK reject 추가 + 1 pure ASCII pass).

- [ ] **Step 3: `validate_bg_prompt_output` 갱신 — owned 검증 + normalize**

`backend/app/modules/pipeline/background_prompt.py` 의 `validate_bg_prompt_output()` 함수를 아래로 교체:

```python
def validate_bg_prompt_output(
    output: Dict[str, Any],
    expected_bg_id: str,
    applies_to_shots: Set[str],
) -> None:
    """Phase 8 v2 + G3.2 invariants:
    1. ``bg_id`` matches expected (식별자 ASCII는 schema pattern에서 강제).
    2. ``t2i_prompt`` >= 50 chars (본문 ASCII guard 제거 — source-language 허용).
    3. ``shot_guides`` covers every applies_to_shots entry.
    4. (G3.2) ``objects_owned_by_background`` 는 list, normalize 후 1+ entries.
       in-place normalize (strip/dedupe/sort/maxLen=80) — 단순 검증 아닌 저장
       직전 정규화 책임.
    """
    from app.core.steps._owned_helpers import normalize_owned_list

    if output.get("bg_id") != expected_bg_id:
        raise ValueError(
            f"bg_id mismatch: got {output.get('bg_id')!r}, expected {expected_bg_id!r}"
        )
    t2i = output.get("t2i_prompt") or ""
    if len(t2i) < 50:
        raise ValueError(
            f"background_prompt {expected_bg_id} t2i too short ({len(t2i)})"
        )
    guides = output.get("shot_guides") or []
    guide_shot_ids = {g.get("shot_id") for g in guides if isinstance(g, dict)}
    missing = applies_to_shots - guide_shot_ids
    if missing:
        raise ValueError(
            f"background_prompt {expected_bg_id} missing shot_guides for: {sorted(missing)}"
        )
    # G3.2: objects_owned_by_background 검증 + normalize
    raw_owned = output.get("objects_owned_by_background")
    if not isinstance(raw_owned, list):
        raise ValueError(
            f"background_prompt {expected_bg_id} objects_owned_by_background "
            f"must be list (got {type(raw_owned).__name__})"
        )
    # round 6 BLOCKING 3: raw list 에 non-ASCII entry 가 하나라도 있으면 raise.
    # silent drop 금지 — 한국어 owned 가 silent 로 축소되면 contract 가 약해짐.
    # validate 단계에서 fail-fast 한 후, 모든 entry 가 ASCII 라야 normalize 진입.
    for idx, item in enumerate(raw_owned):
        if not isinstance(item, str):
            continue  # normalize 가 처리 (non-string drop)
        try:
            item.encode("ascii")
        except UnicodeEncodeError:
            raise ValueError(
                f"background_prompt {expected_bg_id} objects_owned_by_background "
                f"entry [{idx}] {item!r} contains non-ASCII (Hangul/CJK/Kana). "
                "owned MUST be English canonical common nouns (round 4 Q2=B / "
                "round 6 BLOCKING 3)."
            )
    normalized = normalize_owned_list(raw_owned)
    if not normalized:
        raise ValueError(
            f"background_prompt {expected_bg_id} objects_owned_by_background "
            "empty after normalize (must have 1+ entries)"
        )
    output["objects_owned_by_background"] = normalized  # in-place 저장 직전 정규화
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_background_prompt_owned_schema.py -v
```
Expected: 11 PASS (6 기본 + 4 한국어/CJK reject + 1 pure ASCII pass).

### Task 7.5: `BackgroundPromptStep._execute()` cp entry build 에 owned 추가 (round 4 critical missed)

**Files:**
- Modify: `backend/app/core/steps/background_prompt_step.py:292-299` (`_process` 의 `return bid, {...}`)

LLM 출력의 `objects_owned_by_background` 가 cp entry 에 carry 되지 않으면 loader 가
못 읽음. plan 의 SCHEMA bump (Task 8) 만으로는 부족 — 결과 dict 빌드 자체에 추가
필요.

- [ ] **Step 1: `_process()` 의 result dict (status="ok" 분기) 갱신**

Edit `backend/app/core/steps/background_prompt_step.py` line 292-299 — `out` 에서
`objects_owned_by_background` 도 result 에 포함:

```python
                return bid, {
                    "status": "ok",
                    "t2i_prompt": out["t2i_prompt"],
                    "ref_guide": out.get("ref_guide", ""),
                    "shot_guides": out.get("shot_guides", []),
                    "objects_owned_by_background": out["objects_owned_by_background"],
                    "spec": spec,
                    "group_id": job["group_id"],
                }
```

`out["objects_owned_by_background"]` 는 Task 7 의 `validate_bg_prompt_output()` 가
in-place normalize 했으므로 strip/dedupe/sort 통과한 list.

- [ ] **Step 2: 검증 — 직전 step run 모킹 후 cp manifest 에 field 존재**

새 unit test `backend/tests/unit/test_background_prompt_step_owned_carry.py`:

```python
"""G3.2 round 4 critical missed: bg_prompt_step cp entry build 에 owned 포함."""
from __future__ import annotations

from unittest.mock import patch, MagicMock


def test_run_background_prompt_returns_owned_in_result() -> None:
    """run_background_prompt 가 owned 를 반환 → step 이 cp 에 보존하는지."""
    from app.modules.pipeline.background_prompt import run_background_prompt

    fake_call = MagicMock(return_value={
        "bg_id": "bg1",
        "t2i_prompt": "x" * 60,
        "ref_guide": "guide",
        "shot_guides": [{"shot_id": "S1_Shot1", "guide_text": "g"}],
        "objects_owned_by_background": ["door", "window"],
    })
    out = run_background_prompt(
        user_prompt="up",
        expected_bg_id="bg1",
        applies_to_shots=["S1_Shot1"],
        call_structured_fn=fake_call,
        project_config=None,
    )
    assert out["objects_owned_by_background"] == ["door", "window"]


def test_step_process_carries_owned_to_cp_entry() -> None:
    """_process() result dict 에 owned key 가 들어가는지 — manual code review.

    실제 _process 는 closure 에 의존해 단위 테스트 어려움. 대신 line 292-299
    range 의 dict literal 에 'objects_owned_by_background' 가 포함됐는지 grep
    으로 회귀 가드.
    """
    import inspect
    from app.core.steps.background_prompt_step import BackgroundPromptStep
    src = inspect.getsource(BackgroundPromptStep)
    assert '"objects_owned_by_background"' in src, (
        "BackgroundPromptStep._process() result dict 에 owned key 누락"
    )
```

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_background_prompt_step_owned_carry.py -v
```
Expected: 2 PASS.

### Task 8: `BackgroundPromptStep` SCHEMA_VERSION + PROMPT_VERSION bump

**Files:**
- Modify: `backend/app/core/steps/background_prompt_step.py:28-32`

- [ ] **Step 1: 상수 갱신**

Edit `backend/app/core/steps/background_prompt_step.py` line 28-32:

```python
SCHEMA_VERSION = 2  # G3.2: objects_owned_by_background 추가.
# G3.2: v5 prompt — Rule 12 (objects_owned_by_background) 추가. config_hash 가
# invalidate 되어 기존 v4 cp 자동 stale. user 가 force 실행 시점에 v5 LLM 호출.
PROMPT_VERSION = "5.202605032354"
```

- [ ] **Step 2: prompt loader 가 새 버전 picking 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.modules.prompt_loader import load_prompt, load_schema
sys = load_prompt('background_prompt', 'system')
sch = load_schema('background_prompt', 'schema')
assert 'objects_owned_by_background' in sch.get('properties', {}), 'schema 미반영'
assert 'objects_owned_by_background' in sys, 'system.md Rule 12 미반영'
print('OK — v5 picked')
"
```
Expected: `OK — v5 picked`

---

## Phase 3 — Judge (prompt + extension registration)

### Task 9: judge prompt 디렉토리 + schema/system/user_template 작성

**Files:**
- Create: `prompts/_base/scene_detail_owned_judge/1.202605032354/system.md`
- Create: `prompts/_base/scene_detail_owned_judge/1.202605032354/user_template.md`
- Create: `prompts/_base/scene_detail_owned_judge/1.202605032354/schema.json`

- [ ] **Step 1: 디렉토리 생성**

Run:
```bash
mkdir -p /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail_owned_judge/1.202605032354
```

- [ ] **Step 2: `system.md` 작성**

```markdown
# scene_detail owned-objects judge — system

scene_detail 이 작성한 t2i_prompt 가 chain_bg 배경 PNG 에 이미 그려진 환경 객체 (`objects_owned_by_background`) 를 **새로 그리도록** 지시했는지 판정한다.

## 입력
- `t2i_prompt`: scene_detail 이 작성한 영어 또는 source-language T2I 프롬프트.
- `owned_list`: 보통명사 환경 객체 list (예: `["door", "window", "TV"]`).
- `camera_direction`: shot 의 자연어 카메라 정보.

## 출력
violations 배열. 정상이면 빈 배열.

## 판정 기준 (의미 기반)

### 위반 (violation)
프롬프트가 owned_list 의 객체를 **새로 생성/추가/배치** 하라고 지시. 패턴 예시:
- `create / render / add / draw / place / generate / place a new <owned>` 류.
- 회피 표현 (`portal` for `door`, `screen` for `TV`) 도 의미상 redraw 면 위반.
- 같은 객체에 새 디테일 추가 (`a TV displaying a news bulletin in the corner`) — anchor 사용 없이 상세 묘사 = 새로 생성 의도로 해석.

### 허용 (non-violation)
- anchor 참조: `near the doorway`, `beside the table`, `against the wall by the window`.
- reference 명시: `from the reference`, `from chain_bg`, `the X already in the room`, `the existing Y`.
- close-up 명시 (이 경우 caller 가 judge 호출 자체를 skip 하지만, 안전망):
  `the door from the reference, in tight close-up`.

## 출력 형식
violations 각 항목:
- `owned_object`: 위반 대상 owned name (string, owned_list 의 항목 그대로).
- `violating_phrase`: t2i_prompt 에서 위반으로 판정한 구절 (string, 짧게 발췌).
- `reason`: 왜 위반인지 1 문장 (string, 의미 근거).

owned_list 항목 중 t2i_prompt 에 등장하지 않는 항목은 violations 에 넣지 마라.
정상 anchor 참조는 violations 에 넣지 마라.

## 시나리오 의존성 0
owned_list 항목과 t2i_prompt 본문 외 어떤 가정도 하지 마라. region/era/장르 가정 금지.
```

- [ ] **Step 3: `user_template.md` 작성**

```markdown
## t2i_prompt
{t2i_prompt}

## owned_list (chain_bg 가 이미 그린 객체 — 새로 그리지 말 것)
{owned_list_block}

## camera_direction
{camera_direction}
```

- [ ] **Step 4: `schema.json` 작성**

```json
{
  "type": "object",
  "properties": {
    "violations": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "owned_object": {"type": "string", "minLength": 1},
          "violating_phrase": {"type": "string", "minLength": 1},
          "reason": {"type": "string", "minLength": 1}
        },
        "required": ["owned_object", "violating_phrase", "reason"],
        "additionalProperties": false
      }
    }
  },
  "required": ["violations"],
  "additionalProperties": false
}
```

### Task 10: `_PIPELINE_STEP_EXTENSIONS` 에 `scene_detail_owned_judge` 등록

**Files:**
- Modify: `backend/app/modules/llm/llm_client.py:278-297`
- Create: `backend/tests/unit/test_g3_2_judge_step_extension.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_g3_2_judge_step_extension.py
"""G3.2 judge step extension registration tests."""
from __future__ import annotations

from app.modules.llm.llm_client import (
    PIPELINE_STEPS,
    _PIPELINE_STEP_EXTENSIONS,
    _resolve_model,
)


def test_judge_step_in_extensions() -> None:
    assert "scene_detail_owned_judge" in _PIPELINE_STEP_EXTENSIONS


def test_judge_default_model_is_gpt_mini() -> None:
    info = _PIPELINE_STEP_EXTENSIONS["scene_detail_owned_judge"]
    assert info["default"] == "gpt-mini"


def test_judge_step_resolves_to_gpt_mini() -> None:
    # _resolve_model 이 unknown step 으로 보지 않고 extension 으로 라우팅.
    model = _resolve_model("scene_detail_owned_judge")
    assert model == "gpt-mini"


def test_judge_step_in_pipeline_steps_view() -> None:
    # PIPELINE_STEPS 가 manifest + extension 합치는지.
    assert "scene_detail_owned_judge" in PIPELINE_STEPS
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_g3_2_judge_step_extension.py -v
```
Expected: 4 FAIL

- [ ] **Step 3: extension 등록**

Edit `backend/app/modules/llm/llm_client.py` — `_PIPELINE_STEP_EXTENSIONS` dict 안의 `# v2 legacy` 블록 직전에 다음 entry 추가 (analysis_sub category 신설):

```python
    # G3.2 judge — scene_detail post-parse owned validation
    "scene_detail_owned_judge":
        {"label": "owned 객체 redraw 검사", "default": "gpt-mini", "category": "analysis_sub"},

    # v2 legacy (기존 코드 호환용 — manifest 제외, runtime 라벨 유지)
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_g3_2_judge_step_extension.py -v
```
Expected: 4 PASS

### Task 11: judge 호출 wrapper 함수 (`run_owned_judge`)

**Files:**
- Modify: `backend/app/core/steps/_owned_helpers.py`
- Modify: `backend/tests/unit/test_owned_helpers.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# 추가 — backend/tests/unit/test_owned_helpers.py
from unittest.mock import MagicMock

from app.core.steps._owned_helpers import run_owned_judge


class TestRunOwnedJudge:
    def test_calls_call_structured_with_correct_step(self) -> None:
        fake_call = MagicMock(return_value={"violations": []})
        result = run_owned_judge(
            t2i_prompt="A figure stands by a wall.",
            owned=["door", "window"],
            camera_direction="eye-level wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        # call_structured 는 step 이름 만 받음 (default_model 인자 X — round 3 #1).
        kwargs = fake_call.call_args.kwargs
        assert kwargs["step"] == "scene_detail_owned_judge"
        # default_model 키가 인자로 전달되지 않는지 검증.
        assert "default_model" not in kwargs
        assert result == []

    def test_returns_violations_list(self) -> None:
        viols = [{"owned_object": "TV", "violating_phrase": "a TV in the corner",
                  "reason": "redraw without anchor"}]
        fake_call = MagicMock(return_value={"violations": viols})
        result = run_owned_judge(
            t2i_prompt="prompt",
            owned=["TV"],
            camera_direction="medium",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        assert result == viols

    def test_empty_owned_returns_empty_no_call(self) -> None:
        # owned 빈 list → judge 호출 자체 skip (불필요한 LLM 비용 절감).
        fake_call = MagicMock()
        result = run_owned_judge(
            t2i_prompt="anything",
            owned=[],
            camera_direction="wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        assert result == []
        fake_call.assert_not_called()
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py::TestRunOwnedJudge -v
```
Expected: 3 FAIL (`run_owned_judge` 미정의)

- [ ] **Step 3: 구현**

Edit `backend/app/core/steps/_owned_helpers.py` — 파일 끝에 추가:

```python
from typing import Callable, Optional


def run_owned_judge(
    *,
    t2i_prompt: str,
    owned: List[str],
    camera_direction: str,
    call_structured_fn: Callable[..., Dict[str, Any]],
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, str]]:
    """post-parse 가 호출하는 judge wrapper.

    `call_structured_fn(step="scene_detail_owned_judge", ...)` 만 호출 — model
    라우팅은 `_PIPELINE_STEP_EXTENSIONS` 가 처리. `default_model` 은
    `call_structured()` 인자가 아님 (round 3 #1).

    owned 가 빈 list 면 LLM 호출 skip → 빈 violations.
    """
    if not owned:
        return []
    from app.modules.prompt_loader import load_prompt, load_schema

    system = load_prompt("scene_detail_owned_judge", "system")
    schema = load_schema("scene_detail_owned_judge", "schema")
    template = load_prompt("scene_detail_owned_judge", "user_template")
    owned_block = "\n".join(f"- {o}" for o in owned) or "(none)"
    user_prompt = (
        template
        .replace("{t2i_prompt}", t2i_prompt or "")
        .replace("{owned_list_block}", owned_block)
        .replace("{camera_direction}", camera_direction or "")
    )
    result = call_structured_fn(
        step="scene_detail_owned_judge",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="scene_detail_owned_judge",
        opik_metadata=opik_metadata,
    )
    return list(result.get("violations") or [])
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_owned_helpers.py -v
```
Expected: 45 PASS (42 + run_owned_judge 3).

---

## Phase 4 — Consumer Loader (scene_context_loader)

### Task 12: `_load_chain_bg_owned_by_shot()` 신설 + ctx 필드 추가

**Files:**
- Modify: `backend/app/core/steps/scene_context_loader.py`
- Create: `backend/tests/unit/test_scene_context_owned_loader.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_scene_context_owned_loader.py
"""G3.2 scene_context_loader._load_chain_bg_owned_by_shot tests."""
from __future__ import annotations

from typing import Any, Dict
from unittest.mock import patch

import pytest

from app.core.steps.scene_context_loader import SceneContextLoader
from app.core.errors import AppError


class _FakeRunner:
    project_id = "p"
    episode_id = "e"
    db = None
    project_config = None

    def __init__(self, cps: Dict[str, Any]) -> None:
        self._cps = cps

    def _load_prev_checkpoint(self, step_id: str):
        return self._cps.get(step_id)

    def build_opik_metadata(self):
        return {}


def _make_loader(cps: Dict[str, Any]) -> SceneContextLoader:
    return SceneContextLoader(_FakeRunner(cps))


@pytest.fixture
def fake_settings_bg_on(monkeypatch):
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_mode", "on", raising=False)


@pytest.fixture
def fake_settings_bg_off(monkeypatch):
    from app.core.config import settings
    monkeypatch.setattr(settings, "background_mode", "off", raising=False)


class TestLoadChainBgOwnedByShot:
    def test_bg_off_returns_empty(self, fake_settings_bg_off) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {"bg1": {"status": "ok",
                "objects_owned_by_background": ["door"]}}},
        }})
        assert loader._load_chain_bg_owned_by_shot() == {}

    def test_bg_on_no_cp_raises(self, fake_settings_bg_on) -> None:
        # round 4 BLOCKING 1: bg-on AND cp 부재 = silent {} 차단 → fail-fast.
        loader = _make_loader({})
        with pytest.raises(AppError) as exc_info:
            loader._load_chain_bg_owned_by_shot()
        assert exc_info.value.code == "step.contract_violation"

    def test_bg_on_old_v4_cp_raises(self, fake_settings_bg_on) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 1,
            "data": {"backgrounds": {"bg1": {"status": "ok"}}},
        }})
        with pytest.raises(AppError) as exc_info:
            loader._load_chain_bg_owned_by_shot()
        assert exc_info.value.code == "step.contract_violation"

    def test_bg_on_partial_owned_raises(self, fake_settings_bg_on) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg1": {"status": "ok", "objects_owned_by_background": ["door"]},
                "bg2": {"status": "ok"},
            }},
        }})
        with pytest.raises(AppError):
            loader._load_chain_bg_owned_by_shot()

    def test_bg_on_v5_cp_maps_via_applies_to_shots(self, fake_settings_bg_on) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg1": {
                    "status": "ok",
                    "objects_owned_by_background": ["window", "door"],
                    "spec": {"applies_to_shots": ["S1_Shot1", "S1_Shot2"]},
                },
            }},
        }})
        result = loader._load_chain_bg_owned_by_shot()
        assert result == {(1, 1): ["door", "window"], (1, 2): ["door", "window"]}

    def test_bg_on_v5_cp_falls_back_to_shot_guides(self, fake_settings_bg_on) -> None:
        # spec.applies_to_shots 부재 → shot_guides[].shot_id fallback (round 3 minor).
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg1": {
                    "status": "ok",
                    "objects_owned_by_background": ["TV"],
                    "shot_guides": [{"shot_id": "S2_Shot3", "guide_text": "g"}],
                },
            }},
        }})
        result = loader._load_chain_bg_owned_by_shot()
        assert result == {(2, 3): ["TV"]}

    def test_skipped_failed_status(self, fake_settings_bg_on) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg_ok": {
                    "status": "ok",
                    "objects_owned_by_background": ["door"],
                    "spec": {"applies_to_shots": ["S1_Shot1"]},
                },
                "bg_failed": {"status": "failed"},
            }},
        }})
        result = loader._load_chain_bg_owned_by_shot()
        assert result == {(1, 1): ["door"]}

    def test_multi_bg_same_shot_union_sorted(self, fake_settings_bg_on) -> None:
        # 두 bg 가 같은 shot 에 적용 → owned 합집합, sorted.
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg1": {
                    "status": "ok",
                    "objects_owned_by_background": ["door"],
                    "spec": {"applies_to_shots": ["S1_Shot1"]},
                },
                "bg2": {
                    "status": "ok",
                    "objects_owned_by_background": ["window", "TV"],
                    "spec": {"applies_to_shots": ["S1_Shot1"]},
                },
            }},
        }})
        result = loader._load_chain_bg_owned_by_shot()
        assert result == {(1, 1): ["TV", "door", "window"]}

    def test_invalid_shot_id_format_skipped(self, fake_settings_bg_on) -> None:
        loader = _make_loader({"background_prompt": {
            "schema_version": 2,
            "data": {"backgrounds": {
                "bg1": {
                    "status": "ok",
                    "objects_owned_by_background": ["door"],
                    "spec": {"applies_to_shots": ["bad_id", "S1_Shot1"]},
                },
            }},
        }})
        result = loader._load_chain_bg_owned_by_shot()
        assert result == {(1, 1): ["door"]}
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_scene_context_owned_loader.py -v
```
Expected: 9 FAIL (메서드 미정의)

- [ ] **Step 3: `SceneContextLoader._load_chain_bg_owned_by_shot()` 추가**

Edit `backend/app/core/steps/scene_context_loader.py` — `_load_chain_bg_camera_meta_by_shot` 직전 (line ~445) 에 새 메서드 추가:

```python
    def _load_chain_bg_owned_by_shot(self) -> Dict[Tuple[int, int], List[str]]:
        """G3.2: background_prompt cp 의 objects_owned_by_background 를
        (scene_index, shot_index) → list[str] 매핑으로 펼친다.

        source = background_prompt cp (Spec 4.1 / round 3 #1 결정).

        shot 매핑 source (round 3 minor):
        - 우선: ``backgrounds[bid].spec.applies_to_shots`` (LLM 입력 그대로).
        - fallback: ``backgrounds[bid].shot_guides[].shot_id`` (LLM 출력).

        fail-fast (Spec 8.7):
        - bg_mode_on + cp schema<2 → AppError("contract_violation").
        - bg_mode_on + ok background entry 의 owned 부재 → AppError.

        허용 path:
        - bg_mode off → {} (caller 진행).
        - bg_mode on + cp ok + 모든 ok bg 에 owned 1+ entries → 정상 매핑.
        (round 5 IMPORTANT 2 정정: bg_mode on + cp 부재 는 fail-fast block — 위
        assert_background_prompt_owned_contract 가 raise. round 4 BLOCKING 1.)

        Multi-bg 가 같은 shot 에 적용되면 owned 합집합, normalize 통과 후 sorted.
        """
        from app.core.config import settings
        from app.core.steps._owned_helpers import (
            assert_background_prompt_owned_contract,
            normalize_owned_list,
        )

        bg_on = settings.background_mode in {"on", "floor_plan_anchored"}
        bp_cp = self.runner._load_prev_checkpoint("background_prompt")
        # fail-fast (옛 v4 / partial v5 cp 차단).
        assert_background_prompt_owned_contract(
            bp_cp, background_mode_on=bg_on,
            where="scene_context_loader._load_chain_bg_owned_by_shot",
        )
        if not bg_on or bp_cp is None:
            return {}

        sid_re = re.compile(r"^S(\d+)_Shot(\d+)$")
        result: Dict[Tuple[int, int], List[str]] = {}
        backgrounds = (bp_cp.get("data", {}) or {}).get("backgrounds", {}) or {}
        for bid, entry in backgrounds.items():
            if not isinstance(entry, dict):
                continue
            if entry.get("status") != "ok":
                continue
            owned = entry.get("objects_owned_by_background") or []
            if not owned:
                continue
            # shot 매핑: spec.applies_to_shots 우선, shot_guides[].shot_id fallback.
            shot_ids: List[str] = []
            spec = entry.get("spec") or {}
            if isinstance(spec, dict):
                shot_ids = list(spec.get("applies_to_shots") or [])
            if not shot_ids:
                guides = entry.get("shot_guides") or []
                shot_ids = [
                    sg.get("shot_id", "") for sg in guides
                    if isinstance(sg, dict)
                ]
            for sid in shot_ids:
                m = sid_re.match(sid or "")
                if not m:
                    logger.warning(
                        "_load_chain_bg_owned_by_shot: invalid shot_id %r in bg %s — skip",
                        sid, bid,
                    )
                    continue
                key = (int(m.group(1)), int(m.group(2)))
                merged = normalize_owned_list(result.get(key, []) + owned)
                result[key] = merged
        if result:
            logger.info(
                "_load_chain_bg_owned_by_shot: %d shots mapped from %d backgrounds",
                len(result), len(backgrounds),
            )
        return result
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_scene_context_owned_loader.py -v
```
Expected: 9 PASS

### Task 13: `SceneAnalysisContext` 에 `chain_bg_owned_by_shot` 필드 추가 + `load_all` 호출 (round 4 IMPORTANT 1 정정)

**Files:**
- Modify: `backend/app/core/dto/scene_analysis.py` (`SceneAnalysisContext` dataclass — round 4 정정: 정확한 위치)
- Modify: `backend/app/core/steps/scene_context_loader.py` (`load_all` 메서드)

**round 4 정정**: dataclass 는 `backend/app/core/dto/scene_analysis.py:93` 에 정의 (line 103 `chain_bg_camera_meta_by_shot` 다음 라인). loader 는 이 dataclass 를 import 만 하고 `load_all()` 에서 인스턴스 생성 + 필드 세팅. plan round 1~3 의 "scene_context_loader.py 의 SceneAnalysisContext dataclass" 표현은 잘못 — 실제 file path 다름.

- [ ] **Step 1: dataclass 위치 확인**

```bash
grep -n "@dataclass\|class SceneAnalysisContext\|chain_bg_camera_meta_by_shot" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/dto/scene_analysis.py
```
Expected: dataclass 가 line 19-20, `chain_bg_camera_meta_by_shot` 가 line 103.

- [ ] **Step 2: dataclass 에 필드 추가**

Edit `backend/app/core/dto/scene_analysis.py` — line 103 (`chain_bg_camera_meta_by_shot` 정의) 다음 라인에 추가:

```python
    chain_bg_owned_by_shot: Dict[Tuple[int, int], List[str]] = field(default_factory=dict)
```

타입 import 가 file 상단에 있는지 확인 — `from typing import Dict, List, Tuple`
이미 있으면 추가 import 불필요.

- [ ] **Step 3: loader 에서 호출 추가**

Edit `backend/app/core/steps/scene_context_loader.py` — `load_all()` 메서드 안
`ctx.chain_bg_camera_meta_by_shot = self._load_chain_bg_camera_meta_by_shot()`
다음 라인에 추가:

```python
        ctx.chain_bg_owned_by_shot = self._load_chain_bg_owned_by_shot()
```

- [ ] **Step 3: import 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.core.steps.scene_context_loader import SceneAnalysisContext
ctx = SceneAnalysisContext()
assert hasattr(ctx, 'chain_bg_owned_by_shot')
assert ctx.chain_bg_owned_by_shot == {}
print('OK')
"
```
Expected: `OK`

---

## Phase 5 — scene_detail v15 (close 3종 skip + post-parse + sentinel + verify)

### Task 14: scene_detail v15 prompt 디렉토리 생성

**Files:**
- Create: `prompts/_base/scene_detail/15.202605032354/system.md`
- Create: `prompts/_base/scene_detail/15.202605032354/detail_schema.json`

- [ ] **Step 1: v14 디렉토리 복사**

```bash
cp -r /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/14.202605031033 /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/15.202605032354
```

- [ ] **Step 2: `detail_schema.json` 그대로 (LLM schema 변경 0)**

검증: v14 와 동일.
```bash
diff /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/14.202605031033/detail_schema.json /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/15.202605032354/detail_schema.json
```
Expected: empty diff

- [ ] **Step 3: `system.md` Rule C 갱신**

Edit `prompts/_base/scene_detail/15.202605032354/system.md` — `## chain_bg 객체 중복 묘사 금지 (Rule C)` 섹션을 아래로 교체:

```markdown
## chain_bg 객체 중복 묘사 금지 (Rule C — G3.2 contract)

reference 배경 PNG 는 환경 객체(문, 창, 가구, 큰 prop)를 모두 가지고 있다. user_prompt 에 `[chain_bg 에 이미 그려진 객체 — 새로 그리지 말 것]` 블록이 주어지면 그 list 가 owned objects. owned 객체를 새로 그리도록 명령하지 마라 — 합성 단계에서 이중으로 그려진다 (두 개의 TV / 두 개의 문 / 위치 어긋남). post-parse 단계가 LLM judge 로 위반을 검증하고 contract_violation status 로 marking 한다 (no retry — round 4 Q1=B). 운영자가 partial 결과 확인 후 force 재실행 결정.

### 사용 규칙

- owned list 의 객체는 reference 안에 있는 그대로 보인다고 가정. 새로 묘사 금지.
- owned 객체에 **카메라가 close-up 하는 경우**, 그 객체를 새로 그리지 말고 reference 에서 가져온다고 명시:
  - ✓ `Use the door from the reference; do not generate a new one`
  - ✓ `The TV from the reference, now in tight close-up, its dim glow filling the lower frame`
- **인물의 자세/위치/시선** 은 reference 와 무관하므로 정상적으로 묘사한다 (Rule A 카메라 일관성만 지키면 됨).

### 위반 패턴 (post-parse judge 가 차단)

- ✗ `A figure stands near a wooden door; a tall window beside her` (owned door/window 를 새로 그리도록 지시)
- ✗ `Focus on a TV displaying a news bulletin in the corner` (owned TV 새 디테일 추가 = 새로 그리기 의도)
- ✗ 회피 표현: `portal` for `door`, `screen` for `TV` — 의미상 redraw 면 위반.

### 허용 패턴 (anchor 사용)

- ✓ `A figure stands near the doorway, her back to the window` (위치 anchor 만 사용)
- ✓ `Focus on the TV from the reference, news bulletin visible on its screen` (reference 명시)
- ✓ `the existing tabletop, a single mug centered on it` (existing 명시)
```

### Task 15: scene_detail SCHEMA_VERSION + PROMPT_VERSION bump

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:98` (`SCENE_DETAIL_SCHEMA_VERSION`, `SCENE_DETAIL_PROMPT_VERSION`)

- [ ] **Step 1: 상수 갱신 위치 확인**

```bash
grep -n "SCENE_DETAIL_SCHEMA_VERSION\s*=\|SCENE_DETAIL_PROMPT_VERSION\s*=" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py
```

- [ ] **Step 2: bump**

Edit `backend/app/core/steps/detail_steps.py` — 해당 라인 갱신:

```python
SCENE_DETAIL_SCHEMA_VERSION = 6  # G3.2: cp shape 에 owned_validation post-parse field
SCENE_DETAIL_PROMPT_VERSION = "15.202605032354"  # Rule C contract 갱신
```

- [ ] **Step 3: prompt loader 검증**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.modules.prompt_loader import load_prompt
sys = load_prompt('scene_detail', 'system')
assert 'G3.2 contract' in sys, 'v15 Rule C 갱신 미반영'
print('OK')
"
```
Expected: `OK`

### Task 16: `_build_phase2_prepend_blocks` close framing 3종 일관 skip + owned block (round 4 Q3=A — toggle 제거)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:106-173` (`_build_phase2_prepend_blocks`)

**round 4 Q3=A**: `chain_bg_owned_enabled` toggle **추가 안 함**. owned contract 는
correctness guard 라 optional 안 함. close framing 만 deterministic skip
(camera_direction 기반).

- [ ] **Step 1: 함수 시그니처 + close framing 일관 처리**

Edit `_build_phase2_prepend_blocks()` — 시그니처에 `chain_bg_owned_by_shot` 만
추가 (toggle 인자 없음), close framing 시 chain_bg_guide 도 skip:

```python
def _build_phase2_prepend_blocks(
    si: int,
    shi: int,
    essence_by_shot: Dict[Tuple[int, int], List[str]],
    chain_bg_guide_by_shot: Dict[Tuple[int, int], str],
    chain_bg_camera_meta_by_shot: Optional[Dict[Tuple[int, int], Dict[str, str]]] = None,
    chain_bg_owned_by_shot: Optional[Dict[Tuple[int, int], List[str]]] = None,
    *,
    shot_essence_enabled: bool,
    chain_bg_guide_enabled: bool,
    chain_bg_camera_meta_enabled: bool = False,
    camera_direction: str = "",
) -> str:
    """Phase 1b essence + Phase 2 chain_bg_guide + Phase 9.1 camera_meta +
    G3.2 owned objects prepend.

    G3.2: close framing 시 chain_bg_guide / chain_bg_camera_meta /
    chain_bg_owned **3 종 모두 skip** (Codex round 2 #3 일관성). 합성 단계가
    chain_bg PNG 자체를 close framing 에서 자동 skip 하므로 reference 가정 표현
    들이 LLM 입력에 가면 안 됨.

    round 4 Q3=A: owned 는 toggle 없음. correctness guard 이라 항상 적용
    (close framing deterministic skip 만).
    """
    blocks: List[str] = []
    is_close_framing = bool(camera_direction) and bool(_CLOSE_FRAMING_RE.search(camera_direction))

    if shot_essence_enabled:
        essence = essence_by_shot.get((si, shi)) or []
        if essence:
            lines = "\n".join(f"- {e}" for e in essence)
            blocks.append(
                "[샷 핵심 시각 요소 — 반드시 t2i_prompt에 포함]\n" + lines
            )

    # G3.2: chain_bg_guide 도 close 시 skip (round 2 #3).
    if chain_bg_guide_enabled and not is_close_framing:
        guide = chain_bg_guide_by_shot.get((si, shi)) or ""
        if guide:
            blocks.append(
                "[chain_bg reference에 이미 있음 — 다시 그리지 말 것]\n" + guide
            )

    if chain_bg_camera_meta_enabled and not is_close_framing:
        meta = (chain_bg_camera_meta_by_shot or {}).get((si, shi)) or {}
        if meta and any(meta.get(k) for k in (
            "camera_position", "camera_height", "lens_hint", "framing_notes"
        )):
            meta_lines = []
            for k_label, k_field in (
                ("position", "camera_position"),
                ("height", "camera_height"),
                ("lens", "lens_hint"),
                ("framing notes", "framing_notes"),
            ):
                v = meta.get(k_field) or ""
                if v:
                    meta_lines.append(f"  {k_label}: {v}")
            if meta_lines:
                blocks.append(
                    "[chain_bg reference 카메라 정보 — t2i_prompt에서 일치시키거나 명시적 deviation 명시]\n"
                    + "\n".join(meta_lines)
                )

    # G3.2: owned objects block — close 시 skip + non-close 만 inject.
    # round 4 Q3=A: toggle 없음. correctness guard 라 항상 inject.
    if not is_close_framing:
        owned = (chain_bg_owned_by_shot or {}).get((si, shi)) or []
        if owned:
            owned_lines = "\n".join(f"- {o}" for o in owned)
            blocks.append(
                "[chain_bg에 이미 그려진 객체 — 새로 그리지 말 것 (Rule C contract)]\n"
                + owned_lines
            )

    if not blocks:
        return ""
    return "\n\n".join(blocks) + "\n\n"
```

- [ ] **Step 2: 호출 사이트 갱신 (`_analyze_one`)**

`SceneDetailStep._analyze_one()` 안의 `_build_phase2_prepend_blocks(...)` 호출
사이트는 line 617-627 (`shot_info is None` else 분기 안). 정확한 변수: `shi`
는 line 612 에서 `shot_info.get("shot_index", shot_info.get("_shot_index", 1))` 로
정의됨, `cam_dir_for_block` 는 line 615-616 에서 staging_map 으로부터 추출.

기존 호출 (`detail_steps.py:617-627`):
```python
prepend_blocks = _build_phase2_prepend_blocks(
    si=si,
    shi=shi,
    essence_by_shot=ctx.essence_by_shot,
    chain_bg_guide_by_shot=ctx.chain_bg_guide_by_shot,
    chain_bg_camera_meta_by_shot=ctx.chain_bg_camera_meta_by_shot,
    shot_essence_enabled=settings.shot_essence_enabled,
    chain_bg_guide_enabled=settings.chain_bg_guide_enabled,
    chain_bg_camera_meta_enabled=settings.chain_bg_camera_meta_enabled,
    camera_direction=cam_dir_for_block,
)
```

Edit 후 (round 4: toggle 인자 추가 안 함):
```python
prepend_blocks = _build_phase2_prepend_blocks(
    si=si,
    shi=shi,
    essence_by_shot=ctx.essence_by_shot,
    chain_bg_guide_by_shot=ctx.chain_bg_guide_by_shot,
    chain_bg_camera_meta_by_shot=ctx.chain_bg_camera_meta_by_shot,
    chain_bg_owned_by_shot=ctx.chain_bg_owned_by_shot,
    shot_essence_enabled=settings.shot_essence_enabled,
    chain_bg_guide_enabled=settings.chain_bg_guide_enabled,
    chain_bg_camera_meta_enabled=settings.chain_bg_camera_meta_enabled,
    camera_direction=cam_dir_for_block,
)
```

(`chain_bg_owned_enabled` 인자 없음 — round 4 Q3=A.)

- [ ] **Step 3: import 검증**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.core.steps.detail_steps import _build_phase2_prepend_blocks
import inspect
sig = inspect.signature(_build_phase2_prepend_blocks)
assert 'chain_bg_owned_by_shot' in sig.parameters
assert 'chain_bg_owned_enabled' not in sig.parameters
print('OK')
"
```
Expected: `OK`

### Task 17: `SceneDetailStep._config_hash()` — owned 토글 추가 안 함 (round 4 Q3=A)

**Files:**
- (변경 없음) `backend/app/core/steps/detail_steps.py:204-220`

**round 4 Q3=A**: `chain_bg_owned_enabled` toggle 추가 안 함. config_hash payload
변경 없음. `SCHEMA_VERSION` 5→6 + `PROMPT_VERSION` 14→15 bump (Task 15) 만으로
cp invalidation 충분.

- [ ] **Step 1: 검증 — 기존 _config_hash 가 그대로 OK**

`_config_hash()` payload 는 Task 15 의 SCHEMA_VERSION + PROMPT_VERSION bump 가
hash 변화 trigger. owned 관련 추가 키 없음 — 의도된 design (round 4 Q3=A toggle
부재).

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
import inspect
from app.core.steps.detail_steps import SceneDetailStep
src = inspect.getsource(SceneDetailStep._config_hash)
assert 'chain_bg_owned_enabled' not in src, 'round 4 Q3=A toggle 미부재 회귀'
print('OK')
"
```
Expected: `OK`

### Task 18: `_analyze_one` post-parse owned validator + sentinel

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` (`SceneDetailStep._analyze_one`)

- [ ] **Step 1: post-parse 흐름 추가**

`_analyze_one()` 안에서 LLM 호출 결과 (variations 리스트가 만들어진 직후, normalize 통과 후) 에 owned validator 호출 추가. 함수 안 적절한 지점 (보통 `result["t2i_variations"] = variations` 이후) 에 다음 블록 추가:

```python
        # G3.2: post-parse owned validation per variation (CP-only sentinel).
        # round 4 Q1=B: no retry — 1 회 judge → violations 시 즉시 status 마킹.
        # round 5 BLOCKING 3 (A): sentinel 에 t2i_prompt_hash 포함.
        # round 5 IMPORTANT 1: shot_info is None scene-level path 가드.
        from app.core.steps._owned_helpers import (
            build_owned_sentinel,
            run_owned_judge,
            assert_owned_sentinel_shape,
        )

        # round 5 IMPORTANT 1: shot_info is None 이면 shi 미정의 → owned judge skip.
        # scene-level fallback path (legacy) 도 sentinel 은 부착해야 verify 가
        # silent missing 으로 판정 안 함. trivial empty sentinel.
        if shot_info is None:
            for variation in result.get("t2i_variations") or []:
                sentinel = build_owned_sentinel(
                    owned=[], camera_direction="",
                    t2i_prompt=variation.get("t2i_prompt", ""),
                    is_close_framing=False, violations=[],
                )
                assert_owned_sentinel_shape(
                    sentinel, where=f"scene_detail._analyze_one s{si} (scene-level)",
                )
                variation["owned_validation"] = sentinel
            # contract_violation 분기 / judge 호출 skip 후 그대로 진행.
        else:
            owned = ctx.chain_bg_owned_by_shot.get((si, shi)) or []
            # round 4 Q3=A: close framing 은 deterministic camera_direction skip.
            # cam_dir_for_block 은 line 615-616 에서 staging_for_block 으로부터 추출됨.
            is_close = bool(cam_dir_for_block) and bool(_CLOSE_FRAMING_RE.search(cam_dir_for_block))
            contract_violation = False
            for variation in result.get("t2i_variations") or []:
                # round 5 BLOCKING 3: sentinel 모든 path 에 t2i_prompt 인자.
                v_t2i = variation.get("t2i_prompt", "")
                if is_close:
                    # close framing → judge skip + close_skip validator marker.
                    sentinel = build_owned_sentinel(
                        owned=owned, camera_direction=cam_dir_for_block or "",
                        t2i_prompt=v_t2i,
                        is_close_framing=True, violations=[],
                    )
                elif not owned:
                    # owned 부재 (loader 가 빈 list 반환) → 계약 trivially 충족.
                    # validator type 은 full (v1) — 단 violations 빈 배열.
                    sentinel = build_owned_sentinel(
                        owned=[], camera_direction=cam_dir_for_block or "",
                        t2i_prompt=v_t2i,
                        is_close_framing=False, violations=[],
                    )
                else:
                    # non-close + owned 존재 → judge 호출.
                    violations = run_owned_judge(
                        t2i_prompt=v_t2i,
                        owned=owned, camera_direction=cam_dir_for_block or "",
                        call_structured_fn=call_structured,
                        project_config=self.project_config,
                        opik_metadata=self.build_opik_metadata(),
                    )
                    if violations:
                        contract_violation = True  # round 4 Q1=B: no retry — 즉시 마킹
                    # round 5 BLOCKING 2 fix: cam_dir_for_block (camera_direction
                    # 변수 부재) + round 5 BLOCKING 3: t2i_prompt 인자.
                    sentinel = build_owned_sentinel(
                        owned=owned, camera_direction=cam_dir_for_block or "",
                        t2i_prompt=v_t2i,
                        is_close_framing=False, violations=violations,
                    )
                assert_owned_sentinel_shape(
                    sentinel, where=f"scene_detail._analyze_one s{si}_shot{shi}",
                )
                variation["owned_validation"] = sentinel

            if contract_violation:
                # round 2 #4: 강한 path — STATUS_CONTRACT_VIOLATION 마킹.
                # round 4 Q1=B: no retry — 1 회 judge 후 violations 즉시 마킹.
                result["status"] = "contract_violation"
```

(주의: 호출 사이트에서 `call_structured` import 가 이미 있어야 함. 없으면 함수 상단에 `from app.modules.llm.llm_client import call_structured` 추가.)

- [ ] **Step 2: import 검증**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.core.steps.detail_steps import SceneDetailStep
print('OK')
"
```
Expected: `OK`

### Task 19: `verify_completion()` sentinel 검증 + contract_violation partial

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:410-473`

- [ ] **Step 1: 함수 갱신**

`verify_completion()` 안 `failed_indices` 로직 다음에 sentinel 검증 + contract_violation 검사 추가. 기존 함수를 아래로 교체:

```python
    def verify_completion(self):
        """Group 1 #3 + G3.2: scene_detail 산출물 검증.

        검증 항목:
        - data.scenes 존재.
        - 각 result 에 t2i_variations 1+개.
        - (G3.2) 각 variation 에 owned_validation sentinel 존재.
        - (G3.2) non-close variation 의 owned_hash 가 현재 owned hash 와 일치
          (drift 차단). camera_direction_hash 도 마찬가지.
        - (G3.2) result["status"] == "contract_violation" 이면 partial 마킹.

        partial / blocked 시 allow_partial_downstream=False (manifest) 가
        downstream 차단.
        """
        from app.core.integrity_report import CompletionReport
        from app.core.steps._owned_helpers import (
            assert_owned_sentinel_shape,
            compute_owned_hash,
            compute_camera_direction_hash,
            compute_t2i_prompt_hash,
            OWNED_VALIDATOR_FULL,
            OWNED_VALIDATOR_CLOSE_SKIP,
        )
        # round 4 BLOCKING 2: drift 4 종 검증 — validator type / t2i_prompt_hash
        # (round 5 BLOCKING 3) / owned_hash / camera_direction_hash. close-skip
        # variation 도 hash 일치 강제.
        # round 5 BLOCKING 4: module-level _CLOSE_FRAMING_RE 재사용 — 생성 path
        # 와 동일 (한국어 클로즈업 / 손가락이 등 포함). local regex 만들지 않음.

        result = getattr(self, "_last_execute_result", None)
        if result is None:
            cp = self._load_prev_checkpoint("scene_detail")
            if not cp:
                return CompletionReport(
                    is_complete=False,
                    missing=["scene_detail cp + result 모두 부재"],
                    severity="missing",
                    metadata={"total_results": 0, "failed_count": 0, "failed_indices": []},
                )
            result = cp
        scenes = result.get("data", {}).get("scenes", []) or []
        for r in scenes:
            _normalize_scene_detail_result(r, where="scene_detail.verify_completion")
        if not scenes:
            return CompletionReport(
                is_complete=False, missing=["scene_detail data.scenes 빈 리스트"],
                severity="missing",
                metadata={"total_results": 0, "failed_count": 0, "failed_indices": []},
            )

        # owned 매핑 reload — verify 시점의 ground truth.
        from app.core.steps.scene_context_loader import SceneContextLoader
        loader = SceneContextLoader(self)
        owned_by_shot = loader._load_chain_bg_owned_by_shot()
        # camera_direction reload — staging_map 으로부터.
        staging_map = loader._load_staging_map() if hasattr(loader, "_load_staging_map") else {}

        failed_indices: list = []
        contract_violations: list = []
        sentinel_drifted: list = []
        for r in scenes:
            tvars = r.get("t2i_variations", []) or []
            si = r.get("scene_index")
            shi = r.get("_shot_index")
            if not tvars:
                failed_indices.append((si, shi))
                continue
            if r.get("status") == "contract_violation":
                contract_violations.append((si, shi))
            for var in tvars:
                sentinel = var.get("owned_validation")
                if sentinel is None:
                    sentinel_drifted.append((si, shi, "missing"))
                    continue
                try:
                    assert_owned_sentinel_shape(
                        sentinel, where=f"scene_detail.verify s{si}_shot{shi}",
                    )
                except Exception:
                    sentinel_drifted.append((si, shi, "shape"))
                    continue
                # round 4 BLOCKING 2 + round 5 BLOCKING 3,4: 모든 variation 에 4 종 drift 검증.
                # 1) validator type 이 현재 close framing 상태와 일치.
                expected_owned = owned_by_shot.get((si, shi), [])
                staging_key = f"{si}_{shi}"
                stg = staging_map.get(staging_key) if isinstance(staging_map, dict) else None
                cam_dir_now = ""
                if isinstance(stg, dict):
                    cam_dir_now = stg.get("camera_direction", "") or ""
                # round 5 BLOCKING 4: 생성 path 와 동일한 module-level regex 사용.
                expected_close = bool(cam_dir_now) and bool(
                    _CLOSE_FRAMING_RE.search(cam_dir_now)
                )
                expected_validator = (
                    OWNED_VALIDATOR_CLOSE_SKIP if expected_close
                    else OWNED_VALIDATOR_FULL
                )
                if sentinel.get("validator") != expected_validator:
                    sentinel_drifted.append((si, shi, "validator_type"))
                    continue
                # 2) round 5 BLOCKING 3: t2i_prompt_hash 일치.
                # t2i_review 가 prompt 수정 후 sentinel 미갱신 하면 여기서 drift.
                v_t2i_now = var.get("t2i_prompt", "")
                if sentinel.get("t2i_prompt_hash") != compute_t2i_prompt_hash(v_t2i_now):
                    sentinel_drifted.append((si, shi, "t2i_prompt_hash"))
                    continue
                # 3) owned_hash 일치 (close-skip 도 검증 — round 3 #4).
                if sentinel.get("owned_hash") != compute_owned_hash(expected_owned):
                    sentinel_drifted.append((si, shi, "owned_hash"))
                    continue
                # 4) camera_direction_hash 일치.
                if sentinel.get("camera_direction_hash") != compute_camera_direction_hash(
                    cam_dir_now
                ):
                    sentinel_drifted.append((si, shi, "camera_direction_hash"))

        total = len(scenes)
        missing_msgs: List[str] = []
        if failed_indices:
            missing_msgs.append(
                f"{len(failed_indices)}/{total} scene/shot result(s) have empty "
                f"t2i_variations: {failed_indices[:5]}"
            )
        if contract_violations:
            missing_msgs.append(
                f"{len(contract_violations)} owned contract_violation: "
                f"{contract_violations[:5]}"
            )
        if sentinel_drifted:
            missing_msgs.append(
                f"{len(sentinel_drifted)} owned_validation sentinel drift/missing: "
                f"{sentinel_drifted[:5]}"
            )
        if missing_msgs:
            severity = (
                "missing"
                if len(failed_indices) >= total
                else "partial"
            )
            return CompletionReport(
                is_complete=False,
                missing=missing_msgs,
                severity=severity,
                metadata={
                    "total_results": total,
                    "failed_count": len(failed_indices) + len(contract_violations) + len(sentinel_drifted),
                    "failed_indices": failed_indices,
                    "contract_violations": contract_violations,
                    "sentinel_drifted": sentinel_drifted,
                },
            )
        return CompletionReport(
            is_complete=True, missing=[], severity="clean",
            metadata={"total_results": total, "failed_count": 0, "failed_indices": []},
        )
```

---

### Task 19.5: `_user_edited` reuse path 에 owned hash 검증 (round 4 IMPORTANT 4)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:300-330` (`_user_edited` 분기)
- Create: `backend/tests/unit/test_g3_2_user_edited_owned_drift.py`

`_user_edited` reuse path 는 G3.1 evidence assert 만 통과시킴 — owned_validation
hash mismatch 가 우회됨. round 4 IMPORTANT 4 강화: validator type / owned_hash /
camera_direction_hash 모두 검증 후 mismatch 시 reuse 거부 + fresh 재생성.

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_g3_2_user_edited_owned_drift.py
"""G3.2 round 4 IMPORTANT 4: _user_edited reuse path owned hash drift."""
from __future__ import annotations

from typing import Any, Dict
import pytest

from app.core.steps._owned_helpers import (
    OWNED_VALIDATOR_FULL,
    OWNED_VALIDATOR_CLOSE_SKIP,
)


def _make_user_edited_variation(
    *,
    owned_hash: str = "abc",
    camera_direction_hash: str = "def",
    validator: str = OWNED_VALIDATOR_FULL,
) -> Dict[str, Any]:
    return {
        "variant_label": "var_1",
        "camera_effect": "wide eye-level",
        "t2i_prompt": "A figure stands by the door from the reference.",
        "outfit_assignments": [],
        "source_facts": ["fact"],
        "visual_inferences": ["inf"],
        "creative_decisions": ["dec"],
        "confidence": "high",
        "owned_validation": {
            "schema_version": 1,
            "owned_hash": owned_hash,
            "camera_direction_hash": camera_direction_hash,
            "validator": validator,
            "violations": [],
        },
    }


def test_user_edited_owned_hash_mismatch_rejects_reuse() -> None:
    """user 가 cp 직접 편집해서 owned_hash 가 stale → reuse 안 하고 fresh 재생성."""
    from app.core.steps.detail_steps import _user_edited_owned_contract_violated

    var = _make_user_edited_variation(owned_hash="stale_hash")
    expected_owned = ["door", "window"]  # 현재 ground truth
    expected_camera_direction = "wide eye-level"
    is_close = False
    assert _user_edited_owned_contract_violated(
        var, expected_owned, expected_camera_direction, is_close,
    )


def test_user_edited_owned_hash_match_passes_reuse() -> None:
    from app.core.steps._owned_helpers import (
        compute_owned_hash, compute_camera_direction_hash,
    )
    from app.core.steps.detail_steps import _user_edited_owned_contract_violated

    expected_owned = ["door", "window"]
    expected_cam = "wide eye-level"
    var = _make_user_edited_variation(
        owned_hash=compute_owned_hash(expected_owned),
        camera_direction_hash=compute_camera_direction_hash(expected_cam),
        validator=OWNED_VALIDATOR_FULL,
    )
    assert not _user_edited_owned_contract_violated(
        var, expected_owned, expected_cam, is_close=False,
    )


def test_user_edited_validator_type_drift_rejects_reuse() -> None:
    """현재 close 인데 sentinel 은 full validator → drift, reuse 거부."""
    from app.core.steps._owned_helpers import (
        compute_owned_hash, compute_camera_direction_hash,
    )
    from app.core.steps.detail_steps import _user_edited_owned_contract_violated

    expected_owned = ["door"]
    expected_cam = "extreme close-up"
    var = _make_user_edited_variation(
        owned_hash=compute_owned_hash(expected_owned),
        camera_direction_hash=compute_camera_direction_hash(expected_cam),
        validator=OWNED_VALIDATOR_FULL,  # 옛날에 wide 였을 때 sentinel
    )
    assert _user_edited_owned_contract_violated(
        var, expected_owned, expected_cam, is_close=True,
    )


def test_user_edited_missing_owned_validation_rejects_reuse() -> None:
    """owned_validation 자체 부재 → drift, reuse 거부."""
    from app.core.steps.detail_steps import _user_edited_owned_contract_violated

    var = _make_user_edited_variation()
    del var["owned_validation"]
    assert _user_edited_owned_contract_violated(
        var, ["door"], "wide", is_close=False,
    )
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_g3_2_user_edited_owned_drift.py -v
```
Expected: 4 FAIL (`_user_edited_owned_contract_violated` 미정의)

- [ ] **Step 3: helper 함수 + reuse path patch**

Edit `backend/app/core/steps/detail_steps.py` — 새 helper 추가 (file 상단 또는
class 안 적절한 위치):

```python
def _user_edited_owned_contract_violated(
    variation: Dict[str, Any],
    expected_owned: List[str],
    expected_camera_direction: str,
    is_close: bool,
) -> bool:
    """G3.2 round 4 IMPORTANT 4 + round 5 BLOCKING 3: _user_edited reuse 의
    owned_validation drift 검사.

    True 반환 시 reuse 거부 + fresh 재생성. False 면 reuse 정상.

    검사 순서 — 어느 한 검증 실패 시 즉시 True:
    1. owned_validation field 존재.
    2. validator type 이 현재 close framing 상태와 일치.
    3. t2i_prompt_hash 일치 (round 5 BLOCKING 3 — user 가 cp 직접 편집해서 prompt
       수정 시 차단).
    4. owned_hash 일치.
    5. camera_direction_hash 일치.
    """
    from app.core.steps._owned_helpers import (
        OWNED_VALIDATOR_FULL,
        OWNED_VALIDATOR_CLOSE_SKIP,
        compute_owned_hash,
        compute_camera_direction_hash,
        compute_t2i_prompt_hash,
        assert_owned_sentinel_shape,
    )

    sentinel = variation.get("owned_validation")
    if sentinel is None:
        return True
    try:
        assert_owned_sentinel_shape(sentinel)
    except Exception:
        return True
    expected_validator = (
        OWNED_VALIDATOR_CLOSE_SKIP if is_close else OWNED_VALIDATOR_FULL
    )
    if sentinel.get("validator") != expected_validator:
        return True
    # round 5 BLOCKING 3: t2i_prompt drift check.
    if sentinel.get("t2i_prompt_hash") != compute_t2i_prompt_hash(
        variation.get("t2i_prompt", "")
    ):
        return True
    if sentinel.get("owned_hash") != compute_owned_hash(expected_owned):
        return True
    if sentinel.get("camera_direction_hash") != compute_camera_direction_hash(
        expected_camera_direction
    ):
        return True
    return False
```

기존 `_user_edited` 분기 (line 300+) 에서 `assert_fresh_llm_evidence` 통과 후
다음 줄에 owned drift 검사 추가:

```python
                            try:
                                assert_fresh_llm_evidence(_var, "scene_detail._user_edited")
                            except AppError as exc:
                                # ... (기존 G3.1 evidence 처리 그대로)
                                break
                            # G3.2 round 4 IMPORTANT 4: owned drift 검증.
                            # owned + camera_direction reload — verify 시점의 ground truth.
                            _owned_for_var = ctx.chain_bg_owned_by_shot.get(
                                (s.get("scene_index"), s.get("_shot_index"))
                            ) or []
                            _staging = ctx.staging_map.get(
                                f"{s.get('scene_index')}_{s.get('_shot_index')}"
                            ) or {}
                            _cam_dir = (
                                _staging.get("camera_direction", "")
                                if isinstance(_staging, dict) else ""
                            )
                            _is_close = bool(_cam_dir) and bool(
                                _CLOSE_FRAMING_RE.search(_cam_dir)
                            )
                            if _user_edited_owned_contract_violated(
                                _var, _owned_for_var, _cam_dir, _is_close,
                            ):
                                logger.warning(
                                    "scene_detail _user_edited %s: owned drift — "
                                    "reuse 거부, fresh 재생성.",
                                    (s.get("scene_index"), s.get("_shot_index")),
                                )
                                break

```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_g3_2_user_edited_owned_drift.py -v
```
Expected: 4 PASS.

### Task 19.6: consumer propagation 회귀 테스트 (round 4 MINOR 2)

**Files:**
- Create: `backend/tests/unit/test_g3_2_consumer_propagation.py`

`owned_validation` 이 t2i_review / scene_still_normalizer / scene_checkpoint_loaders
를 통과해도 strip 안 되는지 회귀 가드.

- [ ] **Step 1: 테스트 작성**

```python
# backend/tests/unit/test_g3_2_consumer_propagation.py
"""G3.2 round 4 MINOR 2: owned_validation consumer propagation 회귀."""
from __future__ import annotations

import copy
from typing import Any, Dict

from app.core.steps._owned_helpers import (
    OWNED_VALIDATOR_FULL,
    build_owned_sentinel,
)


def _make_t2i_variation_with_sentinel() -> Dict[str, Any]:
    t2i_prompt = "Photorealistic still ..."
    sentinel = build_owned_sentinel(
        owned=["door", "window"], camera_direction="wide",
        t2i_prompt=t2i_prompt,  # round 6 BLOCKING 2: 시그니처 인자 누락 fix.
        is_close_framing=False, violations=[],
    )
    return {
        "variant_label": "var_1",
        "camera_effect": "wide",
        "t2i_prompt": t2i_prompt,
        "outfit_assignments": [],
        "source_facts": ["fact"],
        "visual_inferences": ["inf"],
        "creative_decisions": ["dec"],
        "confidence": "high",
        "owned_validation": sentinel,
    }


def test_normalize_scene_detail_result_preserves_owned_validation() -> None:
    """G3.1 _normalize_scene_detail_result 가 owned_validation 안 strip 하는지."""
    from app.core.steps._evidence_helpers import _normalize_scene_detail_result

    var = _make_t2i_variation_with_sentinel()
    scene_result = {
        "scene_index": 1, "_shot_index": 1,
        "t2i_variations": [var],
    }
    _normalize_scene_detail_result(scene_result, where="test")
    assert "owned_validation" in scene_result["t2i_variations"][0]


def test_t2i_review_apply_scene_fixes_preserves_owned_validation() -> None:
    """t2i_review._apply_scene_fixes 가 owned_validation 보존하는지 (deep copy).

    실제 _apply_scene_fixes 는 외부 의존이 많아 단위 테스트 어려움. 대신 구조적
    회귀 가드 — t2i_review.py 소스에 owned_validation 을 명시적으로 strip 하는
    코드가 없는지 확인.
    """
    import inspect
    from app.modules.pipeline import t2i_review

    src = inspect.getsource(t2i_review)
    forbidden_patterns = [
        'pop("owned_validation"',
        "pop('owned_validation'",
        'del var["owned_validation"]',
        "del var['owned_validation']",
    ]
    for pat in forbidden_patterns:
        assert pat not in src, (
            f"t2i_review 가 owned_validation 을 명시적으로 strip 하면 안 됨: {pat}"
        )


def test_scene_checkpoint_loaders_preserves_owned_validation() -> None:
    """scene_checkpoint_loaders.load_shot_t2i_variations 가 owned_validation 보존."""
    import inspect
    from app.services import scene_checkpoint_loaders

    src = inspect.getsource(scene_checkpoint_loaders)
    forbidden_patterns = [
        'pop("owned_validation"',
        "pop('owned_validation'",
    ]
    for pat in forbidden_patterns:
        assert pat not in src, f"scene_checkpoint_loaders strip 금지: {pat}"
```

- [ ] **Step 2: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/unit/test_g3_2_consumer_propagation.py -v
```
Expected: 3 PASS (현재 코드가 owned_validation 을 모르므로 자동 보존됨).

## Phase 6 — step_manifest DAG 재배치

### Task 20: `step_manifest.py` order 재배치 + scene_detail dep + allow_partial_downstream + schema_version

**Files:**
- Modify: `backend/app/core/step_manifest.py`

- [ ] **Step 1: scene_detail entry 갱신 (line ~624)**

```python
    "scene_detail": {
        "label": "씬 상세 분석 (병렬)",
        "category": "analysis",
        "order": 21.70,  # G3.2: 20→21.70 (background_prompt 21.60 뒤).
        "default_model": "gemini-pro",
        "provider": "gemini",
        # G3.2: 5→6 (cp shape 에 owned_validation post-parse field 추가).
        "schema_version": 6,
        "depends_on": [
            "shot_dependency", "shot_director", "outlook_phase3", "entity_t2i",
            "shot_staging", "scene_consistency",
            "background_prompt",  # G3.2: owned list 의존성 (round 2 #1).
        ],
        "fan_out": True,
        "applicability": "always",
        "step_type": "transform",
        "lifecycle": "active",
        "consumes_downstream": ["shot_dependency_t2i"],
        "requires_projection_sync_before_run": True,
        # G3.2: contract_violation partial 시 downstream 차단 (round 2 #6).
        "allow_partial_downstream": False,
    },
```

- [ ] **Step 2: shot_dependency_t2i order 갱신 (line ~651)**

```python
    "shot_dependency_t2i": {
        "label": "Shot 연관 재계산 (LLM)",
        "category": "analysis",
        "order": 21.71,  # G3.2: 20.5→21.71.
        ...
    },
```

- [ ] **Step 3: t2i_review order 갱신 (line ~665)**

```python
    "t2i_review": {
        "label": "T2I 프롬프트 검수",
        "category": "analysis",
        "order": 21.72,  # G3.2: 20.7→21.72.
        ...
    },
```

- [ ] **Step 4: background_prompt schema_version 명시 (line ~758)**

```python
    "background_prompt": {
        "label": "배경 t2i 프롬프트",
        "category": "analysis",
        "order": 21.60,
        "default_model": "gpt",
        "provider": "openai",
        "schema_version": 2,  # G3.2: 1→2 명시 (single source of truth).
        "depends_on": [
            "background_master_plan", "floor_plan_render",
            "scene_save", "shot_validator", "shot_selection",
            "visual_world_rules",
        ],
        ...
    },
```

- [ ] **Step 5: 검증**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && python -c "
from app.core.step_manifest import STEP_MANIFEST
sd = STEP_MANIFEST['scene_detail']
assert sd['order'] == 21.70, f'order={sd[\"order\"]}'
assert sd['schema_version'] == 6
assert 'background_prompt' in sd['depends_on']
assert sd.get('allow_partial_downstream') is False
assert STEP_MANIFEST['shot_dependency_t2i']['order'] == 21.71
assert STEP_MANIFEST['t2i_review']['order'] == 21.72
assert STEP_MANIFEST['background_prompt']['schema_version'] == 2
print('OK')
"
```
Expected: `OK`

---

## Phase 7 — Integration Tests

### Task 21: 통합 테스트 — close framing 3 종 skip + non-close inject + sentinel drift + contract_violation + analysis-only block + bg-mode-off + schema isolation + old v4 / partial v5 cp

**Files:**
- Create: `backend/tests/integration/test_g3_2_consumer_wiring.py`
- Create: `backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py`

- [ ] **Step 1: integration test 작성 — wiring + 3종 skip**

```python
# backend/tests/integration/test_g3_2_consumer_wiring.py
"""G3.2 consumer wiring integration tests.

검증:
- close framing 시 _build_phase2_prepend_blocks 가 chain_bg_guide /
  camera_meta / owned 3 종 모두 skip.
- non-close 시 owned block prepend.
- detail_schema.json 에 owned_validation 부재 (LLM schema isolation).
- old v4 background_prompt cp 차단.
- partial v5 cp 차단.
- bg_mode off 진행.
- analysis-only + bg on 시 dep 미충족 path (gate 동작).
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict

import pytest

from app.core.errors import AppError
from app.core.steps.detail_steps import _build_phase2_prepend_blocks


def _empty_camera_direction() -> str:
    return "wide eye-level shot of doorway"


def _close_camera_direction() -> str:
    return "extreme close-up on hand"


class TestPhase2PrependClose3WaySkip:
    def test_close_framing_skips_all_3_blocks(self) -> None:
        # round 4 Q3=A: chain_bg_owned_enabled 인자 없음.
        out = _build_phase2_prepend_blocks(
            si=1, shi=1,
            essence_by_shot={(1, 1): ["essence-line"]},
            chain_bg_guide_by_shot={(1, 1): "guide-text"},
            chain_bg_camera_meta_by_shot={(1, 1): {"camera_position": "p"}},
            chain_bg_owned_by_shot={(1, 1): ["door", "window"]},
            shot_essence_enabled=True,
            chain_bg_guide_enabled=True,
            chain_bg_camera_meta_enabled=True,
            camera_direction=_close_camera_direction(),
        )
        # essence 만 inject. guide / camera / owned 모두 skip (close framing
        # deterministic — toggle 없이도).
        assert "essence-line" in out
        assert "guide-text" not in out
        assert "camera_position" not in out
        assert "door" not in out
        assert "window" not in out

    def test_non_close_injects_all_3_blocks(self) -> None:
        out = _build_phase2_prepend_blocks(
            si=1, shi=1,
            essence_by_shot={(1, 1): ["essence-line"]},
            chain_bg_guide_by_shot={(1, 1): "guide-text"},
            chain_bg_camera_meta_by_shot={(1, 1): {"camera_position": "p"}},
            chain_bg_owned_by_shot={(1, 1): ["door"]},
            shot_essence_enabled=True,
            chain_bg_guide_enabled=True,
            chain_bg_camera_meta_enabled=True,
            camera_direction=_empty_camera_direction(),
        )
        assert "essence-line" in out
        assert "guide-text" in out
        assert "p" in out
        assert "door" in out
        assert "Rule C contract" in out  # owned block header

    # round 4 Q3=A: test_owned_disabled_skips_owned_only 삭제 — toggle 자체가 없음.


class TestDetailSchemaOwnedIsolation:
    def test_detail_schema_does_not_contain_owned_validation(self) -> None:
        # spec 7.3 — LLM schema (response_schema) 에 owned_validation 부재.
        schema_path = (
            Path(__file__).parents[3]
            / "prompts/_base/scene_detail/15.202605032354/detail_schema.json"
        )
        schema = json.loads(schema_path.read_text(encoding="utf-8"))
        # t2i_variations item 의 properties 에 owned_validation 부재.
        item = schema["properties"]["t2i_variations"]["items"]
        assert "owned_validation" not in item.get("properties", {}), (
            "detail_schema 에 owned_validation 이 들어가면 LLM 이 hash 출력해야 해서 실패"
        )
        assert "owned_validation" not in item.get("required", []), (
            "detail_schema required 에 owned_validation 이 들어가면 LLM 검증 실패"
        )


class TestOldV4CpBlocked:
    def test_loader_raises_on_v4_cp(self, monkeypatch) -> None:
        from app.core.config import settings
        monkeypatch.setattr(settings, "background_mode", "on", raising=False)

        from app.core.steps.scene_context_loader import SceneContextLoader

        class _R:
            project_id = "p"
            episode_id = "e"
            db = None
            project_config = None

            def _load_prev_checkpoint(self, sid: str):
                if sid == "background_prompt":
                    return {
                        "schema_version": 1,  # 옛 v4 cp
                        "data": {"backgrounds": {"bg1": {"status": "ok"}}},
                    }
                return None

            def build_opik_metadata(self):
                return {}

        loader = SceneContextLoader(_R())
        with pytest.raises(AppError) as exc_info:
            loader._load_chain_bg_owned_by_shot()
        assert exc_info.value.code == "step.contract_violation"
        assert "schema_version" in exc_info.value.message


class TestPartialV5CpBlocked:
    def test_loader_raises_on_partial_owned(self, monkeypatch) -> None:
        from app.core.config import settings
        monkeypatch.setattr(settings, "background_mode", "on", raising=False)

        from app.core.steps.scene_context_loader import SceneContextLoader

        class _R:
            project_id = "p"
            episode_id = "e"
            db = None
            project_config = None

            def _load_prev_checkpoint(self, sid: str):
                if sid == "background_prompt":
                    return {
                        "schema_version": 2,
                        "data": {"backgrounds": {
                            "bg_ok": {
                                "status": "ok",
                                "objects_owned_by_background": ["door"],
                                "spec": {"applies_to_shots": ["S1_Shot1"]},
                            },
                            "bg_partial": {"status": "ok"},  # owned 누락
                        }},
                    }
                return None

            def build_opik_metadata(self):
                return {}

        loader = SceneContextLoader(_R())
        with pytest.raises(AppError) as exc_info:
            loader._load_chain_bg_owned_by_shot()
        assert "bg_partial" in exc_info.value.message


class TestBgModeOff:
    def test_loader_returns_empty_when_off(self, monkeypatch) -> None:
        from app.core.config import settings
        monkeypatch.setattr(settings, "background_mode", "off", raising=False)

        from app.core.steps.scene_context_loader import SceneContextLoader

        class _R:
            project_id = "p"
            episode_id = "e"
            db = None
            project_config = None

            def _load_prev_checkpoint(self, sid: str):
                # bg off 면 옛 cp 가 있어도 통과.
                if sid == "background_prompt":
                    return {"schema_version": 1, "data": {"backgrounds": {}}}
                return None

            def build_opik_metadata(self):
                return {}

        loader = SceneContextLoader(_R())
        assert loader._load_chain_bg_owned_by_shot() == {}


class TestAnalysisOnlyWithBgOnBlocked:
    """round 5 IMPORTANT 3 + round 6 IMPORTANT 1 + round 7 IMPORTANT 2:
    dispatcher / gate 까지 타는 통합 테스트.

    Spec 8.8: analysis-only + bg on 시 background_prompt 가 floor_plan_render
    (image category) 에 막혀 not run → scene_detail 이 dep 미충족 block.

    **round 7 IMPORTANT 2 라벨 강등**: 통합 테스트 본문은 implementor 가 fixture
    셋업 후 활성화. 현재 plan 에는 manifest sanity check + skip placeholder 만
    있음. 즉 본 테스트는 "구현 완료 가드" 가 아니라 **"구현 시 TODO"**. 회귀
    가드 강도 = 약함. 본격 가드는 dispatcher fixture 통합 후 placeholder 활성화
    필요.
    """

    def test_dag_dep_is_background_prompt_manifest(self) -> None:
        # Sanity check — manifest dep 자체.
        from app.core.step_manifest import STEP_MANIFEST
        assert "background_prompt" in STEP_MANIFEST["scene_detail"]["depends_on"]

    def test_analysis_only_dispatcher_returns_no_image_steps(
        self, monkeypatch
    ) -> None:
        """category != "image" 호출 시 dispatcher 가 image step (floor_plan_render
        등) 을 반환하지 않는지 회귀.
        """
        from app.core.step_manifest import STEP_MANIFEST
        # background_prompt + scene_detail 은 analysis category 이므로 포함됨.
        # floor_plan_render / background_render 는 image — 포함 안 됨.
        analysis_steps = {
            sid for sid, info in STEP_MANIFEST.items()
            if info.get("category") == "analysis"
        }
        image_steps = {
            sid for sid, info in STEP_MANIFEST.items()
            if info.get("category") == "image"
        }
        assert "scene_detail" in analysis_steps
        assert "background_prompt" in analysis_steps
        assert "floor_plan_render" in image_steps
        assert "background_render" in image_steps

    def test_analysis_only_run_blocks_scene_detail_when_bg_on(
        self, monkeypatch, tmp_path
    ) -> None:
        """bg on + analysis-only 실행 시 scene_detail 이 실제로 dep 미충족 block.

        StepRunner gate 가 dep 의 status / cp 존재 를 보고 진행 여부 결정.
        floor_plan_render 가 image category 라 analysis-only 에서는 안 돌아 → cp
        부재 → background_prompt 가 dep 미충족 → not_run → scene_detail dep 도
        미충족 → not_run.

        실제 dispatcher 까지 타는 fixture 기반 통합 테스트. settings + DB row 모두
        준비.
        """
        from app.core.config import settings
        monkeypatch.setattr(settings, "background_mode", "on", raising=False)
        # 통합 테스트 셋업은 conftest 의 fresh-PID fixture 와 결합.
        # 검증 기준: bg on + analysis 카테고리만 force/run 시:
        #   - scene_detail step run row 가 'blocked' / 'skipped' / 'not_run'.
        #   - background_prompt step run row 도 동일.
        #   - floor_plan_render 는 unrun 상태.
        # 구현 시 backend/app/services/analysis_dispatch_service.py 의
        # dispatch_analysis_run() 또는 동등 entry 를 호출. 실제 호출 entry 는
        # 구현자가 backend code 와 대조해 해당 함수에 전달.
        # 여기서는 placeholder skeleton — 실제 fixture 셋업은 implementor 가
        # backend/tests/integration 의 기존 dispatch 테스트 패턴 참고.
        pytest.skip(
            "implementor 가 backend/tests/integration 의 dispatch 테스트 패턴을 "
            "참고해 fixture 셋업 후 활성화. 회귀 가드: bg on + analysis-only "
            "실행 시 scene_detail dep 미충족 block."
        )
```

- [ ] **Step 2: close skip sentinel drift unit test 작성**

```python
# backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py
"""G3.2 close-skip sentinel drift detection (round 3 #4 + round 5 BLOCKING 3)."""
from __future__ import annotations

from app.core.steps._owned_helpers import (
    OWNED_VALIDATOR_CLOSE_SKIP,
    build_owned_sentinel,
    compute_camera_direction_hash,
    compute_owned_hash,
    compute_t2i_prompt_hash,
)


def test_close_skip_sentinel_has_all_hashes() -> None:
    # round 3 #4 + round 5 BLOCKING 3: close skip 도 owned_hash +
    # camera_direction_hash + t2i_prompt_hash 모두 포함.
    s = build_owned_sentinel(
        owned=["door", "window"],
        camera_direction="extreme close-up",
        t2i_prompt="Focus on a hand near the door.",
        is_close_framing=True,
        violations=[],
    )
    assert s["validator"] == OWNED_VALIDATOR_CLOSE_SKIP
    assert s["owned_hash"] == compute_owned_hash(["door", "window"])
    assert s["camera_direction_hash"] == compute_camera_direction_hash("extreme close-up")
    assert s["t2i_prompt_hash"] == compute_t2i_prompt_hash("Focus on a hand near the door.")


def test_close_to_close_camera_change_detectable_via_hash() -> None:
    # 같은 close framing 이라도 camera_direction 이 다르면 hash 변경 → drift 검출 가능.
    s_ecu = build_owned_sentinel(
        owned=[], camera_direction="extreme close-up of hand",
        t2i_prompt="prompt 1",
        is_close_framing=True, violations=[],
    )
    s_cu = build_owned_sentinel(
        owned=[], camera_direction="medium close-up of face",
        t2i_prompt="prompt 1",
        is_close_framing=True, violations=[],
    )
    assert s_ecu["camera_direction_hash"] != s_cu["camera_direction_hash"]


def test_close_skip_t2i_prompt_change_detectable_via_hash() -> None:
    # round 5 BLOCKING 3: close framing 안에서 t2i_prompt 가 변경되어도 drift 검출.
    s1 = build_owned_sentinel(
        owned=["door"], camera_direction="ECU",
        t2i_prompt="Original close-up.",
        is_close_framing=True, violations=[],
    )
    s2 = build_owned_sentinel(
        owned=["door"], camera_direction="ECU",
        t2i_prompt="Modified close-up.",
        is_close_framing=True, violations=[],
    )
    assert s1["t2i_prompt_hash"] != s2["t2i_prompt_hash"]
```

- [ ] **Step 3: 모든 테스트 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/integration/test_g3_2_consumer_wiring.py tests/unit/test_g3_2_close_skip_sentinel_drift.py -v
```
Expected: ~13 PASS + 1 SKIP (round 6: close-skip drift 3 신규 + analysis-only
integration 1 skip placeholder).

### Task 22: 전체 회귀 검증

- [ ] **Step 1: 전체 테스트 suite 실행**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/ --ignore=tests/migration -x -q 2>&1 | tail -50
```

Expected: G3.1 baseline (1923 passed / 20 baseline failed) 대비 증가 (G3.2 신규 ~50+) — **새로운 회귀 0**.

확인 항목:
- 1923 + ~50 ≈ 1970+ passed.
- 20 baseline failed 그대로 (G3.2 무관).
- 새로 fail 추가되면 backtrace 분석 → 수정.

- [ ] **Step 2: 만약 새 회귀가 있으면 수정**

흔한 회귀 패턴:
- step_manifest order 변경으로 fixture 가 옛 order assertion 사용 중 → fixture 갱신.
- detail_steps `_build_phase2_prepend_blocks` 시그니처 변경으로 호출 사이트 누락 → 호출 사이트 검색 및 추가:

```bash
grep -rn "_build_phase2_prepend_blocks" /Users/manta/Documents/Projects/TheRoad-I1/backend/app /Users/manta/Documents/Projects/TheRoad-I1/backend/tests
```

각 호출 사이트에 새 인자 (`chain_bg_owned_by_shot`) 누락 시 default 로 추가. round 4 Q3=A 로 `chain_bg_owned_enabled` 는 인자 자체가 없음.

- [ ] **Step 3: 회귀 0 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/ --ignore=tests/migration -x -q 2>&1 | tail -10
```

Expected: 1970+ passed / 20 failed (baseline) — 새 fail 0.

---

## Phase 8 — Dual Review + Single Commit

### Task 23: Codex review

- [ ] **Step 1: Codex 의뢰**

`codex:rescue` 또는 사용자가 별도 invoke. 변경 전체 (Phase 1~7) 을 spec 기준으로 review 의뢰. 변경 파일 목록 제공:

```
backend/app/core/steps/_owned_helpers.py (신설)
backend/app/core/steps/background_prompt_step.py (수정 — round 4 cp entry build 포함)
backend/app/modules/pipeline/background_prompt.py (수정)
backend/app/core/dto/scene_analysis.py (수정 — round 4 IMPORTANT 1: dataclass 위치)
backend/app/core/steps/scene_context_loader.py (수정 — load_all 만)
backend/app/core/steps/detail_steps.py (수정)
backend/app/core/step_manifest.py (수정)
backend/app/modules/llm/llm_client.py (수정)
(round 4 Q3=A: backend/app/core/config.py 수정 없음 — toggle 제거)
prompts/_base/background_prompt/5.202605032354/* (신설)
prompts/_base/scene_detail/15.202605032354/* (신설)
prompts/_base/scene_detail_owned_judge/1.202605032354/* (신설)
backend/tests/unit/test_owned_helpers.py (신설)
backend/tests/unit/test_background_prompt_owned_schema.py (신설)
backend/tests/unit/test_background_prompt_step_owned_carry.py (신설 — round 4)
backend/tests/unit/test_scene_context_owned_loader.py (신설)
backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py (신설)
backend/tests/unit/test_g3_2_judge_step_extension.py (신설)
backend/tests/unit/test_g3_2_user_edited_owned_drift.py (신설 — round 4 IMPORTANT 4)
backend/tests/unit/test_g3_2_consumer_propagation.py (신설 — round 4 MINOR 2)
backend/tests/integration/test_g3_2_consumer_wiring.py (신설)
```

요청 — spec section 0a / 0 / 1~12 모두 covered 했는지 + round 1/2/3 결정 모두 반영했는지 + silent fallback 차단 / contract violation / fail-fast / sentinel hash drift detection 빠짐없이.

- [ ] **Step 2: Codex 결과 fix loop**

BLOCKING / IMPORTANT 모두 반영. inline 수정 후 회귀 검증 다시 (Task 22).

### Task 24: Claude review

- [ ] **Step 1: feature-dev:code-reviewer agent 의뢰**

prompt: G3.2 spec 기준 코드 리뷰. 변경 전체 (Task 23 과 동일 파일 list).

- [ ] **Step 2: Claude 결과 fix loop**

BLOCKING / IMPORTANT 모두 반영. 회귀 검증.

### Task 25: 최종 commit + push

- [ ] **Step 1: staged 파일 확인**

Run:
```bash
git status
```

Expected: 위 파일 list 가 모두 modified 또는 untracked (새 prompt 디렉토리 + 새 helper / 테스트).

- [ ] **Step 2: stage + commit**

Run:
```bash
git add prompts/_base/background_prompt/5.202605032354 \
        prompts/_base/scene_detail/15.202605032354 \
        prompts/_base/scene_detail_owned_judge/1.202605032354 \
        backend/app/core/steps/_owned_helpers.py \
        backend/app/core/steps/background_prompt_step.py \
        backend/app/modules/pipeline/background_prompt.py \
        backend/app/core/dto/scene_analysis.py \
        backend/app/core/steps/scene_context_loader.py \
        backend/app/core/steps/detail_steps.py \
        backend/app/core/step_manifest.py \
        backend/app/modules/llm/llm_client.py \
        backend/tests/unit/test_owned_helpers.py \
        backend/tests/unit/test_background_prompt_owned_schema.py \
        backend/tests/unit/test_background_prompt_step_owned_carry.py \
        backend/tests/unit/test_scene_context_owned_loader.py \
        backend/tests/unit/test_g3_2_close_skip_sentinel_drift.py \
        backend/tests/unit/test_g3_2_judge_step_extension.py \
        backend/tests/unit/test_g3_2_user_edited_owned_drift.py \
        backend/tests/unit/test_g3_2_consumer_propagation.py \
        backend/tests/integration/test_g3_2_consumer_wiring.py

git commit -m "$(cat <<'EOF'
feat(pipeline): G3.2 bg/fg ownership contract — objects_owned_by_background

background_prompt v5: schema 에 objects_owned_by_background: string[]
(minItems=1) 추가. system Rule 12 — t2i_prompt 에 그린 환경 객체를
보통명사 list 로 enumerate. validate_bg_prompt_output() 가 strip/dedupe/
maxLen=80 normalize 후 cp 저장.

scene_detail v15: post-parse owned validation. close framing 이 아닌
variation 마다 별도 LLM judge (gpt-mini, _PIPELINE_STEP_EXTENSIONS
등록) 가 t2i_prompt × owned_list 검사. 위반 시 status="contract_violation".
모든 variation 에 owned_validation sentinel (schema_version, t2i_prompt_hash,
owned_hash, camera_direction_hash, validator, violations) CP-only
post-parse 저장. detail_schema.json 은 변경 없음 — LLM 응답 schema 와 CP
shape 분리. verify_completion 이 sentinel 존재 + 모든 variation (close-skip
포함) 의 validator type / t2i_prompt_hash / owned_hash / camera_direction_hash
4 종 drift 검사 → partial 시 allow_partial_downstream=False 가 downstream
차단. t2i_review 가 t2i_prompt 수정해도 sentinel 미갱신 → 다음
verify_completion 진입에서 t2i_prompt_hash drift 로 partial 마킹 (즉시
상태 전환 아님).

DAG 재배치: scene_detail 20→21.70, shot_dep_t2i 20.5→21.71, t2i_review
20.7→21.72. scene_detail.depends_on 에 background_prompt 추가.
background_prompt schema_version=2 manifest 에 명시.

close framing 처리: chain_bg_guide / camera_meta / owned 3 종 모두 skip
(round 2 #3 일관성). close skip sentinel 도 hash 포함 — close 내부
camera_direction drift 검출.

fail-fast (round 3 #3): _load_chain_bg_owned_by_shot() 진입부에서
background_mode on + cp schema<2 또는 partial owned ok background entry
시 AppError("contract_violation"). 옛 v4 cp / partial v5 cp 통과 차단.

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

- [ ] **Step 3: 회귀 최종 확인 + push**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && pytest tests/ --ignore=tests/migration -q 2>&1 | tail -5
```

Expected: 1970+ passed / 20 baseline failed.

```bash
git push origin main
```

---

## Self-Review Checklist

이 plan 작성 후 spec 대조:

**Spec coverage:**
- ✅ Spec 0a (round 3) 5 critical: judge extension (Task 10), background_render config_hash 정정 (commit msg + Task 22 회귀 가드), 옛 v4 fail-fast (Task 5/12), close skip hash (Task 4), category=all (Task 21 dep 회귀).
- ✅ Spec 0a (round 3) minor 2: shot 매핑 source (Task 12), normalize 책임 (Task 7).
- ✅ Spec 0 (round 2) 8 항목: source-of-truth = bp cp (Task 12), schema bump 축소 (Tasks 6/8/15/20), close 3 종 skip (Task 16), STATUS_CONTRACT_VIOLATION (Task 18/19), per-variation sentinel (Tasks 4/18/19), judge extension (Task 10), v5 prompt pack (Task 6).
- ✅ Spec 3 핵심 결정 4개: flat list (Task 6), LLM judge (Tasks 9/11/18), cascade regen (Task 8/15 — 사용자 force 시점), close skip (Task 16/18).
- ✅ Spec 4.1~4.7: 모든 task 커버.
- ✅ Spec 7.1~7.5: schema (Task 6), system (Tasks 6/14), judge prompts (Task 9), Rule C 갱신 (Task 14).
- ✅ Spec 8.1~8.8: 마이그레이션 전략 (Task 8/15: 자연 cascade), DAG 재배치 (Task 20), bg-mode-off / not_applicable (Task 21), v4 cp 차단 (Task 5/12/21), category=all (Task 21).

**Placeholder scan:**
- 모든 코드 step 에 실제 코드 + 정확한 file path + 정확한 명령 + expected output 포함.
- "TBD" / "TODO" / "appropriate error handling" 표현 없음.

**Type consistency:**
- `OWNED_SENTINEL_SCHEMA_VERSION` / `OWNED_VALIDATOR_FULL` / `OWNED_VALIDATOR_CLOSE_SKIP` / `OWNED_MAX_LEN` 모두 helper 모듈에서 정의 후 일관 사용.
- `build_owned_sentinel` 시그니처 (kw-only) — 모든 호출 사이트 동일.
- `_load_chain_bg_owned_by_shot` 반환 타입 `Dict[Tuple[int, int], List[str]]` — Task 12/13/16/19 모두 동일.
- `chain_bg_owned_by_shot` 필드명 — dataclass / phase2_prepend / _analyze_one / verify_completion 모두 동일.

이슈 없음.

---

## Execution Handoff

Plan 저장 위치: `docs/superpowers/plans/2026-05-03-g3.2-bg-fg-ownership-implementation.md`.

두 가지 실행 옵션:

1. **Subagent-Driven (recommended)** — 사용자가 task 단위 fresh subagent 디스패치, two-stage review.
2. **Inline Execution** — 본 세션에서 batch 실행, checkpoint 단위 review.

어느 쪽으로 갈지 알려주세요.
