# Area D-min — prop attach `required_refs` SOT 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:** `scene_reference_service.resolve_refs_for_prompt` 의 legacy prop text matching 분기 (line 549-590, (a) P## word-boundary + (b) prop_name word-boundary/substring) 폐기 + `shot_description` kwarg signature cascade 제거. Area B-min `render_contracts → required_refs` 가 prop attach 의 단일 SOT.

**Architecture:** 새 추상화 / 새 producer / 새 schema / 새 LLM step 모두 0. 순수 consumer cleanup + signature 단순화. TDD red 확인은 plan 안 step 으로 진행하되 **main history 에 red commit 0** (Codex review IMPORTANT 2 정책) — Task 2 의 통합 commit (test + code) 으로 한 번에 진입. 기존 prop attach 가정 test 는 prop 섹션 (line 1036-1190) + D5 P2/invariant 섹션 (line 898 + 954) 두 영역에서 정리 (Codex review IMPORTANT 1).

**Tech Stack:** Python 3.12, pytest, FastAPI backend, SQLAlchemy ORM (MagicMock 으로 svc 만 mock).

**Spec:** `docs/superpowers/specs/2026-05-14-area-d-min-prop-required-refs-sot.md`

---

## File Structure

각 file 의 책임 + 변경 범위:

- **Modify:** `backend/app/services/scene_reference_service.py`
  - line 1-16 module docstring — `resolve_refs_for_prompt` 의 "prop" 언급을 `required_refs` SOT 로 갱신.
  - line 341-377 `resolve_refs_for_prompt_set` — signature 에서 `shot_description` kwarg 제거 + forwarding 제거 + docstring 갱신.
  - line 379-400 `resolve_refs_for_prompt` — signature 에서 `shot_description` kwarg 제거 + docstring 갱신.
  - line 549-590 legacy prop loop 본문 + `_haystack`/`_shot_desc`/`_MIN_NAME_LEN` 변수 완전 제거.
  - line 592-622 `required_refs` forced attach 분기 주석 갱신 (Area D-min 명시).

- **Modify:** `backend/app/services/scene_generation_coordinator.py`
  - line 321-327 entity-only batch caller — `shot_description=` kwarg 제거.
  - line 614-620 target_variations batch caller — `shot_description=` kwarg 제거.

- **Modify:** `backend/tests/services/test_scene_reference_service.py`
  - 기존 prop matching test (line 1040-1175) 중 새 동작 (required_refs SOT) 과 충돌하는 5-6 개의 expectation 갱신 (Task 1).
  - 신규 T1-T8 test 추가 (Task 1 + Task 5).
  - 기존 test 의 `shot_description=` kwarg 호출 site 전수 제거 (Task 4).

- **Modify (있다면):** `backend/tests/services/test_scene_generation_coordinator.py`
  - signature cascade 검증 (Task 5 의 T8).

---

## Task 1: TDD red — 신규 T1-T4 + 기존 prop test expectation 갱신

**Files:**
- Modify: `backend/tests/services/test_scene_reference_service.py:1036-1175`

기존 prop matching test 의 동작 가정은 **legacy text matching SOT 시대의 가정**. Area B-min 후 producer SOT (`render_contracts → required_refs`) 가 prop attach 의 단일 결정자. 새 동작:

| 입력 | 새 동작 |
|---|---|
| `required_refs=None` 또는 `[]` + `t2i_prompt` 에 P## 있음 | attach 0 |
| `required_refs=None` 또는 `[]` + `t2i_prompt` 에 prop name word-boundary 일치 | attach 0 |
| `required_refs=[{"kind":"prop","id":"P02"}]` + `t2i_prompt` 에 P## 없음 | attach |
| visible_entities 에 없는 prop sid 가 `required_refs` 에 있음 | attach 0 (Tier 1 validator 영역, line 605-611) |

- [ ] **Step 1: 기존 prop attach 가정 test 전수 분석 + 갱신 결정**

본 step 은 두 영역 grep:
- prop 섹션 (line 1036-1190) 의 9 test
- D5 P2 / invariant 섹션 (line 898 + line 954) 의 prop attach 가정 2 test (Codex review IMPORTANT 1 반영)

**D5 P2 / invariant 영역 (2 test, line 898 + line 954) — Codex 추가**:

```
test_resolve_refs_d5_prop_meta_prop_kind (898)
  옛: t2i="Character holds 스마트폰." + shot_description="" → ("prop","P03") expected
  새: required_refs 없음 → prop attach 0 → assert FAIL
  → 갱신: required_refs=[{"kind":"prop","id":"P03","policy":"required"}] 추가 +
    shot_description="" kwarg 제거 (signature cascade).

test_resolve_refs_d5_length_match_invariant_across_all_paths (954)
  옛: t2i="C01O02 holds phone." + shot_description="phone in hand" → char + prop = 2 expected
  새: required_refs 없음 → prop attach 0 → length=1 (char만) → assert FAIL
  → 갱신: required_refs=[{"kind":"prop","id":"P03","policy":"required"}] 추가 +
    shot_description="phone in hand" kwarg 제거.
```

**prop 섹션 영역 (line 1036-1190)**, 기존 9 test 의 새 동작 expectation:

```
test_resolve_refs_prop_inject_when_short_id_in_t2i_prompt (1040)
  옛: t2i 에 P02 → attach (PASS)
  새: required_refs=None → attach 0 (옛 동작과 충돌)
  → required_refs=[{"kind":"prop","id":"P02"}] 추가 + attach 유지
  (또는: 삭제하고 T1+T2 로 대체)

test_resolve_refs_prop_inject_when_name_in_t2i_prompt (1053)
  옛: name "여행 가방" word-boundary → attach (PASS)
  새: required_refs=None → attach 0 (충돌)
  → 새 동작: not any("P02" in label ...) 으로 expectation 변경
  (legacy substring path 삭제 회귀 가드 = T3/T4 와 같은 역할)

test_resolve_refs_prop_inject_when_name_in_shot_description (1066)
  shot_description kwarg 자체가 제거됨 — test 자체 삭제 + cascade

test_resolve_refs_prop_skip_when_no_match (1080)
  옛/새 동일 PASS, 그러나 shot_description kwarg 제거 cascade 의무

test_resolve_refs_prop_no_short_id_inject_when_name_matches (1095)
  옛: name match → "object appearance" label attach (PASS)
  새: required_refs 없으면 attach 0 (충돌)
  → 삭제 (legacy path 사라짐)

test_resolve_refs_prop_inject_uses_haystack_combined (1108)
  shot_description kwarg + haystack 합성 — 삭제

test_resolve_refs_prop_skip_short_name_under_threshold (1122)
  shot_description kwarg + _MIN_NAME_LEN — 삭제

test_resolve_refs_prop_skip_when_shot_description_none (1138)
  shot_description kwarg 자체 사라짐 — 삭제

test_resolve_refs_prop_skip_english_substring_false_positive (1152)
  옛/새 동일 PASS — 그러나 새 동작에서는 required_refs SOT 가 차단
  → expectation 동일 유지 가능, 또는 회귀 가드 T3 로 대체. **유지 + 의도 주석 갱신**

test_resolve_refs_prop_inject_when_english_word_isolated (1166)
  옛: 'cup' 단어 boundary 일치 → attach (PASS)
  새: required_refs 없으면 attach 0 (충돌)
  → 삭제

test_resolve_refs_prop_min_name_threshold_skips_two_chars (1179) — Codex 추가
  옛: _MIN_NAME_LEN threshold 보존 검증 (2자 한글 → skip)
  새: required_refs 없으면 attach 0 — 길이 threshold 영역 자체 폐기.
  → 삭제 (T4 `_attach_0_for_cjk_substring_path_deleted` 와 보장 중복).
```

**결정 (Codex IMPORTANT 1 반영)**:
- **삭제** 9 test (prop 섹션):
  - `_when_short_id_in_t2i_prompt`
  - `_when_name_in_t2i_prompt`
  - `_when_name_in_shot_description`
  - `_no_short_id_inject_when_name_matches`
  - `_inject_uses_haystack_combined`
  - `_skip_short_name_under_threshold`
  - `_skip_when_shot_description_none`
  - `_inject_when_english_word_isolated`
  - `_min_name_threshold_skips_two_chars` (Codex 추가)
- **유지** 2 test (prop 섹션):
  - `_skip_when_no_match` (shot_description kwarg 만 제거)
  - `_skip_english_substring_false_positive` (의도 주석 갱신)
- **갱신** 2 test (D5 P2 / invariant 섹션, Codex 추가):
  - `_d5_prop_meta_prop_kind` (line 898) — required_refs 추가 + shot_description kwarg 제거
  - `_d5_length_match_invariant_across_all_paths` (line 954) — required_refs 추가 + shot_description kwarg 제거

(추후 plan 의 Task 1 step 6 에서 실제 수정 본문.)

- [ ] **Step 2: 신규 T1 test 작성 — P## in t2i_prompt + required_refs empty → attach 0**

본 D-min 의 핵심 회귀 가드. legacy text matching path (a) 폐기 검증.

`backend/tests/services/test_scene_reference_service.py` 의 line 1036 위 (prop 섹션 헤더 직전) 또는 prop 섹션 안에 추가:

```python
# ──────────────────────────────────────────────────────────────────────
# Area D-min: prop attach = required_refs 단일 SOT
# ──────────────────────────────────────────────────────────────────────


def test_resolve_refs_prop_attach_0_when_p_id_in_t2i_no_required_refs(svc):
    """D-min T1: t2i_prompt 에 P## 박혀 있어도 required_refs 없으면 attach 안 됨.

    legacy text matching (a) P## word-boundary 분기 폐기 회귀 가드.
    Area B reference_required=false 면 required_refs 미발생 → contract absence.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="A worn P02 sits beside the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta
    assert len(out) == len(meta)  # invariant 1
```

- [ ] **Step 3: 신규 T2 test 작성 — required_refs 있으면 P## 없어도 attach**

```python
def test_resolve_refs_prop_attach_when_required_refs_even_without_p_id_in_t2i(svc):
    """D-min T2: required_refs(kind='prop') 있으면 t2i_prompt 에 P## 없어도 attach.

    Area B producer SOT (render_contracts → required_refs) 가 단일 결정자.
    Patch A Tier 2 forced attach 분기 유지 확인.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="A worn travel bag sits beside the door.",  # P## 없음
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    assert any("P02" in label for label, _ in out)
    assert ("prop", "P02") in meta
    assert len(out) == len(meta)
```

- [ ] **Step 4: 신규 T3 test — substring false-positive path 삭제 회귀 가드**

```python
def test_resolve_refs_prop_attach_0_for_substring_path_deleted(svc):
    """D-min T3: prop name 'cup' 이 visible 에 있고 t2i 에 'cupboard' 있어도 attach 0.

    legacy text matching (b) prop_name word-boundary/CJK substring 폐기 회귀 가드.
    legacy 가 차단하던 path (cup ⊂ cupboard) 도, legacy 가 attach 하던 path
    (cup 단독 word) 도, 모두 required_refs SOT 로 통일된다.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "cup", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="The character lifts the cup to drink.",  # cup 단독 word
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    # legacy 였으면 attach 됐을 케이스 — required_refs 없으므로 attach 0.
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta
```

- [ ] **Step 5: 신규 T4 test — CJK substring path 폐기 회귀 가드**

```python
def test_resolve_refs_prop_attach_0_for_cjk_substring_path_deleted(svc):
    """D-min T4: 한국어 prop name 이 t2i 에 substring 등장해도 required_refs 없으면 attach 0.

    legacy text matching CJK substring 분기 (line 577-579 `prop_name in _haystack`) 폐기 검증.
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="A 여행 가방 sits beside the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta
```

- [ ] **Step 6: 기존 prop test 삭제 (9 test) + 갱신 (2 prop 섹션 + 2 D5 invariant 섹션 = 4 test)**

삭제 target (line 번호는 갱신 전 기준):

```
line 1040-1050  test_resolve_refs_prop_inject_when_short_id_in_t2i_prompt
line 1053-1063  test_resolve_refs_prop_inject_when_name_in_t2i_prompt
line 1066-1077  test_resolve_refs_prop_inject_when_name_in_shot_description
line 1095-1105  test_resolve_refs_prop_no_short_id_inject_when_name_matches
line 1108-1119  test_resolve_refs_prop_inject_uses_haystack_combined
line 1122-1135  test_resolve_refs_prop_skip_short_name_under_threshold
line 1138-1149  test_resolve_refs_prop_skip_when_shot_description_none
line 1166-1176  test_resolve_refs_prop_inject_when_english_word_isolated
line 1179-1190  test_resolve_refs_prop_min_name_threshold_skips_two_chars
```

9 test 모두 삭제. 이유는 새 동작 (required_refs 단일 SOT) 하에 의도 false.

유지 + 갱신 target (prop 섹션 2 test):

```python
def test_resolve_refs_prop_skip_when_no_match(svc):
    """D-min: required_refs 없으면 어떤 prop 도 attach 안 됨 (contract absence).

    옛: short_id/name/description 매칭 실패 → skip 의도
    새: required_refs 단일 SOT — 매칭 가설 자체 폐기, 동일 동작 (attach 0).
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, _meta = svc.resolve_refs_for_prompt(
        t2i_prompt="A nondescript object on the floor.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    assert not any("P02" in label for label, _ in out)


def test_resolve_refs_prop_skip_english_substring_false_positive(svc):
    """D-min: legacy substring false-positive ('cup' ⊂ 'cupboard') 가 의도된 skip 인지 검증.

    옛: word-boundary regex 가 차단
    새: required_refs 가 SOT — 매칭 가설 자체 폐기. 동일하게 attach 0 (다른 이유).
    """
    visible = [{"id": "p1", "short_id": "P02", "name": "cup", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, _meta = svc.resolve_refs_for_prompt(
        t2i_prompt="The character opens the cupboard quietly.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
    )
    assert not any("P02" in label for label, _ in out)
```

갱신 target (D5 P2 / invariant 섹션 2 test, Codex IMPORTANT 1 반영):

```python
def test_resolve_refs_d5_prop_meta_prop_kind(svc):
    """D5 line 496: prop → ('prop', P##).

    Area D-min: required_refs SOT 로 전환 — 옛 'Character holds 스마트폰.' text match
    가정은 폐기. visible_entities 등재 + required_refs(kind='prop') 가 attach 결정.
    """
    visible = [{"id": "p1", "short_id": "P03", "name": "스마트폰", "entity_type": "prop"}]
    entity_lookup = {"p1": visible[0]}
    scene_ref_map = {"p1": b"prop"}
    _, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="Character holds 스마트폰.", visible_entities=visible,
        scene_ref_image_map=scene_ref_map, entity_lookup=entity_lookup,
        required_refs=[{"kind": "prop", "id": "P03", "policy": "required"}],
    )
    assert ("prop", "P03") in meta


def test_resolve_refs_d5_length_match_invariant_across_all_paths(svc):
    """D5 invariant 1: 모든 append site 가 labeled_refs + attached_meta 동시 add.
    composite + base fallback + prop 혼합 시나리오에서 length 일치 검증.

    Area D-min: prop attach 는 required_refs SOT — 옛 phone/shot_description 매칭
    가정은 폐기.
    """
    visible = [
        {"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"},
        {"id": "p1", "short_id": "P03", "name": "phone", "entity_type": "prop"},
    ]
    entity_lookup = {
        "c1": visible[0], "p1": visible[1],
        "o2": {"id": "o2", "short_id": "O02", "entity_type": "outlook"},
    }
    # composite 존재 + prop 존재
    scene_ref_map = {"composite:c1:o2": b"comp", "p1": b"prop"}
    labeled_refs, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O02 holds phone.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup=entity_lookup,
        required_refs=[{"kind": "prop", "id": "P03", "policy": "required"}],
    )
    assert len(labeled_refs) == len(meta) == 2
    # 매핑 완전성 (P1 — 모든 ref 가 source-of-truth meta)
    kinds = {kind for kind, _ in meta}
    assert kinds == {"character_outlook", "prop"}
```

(기존 test 의 `shot_description=` kwarg 호출은 Task 4 의 signature cascade 에서 제거. Task 1 의 갱신 후 test 는 본 kwarg 안 받는 형태.)

- [ ] **Step 7: pytest 실행 — red phase 확인 (commit 안 함)**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v 2>&1 | tail -60
```

Expected (옛 코드 상태에서):

신규 T1-T4:
- T1 (`_attach_0_when_p_id_in_t2i_no_required_refs`): **FAIL** — 옛 코드가 P02 text matching 으로 attach.
- T2 (`_attach_when_required_refs_even_without_p_id_in_t2i`): **PASS** — Patch A Tier 2 forced attach 이미 wire.
- T3 (`_attach_0_for_substring_path_deleted`): **FAIL** — 옛 코드가 'cup' word-boundary 로 attach.
- T4 (`_attach_0_for_cjk_substring_path_deleted`): **FAIL** — 옛 코드가 CJK substring 으로 attach.

prop 섹션 유지 2 test:
- `_skip_when_no_match`: **PASS** (양쪽 다).
- `_skip_english_substring_false_positive`: **PASS** (양쪽 다).

D5 P2 / invariant 섹션 갱신 2 test (required_refs 추가 + shot_description kwarg 제거):
- `_d5_prop_meta_prop_kind`: **PASS** (옛 코드도 CJK substring 매칭 + required_refs forced attach dedup 으로 PASS, 새 코드는 required_refs SOT 로 PASS).
- `_d5_length_match_invariant_across_all_paths`: **PASS** (옛 코드 영어 word-boundary 매칭 + required_refs dedup, 새 코드 required_refs SOT, 양쪽 length=2).

요약: **3 FAIL (T1/T3/T4) + 나머지 PASS**. red phase 는 본 step 에서 확인만 — **commit 안 함** (Codex review IMPORTANT 2 반영). Task 2 의 code green 후 test + code 통합 commit.

- [ ] **Step 8: red phase 확인 결과 기록 (작업 노트, no commit)**

본 step 은 작업자가 step 7 의 출력을 caller (subagent dispatch leader 또는 inline) 에게 보고하기 위한 노트 작성 단계. main history 에 commit 만들지 않음. test 변경 사항은 working tree 에 남고, Task 2 step 4 의 통합 commit 으로 함께 staged.

Codex review IMPORTANT 2 (2026-05-14):
> red commit 은 피하는 게 낫습니다. Plan lines 275-296 은 실패하는 red phase 를 커밋하게 되어 있습니다. 로컬이라도 main 히스토리에 의도적 fail commit 을 남기는 건 중간 중단/리뷰 시 혼선을 만듭니다.
> Fix: Task 1 은 red 확인까지만 하고 커밋하지 말고, Task 2 green 까지 합쳐 첫 커밋을 만들거나 임시 브랜치에서만 red commit 을 허용한다고 명시하세요.

본 plan 은 main 직접 작업 — red commit 0 정책. Task 2 step 4 가 통합 commit.

---

## Task 2: code green — legacy text matching 분기 폐기

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:549-590`

- [ ] **Step 1: line 549-590 본문 완전 제거**

다음 본문 (현재 line 549-590) 을 완전 삭제:

```python
        # 3) prop — visible_entities 등재 prop을 다음 셋 중 하나가 만족하면 inject:
        #    (a) t2i_prompt 또는 shot_description 에 short_id (P##) 명시
        #    (b) t2i_prompt 또는 shot_description 에 prop name 이 word-boundary 매칭
        # 매칭 실패 시 skip (over-include 방지). visible_entities 가 LLM 작성 리스트
        # 이므로 prompt/description 의 명시 신호와 교차해 정확도를 높인다.
        # 변경 이유: 기존 (a) 만 검사 시 LLM 이 short_id 누락하면 ref drop → 결함 C 회귀 가드.
        # word-boundary 매칭으로 영어 짧은 prop name (`cup` ⊂ `cupboard` 등) substring
        # false-positive 차단. 한국어는 `\b` 가 모든 한글 사이에 매치되어 자연스럽게 통과.
        # 1-2자 prop name 은 너무 짧아 우연 매치 위험이라 추가 가드 (`_MIN_NAME_LEN`).
        _shot_desc = shot_description or ""
        _haystack = f"{t2i_prompt}\n{_shot_desc}"
        _MIN_NAME_LEN = 3  # 1-2자 prop name 은 word-boundary 매치도 우연 충돌 위험 → skip
        for ve in visible_entities:
            eid = ve.get("id", "")
            etype = ve.get("entity_type", "")
            if etype != "prop":
                continue
            if eid not in scene_ref_image_map or eid in _used_ref_ids:
                continue
            prop_sid = ve.get("short_id", "")
            prop_name = (ve.get("name") or "").strip()
            matched = False
            if prop_sid and _re.search(rf'\b{_re.escape(prop_sid)}\b', _haystack):
                matched = True
            elif prop_name and len(prop_name) >= _MIN_NAME_LEN:
                # ASCII (영어 등) 만이면 word-boundary 로 substring false-positive 차단
                # (e.g. `cup` ⊂ `cupboard`). 한국어/CJK 등 non-ASCII 포함이면 word-
                # boundary 가 동작 안 하므로 simple substring (CJK 는 boundary 무관).
                _has_non_ascii = any(ord(c) > 127 for c in prop_name)
                if _has_non_ascii:
                    matched = prop_name in _haystack
                else:
                    matched = bool(_re.search(rf'\b{_re.escape(prop_name)}\b', _haystack))
            if not matched:
                continue
            label = f"object {prop_sid}" if prop_sid else "object appearance"
            labeled_refs.append((label, scene_ref_image_map[eid]))
            # D5: prop meta — short_id SOT. prop_sid 빈 string 일 수 있으나 (LLM
            # 미부여) labeled_refs 와 length 일치 위해 그대로 attach. validator
            # 에서 빈 id 가 어떤 prop required 도 만족 X (자연스러운 fail-fast).
            attached_meta.append(("prop", prop_sid))
            _used_ref_ids.add(eid)
```

치환 후, line 592 의 `# 4) Patch A Tier 2` 주석이 그 자리에 직접 이어진다.

- [ ] **Step 2: line 592 의 주석 블록 D-min 변경 반영**

기존 (`# 4) Patch A Tier 2 — ...`) 주석을 다음으로 교체 (`required_refs forced attach` 분기는 단일 prop attach 경로로 격상됨을 명시):

```python
        # 3) prop attach — Area D-min: required_refs(kind='prop') 가 prop attach 의
        #    단일 SOT. legacy text matching (옛 (a) P## word-boundary + (b) prop_name
        #    word-boundary/CJK substring) 분기는 Area D-min 에서 폐기 — Area B 의
        #    render_contracts → required_refs_from_render_contracts → asset_requirements
        #    → coordinator → 본 keyword arg 까지 producer SOT 일관.
        #
        #    RPC producer SOT — required_refs(kind='prop') 만 attach 결정 신호.
        #    visible_entities 미등재 prop / ref image 부재 prop 은 skip — Tier 3
        #    validate_attached_refs 가 image stage 직전 fail-fast (RefContractError).
```

(line 번호는 step 1 의 본문 제거 후 재정렬.)

- [ ] **Step 3: pytest 실행 — green phase 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v 2>&1 | tail -50
```

Expected:
- T1 PASS, T2 PASS, T3 PASS, T4 PASS (red → green).
- 기존 prop test (Task 1 step 6 의 유지/갱신 2 test) PASS.
- 기존 char/legacy/state_variant 분기 test PASS.

회귀 발견 시 본 task 안에서 fix. 다른 영역 회귀는 Task 6 에서.

- [ ] **Step 4: commit (test + code 통합 — D-min 첫 commit)**

Task 1 의 test 변경 (working tree 에 남은 상태) + Task 2 의 code 변경을 한 commit 으로 묶음 (Codex review IMPORTANT 2 — red commit 0 정책):

```bash
git add backend/tests/services/test_scene_reference_service.py \
        backend/app/services/scene_reference_service.py
git commit -m "$(cat <<'EOF'
refactor(area-d-min): drop legacy prop text matching + test cascade

scene_reference_service.resolve_refs_for_prompt 의 legacy prop attach 분기
폐기. Area B-min render_contracts → required_refs 가 prop attach 의 단일 SOT.

code (scene_reference_service.py):
- 폐기 (옛 line 549-590):
  · (a) prop_sid P## word-boundary 매칭
  · (b) prop_name ASCII word-boundary / CJK substring
  · _haystack 합성 (t2i_prompt + shot_description)
  · _MIN_NAME_LEN constant
  · for ve in visible_entities prop loop 전체
- 유지 + 격상 (현 line 592-622, Patch A Tier 2):
  · required_refs(kind='prop') forced attach — 단일 prop attach 경로.
  · 주석 블록 D-min 컨텍스트 반영.

test (test_scene_reference_service.py):
- 신규 T1-T4 (required_refs 단일 SOT 회귀 가드):
  · T1: P## in t2i + required_refs=None → attach 0
  · T2: required_refs 있음 + P## 없음 → attach
  · T3: 'cup' word-boundary + required_refs=None → attach 0 (legacy (b) ASCII 폐기)
  · T4: CJK substring + required_refs=None → attach 0 (legacy (b) CJK 폐기)
- 삭제 9 (legacy text matching SOT 가정의 test, 의도 false):
  · prop 섹션 8 (_inject_when_short_id_in_t2i_prompt, _inject_when_name_in_t2i_prompt,
    _inject_when_name_in_shot_description, _no_short_id_inject_when_name_matches,
    _inject_uses_haystack_combined, _skip_short_name_under_threshold,
    _skip_when_shot_description_none, _inject_when_english_word_isolated)
  · _min_name_threshold_skips_two_chars (T4 와 보장 중복, Codex IMPORTANT 1)
- 갱신 2 prop 섹션: _skip_when_no_match (kwarg 정리), _skip_english_substring_false_positive (의도 주석)
- 갱신 2 D5 P2 / invariant 섹션 (Codex IMPORTANT 1):
  · _d5_prop_meta_prop_kind (required_refs 추가 + shot_description kwarg 제거)
  · _d5_length_match_invariant_across_all_paths (required_refs 추가 + kwarg 제거)

TDD red → green: T1/T3/T4 PASS. main history 에 red commit 0 (Codex IMPORTANT 2).

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

---

## Task 3: docstring + module-level cleanup

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:1-16` (module docstring)
- Modify: `backend/app/services/scene_reference_service.py:341-377` (`resolve_refs_for_prompt_set` docstring)
- Modify: `backend/app/services/scene_reference_service.py:379-400` (`resolve_refs_for_prompt` docstring)

- [ ] **Step 1: module-level docstring 갱신**

현재 (line 1-16):

```python
"""씬 이미지 reference/entity 해결 서비스 — W5 F22 Phase B.8.

SceneImageService에서 reference/visible-entity 매핑 로직을 이관. 공개 API는
facade가 유지하고 본 서비스는 구현을 담당. Codex B.6 조언("reference resolution
성격은 validation과 분리")을 반영한 별도 서비스.

공개 API (SceneImageService facade가 delegation):
- `get_visible_entities(visible_entities_json)` — JSON → EntityCanon lookup
- `get_reference_image_map(visible_entities)` — entity_id → file bytes
- `build_scene_ref_image_map(ref_image_map, entity_lookup)` — location 제외 + outlook + composite + state_variant
- `resolve_refs_for_prompt(t2i_prompt, ...)` — T2I ↔ ref 이미지 매칭 (short_id + 레거시 + state_variant + prop)
"""
```

마지막 bullet 변경:

```python
- `resolve_refs_for_prompt(t2i_prompt, ...)` — T2I ↔ ref 이미지 매칭 (short_id + 레거시 + state_variant). prop attach 는 Area D-min 이후 `required_refs` (Area B render_contracts SOT) 단일 결정자.
```

- [ ] **Step 2: `resolve_refs_for_prompt` docstring 갱신 (line 379-400)**

현재:

```python
    def resolve_refs_for_prompt(
        self,
        t2i_prompt: str,
        ...
    ) -> Tuple[List[Tuple[str, bytes]], List[Tuple[str, str]]]:
        """T2I 프롬프트에서 참조 이미지 매칭 — short_id(C01O02) + 레거시([[name]+[outlook]]) 동시 지원.

        W5 F22 Phase B.8.3 이관.
        state_variant_sids: 죽은/다친 인물 short_id → {key, state} — state variant ref 제공.

        D5 §4.2 + §4.3 (2026-05-09): return 을 (labeled_refs, attached_meta) 2-tuple.
        attached_meta = list[(kind, id)] — append 시점의 source-of-truth (regex 매칭 결과
        / entity_lookup short_id) 만 사용 (P1: 라벨 추론 X). composite 부재 fallback 은
        ("character", char_sid) 별 kind 로 기록 — character_outlook 위조 금지 (P2).
        invariant 1: len(labeled_refs) == len(attached_meta) (모든 append site 동시).
        """
```

수정 후 (signature 의 shot_description 제거는 Task 4 에서, 본 task 는 docstring 만):

```python
        """T2I 프롬프트에서 참조 이미지 매칭 — short_id(C01O02) + 레거시([[name]+[outlook]]) + state_variant.

        prop attach 는 Area D-min 이후 `required_refs` (Area B render_contracts
        producer SOT) 단일 결정자. 옛 prop text matching 분기 (P## word-boundary,
        prop name word-boundary/CJK substring) 는 폐기.

        W5 F22 Phase B.8.3 이관. state_variant_sids: 죽은/다친 인물 short_id →
        {key, state} — state variant ref 제공.

        D5 §4.2 + §4.3 (2026-05-09): return 을 (labeled_refs, attached_meta) 2-tuple.
        attached_meta = list[(kind, id)] — append 시점의 source-of-truth (regex 매칭
        결과 / entity_lookup short_id) 만 사용 (P1: 라벨 추론 X). composite 부재
        fallback 은 ("character", char_sid) 별 kind 로 기록 — character_outlook
        위조 금지 (P2). invariant 1: len(labeled_refs) == len(attached_meta).

        Area D-min (2026-05-14): prop attach 는 required_refs keyword arg 단일 SOT.
        """
```

- [ ] **Step 3: `resolve_refs_for_prompt_set` docstring 갱신 (line 341-363)**

현재:

```python
        """Multiple t2i_prompts 의 ID 매칭 union 으로 ref/meta build.

        2026-05-10 — 24 shot deterministic ref-contract fail fix (Fix A): 기존
        coordinator 가 first variation 의 t2i_prompt 만 보고 attached_meta build
        하던 결함 차단. target_variations[*] 모든 prompt 의 ID union 으로
        attach — 모든 variation 이 동일 ref set 공유 (Gemini image_edit 은 사용
        안 하는 ref 무시 OK).

        구현: prompts 를 ``\\n`` join 후 기존 resolve_refs_for_prompt 위임.
        regex 매칭은 join 텍스트 전체에서 작동, _used_ref_ids set 이 dedup 보장.
        빈 list / 모두 빈 string → 빈 ref/meta 반환. invariant 1 보존.
        """
```

수정:

```python
        """Multiple t2i_prompts 의 ID 매칭 union 으로 ref/meta build.

        2026-05-10 — 24 shot deterministic ref-contract fail fix (Fix A): 기존
        coordinator 가 first variation 의 t2i_prompt 만 보고 attached_meta build
        하던 결함 차단. target_variations[*] 모든 prompt 의 ID union 으로
        attach — 모든 variation 이 동일 ref set 공유 (Gemini image_edit 은 사용
        안 하는 ref 무시 OK).

        구현: prompts 를 ``\\n`` join 후 기존 resolve_refs_for_prompt 위임.
        char/outlook short_id regex 매칭은 join 텍스트 전체에서 작동, _used_ref_ids
        set 이 dedup 보장. 빈 list / 모두 빈 string → 빈 ref/meta 반환. invariant 1 보존.

        Area D-min (2026-05-14): prop attach 는 required_refs keyword arg 단일 SOT —
        join 텍스트의 P##/prop name 매칭은 더 이상 prop attach 신호가 아님.
        """
```

- [ ] **Step 4: pytest 회귀 0 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v 2>&1 | tail -30
```

docstring 변경만이므로 회귀 0 예상.

- [ ] **Step 5: commit**

```bash
git add backend/app/services/scene_reference_service.py
git commit -m "$(cat <<'EOF'
docs(area-d-min): cleanup docstrings — prop text matching 폐기 반영

module-level + resolve_refs_for_prompt + resolve_refs_for_prompt_set
docstring 에서 prop text matching 함의 제거. Area D-min 의 required_refs
단일 SOT 명시.

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

---

## Task 4: shot_description kwarg signature cascade 제거

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:341-377` + `:379-400`
- Modify: `backend/app/services/scene_generation_coordinator.py:321-327` + `:614-620`
- Modify: `backend/tests/services/test_scene_reference_service.py` (cascade)

- [ ] **Step 1: `resolve_refs_for_prompt` signature 에서 shot_description 제거 (line 379-400)**

```python
    def resolve_refs_for_prompt(
        self,
        t2i_prompt: str,
        visible_entities: list,
        scene_ref_image_map: Dict[str, bytes],
        entity_lookup: Dict[str, Dict],
        state_variant_sids: Optional[Dict[str, Dict]] = None,
        *,
        required_refs: Optional[List[Dict[str, Any]]] = None,
    ) -> Tuple[List[Tuple[str, bytes]], List[Tuple[str, str]]]:
```

(`shot_description: Optional[str] = None,` line 제거.)

- [ ] **Step 2: `resolve_refs_for_prompt_set` signature 변경 + forwarding 제거 (line 341-377)**

```python
    def resolve_refs_for_prompt_set(
        self,
        t2i_prompts: List[str],
        visible_entities: list,
        scene_ref_image_map: Dict[str, bytes],
        entity_lookup: Dict[str, Dict],
        state_variant_sids: Optional[Dict[str, Dict]] = None,
        *,
        required_refs: Optional[List[Dict[str, Any]]] = None,
    ) -> Tuple[List[Tuple[str, bytes]], List[Tuple[str, str]]]:
        """... (Task 3 step 3 갱신본 그대로) ..."""
        if not t2i_prompts:
            return [], []
        joined = "\n".join(p for p in t2i_prompts if p)
        if not joined:
            return [], []
        return self.resolve_refs_for_prompt(
            t2i_prompt=joined,
            visible_entities=visible_entities,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            state_variant_sids=state_variant_sids,
            required_refs=required_refs,
        )
```

(기존 `shot_description=shot_description,` forwarding line 제거.)

- [ ] **Step 3: pytest 실행 — TypeError 발견**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v 2>&1 | tail -40
```

Expected: 기존 test 중 `shot_description=` kwarg 로 호출하는 site 에서 `TypeError: resolve_refs_for_prompt() got an unexpected keyword argument 'shot_description'` 발생.

Task 1 step 6 의 갱신본 (2 test) + Task 1 step 6 의 신규 4 test 는 `shot_description` 사용 안 함 → PASS. TypeError 가 발생하면 다른 곳에 잔재 있음 — step 4 에서 처리.

- [ ] **Step 4: 기존 test fixture 의 `shot_description=` kwarg 전수 제거**

```bash
grep -n "shot_description=" backend/tests/services/test_scene_reference_service.py
```

결과의 모든 site (Task 1 에서 삭제된 test 외에 남은 것 있다면) — kwarg line 제거. 다른 test file 에도 grep:

```bash
grep -rn "shot_description=" backend/tests --include="*.py"
```

발견된 모든 site 에서 `shot_description=...` kwarg line 제거.

- [ ] **Step 5: coordinator caller 2 곳 정리**

`backend/app/services/scene_generation_coordinator.py:321-327`:

```python
    # 4. resolve_refs_for_prompt_set (entity-only) — Fix A (2026-05-10):
    #    target_variations 모든 prompt 의 ID union 으로 attached_meta build.
    #    빈 prompt 는 join 단계에서 자연 skip.
    labeled_refs, attached_meta = reference_svc.resolve_refs_for_prompt_set(
        t2i_prompts=target_variations,
        visible_entities=visible_entities,
        scene_ref_image_map=scene_ref_image_map,
        entity_lookup=entity_lookup,
        state_variant_sids=state_variant_sids,
        required_refs=required_refs,
    )
```

(line 327 의 `shot_description=still_data.get("shot_description")` 제거.)

`backend/app/services/scene_generation_coordinator.py:614-620`:

```python
        # Fix A (2026-05-10): resolve_refs_for_prompt_set 호출 — 모든 target
        # variation 의 prompt 를 union 으로 attached_meta build.
        labeled_refs, attached_meta = self._reference_svc.resolve_refs_for_prompt_set(
            t2i_prompts=target_variations,
            visible_entities=visible_entities,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            state_variant_sids=state_variant_sids,
            required_refs=required_refs,
        )
```

(line 620 의 `shot_description=still_data.get("shot_description") or still_data.get("still_frame_prompt"),` 제거.)

- [ ] **Step 6: pytest 회귀 0 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py tests/services/test_scene_generation_coordinator.py -v 2>&1 | tail -50
```

(coordinator test file 부재 시 첫 file 만으로 실행.)

Expected: 회귀 0.

- [ ] **Step 7: production 코드 grep — `shot_description=` 호출 site 잔재 0 확인**

```bash
grep -rn "shot_description=" backend/app --include="*.py" | grep -v "still_data\['shot_description'\]\|get('shot_description')" | head -10
```

(`still_data.get('shot_description')` 같은 dict 접근은 다른 의미 — kwarg pass 만 차단.)

Expected: 0 line.

- [ ] **Step 8: commit**

```bash
git add backend/app/services/scene_reference_service.py \
        backend/app/services/scene_generation_coordinator.py \
        backend/tests/services/test_scene_reference_service.py
# coordinator test 파일 있으면 같이 add
git commit -m "$(cat <<'EOF'
refactor(area-d-min): drop shot_description kwarg signature cascade

resolve_refs_for_prompt / resolve_refs_for_prompt_set signature 에서
shot_description kwarg 제거. legacy prop text matching 분기 (Task 2) 폐기
이후 dead — caller / test fixture 전수 정리.

Cascade:
- scene_reference_service: resolver 2 함수 signature + forwarding.
- scene_generation_coordinator: caller 2 곳 (entity-only batch + target_variations batch).
- test fixtures: 기존 호출 site 전수 정리.

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

---

## Task 5: T5-T8 invariant + cascade test 추가

**Files:**
- Modify: `backend/tests/services/test_scene_reference_service.py`

- [ ] **Step 1: T5 — dedup invariant (`required_refs` 중복 P05 → single attach)**

```python
def test_resolve_refs_prop_dedup_when_required_refs_repeated(svc):
    """D-min T5: required_refs 가 같은 P## 를 중복 포함해도 single attach (dedup)."""
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="A travel bag sits by the door.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[
            {"kind": "prop", "id": "P02", "policy": "required"},
            {"kind": "prop", "id": "P02", "policy": "required"},  # 중복
        ],
    )
    p02_count = sum(1 for label, _ in out if "P02" in label)
    assert p02_count == 1
    assert meta.count(("prop", "P02")) == 1
    assert len(out) == len(meta)
```

- [ ] **Step 2: T6 — length invariant (labeled_refs/attached_meta 동일 길이)**

```python
def test_resolve_refs_length_invariant_across_branches(svc):
    """D-min T6: (labeled_refs, attached_meta) length 일치 invariant — char + prop 혼합."""
    visible = [
        {"id": "c1", "short_id": "C01", "name": "X", "entity_type": "character"},
        {"id": "p1", "short_id": "P02", "name": "trinket", "entity_type": "prop"},
    ]
    scene_ref_map = {"c1": b"CHAR_PNG", "p1": b"PROP_PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="C01O00 holds a trinket.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={"c1": {"short_id": "C01", "entity_type": "character", "name": "X"}},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    assert len(out) == len(meta)
    assert len(out) == 2  # character C01 + prop P02
```

- [ ] **Step 3: T7 — resolve_refs_for_prompt_set forwarding without shot_description**

```python
def test_resolve_refs_for_prompt_set_forwards_required_refs(svc):
    """D-min T7: resolve_refs_for_prompt_set 가 required_refs forwarding 만으로 동일 동작."""
    visible = [{"id": "p1", "short_id": "P02", "name": "여행 가방", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"PNG"}
    out, meta = svc.resolve_refs_for_prompt_set(
        t2i_prompts=["A bag sits.", "The bag stays."],
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[{"kind": "prop", "id": "P02", "policy": "required"}],
    )
    assert any("P02" in label for label, _ in out)
    assert ("prop", "P02") in meta
```

- [ ] **Step 4: T8 — signature cascade (shot_description kwarg TypeError)**

```python
def test_resolve_refs_for_prompt_rejects_shot_description_kwarg(svc):
    """D-min T8: shot_description kwarg 제거 후 caller 가 그 kwarg 로 부르면 TypeError.

    signature cascade 회귀 가드 — 미래에 누군가 shot_description= 부활시키면 즉시 차단.
    """
    visible = []
    scene_ref_map = {}
    with pytest.raises(TypeError, match="shot_description"):
        svc.resolve_refs_for_prompt(
            t2i_prompt="x",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_map,
            entity_lookup={},
            shot_description="legacy kwarg",  # type: ignore[call-arg]
        )


def test_resolve_refs_for_prompt_set_rejects_shot_description_kwarg(svc):
    """D-min T8b: set wrapper 도 동일 cascade 가드."""
    with pytest.raises(TypeError, match="shot_description"):
        svc.resolve_refs_for_prompt_set(
            t2i_prompts=["x"],
            visible_entities=[],
            scene_ref_image_map={},
            entity_lookup={},
            shot_description="legacy kwarg",  # type: ignore[call-arg]
        )
```

- [ ] **Step 5: pytest 실행 — T5-T8 PASS 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v -k "D-min or dedup or length_invariant or for_prompt_set_forwards or rejects_shot_description" 2>&1 | tail -30
```

Expected: T5, T6, T7, T8 (`_rejects_shot_description_kwarg` + `_set_rejects_shot_description_kwarg`) 모두 PASS.

- [ ] **Step 6: commit**

```bash
git add backend/tests/services/test_scene_reference_service.py
git commit -m "$(cat <<'EOF'
test(area-d-min): add invariant + cascade tests T5-T8

T5 (dedup): required_refs 중복 P## → single attach.
T6 (length invariant): char + prop 혼합 (labeled_refs, attached_meta) 일치.
T7 (resolve_refs_for_prompt_set forwarding): required_refs forwarding 만으로 동일 동작.
T8 (signature cascade): shot_description kwarg 부활 시 TypeError 회귀 가드.

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

---

## Task 6: broader regression + resolver contract canary + Codex external review

**Files:**
- Run: backend 전체 test
- Modify (있다면): regression fix
- Modify: `backend/tests/services/test_scene_reference_service.py` (resolver contract canary)

- [ ] **Step 1: backend 전체 test 회귀 확인**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/ -v 2>&1 | tail -80
```

Expected:
- Area A (`test_orientation_enum_alignment`, `shot_staging` 영역) 회귀 0.
- Area B (`render_contracts`, `entity_metadata`, `asset_requirements`) 회귀 0.
- char short_id (분기 1) + legacy `[[name]+[outlook]]` (분기 2) + state_variant 회귀 0.
- pre-existing fail (e.g. `test_text_cleanup.py`, `test_pipeline_v3_e2e::test_sync_updates_episode_status`) 는 무관 영역 — Task 6 closure 범위 밖.

회귀 발견 시 단계별 fix:
- 회귀가 D-min 본문 변경 (Task 2-4) 영향이면 직접 fix.
- 다른 영역 회귀면 분리 — D-min closure 차단 X.

- [ ] **Step 2: resolver contract canary test 작성 (hard gate, spec §7 항목 7)**

`backend/tests/services/test_scene_reference_service.py` 끝에 별도 섹션 추가:

```python
# ──────────────────────────────────────────────────────────────────────
# Area D-min closure canary — resolver contract
# spec §7 acceptance 7: required_refs 있음 → attach, 없음 + P##/name 있음 → attach 0.
# ──────────────────────────────────────────────────────────────────────


def test_d_min_canary_required_refs_present_attaches(svc):
    """D-min canary: required_refs 있음 + visible 등재 + ref image 있음 → attach."""
    visible = [
        {"id": "p1", "short_id": "P02", "name": "잔", "entity_type": "prop"},
        {"id": "p2", "short_id": "P05", "name": "table", "entity_type": "prop"},
    ]
    scene_ref_map = {"p1": b"P02_PNG", "p2": b"P05_PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="Random text without P-ids or names.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        required_refs=[
            {"kind": "prop", "id": "P02", "policy": "required"},
            {"kind": "prop", "id": "P05", "policy": "required"},
        ],
    )
    assert any("P02" in label for label, _ in out)
    assert any("P05" in label for label, _ in out)
    assert ("prop", "P02") in meta
    assert ("prop", "P05") in meta


def test_d_min_canary_no_required_refs_but_p_id_in_t2i_attaches_0(svc):
    """D-min canary: required_refs 없음 + t2i 에 P## 와 prop name 둘 다 있음 → attach 0."""
    visible = [{"id": "p1", "short_id": "P02", "name": "잔", "entity_type": "prop"}]
    scene_ref_map = {"p1": b"P02_PNG"}
    out, meta = svc.resolve_refs_for_prompt(
        t2i_prompt="The 잔 sits on P02 corner — both signals present.",
        visible_entities=visible,
        scene_ref_image_map=scene_ref_map,
        entity_lookup={},
        # required_refs intentionally absent
    )
    assert not any("P02" in label for label, _ in out)
    assert ("prop", "P02") not in meta
```

- [ ] **Step 3: canary test 실행**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && \
  .venv/bin/pytest tests/services/test_scene_reference_service.py -v -k "d_min_canary" 2>&1 | tail -10
```

Expected: 2 PASS.

- [ ] **Step 4: commit (canary)**

```bash
git add backend/tests/services/test_scene_reference_service.py
git commit -m "$(cat <<'EOF'
test(area-d-min): resolver contract canary (closure gate)

spec §7 acceptance criteria 7 — hard gate canary 영역.
required_refs 있음 → attach (정상 경로).
required_refs 없음 + t2i 에 P##/prop name 둘 다 있음 → attach 0 (legacy 폐기 확인).

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

- [ ] **Step 5: Codex external review prep**

본 patch (Task 1-6) 의 누적 변경 요약 + diff 를 Codex 에 review 요청. 형식:

```
[리뷰 패키지]
- spec: docs/superpowers/specs/2026-05-14-area-d-min-prop-required-refs-sot.md (7703efc)
- plan: docs/superpowers/plans/2026-05-14-area-d-min-prop-required-refs-sot-implementation.md (이 plan commit)
- 본 patch HEAD 의 최근 N 개 commit:
  - test(area-d-min): TDD red — T1-T4 + prop test 갱신
  - refactor(area-d-min): drop legacy prop text matching (line 549-590)
  - docs(area-d-min): cleanup docstrings — prop text matching 폐기 반영
  - refactor(area-d-min): drop shot_description kwarg signature cascade
  - test(area-d-min): add invariant + cascade tests T5-T8
  - test(area-d-min): resolver contract canary (closure gate)

Codex review 요청 영역:
1. legacy 분기 폐기의 silent fallback 차단 검증 (Gate 4).
2. signature cascade 누락 site 0 확인 (caller + test + monkeypatch grep evidence).
3. (labeled_refs, attached_meta) length invariant 보존.
4. visible_entities prop 의 LLM SOT 신뢰 — reference_required=false 의도된 변화 영역.
5. test T1-T8 의 회귀 가드 충분성.
```

본 step 은 실행 — 결과 NEEDS_REVISION_* / APPROVED_* 에 따라 분기:

- **APPROVED_***: Step 6 (메모리 + closure) 로.
- **NEEDS_REVISION_***: 본 task 안에서 fix + 새 commit + 재 review.

Codex review 실행 자체는 `codex:rescue` agent 또는 사용자 영역. plan 의 본 step 은 외부 review 의무 명시만.

- [ ] **Step 6: closure**

모든 acceptance §7 criteria PASS 확인:

1. ✅ `scene_reference_service.py` line 549-590 본문 + 주석/docstring 동시 제거 (Task 2 + Task 3).
2. ✅ resolver 2 함수 signature 에서 `shot_description` kwarg 제거 (Task 4).
3. ✅ coordinator caller 2 곳 정리 (Task 4).
4. ✅ Test fixtures 전수 `shot_description=` kwarg 정리 (Task 4).
5. ✅ §5 test T1-T8 PASS (Task 1, 5).
6. ✅ broader regression 0 (Task 6 step 1).
7. ✅ Hard gate canary PASS (Task 6 step 2-3).
8. ✅ Booster canary (image regen) 는 hard gate 아님 — closure 직후 별도 실행 가능.
9. ✅ Codex review APPROVED (Task 6 step 5).

closure commit (필요 시 — 보통 별도 commit 없이 step 5 의 review APPROVED 후 종료):

```bash
# closure 메모는 push 후 사용자 영역 — auto memory 갱신 + next_session 정리.
```

---

## Self-review

### Spec coverage

| Spec § | Task / Step |
|---|---|
| §1.1 본질 (prop attach 단일 SOT) | Task 2 |
| §1.2 원칙 (단일 SOT, silent fallback 금지, signature cascade, 의도된 변화) | Task 1, 2, 3, 4, 5 |
| §1.3 Out-of-scope | (none — carry only) |
| §2.1 현재 wire diagram | Task 1 step 1 (현 동작 분석) |
| §2.2 변경 후 wire | Task 2, 4 |
| §2.3 invariant (단일 경로, char/outlook 보존, length match, dedup) | Task 5 T5/T6/T7 |
| §3.1 폐기 + 격상 | Task 2 |
| §3.2 signature 변경 | Task 4 |
| §3.3 test fixtures cascade | Task 1 step 6 + Task 4 step 4 |
| §3.4 comment/docstring cleanup | Task 3 |
| §4 Gate 1-4 compliance | Task 2 (Gate 1), Task 6 step 5 Codex review (Gate 4 check) |
| §5 T1-T8 test scenarios | Task 1 T1-T4 + Task 5 T5-T8 |
| §6 Out-of-scope (C8/U2) | (plan 안에서 변경 0 — spec 의 명시) |
| §7 acceptance criteria 1-9 | Task 6 step 6 closure check |
| §8 Risk + carry | Task 4 step 7 (signature grep), Task 5 T5 (dedup), Task 6 step 5 (Codex) |

모든 spec 영역 cover.

### Placeholder scan

- "TBD" / "TODO" / "fill in details" / "similar to Task N" / "Add appropriate error handling" — 0.
- 모든 code step 에 actual code 포함.
- 모든 pytest command + expected output 명시.

### Type consistency

- `resolve_refs_for_prompt` signature: Task 1 (test) + Task 4 (code) + Task 5 (test) 모두 동일 — `shot_description` 없음, `required_refs` 있음.
- `resolve_refs_for_prompt_set` signature: Task 4 + Task 5 동일.
- test 의 `(out, meta)` 또는 `(labeled_refs, attached_meta)` 2-tuple shape — 모든 step 일치.
- `required_refs` shape `{kind, id, policy}` — Task 1 T2 + Task 5 T5/T6/T7 + Task 6 canary 동일.

### Risk reminder

- Task 4 step 4 + step 7 — `shot_description=` kwarg grep evidence 누락 시 production silent drift. 본 plan 안에서 caller 2 곳 + test fixture 전수 정리 명시.
- Task 6 step 1 — broader regression 발견 시 분리 (D-min 본문 변경 영향 아니면 별도 영역).
- Codex review 가 NEEDS_REVISION 반환하면 Task 6 안에서 fix + 재 review (closure 차단).
- **Codex plan review (2026-05-14) 흡수**:
  - IMPORTANT 1 — Task 1 step 1 + step 6 에서 D5 P2/invariant 섹션 2 test (line 898 / 954) + `_min_name_threshold_skips_two_chars` (line 1179) 추가 cascade 처리. **누락 시 9 → 6 삭제 + 4 → 2 갱신 으로 production 진입 시 cascade regression 발생.**
  - IMPORTANT 2 — Task 1 step 8 의 red commit 제거. Task 2 step 4 의 통합 commit 정책. main history 의 의도적 fail commit 0.
