# FINDING C — image-phase ref-contract residue 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:** scene_image_pipeline 의 `reference_phrase_kinds` sidecar 가 실제 attached
ref meta 와 불일치해 image-gen 직전 REF_CONTRACT_VIOLATION 으로 실패하는 잔여
3 케이스를 narrow contract-level fix 로 해소한다 (금월도 E2E 63/66 → 66/66).

**Architecture:** 두 독립 카테고리, 두 layer. **Category B (W1, producer)** —
`build_asset_requirements` 가 `base_id_required` 캐릭터의 character required_ref
를 `_state_variant`/`O00` `continue` 때문에 emit 못 하는 루프 순서 결함을 수정.
**Category A (W2, consumer)** — `background` over-declaration 을 coordinator 의
attachment 확정 후 boundary 에서 normalize. **W3** — E2E 재검증 + closure + push.

**Tech Stack:** Python 3.12, pytest, FastAPI backend, PostgreSQL. 수정 대상 =
`backend/app/core/steps/render_prompt_card.py` (W1) +
`backend/app/services/scene_generation_coordinator.py` (W2).

---

## Background — W0 triage 확정 사실

W0 read-only triage (live checkpoint/DB/manifest/image_asset + code 5파일 +
git 이력) 결과. Codex `FINDING_C_W0_TRIAGE_VERDICT` 전 항목 승인.

**대상:** project `fdd90057-7bc9-4d3c-82ba-5d8489c021a2` / episode
`eb4b3a35-399d-4bd5-98bd-8d791659d600`. scene_checkpoint.json failed dict 3건,
전부 validator `ref_contract_validator.py` step 6 (sidecar phantom guard) 에서
RefContractError.

**Category B (case 2,3):**
- still `b512587c` (DB scene11 shot10) / `68eb2cb9` (DB scene11 shot14). 둘 다
  C08 = "죽은 엄마", `dead` state-variant (`state_variant:d06ba86f...:dead`
  ref image 확인) + `id_policy.subject_reference_policy.policy = base_id_required`.
- producer (W4b) 가 t2i_prompt 에 bare `C08` 작성. variation
  `reference_phrase_kinds` 에 `character` 선언 (정상).
- 근본 원인: `render_prompt_card.py` `build_asset_requirements` 의
  `for pair in outlook_pairs` 루프에서 `if cid in _state_variant: continue`
  (line ~2041) 가 `base_id_required` policy 분기 (line ~2061) **앞**에 위치 →
  C08 의 `{kind:character, id:C08}` required_ref 가 **emit 안 됨**.
- → `required_refs` 에 character 항목 0 → resolver `resolve_refs_for_prompt`
  section 1 regex `(C##)(O##)` 는 bare `C08` 미매칭 / section 2.5 는
  `required_refs(kind=character)` 신호 필요 → character meta attach 0 →
  step 6 `character` check fail.
- FINDING 9 W2 (`4538b66`) 의 true residue — `_state_variant` continue
  (`50205be`, 2026-05-10) 가 W2 의 base_id_required 분기 추가보다 선행.

**Category A (case 1):**
- still `96cb0479` (DB scene2 shot1). `background_binding.mode="not_applicable"`,
  `bg_id=null`, `asset_requirements.required_refs=[]`. t2i_prompt 에
  `[L02: ...]` free-form location 묘사 블록.
- scene_detail LLM 이 `[L02:]` 블록 보고 variation
  `reference_phrase_kinds=['background']` over-declare. background ref 안 bound.
- coordinator background attach (`scene_generation_coordinator.py` 5a~5d 분기):
  chain_bg 부재 → prev_shot try → `build_prev_shot_background_ref` None →
  entity-only → `attached_meta` 에 background 0 → step 6 `background` fail.
- producer 는 background attach 여부를 예측 불가 (chain_bg map + prev_shot 는
  coordinator runtime SOT). `_strip_overdeclared_prop/character_phrase_kind`
  helper (W4 `ad5e6a0` / FINDING 11 `00243a6`) 가 명시적으로 `background` 를
  scope-out 한 미커버 gap.

**Codex 승인 SOT:**
- B: base_id_required + state_variant/O00 캐릭터에 `{kind:character}` emit.
  `_state_variant`/`O00` exclusion 은 `character_outlook` 위조 방지용 →
  `id_and_outlook_required` 분기에만 적용. resolver 무변경 (section 2.5 이미
  `required_refs(kind=character)` → base attach). state_variant ref 우선 attach
  는 이번 scope 밖 (semantic refinement, blast radius↑).
- A: consumer boundary normalize. 3-조건 strip. producer helper 금지.
  validator step 6 무변경 (약화 0). single + batch path 양쪽 parity.

---

## File Structure

- **Modify** `backend/app/core/steps/render_prompt_card.py` — W1: `build_asset_requirements`
  루프 재구성 (~line 2021-2076). policy 를 `_state_variant`/`O00` `continue`
  이전에 resolve, exclusion 을 `id_and_outlook_required` 분기에만 적용.
- **Modify** `backend/app/services/scene_generation_coordinator.py` — W2: 모듈
  레벨 `_normalize_background_phrase_kind` helper 신설 + 2개 `validate_attached_refs`
  call site (single ~line 1465, variation ~line 1574) 직전에 wiring.
- **Create** `backend/tests/core/test_finding_c_w1_state_variant_base_id.py` — W1 TDD.
- **Create** `backend/tests/services/test_finding_c_w2_background_overdeclare.py` — W2 TDD.

수정 0 (W0 triage 가 무변경 확정): `ref_contract_validator.py` (step 6 무변경),
`scene_reference_service.py` (section 2.5 base attach 이미 정상), `detail_steps.py`
(`_strip_overdeclared_*` helper 무변경 — background 는 consumer layer).

---

## Task W1: Category B — base_id_required character required_ref emit 수정 (producer)

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:2021-2076` (`build_asset_requirements` 루프)
- Test: `backend/tests/core/test_finding_c_w1_state_variant_base_id.py` (create)

- [ ] **Step W1.0: W0 broad test-pin (read-only)**

기존 동작을 pin 하는 테스트가 깨질 수 있으므로 먼저 baseline 을 잡는다.

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/core/test_finding9_w2_base_id_required.py tests/unit/test_render_prompt_card.py tests/unit/test_subject_reference_policy_helper.py -q
```
Expected: 전부 PASS (baseline green). 출력의 pass 개수를 기록한다. 만약
`build_asset_requirements` 의 `_state_variant`+`base_id_required` 교집합의
현재 (버그) 동작을 pin 하는 테스트가 있으면 식별 — W1 구현 시 갱신 필요
(test-pin blocker → Codex 의논 후 흡수).

- [ ] **Step W1.1: Write the failing tests**

Create `backend/tests/core/test_finding_c_w1_state_variant_base_id.py`:

```python
"""FINDING C W1 (Category B) — base_id_required + state_variant/O00 character
required_ref emit 수정.

W0 triage: `build_asset_requirements` 의 `for pair in outlook_pairs` 루프에서
`if cid in _state_variant: continue` 와 `if oid == "O00": continue` 가
`base_id_required` policy 분기보다 앞 → base_id_required 이면서 state_variant
이거나 O00 인 캐릭터는 `{kind:character}` required_ref 가 emit 0 → resolver
attach 0 → validator step 6 `character` fail.

fix: policy 를 exclusion 이전에 resolve. `_state_variant`/`O00` exclusion 은
`character_outlook` 위조 방지용 → `id_and_outlook_required` 분기에만 적용.
base_id_required 는 state_variant/O00 무관 `{kind:character}` emit.

대상 실패 still: b512587c S11.10 / 68eb2cb9 S11.14 (둘 다 C08 base_id_required
+ dead state-variant). Gates G1-G8.
"""
from __future__ import annotations

import pytest

from app.core.ref_contract_validator import RefContractError, validate_attached_refs
from app.core.steps.render_prompt_card import build_asset_requirements
from app.core.subject_reference_policy import SubjectReferencePolicy
from app.services.scene_reference_service import SceneReferenceService


def _policy(subject_id: str, policy: str) -> SubjectReferencePolicy:
    return SubjectReferencePolicy(
        subject_id=subject_id,
        policy_type="identity_reference",
        policy=policy,
        reason="(test fixture)",
    )


# ── G1: base_id_required + state_variant → emits {kind:character} ──────
def test_g1_base_id_required_state_variant_emits_character() -> None:
    """base_id_required 이면서 state_variant 인 캐릭터 → required_refs 에
    {kind:character} emit (현재 버그: state_variant continue 가 skip)."""
    a = build_asset_requirements(
        visible_entities=["C08"],
        outlook_pairs=[{"character_id": "C08", "outlook_id": "O04"}],
        bg_id=None, is_close_framing=True, background_mode_on=True,
        used_outlook_pairs={("C08", "O04")},
        state_variant_chars={"C08"},
        render_contracts=[],
        policy_map={"C08": _policy("C08", "base_id_required")},
    )
    refs = a["required_refs"]
    assert {"kind": "character", "id": "C08", "policy": "required"} in refs
    assert not any(r["kind"] == "character_outlook" for r in refs)


# ── G2: base_id_required + O00 → emits {kind:character} ────────────────
def test_g2_base_id_required_o00_emits_character() -> None:
    """base_id_required 이면서 outlook O00 인 캐릭터 → {kind:character} emit
    (현재 버그: O00 continue 가 skip). production-shape narrow-RPC filter
    (used_outlook_pairs) 하에서 검증 — Codex plan review 권고."""
    a = build_asset_requirements(
        visible_entities=["C08"],
        outlook_pairs=[{"character_id": "C08", "outlook_id": "O00"}],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        used_outlook_pairs={("C08", "O00")},
        render_contracts=[],
        policy_map={"C08": _policy("C08", "base_id_required")},
    )
    refs = a["required_refs"]
    assert {"kind": "character", "id": "C08", "policy": "required"} in refs


# ── G3: regression — id_and_outlook + state_variant → still excluded ──
def test_g3_id_and_outlook_state_variant_still_excluded() -> None:
    """회귀 가드: id_and_outlook_required 이면서 state_variant 인 캐릭터 →
    character_outlook 도 character 도 emit 안 함 (resolver 가 character_state
    ref attach — character_outlook 위조 금지, validator P2)."""
    a = build_asset_requirements(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        state_variant_chars={"C01"},
        render_contracts=[],
        policy_map={"C01": _policy("C01", "id_and_outlook_required")},
    )
    refs = a["required_refs"]
    assert not any(r["kind"] in ("character", "character_outlook") for r in refs)


# ── G4: regression — id_and_outlook + O00 → still excluded ────────────
def test_g4_id_and_outlook_o00_still_excluded() -> None:
    """회귀 가드: id_and_outlook_required 이면서 O00 → character_outlook 미emit
    (resolver O00 분기가 base ref attach)."""
    a = build_asset_requirements(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O00"}],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        render_contracts=[],
        policy_map={"C01": _policy("C01", "id_and_outlook_required")},
    )
    refs = a["required_refs"]
    assert not any(r["kind"] == "character_outlook" for r in refs)


# ── G5: regression — base_id_required 평범 (no state_variant) ──────────
def test_g5_base_id_required_plain_still_emits_character() -> None:
    """회귀 가드: state_variant 아닌 base_id_required → {kind:character} emit
    (W2 기존 동작 보존)."""
    a = build_asset_requirements(
        visible_entities=["C07"],
        outlook_pairs=[{"character_id": "C07", "outlook_id": "O09"}],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        render_contracts=[],
        policy_map={"C07": _policy("C07", "base_id_required")},
    )
    refs = a["required_refs"]
    assert {"kind": "character", "id": "C07", "policy": "required"} in refs


# ── G6: regression — generic_descriptor_allowed + state_variant ───────
def test_g6_generic_descriptor_state_variant_emits_neither() -> None:
    """회귀 가드: generic_descriptor_allowed 이면 state_variant 든 아니든
    character / character_outlook 둘 다 emit 안 함."""
    a = build_asset_requirements(
        visible_entities=["C03"],
        outlook_pairs=[{"character_id": "C03", "outlook_id": "O05"}],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        state_variant_chars={"C03"},
        render_contracts=[],
        policy_map={"C03": _policy("C03", "generic_descriptor_allowed")},
    )
    refs = a["required_refs"]
    assert not any(r["kind"] in ("character", "character_outlook") for r in refs)


# ── G7: dedup — base_id_required + state_variant, 복수 outlook_pair ────
def test_g7_base_id_required_state_variant_dedup() -> None:
    """base_id_required + state_variant 캐릭터가 outlook_pairs 에 복수 등장 →
    {kind:character} 는 1회만 emit (dedup 보존)."""
    a = build_asset_requirements(
        visible_entities=["C08"],
        outlook_pairs=[
            {"character_id": "C08", "outlook_id": "O04"},
            {"character_id": "C08", "outlook_id": "O07"},
        ],
        bg_id=None, is_close_framing=False, background_mode_on=False,
        used_outlook_pairs={("C08", "O04"), ("C08", "O07")},
        state_variant_chars={"C08"},
        render_contracts=[],
        policy_map={"C08": _policy("C08", "base_id_required")},
    )
    char_refs = [r for r in a["required_refs"] if r["kind"] == "character" and r["id"] == "C08"]
    assert len(char_refs) == 1


# ── G8: repro — case 2/3 end-to-end (build → resolve → validate) ──────
def test_g8_finding_c_case2_3_repro_no_violation() -> None:
    """실패 still b512587c/68eb2cb9 재현: C08 base_id_required + state_variant,
    bare C08 t2i_prompt, reference_phrase_kinds=['character'] → fix 후
    REF_CONTRACT_VIOLATION 0."""
    policy_map = {"C08": _policy("C08", "base_id_required")}
    ar = build_asset_requirements(
        visible_entities=["C08"],
        outlook_pairs=[{"character_id": "C08", "outlook_id": "O04"}],
        bg_id=None, is_close_framing=True, background_mode_on=True,
        used_outlook_pairs={("C08", "O04")},
        state_variant_chars={"C08"},
        render_contracts=[],
        policy_map=policy_map,
    )
    assert {"kind": "character", "id": "C08", "policy": "required"} in ar["required_refs"]

    svc = SceneReferenceService.__new__(SceneReferenceService)
    prompt = "Photorealistic cinematic still. C08, an East Asian woman, lying motionless."
    payload = svc.resolve_refs_for_prompt(
        t2i_prompt=prompt,
        visible_entities=[
            {"id": "uuid-c08", "short_id": "C08", "entity_type": "character", "name": "Mother"},
        ],
        scene_ref_image_map={"uuid-c08": b"c08_face"},
        entity_lookup={},
        required_refs=ar["required_refs"],
    )
    assert ("character", "C08") in payload.attached_meta

    validate_attached_refs(
        {"asset_requirements": ar},
        labeled_refs=list(payload.labeled_refs),
        attached_meta=list(payload.attached_meta),
        prompt=prompt, is_close_framing=True,
        reference_phrase_kinds=["character"],
    )
```

- [ ] **Step W1.2: Run tests to verify they fail**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/core/test_finding_c_w1_state_variant_base_id.py -v
```
Expected: G1, G2, G7, G8 FAIL (현재 `_state_variant`/`O00` continue 가 emit
skip → `{kind:character}` 부재 → assert 실패 / G8 은 RefContractError raise).
G3, G4, G5, G6 PASS (현재 동작이 이미 정합 — 회귀 가드).

- [ ] **Step W1.3: Implement the fix**

`backend/app/core/steps/render_prompt_card.py` — `build_asset_requirements`
루프 (현재 line ~2021-2076) 를 아래로 교체. `for pair in outlook_pairs:` 부터
`character_outlook` emit 까지의 블록 전체 교체. `_emitted_char_bases` /
`forbidden` / `required.extend(...)` 등 루프 밖 코드는 무변경.

```python
    for pair in outlook_pairs:  # R2-B4: no `or []`
        # R5-B1 (G4.3): None vs non-dict pair fail-fast — silent `or {}` 제거.
        if not isinstance(pair, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    "build_asset_requirements: outlook_pairs entry is "
                    f"{type(pair).__name__} (expected dict)"
                ),
            )
        cid = pair.get("character_id") or ""
        oid = pair.get("outlook_id") or ""
        if not (cid and oid):
            continue
        # 2026-05-10 narrow: used_outlook_pairs 지정 시 그 set 안에 있어야 박음.
        if used_outlook_pairs is not None and (cid, oid) not in used_outlook_pairs:
            continue
        # FINDING C W1 (Category B): policy 를 state_variant / O00 exclusion
        # 이전에 resolve. base_id_required → {kind:character} emit 은
        # state_variant / O00 와 무관 (resolver section 2.5 base attach 가 SOT,
        # validator step 3b/6 가 character kind honor). state_variant / O00
        # exclusion 은 character_outlook 위조 방지용 (resolver 가 각각
        # character_state / O00 base ref 로 attach → character_outlook strict
        # 만족 X, validator P2) → id_and_outlook_required 분기에만 적용.
        _policy = get_subject_reference_policy_or_default(
            policy_map, cid, where="build_asset_requirements",
        )
        if _policy.policy == "base_id_required":
            # W4b 가 bare C## 사용 — character_outlook 위조 금지, resolver/
            # validator 가 base ref honor. FINDING 9 W2 (Cat2) dedup 보존.
            if cid not in _emitted_char_bases:
                required.append({
                    "kind": "character",
                    "id": cid,
                    "policy": "required",
                })
                _emitted_char_bases.add(cid)
            continue
        if _policy.policy == "generic_descriptor_allowed":
            continue  # character / character_outlook required 둘 다 emit 안 함
        # id_and_outlook_required (default / 누락) — character_outlook required.
        # 2026-05-10 state_variant 제외: subject_state immobilized 인물의 outlook
        # 은 character_state ref 로 attach 되므로 character_outlook required 에서
        # 빼야 validator 정합 (S12_Shot6 결함 fix).
        if cid in _state_variant:
            continue
        # 2026-05-10 (Fix D1) — Null Outlook (O00) 제외: resolver 가 character
        # base ref 로 attach (scene_reference_service O00 분기). validator 의
        # character_outlook strict check 가 base 만족 X (D5 P2). S7/S17 fix.
        if oid == "O00":
            continue
        required.append({
            "kind": "character_outlook",
            "id": f"{cid}{oid}",
            "policy": "required",
        })
```

- [ ] **Step W1.4: Run tests to verify they pass**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/core/test_finding_c_w1_state_variant_base_id.py -v
```
Expected: G1-G8 전부 PASS.

- [ ] **Step W1.5: Targeted regression gate**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/core/test_finding9_w2_base_id_required.py tests/core/test_finding11_character_phrase_kind.py tests/core/test_finding9_w4_reference_phrase_kinds.py tests/unit/test_render_prompt_card.py tests/unit/test_subject_reference_policy_helper.py tests/services/test_ref_contract_validator.py tests/services/test_ref_contract_validator_sidecar.py -q
```
Expected: 전부 PASS. W1.0 baseline 대비 회귀 0. 실패 시 → 진짜 회귀인지
test-pin (기존 버그 동작 pin) 인지 판별 → test-pin 이면 Codex 의논 후 갱신.

- [ ] **Step W1.6: Broad regression gate**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/core tests/unit tests/services -q
```
Expected: 전부 PASS (또는 W0 triage 시점 pre-existing 실패와 동일 — 회귀 0).
신규 실패가 있으면 W1 변경이 원인인지 확인.

- [ ] **Step W1.7: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py backend/tests/core/test_finding_c_w1_state_variant_base_id.py
git commit -m "$(cat <<'EOF'
FINDING C W1: base_id_required character required_ref emit for state_variant/O00 subjects

build_asset_requirements 의 outlook_pairs 루프에서 _state_variant / O00
continue 가 base_id_required policy 분기보다 앞에 있어, base_id_required
이면서 immobilized(dead 등)이거나 O00 인 캐릭터는 {kind:character}
required_ref 가 emit 되지 않았다. resolver attach 0 → validator step 6
phantom guard fail. policy 를 exclusion 이전에 resolve 하고 state_variant
/ O00 exclusion 을 character_outlook(id_and_outlook_required) 분기에만
적용해 수정. FINDING 9 W2 의 잔여 (금월도 E2E still b512587c/68eb2cb9).

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

---

## Task W2: Category A — background over-declaration consumer normalization

**Files:**
- Modify: `backend/app/services/scene_generation_coordinator.py` — 모듈 레벨 helper
  신설 + 2개 validate call site (single ~line 1465, variation ~line 1574) wiring
- Test: `backend/tests/services/test_finding_c_w2_background_overdeclare.py` (create)

- [ ] **Step W2.0: W0 broad test-pin (read-only)**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/services/test_scene_generation_coordinator.py tests/services/test_build_scene_attached_refs.py tests/core/test_finding6_w4a_visible_normalization.py -q
```
Expected: 전부 PASS (baseline green). pass 개수 기록.

- [ ] **Step W2.1: Write the failing tests**

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

```python
"""FINDING C W2 (Category A) — background over-declaration consumer normalization.

W0 triage: scene_detail LLM 이 [L##: ...] free-form location 묘사 블록을 보고
per-variation sidecar reference_phrase_kinds 에 'background' 를 over-declare
할 수 있다. background ref attach 여부는 coordinator runtime SOT (chain_bg map
lookup + build_prev_shot_background_ref) — producer 가 예측 불가. attached_meta
가 확정된 consumer boundary 에서 normalize.

strip 3-조건: (1) reference_phrase_kinds 에 'background' 있음 (2) attached_meta
에 ('background',*)/('background_prev_shot',*) 없음 (3) required_refs 에
kind='background' 없음. 조건 3 = genuine missing required background 는 mask
안 함 (validator step 4 가 step 6 전에 fail-fast).

대상 실패 still: 96cb0479 S2.1 (background_binding.mode=not_applicable,
[L02:] free-form 블록). Gates G1-G7.
"""
from __future__ import annotations

import pytest

from app.core.errors import StaleUpstreamError
from app.core.ref_contract_validator import validate_attached_refs
from app.services.scene_generation_coordinator import (
    _normalize_background_phrase_kind,
)


# ── G1: strip — bg declared, no bg attached, no bg required ────────────
def test_g1_strip_when_no_background_attached() -> None:
    """background 선언 + attached_meta 에 bg 없음 + required_refs 에 bg 없음
    → 'background' strip."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[],
        rpc={"asset_requirements": {"required_refs": []}},
    )
    assert result == []


# ── G2: keep — ('background', L##) attached ───────────────────────────
def test_g2_keep_when_background_attached() -> None:
    """attached_meta 에 ('background', L##) 있으면 'background' 유지."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[("background", "L04B07")],
        rpc={"asset_requirements": {"required_refs": []}},
    )
    assert result == ["background"]


# ── G3: keep — ('background_prev_shot', loc) attached ─────────────────
def test_g3_keep_when_background_prev_shot_attached() -> None:
    """attached_meta 에 ('background_prev_shot', loc) 있으면 'background' 유지."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[("background_prev_shot", "L04")],
        rpc={"asset_requirements": {"required_refs": []}},
    )
    assert result == ["background"]


# ── G4: keep — required_refs has background (don't mask) ──────────────
def test_g4_keep_when_required_background_present() -> None:
    """required_refs 에 kind='background' 있으면 strip 금지 — genuine missing
    required background 는 validator step 4 가 fail-fast 해야 한다."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[],
        rpc={"asset_requirements": {"required_refs": [
            {"kind": "background", "id": "L04B07", "policy": "required"},
        ]}},
    )
    assert result == ["background"]


# ── G4b: required background no-mask — validator still fail-fast ──────
def test_g4b_required_background_not_masked_validator_raises() -> None:
    """required_refs 에 background 가 있고 미attach 면, normalize 가 strip 안 해
    'background' 가 유지되고 validator 가 fail-fast 한다 (guard 가 genuine
    missing required background 를 mask 하지 않음 증명 — Codex plan review 권고).
    chain bg id (L##B## form) 는 BG_ID_RE 매칭 → step 4 가 StaleUpstreamError."""
    rpc = {"asset_requirements": {"required_refs": [
        {"kind": "background", "id": "L04B07", "policy": "required"},
    ]}}
    normalized = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[],
        rpc=rpc,
    )
    assert normalized == ["background"]
    with pytest.raises(StaleUpstreamError):
        validate_attached_refs(
            rpc, labeled_refs=[], attached_meta=[],
            prompt="A wide shot.", is_close_framing=False,
            reference_phrase_kinds=normalized,
        )


# ── G5: unchanged — no background in reference_phrase_kinds ───────────
def test_g5_unchanged_when_no_background_declared() -> None:
    """reference_phrase_kinds 에 background 없으면 무변경."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["character"],
        attached_meta=[],
        rpc={"asset_requirements": {"required_refs": []}},
    )
    assert result == ["character"]


# ── G6: partial strip — keep character/prop, strip background ────────
def test_g6_strip_background_keep_others() -> None:
    """background 만 strip, character/prop 은 유지."""
    result = _normalize_background_phrase_kind(
        reference_phrase_kinds=["character", "background", "prop"],
        attached_meta=[("prop", "P03")],
        rpc={"asset_requirements": {"required_refs": [
            {"kind": "prop", "id": "P03", "policy": "required"},
        ]}},
    )
    assert result == ["character", "prop"]


# ── G7: repro — case 1 (96cb0479) end-to-end ─────────────────────────
def test_g7_finding_c_case1_repro_no_violation() -> None:
    """실패 still 96cb0479 재현: background_binding.mode=not_applicable,
    required_refs=[], attached_meta=[], reference_phrase_kinds=['background']
    → normalize 후 validator step 6 통과 (RefContractError 0)."""
    rpc = {"asset_requirements": {"required_refs": [], "readiness_policy": "not_applicable"}}
    normalized = _normalize_background_phrase_kind(
        reference_phrase_kinds=["background"],
        attached_meta=[],
        rpc=rpc,
    )
    assert normalized == []
    # normalize 결과로 validator 호출 — phantom guard 통과.
    validate_attached_refs(
        rpc, labeled_refs=[], attached_meta=[],
        prompt="A wide shot. [L02: a wet ground].",
        is_close_framing=False,
        reference_phrase_kinds=normalized,
    )
```

- [ ] **Step W2.2: Run tests to verify they fail**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/services/test_finding_c_w2_background_overdeclare.py -v
```
Expected: 전부 FAIL — `ImportError: cannot import name '_normalize_background_phrase_kind'`
(helper 미존재).

- [ ] **Step W2.3: Implement the helper + wire into both validate sites**

**(3a)** `backend/app/services/scene_generation_coordinator.py` — `build_chain_bg_lookup`
(line ~191) 정의 근처, 모듈 레벨에 helper 추가:

```python
def _normalize_background_phrase_kind(
    reference_phrase_kinds: List[str],
    attached_meta: List[Tuple[str, str]],
    rpc: Optional[Dict[str, Any]],
) -> List[str]:
    """FINDING C W2 (Category A) — background over-declaration consumer normalization.

    scene_detail LLM 이 [L##: ...] free-form location 묘사 블록을 보고
    per-variation sidecar `reference_phrase_kinds` 에 'background' 를
    over-declare 할 수 있다. background ref attach 여부는 coordinator runtime
    SOT (chain_bg map lookup + build_prev_shot_background_ref) 이므로 producer
    는 예측 불가 — attached_meta 가 확정된 consumer boundary 에서 normalize.

    'background' strip 3-조건 (모두 만족 시):
      1. reference_phrase_kinds 에 'background' 있음.
      2. attached_meta 에 ('background', *) / ('background_prev_shot', *) 없음.
      3. rpc.asset_requirements.required_refs 에 kind='background' 없음.
    조건 3 = genuine missing REQUIRED background 는 mask 안 함 — required
    background 이 있는데 미attach 면 validator step 4 가 step 6 전에 fail-fast.

    validator step 6 (sidecar phantom guard) 자체는 무변경 — over-declaration
    만 producer/runtime 불일치로 보고 consumer 가 정합화한다. 약화 0.
    """
    if "background" not in reference_phrase_kinds:
        return list(reference_phrase_kinds)
    _attached_kinds = {k for k, _ in attached_meta}
    if "background" in _attached_kinds or "background_prev_shot" in _attached_kinds:
        return list(reference_phrase_kinds)
    _required_refs = (
        (rpc or {}).get("asset_requirements") or {}
    ).get("required_refs") or []
    _has_bg_required = any(
        isinstance(r, dict) and r.get("kind") == "background"
        for r in _required_refs
    )
    if _has_bg_required:
        return list(reference_phrase_kinds)
    return [k for k in reference_phrase_kinds if k != "background"]
```

(`List`, `Tuple`, `Dict`, `Any`, `Optional` 은 파일 상단 typing import 에 이미
존재 — 확인만. 없으면 추가.)

**(3b)** Single path — `validate_attached_refs` 호출 (line ~1465) 직전에 추가.
현재:
```python
        _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,
            reference_phrase_kinds=reference_phrase_kinds,
        )
```
→ 교체:
```python
        _chain_bg_lookup = build_chain_bg_lookup(ctx["background_chain_bg_map"])
        # FINDING C W2 (Category A): background over-declaration consumer
        # normalization — attached_meta 확정 후, validator 호출 직전.
        reference_phrase_kinds = _normalize_background_phrase_kind(
            reference_phrase_kinds, attached_meta, _rpc,
        )
        validate_attached_refs(
            _rpc, labeled_refs, attached_meta, full_prompt,
            is_close_framing=_is_close_framing,
            chain_bg_lookup=_chain_bg_lookup,
            reference_phrase_kinds=reference_phrase_kinds,
        )
```

**(3c)** Variation path — `_generate_variation_in_loop` 의 `validate_attached_refs`
호출 (line ~1574) 직전에 추가. 현재:
```python
        try:
            # Area #11 v1 W2: payload.labeled_refs / payload.attached_meta 사용.
            validate_attached_refs(
                rpc, payload.labeled_refs, payload.attached_meta, _full_prompt,
                is_close_framing=is_close_framing,
                chain_bg_lookup=chain_bg_lookup,
                reference_phrase_kinds=reference_phrase_kinds,
            )
```
→ 교체:
```python
        try:
            # FINDING C W2 (Category A): background over-declaration consumer
            # normalization — payload.attached_meta 확정 후, validator 호출 직전.
            reference_phrase_kinds = _normalize_background_phrase_kind(
                reference_phrase_kinds, payload.attached_meta, rpc,
            )
            # Area #11 v1 W2: payload.labeled_refs / payload.attached_meta 사용.
            validate_attached_refs(
                rpc, payload.labeled_refs, payload.attached_meta, _full_prompt,
                is_close_framing=is_close_framing,
                chain_bg_lookup=chain_bg_lookup,
                reference_phrase_kinds=reference_phrase_kinds,
            )
```

- [ ] **Step W2.4: Run tests to verify they pass**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/services/test_finding_c_w2_background_overdeclare.py -v
```
Expected: G1-G7 전부 PASS.

- [ ] **Step W2.5: Verify single/batch parity (structural)**

Run:
```bash
cd backend && grep -n '_normalize_background_phrase_kind' app/services/scene_generation_coordinator.py
```
Expected: 정확히 3 hits — (1) helper `def`, (2) single path validate 직전,
(3) variation path validate 직전. 2개 call site 모두 wiring 됐는지 확인.

- [ ] **Step W2.6: Targeted + broad regression gate**

Run:
```bash
cd backend && .venv/bin/python -m pytest tests/services/test_scene_generation_coordinator.py tests/services/test_build_scene_attached_refs.py tests/services/test_single_scene_uses_helper.py tests/services/test_ref_contract_validator.py tests/services/test_ref_contract_validator_sidecar.py tests/core/test_finding6_w4a_visible_normalization.py -q
```
Expected: 전부 PASS, W2.0 baseline 대비 회귀 0.

Run (broad):
```bash
cd backend && .venv/bin/python -m pytest tests/core tests/unit tests/services -q
```
Expected: 전부 PASS (또는 W0 시점 pre-existing 실패와 동일 — 회귀 0).

- [ ] **Step W2.7: Commit**

```bash
git add backend/app/services/scene_generation_coordinator.py backend/tests/services/test_finding_c_w2_background_overdeclare.py
git commit -m "$(cat <<'EOF'
FINDING C W2: background over-declaration consumer normalization

scene_detail LLM 이 [L##: ...] free-form location 묘사 블록을 보고
per-variation reference_phrase_kinds 에 'background' 를 over-declare 할 수
있으나, background ref attach 여부는 coordinator runtime SOT (chain_bg map
+ build_prev_shot_background_ref) 라 producer 가 예측 불가하다.
attached_meta 가 확정된 consumer boundary 에서 _normalize_background_phrase_kind
helper 로 normalize — attached/required 둘 다 없을 때만 'background' strip.
single + variation path 양쪽 wiring. validator step 6 무변경.
금월도 E2E still 96cb0479 해소.

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

---

## Task W3: E2E 재검증 + closure + push

**Files:** 코드 수정 0 — 검증 + 문서 + push 만.

- [ ] **Step W3.1: backend 재시작**

W1+W2 코드 변경 반영. backend 는 `--reload` 없음 — 수동 재시작 필요.

- [ ] **Step W3.2: scene_detail force 재실행 (W1 trigger — Codex W2 review 정정)**

**중요 (Codex W2_REVIEW W3_GUIDANCE):** W1 은 producer
(`build_asset_requirements`, scene_detail/render_prompt_card 내부) 출력을
바꾼다. 기존 scene_detail manifest 는 b512587c / 68eb2cb9 에 대해 여전히
stale `required_refs` (character 항목 없음) 를 들고 있다. scene_image_pipeline
resume 만으로는 W1 fix 가 반영 안 됨 — scene_detail 을 먼저 refresh 해야 한다.

project `fdd90057-7bc9-4d3c-82ba-5d8489c021a2` / episode
`eb4b3a35-399d-4bd5-98bd-8d791659d600` 에 대해 `scene_detail` 을 force 로
재실행 (또는 affected render_prompt_card entry 를 regenerate 하는 최소 등가
방식). render_prompt_card 의 `required_refs` 가 W1 fix 반영해 갱신됨.

주의: scene_detail 은 LLM 비결정 step — force 재실행이 새로운 nondeterministic
실패를 유발하면 별도 finding 으로 분류 (exact log → 분류), silent 수용 금지.

- [ ] **Step W3.3a: scene_image_pipeline resume 재실행**

scene_detail refresh 후 `category=all` resume 또는 scene_image_pipeline
`mode=resume` 로 image 단계 재생성 — failed dict 3건 (96cb0479 / b512587c /
68eb2cb9) 재처리. 브라우저 인증 세션 통해 API 직접 호출
(`feedback_e2e_pipeline_monitoring` 의 mtime liveness + 종료 regex 적용).

Expected: scene_checkpoint.json `failed` dict 가 비고 `completed` →
**66/66**.

- [ ] **Step W3.3b: 결과 검증**

```bash
python3 -c "import json; d=json.load(open('projects/fdd90057-7bc9-4d3c-82ba-5d8489c021a2/checkpoints/images/eb4b3a35-399d-4bd5-98bd-8d791659d600/scene_checkpoint.json')); print('completed', len(d['completed']), 'failed', len(d.get('failed', {})))"
```
Expected: `completed 66 failed 0`.

3건 still 의 image_asset 생성 + PNG on-disk (1376×768) 확인.

- [ ] **Step W3.4: Codex range review**

range `<W1 commit>..<W2 commit>` 을 Codex MCP 에 read-only review 요청 —
W1/W2 commit + W3 E2E 결과. verdict `APPROVED_FOR_PUSH` 받을 때까지 의논.

- [ ] **Step W3.5: closure docs + memory**

closure 문서 작성 + auto-memory 갱신 ([[next_session_finding_c_reference_phrase_kinds_parity]]
해소 표기, closure session memory 신설).

- [ ] **Step W3.6: Push**

```bash
git push origin main
```
Codex `APPROVED_FOR_PUSH` 후 W1+W2 일괄 push. push 후 상태 보고.

---

## Self-Review

**1. Spec coverage (W0 triage 3건):**
- case 2 (b512587c) — W1 G1/G8 이 base_id_required + state_variant emit 커버. ✓
- case 3 (68eb2cb9) — W1 G1/G8 (동일 root cause, C08 base_id_required +
  state_variant) 커버. case 3 는 required_refs 에 bg+prop 이 이미 있고
  character 만 누락 — G8 repro 가 character emit 확인. ✓
- case 1 (96cb0479) — W2 G1/G7 이 background over-declaration strip 커버. ✓

**2. Placeholder scan:** 모든 step 에 실제 코드/명령/expected output 포함.
W3 의 E2E 재실행은 본질적으로 runtime-dependent — step 은 구체적 검증 명령
(W3.3) + expected 로 명시. placeholder 없음.

**3. Type consistency:** `_normalize_background_phrase_kind(reference_phrase_kinds,
attached_meta, rpc)` — W2.1 테스트 / W2.3 helper / W2.3 call site 3곳 시그니처
일치. `build_asset_requirements` 키워드 인자 (`state_variant_chars`,
`used_outlook_pairs`, `policy_map`) — W1 테스트가 실제 시그니처
(render_prompt_card.py:1938-1953) 와 일치. `SubjectReferencePolicy` /
`_policy()` helper — 기존 `test_finding9_w2_base_id_required.py` 패턴 재사용.

**4. Codex W0 verdict 반영:** B1_O00_EDGE (W1 G2/G4 가 O00 커버) /
A1_CONDITION (W2 G4 가 required guard 커버) / WAVE_ORDER (W1→W2→W3) /
single-batch parity (W2.5 + W2.3 3b/3c) — 전부 반영.

---

## 미해결 질문 / Codex plan review 대상

1. **W1 G8 resolver `scene_ref_image_map`** — repro 테스트가 `{"uuid-c08":
   b"c08_face"}` 로 base ref 존재 가정. 실제 C08 base ref image 가 항상
   존재하는지는 entity ref_image_gen 산출에 의존 — 부재 시 resolver section
   2.5 가 skip 하고 validator step 3b 가 fail-fast (정상 동작). 테스트는
   존재 케이스만 — 부재 케이스는 step 3b 기존 테스트가 커버.
2. **W3 E2E 재실행 trigger 방식** — `mode=resume` single-step vs category=all
   resume. W3.2 진입 시 Codex 의논해 결정.
