# Attached Reference Identity Contract Implementation Plan (D5)

**Status**: DRAFT_R1 (dual review NEEDS_REVISION 적용 — B1 T2 batch compat / B2 T4 variation-loop validator / I1 bg_map enabled monkeypatch / I2 batch RPC source / I3 site 876 분류 / I4 AC↔step + AC-10 / I5 legacy char_sid single SOT / M1 placeholder 제거 / M2 TDD 분리 / M3 P2 strict exception)


> **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:** RPC `required_refs` 의 `(kind, id)` 가 attached refs 에 exact match 로 존재해야 통과하도록 reference contract 를 identity 기반으로 강화. label substring 매칭 폐기.

**Architecture:** `labeled_refs: list[tuple[str, bytes]]` 옆에 `attached_meta: list[tuple[str, str]]` 평행 리스트 추가. 각 ref 의 source identity 를 append 시점에 기록 (P1 — label 추론 금지). validator 가 (kind, id) subset coverage 매칭. silent fallback (composite→base, outlook→state, label parsing) 모두 별 kind 로 기록되어 자연스럽게 fail-fast (P2).

**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy / pytest. 데이터 변경 0 (runtime contract fix only). 13 append site 일괄 update + validator 재작성 + batch path wiring.

**Spec:** `docs/superpowers/specs/2026-05-09-attached-reference-identity-contract-design.md` (DRAFT_R2)

**Today's date:** 2026-05-09 / branch: `main` / HEAD: `db6ec84`

---

> **Non-goals enforcement** (이 plan 의 모든 task 에서 절대 추가 금지):
> - [ ] forbidden_refs 의미 확장 / allowed_refs / extra ref 차단 X
> - [ ] build_scene_attached_refs 의 RPC-driven 재설계 X
> - [ ] AttachedRef dataclass / 4-tuple 구조화 X (평행 리스트만)
> - [ ] state_variant ↔ character_outlook 대체 정책 X (별 kind 만)
> - [ ] reference_image_ids / actual_attached_refs 컬럼 변경 X
> - [ ] classify_from_the_reference 알고리즘 재설계 X (phantom guard 그대로)
> - [ ] scene_detail manifest / RPC producer 변경 X
> - [ ] 샷별 prompt 수동 보정 X
> - [ ] custom_prompt 경로 (manual override bypass) 변경 X (별 spec F7)
>
> 매 task 시작 시 reviewer 가 위 9개 위반 안 했는지 confirm. 위반 발견 시 task 중단 + spec 변경 협의.

---

## Task 순서 + 의존성

```
T1 (foundation) — bg_map entry 확장 + prev_shot helper 3-tuple
   ↓ blockedBy
T2 (meta plumbing) — resolver/builder return signature + 13 site append
   ↓ blockedBy
T3 (validator) — exact-match + chain_bg_lookup + readiness + length guard
   ↓ blockedBy
T4 (batch wiring) — generate_images 새 시그니처 적용 + validator 호출
   ↓ blockedBy
T5 (regression + canary) — 기존 tests update + S8 fixture + AC-1~15
```

각 task 는 독립 commit. T1 commit 후 T2 시작. 중간 commit 은 task 안의 step 단위로 자유 (failing test → implement → pass).

---

## Task 1: Foundation — bg_map entry + prev_shot helper return

**Goal:** chain_bg meta 와 prev_shot meta 를 P1 (라벨 파싱 금지) 에 따라 만들 수 있도록 데이터 source 확장. 후속 task 의 모든 meta 가 이 두 source 를 read.

**Files:**
- Modify: `backend/app/services/scene_checkpoint_loaders.py:93-200` (`_ingest_phase7_groups_shape` + `load_background_chain_bg_map`)
- Modify: `backend/app/services/scene_reference_service.py:660-810` (`build_prev_shot_background_ref`)
- Test: `backend/tests/services/test_scene_checkpoint_loaders.py` (신규 또는 기존 확장)
- Test: `backend/tests/services/test_scene_reference_service.py` (기존 확장)

### Step 1.1: bg_map entry contract 회귀 test (failing)

`backend/tests/services/test_scene_checkpoint_loaders.py` 에 추가:

**I1 fix (Codex)**: `load_background_chain_bg_map` 가 default `settings.background_chain_enabled=False` 환경에서 빈 dict 반환 가능. 모든 bg_map test 가 fixture 안에서 `monkeypatch.setattr(settings, "background_chain_enabled", True)` 명시 — toggle 으로 잘못 fail 방지. (실제 loader 코드를 grep 으로 enabled 검사 위치 확인 후 적용)

```python
import pytest
from app.core.config import settings


@pytest.fixture
def bg_chain_enabled(monkeypatch):
    """bg_map loader 가 toggle off 로 빈 dict 반환하지 않도록 강제 enable.
    I1 (Codex review) — 실제 default off 환경에서 test 가 잘못된 이유로 fail 회피.
    """
    monkeypatch.setattr(settings, "background_chain_enabled", True)
    return True


def test_load_background_chain_bg_map_entry_includes_bg_id_and_location_id(tmp_path, bg_chain_enabled):
    """AC-13: bg_map entry 가 bg_id + location_id 보존 (D5 §4.2.1)."""
    proj = tmp_path / "project"
    ep_cp_dir = proj / "checkpoints" / "episodes" / "ep1" / "background_render"
    ep_cp_dir.mkdir(parents=True)
    png_path = tmp_path / "bg_kitchen.png"
    png_path.write_bytes(b"fakepng")
    cp = ep_cp_dir / "checkpoint.json"
    cp.write_text(json.dumps({
        "data": {
            "groups": {
                "bg_kitchen_morning": {
                    "status": "ok",
                    "png_path": str(png_path),
                    "shot_ids": ["S5_Shot3"],
                    "location_id": "L05",
                }
            }
        }
    }))
    bg_map = load_background_chain_bg_map(str(tmp_path), "project", "ep1")
    assert "5_3" in bg_map
    entry = bg_map["5_3"]
    assert entry["bg_id"] == "bg_kitchen_morning"  # NEW (D5)
    assert entry["location_id"] == "L05"  # NEW (D5)
    assert entry["image_bytes"] == b"fakepng"  # 기존 보존
    assert "label" in entry  # 기존 보존
```

**검증**: loader 가 실제로 `settings.background_chain_enabled` 분기 갖는지 grep 으로 확인:

```bash
grep -n "background_chain_enabled\|background_mode\|chain_enabled" backend/app/services/scene_checkpoint_loaders.py
```

- [ ] Hit 있으면 fixture 적용 의무
- [ ] Hit 없으면 toggle 무관 — fixture 생략 가능 (단 spec §4.2.1 의 Phase 7/5 dual loader 가 enabled 분기 갖는 것이 일반적이므로 일관성 위해 fixture 유지 권장)

- [ ] Run: `cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/test_scene_checkpoint_loaders.py::test_load_background_chain_bg_map_entry_includes_bg_id_and_location_id -v`

Expected: FAIL — `KeyError: 'bg_id'` 또는 assertion failure.

### Step 1.2: bg_map entry 확장 구현

`backend/app/services/scene_checkpoint_loaders.py:135-141` 변경:

```python
# 기존
bg_map[key] = {
    "image_bytes": image_bytes,
    "label": (
        f"background chain ref ({bg_id} for {loc_id}) — "
        f"match wall/floor/ceiling/lighting"
    ),
}

# 신규 (D5 §4.2.1)
bg_map[key] = {
    "image_bytes": image_bytes,
    "label": (
        f"background chain ref ({bg_id} for {loc_id}) — "
        f"match wall/floor/ceiling/lighting"
    ),
    "bg_id": bg_id,           # P1 — caller 가 라벨 파싱 안 하도록
    "location_id": loc_id,    # P1
}
```

`bg_id` 는 함수 안 line 105 의 dict key, `loc_id` 는 line 116 의 변수 — 이미 보유한 값을 그대로 entry 에 추가.

- [ ] Run: 같은 명령어
- [ ] Expected: PASS

### Step 1.3a: Phase 5 fallback failing test (M2 fix — TDD 분리)

`backend/app/services/scene_checkpoint_loaders.py:146-200` 의 `_ingest_phase5_shape` (있으면) 또는 동등 분기에서 같은 entry 형식 보장.

`grep -n "bg_map\[" backend/app/services/scene_checkpoint_loaders.py` 로 모든 entry insertion 사이트 enumerate. **각 insertion site 가 `bg_id` + `location_id` 둘 다 set 하지 않으면 task 실패**.

failing test 작성 (Step 1.2 의 Phase 7 구현 후, Phase 5 구현 *전*):

```python
def test_load_background_chain_bg_map_phase5_fallback_entry_shape(tmp_path, bg_chain_enabled):
    """Phase 5 fallback (background_chain_render) 도 entry shape 동등 — failing test."""
    # Phase 7 file 부재 + Phase 5 file 만 존재 시
    proj = tmp_path / "project"
    ep_cp_dir = proj / "checkpoints" / "episodes" / "ep1" / "background_chain_render"
    ep_cp_dir.mkdir(parents=True)
    png_path = tmp_path / "bg_phase5.png"
    png_path.write_bytes(b"phase5png")
    cp = ep_cp_dir / "checkpoint.json"
    cp.write_text(json.dumps({
        "data": {
            "groups": {
                "bg_phase5_test": {
                    "status": "ok",
                    "png_path": str(png_path),
                    "shot_ids": ["S5_Shot3"],
                    "location_id": "L05",
                }
            }
        }
    }))
    bg_map = load_background_chain_bg_map(str(tmp_path), "project", "ep1")
    for entry in bg_map.values():
        assert "bg_id" in entry, "Phase 5 fallback entry missing bg_id"
        assert "location_id" in entry, "Phase 5 fallback entry missing location_id"
```

- [ ] Run: FAIL (Phase 5 분기 미update — Step 1.2 가 Phase 7 만 update 했을 가능성)

### Step 1.3b: Phase 5 분기 구현 + verify pass

`scene_checkpoint_loaders.py` 안 Phase 5 fallback 분기 (있다면) 에 동일 entry 확장 적용. 사이트 grep 후 모든 `bg_map[key] = {...}` 에 `bg_id` + `location_id` 보장.

- [ ] Run: 위 test PASS
- [ ] grep verify: `grep -n "bg_map\[.*\] = " backend/app/services/scene_checkpoint_loaders.py` 결과 모든 site 가 bg_id+location_id 포함

### Step 1.4: prev_shot helper 3-tuple 회귀 test (failing)

`backend/tests/services/test_scene_reference_service.py` 에 추가:

```python
def test_build_prev_shot_background_ref_returns_3tuple_with_loc_id():
    """AC-14: helper return 이 (label, bytes, loc_id) 3-tuple (D5 §4.2.2)."""
    svc = SceneReferenceService(db=mock_db, project_id="p1")
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"fake",
        still_data={"scene_index": 5, "shot_index": 3},
        visible_entities=[{"id": "char1", "entity_type": "character"}],
        current_location_ids=["L05"],
        dep_scene_id=None,
        stills=[],
        location_scene_history={"L05": (b"fake", {"id": "prev_still", "visible_entities_json": "[]"})},
        dep_detail_map={},
        staging=None,
        state_variant_sids={},
        entity_lookup={},
    )
    assert result is not None
    assert len(result) == 3, "expected (label, bytes, loc_id) 3-tuple"
    label, image_bytes, loc_id = result
    assert isinstance(label, str)
    assert isinstance(image_bytes, bytes)
    assert loc_id == "L05"  # location_scene_history 첫 매칭


def test_build_prev_shot_background_ref_loc_id_priority_dep_scene_first():
    """loc_id 우선순위: dep_scene_id 의 location > location_scene_history 첫 매칭."""
    svc = SceneReferenceService(db=mock_db, project_id="p1")
    # dep_scene_id 의 location 이 L08, current_location_ids 첫 항이 L05 → L08 선택
    result = svc.build_prev_shot_background_ref(
        best_prev_bytes=b"fake",
        still_data={"scene_index": 5, "shot_index": 3},
        visible_entities=[],
        current_location_ids=["L05"],
        dep_scene_id="dep_still_L08",
        stills=[{"id": "dep_still_L08", "visible_entities_json": json.dumps([{"id": "L08", "entity_type": "location"}])}],
        location_scene_history={},
        dep_detail_map={},
        staging=None,
        state_variant_sids={},
        entity_lookup={"L08": {"entity_type": "location"}},
    )
    assert result is not None
    _, _, loc_id = result
    assert loc_id == "L08", "dep_scene_id 의 location 이 우선이어야 함"
```

- [ ] Run: FAIL — return 이 2-tuple 이라 unpack 실패

### Step 1.5: prev_shot helper return 확장 구현

`backend/app/services/scene_reference_service.py:676-804` 변경:

```python
# 기존
def build_prev_shot_background_ref(
    self,
    *,
    best_prev_bytes: Optional[bytes],
    ...
) -> Optional[Tuple[str, bytes]]:
    ...
    if not best_prev_bytes:
        return None
    ...
    return (label, best_prev_bytes)

# 신규 (D5 §4.2.2)
def build_prev_shot_background_ref(
    self,
    *,
    best_prev_bytes: Optional[bytes],
    ...
) -> Optional[Tuple[str, bytes, str]]:
    ...
    if not best_prev_bytes:
        return None
    
    # loc_id 결정 우선순위 (helper 가 source-of-truth):
    #   1. dep_scene_id 가 있으면 그 dep_scene 의 location_id (visible_entities_json
    #      안에서 entity_type='location' 첫 항)
    #   2. 아니면 current_location_ids 중 location_scene_history 매칭된 loc_id
    loc_id = None
    if dep_scene_id and prev_still_data:
        try:
            prev_vis = json.loads(prev_still_data.get("visible_entities_json", "[]"))
            loc_id = next(
                (pv.get("id") for pv in prev_vis
                 if isinstance(pv, dict) and pv.get("entity_type") == "location"),
                None,
            )
        except (json.JSONDecodeError, TypeError):
            loc_id = None
    if not loc_id:
        for lid in current_location_ids:
            if lid in location_scene_history:
                loc_id = lid
                break
    
    if not loc_id:
        # P2 strict (M3 Claude fix): silent fallback 차단. loc_id 결정 못하면
        # prev_shot ref 자체 거부 — None 반환 후 caller (build_scene_attached_refs)
        # 가 prev_shot 미주입. 기존 try/except Exception 의 broad catch 도 동일 정책
        # — exception 후 ref + meta 동시 skip (length match invariant 1 보존).
        logger.warning(
            "Scene %d Shot %d: prev_shot loc_id 결정 실패 — ref 미주입 (P2 strict)",
            still_data.get("scene_index", 0), still_data.get("shot_index", 0),
        )
        return None
    
    ...  # 기존 label 결정 로직 유지
    return (label, best_prev_bytes, loc_id)
```

**M3 fix — caller `build_scene_attached_refs` 의 exception handling**:

`scene_generation_coordinator.py:327-335` 의 try/except 가 `_prev_shot_ref = None` set 후 다음 분기 — labeled_refs 미주입. 동일하게 attached_meta 도 미주입 (length match invariant). exception 시 logger.warning 만 — silent skip 의도 명시:

```python
try:
    _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
except Exception as exc:
    # P2 strict: exception 시 prev_shot ref + meta 동시 skip.
    # silent fallback 금지 (sentinel meta 위조 X).
    logger.warning(
        "Scene %d Shot %d: prev_shot_ref build failed (%s) — entity-only fallback (P2)",
        still_data.get("scene_index", 0), still_data.get("shot_index", 0), exc,
    )
    _prev_shot_ref = None
# 다음 분기: _prev_shot_ref None 시 labeled_refs/attached_meta 모두 미변경
```

- [ ] Run + Expected PASS (양쪽 test)

### Step 1.6: 기존 caller 미변경 임시 호환 검증

`build_prev_shot_background_ref` 의 기존 caller 2곳:
- `scene_generation_coordinator.py:314-337` (단건 build_scene_attached_refs)
- `scene_generation_coordinator.py:578-590` (배치)

이 두 caller 는 **T2/T4 에서 update**. T1 commit 후 두 caller 는 일시적으로 깨짐 (3-tuple unpack 못함). 따라서 **T1 의 commit 은 T2 와 묶어서 push** 또는 **T2 가 빠르게 따라가야 함**.

대안: T1 에서 caller 를 임시로 `result = helper(...); if result: label, bytes_, _loc_id = result; ...` 형식으로 update 해서 broken 상태 회피. **권장 — T1 commit 이 standalone green 이어야 함.**

- [ ] `scene_generation_coordinator.py:336` 변경:

```python
# 기존
if _prev_shot_ref:
    labeled_refs.insert(0, _prev_shot_ref)

# 신규 (T1 임시 — T2 에서 attached_meta insert 추가)
if _prev_shot_ref:
    _label, _bytes, _loc_id = _prev_shot_ref
    labeled_refs.insert(0, (_label, _bytes))
    # T2: attached_meta.insert(0, ("background_prev_shot", _loc_id))
```

같은 변경을 line 590 (배치) 에도 적용.

- [ ] Run: `cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/ -x` (전체 services 회귀 0)
- [ ] Expected: PASS (전체)

### Step 1.7: T1 commit

```bash
git add backend/app/services/scene_checkpoint_loaders.py \
        backend/app/services/scene_reference_service.py \
        backend/app/services/scene_generation_coordinator.py \
        backend/tests/services/test_scene_checkpoint_loaders.py \
        backend/tests/services/test_scene_reference_service.py
git commit -m "$(cat <<'EOF'
feat(scene_image): T1 D5 foundation — bg_map entry + prev_shot helper return 확장

spec: docs/superpowers/specs/2026-05-09-attached-reference-identity-contract-design.md §4.2.1+§4.2.2

- load_background_chain_bg_map entry 에 bg_id + location_id 보존
- build_prev_shot_background_ref return 을 (label, bytes, loc_id) 3-tuple 확장
- caller 2곳 임시 unpack 호환 (T2 에서 attached_meta insert 추가)

P1 (라벨 파싱 금지) 강화 — meta source-of-truth 확보. T2 의 prerequisite.

AC-13 / AC-14 unit tests 추가.

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

- [ ] Verify: `git log -1 --stat` shows 5 files modified

---

## Task 2: Meta plumbing — resolver/builder return signature 확장

**Goal:** `attached_meta` 평행 리스트를 13 append site 에서 동시 build. resolver + builder + helper + 단건 caller 까지 새 시그니처 적용.

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:340-499` (`resolve_refs_for_prompt` — 9 append sites)
- Modify: `backend/app/services/scene_reference_service.py:110-162` (`build_image_index` — meta passthrough)
- Modify: `backend/app/services/scene_generation_coordinator.py:198-357` (`build_scene_attached_refs` — chain_bg + prev_shot meta insert)
- Modify: `backend/app/services/scene_generation_coordinator.py:1029-1115` (`_build_single_scene_prompt_and_refs` — 새 시그니처 받음)
- Test: `backend/tests/services/test_scene_reference_service.py` (resolver meta 검증)
- Test: `backend/tests/services/test_scene_generation_coordinator.py` (builder meta 검증)

### Step 2.1: resolver meta 매핑 회귀 test (failing)

`backend/tests/services/test_scene_reference_service.py` 에 추가:

```python
def test_resolve_refs_for_prompt_returns_labeled_and_meta_lists(svc):
    """T2: resolver return 이 (labeled_refs, attached_meta) 2-tuple."""
    visible_entities = [
        {"id": "c1_uuid", "short_id": "C01", "name": "수리영", "entity_type": "character"},
        {"id": "p3_uuid", "short_id": "P03", "name": "스마트폰", "entity_type": "prop"},
    ]
    entity_lookup = {ve["id"]: ve for ve in visible_entities}
    scene_ref_image_map = {
        "composite:c1_uuid:o2_uuid": b"composite_bytes",
        "p3_uuid": b"prop_bytes",
    }
    # outlook entity 도 lookup 에 추가
    entity_lookup["o2_uuid"] = {"id": "o2_uuid", "short_id": "O02", "entity_type": "outlook", "name": "casual"}
    
    result = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks past the supermarket holding 스마트폰.",
        visible_entities=visible_entities,
        scene_ref_image_map=scene_ref_image_map,
        entity_lookup=entity_lookup,
        state_variant_sids={},
    )
    
    assert isinstance(result, tuple) and len(result) == 2, "expected (labeled_refs, attached_meta)"
    labeled_refs, attached_meta = result
    assert len(labeled_refs) == len(attached_meta), "len mismatch (invariant 1)"
    
    # composite (C01O02) → ("character_outlook", "C01O02")
    assert ("character_outlook", "C01O02") in attached_meta
    # prop P03 → ("prop", "P03")
    assert ("prop", "P03") in attached_meta


def test_resolve_refs_for_prompt_base_character_meta_when_composite_missing(svc):
    """P2: composite 부재 시 base ref 가 ("character", "C01") 별 kind — outlook 만족 X."""
    visible_entities = [
        {"id": "c1_uuid", "short_id": "C01", "name": "수리영", "entity_type": "character"},
    ]
    entity_lookup = {ve["id"]: ve for ve in visible_entities}
    entity_lookup["o2_uuid"] = {"id": "o2_uuid", "short_id": "O02", "entity_type": "outlook", "name": "casual"}
    scene_ref_image_map = {"c1_uuid": b"base_bytes"}  # composite 부재, base 만 존재
    
    _, attached_meta = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 walks.",
        visible_entities=visible_entities,
        scene_ref_image_map=scene_ref_image_map,
        entity_lookup=entity_lookup,
        state_variant_sids={},
    )
    
    # P2: base fallback → 별 kind, character_outlook 위조 금지
    assert ("character", "C01") in attached_meta
    assert ("character_outlook", "C01O02") not in attached_meta


def test_resolve_refs_for_prompt_state_variant_meta_separate_kind(svc):
    """state_variant 가 character_state 별 kind. character_outlook 만족 X (P2)."""
    visible_entities = [
        {"id": "c1_uuid", "short_id": "C01", "name": "수리영", "entity_type": "character"},
    ]
    entity_lookup = {ve["id"]: ve for ve in visible_entities}
    sv_key = "state_variant:c1_uuid:unconscious"
    scene_ref_image_map = {sv_key: b"sv_bytes"}
    
    _, attached_meta = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 lies.",
        visible_entities=visible_entities,
        scene_ref_image_map=scene_ref_image_map,
        entity_lookup=entity_lookup,
        state_variant_sids={"C01": {"key": sv_key, "state": "unconscious"}},
    )
    
    assert ("character_state", "C01:unconscious") in attached_meta
    assert ("character_outlook", "C01O02") not in attached_meta
```

- [ ] Run: FAIL — `result` 가 list 라 2-tuple unpack 실패

### Step 2.2: resolver `resolve_refs_for_prompt` return + 9 append sites update

`backend/app/services/scene_reference_service.py:340-499` 변경:

```python
def resolve_refs_for_prompt(
    self,
    t2i_prompt: str,
    visible_entities: List[Dict[str, Any]],
    scene_ref_image_map: Dict[str, bytes],
    entity_lookup: Dict[str, Dict[str, Any]],
    state_variant_sids: Optional[Dict[str, Dict[str, str]]] = None,
    *,
    shot_description: Optional[str] = None,
) -> Tuple[List[Tuple[str, bytes]], List[Tuple[str, str]]]:  # NEW return
    """T2I 프롬프트에서 참조 이미지 매칭 + meta 평행 리스트.
    
    D5 §4.2: attached_meta = list[(kind, id)] — 13 append site 동시 build.
    P1: append 시점의 source id 사용 (label 추론 X).
    """
    import re as _re
    labeled_refs: List[Tuple[str, bytes]] = []
    attached_meta: List[Tuple[str, str]] = []  # NEW
    _used_ref_ids: set = set()
    ...
```

**append site 분류** (Claude I3 fix — site 876 명확화):

resolver 의 9 grep hit 중 8 site 가 D5 변경 대상. site 876 (`build_custom_labeled_refs`) 은 custom_prompt 경로 — spec §3 + AC-15 의 명시적 out-of-scope. 따라서 **resolver 변경 = 8 site, out-of-scope = 1 site**.

각 8 변경 site:

| line (현재) | 출처 | 추가할 meta append |
|---|---|---|
| 391 (state_variant) | C##O## state_variant | `attached_meta.append(("character_state", f"{char_sid}:{sv['state']}"))` |
| 408 (O00 base) | C## base (O00) | `attached_meta.append(("character", char_sid))` |
| 417 (composite) | C##O## outfit | `attached_meta.append(("character_outlook", sid))` ← `sid = match.group(0) = "C01O02"` |
| 424 (composite 부재 fallback) | C## base | `attached_meta.append(("character", char_sid))` |
| 439 (legacy 미지정) | C## base | `attached_meta.append(("character", char_sid))` ← I5 fix: `entity_lookup[char_id]["short_id"]` SOT |
| 446 (legacy composite) | C##O## outfit | `attached_meta.append(("character_outlook", composite_sid))` ← composite_sid = char_sid + outlook_sid |
| 455 (legacy fallback) | C## base | `attached_meta.append(("character", char_sid))` ← I5 fix |
| 496 (prop) | P## | `attached_meta.append(("prop", prop_sid))` |
| **876 (custom_prompt — out-of-scope)** | — | **변경 0** — D5 미적용 (AC-15 검증) |

coordinator 의 4 site (chain_bg + prev_shot, 단건 + 배치) 는 step 2.5 + step 4.2 에서 별도 처리. 합계 = resolver 8 + coordinator 4 = **12 변경 site + 1 out-of-scope 명시 = 13 enumerate**.

**legacy [[name]+[outlook]] 패턴 (line 431-458) 의 char_sid 추출 — I5 fix (Claude single SOT)**:

기존 plan 의 `_sid_to_uuid_inv.get(char_id, "")` fallback 폐기 — `entity_lookup[char_id]["short_id"]` 단일 source-of-truth 사용. P1 강화 (label/regex 추론 X, append 시점 보유 source id 만).

```python
# legacy 경로 char_sid / outlook_sid 결정 — single SOT
char_info = entity_lookup.get(char_id) or {}
char_sid = char_info.get("short_id", "")
if not char_sid:
    # P2: SOT 부재 시 silent meta 위조 차단. ref + meta 동시 skip.
    logger.warning(
        "legacy ref skip: char_id=%s entity_lookup 의 short_id 부재 (P1 SOT 위반 회피)",
        char_id[:8] if char_id else "<empty>",
    )
    continue  # labeled_refs append 도 안 함 — invariant 1 (length match) 보존

# composite 경로
if outlook_id:
    outlook_info = entity_lookup.get(outlook_id) or {}
    outlook_sid = outlook_info.get("short_id", "")
    if outlook_sid:
        composite_sid = f"{char_sid}{outlook_sid}"  # "C01O02"
        labeled_refs.append((label, scene_ref_image_map[composite_key]))
        attached_meta.append(("character_outlook", composite_sid))
        ...
        continue

# base fallback
labeled_refs.append((label, scene_ref_image_map[char_id]))
attached_meta.append(("character", char_sid))
```

**P1 SOT 의무**: `_sid_to_uuid_inv` / regex name parsing / label substring split 모두 금지. `entity_lookup[char_id]["short_id"]` 가 유일 source.

return 변경:

```python
return labeled_refs, attached_meta  # 기존: return labeled_refs
```

- [ ] Run: `cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/test_scene_reference_service.py::test_resolve_refs_for_prompt_returns_labeled_and_meta_lists tests/services/test_scene_reference_service.py::test_resolve_refs_for_prompt_base_character_meta_when_composite_missing tests/services/test_scene_reference_service.py::test_resolve_refs_for_prompt_state_variant_meta_separate_kind -v`
- [ ] Expected: PASS (3개)

### Step 2.3: `build_image_index` meta passthrough test (failing)

```python
def test_build_image_index_passthrough_meta_unchanged(svc):
    """T2: _build_image_index_helper 는 label rewrite, meta 변경 0 (P1 강화)."""
    labeled_refs = [
        ("character C01 identity", b"c01_bytes"),
        ("object P03", b"p03_bytes"),
    ]
    attached_meta = [
        ("character", "C01"),
        ("prop", "P03"),
    ]
    entity_lookup = {
        "c1_uuid": {"id": "c1_uuid", "short_id": "C01", "name": "수리영", "t2i_prompt": "..."},
        "p3_uuid": {"id": "p3_uuid", "short_id": "P03", "name": "스마트폰", "t2i_prompt": "..."},
    }
    indexed_refs, sid_to_img, sid_info, indexed_meta = build_image_index(
        labeled_refs, entity_lookup, attached_meta=attached_meta,
    )
    assert len(indexed_refs) == len(indexed_meta)
    # label 은 rewrite 됨
    assert indexed_refs[0][0].startswith("Image 1 (character reference):")
    # meta 는 변경 없음 (P1)
    assert indexed_meta == attached_meta
```

- [ ] Run: FAIL — `build_image_index` signature 가 attached_meta param 없음

### Step 2.4: `build_image_index` signature 확장 (passthrough only)

`backend/app/services/scene_reference_service.py:110-162` 변경:

```python
def build_image_index(
    labeled_refs: list,
    entity_lookup: Dict[str, Dict],
    *,
    attached_meta: Optional[List[Tuple[str, str]]] = None,
) -> tuple:
    """labeled_refs에 Image N 번호 부여. meta 는 평행 통과 (label 만 rewrite).
    
    Returns: (indexed_labeled_refs, sid_to_img_map, sid_info, indexed_attached_meta)
    """
    ...
    # 기존 indexing 로직 — label 만 rewrite, meta 는 같은 인덱스 그대로
    indexed_meta = list(attached_meta) if attached_meta is not None else []
    return indexed_refs, sid_to_img, _sid_info, indexed_meta
```

caller 변경: `_build_image_index_helper` import 사이트 (`scene_generation_coordinator.py:341` + `:597`) 가 4-tuple 받음. 단 T2 에서는 builder + 단건 caller 만 update, batch caller (line 597) 는 T4 에서.

```python
# 단건 (line 341 in build_scene_attached_refs)
labeled_refs, _sid_to_img, _sid_info, attached_meta = _build_image_index_helper(
    labeled_refs, entity_lookup, attached_meta=attached_meta,
)
```

- [ ] Run: passthrough test PASS

### Step 2.5: `build_scene_attached_refs` chain_bg + prev_shot meta insert

`backend/app/services/scene_generation_coordinator.py:198-357` 변경:

```python
def build_scene_attached_refs(
    *, still, episode_id, still_data, visible_entities, ref_image_map,
    cached_style_context, cached_entity_text_map,
    scene_paths_by_index_by_id, location_scene_history,
    background_chain_bg_map, dep_detail_map,
    staging, entity_lookup, project_id, project_config,
    reference_svc, stills=None,
) -> Tuple[str, List[Tuple[str, bytes]], List[Tuple[str, str]]]:  # NEW return
    """spec D5 §4.3: return (prompt, labeled_refs, attached_meta)."""
    ...
    # line 260: resolve_refs_for_prompt — 새 시그니처
    labeled_refs, attached_meta = reference_svc.resolve_refs_for_prompt(
        var_t2i, visible_entities, scene_ref_image_map, entity_lookup,
        state_variant_sids,
        shot_description=still_data.get("shot_description")
        or still_data.get("still_frame_prompt"),
    )
    ...
    # line 296 (chain_bg insert) — meta 동시 insert
    _bc_bg = background_chain_bg_map.get(_bc_key)
    if _bc_bg and _bc_bg.get("image_bytes") and not _is_close_framing:
        labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))
        attached_meta.insert(0, ("background", _bc_bg["bg_id"]))  # NEW (P1: T1 의 entry 에서 read)
        ...
    else:
        ...
        # line 314-337 (prev_shot insert) — 3-tuple unpack + meta 동시 insert
        try:
            _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
        except Exception as exc:
            ...
            _prev_shot_ref = None
        if _prev_shot_ref:
            _label, _bytes, _loc_id = _prev_shot_ref  # T1 의 3-tuple
            labeled_refs.insert(0, (_label, _bytes))
            attached_meta.insert(0, ("background_prev_shot", _loc_id))  # NEW (P1)
    
    # line 341 — image_index — meta 평행 통과
    labeled_refs, _sid_to_img, _sid_info, attached_meta = _build_image_index_helper(
        labeled_refs, entity_lookup, attached_meta=attached_meta,
    )
    ...
    return _full_prompt, labeled_refs, attached_meta
```

builder integration test:

```python
def test_build_scene_attached_refs_meta_includes_chain_bg_and_prev_shot(...):
    """T2: builder 가 chain_bg meta + prev_shot meta 동시 insert."""
    # chain_bg 가능한 시나리오
    bg_map = {
        "8_4": {
            "image_bytes": b"chainbg",
            "label": "background chain ref (bg_store_… for L09)",
            "bg_id": "bg_store_sales_floor_dusk_busy_exit_visible",
            "location_id": "L09",
        }
    }
    prompt, labeled_refs, attached_meta = build_scene_attached_refs(...)
    assert ("background", "bg_store_sales_floor_dusk_busy_exit_visible") in attached_meta
```

- [ ] Run + Expected PASS

### Step 2.6: `_build_single_scene_prompt_and_refs` 새 시그니처 적용

`backend/app/services/scene_generation_coordinator.py:1029-1115` 변경:

```python
def _build_single_scene_prompt_and_refs(
    self, *, still, episode_id, still_data, visible_entities,
    ref_image_map=None,
) -> Tuple[str, List[Tuple[str, bytes]], List[Tuple[str, str]]]:
    """D5 §4.3: return (prompt, labeled_refs, attached_meta)."""
    ...
    full_prompt, labeled_refs, attached_meta = build_scene_attached_refs(...)  # 3-tuple
    
    # validator 호출은 T3 에서 attached_meta 추가
    ...
    validate_attached_refs(
        _rpc, labeled_refs, full_prompt, is_close_framing=_is_close_framing,
    )
    # T3 에서 변경: validate_attached_refs(_rpc, labeled_refs, attached_meta, full_prompt, ...)
    
    return full_prompt, labeled_refs, attached_meta
```

caller `scene_image_service.py:670-676` 도 3-tuple 받음:

```python
_full_prompt, labeled_refs, attached_meta = self._coord._build_single_scene_prompt_and_refs(
    still=still,
    episode_id=episode_id,
    still_data=still_data,
    visible_entities=visible_entities,
    ref_image_map=ref_image_map,
)
# attached_meta 는 T3 까지 사용 안 함 — discard 가능, 단 length 검증 회귀 위해 보존
```

- [ ] Run: `cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/ -x` (전체)
- [ ] Expected: PASS (전체 회귀 0)

### Step 2.6b: batch path 임시 unpack compat (B1 fix — T2 standalone green 의무)

**왜 필요한가**: T2 가 resolver/builder return 시그니처 변경. 단건 caller 만 update 하면 batch path (`_generate_scene_in_loop` line 526/555/590/597) 가 1-list 가정으로 짜여 있어 T2 commit 후 broken. T4 까지 미update 시 `pytest tests/services/` 가 batch path 회귀 catch 못 하면 silent fallback origin (Claude+Codex 양쪽 BLOCKING B1).

T1 step 1.6 의 caller 임시 호환 패턴 동일 적용. attached_meta 활성화는 T4 — 여기는 **시그니처 broken 차단만**.

`backend/app/services/scene_generation_coordinator.py:526` 변경:

```python
# 기존
labeled_refs = self._reference_svc.resolve_refs_for_prompt(...)

# T2 임시 (T4 에서 attached_meta 활성화)
labeled_refs, _attached_meta_unused = self._reference_svc.resolve_refs_for_prompt(...)
# T4: _attached_meta_unused 활성화 + chain_bg/prev_shot meta insert + validator 호출
```

`backend/app/services/scene_generation_coordinator.py:555` (chain_bg insert) — labeled_refs 만 insert (T2 임시):

```python
# T2 임시 — meta insert 는 T4
labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))
# T4: _attached_meta_unused.insert(0, ("background", _bc_bg["bg_id"]))
```

`backend/app/services/scene_generation_coordinator.py:590` (prev_shot insert) — 3-tuple unpack:

```python
# T2 임시
if _prev_shot_ref:
    _label, _bytes, _loc_id = _prev_shot_ref  # T1 의 3-tuple
    labeled_refs.insert(0, (_label, _bytes))
    # T4: _attached_meta_unused.insert(0, ("background_prev_shot", _loc_id))
```

`backend/app/services/scene_generation_coordinator.py:597` (image_index helper) — 4-tuple unpack:

```python
# T2 임시 — meta passthrough discard
labeled_refs, _sid_to_img, _sid_info, _attached_meta_unused = _build_image_index_helper(
    labeled_refs, entity_lookup, attached_meta=_attached_meta_unused,
)
# T4: discard 폐기, attached_meta 활성화
```

추가 회귀 test (batch path 시그니처 broken catch):

```python
def test_generate_scene_in_loop_signature_compat_T2(...):
    """T2 commit 후 batch path 가 새 시그니처 받아 broken 되지 않음을 보장.
    T4 까지 attached_meta 는 discard 임시. 이 test 는 T4 에서 활성화 검증으로 진화.
    """
    coord = SceneGenerationCoordinator(...)
    result = coord._generate_scene_in_loop(si=0, ...)
    assert isinstance(result, tuple) and len(result) == 4  # 기존 contract
    # labeled_refs 정상 list (튜플 아님)
    assert isinstance(result[1], list) or result[1] is None
```

- [ ] Run: `pytest tests/services/test_scene_generation_coordinator.py -x`
- [ ] Expected: PASS (batch path standalone green)

### Step 2.7: T2 commit

```bash
git add backend/app/services/scene_reference_service.py \
        backend/app/services/scene_generation_coordinator.py \
        backend/app/services/scene_image_service.py \
        backend/tests/services/test_scene_reference_service.py \
        backend/tests/services/test_scene_generation_coordinator.py
git commit -m "$(cat <<'EOF'
feat(scene_image): T2 D5 meta plumbing — resolver/builder return signature 확장

spec: §4.2 + §4.3

- resolve_refs_for_prompt return → (labeled_refs, attached_meta)
- 13 append site 중 9 site (resolver) 에 meta 동시 append
  - composite (C01O02), base (C01), state_variant (C01:state),
    legacy 변형 3, prop (P03)
- build_image_index 가 attached_meta passthrough (label rewrite 만, P1 강화)
- build_scene_attached_refs return → (prompt, labeled_refs, attached_meta)
- 단건 chain_bg insert (:296) + prev_shot insert (:337) 에 meta 동시 insert
  (T1 의 bg_map.bg_id / prev_shot helper.loc_id source-of-truth read)
- _build_single_scene_prompt_and_refs / scene_image_service.generate_single
  새 시그니처 적용

T1 blockedBy. T3 의 prerequisite (validator 가 attached_meta 받음).

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

---

## Task 3: Validator exact-match + chain_bg_lookup

**Goal:** `validate_attached_refs` 가 (kind, id) subset coverage. substring `_has_*_ref` 폐기. readiness/length/strict 가드.

**Files:**
- Modify: `backend/app/core/ref_contract_validator.py` (전면 재작성)
- Create: `backend/app/services/chain_bg_lookup.py` (신규 helper) 또는 inline in validator
- Modify: `backend/app/services/scene_generation_coordinator.py:1093-1113` (validator 호출 site)
- Test: `backend/tests/core/test_ref_contract_validator.py` (전면 재작성)

### Step 3.1: AC-1 S8 회귀 fixture test (failing — 이게 핵심)

```python
def test_S8_canary_character_t2i_prompt_white_background_does_not_satisfy_bg_required():
    """AC-1 (사용자 binding): C01 t2i_prompt 안 'plain white background' inline 상태에서
    background 요구가 차단되어야 함. D5 의 raison d'être."""
    rpc = {
        "asset_requirements": {
            "required_refs": [
                {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
                {"kind": "background", "id": "bg_store_sales_floor_dusk_busy_exit_visible", "policy": "required"},
            ],
            "readiness_policy": "block_if_missing",
        }
    }
    # S8 실제 production 상태 — labeled_refs 1개 (C01 base)
    labeled_refs = [(
        "Image 1 (character reference): 수리영 — Set in modern mid-2020s, "
        "대한민국. Passport-style ID photo, head and upper chest visible, "
        "plain white background. Korean early 20s female, ...",
        b"c01_bytes",
    )]
    attached_meta = [("character", "C01")]  # composite 부재 fallback
    
    # chain_bg_lookup 부재 환경 (background_render checkpoint 없음)
    chain_bg_lookup = lambda _bg_id: None
    
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="dummy",
            is_close_framing=False,
            chain_bg_lookup=chain_bg_lookup,
        )
```

- [ ] Run: FAIL — validator 시그니처가 attached_meta param 없음

### Step 3.2: validator 전면 재작성

`backend/app/core/ref_contract_validator.py` 전체 교체 (substring 분기 폐기):

```python
"""Reference attachment contract validator — D5 identity-level edition.

spec: docs/superpowers/specs/2026-05-09-attached-reference-identity-contract-design.md §4.4

required_refs 의 (kind, id) 가 attached_meta 에 exact match 로 존재해야 통과.
substring/text 매칭 폐기. P1 (label 추론 X) + P2 (fallback meta 위조 X).
"""
from __future__ import annotations
import logging, re
from typing import Any, Callable, Dict, List, Optional, Tuple
from app.core.errors import AppError

logger = logging.getLogger(__name__)


class RefContractError(AppError):
    def __init__(self, detail: str):
        super().__init__(code="ref_contract.violation", message=detail, status_code=422)


# phantom guard 토큰 (D1~D4 그대로 — 알고리즘 재설계 X, non-goals 6)
_CHARACTER_TOKENS = ("character", "figure", "face", "eyes", "expression", "her hand", "his hand", "her arm", "his arm", "his face", "her face", "shoulders")
_BACKGROUND_TOKENS = ("door", "glass", "wall", "floor", "desk", "storefront", "aisle", "tv", "ceiling", "window", "stair", "mart", "store", "room", "bench", "road", "street", "table")
_OBJECT_TOKENS = ("photograph", "memo", "paper", "phone", "smartphone", "cup", "memo pad", "note", "knife", "object")
_FROM_THE_REFERENCE_RE = re.compile(r"from\s+(?:the\s+)?reference\b", re.IGNORECASE)


def classify_from_the_reference(prompt: str) -> List[Dict[str, Any]]:
    """기존 D1~D4 그대로 — phantom guard 알고리즘 비변경."""
    matches: List[Dict[str, Any]] = []
    for m in _FROM_THE_REFERENCE_RE.finditer(prompt):
        start = max(0, m.start() - 60)
        window = prompt[start:m.start()].lower()
        ref_type = "ambiguous"
        if any(t in window for t in _CHARACTER_TOKENS):
            ref_type = "character"
        elif any(t in window for t in _OBJECT_TOKENS):
            ref_type = "object"
        elif any(t in window for t in _BACKGROUND_TOKENS):
            ref_type = "background"
        matches.append({"position": m.start(), "type": ref_type})
    return matches


def _meta_has_kind(attached_set: set, kinds: Tuple[str, ...]) -> bool:
    """meta set 안에 지정 kind 가 하나라도 있는지."""
    attached_kinds = {k for k, _ in attached_set}
    return any(kind in attached_kinds for kind in kinds)


def _normalize_required_refs(raw: Any) -> Dict[str, List[str]]:
    """기존 D1~D4 strict — list 또는 dict shape 정규화. silent fallback 금지."""
    # ... 기존 로직 그대로 (R2 spec 의 D2 strict 보존)


def validate_attached_refs(
    rpc: Optional[Dict[str, Any]],
    labeled_refs: List[Tuple[str, bytes]],
    attached_meta: List[Tuple[str, str]],
    prompt: str,
    is_close_framing: bool,
    *,
    chain_bg_lookup: Optional[Callable[[str], Optional[str]]] = None,
) -> None:
    """spec D5 §4.4."""
    # invariant 1: length match
    if len(labeled_refs) != len(attached_meta):
        raise RefContractError(
            f"meta length mismatch: labeled_refs={len(labeled_refs)} "
            f"attached_meta={len(attached_meta)} (D5 §2.3 invariant 1)"
        )
    
    # rpc strict (D2 보존)
    if rpc is None:
        required: Dict[str, List[str]] = {}
        readiness: Optional[str] = None
    else:
        if not isinstance(rpc, dict):
            raise RefContractError(f"rpc malformed: must be dict, got {type(rpc).__name__}")
        if "asset_requirements" not in rpc:
            raise RefContractError("rpc malformed: 'asset_requirements' field missing")
        asset_req = rpc.get("asset_requirements")
        if asset_req is None:
            raise RefContractError("rpc malformed: 'asset_requirements' is None")
        if not isinstance(asset_req, dict):
            raise RefContractError(f"rpc malformed: 'asset_requirements' must be dict")
        if "required_refs" not in asset_req:
            raise RefContractError("rpc malformed: 'asset_requirements.required_refs' missing")
        raw_required_refs = asset_req.get("required_refs")
        if raw_required_refs is None:
            raise RefContractError("rpc malformed: 'asset_requirements.required_refs' is None")
        required = _normalize_required_refs(raw_required_refs)
        readiness = asset_req.get("readiness_policy")
    
    attached_set = set(attached_meta)
    
    # 1. character_outlook strict (close_framing 무관)
    for cid in required.get("character_outlook", []):
        if ("character_outlook", cid) not in attached_set:
            raise RefContractError(
                f"required character_outlook {cid!r} missing — "
                f"attached={sorted(attached_set)} "
                f"(base 'character' / 'character_state' do NOT satisfy)"
            )
    
    # 2. background — exact bg_id OR prev_shot lineage
    if not is_close_framing:
        for bg_id in required.get("background", []):
            if ("background", bg_id) in attached_set:
                continue
            required_loc = chain_bg_lookup(bg_id) if chain_bg_lookup else None
            if required_loc and ("background_prev_shot", required_loc) in attached_set:
                continue
            raise RefContractError(
                f"required background {bg_id!r} missing "
                f"(expected ('background', {bg_id!r}) or "
                f"('background_prev_shot', {required_loc!r})) — "
                f"attached={sorted(attached_set)}"
            )
    
    # 3. readiness consistency
    if readiness == "block_if_missing" and not any(required.values()):
        raise RefContractError(
            "rpc drift: readiness_policy=block_if_missing but required_refs is empty"
        )
    
    # 4. phantom guard — meta 기반 ref 존재 판정
    classifier_matches = classify_from_the_reference(prompt)
    for match in classifier_matches:
        ref_type = match["type"]; pos = match["position"]
        if ref_type == "ambiguous":
            logger.warning("ref_contract: 'from the reference' ambiguous at pos=%d", pos)
            continue
        if ref_type == "character" and not _meta_has_kind(attached_set, ("character", "character_outlook", "character_state")):
            raise RefContractError(f"phantom 'from the reference' classifier=character at pos={pos} but no character meta — attached={sorted(attached_set)}")
        if ref_type == "background" and not is_close_framing and not _meta_has_kind(attached_set, ("background", "background_prev_shot")):
            raise RefContractError(f"phantom 'from the reference' classifier=background at pos={pos} but no background meta — attached={sorted(attached_set)}")
        if ref_type == "object" and not _meta_has_kind(attached_set, ("prop",)):
            raise RefContractError(f"phantom 'from the reference' classifier=object at pos={pos} but no prop meta — attached={sorted(attached_set)}")
```

- [ ] Run: AC-1 test PASS

### Step 3.3: AC-2~8 + AC-13/14 unit tests (모두 작성)

각 AC 마다 1개 test:
- AC-2: composite 정상 첨부 시 통과
- AC-3: prev_shot lineage 일치 통과
- AC-4: prev_shot lineage 불일치 차단
- AC-5: meta length mismatch fail-fast
- AC-6: readiness drift
- AC-7: close framing background 면제 보존
- AC-8: state_variant outlook 만족 X

```python
def test_AC2_composite_satisfies_outlook_required():
    rpc = _make_rpc([("character_outlook", "C01O02")])
    labeled_refs = [("Image 1 (character reference): ...", b"")]
    attached_meta = [("character_outlook", "C01O02")]
    validate_attached_refs(rpc, labeled_refs, attached_meta, "", False, chain_bg_lookup=lambda _: None)
    # raise 없으면 통과


def test_AC3_prev_shot_lineage_match_satisfies_background():
    rpc = _make_rpc([("background", "bg_kitchen_morning")])
    labeled_refs = [
        ("Image 1 (character reference): ...", b""),
        ("Image 2: previous shot at same location — ...", b""),
    ]
    attached_meta = [
        ("character_outlook", "C01O02"),
        ("background_prev_shot", "L05"),
    ]
    chain_bg_lookup = lambda bg_id: "L05" if bg_id == "bg_kitchen_morning" else None
    validate_attached_refs(rpc, labeled_refs, attached_meta, "", False, chain_bg_lookup=chain_bg_lookup)


def test_AC4_prev_shot_lineage_mismatch_blocks():
    rpc = _make_rpc([("background", "bg_kitchen_morning")])
    labeled_refs = [("Image 1: ...", b"")]
    attached_meta = [("background_prev_shot", "L08")]
    chain_bg_lookup = lambda bg_id: "L05"  # 다른 location
    with pytest.raises(RefContractError, match="bg_kitchen_morning.*missing"):
        validate_attached_refs(rpc, labeled_refs, attached_meta, "", False, chain_bg_lookup=chain_bg_lookup)


def test_AC5_meta_length_mismatch():
    labeled_refs = [("Image 1: ...", b""), ("Image 2: ...", b"")]
    attached_meta = [("character", "C01")]  # 길이 불일치
    with pytest.raises(RefContractError, match="meta length mismatch"):
        validate_attached_refs(None, labeled_refs, attached_meta, "", False)


def test_AC6_readiness_block_if_missing_with_empty_required():
    rpc = {"asset_requirements": {"required_refs": [], "readiness_policy": "block_if_missing"}}
    with pytest.raises(RefContractError, match="readiness_policy=block_if_missing.*empty"):
        validate_attached_refs(rpc, [], [], "", False)


def test_AC7_close_framing_background_exempt():
    rpc = _make_rpc([("background", "bg_…")])
    # background 미첨부 + close framing
    validate_attached_refs(rpc, [], [], "", is_close_framing=True, chain_bg_lookup=lambda _: None)
    # raise 없음 (close framing 면제)


def test_AC7b_close_framing_does_NOT_exempt_character_outlook():
    """close framing 시에도 character_outlook 은 strict (D5 §4.4)."""
    rpc = _make_rpc([("character_outlook", "C01O02")])
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(rpc, [], [], "", is_close_framing=True, chain_bg_lookup=lambda _: None)


def test_AC8_state_variant_does_NOT_satisfy_outlook():
    rpc = _make_rpc([("character_outlook", "C01O02")])
    labeled_refs = [("Image 1: ... state reference", b"")]
    attached_meta = [("character_state", "C01:unconscious")]
    with pytest.raises(RefContractError, match="character_outlook"):
        validate_attached_refs(rpc, labeled_refs, attached_meta, "", False, chain_bg_lookup=lambda _: None)
```

- [ ] 모두 PASS

### Step 3.4: chain_bg_lookup helper 신설

`backend/app/services/scene_generation_coordinator.py` (또는 chain_bg_lookup 별 module) 에 추가:

```python
def build_chain_bg_lookup(background_chain_bg_map: Dict[str, Dict[str, Any]]):
    """T1 의 entry 확장 활용 — 추가 IO 없음.
    
    spec D5 §4.5.
    """
    bg_to_loc = {
        entry["bg_id"]: entry["location_id"]
        for entry in background_chain_bg_map.values()
        if entry.get("bg_id") and entry.get("location_id")
    }
    return lambda bg_id: bg_to_loc.get(bg_id)
```

unit test:

```python
def test_build_chain_bg_lookup_returns_loc_for_known_bg_id():
    bg_map = {
        "5_3": {"bg_id": "bg_kitchen_morning", "location_id": "L05", "image_bytes": b"", "label": ""},
        "8_4": {"bg_id": "bg_store_dusk", "location_id": "L09", "image_bytes": b"", "label": ""},
    }
    lookup = build_chain_bg_lookup(bg_map)
    assert lookup("bg_kitchen_morning") == "L05"
    assert lookup("bg_store_dusk") == "L09"
    assert lookup("unknown_bg") is None


def test_build_chain_bg_lookup_skips_entries_without_bg_id_or_loc_id():
    """T1 entry 확장이 없는 (legacy) entry 는 lookup 에서 자연스럽게 제외."""
    bg_map = {"5_3": {"image_bytes": b"", "label": ""}}  # bg_id/loc_id 부재
    lookup = build_chain_bg_lookup(bg_map)
    assert lookup("anything") is None
```

- [ ] PASS

### Step 3.5: 단건 caller 가 validator 새 시그니처 호출

`backend/app/services/scene_generation_coordinator.py:1093-1113` 변경:

```python
from app.core.ref_contract_validator import validate_attached_refs
_chain_bg_lookup = build_chain_bg_lookup(ctx["background_chain_bg_map"])
validate_attached_refs(
    _rpc, labeled_refs, attached_meta, full_prompt,
    is_close_framing=_is_close_framing,
    chain_bg_lookup=_chain_bg_lookup,
)
```

- [ ] 전체 services + core regression: `pytest tests/core/test_ref_contract_validator.py tests/services/ -x`
- [ ] Expected: PASS

### Step 3.6: T3 commit

```bash
git add backend/app/core/ref_contract_validator.py \
        backend/app/services/scene_generation_coordinator.py \
        backend/tests/core/test_ref_contract_validator.py \
        backend/tests/services/test_scene_generation_coordinator.py
git commit -m "$(cat <<'EOF'
feat(scene_image): T3 D5 validator exact-match + chain_bg_lookup

spec: §4.4 + §4.5

- _has_*_ref 3개 폐기, validate_attached_refs 가 (kind, id) subset coverage
- character_outlook strict (close_framing 무관, base/state_variant 불통)
- background 는 exact bg_id 또는 prev_shot lineage 일치만 통과
- readiness_policy=block_if_missing consistency check
- meta length mismatch 즉시 fail-fast
- D2 strict extractor 그대로 보존 (or {} 금지)
- phantom guard 의 ref 존재 판정은 _meta_has_kind 만
- build_chain_bg_lookup helper (T1 의 bg_map entry 활용, 추가 IO 0)
- 단건 caller 가 attached_meta + chain_bg_lookup 전달

AC-1 S8 회귀 fixture (사용자 binding) + AC-2~8 + AC-13/14 unit tests.

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

---

## Task 4: Batch path wiring — validator inside `_generate_variation_in_loop`

**Goal:** 배치 경로가 단건과 동등한 contract enforcement. **B2 (Codex)**: batch 의 `_full_prompt` 가 variation 별 build (line 1192) — validator 호출은 **`_generate_variation_in_loop` 안 line 1195 (build) 후, line 1205 (Gemini) 전**. outer (`_generate_scene_in_loop`) 가 아님. phantom guard 가 variation specific 이라 per-variation 호출 의무.

**Architectural note**:
- 단건: 1 image, full_prompt 1개 — `_build_single_scene_prompt_and_refs` 안 line 1111 validator (T3 이미 적용)
- 배치: 1 still 당 N variation, var_t2i 별 _full_prompt 별 — `_generate_variation_in_loop` 안 (이번 task)

**Files:**
- Modify: `backend/app/services/scene_generation_coordinator.py:437-651` (`_generate_scene_in_loop` — Step 2.6b 의 임시 discard 활성화 + chain_bg_lookup build + variation loop 에 새 param 전달)
- Modify: `backend/app/services/scene_generation_coordinator.py:1161-1240` (`_generate_variation_in_loop` — 새 param 추가 + RPC lookup + validator 호출)
- Test: `backend/tests/services/test_scene_generation_coordinator.py` (배치 통합)

### Step 4.1: 배치 variation-loop validator 호출 회귀 test (failing)

```python
def test_generate_variation_in_loop_calls_validator_after_full_prompt_build():
    """T4 (AC-9 + B2 fix): variation loop 안에서 validator 호출 — full_prompt
    build 후, Gemini call 전. variation 별 phantom guard 검증."""
    coord = SceneGenerationCoordinator(...)
    # Mock generate_and_validate_scene + _build_final_scene_prompt
    with patch("app.services.scene_generation_coordinator.validate_attached_refs") as mock_v, \
         patch("app.services.scene_generation_coordinator._build_final_scene_prompt") as mock_p, \
         patch("app.modules.pipeline.scene_image_pipeline.generate_and_validate_scene") as mock_gen:
        mock_p.return_value = "FINAL_PROMPT_FOR_GEMINI"
        mock_gen.return_value = {"file_path": "x.png", "validation": {}}
        
        coord._generate_variation_in_loop(
            still_data={"scene_index": 8, "shot_index": 4, "id": "s1"},
            var_t2i="...", var_theme="t1", var_theme_label="lbl",
            visible_entities=[], labeled_refs=[("Image 1: ...", b"")],
            attached_meta=[("character_outlook", "C01O02")],  # NEW param
            rpc={"asset_requirements": {"required_refs": [], "readiness_policy": None}},
            is_close_framing=False,
            chain_bg_lookup=lambda _: None,
            gemini_client=Mock(), sanitizer=Mock(), validator=None,
            scene_dir=Path("/tmp"),
            cached_style_context="", cached_entity_text_map={},
            world_guide={}, episode_id="ep1",
        )
        
        # validator 호출 1회 — full_prompt build 후, Gemini call 전
        assert mock_v.call_count == 1
        v_args, v_kwargs = mock_v.call_args
        # validator 가 받은 prompt 가 _build_final_scene_prompt 결과
        assert v_args[3] == "FINAL_PROMPT_FOR_GEMINI" or v_kwargs.get("prompt") == "FINAL_PROMPT_FOR_GEMINI"


def test_generate_variation_in_loop_blocks_gemini_on_ref_contract_violation():
    """T4: validator raise 시 Gemini 호출 안 됨, variation 결과 None."""
    with patch("app.services.scene_generation_coordinator.validate_attached_refs") as mock_v, \
         patch("app.modules.pipeline.scene_image_pipeline.generate_and_validate_scene") as mock_gen:
        mock_v.side_effect = RefContractError("required character_outlook 'C01O02' missing")
        result = coord._generate_variation_in_loop(...)
        assert result is None  # variation 실패
        assert mock_gen.call_count == 0  # Gemini 호출 안 됨
```

- [ ] Run: FAIL — `_generate_variation_in_loop` 가 attached_meta/rpc/is_close_framing/chain_bg_lookup param 받지 않음

### Step 4.2: `_generate_scene_in_loop` 에 chain_bg_lookup build + variation loop 에 param 전달

`backend/app/services/scene_generation_coordinator.py:526-651` 의 변경:

Step 2.6b 의 임시 discard `_attached_meta_unused` 를 활성화:

```python
# line 526 — 활성화 (T2 의 임시 discard 폐기)
labeled_refs, attached_meta = self._reference_svc.resolve_refs_for_prompt(...)

# line 555 — chain_bg meta 활성화
if _bc_bg and _bc_bg.get("image_bytes") and not _is_close_framing:
    labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))
    attached_meta.insert(0, ("background", _bc_bg["bg_id"]))  # T1 의 entry 에서 read

# line 590 — prev_shot meta 활성화
if _prev_shot_ref:
    _label, _bytes, _loc_id = _prev_shot_ref
    labeled_refs.insert(0, (_label, _bytes))
    attached_meta.insert(0, ("background_prev_shot", _loc_id))

# line 597 — image_index passthrough 활성화
labeled_refs, _sid_to_img, _sid_info, attached_meta = _build_image_index_helper(
    labeled_refs, entity_lookup, attached_meta=attached_meta,
)
```

씬 단위로 chain_bg_lookup + RPC 한 번 build (variation 마다 재계산 회피):

```python
# scene 진입 후 (line ~525 직전):
chain_bg_lookup = build_chain_bg_lookup(background_chain_bg_map)

# RPC lookup — 단건 (scene_generation_coordinator.py:1102-1109) 패턴 재사용.
# scene_index/shot_index 는 still_data 에서 read (I2 — Codex 지적):
_scene_index = still_data.get("scene_index")
_shot_index = still_data.get("shot_index") or still_data.get("_shot_index")
rpc = lookup_render_prompt_card(
    project_id=self._project_id, episode_id=episode_id,
    scene_index=_scene_index, shot_index=_shot_index,
)
```

variation loop 호출 (line 622-642) 에 새 param 추가:

```python
var_futures[var_executor.submit(
    self._generate_variation_in_loop,
    still_data=still_data,
    var_t2i=var_t2i,
    var_theme=var_theme, var_theme_label=var_theme_label,
    visible_entities=visible_entities,
    labeled_refs=labeled_refs,
    attached_meta=attached_meta,        # NEW
    rpc=rpc,                            # NEW
    is_close_framing=_is_close_framing, # NEW
    chain_bg_lookup=chain_bg_lookup,    # NEW
    gemini_client=gemini_client, sanitizer=sanitizer, validator=validator,
    scene_dir=scene_dir,
    cached_style_context=cached_style_context,
    cached_entity_text_map=cached_entity_text_map,
    world_guide=world_guide, episode_id=episode_id,
)] = vi
```

### Step 4.3: `_generate_variation_in_loop` 안 validator 호출

`backend/app/services/scene_generation_coordinator.py:1161-1198` 변경:

```python
def _generate_variation_in_loop(
    self, *,
    still_data: Dict[str, Any],
    var_t2i: str, var_theme: str, var_theme_label: str,
    visible_entities: list,
    labeled_refs: list,
    attached_meta: List[Tuple[str, str]],          # NEW
    rpc: Optional[Dict[str, Any]],                 # NEW
    is_close_framing: bool,                        # NEW
    chain_bg_lookup: Optional[Callable[[str], Optional[str]]],  # NEW
    gemini_client: Any, sanitizer: Any,
    validator: Optional[ImageValidator],
    scene_dir: Path,
    cached_style_context: str, cached_entity_text_map: Dict[str, str],
    world_guide: Dict[str, Any],
    episode_id: str,
) -> Optional[Dict[str, Any]]:
    """B2 fix — variation 별 full_prompt build 후 validator 호출, Gemini 전 차단."""
    from app.modules.pipeline.scene_image_pipeline import generate_and_validate_scene
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError

    try:
        _full_prompt = _build_final_scene_prompt(
            var_t2i, labeled_refs, cached_style_context,
            entity_text_map=cached_entity_text_map,
        )
    except Exception as _prompt_exc:
        logger.warning("Prompt build failed for variation %s: %s", var_theme, _prompt_exc)
        _full_prompt = f"{cached_style_context}\n\n{var_t2i}" if cached_style_context else var_t2i

    # B2 fix — full_prompt build 후, Gemini call 전 validator 호출.
    # variation 별 _full_prompt 가 다르므로 phantom guard surface 도 variation-specific.
    try:
        validate_attached_refs(
            rpc, labeled_refs, attached_meta, _full_prompt,
            is_close_framing=is_close_framing,
            chain_bg_lookup=chain_bg_lookup,
        )
    except RefContractError as exc:
        logger.error(
            "Scene %d Shot %d variation=%s: ref contract violation — skip variation: %s",
            still_data.get("scene_index", 0), still_data.get("shot_index", 0),
            var_theme, exc,
        )
        return None  # 그 variation 만 skip — 다른 variation/still 계속

    # 순화 retry: 원본 1회 + sanitized 2회 (기존 그대로)
    _current_prompt = _full_prompt
    ...
```

**의도 명시**: variation 단위 fail-fast — 한 variation RefContractError 시 다른 variation/still 계속 (기존 fail-fast 정책 보존).

- [ ] Run: 배치 통합 + integration tests
- [ ] Expected: PASS

### Step 4.4: Step 2.6b 임시 discard 검증 — 활성화 확인

T2 의 `_attached_meta_unused` 변수가 더 이상 존재하지 않아야 함 (T4 가 활성화). grep 검증:

```bash
grep -n "_attached_meta_unused" backend/app/services/scene_generation_coordinator.py
```

- [ ] Expected: 0 hit (T2 임시 변수 모두 활성화됨)

### Step 4.3: T4 commit

```bash
git commit -m "$(cat <<'EOF'
feat(scene_image): T4 D5 batch path wiring — generate_images validator 적용

spec: §4.6

- 배치 path 4 사이트 (526/555/590/597) 새 시그니처 적용
- 씬별 RPC lookup + validate_attached_refs 호출 추가
- 한 씬 fail-fast 시 그 씬만 skip, 다른 씬 계속 (기존 정책)
- D1~D4 spec 후속 항목 'B. batch path validate_attached_refs 적용' 동시 해결
- single 과 동등 contract enforcement (divergence 재발 방지)

T3 blockedBy. AC-9.

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

---

## Task 5: Regression + S8 canary fixture

**Goal:** 기존 substring 의존 tests 모두 meta 기반으로 갈아엎음. AC-1~15 통합 회귀. 전체 backend 회귀 0.

**Files:**
- Modify: `backend/tests/core/test_ref_contract_validator.py` (전면 update — 기존 substring assertion 폐기)
- Modify: `backend/tests/services/test_scene_reference_service.py` / `test_scene_generation_coordinator.py` (이미 T2/T3/T4 에서 추가됨)
- Create: `backend/tests/integration/test_d5_canary_s8.py` (S8 e2e regression — manifest fixture 사용)
- Modify: `backend/tests/fixtures/c01_zero_gate_regression.json` (해당하면 S8 expected 추가)

### Step 5.1: 기존 substring assertion enumerate

```bash
cd backend
grep -rn "_has_character_ref\|_has_background_ref\|_has_object_ref\|substring.*background\|in label.lower" tests/
```

각 hit 마다:
- assertion 의도 파악 (기존 substring 판정 → meta 판정으로 의역)
- meta 기반 assertion 으로 교체

예: `assert "character" in labeled_refs[0][0].lower()` → `assert ("character_outlook", "C01O02") in attached_meta or ("character", "C01") in attached_meta`

- [ ] 전체 substring 의존 assertion 0건 잔존

### Step 5.2: S8 e2e canary fixture test (신규)

```python
# tests/integration/test_d5_canary_s8.py
"""D5 S8 회귀 canary — production manifest 기반 fail-fast 검증."""
import pytest, json
from pathlib import Path

@pytest.fixture
def s8_manifest_fixture(tmp_path):
    """S8_Shot4 production manifest 의 RPC subset (canonical)."""
    return {
        "render_prompt_card": {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
                    {"kind": "background", "id": "bg_store_sales_floor_dusk_busy_exit_visible", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
                "constraints": [
                    "do not imply or describe a reference image that is not listed in required_refs (no phantom references)",
                ],
            }
        }
    }


def test_S8_canary_validator_blocks_when_only_C01_base_attached(s8_manifest_fixture):
    """S8 production 결함 시뮬레이션 — composite 부재 + bg 미첨부 + character description
    안 'plain white background' inline → identity 검증으로 차단."""
    from app.core.ref_contract_validator import validate_attached_refs, RefContractError
    
    rpc = s8_manifest_fixture["render_prompt_card"]
    # production 에서 실제 발생한 attached: C01 base 1개만 (composite 부재 fallback)
    labeled_refs = [(
        "Image 1 (character reference): 수리영 — Set in modern mid-2020s, 대한민국. "
        "Passport-style ID photo, head and upper chest visible, plain white background. "
        "Korean early 20s female, long black hair, soft facial structure, dark eyes.",
        b"c01_base_bytes",
    )]
    attached_meta = [("character", "C01")]  # P2: 별 kind
    
    with pytest.raises(RefContractError) as exc_info:
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="dummy",
            is_close_framing=False,
            chain_bg_lookup=lambda _: None,
        )
    
    # 첫 번째 raise 는 character_outlook (line 검증 순서)
    assert "character_outlook" in str(exc_info.value)
    assert "'C01O02'" in str(exc_info.value)


def test_S8_canary_pre_d5_substring_logic_would_have_passed(s8_manifest_fixture):
    """S8 production false-positive 의 사후 evidence — D5 이전 substring 검증이
    이 케이스를 통과시켰음을 명시적으로 기록 (회귀 방지)."""
    label = (
        "Image 1 (character reference): 수리영 — ... plain white background. ..."
    )
    # D5 이전 substring 검사 (회귀 evidence — 이 assertion 은 변경하지 말 것)
    assert "character" in label.lower(), "사후 evidence: pre-D5 character 검사 false-positive"
    assert "background" in label.lower(), "사후 evidence: pre-D5 background 검사 false-positive (plain white background)"
    # D5 의 raison d'être — 이 두 substring 이 trigger 라 false-positive 발생했음
```

- [ ] Run: PASS

### Step 5.3: AC ↔ task step 매핑 (I4 fix — Claude) + AC-15 통합 검증

**AC ↔ step 매핑 표** (I4 — 이전 plan 의 self-review 두루뭉술 표현 정밀화):

| AC | 검증 step | 비고 |
|---|---|---|
| AC-1 (S8 회귀) | T3 step 3.1 + T5 step 5.2 | T3 unit + T5 e2e fixture |
| AC-2 (composite outlook 통과) | T3 step 3.3 | unit |
| AC-3 (prev_shot lineage match) | T3 step 3.3 | unit |
| AC-4 (prev_shot lineage mismatch) | T3 step 3.3 | unit |
| AC-5 (meta length mismatch) | T3 step 3.3 | unit |
| AC-6 (readiness drift) | T3 step 3.3 | unit |
| AC-7 (close framing bg 면제) | T3 step 3.3 | unit + AC-7b 추가 (close framing 시 character_outlook strict) |
| AC-8 (state_variant outlook 불통) | T3 step 3.3 | unit |
| AC-9 (batch wiring) | T4 step 4.1 + 4.3 | unit + integration |
| **AC-10 (label 변경 0)** | **T5 step 5.3a (신규)** | **regex grep + diff verify** |
| AC-11 (tests update) | T5 step 5.1 | substring 의존 assertion 0건 |
| AC-12 (broader regression) | T5 step 5.4 | 전체 backend pytest |
| AC-13 (bg_map entry contract) | T1 step 1.1 + 1.3a | Phase 7 + Phase 5 |
| AC-14 (prev_shot 3-tuple contract) | T1 step 1.4 | unit |
| AC-15 (custom_prompt 변경 0) | T5 step 5.3 | unit |

#### Step 5.3a: AC-10 (label 변경 0) verification (신규 — Claude I4 fix)

LLM-facing label 형식 변경 0 검증. resolver 의 8 변경 site + coordinator 4 site 모두 label 문자열 그대로 보존. attached_meta 만 추가.

```python
def test_AC10_label_format_unchanged_after_d5():
    """AC-10: D5 변경 후 label 형식 그대로. Gemini 송신 payload 무변경 invariant 3."""
    # 1. 6 출처 label fixture (D5 이전 production 실측)
    expected_labels = [
        "character C01 identity",
        "character C01O02 in outfit",
        "character C01O02 — unconscious state reference",
        "character identity",  # legacy 미지정
        "object P03",
        # chain_bg / prev_shot 도 추가
    ]
    # 2. 각 fixture 로 build_scene_attached_refs 호출
    # 3. labeled_refs[i][0] (label) 가 expected 와 정확히 일치 (substring 아님)
    for fixture in fixtures:
        prompt, labeled_refs, attached_meta = build_scene_attached_refs(**fixture)
        for label, _bytes in labeled_refs:
            # _build_image_index_helper 가 "Image N (kind reference): ..." 로 rewrite
            # 단 raw label (rewrite 전) 형식이 보존됨을 검증
            assert any(exp in label for exp in expected_labels) or \
                   label.startswith("Image ") or \
                   label.startswith("background chain ref") or \
                   label.startswith("previous shot")


def test_AC10_image_index_rewrite_format_unchanged():
    """AC-10: _build_image_index_helper 의 'Image N (...)' rewrite 형식 보존."""
    labeled_refs = [("character C01 identity", b"")]
    attached_meta = [("character", "C01")]
    entity_lookup = {"c1": {"id": "c1", "short_id": "C01", "name": "수리영", "t2i_prompt": "..."}}
    indexed, _, _, indexed_meta = build_image_index(labeled_refs, entity_lookup, attached_meta=attached_meta)
    # 기존 형식 그대로 — D5 이전 패턴
    assert indexed[0][0].startswith("Image 1 (character reference):")
    assert indexed_meta == attached_meta  # P1: meta 변경 0
```

#### Step 5.3b: AC-13/14/15 통합 검증

AC-13 + AC-14 는 T1 step 1.1+1.3a (bg_map entry) + step 1.4 (prev_shot 3-tuple) 에서 작성. T5 에서는 e2e 통합 시 양쪽 contract 가 production fixture (S8 manifest 형식) 에서 정상 동작 확인. 별도 test 작성 안 함 (T1 unit test 가 이미 contract 보장).

AC-15 명시적 검증:

```python
def test_AC15_custom_prompt_path_unchanged():
    """custom_prompt 경로 D5 미변경 (out-of-scope, spec §3 + F7 후속).
    build_custom_labeled_refs 가 여전히 (label, bytes) 만 반환 + validator 호출 안 됨."""
    svc = SceneReferenceService(db=mock_db, project_id="p1")
    visible_entities = [{"id": "c1", "entity_type": "character"}]
    ref_image_map = {"c1": b"bytes"}
    result = svc.build_custom_labeled_refs(visible_entities, ref_image_map)
    # 여전히 list[tuple[str, bytes]] — meta 평행 리스트 X
    assert isinstance(result, list)
    if result:
        for item in result:
            assert isinstance(item, tuple) and len(item) == 2
            assert isinstance(item[0], str) and isinstance(item[1], bytes)


def test_AC15_custom_prompt_does_not_call_validator():
    """custom_prompt 경로가 validate_attached_refs 미호출 검증."""
    svc = make_scene_image_service(...)
    with patch("app.core.ref_contract_validator.validate_attached_refs") as mock_v:
        svc.generate_single_scene_image(
            still_id="s1",
            custom_prompt="user-edited prompt — no contract enforcement",
            ip=None,
        )
        assert mock_v.call_count == 0  # custom_prompt path bypasses validator
```

### Step 5.4: 전체 backend regression

```bash
cd backend
PYTHONPATH=. .venv/bin/pytest tests/ -x --tb=short
```

- [ ] Expected: 전체 PASS, baseline 회귀 0건
- [ ] token canary 변경 0 (LLM prompt 변경 없음)

### Step 5.5: T5 commit

```bash
git commit -m "$(cat <<'EOF'
test(scene_image): T5 D5 regression + S8 canary fixture

spec: §5 AC-1~15

- substring 의존 validator tests 모두 meta 기반으로 갈아엎음
- S8 production 결함 (C01 t2i 안 'plain white background' inline +
  composite 부재 → C01 base meta) 회귀 fixture 추가
- AC-1~15 통합 회귀 cover
- pre-D5 substring 검증의 false-positive evidence 영구 기록 (회귀 방지)
- token canary 변경 0 / 전체 backend 회귀 0

D5 spec 종료. 다음 = S8 production canary 단건 재호출 (사용자 실행).

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

---

## Risks & Mitigations

| Risk | Mitigation |
|---|---|
| composite 부재 stills 가 D5 후 fail-fast 차단 | 사용자 production canary 1 still 부터. composite step / chain_bg step 의 상류 결함 노출 — operator 가 force regen 으로 복구 |
| T1 의 prev_shot helper return 변경 후 기존 caller 가 일시 깨짐 | T1 의 step 1.6 에서 caller 임시 unpack 적용 — T1 commit standalone green |
| chain_bg checkpoint 부재 환경 (background_mode_on=false) 에서 prev_shot 모두 차단 | chain_bg_lookup 가 None 반환 시 prev_shot 통과 X — 단 background 요구 자체가 RPC 에 없으므로 자연스럽게 hit 안 함 (Risk 제한적) |
| helper return signature 변경이 외부 caller (스크립트/테스트) 깨뜨림 | T2 step 2.6 의 grep — 모든 caller site 전수 enumerate + update |
| custom_prompt 경로의 contract bypass 가 향후 결함 origin | F7 후속 spec 에서 enforce. D5 는 명시적 out-of-scope (operator 인지 필요) |

---

## Self-review (writing-plans skill 가이드)

- **Spec coverage**: §3 non-goals (9개) 모두 plan 헤더 checklist / §4.2 매핑 표 (T2 step 2.2 — site 876 분리 명시) / §4.2.1 (T1 step 1.1+1.2+1.3a+1.3b) / §4.2.2 (T1 step 1.4~1.5) / §4.3 (T2 step 2.5) / §4.4 (T3 step 3.2) / §4.5 (T3 step 3.4) / §4.6 (T4 step 4.2+4.3 — variation-loop validator) / AC-1~15 매핑 표 (T5 step 5.3) / Migration (전체 task 합계) — 모든 spec 항목이 task 로 mapping. AC-10 검증 step 5.3a 신규 추가.
- **Placeholder scan**: 모든 step 에 exact code + exact command + expected output 명시. "TBD"/"implement later"/"similar to" 0건.
- **Type consistency**: `attached_meta = list[tuple[str, str]]`, `(label, bytes, loc_id)` 3-tuple, `chain_bg_lookup: Callable[[str], Optional[str]]` 모든 task 에서 일관.

---

## Test commands cheat sheet

```bash
# T1
cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/test_scene_checkpoint_loaders.py tests/services/test_scene_reference_service.py -v

# T2
cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/ -v

# T3
cd backend && PYTHONPATH=. .venv/bin/pytest tests/core/test_ref_contract_validator.py -v

# T4
cd backend && PYTHONPATH=. .venv/bin/pytest tests/services/test_scene_generation_coordinator.py -v

# T5 — full regression
cd backend && PYTHONPATH=. .venv/bin/pytest tests/ -x --tb=short

# S8 production canary (T5 commit 후, 사용자 실행)
PID="80f62523-775f-4893-9388-d67b17a4339c"
SID_S8="b6662ce1-a38c-4469-8fb6-681d208667d5"
TOK="<admin token>"
curl -sS -w "\nHTTP %{http_code}\n" -X POST -H "Content-Type: application/json" \
    -b "session=$TOK" -d '{}' --max-time 240 \
    "http://localhost:8000/api/v1/projects/$PID/stills/$SID_S8/generate-image" | head -c 800
# Expected: HTTP 422 ref_contract.violation — character_outlook C01O02 missing
```

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-09-attached-reference-identity-contract-implementation.md`. Two execution options:

**1. Subagent-Driven (recommended)** — task per fresh subagent, dual review between tasks (Codex + Claude), fast iteration.

**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with review checkpoints.

**Which approach?**
