# Area #11 — `prompt_service._classify_label` Substring Replacement v1 Implementation Plan

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

**Goal:** Replace `prompt_service._classify_label` substring dispatch with producer-side `LabeledRefPayload` container (parallel sidecar = `ref_roles`/`ref_role_metadata`). Consumer reads enum only, substring branch 0.

**Architecture:** Producer cascade (`scene_reference_service` + `scene_generation_coordinator` chain/prev_shot insert + `_build_image_index_helper` 6-tuple passthrough + `build_prev_shot_background_ref` 5-tuple) emits `LabeledRefPayload`. Consumer (`resolve_ref_roles(payload)` + `build_final_scene_prompt(payload, ...)`) enum dispatch only.

**Tech Stack:** Python 3.12 / dataclass / pytest / parallel sidecar SOT (Area #5 pattern reuse).

**Reference**:
- spec: `docs/superpowers/specs/2026-05-18-area-11-classify-label-substring-replacement-design.md` (commit `69a1181`)
- Tier 2 #2 closure (pattern donor): [[session_20260518_area_5_reference_phrase_phantom_guard_sot_v1_closure]]
- 4-gate compliance: [[feedback_llm_based_judgment]]
- NO VLM: [[feedback_no_vlm_dependency]]

---

## File Structure

### Production code (modified or created)

| File | Status | Change |
|------|--------|--------|
| `backend/app/services/prompt_service.py` | Modify | Add `LabeledRefPayload` dataclass + `REF_ROLE_VALUES` enum tuple + `RefRoleError` exception + `make_labeled_ref_payload` factory (W1). Rewrite `resolve_ref_roles(payload)` 11-branch enum dispatch + `build_final_scene_prompt(payload, ...)` signature cascade + drop `_classify_label` from production dispatch path (W2). |
| `backend/app/services/scene_reference_service.py` | Modify | (W1) Add internal `_collect_ref_roles_for_prompt_set(...)` placeholder helper (RefRoleError raise — No Silent Fallback gate, W2 implementation pending). (W2) **Both** `resolve_refs_for_prompt` (producer body, line 305+) **and** `resolve_refs_for_prompt_set` (wrapper, line 296) return `LabeledRefPayload` [Codex iter 2 Minor 3 fix]. `build_prev_shot_background_ref` 3-tuple → 5-tuple. `build_image_index` 4-tuple → 6-tuple. 8 producer append sites (377/396/407/416/447/456/473/509) ref_role 동반 emit. |
| `backend/app/services/scene_generation_coordinator.py` | Modify | (W2) **Single + batch + variation 3-path cascade**: Single helper (build_scene_attached_refs :354/:395/:445/:452/:459) + Batch path (_generate_scene_in_loop :659/:687/:736/:747) + Variation path (_generate_variation_in_loop :846 submit + method signature + :1475 call). All payload arg + 4 parallel list parallel + 5-tuple/6-tuple cascade. |

### Test code

| File | Status | Change |
|------|--------|--------|
| `backend/tests/services/test_prompt_service.py` | Modify (W3) | Cascade: `resolve_ref_roles` 호출은 payload 받음. substring 기반 가정 제거. |
| `backend/tests/services/test_prompt_service_label_routing.py` | Modify (W3) | Cascade: `_classify_label` direct test 폐기 또는 helper-level retention only. `resolve_ref_roles(payload)` enum dispatch test. |
| `backend/tests/services/test_prompt_service_payload.py` | Create (W1) | New: `LabeledRefPayload` factory 9 fail-fast unit tests. |
| `backend/tests/services/test_scene_reference_service.py` | Modify (W3) | resolve_refs_for_prompt_set payload return cascade. |
| `backend/tests/services/test_build_scene_attached_refs.py` | Modify (W3) | helper return cascade if applicable. |
| `backend/tests/services/test_single_scene_uses_helper.py` | Modify (W3, defensive) | mock cascade — W2 commit 시점 fail 0 (currently green); W3 mock signature 변경 시 영향 가능 (Codex iter 4 W2 review 정정). |
| `backend/tests/services/test_scene_generation_coordinator.py` | Modify (W3) | variation path `_generate_variation_in_loop(labeled_refs=...)` kwarg 가정 → `payload=...` cascade (W2 fail 2, Codex iter 4 W2 review 추가). |
| `backend/tests/unit/test_render_prompt_card_integration.py` | Modify (W3) | `:553` `resolve_refs_for_prompt` 2-tuple unpack → payload cascade (Codex iter 3 Important 1 fix). |
| `backend/tests/integration/test_area_11_canary.py` | Create (W4) | 17 canary scenarios (11 enum dispatch + 4 fail-fast + 1 full pipeline + 1 sanity). |
| `backend/tests/integration/test_area_11_residue_gate.py` | Create (W4) | residue gate: production substring branch 0 (in-scope path) + in-scope producer coverage 100%. |

### Documentation

| File | Status | Change |
|------|--------|--------|
| `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` | Modify (W4) | §5.11 `✓ closed C-11` marker + §11 closed area row + §14.3 Tier 2 #3 closed marker + 다음 진입점 = Area #6. |
| `~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_20260518_area_11_classify_label_substring_replacement_v1_closure.md` | Create (W4) | Closure memo (Area #5 pattern donor). |
| `~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/MEMORY.md` | Modify (W4) | Index entry. |

---

## Wave 0: Plan + Codex iter approval

### Task 0.1: Codex plan review iter

- [ ] **Step 1: Plan draft 작성 (본 문서)**
- [ ] **Step 2: Codex MCP 의논 prompt 송신 (trigger 포함 `완료되면 끝났다고 claude mcp 호출해줘!`)**
- [ ] **Step 3: Codex verdict 흡수 (Critical/Important/Minor + spec inconsistency 정정 의무)**
- [ ] **Step 4: APPROVED_FOR_EXECUTION 받으면 W1 진입**

### Task 0.2: Plan commit

```bash
git add docs/superpowers/plans/2026-05-18-area-11-classify-label-substring-replacement-implementation.md
git commit -m "docs(area-11-classify-label-substring-replacement-v1): W0 — plan implementation (Codex iter N APPROVED_FOR_EXECUTION)"
```

---

## Wave 1: Compatibility helper introduction (no production switch)

**Goal**: `LabeledRefPayload` + factory + `REF_ROLE_VALUES` + `RefRoleError` + internal `_collect_ref_roles_for_prompt_set` helper 신설. **Production return shape 보존** — consumer / caller / test 미변경.

### Task 1.1: prompt_service.py — LabeledRefPayload container + factory + enum + RefRoleError

**Files:**
- Modify: `backend/app/services/prompt_service.py` (after `RefResolution` dataclass)

- [ ] **Step 1: Write failing test first (W1 unit, 9 fail-fast paths)**

Create: `backend/tests/services/test_prompt_service_payload.py`

```python
"""Area #11 v1 W1 — LabeledRefPayload factory fail-fast tests.

9 fail-fast paths:
- 4 non-list isinstance (labeled_refs / ref_roles / ref_role_metadata / attached_meta)
- 3 length mismatch (ref_roles / ref_role_metadata / attached_meta)
- 1 invalid enum value
- 1 non-dict metadata entry

Pattern donor: Area #5 W2 test_ref_contract_validator_sidecar.py.
"""
import pytest
from app.services.prompt_service import (
    LabeledRefPayload, make_labeled_ref_payload, RefRoleError, REF_ROLE_VALUES,
)


def _ok_inputs():
    """1-element valid payload — baseline for fail-fast tests."""
    return {
        "labeled_refs": [("character C01O01", b"\x89PNG")],
        "ref_roles": ["character_ref"],
        "ref_role_metadata": [{"sid": "C01"}],
        "attached_meta": [("character", "C01")],
    }


def test_factory_happy_path_returns_payload():
    payload = make_labeled_ref_payload(**_ok_inputs())
    assert isinstance(payload, LabeledRefPayload)
    assert payload.ref_roles == ["character_ref"]


def test_factory_non_list_labeled_refs_raises():
    inputs = _ok_inputs()
    inputs["labeled_refs"] = "not-list"
    with pytest.raises(RefRoleError, match="labeled_refs is str"):
        make_labeled_ref_payload(**inputs)


def test_factory_non_list_ref_roles_raises():
    inputs = _ok_inputs()
    inputs["ref_roles"] = {"character_ref": True}
    with pytest.raises(RefRoleError, match="ref_roles is dict"):
        make_labeled_ref_payload(**inputs)


def test_factory_non_list_ref_role_metadata_raises():
    inputs = _ok_inputs()
    inputs["ref_role_metadata"] = None
    with pytest.raises(RefRoleError, match="ref_role_metadata is NoneType"):
        make_labeled_ref_payload(**inputs)


def test_factory_non_list_attached_meta_raises():
    inputs = _ok_inputs()
    inputs["attached_meta"] = 0
    with pytest.raises(RefRoleError, match="attached_meta is int"):
        make_labeled_ref_payload(**inputs)


def test_factory_ref_roles_length_mismatch_raises():
    inputs = _ok_inputs()
    inputs["ref_roles"] = ["character_ref", "prop_ref"]  # 2 vs 1
    with pytest.raises(RefRoleError, match="ref_roles length 2 != labeled_refs length 1"):
        make_labeled_ref_payload(**inputs)


def test_factory_ref_role_metadata_length_mismatch_raises():
    inputs = _ok_inputs()
    inputs["ref_role_metadata"] = []  # 0 vs 1
    with pytest.raises(RefRoleError, match="ref_role_metadata length 0 != labeled_refs length 1"):
        make_labeled_ref_payload(**inputs)


def test_factory_attached_meta_length_mismatch_raises():
    inputs = _ok_inputs()
    inputs["attached_meta"] = [("character", "C01"), ("prop", "P01")]  # 2 vs 1
    with pytest.raises(RefRoleError, match="attached_meta length 2 != labeled_refs length 1"):
        make_labeled_ref_payload(**inputs)


def test_factory_invalid_enum_value_raises():
    inputs = _ok_inputs()
    inputs["ref_roles"] = ["INVALID_ROLE"]
    with pytest.raises(RefRoleError, match="ref_roles\\[0\\]='INVALID_ROLE' not in REF_ROLE_VALUES"):
        make_labeled_ref_payload(**inputs)


def test_factory_non_dict_metadata_entry_raises():
    inputs = _ok_inputs()
    inputs["ref_role_metadata"] = [["sid", "C01"]]  # list not dict
    with pytest.raises(RefRoleError, match="ref_role_metadata\\[0\\] is list, not dict"):
        make_labeled_ref_payload(**inputs)


def test_ref_role_values_has_11_entries():
    """spec §3.1 — 11 enum values (Area #11 v1 baseline)."""
    assert len(REF_ROLE_VALUES) == 11
    expected = {
        "outfit_ref_explicit",
        "previous_shot_same_frame_zoomed",
        "previous_shot_same_room",
        "previous_shot_continuity",
        "background_chain_ref",
        "character_ref",
        "character_state_ref",
        "outfit_ref_inline",
        "prop_ref",
        "background_general",
        "fallback",
    }
    assert set(REF_ROLE_VALUES) == expected
```

- [ ] **Step 2: Run test — verify all 11 fail (LabeledRefPayload not yet defined)** (Codex iter 1 Minor 1 fix — count math 정정: 9 fail-fast + 1 happy path + 1 REF_ROLE_VALUES count = 11 tests)

```bash
./.venv/bin/pytest backend/tests/services/test_prompt_service_payload.py -v
```

Expected: 11 errors / failures (collection-time `ImportError` OR runtime `AttributeError`) for missing `LabeledRefPayload` / `make_labeled_ref_payload` / `RefRoleError` / `REF_ROLE_VALUES`. Codex iter 3 Minor fix — `pytest` 가 collection 실패 시 11 ERROR 로 report 가능 (not necessarily 11 FAIL); 둘 다 acceptable.

- [ ] **Step 3: Implement in prompt_service.py**

Add at top of `prompt_service.py` (after imports, before existing `RefResolution`):

```python
# ──────────────────────────────────────────────────────────────────────
# Area #11 v1 W1 — LabeledRefPayload container + factory.
# spec: docs/superpowers/specs/2026-05-18-area-11-classify-label-substring-replacement-design.md §3.1
# ──────────────────────────────────────────────────────────────────────

REF_ROLE_VALUES = (
    "outfit_ref_explicit",
    "previous_shot_same_frame_zoomed",
    "previous_shot_same_room",
    "previous_shot_continuity",
    "background_chain_ref",
    "character_ref",
    "character_state_ref",
    "outfit_ref_inline",
    "prop_ref",
    "background_general",
    "fallback",
)


class RefRoleError(Exception):
    """LabeledRefPayload invariant 위반 — fail-fast (No Silent Fallback gate)."""


@dataclass(frozen=True)
class LabeledRefPayload:
    """Area #11 v1 — producer-side structured payload for reference image dispatch.

    Invariants (fail-fast via make_labeled_ref_payload factory):
    - len(labeled_refs) == len(ref_roles) == len(ref_role_metadata) == len(attached_meta)
    - 각 ref_roles entry ∈ REF_ROLE_VALUES (else RefRoleError)
    - 각 ref_role_metadata entry 는 dict
    - 모든 4 collection 은 list (factory top-level isinstance gate)
    """
    labeled_refs: List[Tuple[str, Any]]
    ref_roles: List[str]
    ref_role_metadata: List[Dict[str, Any]]
    attached_meta: List[Tuple[str, str]]


def make_labeled_ref_payload(
    labeled_refs: List[Tuple[str, Any]],
    ref_roles: List[str],
    ref_role_metadata: List[Dict[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> LabeledRefPayload:
    """Factory — fail-fast on invariant violation (No Silent Fallback gate)."""
    if not isinstance(labeled_refs, list):
        raise RefRoleError(f"labeled_refs is {type(labeled_refs).__name__}, not list")
    if not isinstance(ref_roles, list):
        raise RefRoleError(f"ref_roles is {type(ref_roles).__name__}, not list")
    if not isinstance(ref_role_metadata, list):
        raise RefRoleError(f"ref_role_metadata is {type(ref_role_metadata).__name__}, not list")
    if not isinstance(attached_meta, list):
        raise RefRoleError(f"attached_meta is {type(attached_meta).__name__}, not list")
    n = len(labeled_refs)
    if len(ref_roles) != n:
        raise RefRoleError(f"ref_roles length {len(ref_roles)} != labeled_refs length {n}")
    if len(ref_role_metadata) != n:
        raise RefRoleError(f"ref_role_metadata length {len(ref_role_metadata)} != labeled_refs length {n}")
    if len(attached_meta) != n:
        raise RefRoleError(f"attached_meta length {len(attached_meta)} != labeled_refs length {n}")
    for i, role in enumerate(ref_roles):
        if role not in REF_ROLE_VALUES:
            raise RefRoleError(
                f"ref_roles[{i}]={role!r} not in REF_ROLE_VALUES "
                f"(allowed: {REF_ROLE_VALUES})"
            )
    for i, m in enumerate(ref_role_metadata):
        if not isinstance(m, dict):
            raise RefRoleError(f"ref_role_metadata[{i}] is {type(m).__name__}, not dict")
    return LabeledRefPayload(
        labeled_refs=list(labeled_refs),
        ref_roles=list(ref_roles),
        ref_role_metadata=list(ref_role_metadata),
        attached_meta=list(attached_meta),
    )
```

- [ ] **Step 4: Run W1 unit tests — verify all 11 PASS**

```bash
./.venv/bin/pytest backend/tests/services/test_prompt_service_payload.py -v
```

Expected: 11 PASS.

- [ ] **Step 5: Verify production untouched**

```bash
./.venv/bin/pytest backend/tests/services/test_prompt_service.py backend/tests/services/test_prompt_service_label_routing.py -q
```

Expected: 모든 기존 test PASS (W1 production return shape 보존, consumer / caller 미변경).

### Task 1.2: scene_reference_service.py — internal _collect_ref_roles_for_prompt_set helper

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

- [ ] **Step 1: 내부 helper 추가 — production return shape 보존**

Add (within SceneReferenceService class, after `resolve_refs_for_prompt_set`):

```python
def _collect_ref_roles_for_prompt_set(
    self,
    *,
    labeled_refs: List[Tuple[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> Tuple[List[str], List[Dict[str, Any]]]:
    """Area #11 v1 W1 internal helper — placeholder, W2 implementation pending.

    Codex iter 2 Important 1 fix — silent fallback stub 폐기.
    Production unused (W2 atomic switch 까지). W1 호출 시 RefRoleError raise
    (No Silent Fallback gate 일관). W2 atomic 에서 producer append site 와
    동시 implementation rewrite.
    """
    from app.services.prompt_service import RefRoleError
    raise RefRoleError(
        "_collect_ref_roles_for_prompt_set not production-ready (W1 placeholder; "
        "W2 implementation required). No Silent Fallback gate."
    )
```

- [ ] **Step 2: Verify production untouched**

```bash
./.venv/bin/pytest backend/tests/services/test_scene_reference_service.py -q
```

Expected: 모든 기존 test PASS.

### Task 1.3: W1 atomic commit + Codex per-wave review

- [ ] **Step 1: Stage W1 files**

```bash
git add backend/app/services/prompt_service.py \
        backend/app/services/scene_reference_service.py \
        backend/tests/services/test_prompt_service_payload.py
```

- [ ] **Step 2: Commit**

```
feat(area-11-classify-label-substring-replacement-v1): W1 — LabeledRefPayload container + factory + internal _collect_ref_roles helper

Compatibility helper introduction only. Production return shape 보존 — consumer / caller / test 미변경. W2 production switch atomic carry.

- prompt_service.py: REF_ROLE_VALUES (11 enum) + RefRoleError + LabeledRefPayload dataclass + make_labeled_ref_payload factory (9 fail-fast paths: 4 non-list isinstance + 3 length mismatch + 1 invalid enum + 1 non-dict metadata).
- scene_reference_service.py: internal `_collect_ref_roles_for_prompt_set` helper stub (W1 unused, W2 production 진입).
- test_prompt_service_payload.py 신설 — 11 unit tests (9 fail-fast + 1 happy path + 1 REF_ROLE_VALUES 검증). [Codex iter 2 Minor 1 fix]

Verify:
- W1 unit 11 PASS.
- 기존 prompt_service + scene_reference_service test green.
- production return shape 변경 0.

NO VLM. No Silent Fallback.
```

- [ ] **Step 3: Codex W1 per-wave review 송신** (trigger 포함)

- [ ] **Step 4: APPROVED_FOR_W2 받으면 W2 진입**

---

## Wave 2: Atomic production switch

**Goal**: 1 atomic commit 으로 producer return + 6 caller cascade + consumer 폐기 + signature cascade 모두 처리. intermediate broken state 없음.

### Task 2.1: scene_reference_service.py — resolve_refs_for_prompt + resolve_refs_for_prompt_set payload return

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:296-516`

**Codex iter 1 Critical 2 fix — return shape boundary 명시**: `resolve_refs_for_prompt_set` (line 296) 은 wrapper, `resolve_refs_for_prompt` (line 305+) 에 위임. append site 377/396/407/416/447/456/473/509 은 `resolve_refs_for_prompt` 안. **W2 = 두 함수 모두 payload return으로 변경** (wrapper-only conversion 안 함, implementation drift 회피).

- [ ] **Step 1: 8 producer append site 에 ref_role + metadata 동반 emit (Codex iter 1 Important 4 fix — metadata sid 정정)**

각 `labeled_refs.append(...)` 라인 다음에 parallel `ref_roles.append(role)` + `ref_role_metadata.append(metadata)` 추가:

| line | role | metadata |
|------|------|----------|
| 377 | `"character_state_ref"` | `{"sid": char_sid, "state": sv["state"]}` |
| 396 | `"character_ref"` | `{"sid": char_sid}` |
| 407 | `"outfit_ref_inline"` | `{"sid": sid}` |
| 416 | `"character_ref"` | `{"sid": char_sid}` |
| 447 | `"character_ref"` | `{}` |
| 456 | `"outfit_ref_inline"` | `{"sid": _legacy_char_sid}` (Codex iter 1 Important 4 fix — char_name → _legacy_char_sid 정정, current code 의 변수명) |
| 473 | `"character_ref"` | `{}` |
| 509 | `"prop_ref"` | `{"sid": prop_sid}` |

- [ ] **Step 2: `resolve_refs_for_prompt` return shape 변경 — `(labeled_refs, attached_meta)` 2-tuple → `LabeledRefPayload`**

```python
return make_labeled_ref_payload(
    labeled_refs=labeled_refs,
    ref_roles=ref_roles,
    ref_role_metadata=ref_role_metadata,
    attached_meta=attached_meta,
)
```

- [ ] **Step 3: `resolve_refs_for_prompt_set` wrapper 동시 cascade**

set 함수는 `resolve_refs_for_prompt` 호출 결과를 받아 그대로 반환 — payload 받아 그대로 반환. signature: `-> LabeledRefPayload`.

### Task 2.2: scene_reference_service.py — build_prev_shot_background_ref 5-tuple

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:892-928`

- [ ] **Step 1: 3-tuple → 5-tuple return**

```python
# ref_usage → role enum 1:1 매핑 (spec §3.2)
if ref_usage == "zoom_in_detail":
    ref_role = "previous_shot_same_frame_zoomed"
    label = "previous shot at same location (SAME FRAME ZOOMED) — ..."
    ...
elif ref_usage == "atmosphere_reference":
    ref_role = "previous_shot_continuity"
    label = "previous shot at same location (DIFFERENT ROOM/ANGLE) — ..."
elif ref_usage == "exact_background":
    ref_role = "previous_shot_same_room"
    label = "previous shot at same location (SAME ROOM) — ..."
else:
    ref_role = "previous_shot_continuity"  # default conservative
    label = "..."

ref_role_metadata = {
    "keep_elements": dep_info.get("keep_elements", []),
    "ignore": ignore or "",
    "remove_hints": remove_hints or [],
    "ref_usage": ref_usage,
}

return (label, bytes_, loc_id, ref_role, ref_role_metadata)  # 5-tuple
```

### Task 2.3: scene_reference_service.py — build_image_index 6-tuple

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

- [ ] **Step 1: signature 확장 + parallel 3 list passthrough**

```python
def build_image_index(
    labeled_refs: List[Tuple[str, Any]],
    entity_lookup: Dict[str, Dict],
    *,
    ref_roles: List[str],
    ref_role_metadata: List[Dict[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> Tuple[
    List[Tuple[str, Any]],
    Dict[str, int],
    Dict[str, Any],
    List[str],
    List[Dict[str, Any]],
    List[Tuple[str, str]],
]:
    """Area #11 v1 W2 — 6-tuple. label rewrite + 3 parallel list passthrough.

    deterministic ID extraction (label → sid_to_img_map) 은 closed-world ID
    pattern 으로 보존 boundary (spec §3.2). role classification 폐기는 별개.
    """
    # length 보존 fail-fast
    n = len(labeled_refs)
    if not (len(ref_roles) == len(ref_role_metadata) == len(attached_meta) == n):
        raise RefRoleError(
            f"build_image_index parallel list length mismatch: "
            f"labeled_refs={n} ref_roles={len(ref_roles)} "
            f"ref_role_metadata={len(ref_role_metadata)} "
            f"attached_meta={len(attached_meta)}"
        )
    # label rewrite + sid extraction logic (보존, 기존 deterministic ID)
    ...
    return (
        indexed_labeled_refs,
        sid_to_img_map,
        sid_info,
        list(ref_roles),
        list(ref_role_metadata),
        list(attached_meta),
    )
```

### Task 2.4: scene_generation_coordinator.py — single + batch + variation 3 path 모두 cascade (Codex iter 1 Critical 1 fix — undercount 정정)

**Files:**
- Modify: `backend/app/services/scene_generation_coordinator.py`

**chain_bg missing bg_id 처리 정책 결정 (Codex iter 1 Important 3 + iter 2 Important 3 fix — wording/behavior 일치화)**:
현재 batch path :687-693 은 bg_id 없으면 attached_meta skip — payload length invariant 충돌. 결정 = **fail-fast (strict)**:
- bg_id None / empty → `RefRoleError("chain_bg missing bg_id — No Silent Fallback gate")` raise (chain_bg insert 자체 차단).
- caller 가 `_bc_bg.get("bg_id")` 검증 후만 insert 호출 의무.
- length parity 유지하면서 empty content 허용 옵션은 폐기 (wording/behavior drift 회피).

#### Task 2.4a: Single helper path (`build_scene_attached_refs` :209+) cascade

- [ ] **`:354` unpack — payload**

```python
payload = reference_svc.resolve_refs_for_prompt_set(
    t2i_prompts=_ref_prompts, ...
)
labeled_refs = payload.labeled_refs
attached_meta = payload.attached_meta
ref_roles = payload.ref_roles
ref_role_metadata = payload.ref_role_metadata
```

- [ ] **`:395` chain_bg insert (single path)** — 3 parallel list parallel insert(0)

```python
# Codex iter 2 Important 3 fix — fail-fast strict (No Silent Fallback)
_bg_id = _bc_bg.get("bg_id")
if not _bg_id:
    raise RefRoleError("chain_bg missing bg_id (single path) — No Silent Fallback gate")
labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))
ref_roles.insert(0, "background_chain_ref")
ref_role_metadata.insert(0, {"bg_id": _bg_id})
attached_meta.insert(0, ("background", _bg_id))
```

- [ ] **`:445` prev_shot insert (single path)** — 5-tuple unpack + 4 parallel list insert

```python
prev_shot_result = reference_svc.build_prev_shot_background_ref(...)
if prev_shot_result is not None:
    _label, _bytes, _loc_id, _role, _metadata = prev_shot_result  # 5-tuple
    labeled_refs.insert(0, (_label, _bytes))
    ref_roles.insert(0, _role)
    ref_role_metadata.insert(0, _metadata)
    attached_meta.insert(0, ("background_prev_shot", _loc_id or ""))
```

- [ ] **`:452` `_build_image_index_helper` (single path)** — 6-tuple unpack

```python
labeled_refs, sid_to_img, sid_info, ref_roles, ref_role_metadata, attached_meta = (
    _build_image_index_helper(
        labeled_refs, entity_lookup,
        ref_roles=ref_roles,
        ref_role_metadata=ref_role_metadata,
        attached_meta=attached_meta,
    )
)
```

- [ ] **`:459` `_build_final_scene_prompt` (single path)** — payload arg

```python
payload = make_labeled_ref_payload(
    labeled_refs=labeled_refs,
    ref_roles=ref_roles,
    ref_role_metadata=ref_role_metadata,
    attached_meta=attached_meta,
)
_full_prompt = _build_final_scene_prompt(var_t2i, payload, cached_style_context, ...)
```

#### Task 2.4b: Batch path (`_generate_scene_in_loop` :538+) cascade (Codex iter 1 Important 1+2 fix)

- [ ] **`:659` unpack — payload**

```python
payload = self._reference_svc.resolve_refs_for_prompt_set(
    t2i_prompts=_target_raw_prompts, ...
)
labeled_refs = payload.labeled_refs
attached_meta = payload.attached_meta
ref_roles = payload.ref_roles
ref_role_metadata = payload.ref_role_metadata
```

- [ ] **`:687` chain_bg insert (batch path)** — 4 parallel list insert (현재 bg_id None guard → fail-fast policy 적용)

```python
if _bc_bg and _bc_bg.get("image_bytes") and not _is_close_framing:
    # Codex iter 2 Important 3 fix — fail-fast strict (No Silent Fallback)
    _bg_id = _bc_bg.get("bg_id")
    if not _bg_id:
        raise RefRoleError("chain_bg missing bg_id (batch path) — No Silent Fallback gate")
    labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))
    ref_roles.insert(0, "background_chain_ref")
    ref_role_metadata.insert(0, {"bg_id": _bg_id})
    attached_meta.insert(0, ("background", _bg_id))
    # 4 parallel list 모두 same length 강제 + bg_id 정합 strict
```

- [ ] **`:736` prev_shot insert (batch path)** — 5-tuple unpack + 4 parallel list insert

```python
if _prev_shot_ref:
    _label, _bytes, _loc_id, _role, _metadata = _prev_shot_ref  # 5-tuple
    labeled_refs.insert(0, (_label, _bytes))
    ref_roles.insert(0, _role)
    ref_role_metadata.insert(0, _metadata)
    attached_meta.insert(0, ("background_prev_shot", _loc_id or ""))
    # 기존 if _loc_id guard 제거 — same length 강제
```

- [ ] **`:747` `_build_image_index_helper` (batch path)** — 6-tuple unpack

```python
labeled_refs, _sid_to_img, _sid_info, ref_roles, ref_role_metadata, attached_meta = (
    _build_image_index_helper(
        labeled_refs, entity_lookup,
        ref_roles=ref_roles,
        ref_role_metadata=ref_role_metadata,
        attached_meta=attached_meta,
    )
)
```

#### Task 2.4c: Variation path (`_generate_variation_in_loop`) cascade (Codex iter 1 Important 2 fix)

- [ ] **`:846` submit arg** — `payload` 전달

```python
# in target_variations loop (line 808+):
payload = make_labeled_ref_payload(
    labeled_refs=labeled_refs,
    ref_roles=ref_roles,
    ref_role_metadata=ref_role_metadata,
    attached_meta=attached_meta,
)
var_futures[var_executor.submit(
    self._generate_variation_in_loop,
    still_data=still_data,
    var_t2i=var_t2i,
    ...,
    payload=payload,    # was: labeled_refs=labeled_refs, attached_meta=attached_meta
    ...
)] = vi
```

- [ ] **`_generate_variation_in_loop` method signature** — payload 받음 (line 1361+)

```python
def _generate_variation_in_loop(
    self,
    *,
    still_data: Dict[str, Any],
    var_t2i: str,
    ...,
    payload: LabeledRefPayload,    # was: labeled_refs: list, attached_meta: list
    ...,
) -> Optional[Dict[str, Any]]:
    ...
```

- [ ] **`:1475` `_build_final_scene_prompt` (variation path)** — payload arg

```python
_full_prompt = _build_final_scene_prompt(
    var_t2i, payload, cached_style_context, ...
)
```

### Task 2.5: prompt_service.py — resolve_ref_roles(payload) + build_final_scene_prompt(payload)

**Files:**
- Modify: `backend/app/services/prompt_service.py:75-209` (resolve_ref_roles + _classify_label drop)

- [ ] **Step 1: `_classify_label` production dispatch 폐기**

production dispatch path (`resolve_ref_roles` 내부 호출) 에서 `_classify_label` 호출 제거. 함수 정의는 test helper / legacy boundary 로 유지 가능 (Codex iter 3 인정).

- [ ] **Step 2: `resolve_ref_roles(payload: LabeledRefPayload) -> RefResolution` rewrite**

```python
def resolve_ref_roles(payload: LabeledRefPayload) -> RefResolution:
    """payload.ref_roles enum dispatch only. _classify_label substring branch 폐기.

    Area #11 v1 W2 — 11 branch enum dispatch.
    """
    ref_roles_text: List[str] = []
    ref_instructions: List[str] = []
    for i, (label, _) in enumerate(payload.labeled_refs, 1):
        role = payload.ref_roles[i - 1]
        metadata = payload.ref_role_metadata[i - 1]
        if role == "outfit_ref_explicit":
            ...
        elif role == "previous_shot_same_frame_zoomed":
            # metadata pre-parsed keep_elements / ignore / remove_hints 활용
            for elem in metadata.get("keep_elements", []):
                ref_instructions.append(f"- from image {i}: keep {elem['label']}")
            ...
        elif role == "previous_shot_same_room":
            ...
        elif role == "previous_shot_continuity":
            ...
        elif role == "background_chain_ref":
            ...
        elif role == "character_ref":
            ...
        elif role == "character_state_ref":  # NEW (Area #11 v1)
            state = metadata.get("state", "")
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(
                f"- use image {i} as state-variant reference ({state}) — "
                f"match the character's body language for this specific state"
            )
        elif role == "outfit_ref_inline":
            ...
        elif role == "prop_ref":
            ...
        elif role == "background_general":
            ...
        else:  # "fallback"
            ref_roles_text.append(f"Reference image {i}: {label}")
            ref_instructions.append(f"- reference image {i}: {label}")
    # ... common ref_instructions ...
    return RefResolution(ref_roles=ref_roles_text, ref_instructions=ref_instructions)
```

- [ ] **Step 3: `build_final_scene_prompt(payload)` signature cascade**

```python
def build_final_scene_prompt(
    t2i_prompt: str,
    payload: LabeledRefPayload,    # was: labeled_refs: list
    style_context: str,
    tracer: Any = None,
    scene_index: int = 0,
    project_config: Optional[Dict] = None,
    entity_text_map: Optional[Dict[str, str]] = None,
) -> str:
    ref_resolution = resolve_ref_roles(payload)
    cleaned = replace_entity_ids(t2i_prompt, payload.labeled_refs, entity_text_map)
    ...
```

### Task 2.6: W2 atomic commit + Codex per-wave review

- [ ] **Step 1: Run W2-scope tests — explicit expected fail set (Codex iter 2 Important 2 fix — boundary 명시화)**

```bash
./.venv/bin/pytest backend/tests/services/test_prompt_service*.py \
                   backend/tests/services/test_scene_reference_service.py \
                   backend/tests/services/test_scene_generation_coordinator.py \
                   backend/tests/services/test_build_scene_attached_refs.py \
                   backend/tests/services/test_single_scene_uses_helper.py \
                   backend/tests/unit/test_render_prompt_card_integration.py \
                   -rfsxX  # Codex iter 3 Minor fix — full failure identity capture (replace `tail -30`, ensure every fail / error / xfail / skip is named)
```

**Expected outcome (W2 commit-time boundary)**:
- W2 commit 시점 = signature-breaking transition. test green 강제 불가 (W3 cascade 까지 transient broken state).
- **Expected fail set (W2-acceptable, W3 cascade pending — Codex iter 4 W2 review NEEDS_REVISION_MINOR fix, 실측 76 fail breakdown)**:
  - `test_scene_reference_service.py` — **46 fail** — `resolve_refs_for_prompt_set` / `resolve_refs_for_prompt` 2-tuple unpack 가정 (W3 cascade payload).
  - `test_build_scene_attached_refs.py` — **15 fail** — helper 4-tuple → payload return (W3 cascade).
  - `test_prompt_service.py` — **9 fail** — `resolve_ref_roles(labeled_refs)` 호출 가정 (W3 cascade payload arg).
  - `test_prompt_service_label_routing.py` — **3 fail** — substring 가정 base 의 `_classify_label` direct test (W3 rewrite scope).
  - `test_scene_generation_coordinator.py` — **2 fail** — variation path `_generate_variation_in_loop(labeled_refs=...)` kwarg 가정 (W3 cascade payload, Codex iter 4 W2 review boundary 정정).
  - `test_render_prompt_card_integration.py:553` — **1 fail** — `resolve_refs_for_prompt` 2-tuple unpack 가정 (W3 Task 3.4 cascade, Codex iter 3 Important 1 fix).
  - **합계 76 fail**.
- **Expected PASS (W2 green carry, W2 commit-time)**:
  - `test_prompt_service_payload.py` — W1 factory unit 11 PASS 유지.
  - `test_single_scene_uses_helper.py` — **0 fail (currently green)** — W2 commit 시점 production switch 영향 없음. W3 mock signature 변경 시 cascade 영향 가능 (Codex iter 4 W2 review 정정 wording).
  - Other unrelated test files green carry.
- W2 verdict 정합 = expected fail set 만 fail + 다른 test green. W3 에서 expected fail set 모두 cascade rewrite → green.

- [ ] **Step 2: Atomic commit**

```
refactor(area-11-classify-label-substring-replacement-v1): W2 — atomic production switch (3-path cascade: single + batch + variation, all signature-breaking sites)

[Codex iter 2 Minor 2 fix — cascade scope sync to Task 2.4a/b/c]

scene_reference_service.py:
- resolve_refs_for_prompt + resolve_refs_for_prompt_set: 2-tuple → LabeledRefPayload (both functions, 8 producer site ref_role 동반 emit).
- build_prev_shot_background_ref: 3-tuple → 5-tuple (ref_usage → role 1:1 매핑).
- build_image_index: 4-tuple → 6-tuple (3 parallel list passthrough + length fail-fast).

scene_generation_coordinator.py (single + batch + variation 3-path):
- Single helper path (build_scene_attached_refs): :354 unpack / :395 chain insert / :445 prev insert (5-tuple) / :452 image_index (6-tuple) / :459 build_final_scene_prompt payload arg.
- Batch path (_generate_scene_in_loop): :659 unpack / :687 chain insert (bg_id fail-fast strict) / :736 prev insert (loc_id allowed empty) / :747 image_index.
- Variation path (_generate_variation_in_loop): :846 submit arg payload / method signature payload kwarg / :1475 build_final_scene_prompt payload arg.

prompt_service.py:
- resolve_ref_roles(payload): 11 branch enum dispatch only, _classify_label production drop.
- build_final_scene_prompt(payload, ...): signature cascade.

W2 commit-time test boundary: expected fail set (W3 cascade pending) 76 fail total — test_scene_reference_service (46) + test_build_scene_attached_refs (15) + test_prompt_service (9) + test_prompt_service_label_routing (3) + test_scene_generation_coordinator (2) + test_render_prompt_card_integration (1). test_single_scene_uses_helper currently green (W3 mock signature 변경 시 영향 가능).

No Silent Fallback. NO VLM.
```

- [ ] **Step 3: Codex W2 per-wave review 송신** (trigger 포함)

---

## Wave 3: Test cascade

**Goal**: W2 signature 변경 cascade test 정합. substring 가정 기반 test rewrite.

### Task 3.1: test_prompt_service_label_routing.py rewrite

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

- [ ] **Step 1: `_classify_label` direct test 폐기 또는 helper-level test 로 변환**

```python
# Before: assert _classify_label("previous shot at same location (SAME ROOM)") == "previous_shot_same_room"
# After: test removed (W2 production drop) OR retained as helper test if _classify_label kept as legacy.
```

- [ ] **Step 2: `resolve_ref_roles(payload)` enum dispatch test**

```python
def test_resolve_ref_roles_character_state_ref():
    payload = make_labeled_ref_payload(
        labeled_refs=[("character C01 — angry state reference", b"")],
        ref_roles=["character_state_ref"],
        ref_role_metadata=[{"sid": "C01", "state": "angry"}],
        attached_meta=[("character_state", "C01:angry")],
    )
    result = resolve_ref_roles(payload)
    assert "state-variant reference" in "\n".join(result.ref_instructions)
    assert "angry" in "\n".join(result.ref_instructions)
```

(11 enum dispatch test 작성)

### Task 3.2: test_scene_reference_service.py + test_scene_generation_coordinator.py cascade

`resolve_refs_for_prompt_set` payload return 가정 + `build_prev_shot_background_ref` 5-tuple + `build_image_index` 6-tuple 가정 mock 정합.

### Task 3.3: test_build_scene_attached_refs + test_single_scene_uses_helper cascade

`test_build_scene_attached_refs.py` (15 fail): helper 4-tuple return → payload return 가정 mock 정합 + 5-tuple `build_prev_shot_background_ref` mock + 6-tuple `build_image_index` mock cascade.

`test_single_scene_uses_helper.py` (W2 commit 시점 0 fail — currently green): defensive cascade. W3 mock signature 변경 (예: helper 4-tuple → payload return, `_build_final_scene_prompt` payload arg) 시 영향 받을 가능성 있으므로 mock side_effect / return_value 시그니처 확인 후 필요 시 보정.

(Area #5 W3 cascade pattern 적용)

### Task 3.4: test_render_prompt_card_integration.py:553 cascade (Codex iter 3 Important 1 fix)

**Files:**
- Modify: `backend/tests/unit/test_render_prompt_card_integration.py:553`

Current test 가 `resolve_refs_for_prompt` 결과를 2-tuple `(labeled_refs, attached_meta)` 로 unpack → W2 production switch 시 즉시 broken (expected fail set 에 포함). W3 cascade:

```python
# Before:
labeled_refs, attached_meta = svc.resolve_refs_for_prompt(...)

# After:
payload = svc.resolve_refs_for_prompt(...)
labeled_refs = payload.labeled_refs
attached_meta = payload.attached_meta
# ref_roles / ref_role_metadata 검증 추가 시 명시
```

Verify:
```bash
./.venv/bin/pytest backend/tests/unit/test_render_prompt_card_integration.py -v -rfsxX
```
Expected: green (W2 expected fail set 해소).

### Task 3.5: W3 atomic commit + Codex per-wave review

```
test(area-11-classify-label-substring-replacement-v1): W3 — test cascade (substring 가정 → enum dispatch + payload return + 5/6-tuple signature)
```

---

## Wave 4: Residue gate + canary + roadmap + closure

### Task 4.1: residue gate (production + in-scope producer coverage)

**Files:**
- Create: `backend/tests/integration/test_area_11_residue_gate.py`

**Codex iter 1 Minor 2 fix — pattern wording 정확화**: residue gate 는 literal grep 으로 오해 회피. `outfit` 같은 단어는 `resolve_ref_roles` 의 instruction text 정상 등장. strict gate = `_classify_label(` production caller pattern + `if "..." in label_lower` pattern (substring branch 형태) 한정.

```python
"""Area #11 v1 — production residue gate (in-scope role dispatch path only).

8 항목 strict 0 (Codex iter 1 Minor 2 fix — pattern 정확화):
- prompt_service.py 내 `if "..." in label_lower` substring branch 8개 (previous shot / continuity / wearing / outfit / prop / object / background / location) 0 hits (literal `outfit` 등 등장은 OK, branch pattern 만 차단)
- prompt_service.py 내 `_classify_label(` production caller (resolve_ref_roles 본문) 0 hits
- scene_reference_service 8 producer append site (line 377/396/407/416/447/456/473/509) 에 ref_role 동반 emit 검증 (resolve_refs_for_prompt 결과 가 LabeledRefPayload instance)
- build_prev_shot_background_ref 5-tuple return signature 검증
- build_image_index 6-tuple return signature 검증
- coordinator caller cascade 검증 (single :354/:395/:445/:452/:459 + batch :659/:687/:736/:747/:846/:1475 모두 payload)
- _is_explicit_character_ref_label production dispatch 진입점 0 (resolve_ref_roles 본문 내 호출 0)
- LabeledRefPayload import site + make_labeled_ref_payload factory 호출 site 검증
"""
```

### Task 4.2: canary 17 (11 enum + 4 fail-fast + 1 full pipeline + 1 sanity)

**Files:**
- Create: `backend/tests/integration/test_area_11_canary.py`

11 enum dispatch (canary 1-11, REF_ROLE_VALUES 각 entry) + 4 factory fail-fast (canary 12-15: non-list / length mismatch / invalid enum / non-dict metadata) + 1 full pipeline happy path (canary 16: build_final_scene_prompt with payload end-to-end) + 1 REF_ROLE_VALUES 11-count sanity (canary_extras). NO real LLM call, NO VLM.

### Task 4.3: roadmap §5.11 / §11 / §14 update

- §5.11 `✓ closed C-11 (Tier 2 #3, 2026-05-18+)` marker
- §11 Closed Area row 추가
- §14.3 Tier 2 #3 closed + 다음 진입점 = Tier 2 #4 Area #6 (T2I review mutation)

### Task 4.4: closure memo + MEMORY.md

`~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_20260518_area_11_classify_label_substring_replacement_v1_closure.md` (Area #5 closure pattern donor).

MEMORY.md 첫 줄에 entry 추가.

### Task 4.5: W4 atomic commit + Codex range W1-W4 review

```
docs(area-11-classify-label-substring-replacement-v1): W4 — closure (residue gate 8 + canary 17 + roadmap §5.11/§11/§14 update + closure memo)
```

### Task 4.6: Push to origin/main (Codex APPROVED_FOR_PUSH 후)

---

## Risks (carry from spec §6)

1. Producer 미적용 site missed → factory fail-fast (length mismatch) 가 1차 방어.
2. `build_image_index` 6-tuple passthrough 누락 → length fail-fast.
3. `scene_image_pipeline.py:243` previous scene Gemini path 진입 시 v1 out-of-scope (boundary).
4. legacy `_classify_label` test 기반 가정 cascade → W3 rewrite.

## 함정 carry (Tier 1+2 + Area #11 신규)

(W4 closure memo 작성 시 finalize)
