# Visual QA — Story-Critical Prop Binding Implementation Plan (Patch A)

**Status**: DRAFT_R1.4.1 (review iter 4 surgical mini-patch — Task 3 test fixture P02 → P91 synthetic 전환 + Task 8 gate 2-stage (scenario tokens 모든 path / synthetic IDs production-only) 분리. implementation 진입 OK 직전 final 상태)

**v1.4 → v1.4.1 surgical patch**:
- ✅ BLOCKING 1 — Task 3 test code `visible_entities=["C01", "P02"]` → `["C91", "P91"]` (3 occurrence)
- ✅ BLOCKING 2 — Task 8 gate 2-stage 분리: Stage 1 `SCENARIO_FORBIDDEN` (app+tests+scripts+prompts 전부 금지) + Stage 2 `SYNTHETIC_FORBIDDEN=\b[PCLO]9[1-9]\b` (production/scripts/prompts 만 금지, tests 허용)

**v1.3 → v1.4 patch 완료 상황**:

- ✅ BLOCKING 1 — tests synthetic fixture 전환 (Task 1/2/4/4.5/5/6/7 7 task 모두 synthetic P91~P99 / C91~C99 / L91~L99 적용)
- ✅ BLOCKING 2 — optional script 제거 (`backend/scripts/check_patch_a_residue.sh` 철회, manual one-off command 만)
- ✅ BLOCKING 3 — Task 8 changed-files based gate (target paths 4 — `backend/app/ + backend/tests/ + backend/scripts/ + prompts/`)
- ✅ BLOCKING 4 — placeholder `pass` body 0 (Task 4.5 4 case + Task 6 1 case 모두 실 body 작성)
- ✅ IMPORTANT 1 — §1.1.5 token scope policy 갱신 (tests 도 금지)
- ✅ IMPORTANT 3 — §9 Execution Handoff v1.4 기준 재작성
- ✅ synthetic IDs scope 명시 — Task 8 step 8.1 의 허용 표에 production code 에서 P91~P99 도 금지 명시

**v1.2 → v1.3 patch 요약** (carry, review iter 2):

- BLOCKING 1: `_STORY_CRITICAL_PROP_NOUNS_KO/EN` category-only.
- BLOCKING 2: production code comment generic.
- BLOCKING 3: Task 8 신규 — production residue grep gate.
- IMPORTANT 1/2: token scope policy + manual canary 복사 금지 명시.

**v1.1 → v1.2 patch 요약** (carry, review iter 1):

- BLOCKING 1: `visible_entity_details` 분리.
- BLOCKING 2: `validate_attached_refs` 대상.
- BLOCKING 3: validator layer 분리.
- BLOCKING 4: 3-tier 방어.

**v1 → v1.1 patch 요약** (carry):
- gap A: 영어 canonical noun fallback.
- gap B: §6.3 fallback 절차.

**v1.2 → v1.3 patch 요약** (review iter 2):

- **BLOCKING 1 fix — production noun set category-only**: `_STORY_CRITICAL_PROP_NOUNS_KO` 에서 `"흑백 사진"`, `"낡은 사진"`, `"사진 액자"` 제거 (descriptor 잔재 / redundant). `_STORY_CRITICAL_PROP_NOUNS_EN` 에서 `"framed photograph"`, `"picture frame"` 제거. 한국어 substring 매칭 + 영어 word-boundary 매칭은 base category noun (`"사진"`/`"photo"`) 단독으로 모든 변이 cover.
- **BLOCKING 2 fix — production code comment generic**: Task 1 helper code 의 한국어 fallback comment 에서 `"'낡은 흑백 사진' name vs '낡은 사진' shot 텍스트"` → `"long localized prop name vs shorter category noun"` generic example.
- **BLOCKING 3 fix — production residue grep gate (Task 8 신규)**: implementation 진입 직전 mandatory step. `backend/app/` 의 5 production 파일에 시나리오 tokens (수리영/민숙/인우/금월도/시골 식당/둥근 원/두 소녀/옥탑방/조타실/갑판/낡은 흑백/흑백 사진/낡은 사진/낡은 액자/S16/S27/S26/P02/P03/P17) zero-hit 보장.
- **IMPORTANT 1 fix — token scope policy**: §1.2 OUT OF SCOPE 에 명시 — scenario tokens 은 `backend/tests/**` + manual canary docs 만 허용.
- **IMPORTANT 2 fix — manual canary 설명**: §6.3 "P02 canonical (시골 식당 + 둥근 원 + 두 소녀)" 표현 옆에 "이 한국어 표현 prompt 문구로 복사 금지 (시각 검증용 내부 어휘)" 명시.
- **IMPORTANT 3 — Task 3 prompt strings**: 이미 generic. 변경 없음.

**v1.1 → v1.2 patch 요약** (carry, review iter 1):

- BLOCKING 1 (visible_entities string contract 보존): `visible_entity_details` 신규 인자로 분리.
- BLOCKING 2 (validator 함수명/normalizer): `validate_attached_refs` 대상. `_normalize_required_refs` 변경 없음.
- BLOCKING 3 (validator layer 분리): `validate_visible_entities_contract` PRO-13 = prompt presence only. attached_meta 검사는 Tier 3 로 이전.
- BLOCKING 4 (3-tier 방어): Tier 1 (PRO-13 prompt) + Tier 2 (resolver forced attach) + Tier 3 (validate_attached_refs strict subset).
- IMPORTANT 1/2/3 fix.

**v1 → v1.1 patch 요약** (carry):
- gap A: 영어 canonical noun fallback 추가 — §2.2 OR 3-tier 명시화 + Task 1 helper code 분기 추가 + 신규 test case `test_english_prop_name_fails_but_canonical_noun_matches`.
- gap B: §6.3 step 1 의 S26/6 fallback 절차 명시.

**v1.1 → v1.2 patch 요약** (review iter 1 BLOCKING 4 + IMPORTANT 3 architectural redesign):

- **BLOCKING 1 fix — visible_entities string contract 보존**: `build_render_prompt_card / build_id_policy / build_asset_requirements` 모두 `visible_entities: List[str]` (sid list) 유지. 신규 인자 `visible_entity_details: List[Dict[str, Any]]` 추가 — entity_lookup 정보를 별도 인자로 propagate. detail_steps 등 모든 caller 의 string list contract 보존.
- **BLOCKING 2 fix — validator 함수명/normalizer redesign**: 실제 함수는 `validate_attached_refs(rpc, labeled_refs, attached_meta, prompt, is_close_framing, *, chain_bg_lookup)`. Task 4 대상 함수 교체. `_normalize_required_refs` 는 이미 arbitrary kind 허용이라 **변경 불필요** (line 199-280).
- **BLOCKING 3 fix — layer 분리**: `validate_visible_entities_contract(shot, name_by_short_id)` 의 시그니처를 보존. PRO-13 룰은 "required_refs prop 의 P## 가 t2i_variations[*].t2i_prompt 에 등장" 검사만 (LLM 출력 정합성). attached_meta 검사는 `validate_attached_refs` 로 이전 (resolver 출력 정합성).
- **BLOCKING 4 fix — 3-tier 방어 (resolver 변경 포함)**: LLM 이 P## 누락할 가능성 대응. Tier 1: visible_entities_validator P## 누락 시 fail (scene_detail step retry). Tier 2: resolver 신규 분기 — required_refs(kind='prop') 강제 attach (prompt 매칭 무관). Tier 3: validate_attached_refs strict subset 최종 차단. §1.2 OUT OF SCOPE 의 "resolver 매칭 로직 변경 안 함" 약속 **수정** — resolver 의 매칭 base 분기는 변경 없음, required_refs(kind='prop') 강제 attach 분기만 추가.
- **IMPORTANT 1 fix**: Task 1 expected FAIL count 8 → 9.
- **IMPORTANT 2 fix**: Task 5-7 placeholder 본문 풀어쓰기 완료.
- **IMPORTANT 3 fix**: §6.3 S26/6 fallback trigger 명시 — `required_refs shape 변경 only` → 재생성 X (단 hash 변경 cp invalidation 은 force re-run 필요). `attached_meta entry 변화 (추가/삭제/순서)` → 단건 이미지 재생성. `시각 동등성 미충족` → patch A revision.

**v1 → v1.1 patch 요약** (carry):
- gap A: 영어 canonical noun fallback 추가 — §2.2 OR 3-tier 명시화 + Task 1 helper code 분기 추가 + 신규 test case `test_english_prop_name_fails_but_canonical_noun_matches`.
- gap B: §6.3 step 1 의 S26/6 fallback 절차 명시.

> **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_detail RPC가 photo/document/map/frame/key 류 story-critical visible prop을 `required_refs`(kind='prop')로 강제 emit하고, `id_policy.common_noun_required_when`의 photo depiction 항목을 face vs object 로 분리해, **사진/액자 prop 자체의 P## ID는 prompt에 보존**되도록 한다. resolver/validator가 required prop 미attach를 image stage 전에 fail-fast로 차단한다.

**Architecture (v1.2 redesign — 3-tier defense):** 두 producer(`build_asset_requirements` / `build_id_policy`) + 두 validator(`visible_entities_validator` / `ref_contract_validator.validate_attached_refs`) + resolver(`scene_reference_service`) 5 사이트 동기 수정. 신규 helper `story_critical_prop_filter` (pure function)가 hardcoded category × shot-text 등장 매칭으로 prop을 좁힌다.

흐름: **producer emits required prop → prompt must name P## → resolver attaches by required_refs SOT → validator proves attached.**

- **Tier 1 — scene_detail 출력 계약** (`validate_visible_entities_contract` PRO-13): required_refs(kind='prop') 이 emit 됐으면 t2i_variations[*].t2i_prompt 본문에 해당 P## 가 등장해야 함. 미등장 시 fail (scene_detail step retry trigger). **attached_meta 검사 없음** (validator 시그니처 보존).
- **Tier 2 — resolver SOT 강제 attach** (`resolve_refs_for_prompt(_set)` 신규 분기): required_refs(kind='prop') 의 P## 가 visible_entities 에 있고 scene_ref_image_map 에 entity_id ref 가 있으면 prompt text matching 결과와 **무관 강제 attach**. Tier 2 의 목적은 *resolver 의 text matching 의존 제거* — LLM 누락 대응이 아니라 producer SOT 와 resolver 의 alignment.
- **Tier 3 — image-stage 최종 검증** (`validate_attached_refs`): required_refs(kind='prop') 의 (kind, id) 가 attached_meta 에 exact match 로 존재하는지 strict subset 검사. 미충족 시 `RefContractError` (운영 차단).

`visible_entities: List[str]` (sid list) **시그니처 보존**. prop entity 정보는 신규 인자 `visible_entity_details: List[Dict[str, Any]]` 로 별도 전달.

**Tech Stack:** Python 3.12 / pytest. 데이터 변경 0 (runtime contract fix only). 신규 schema field 없음 (`required_refs[]`에 `kind='prop'` 추가만).

**Today's date:** 2026-05-11 / branch: `main` / HEAD: `2a55adc`

**상위 분석 보고서:** 본 세션 v3/v4 분석 (PID `02829fe8` / EP `fc38cf03`) — story-critical prop이 RPC에 producer로 emit되지 않고, common_noun rule × resolver 한국어 prop_name substring 매치 약점이 곱해져 S16/14·S27/5에서 사진 ref가 누락된 패턴.

---

## 1. Scope

### 1.1 In scope (Patch A)

- **P0-1**: `build_asset_requirements`(render_prompt_card.py:1641-1755)에 `kind='prop'` producer 추가. story-critical visible prop만 좁혀서 `required_refs`에 emit.
- **P0-2**: `build_id_policy`(render_prompt_card.py:958-1180)의 `common_noun_required_when` 항목 split.
  - "face inside photo/poster/screen/mirror/reflection/projection depiction" → common noun 유지 (face identity 방어).
  - "physical photo/poster/document/frame/map/key prop itself" → **P## ID 보존** (신규 분기).
- **검증 layer**: `ref_contract_validator.py`(prop kind normalization 활성화) + `visible_entities_validator.py`(prop required_refs membership check 신설 + line 664 주석 폐기).
- **fail-fast 시점**: image stage 진입 직전(`scene_image_pipeline`이 RPC를 소비하기 전). 현재 validator가 character_outlook 대상으로 이미 fail-fast하는 경로에 prop kind 추가.

### 1.1.5 Token scope policy (v1.4 — 시나리오 의존성 0, repo code 전반)

**CLAUDE.md `scenario_dependency_ban` 원칙 강제 — "코딩이나 프롬프트" = repo code 전반 적용**:

- Scenario-specific tokens (작품 캐릭터명: 수리영/혜수/민숙/인우 / 장소명: 금월도/시골 식당/둥근 원/두 소녀/옥탑방/조타실/갑판 / 작품 prop 묘사 descriptor: 낡은 흑백/흑백 사진/낡은 사진/낡은 액자 / 결함 shot ID: S16/S27/S26 / 작품 entity short_id: P02/P03/P17 등) 은:
  - ✓ 허용: `docs/superpowers/plans/**` (manual canary 설명 영역) — 사용자 검증 ground truth.
  - ✗ 금지 (repo code 전반): `backend/app/**` (production code), **`backend/tests/**` (test fixture 도 repo code — v1.4 정정)**, `backend/scripts/**` (utility script), `prompts/**` (prompt packs).
- 카테고리 noun (photo/photograph/picture/frame/document/paper/letter/map/key / 사진/액자/문서/종이/편지/지도/열쇠/키), schema placeholder literal (P##, C##, L##, O##, kind='prop', 그리고 v1.4 신규 — **synthetic test range**: `P91`~`P99`, `C91`~`C99`, `L91`~`L99`, `O91`~`O99` — 작품 short_id 와 명시적 분리), 일반 영어 어휘 (descriptor 무관) 는 repo code 어디든 OK.
- 강제 수단: **Task 8 production residue grep gate** (mandatory step, changed-files based, implementation merge 전 zero-hit 보장).

### 1.2 Out of scope (이 Patch에서 절대 안 함)

다음은 별도 Patch로 분리됨. 이 plan의 어떤 task도 다음 영역을 건드리지 않는다:

- **Patch B (sanitizer polarity guard)**: scene_image_pipeline의 safety/sanitize alive-rewrite 차단. S11/14·S11/20 결함 영역.
- **Patch C (photo orientation constraint)**: RPC `constraints`에 `photo_orientation` schema 추가. S26/6 결함.
- **Patch D (boat SOT)**: L17 negation strip + mobile_fixture entity 추출. S28/3 결함.
- **Patch E (owned_objects prop 승격)**: `background_binding.owned_objects`에 prop noun 포함 시 prop ref 별도 binding. S11/14 부차 결함.
- **resolver alias 확장 (G7)**: P3 todo로 별도 phase. 이 Patch에서는 resolver 의 **기본 매칭 base 분기 (line 544-587 의 P## 매칭 + prop_name 매칭)** 는 변경 안 함. **단 v1.2 fix — required_refs(kind='prop') 강제 attach 분기만 신규 추가** (BLOCKING 4 fix). 강제 attach 분기는 RPC 가 emit 한 required prop 만 처리, 자유로운 alias 확장은 G7 (P3) 영역.
- **`prop_role` LLM tagger step 추가**: P3 todo로 별도 phase.

### 1.3 Files to modify (exact, v1.2 redesign)

```
backend/app/core/steps/render_prompt_card.py
  - build_asset_requirements()         (line 1641-1755, signature 확장:
                                        visible_entities: List[str] 유지 +
                                        visible_entity_details: List[Dict] 신규
                                        + shot text sources 신규)
  - build_id_policy()                  (line 958-1180, common_noun_required_when
                                        split. visible_entities 시그니처 유지)
  - build_render_prompt_card()         (line 3013-3072, caller — 신규 인자
                                        propagate. visible_entities 시그니처 유지)
  - story_critical_prop_filter()       (신규 helper)

backend/app/core/steps/detail_steps.py
  - line 2072 근처 build_render_prompt_card 호출부에 신규 인자 propagate.
    visible_entity_details + shot_description + representative_moment +
    t2i_prompts 전달. visible (string list) 시그니처 변경 X.

backend/app/core/ref_contract_validator.py
  - _normalize_required_refs()         (line 199-280, **변경 없음** — 이미
                                        arbitrary kind 허용. v1.1 의 "kind 확장"
                                        은 불필요한 가정이었음.)
  - validate_attached_refs()           (line 283+, prop kind enforcement 추가:
                                        required_refs 의 (prop, P##) 가
                                        attached_meta 에 exact match 로 존재
                                        하는지 strict 검사. 미충족 시
                                        RefContractError fail-fast)

backend/app/core/visible_entities_validator.py
  - line 664 prop-not-emitted 주석 폐기
  - validate_visible_entities_contract() 안에 신규 PRO-13 룰:
    required_refs 의 (prop, P##) 가 t2i_variations[*].t2i_prompt 본문 중 적어도
    하나에 등장해야 함. 미등장 시 contract_violation fail (scene_detail step
    retry 트리거). 시그니처 (shot, name_by_short_id) **변경 없음** — attached_meta
    검사 없음.

backend/app/services/scene_reference_service.py  (v1.2 신규)
  - resolve_refs_for_prompt() / resolve_refs_for_prompt_set() 시그니처에
    신규 인자 required_refs: Optional[List[Dict]] = None 추가.
  - 기본 prop 매칭 분기 (line 544-587) 변경 없음.
  - 신규 분기: required_refs(kind='prop') 의 P## 가 visible_entities 에 있고
    scene_ref_image_map 에 해당 entity_id ref 가 있으면, P## 매칭/prop_name
    매칭 실패 여부 무관 강제 attach. attached_meta 에 ("prop", P##) 등록.
  - 강제 attach 분기는 required 인 prop 만 처리 (over-attach 차단).

backend/tests/unit/test_render_prompt_card.py          (Task 1, 2, 3 신규 케이스)
backend/tests/services/test_ref_contract_validator.py  (Task 4 신규 케이스 —
                                                        validate_attached_refs)
backend/tests/services/test_scene_reference_service.py (Task 4.5 신규 — resolver
                                                        강제 attach 분기)
backend/tests/core/test_visible_entities_validator.py  (Task 5 신규 — PRO-13)
backend/tests/integration/test_patch_a_story_prop_binding.py (Task 7 통합)
```

---

## 2. Story-Critical Prop 판정

### 2.1 Hardcoded category set (1차)

```python
# render_prompt_card.py 모듈 상수 (story_critical_prop_filter 가 사용)
#
# v1.3 redesign — category-only. descriptor (old / worn / black-and-white / faded /
# 낡은 / 흑백 / 오래된 등) 추가 절대 금지. base category noun 단독 substring
# (한국어) / word-boundary (영어) 매칭으로 모든 시나리오 prop 변이 cover.

# canonical English nouns (entity_canon.t2i_prompt 영어 본문 매칭용)
_STORY_CRITICAL_PROP_NOUNS_EN: tuple[str, ...] = (
    "photo", "photograph", "picture",
    "frame",
    "document", "paper", "letter",
    "map",
    "key",
)

# canonical Korean nouns (entity_canon.name / shot_description 한글 매칭용)
_STORY_CRITICAL_PROP_NOUNS_KO: tuple[str, ...] = (
    "사진", "액자",
    "문서", "종이", "편지",
    "지도",
    "열쇠", "키",
)

_STORY_CRITICAL_PROP_NOUNS: tuple[str, ...] = (
    _STORY_CRITICAL_PROP_NOUNS_EN + _STORY_CRITICAL_PROP_NOUNS_KO
)
```

### 2.2 AND 등장 조건

prop이 story-critical로 분류되려면 **다음 두 조건을 모두** 만족:

1. **카테고리 매칭**: prop의 `entity_canon.name` 또는 `entity_canon.t2i_prompt` 본문에 `_STORY_CRITICAL_PROP_NOUNS` 중 하나가 substring으로 등장 (한국어는 substring 매치, 영어는 `\b` word-boundary 매치 — `cup` ⊂ `cupboard` false-positive 차단).

2. **등장 매칭** — **사용자 요구사항 §2 OR 3-tier**. 다음 텍스트 sources 중 적어도 하나에서 **다음 세 가지 중 하나**가 등장:
   - **(a) prop short_id** (예: `P02`) — word-boundary 매치.
   - **(b) prop name** (예: "낡은 흑백 사진") — 한국어 substring / 영어 word-boundary.
   - **(c) canonical noun** — `_STORY_CRITICAL_PROP_NOUNS` 의 카테고리 단어 자체 (예: "사진", "액자", "photo", "frame") 가 텍스트에 등장.
     - **이 (c) 항목이 사용자 §2 의 "canonical noun" 요구**. (a)/(b) 매치 실패 시 fallback 으로 검사. 영어/한국어 분기 모두에서 동일하게 적용 (gap A fix).

   텍스트 sources:
   - `shot_description`
   - `representative_moment` (씬 대표 순간 문장)
   - shot의 모든 `t2i_variations[*].t2i_prompt` 본문 (build 시점에 이미 결정된 경우만 — RPC가 LLM 호출 *후* 재build되는 cp-save 경로에서)
   - LLM input 용 wide RPC build 시점에는 t2i_prompt 가 아직 없으므로 (1)+(shot_description ∨ representative_moment) 만 사용

매칭 알고리즘 base 는 `scene_reference_service.py:544-587`의 resolver 매칭과 동일 (한국어 substring / 영어 word-boundary). canonical noun fallback (c) 는 본 patch 에서 신규 추가 — resolver 본체는 변경 안 함, 본 RPC producer 의 entry filter 만 적용.

### 2.3 P3 todo (future phase, 이 Patch 범위 밖)

```markdown
# 향후 phase에 entity_extractor가 LLM으로 prop_role tag를 부여하는 step 도입
# entity_canon.metadata_json:
#   { "prop_role": "story_critical" | "set_dressing" }
# 이 phase가 도입되면 _STORY_CRITICAL_PROP_NOUNS hardcoded category 폐기 가능.
```

이 P3 todo는 본 plan의 어떤 task도 구현하지 않는다.

---

## 3. Contract

### 3.1 RPC 출력 변경

`build_asset_requirements`의 `required_refs`에 다음 신규 entry shape가 추가될 수 있다:

```json
{
  "kind": "prop",
  "id": "P02",
  "policy": "required"
}
```

기존 `character_outlook` / `background` entry shape는 변경 없음 (backward compat).

### 3.2 `id_policy.common_noun_required_when` split

**현재 (변경 전)**:
```python
"common_noun_required_when": [
    "body-part close-up",
    "photo/poster/screen/mirror/reflection/projection depiction",  # ← 통합 항목
    "unregistered extra",
]
```

**변경 후**:
```python
"common_noun_required_when": [
    "body-part close-up",
    "a reproduced face inside a photo/poster/screen/mirror/reflection/projection",  # face only
    "unregistered extra",
]
```

신규 `id_use_required_when` (or 본문 명시 강화):
```python
"id_use_required_when": [
    "a physical photo/poster/document/picture-frame/map/key prop appearing in the scene — use P## ID and preserve canonical content"
]
```

`constraints` 본문(line 1110-1150)의 항목 #1 (line 1113-1118)도 동시 수정:

**현재**:
```
"do not use C##O## for a reproduced face inside a photograph, poster, painting,
 portrait, monitor, TV, mirror, window reflection, or projection — use a common
 noun + demographic descriptor instead"
```

**변경 후**:
```
"do not use C##O## for a reproduced face inside a photograph, poster, painting,
 portrait, monitor, TV, mirror, window reflection, or projection — use a common
 noun + demographic descriptor instead. NOTE: the physical photo/poster/document
 prop itself MUST use its P## ID — the common-noun rule applies only to the
 face reproduction, not to the prop entity."
```

`reproduction_surface_rule.rationale_summary`(line 1064-1071)에도 동일한 분리 명시 추가.

### 3.3 Validator 강화 (v1.2 redesign — 3-tier)

- `ref_contract_validator._normalize_required_refs` (line 199-280): **변경 없음**. 이미 arbitrary kind 허용 (line 261 `normalized.setdefault(kind, []).append(rid)`) — `kind='prop'` 자동 통과.
- **Tier 3** — `ref_contract_validator.validate_attached_refs` (line 283+): required prop이 attached_meta에 `("prop", "P##")` 형태로 존재하는지 strict subset 검사. 미충족 → `RefContractError` raise (HTTP 422). character_outlook strict 검사와 동일 패턴 (line 283 docstring 참조). `AppError` 아님.
- **Tier 1** — `visible_entities_validator.validate_visible_entities_contract` 안 신규 PRO-13 룰: required_refs(kind='prop') 의 P## ID 가 `t2i_variations[*].t2i_prompt` 본문 중 적어도 하나에 등장하는지 검사. word-boundary 매치. 미등장 시 `AppError(code="step.scene_detail.contract_violation_prop_p_id_missing_in_prompt")` raise — scene_detail step retry trigger. **attached_meta 검사 없음** — 시그니처 `(shot, name_by_short_id)` 보존.

### 3.4 fail-fast 지점

- **Tier 1 fail-fast** (scene_detail step 내부): scene_detail LLM 응답 후 `_post_process` 가 `validate_visible_entities_contract` 호출 — PRO-13 미충족 시 step retry. max retry 2 (기존 scene_detail retry 정책 활용).
- **Tier 3 fail-fast** (image stage 진입 직전): `validate_attached_refs` 가 strict subset 검사. 미충족 → `RefContractError`. 이 plan은 새 fail-fast 진입점을 만들지 않고, 기존 character_outlook strict 검사 path 에 prop kind 만 추가.

---

## 4. File Structure (Tasks 1-7 Build Order)

| Task | File | Responsibility |
|---|---|---|
| 1 | `render_prompt_card.py`: 신규 helper `story_critical_prop_filter()` | hardcoded category × 등장 텍스트 AND 매칭 (pure function, deps: regex only). 입력은 `visible_entity_details: List[Dict]` (dict list) |
| 2 | `render_prompt_card.py`: `build_asset_requirements()` 확장 | `visible_entities: List[str]` 시그니처 보존 + 신규 `visible_entity_details: List[Dict]` + shot text sources 인자. helper 호출 후 `kind='prop'` emit |
| 3 | `render_prompt_card.py`: `build_id_policy()` 수정 | common_noun_required_when split + constraints[#1] 본문 수정. **시그니처 변경 없음** |
| 4 | `ref_contract_validator.py`: `validate_attached_refs()` (Tier 3) | required prop strict subset 검사. `_normalize_required_refs` 는 변경 없음. `RefContractError` raise |
| **4.5 (신규)** | `scene_reference_service.py`: `resolve_refs_for_prompt(_set)()` (Tier 2) | 신규 인자 `required_refs: Optional[List[Dict]] = None` 추가. required prop 강제 attach 분기 신설 (text matching 무관). 기본 P##/prop_name 매칭 분기 변경 없음 |
| 5 | `visible_entities_validator.py`: `validate_visible_entities_contract()` (Tier 1) | line 664 주석 폐기. PRO-13 룰: required_refs(prop) P## 가 t2i_variations[*].t2i_prompt 에 등장. **시그니처 (shot, name_by_short_id) 보존 — attached_meta 검사 없음** |
| 6 | Caller wiring (`detail_steps.py:2072+`, `build_render_prompt_card`) | `visible_entity_details` + shot text sources propagate. `visible` (string list) 시그니처 변경 X. resolver 호출부에 `required_refs` 전달 |
| 7 | Integration test (regression guards G1-G5) | S16/14·S27/5·S26/6·잡동사니·schema cases — 실 구현 (placeholder 0) |
| **8 (v1.3 신규)** | Production residue grep gate (verification only, no source change) | `backend/app/` 의 5 production 파일에 시나리오 token 0-hit 보장. CLAUDE.md `scenario_dependency_ban` 강제 |

---

## 5. Tasks

### Task 1: `story_critical_prop_filter` Helper (Pure Function + TDD)

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py` (모듈 상수 + 신규 함수 추가, 기존 함수 변경 없음)
- Test: `backend/tests/unit/test_render_prompt_card.py` (신규 test class)

- [ ] **Step 1.1: Write failing tests**

```python
# tests/unit/test_render_prompt_card.py 의 신규 class
# v1.4 — synthetic fixture. 모든 entity short_id 는 P91~P99 / C91~C99 / L91~L99
# (synthetic test range — 작품 short_id 와 명시적 분리). 한국어 shot text 는
# 영어 generic. 작품 어휘 (수리영 / 민숙 / 옥탑방 등) 0 hits.

class TestStoryCriticalPropFilter:
    def test_korean_photo_name_matches_korean_canonical_in_shot_text(self):
        """한국어 prop name 안 카테고리 noun '사진' × shot_description 의 '사진'
        substring → match. (substring 매칭이 partial 어휘로도 카테고리 등장 매치.)"""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P91", "name": "long_localized_photo_prop_name 사진",
                    "entity_type": "prop",
                    "t2i_prompt": "Set in modern context... black and white photograph..."}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="closeup of a hand pointing at one of two figures in a 사진.",
            representative_moment="",
            t2i_prompts=[],
        )
        assert result == [{"short_id": "P91", "id_or_name_evidence": "사진"}]

    def test_korean_frame_name_matches_korean_canonical_in_shot_text(self):
        """한국어 prop name 의 '액자' substring × shot_description 의 '액자' substring
        매칭 — base category noun 단독 매칭."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P92", "name": "long_localized_frame_prop_name 액자",
                    "entity_type": "prop",
                    "t2i_prompt": "...old desk photo frame..."}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="closeup of a 액자 surface with a distorted face inside.",
            representative_moment="",
            t2i_prompts=[],
        )
        assert result == [{"short_id": "P92", "id_or_name_evidence": "액자"}]

    def test_key_category_match_but_appearance_miss_skipped(self):
        """카테고리 매치 OK ('키' / 'key') 하지만 shot_description 등장 매칭 실패
        → 전체 결과는 skip."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P97", "name": "directional 키",
                    "entity_type": "prop",
                    "t2i_prompt": "...a small dark metal key with directional markings..."}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="A boat slowly disappears into a thick fog wall over the sea.",
            representative_moment="",
            t2i_prompts=[],
        )
        # 카테고리 매치 OK 하지만 등장 매칭 실패 → 제외
        assert result == []

    def test_non_story_critical_prop_skipped(self):
        """카테고리 미매치 prop (예: backpack / 가방) 은 skip."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P95", "name": "set_dressing_backpack",
                    "entity_type": "prop",
                    "t2i_prompt": "...a worn backpack with a small doll charm..."}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="A figure walks down an alley with a backpack on their shoulder.",
            representative_moment="",
            t2i_prompts=[],
        )
        # 카테고리에 backpack / 백팩 없음
        assert result == []

    def test_character_and_location_entities_skipped(self):
        """entity_type != 'prop' 은 항상 skip."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [
            {"short_id": "C91", "name": "character_A", "entity_type": "character"},
            {"short_id": "L91", "name": "indoor_space_A", "entity_type": "location"},
        ]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="Character A looks at a 사진 inside indoor space A.",
            representative_moment="",
            t2i_prompts=[],
        )
        assert result == []

    def test_no_visible_prop_returns_empty(self):
        """visible_entity_details=[] → []."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        assert story_critical_prop_filter(
            visible_entity_details=[], shot_description="anything",
            representative_moment="", t2i_prompts=[]) == []

    def test_short_word_boundary_blocks_substring_false_positive(self):
        """영어 'key' 카테고리가 'monkey' / 'kettle' substring false-positive 차단."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P98", "name": "stuffed_monkey_toy",
                    "entity_type": "prop",
                    "t2i_prompt": "a stuffed monkey toy"}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="a monkey on the floor",
            representative_moment="",
            t2i_prompts=[],
        )
        # 'monkey' 안에 'key' 가 substring 으로 들어있지만 word-boundary 로 차단
        assert result == []

    def test_prop_sid_in_t2i_prompt_satisfies_appearance(self):
        """t2i_prompts 본문에 synthetic P## sid 등장 시 등장조건 만족
        (shot_description 없어도 OK)."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P91", "name": "photo_prop_a",
                    "entity_type": "prop",
                    "t2i_prompt": "...old faded black and white photograph..."}]
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="",  # 미등장
            representative_moment="",
            t2i_prompts=["Photorealistic still. P91 lies flat on the console..."],
        )
        assert result == [{"short_id": "P91", "id_or_name_evidence": "P91"}]

    def test_english_prop_name_fails_but_canonical_noun_matches(self):
        """gap A fix — prop_name 영어 word-boundary 매치 실패해도 canonical noun
        ('photograph') 가 shot 텍스트에 등장하면 매칭 통과.
        사용자 §2 의 OR 3-tier — canonical noun fallback."""
        from app.core.steps.render_prompt_card import story_critical_prop_filter
        visible = [{"short_id": "P91", "name": "vintage_portrait_artifact",
                    "entity_type": "prop",
                    "t2i_prompt": "...an old photograph..."}]
        # shot_description 에 'vintage_portrait_artifact' 정확 매치는 없고
        # 'photograph' canonical noun 만 등장.
        result = story_critical_prop_filter(
            visible_entity_details=visible,
            shot_description="A weathered photograph rests on the table.",
            representative_moment="",
            t2i_prompts=[],
        )
        assert result == [{"short_id": "P91", "id_or_name_evidence": "photograph"}]
```

- [ ] **Step 1.2: Run tests to verify they fail**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestStoryCriticalPropFilter -v
```

Expected: 9 FAIL (`ImportError: cannot import name 'story_critical_prop_filter'`).

- [ ] **Step 1.3: Implement `story_critical_prop_filter`**

`backend/app/core/steps/render_prompt_card.py` 의 모듈 상수 섹션 (`_ID_BODY_PART_TRIGGERS` 등 다른 hardcoded tuple 근처) 에 다음 추가:

```python
# v1.3 — category-only. descriptor (old / worn / faded / black-and-white /
# 낡은 / 흑백 / 오래된 등) 추가 절대 금지 (시나리오 의존성 0 — CLAUDE.md
# scenario_dependency_ban). base noun 단독 매칭으로 모든 시나리오 변이 cover.
_STORY_CRITICAL_PROP_NOUNS_EN: tuple[str, ...] = (
    "photo", "photograph", "picture",
    "frame",
    "document", "paper", "letter",
    "map", "key",
)

_STORY_CRITICAL_PROP_NOUNS_KO: tuple[str, ...] = (
    "사진", "액자",
    "문서", "종이", "편지",
    "지도", "열쇠", "키",
)

_STORY_CRITICAL_PROP_NOUNS: tuple[str, ...] = (
    _STORY_CRITICAL_PROP_NOUNS_EN + _STORY_CRITICAL_PROP_NOUNS_KO
)

_STORY_PROP_MIN_NAME_LEN: int = 3  # 1-2자 영어 noun 은 substring 매치 false-positive 차단 (resolver 일관)


def _is_story_critical_category(text: str, *, has_non_ascii: bool) -> str | None:
    """text 안에 _STORY_CRITICAL_PROP_NOUNS 중 하나가 등장하면 evidence 반환, 없으면 None.

    한국어 (non_ascii) 는 simple substring (CJK 는 word-boundary 무관).
    영어는 word-boundary + min length 3 으로 false-positive 차단.
    """
    import re as _re
    if not text:
        return None
    for noun in _STORY_CRITICAL_PROP_NOUNS:
        _has_non_ascii = any(ord(c) > 127 for c in noun)
        if _has_non_ascii:
            if noun in text:
                return noun
        else:
            if len(noun) < _STORY_PROP_MIN_NAME_LEN:
                continue
            if _re.search(rf'\b{_re.escape(noun)}\b', text):
                return noun
    return None


def story_critical_prop_filter(
    *,
    visible_entity_details: list[dict],
    shot_description: str,
    representative_moment: str,
    t2i_prompts: list[str],
) -> list[dict]:
    """spec §2.2 — story-critical prop AND filter.

    Args:
        visible_entity_details: dict list — 각 dict 는 entity_canon 의 정보
            (short_id, name, entity_type, t2i_prompt) 를 담음. v1.2 redesign —
            상위 caller (build_render_prompt_card 등) 에서는 visible_entities
            (sid list) 와 별개의 신규 인자로 전달.
        shot_description / representative_moment / t2i_prompts: 등장 매칭에
            사용할 텍스트 sources (사용자 §2 OR 3-tier).

    조건 1 (카테고리): prop entity 의 name OR t2i_prompt 에
                       _STORY_CRITICAL_PROP_NOUNS 중 하나 등장.
    조건 2 (등장)     : shot_description / representative_moment / t2i_prompts 중
                       하나에 prop short_id, prop name, 또는 canonical noun 등장
                       (OR 3-tier).
    두 조건 모두 만족한 prop 만 결과에 포함 — 등장 evidence (sid / name / cat noun)
    를 같이 반환 (debug + validator 근거 로그용).
    """
    import re as _re
    if visible_entity_details is None:
        return []
    result: list[dict] = []
    appearance_haystack = "\n".join(
        s for s in [shot_description or "",
                    representative_moment or "",
                    *(t2i_prompts or [])] if s
    )
    for ve in visible_entity_details:
        if not isinstance(ve, dict):
            continue
        if ve.get("entity_type") != "prop":
            continue
        prop_sid = (ve.get("short_id") or "").strip()
        prop_name = (ve.get("name") or "").strip()
        prop_t2i = (ve.get("t2i_prompt") or "").strip()
        # 조건 1 — 카테고리 매칭
        cat_evidence = (
            _is_story_critical_category(prop_name, has_non_ascii=True)
            or _is_story_critical_category(prop_t2i, has_non_ascii=False)
        )
        if not cat_evidence:
            continue
        # 조건 2 — 등장 매칭
        appear_evidence: str | None = None
        if prop_sid and _re.search(rf'\b{_re.escape(prop_sid)}\b', appearance_haystack):
            appear_evidence = prop_sid
        elif prop_name and len(prop_name) >= _STORY_PROP_MIN_NAME_LEN:
            _has_non_ascii = any(ord(c) > 127 for c in prop_name)
            if _has_non_ascii:
                # name 전체 substring 매칭 실패 시 카테고리 noun 으로 fallback
                if prop_name in appearance_haystack:
                    appear_evidence = prop_name
                else:
                    # 카테고리 noun 한국어 등장 검사 — resolver 의 'word-stem'
                    # 매치 약점 보완 (예: long localized prop name vs shorter
                    # category noun in shot text — v1.3 generic example).
                    cat_appear = _is_story_critical_category(
                        appearance_haystack, has_non_ascii=True)
                    if cat_appear:
                        appear_evidence = cat_appear
            else:
                if _re.search(rf'\b{_re.escape(prop_name)}\b', appearance_haystack):
                    appear_evidence = prop_name
                else:
                    # gap A fix — 영어 canonical noun fallback (사용자 §2 OR 3-tier (c))
                    cat_appear = _is_story_critical_category(
                        appearance_haystack, has_non_ascii=False)
                    if cat_appear:
                        appear_evidence = cat_appear
        if not appear_evidence:
            continue
        result.append({
            "short_id": prop_sid,
            "id_or_name_evidence": appear_evidence,
        })
    return result
```

- [ ] **Step 1.4: Run tests to verify they pass**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestStoryCriticalPropFilter -v
```

Expected: 9 PASS.

- [ ] **Step 1.5: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py backend/tests/unit/test_render_prompt_card.py
git commit -m "feat(rpc): story_critical_prop_filter helper (Patch A Task 1)"
```

---

### Task 2: `build_asset_requirements` — `kind='prop'` Producer (v1.2 redesign)

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:1641-1755` (`build_asset_requirements` — 신규 인자 추가, **visible_entities 시그니처 보존**)
- Modify: `backend/app/core/steps/render_prompt_card.py:3013-3072` (`build_render_prompt_card` — caller. 신규 인자 propagate)
- Modify: `backend/app/core/steps/detail_steps.py:2072` 근처 (build_render_prompt_card 호출부 — entity_details lookup + shot text 전달)
- Test: `backend/tests/unit/test_render_prompt_card.py` (신규 케이스 + 기존 케이스 회귀)

- [ ] **Step 2.1: Inspect caller chain (read-only)**

```bash
grep -n "build_render_prompt_card\|build_asset_requirements" backend/app/ -rn --include="*.py" | grep -v __pycache__ | grep -v test_
```

기대 결과:
- `detail_steps.py:2072+` 가 `build_render_prompt_card` 를 호출.
- `detail_steps.py` 의 `_post_process` 류 함수가 이미 entity_canon 정보를 갖고 있음 (resolver, validator 와 동일 source).
- caller 에서 `visible` (string sid list) 와 별개로 `entity_lookup` 또는 entity dict list 를 propagate 하면 됨. **caller 인자 chain 확인만, 코드 수정은 Step 2.5 에서**.

- [ ] **Step 2.2: Write failing tests**

```python
# tests/unit/test_render_prompt_card.py 의 신규 class

class TestBuildAssetRequirementsPropKind:
    def test_frame_prop_required_when_close_framing(self):
        """Regression analog of S16/14 — close framing 인데도 frame prop required 로 emit.
        visible_entities (sid list) 보존 + visible_entity_details 별도 인자."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        ar = build_asset_requirements(
            visible_entities=["C91", "C92", "P94", "P92", "L91"],
            visible_entity_details=[
                {"short_id": "C91", "name": "character_a", "entity_type": "character"},
                {"short_id": "C92", "name": "character_b", "entity_type": "character"},
                {"short_id": "P94", "name": "set_dressing_phone", "entity_type": "prop",
                 "t2i_prompt": "...smartphone..."},
                {"short_id": "P92", "name": "frame_prop_long_name 액자",
                 "entity_type": "prop",
                 "t2i_prompt": "...old desk photo frame..."},
                {"short_id": "L91", "name": "indoor_space_a", "entity_type": "location"},
            ],
            outlook_pairs=[],
            bg_id="L91B05",
            is_close_framing=True,
            background_mode_on=True,
            shot_description="closeup of a 액자 surface, a face image inside warped into a distortion.",
            representative_moment="distorted face inside a frame",
            t2i_prompts=[],
        )
        assert {"kind": "prop", "id": "P92", "policy": "required"} in ar["required_refs"]
        # close-framing 이라 background 는 forbidden
        assert any(f["kind"] == "background" for f in ar["forbidden_refs"])

    def test_photo_prop_required_when_close_framing(self):
        """Regression analog of S27/5 — close framing 에서도 photo prop required."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        ar = build_asset_requirements(
            visible_entities=["C91", "C94", "P91", "P97", "L93"],
            visible_entity_details=[
                {"short_id": "C91", "name": "character_a", "entity_type": "character"},
                {"short_id": "C94", "name": "character_d", "entity_type": "character"},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...old faded black and white photograph..."},
                {"short_id": "P97", "name": "key_prop_long_name 키",
                 "entity_type": "prop",
                 "t2i_prompt": "...a small dark metal key..."},
                {"short_id": "L93", "name": "indoor_space_b", "entity_type": "location"},
            ],
            outlook_pairs=[],
            bg_id="L93B02",
            is_close_framing=True,
            background_mode_on=True,
            shot_description="closeup of a finger pointing at one figure in a 사진.",
            representative_moment="reveal moment of the photograph",
            t2i_prompts=[],
        )
        assert {"kind": "prop", "id": "P91", "policy": "required"} in ar["required_refs"]
        # P97 (키) 는 shot_description 등장 매칭 실패 → 미포함
        assert not any(r.get("id") == "P97" for r in ar["required_refs"])

    def test_photo_prop_required_with_character_outlook_preserved(self):
        """Regression analog of S26/6 — 기존 character_outlook + 신규 prop 함께 emit (회귀 0)."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        ar = build_asset_requirements(
            visible_entities=["C93", "P91", "P93", "L92"],
            visible_entity_details=[
                {"short_id": "C93", "name": "character_c", "entity_type": "character"},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...old faded black and white photograph..."},
                {"short_id": "P93", "name": "map_prop_long_name 지도",
                 "entity_type": "prop",
                 "t2i_prompt": "...printed paper map with coordinates..."},
                {"short_id": "L92", "name": "outdoor_space_a", "entity_type": "location"},
            ],
            outlook_pairs=[{"character_id": "C93", "outlook_id": "O92"}],
            bg_id="L92B01",
            is_close_framing=True,
            background_mode_on=True,
            shot_description="closeup of character C with widened eyes facing a 사진.",
            representative_moment="character reacting to the photo",
            t2i_prompts=[],
        )
        assert {"kind": "prop", "id": "P91", "policy": "required"} in ar["required_refs"]
        # P93 (지도) 는 shot_description 등장 X → 미포함
        assert not any(r.get("id") == "P93" for r in ar["required_refs"])
        # 기존 character_outlook 도 함께 emit
        assert {"kind": "character_outlook", "id": "C93O92", "policy": "required"} in ar["required_refs"]

    def test_visible_photo_prop_not_required_when_not_in_shot_text(self):
        """Regression analog of S11/14 — visible 에는 photo prop 있지만 shot_description
        미등장 → required 승격 안 됨 (over-attach 방지)."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        ar = build_asset_requirements(
            visible_entities=["C92", "P91", "P95", "L91"],
            visible_entity_details=[
                {"short_id": "C92", "name": "character_b", "entity_type": "character"},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...old faded black and white photograph..."},
                {"short_id": "P95", "name": "set_dressing_backpack",
                 "entity_type": "prop",
                 "t2i_prompt": "...backpack..."},
                {"short_id": "L91", "name": "indoor_space_a", "entity_type": "location"},
            ],
            outlook_pairs=[{"character_id": "C92", "outlook_id": "O91"}],
            bg_id="L91B06",
            is_close_framing=False,
            background_mode_on=True,
            shot_description="full-body view of character B slumped behind a curtain.",
            representative_moment="discovery of a still figure",
            t2i_prompts=[],
        )
        # P91 등장 매칭 실패 → 미포함 (시각 결함은 다른 Patch 영역)
        assert not any(r.get("id") == "P91" for r in ar["required_refs"])
        # P95 (backpack) 는 카테고리 미매치 → 미포함
        assert not any(r.get("id") == "P95" for r in ar["required_refs"])
        # background 는 정상 emit
        assert {"kind": "background", "id": "L91B06", "policy": "required"} in ar["required_refs"]

    def test_existing_character_outlook_emit_unchanged(self):
        """기존 character_outlook + background producer 동작 회귀 0.
        visible_entity_details 에 prop 없으면 prop emit 0."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        ar = build_asset_requirements(
            visible_entities=["C91"],
            visible_entity_details=[
                {"short_id": "C91", "name": "character_a", "entity_type": "character"},
            ],
            outlook_pairs=[{"character_id": "C91", "outlook_id": "O93"}],
            bg_id="L91B01",
            is_close_framing=False,
            background_mode_on=True,
            shot_description="",
            representative_moment="",
            t2i_prompts=[],
        )
        assert {"kind": "character_outlook", "id": "C91O93", "policy": "required"} in ar["required_refs"]
        assert {"kind": "background", "id": "L91B01", "policy": "required"} in ar["required_refs"]
        # prop 없음
        assert not any(r["kind"] == "prop" for r in ar["required_refs"])

    def test_visible_entities_none_still_fails_fast(self):
        """기존 R2-B4 None vs [] fail-fast 회귀 0. visible_entities (string list)
        가 None 이면 AppError. visible_entity_details None 도 별도 fail-fast."""
        from app.core.steps.render_prompt_card import build_asset_requirements
        from app.core.errors import AppError
        import pytest
        with pytest.raises(AppError) as exc:
            build_asset_requirements(
                visible_entities=None, visible_entity_details=[],
                outlook_pairs=[],
                bg_id=None, is_close_framing=False, background_mode_on=False,
                shot_description="", representative_moment="", t2i_prompts=[],
            )
        assert "visible_entities is None" in str(exc.value)
        # visible_entity_details None 도 fail-fast
        with pytest.raises(AppError) as exc:
            build_asset_requirements(
                visible_entities=[], visible_entity_details=None,
                outlook_pairs=[],
                bg_id=None, is_close_framing=False, background_mode_on=False,
                shot_description="", representative_moment="", t2i_prompts=[],
            )
        assert "visible_entity_details is None" in str(exc.value)
```

- [ ] **Step 2.3: Run tests to verify they fail**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirementsPropKind -v
```

Expected: 6 FAIL (`TypeError: unexpected keyword argument 'shot_description'` 또는 prop assertion 실패).

- [ ] **Step 2.4: Extend `build_asset_requirements` (v1.2 redesign)**

`render_prompt_card.py:1641-1755` 변경. **`visible_entities: List[str]` 시그니처 보존** + `visible_entity_details: List[Dict[str, Any]]` 신규 인자 + shot text sources 신규 인자:

```python
def build_asset_requirements(
    *,
    visible_entities: List[str],                       # v1.2 보존 — sid string list
    visible_entity_details: List[Dict[str, Any]],      # v1.2 신규 — entity info dict list
    outlook_pairs: List[Dict[str, str]],
    bg_id: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
    used_outlook_pairs: Optional[Set[Tuple[str, str]]] = None,
    state_variant_chars: Optional[Set[str]] = None,
    # Patch A 신규 인자 — story-critical prop binding 용 shot 텍스트 sources
    shot_description: str = "",
    representative_moment: str = "",
    t2i_prompts: Optional[List[str]] = None,
) -> Dict[str, Any]:
    """... (기존 docstring 유지) ...

    Patch A (2026-05-11) — story-critical prop binding:
    visible_entity_details 중 photo/document/map/frame/key 카테고리 prop 이 shot 텍스트
    (shot_description / representative_moment / t2i_prompts) 에 등장하면
    required_refs 에 kind='prop' 으로 emit. story_critical_prop_filter helper 사용.
    over-attach 방지 — 등장 매칭 실패 prop 은 skip.

    v1.2 redesign — visible_entities (sid list) 시그니처 보존 (caller breaking
    change 회피). prop entity 정보는 visible_entity_details (dict list) 별도 인자
    로 전달. 두 인자 모두 None 이면 fail-fast (R2-B4 패턴).
    """
    # 기존 None vs [] fail-fast (line 1672-1688) 유지
    if visible_entities is None:
        raise AppError(code="step.contract_violation",
                       message="build_asset_requirements: visible_entities is None ...")
    # 신규 — visible_entity_details None vs [] fail-fast
    if visible_entity_details is None:
        raise AppError(code="step.contract_violation",
                       message="build_asset_requirements: visible_entity_details is None ...")
    # 기존 outlook_pairs None check 유지

    # 기존 character_outlook 처리 (line 1689-1726) 유지 — visible_entities (sid) 기반

    # 기존 background forbidden / required 처리 (line 1727-1736) 유지

    # Patch A 신규 — story-critical prop emit (visible_entity_details 기반)
    story_props = story_critical_prop_filter(
        visible_entity_details=visible_entity_details,
        shot_description=shot_description,
        representative_moment=representative_moment,
        t2i_prompts=t2i_prompts or [],
    )
    for sp in story_props:
        prop_sid = sp.get("short_id") or ""
        if not prop_sid:
            continue
        required.append({
            "kind": "prop",
            "id": prop_sid,
            "policy": "required",
        })

    # 기존 readiness_policy 결정 + constraints (line 1737-1755) 유지
```

- [ ] **Step 2.5: Update `build_render_prompt_card` caller (line 3013-3072) + `detail_steps.py:2072`**

`build_render_prompt_card` 시그니처 (line 3013-3019) 에 신규 인자 4 개 추가 (visible_entities 는 보존):

```python
def build_render_prompt_card(
    *,
    scene_index: int,
    shot_index: int,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    visible_entities: List[str],                                # v1.2 보존
    visible_entity_details: List[Dict[str, Any]],               # v1.2 신규
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
    staging: Optional[Dict[str, Any]],
    bg_id: Optional[str],
    bg_owned: List[str],
    bg_camera_meta: Optional[Dict[str, Any]],
    bg_guide: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
    fixed_elements: List[Dict[str, Any]],
    previous_shot_refs: List[Dict[str, Any]],
    forward_zoom_targets: List[Dict[str, Any]],
    used_outlook_pairs: Optional[Set[Tuple[str, str]]] = None,
    state_variant_chars: Optional[Set[str]] = None,
    # Patch A 신규 인자
    shot_description: str = "",
    representative_moment: str = "",
    t2i_prompts: Optional[List[str]] = None,
) -> Dict[str, Any]:
    ...
    card["asset_requirements"] = build_asset_requirements(
        visible_entities=visible_entities,
        visible_entity_details=visible_entity_details,  # v1.2 신규
        outlook_pairs=outlook_pairs,
        bg_id=bg_id, is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
        used_outlook_pairs=used_outlook_pairs,
        state_variant_chars=state_variant_chars,
        shot_description=shot_description,
        representative_moment=representative_moment,
        t2i_prompts=t2i_prompts,
    )
```

`detail_steps.py:2072` 근처 (RPC build caller) 에서:

```python
# detail_steps.py — _post_process 또는 RPC build site

# v1.2 신규 — entity_canon lookup 으로 visible_entity_details 구축
_entity_lookup_by_sid = {
    e.get("short_id"): e for e in entity_lookup_list if e.get("short_id")
}  # entity_lookup_list 는 이미 detail_steps 에 존재하는 source (resolver / validator 공통)
visible_entity_details: List[Dict[str, Any]] = [
    _entity_lookup_by_sid[sid] for sid in visible
    if sid in _entity_lookup_by_sid
]

# v1.2 신규 — shot text sources
_shot_description = shot_info.get("shot_description") or ""
_representative_moment = shot_info.get("representative_moment") or ""
_t2i_prompts = [v.get("t2i_prompt", "") for v in (shot_info.get("t2i_variations") or [])]

card = build_render_prompt_card(
    scene_index=scene_index, shot_index=shot_index,
    seg=seg, shot_info=shot_info,
    visible_entities=visible,                                # 기존 sid list
    visible_entity_details=visible_entity_details,           # v1.2 신규
    outlook_pairs=_g41_outlook_pairs,
    perception_mode=perception_mode,
    staging=staging,
    bg_id=bg_id, bg_owned=bg_owned, ...,
    shot_description=_shot_description,
    representative_moment=_representative_moment,
    t2i_prompts=_t2i_prompts,
)
```

⚠️ caller 의 `entity_lookup_list` source 는 build phase 에서 정확히 확인 — `detail_steps.py` 의 `_post_process` 또는 외부 함수에서 entity_canon 정보를 어떤 변수로 보유하는지 (예: `entity_lookup` dict). resolver 와 동일 source 사용해야 정합.

- [ ] **Step 2.6: Run tests to verify pass**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirementsPropKind -v
pytest tests/unit/test_render_prompt_card.py -v  # 기존 케이스 회귀 0
```

Expected: 신규 6 PASS + 기존 케이스 모두 PASS.

- [ ] **Step 2.7: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py backend/tests/unit/test_render_prompt_card.py
git commit -m "feat(rpc): emit kind='prop' required_refs for story-critical visible props (Patch A Task 2)"
```

---

### Task 3: `build_id_policy` — `common_noun_required_when` Split

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:958-1180` (`build_id_policy`)
- Modify: `backend/app/core/steps/render_prompt_card.py:1108-1150` (constraints 본문)
- Modify: `backend/app/core/steps/render_prompt_card.py:1063-1071` (`reproduction_surface_rule.rationale_summary`)
- Test: `backend/tests/unit/test_render_prompt_card.py`

- [ ] **Step 3.1: Write failing tests**

```python
class TestIdPolicyPhotoDepictionSplit:
    def test_common_noun_rule_face_only(self):
        """common_noun_required_when 의 photo depiction 항목이 face-only 로 좁혀짐."""
        from app.core.steps.render_prompt_card import build_id_policy
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
        )
        cnr = ip["common_noun_required_when"]
        # 기존 통합 항목은 사라짐
        assert "photo/poster/screen/mirror/reflection/projection depiction" not in cnr
        # 신규 face-only 항목 존재
        assert "a reproduced face inside a photo/poster/screen/mirror/reflection/projection" in cnr

    def test_id_use_required_when_for_physical_prop(self):
        """신규 id_use_required_when 항목에 physical prop 보존 rule 명시."""
        from app.core.steps.render_prompt_card import build_id_policy
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
        )
        # 신규 key 존재
        assert "id_use_required_when" in ip
        iur = ip["id_use_required_when"]
        assert any("physical photo" in s and "P##" in s for s in iur)

    def test_constraints_item_one_split_note(self):
        """constraints[#1] 본문에 'NOTE: physical photo prop itself MUST use P## ID' 추가."""
        from app.core.steps.render_prompt_card import build_id_policy
        ip = build_id_policy(
            visible_entities=["C91", "P91"], outlook_pairs=[],
            perception_mode=None,
        )
        # constraints index 1 = reproduction surface rule
        # 본문에 NOTE clause 가 있어야 함
        c1 = ip["constraints"][1]
        assert "NOTE" in c1 and "physical" in c1 and "P##" in c1
```

- [ ] **Step 3.2: Run tests to verify they fail**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestIdPolicyPhotoDepictionSplit -v
```

Expected: 3 FAIL.

- [ ] **Step 3.3: Modify `build_id_policy`**

line 1166-1170 (현재):
```python
"common_noun_required_when": [
    "body-part close-up",
    "photo/poster/screen/mirror/reflection/projection depiction",
    "unregistered extra",
],
```

→ 변경 후:
```python
"common_noun_required_when": [
    "body-part close-up",
    "a reproduced face inside a photo/poster/screen/mirror/reflection/projection",
    "unregistered extra",
],
"id_use_required_when": [
    "a physical photo/poster/document/picture-frame/map/key prop appearing in the scene — use its P## ID and preserve canonical content from entity_canon",
],
```

line 1113-1118 (constraints[#1] 현재):
```python
(
    "do not use C##O## for a reproduced face inside a photograph, "
    "poster, painting, portrait, monitor, TV, mirror, window reflection, "
    "or projection — use a common noun + demographic descriptor instead"
),
```

→ 변경 후:
```python
(
    "do not use C##O## for a reproduced face inside a photograph, "
    "poster, painting, portrait, monitor, TV, mirror, window reflection, "
    "or projection — use a common noun + demographic descriptor instead. "
    "NOTE: the physical photo/poster/document/picture-frame/map/key prop "
    "itself MUST use its P## ID — the common-noun rule applies only to a "
    "face reproduction, not to the prop entity."
),
```

line 1063-1071 (`reproduction_surface_rule.rationale_summary` 현재):
```python
"rationale_summary": (
    "C##O## 은 얼굴 reference 이미지를 원본 해상도로 inject 하므로 사진/화면 "
    "속 얼굴이 사진 밖 실물 크기로 합성됨"
),
```

→ 변경 후:
```python
"rationale_summary": (
    "C##O## 은 얼굴 reference 이미지를 원본 해상도로 inject 하므로 사진/화면 "
    "속 얼굴이 사진 밖 실물 크기로 합성됨. 단, 이 rule 은 사진 안 얼굴 (face) "
    "에만 적용 — 물리적 사진/액자/문서 prop 자체는 P## ID 보존 (Patch A)"
),
```

- [ ] **Step 3.4: Run tests to verify pass**

```bash
cd backend
pytest tests/unit/test_render_prompt_card.py::TestIdPolicyPhotoDepictionSplit -v
pytest tests/unit/test_render_prompt_card.py -v
```

Expected: 신규 3 PASS + 기존 케이스 모두 PASS.

⚠️ **Hash 회귀 주의**: id_policy 가 RPC hash 입력에 포함되므로 (`canonicalize_render_prompt_card` line 1820-1828), constraints 본문/sub-field rationale 변경은 **render_prompt_card_hash 가 달라짐**. 기존 cp 데이터는 force re-run 으로 update 필요. plan v1 의 caveat — 다음 step.

- [ ] **Step 3.5: Update `render_prompt_card_hash` golden test fixtures**

`tests/unit/test_render_prompt_card_hash.py` 의 golden hash 값들이 수정된 id_policy 본문 기준으로 갱신되어야 한다. 신규 hash 값을 한 번 실행해 dump 후 fixture 업데이트.

```bash
cd backend
pytest tests/unit/test_render_prompt_card_hash.py -v --tb=short  # 깨진 hash 확인
# 새 hash 값으로 fixture 교체 후 재실행
pytest tests/unit/test_render_prompt_card_hash.py -v  # PASS
```

- [ ] **Step 3.6: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py backend/tests/unit/test_render_prompt_card.py backend/tests/unit/test_render_prompt_card_hash.py
git commit -m "feat(rpc): split id_policy common_noun rule — face vs physical prop (Patch A Task 3)"
```

---

### Task 4: `validate_attached_refs` — Tier 3 Strict Subset 검사 (v1.2 redesign)

**Files:**
- Modify: `backend/app/core/ref_contract_validator.py:283+` (`validate_attached_refs`)
- `_normalize_required_refs` 는 **변경 없음** (이미 arbitrary kind 허용 line 251)
- Test: `backend/tests/services/test_ref_contract_validator.py`

- [ ] **Step 4.1: Read current validator (verify v1.2 가정)**

```bash
sed -n '283,420p' backend/app/core/ref_contract_validator.py
```

확인:
- 함수 시그니처: `validate_attached_refs(rpc, labeled_refs, attached_meta, prompt, is_close_framing, *, chain_bg_lookup=None)`.
- 현재 character_outlook 의 strict 검사는 `("character_outlook", cid)` exact match 패턴 (line 283 docstring 참조).
- `_normalize_required_refs` (line 199-280) 는 arbitrary kind 처리 — prop 자동 통과.
- raise 는 `RefContractError`, `AppError` 아님.

- [ ] **Step 4.2: Write failing tests**

```python
# tests/services/test_ref_contract_validator.py 의 신규 class

class TestTier3RequiredPropContract:
    # v1.4 — synthetic short_ids only. P91~P99 (test range, 작품 short_id 와 명시적 분리).

    def test_required_prop_attached_passes(self):
        """Tier 3: required_refs (prop, P91) 가 attached_meta 에 매칭되면 통과."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        labeled_refs = [("object P91", b"\x89PNG\r\n")]
        attached_meta = [("prop", "P91")]
        # 통과 (예외 없음)
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...P91 lies flat on the console...",
            is_close_framing=True,
        )

    def test_required_prop_missing_raises_ref_contract_error(self):
        """Tier 3: required_refs (prop, P91) 가 attached_meta 에 없으면 RefContractError."""
        from app.core.ref_contract_validator import (
            validate_attached_refs, RefContractError,
        )
        import pytest
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        # labeled_refs 와 attached_meta 길이 invariant 유지 (둘 다 0).
        with pytest.raises(RefContractError) as exc:
            validate_attached_refs(
                rpc=rpc, labeled_refs=[], attached_meta=[],
                prompt="...the photograph lies flat...",
                is_close_framing=True,
            )
        # error 메시지에 prop kind + 누락 P## 명시
        assert "prop" in str(exc.value).lower() and "P91" in str(exc.value)

    def test_normalize_accepts_prop_kind_unchanged(self):
        """_normalize_required_refs 는 v1.2 에서 변경 없음 — 이미 arbitrary kind 허용.
        prop kind 정상 통과 확인 (regression check)."""
        from app.core.ref_contract_validator import _normalize_required_refs
        normalized = _normalize_required_refs(
            [{"kind": "prop", "id": "P91", "policy": "required"}]
        )
        assert normalized == {"prop": ["P91"]}

    def test_prop_mixed_with_other_kinds_all_pass(self):
        """prop + character_outlook + background 혼합 정상 (3-way strict subset)."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [
                    {"kind": "background", "id": "L92B01", "policy": "required"},
                    {"kind": "character_outlook", "id": "C93O92", "policy": "required"},
                    {"kind": "prop", "id": "P91", "policy": "required"},
                ],
                "forbidden_refs": [],
                "readiness_policy": "block_if_missing",
            }
        }
        labeled_refs = [
            ("pre-rendered BACKGROUND chain reference", b"bg"),
            ("character C93O92 in outfit", b"co"),
            ("object P91", b"p"),
        ]
        attached_meta = [
            ("background", "L92B01"),
            ("character_outlook", "C93O92"),
            ("prop", "P91"),
        ]
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...C93O92...P91...",
            is_close_framing=False,
        )

    def test_extra_prop_attached_without_required_does_not_fail(self):
        """attached_meta 가 required 보다 많아도 (예: visible 매칭으로 attach 된
        non-required prop) 통과. strict subset 의미 — required ⊆ attached."""
        from app.core.ref_contract_validator import validate_attached_refs
        rpc = {
            "asset_requirements": {
                "required_refs": [],
                "forbidden_refs": [],
                "readiness_policy": "n/a",
            }
        }
        labeled_refs = [("object P93", b"p")]
        attached_meta = [("prop", "P93")]  # required 0, attached 1
        validate_attached_refs(
            rpc=rpc, labeled_refs=labeled_refs, attached_meta=attached_meta,
            prompt="...the map sits on the table...",
            is_close_framing=True,
        )
```

- [ ] **Step 4.3: Run tests to verify they fail**

```bash
cd backend
pytest tests/services/test_ref_contract_validator.py::TestTier3RequiredPropContract -v
```

Expected: 4 FAIL (`required prop P91 not in attached_meta` 류 에러 없음 = 현재 validator 가 prop 검사 안 함). `test_normalize_accepts_prop_kind_unchanged` 는 통과 (regression check, 이미 정상).

- [ ] **Step 4.4: Modify `validate_attached_refs`**

`ref_contract_validator.py:283+` 의 `validate_attached_refs` 내부, character_outlook strict 검사 분기 (현재 코드의 line 320 부근 — character_outlook 처리) 와 같은 위치에 prop strict 검사 추가:

```python
# 신규 prop strict 검사 — character_outlook 패턴 일관
attached_set = set(attached_meta)
required_props = required.get("prop", [])
for prop_id in required_props:
    if ("prop", prop_id) not in attached_set:
        raise RefContractError(
            f"required prop {prop_id} not in attached_meta — "
            f"Tier 3 strict subset violation (Patch A). "
            f"attached_meta={list(attached_set)}"
        )
```

- [ ] **Step 4.5: Run tests to verify pass**

```bash
cd backend
pytest tests/services/test_ref_contract_validator.py -v
```

Expected: 신규 5 PASS + 기존 케이스 회귀 0.

- [ ] **Step 4.6: Commit**

```bash
git add backend/app/core/ref_contract_validator.py backend/tests/services/test_ref_contract_validator.py
git commit -m "feat(validator): Tier 3 — prop strict subset in validate_attached_refs (Patch A Task 4)"
```

---

### Task 4.5: `resolve_refs_for_prompt(_set)` — Tier 2 Required Prop Forced Attach (v1.2 신규)

**Files:**
- Modify: `backend/app/services/scene_reference_service.py:376-587` (`resolve_refs_for_prompt`)
- Modify: `backend/app/services/scene_reference_service.py:341-374` (`resolve_refs_for_prompt_set`)
- Test: `backend/tests/services/test_scene_reference_service.py`

목적: **resolver 의 text matching 의존 제거**. required_refs(kind='prop') 의 P## 는 RPC producer SOT — resolver 가 prompt text 매칭 결과와 무관하게 강제 attach. 기본 prop 매칭 분기 (line 544-587) 는 변경 없음 (over-attach 차단).

- [ ] **Step 4.5.1: Write failing tests**

```python
# tests/services/test_scene_reference_service.py 의 신규 class

class TestTier2ForcedPropAttach:
    # v1.4 — synthetic fixture. resolver 의 mock 의존성을 최소화 — visible_entities /
    # scene_ref_image_map / entity_lookup 모두 in-memory dict 로 전달.

    def _make_service(self, db_mock=None):
        """SceneReferenceService instance — DB session mock + project_id."""
        from app.services.scene_reference_service import SceneReferenceService
        import unittest.mock as _mock
        db = db_mock or _mock.MagicMock()
        # CharacterOutlook query 호출 시 빈 리스트 반환 (synthetic — outlook 무관)
        db.query.return_value.filter.return_value.all.return_value = []
        return SceneReferenceService(db, project_id="proj_synthetic_a")

    def test_required_prop_attached_even_when_prompt_missing_p_id(self):
        """Tier 2: required_refs (prop, P91) 가 emit 됐고 visible_entities +
        scene_ref_image_map 에 P91 가 있으면, t2i_prompt 에 P91 ID 없어도 강제 attach."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        labeled_refs, attached_meta = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",  # P91 ID 없음
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        assert ("prop", "P91") in attached_meta
        assert any("P91" in label for label, _ in labeled_refs)

    def test_required_prop_not_double_attached_when_already_text_matched(self):
        """required_refs 에 P91 가 있고 prompt 에 P91 ID 도 있으면, text 매칭 분기에서
        먼저 attach — Tier 2 강제 분기는 _used_ref_ids 검사로 skip. labeled_refs 1 장만."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        labeled_refs, attached_meta = svc.resolve_refs_for_prompt(
            t2i_prompt="P91 lies flat on the console",  # P91 ID 등장
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        # 정확히 1번 attach (text 매칭이 먼저, Tier 2 강제 분기는 skip)
        p91_meta_count = sum(1 for k, i in attached_meta
                             if k == "prop" and i == "P91")
        assert p91_meta_count == 1
        assert len(labeled_refs) == 1

    def test_no_required_refs_falls_back_to_text_matching_only(self):
        """required_refs 인자 None / 빈 prop → Tier 2 분기 skip. 기존 매칭 분기만 동작."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        # required_refs=None 케이스
        labeled_refs, attached_meta = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",  # P91 ID 없음
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=None,
        )
        # Tier 2 강제 attach 분기 작동 안 함 — text 매칭만 (P91 없으니 skip)
        assert ("prop", "P91") not in attached_meta
        assert labeled_refs == []

    def test_required_prop_visible_but_ref_image_missing_skips(self):
        """required_refs (prop, P91) 있지만 scene_ref_image_map 에 P91 ref image 없으면
        skip (resolver 가 ref 강제 attach 불가). Tier 3 validator 가 missing 으로 fail-fast."""
        svc = self._make_service()
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {}  # ← P91 ref 없음
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        labeled_refs, attached_meta = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        # ref image 부재 → Tier 2 가 attach 못 함. labeled_refs / attached_meta 모두 빈 채.
        # (Tier 3 validate_attached_refs 가 image stage 직전 fail-fast.)
        assert ("prop", "P91") not in attached_meta
        assert labeled_refs == []
```

- [ ] **Step 4.5.2: Run tests to verify they fail**

```bash
cd backend
pytest tests/services/test_scene_reference_service.py::TestTier2ForcedPropAttach -v
```

Expected: 4 FAIL (`required_refs` 인자 미존재).

- [ ] **Step 4.5.3: Modify resolver — `resolve_refs_for_prompt` 에 신규 인자 + 분기**

`scene_reference_service.py:376` 시그니처:

```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,
    shot_description: Optional[str] = None,
    *,
    required_refs: Optional[List[Dict[str, Any]]] = None,   # v1.2 신규
) -> Tuple[List[Tuple[str, bytes]], List[Tuple[str, str]]]:
```

기본 매칭 분기 (line 544-587) 다음, **신규 Tier 2 강제 attach 분기**:

```python
# 4) Tier 2 — required_refs(kind='prop') 강제 attach (Patch A v1.2)
#    text matching 의존 제거. RPC producer SOT — required prop 은 P## ID
#    prompt 매칭/prop_name 매칭 결과 무관 강제 attach.
#    이미 (3) 분기에서 text 매칭으로 attach 된 prop 은 _used_ref_ids 검사로 skip
#    (double attach 방지).
import re as _re_t2
_required_props: list[str] = []
if required_refs:
    for r in required_refs:
        if isinstance(r, dict) and r.get("kind") == "prop":
            pid = r.get("id") or ""
            if pid:
                _required_props.append(pid)
for prop_sid in _required_props:
    # visible_entities 에서 prop entity_id 찾기
    target_eid = next(
        (ve.get("id", "") for ve in visible_entities
         if ve.get("short_id") == prop_sid and ve.get("entity_type") == "prop"),
        None,
    )
    if not target_eid:
        continue  # visible 에 없으면 skip (Tier 1 validator 가 잡음)
    if target_eid in _used_ref_ids:
        continue  # 이미 text 매칭 분기에서 attach
    if target_eid not in scene_ref_image_map:
        continue  # ref image 없으면 attach 불가 (Tier 3 가 잡음)
    labeled_refs.append((
        f"object {prop_sid} (required)",
        scene_ref_image_map[target_eid],
    ))
    attached_meta.append(("prop", prop_sid))
    _used_ref_ids.add(target_eid)
```

`resolve_refs_for_prompt_set` (line 341) 도 `required_refs` 인자 추가 + 내부에서 `resolve_refs_for_prompt` 호출 시 전달.

- [ ] **Step 4.5.4: Update resolver 호출자 — RPC.required_refs 전달**

resolver caller (scene_image_pipeline / scene_image_service 등) 에서:

```python
# 호출자 — RPC 의 asset_requirements.required_refs 추출 후 resolver 에 전달
rpc = shot_data.get("render_prompt_card") or {}
asset_req = rpc.get("asset_requirements") or {}
required_refs = asset_req.get("required_refs") or []

labeled_refs, attached_meta = ref_service.resolve_refs_for_prompt(
    t2i_prompt=t2i_prompt,
    visible_entities=visible_entities,
    scene_ref_image_map=ref_map,
    entity_lookup=entity_lookup,
    state_variant_sids=state_variant_sids,
    shot_description=shot_description,
    required_refs=required_refs,   # v1.2 신규
)
```

호출자 path 정확 위치는 build phase 에서 `grep -n "resolve_refs_for_prompt" backend/app/` 으로 식별.

- [ ] **Step 4.5.5: Run tests + commit**

```bash
cd backend
pytest tests/services/test_scene_reference_service.py::TestTier2ForcedPropAttach -v
pytest tests/services/test_scene_reference_service.py -v  # 기존 회귀 0
git add backend/app/services/scene_reference_service.py backend/tests/services/test_scene_reference_service.py
git commit -m "feat(resolver): Tier 2 — required_refs(prop) forced attach (Patch A Task 4.5)"
```

---

### Task 5: `validate_visible_entities_contract` — Tier 1 PRO-13 Rule (v1.2 redesign)

**Files:**
- Modify: `backend/app/core/visible_entities_validator.py:310+` (`validate_visible_entities_contract`)
- Modify: `backend/app/core/visible_entities_validator.py:664` (주석 폐기)
- **시그니처 `(shot, name_by_short_id)` 보존 — attached_meta 검사 없음**
- Test: `backend/tests/core/test_visible_entities_validator.py`

목적: scene_detail LLM 출력 정합성만 검사. required_refs(kind='prop') 가 emit 됐으면 `t2i_variations[*].t2i_prompt` 본문에 해당 P## 가 word-boundary 매치로 등장해야 함. 미등장 시 `AppError` raise → scene_detail step retry trigger.

- [ ] **Step 5.1: Write failing tests**

```python
# tests/core/test_visible_entities_validator.py 의 신규 class

class TestPro13PropPidInPrompt:
    def _make_shot(self, required_prop_ids: list[str], variation_prompts: list[str],
                   visible_short_ids: list[str] | None = None) -> dict:
        """fixture builder — required_refs prop 과 t2i_variations 만 좁게 구성."""
        required_refs = [
            {"kind": "prop", "id": pid, "policy": "required"}
            for pid in required_prop_ids
        ]
        visible = visible_short_ids or required_prop_ids
        return {
            "scene_index": 1, "_shot_index": 1,
            "visible_entities": visible,
            "t2i_variations": [
                {"t2i_prompt": p} for p in variation_prompts
            ],
            "render_prompt_card": {
                "id_policy": {
                    "allowed_base_entity_ids": list(visible),
                    "allowed_outlook_pairs": [],
                    "common_noun_required_when": [],
                    "id_use_required_when": [],
                },
                "asset_requirements": {
                    "required_refs": required_refs,
                    "forbidden_refs": [],
                    "readiness_policy": "block_if_missing" if required_refs else "n/a",
                },
                "background_binding": {"mode": "not_applicable"},
            },
        }

    def test_pro13_prop_pid_in_variation_passes(self):
        """PRO-13: required prop P91 가 적어도 한 variation 의 t2i_prompt 에 등장 → pass."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. P91 lies flat on the console.",
            ],
        )
        validate_visible_entities_contract(shot, name_by_short_id={})

    def test_pro13_prop_pid_missing_from_all_variations_fails(self):
        """PRO-13: required prop P91 가 어떤 variation 에도 없으면 fail."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        import pytest
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. an old photograph lies flat.",
            ],
        )
        with pytest.raises(AppError) as exc:
            validate_visible_entities_contract(shot, name_by_short_id={})
        assert exc.value.code == (
            "step.scene_detail.contract_violation_prop_p_id_missing_in_prompt"
        )
        assert "P91" in str(exc.value.message)

    def test_pro13_word_boundary_blocks_substring_false_positive(self):
        """PRO-13: 'P91' word-boundary 만. 'P910' 안 substring 매치 X."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        import pytest
        shot = self._make_shot(
            required_prop_ids=["P91"],
            variation_prompts=[
                "Photorealistic still. P910 lies on the table.",
            ],
        )
        with pytest.raises(AppError):
            validate_visible_entities_contract(shot, name_by_short_id={})

    def test_pro13_multi_required_props_all_must_appear(self):
        """PRO-13: required 가 다수 prop 이면 각 P## 가 적어도 한 variation 에 등장.
        하나라도 누락 시 fail."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        from app.core.errors import AppError
        import pytest
        # P91 만 있고 P93 누락
        shot = self._make_shot(
            required_prop_ids=["P91", "P93"],
            variation_prompts=[
                "Photorealistic still. P91 lies flat.",
                "Wide shot — P91 on the console.",
            ],
        )
        with pytest.raises(AppError) as exc:
            validate_visible_entities_contract(shot, name_by_short_id={})
        assert "P93" in str(exc.value.message)

    def test_pro13_no_required_prop_skips_check(self):
        """PRO-13: required_refs 에 prop 없으면 PRO-13 검사 자체 skip (기존 동작 회귀 0)."""
        from app.core.visible_entities_validator import validate_visible_entities_contract
        shot = self._make_shot(
            required_prop_ids=[],
            variation_prompts=["Photorealistic still. a wide landscape."],
        )
        # 통과 (이 PRO-13 룰 무관)
        validate_visible_entities_contract(shot, name_by_short_id={})
```

- [ ] **Step 5.2: Run tests to verify they fail**

```bash
cd backend
pytest tests/core/test_visible_entities_validator.py::TestPro13PropPidInPrompt -v
```

Expected: 4 FAIL (`step.scene_detail.contract_violation_prop_p_id_missing_in_prompt` 코드 없음) + 1 PASS (no required prop skip).

- [ ] **Step 5.3: Modify validator**

`visible_entities_validator.py` line 664 의 *"producer (build_asset_requirements) does not emit kind='prop' as required_refs"* 주석 폐기 (실제 코드 라인은 build phase 에서 확인).

`validate_visible_entities_contract` 내부, 기존 source 3a (required_refs entry validation) 분기 다음에 신규 PRO-13 룰:

```python
# Patch A v1.2 — PRO-13: required prop P## 가 적어도 한 variation 의 t2i_prompt
# 에 word-boundary 매치로 등장해야 함. attached_meta 검사 없음 (Tier 1 — LLM
# 출력 정합성만). 미등장 시 scene_detail step retry trigger.
import re as _re_p13
_required_props: list[str] = []
for entry in required_refs_list:  # 위에서 normalized 된 list
    if isinstance(entry, dict) and entry.get("kind") == "prop":
        pid = entry.get("id") or ""
        if pid:
            _required_props.append(pid)
if _required_props:
    _all_prompts = "\n".join(
        (v.get("t2i_prompt") or "") for v in t2i_variations
        if isinstance(v, dict)
    )
    _missing: list[str] = []
    for pid in _required_props:
        if not _re_p13.search(rf'\b{_re_p13.escape(pid)}\b', _all_prompts):
            _missing.append(pid)
    if _missing:
        raise AppError(
            code="step.scene_detail.contract_violation_prop_p_id_missing_in_prompt",
            message=(
                f"{shot_label} (PRO-13): required prop P## not in any "
                f"t2i_variation prompt — missing={_missing}. "
                f"Tier 1 fail-fast — scene_detail step retry."
            ),
            status_code=400,
        )
```

- [ ] **Step 5.4: Run tests + commit**

```bash
cd backend
pytest tests/core/test_visible_entities_validator.py -v
git add backend/app/core/visible_entities_validator.py backend/tests/core/test_visible_entities_validator.py
git commit -m "feat(validator): Tier 1 — PRO-13 required prop P## in prompt (Patch A Task 5)"
```

---

### Task 6: Caller Wiring (v1.2 redesign — visible_entity_details propagate)

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:3013-3072` (`build_render_prompt_card`)
- Modify: `backend/app/core/steps/detail_steps.py:2072+` (RPC build caller — entity_lookup → visible_entity_details 구축 + shot text propagate)
- Modify: 그 외 `build_render_prompt_card` caller (Step 6.1 에서 grep)
- Modify: resolver 호출자 — `required_refs` 인자 propagate (Task 4.5 step 4.5.4 와 통합 wiring)
- Test: `backend/tests/unit/test_render_prompt_card_integration.py`

- [ ] **Step 6.1: Trace caller chain**

```bash
grep -rn "build_render_prompt_card(" backend/app/ --include="*.py" | grep -v __pycache__ | grep -v test_
grep -rn "resolve_refs_for_prompt(" backend/app/ --include="*.py" | grep -v __pycache__ | grep -v test_
```

기대 결과:
- `detail_steps.py:2072+` `build_render_prompt_card` 단일 caller.
- `scene_image_pipeline.py` 또는 `scene_image_service.py` 가 `resolve_refs_for_prompt(_set)` 호출자.
- caller 의 entity_lookup source 정확히 식별 → visible_entity_details 구축에 재사용.

- [ ] **Step 6.2: Write integration test**

```python
# tests/unit/test_render_prompt_card_integration.py 의 신규 class

class TestPatchACallerWiring:
    # v1.4 — synthetic fixture (P91/P92, L91/L93, C91/C93). 작품 어휘 0 hits.

    def test_build_rpc_propagates_visible_entity_details(self):
        """build_render_prompt_card 가 visible_entity_details 를
        build_asset_requirements 에 propagate — prop required_refs 까지 도달."""
        from app.core.steps.render_prompt_card import build_render_prompt_card
        card = build_render_prompt_card(
            scene_index=92, shot_index=5,
            seg={"scene_index": 92}, shot_info={
                "shot_description": "closeup of a hand pointing at a 사진."},
            visible_entities=["C91", "P91", "L93"],
            visible_entity_details=[
                {"short_id": "C91", "name": "character_a",
                 "entity_type": "character"},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...black and white photograph..."},
                {"short_id": "L93", "name": "indoor_space_b",
                 "entity_type": "location"},
            ],
            outlook_pairs=[], perception_mode=None, staging=None,
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=True, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
            shot_description="closeup of a hand pointing at a 사진.",
            representative_moment="reveal moment of the photograph",
            t2i_prompts=[],
        )
        required = card["asset_requirements"]["required_refs"]
        assert {"kind": "prop", "id": "P91", "policy": "required"} in required

    def test_resolver_wiring_with_required_refs(self):
        """resolver 호출자가 RPC.asset_requirements.required_refs 를 resolver 에
        전달 + Tier 2 강제 attach 결과를 attached_meta 에 포함."""
        from app.services.scene_reference_service import SceneReferenceService
        import unittest.mock as _mock
        db = _mock.MagicMock()
        db.query.return_value.filter.return_value.all.return_value = []
        svc = SceneReferenceService(db, project_id="proj_synthetic_a")
        # synthetic fixture — visible P91 + scene_ref_image_map[P91 uuid] +
        # required_refs[{kind:prop, id:P91}]
        visible = [
            {"short_id": "P91", "id": "uuid_P91", "name": "photo_prop_a",
             "entity_type": "prop"},
        ]
        scene_ref_image_map = {"uuid_P91": b"\x89PNG_P91"}
        entity_lookup = {"uuid_P91": {"short_id": "P91", "entity_type": "prop",
                                       "name": "photo_prop_a"}}
        required_refs = [{"kind": "prop", "id": "P91", "policy": "required"}]
        # caller chain: RPC.asset_requirements.required_refs → resolver 인자
        labeled_refs, attached_meta = svc.resolve_refs_for_prompt(
            t2i_prompt="a photograph rests on the console",  # P91 ID 없음 (Tier 2 강제 attach)
            visible_entities=visible,
            scene_ref_image_map=scene_ref_image_map,
            entity_lookup=entity_lookup,
            required_refs=required_refs,
        )
        # Tier 2 강제 attach 결과
        assert ("prop", "P91") in attached_meta
        # labeled_refs 의 label 본문에 "P91" 등장 + "(required)" suffix
        assert any("P91" in label and "(required)" in label
                   for label, _ in labeled_refs)
```

- [ ] **Step 6.3: Run tests + commit**

```bash
cd backend
pytest tests/unit/test_render_prompt_card_integration.py::TestPatchACallerWiring -v
git add backend/app/core/steps/render_prompt_card.py backend/app/core/steps/detail_steps.py backend/tests/unit/test_render_prompt_card_integration.py
git commit -m "feat(rpc): propagate visible_entity_details + required_refs to resolver (Patch A Task 6)"
```

---

### Task 7: Integration Test — 5 Regression Guards (placeholder 0)

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

Task 7 는 § 6.1 의 5 regression guards 를 단일 파일에 모은 통합 fixture. 각 fixture 는 실제 PID `02829fe8` / EP `fc38cf03` 의 shot 데이터를 mock 으로 재구성. **placeholder 없음** — 모든 case 실 구현.

- [ ] **Step 7.1: Write 5 regression test cases (full body)**

```python
# tests/integration/test_patch_a_story_prop_binding.py

from app.core.steps.render_prompt_card import build_render_prompt_card
from app.core.ref_contract_validator import (
    validate_attached_refs, _normalize_required_refs, RefContractError,
)
from app.core.visible_entities_validator import validate_visible_entities_contract


def _build_minimal_rpc(*, visible: list, details: list, outlook_pairs: list,
                       bg_id, is_close, shot_desc: str, rep_moment: str,
                       t2i_prompts: list) -> dict:
    """Test helper — visible_entities + visible_entity_details 신규 contract 기준."""
    return build_render_prompt_card(
        scene_index=1, shot_index=1,
        seg={"scene_index": 1}, shot_info={"shot_description": shot_desc},
        visible_entities=visible,
        visible_entity_details=details,
        outlook_pairs=outlook_pairs,
        perception_mode=None, staging=None,
        bg_id=bg_id, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=is_close,
        background_mode_on=bg_id is not None,
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        shot_description=shot_desc,
        representative_moment=rep_moment,
        t2i_prompts=t2i_prompts,
    )


class TestPatchARegressionGuards:
    # v1.4 — synthetic fixture. 작품 어휘 0 hits. 시각적 결함의 contract 의미는 보존:
    # G1 = frame prop required + 3-tier 통과
    # G2 = photo prop required + key prop 등장 매칭 실패 skip
    # G3 = photo prop required + character_outlook 함께 emit (기존 attach 보존)
    # G4 = set-dressing props (category 미매치/등장조건 미충족) 모두 required 미승격
    # G5 = normalizer + validator schema membership (prop kind 통과 + missing 시 fail)

    def test_g1_frame_prop_required_and_in_attached_meta(self):
        """G1: frame prop (P92) required_refs 에 emit (Producer Tier 0).
        prompt 에 P92 ID 가 LLM 출력으로 등장하면 PRO-13 통과. Tier 3 strict subset 통과
        (attached_meta=[(prop, P92)]). Tier 2 강제 attach 분기는 unit test 에서 검증."""
        rpc = _build_minimal_rpc(
            visible=["P92", "L91"],
            details=[
                {"short_id": "P92", "name": "frame_prop_long_name 액자",
                 "entity_type": "prop",
                 "t2i_prompt": "...old desk photo frame..."},
                {"short_id": "L91", "name": "indoor_space_a",
                 "entity_type": "location"},
            ],
            outlook_pairs=[], bg_id=None, is_close=True,
            shot_desc="closeup of a 액자 surface, a face image inside warped into a distortion.",
            rep_moment="distorted face inside a frame",
            t2i_prompts=[],
        )
        assert {"kind": "prop", "id": "P92", "policy": "required"} \
            in rpc["asset_requirements"]["required_refs"]
        # PRO-13 — t2i_prompt 에 P92 등장 시 통과 (Tier 1)
        shot_with_pid = {
            "scene_index": 91, "_shot_index": 14,
            "visible_entities": ["P92", "L91"],
            "t2i_variations": [{"t2i_prompt": "Photorealistic still. P92 lies on table."}],
            "render_prompt_card": rpc,
        }
        validate_visible_entities_contract(shot_with_pid, name_by_short_id={})
        # Tier 3 — attached_meta 에 P92 있으면 strict subset 통과
        validate_attached_refs(
            rpc=rpc, labeled_refs=[("object P92", b"p")],
            attached_meta=[("prop", "P92")],
            prompt="Photorealistic still. P92 lies on table.",
            is_close_framing=True,
        )

    def test_g2_photo_prop_required_and_key_prop_skipped(self):
        """G2: photo prop (P91) required + key prop (P97) 은 shot text 미등장 → skip."""
        rpc = _build_minimal_rpc(
            visible=["P91", "P97", "L93"],
            details=[
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...black and white photograph..."},
                {"short_id": "P97", "name": "key_prop_long_name 키",
                 "entity_type": "prop",
                 "t2i_prompt": "...key with compass markings..."},
                {"short_id": "L93", "name": "indoor_space_b",
                 "entity_type": "location"},
            ],
            outlook_pairs=[], bg_id=None, is_close=True,
            shot_desc="closeup of a finger pointing at one figure in a 사진.",
            rep_moment="reveal moment of the photograph",
            t2i_prompts=[],
        )
        assert {"kind": "prop", "id": "P91", "policy": "required"} \
            in rpc["asset_requirements"]["required_refs"]
        # P97 은 등장 매칭 실패 → 미포함
        assert not any(r.get("id") == "P97"
                       for r in rpc["asset_requirements"]["required_refs"])

    def test_g3_photo_prop_required_with_character_outlook_preserved(self):
        """G3: photo prop (P91) shot text 등장 → required emit + 기존
        character_outlook (C93O92) 도 함께 emit (회귀 0)."""
        rpc = _build_minimal_rpc(
            visible=["C93", "P91", "L92"],
            details=[
                {"short_id": "C93", "name": "character_c", "entity_type": "character"},
                {"short_id": "P91", "name": "photo_prop_long_name 사진",
                 "entity_type": "prop",
                 "t2i_prompt": "...black and white photograph..."},
                {"short_id": "L92", "name": "outdoor_space_a",
                 "entity_type": "location"},
            ],
            outlook_pairs=[{"character_id": "C93", "outlook_id": "O92"}],
            bg_id=None, is_close=True,
            shot_desc="closeup of character C with widened eyes facing a 사진.",
            rep_moment="character reacting to the photo",
            t2i_prompts=[],
        )
        rr = rpc["asset_requirements"]["required_refs"]
        assert {"kind": "prop", "id": "P91", "policy": "required"} in rr
        assert {"kind": "character_outlook", "id": "C93O92", "policy": "required"} in rr

    def test_g4_set_dressing_props_not_over_attached(self):
        """G4: set-dressing prop (phone P94 / backpack P95 / marking P96) 은
        카테고리 미매치 또는 등장조건 미충족으로 required 미승격. over-attach 차단."""
        rpc = _build_minimal_rpc(
            visible=["P94", "P95", "P96", "L91"],
            details=[
                {"short_id": "P94", "name": "set_dressing_phone",
                 "entity_type": "prop", "t2i_prompt": "...smartphone..."},
                {"short_id": "P95", "name": "set_dressing_backpack",
                 "entity_type": "prop",
                 "t2i_prompt": "...backpack with doll charm..."},
                {"short_id": "P96", "name": "set_dressing_marking",
                 "entity_type": "prop", "t2i_prompt": "...red circular mark..."},
                {"short_id": "L91", "name": "indoor_space_a",
                 "entity_type": "location"},
            ],
            outlook_pairs=[], bg_id=None, is_close=False,
            shot_desc="character walking down an alley with a backpack on their shoulder.",
            rep_moment="departure scene",
            t2i_prompts=[],
        )
        # 어떤 prop 도 required 로 승격되지 않음
        assert not any(r.get("kind") == "prop"
                       for r in rpc["asset_requirements"]["required_refs"])

    def test_g5_schema_normalizer_and_validator_membership(self):
        """G5: _normalize_required_refs 가 prop kind 정상 처리 + validate_attached_refs
        가 prop strict subset 검사 + missing 시 RefContractError."""
        normalized = _normalize_required_refs(
            [{"kind": "prop", "id": "P91", "policy": "required"}]
        )
        assert normalized == {"prop": ["P91"]}
        # missing 시 fail
        import pytest
        with pytest.raises(RefContractError):
            validate_attached_refs(
                rpc={"asset_requirements": {
                    "required_refs": [{"kind": "prop", "id": "P91", "policy": "required"}],
                    "forbidden_refs": [], "readiness_policy": "block_if_missing",
                }},
                labeled_refs=[], attached_meta=[],
                prompt="photograph", is_close_framing=True,
            )
```

- [ ] **Step 7.2: Run tests + commit**

```bash
cd backend
pytest tests/integration/test_patch_a_story_prop_binding.py -v
git add backend/tests/integration/test_patch_a_story_prop_binding.py
git commit -m "test(integration): Patch A 5 regression guards full body (Patch A Task 7)"
```

---

### Task 8 (v1.4 redesign): Repo Code Residue Grep Gate — 시나리오 의존성 0 강제

**Files:**
- (no source code change — verification step only, **commit 0**)
- ~~`backend/scripts/check_patch_a_residue.sh`~~ **v1.4 제거** (script 본문에 시나리오 token blocklist 박아넣으면 그 자체가 시나리오 의존적 코딩. one-off command 로만 실행)

목적: implementation merge / canary 진입 직전 mandatory gate. CLAUDE.md `scenario_dependency_ban` 원칙을 **repo code 전반** (production + tests + scripts + prompts) 에 강제. v1.4 BLOCKING 1/2/3 통합 fix.

- [ ] **Step 8.1: Token blocklist 정의 (plan 본문 inline — single source-of-truth, no committed script)**

다음 token group 이 repo code (manual canary docs 제외 전체) 에 등장하면 fail:

| Group | Tokens | 사유 |
|---|---|---|
| 작품 캐릭터명 (KO) | 수리영, 혜수, 민숙, 인우 | 시나리오-specific proper noun |
| 작품 location 명 (KO) | 금월도, 시골 식당, 둥근 원, 두 소녀, 옥탑방, 조타실, 갑판 | 시나리오 manual canary 어휘 |
| 작품 prop 묘사 descriptor (KO) | 낡은 흑백, 흑백 사진, 낡은 사진, 낡은 액자 | descriptor 잔재 (category-only 위반) |
| 결함 shot ID | S16, S27, S26 | 시나리오 shot identifier |
| 작품 entity short_id | P02, P03, P17 | 시나리오 entity identifier (단 P##, C## placeholder literal 및 synthetic P91~P99 range 는 OK) |

**허용 (false-positive 방지) — scope 별 차등 적용 (v1.4 명시)**:

| 어휘 | `backend/app/**` (production) | `backend/tests/**` (test fixture) | `backend/scripts/**`, `prompts/**` |
|---|---|---|---|
| schema placeholder literal (`P##`, `C##`, `L##`, `O##`, `kind='prop'`, `kind="prop"`) | ✓ (docstring/주석 placeholder 형태) | ✓ | ✓ |
| **synthetic test range** (`P91`~`P99`, `C91`~`C99`, `L91`~`L99`, `O91`~`O99`) | ✗ **금지** — production 에 들어가면 결국 시나리오-specific 어휘. test scope 안에만 허용 | ✓ | ✗ |
| 카테고리 noun (`photo`/`photograph`/`picture`/`frame`/`document`/`paper`/`letter`/`map`/`key`/`사진`/`액자`/`문서`/`종이`/`편지`/`지도`/`열쇠`/`키`) | ✓ | ✓ | ✓ |
| 일반 영어 어휘 (descriptor 무관) | ✓ | ✓ | ✓ |
| 작품 entity short_id (P02/P03/P17/S16/S27/S26 등) | ✗ | ✗ | ✗ |
| 작품 캐릭터명/location/descriptor | ✗ | ✗ | ✗ |

production code 에 synthetic test range (P91~P99 등) 가 등장하면 step 8.4 failure handling 적용: 해당 token 을 schema placeholder literal (`P##`) 또는 docstring 추상 형태로 교체. test fixture 영역만 P91~P99 사용 OK.

- [ ] **Step 8.2: Run gate — 2-stage (v1.4.1 BLOCKING 2 fix)**

gate 를 2 stage 로 분리. **Stage 1** (scenario tokens) 은 모든 path 금지. **Stage 2** (synthetic IDs P91~P99 등) 은 production / scripts / prompts 만 금지, tests 는 허용.

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1

# ===== Stage 1: scenario tokens — backend/app + backend/tests + backend/scripts + prompts 전부 금지 =====
SCENARIO_FORBIDDEN='수리영|혜수|민숙|인우|금월도|시골 식당|둥근 원|두 소녀|옥탑방|조타실|갑판|낡은 흑백|흑백 사진|낡은 사진|낡은 액자|S16\b|S27\b|S26\b|\bP02\b|\bP03\b|\bP17\b'
rg -n "$SCENARIO_FORBIDDEN" backend/app backend/tests backend/scripts prompts 2>/dev/null
echo "stage 1 exit: $?"  # 0 hits → rg exit=1, 1+ hits → rg exit=0

# ===== Stage 2: synthetic IDs (P91~P99/C91~C99/L91~L99/O91~O99) — production/scripts/prompts 만 금지, tests 허용 =====
SYNTHETIC_FORBIDDEN='\b[PCLO]9[1-9]\b'
rg -n "$SYNTHETIC_FORBIDDEN" backend/app backend/scripts prompts 2>/dev/null
echo "stage 2 exit: $?"  # 0 hits → rg exit=1
```

Expected: **양 stage 모두 0 hits** (rg exit code = 1, "no matches"). `\b` boundary 가 false-positive substring 매치 차단.

- [ ] **Step 8.3: Staged-files gate (pre-commit incremental)**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
SCENARIO_FORBIDDEN='수리영|혜수|민숙|인우|금월도|시골 식당|둥근 원|두 소녀|옥탑방|조타실|갑판|낡은 흑백|흑백 사진|낡은 사진|낡은 액자|S16\b|S27\b|S26\b|\bP02\b|\bP03\b|\bP17\b'
SYNTHETIC_FORBIDDEN='\b[PCLO]9[1-9]\b'

# Stage 1 — scenario tokens (모든 path)
git diff --name-only --cached -- backend/app backend/tests backend/scripts prompts \
  | xargs -I{} rg -n "$SCENARIO_FORBIDDEN" {} 2>/dev/null
echo "stage 1 staged exit: $?"

# Stage 2 — synthetic IDs (production/scripts/prompts 만, tests 제외)
git diff --name-only --cached -- backend/app backend/scripts prompts \
  | xargs -I{} rg -n "$SYNTHETIC_FORBIDDEN" {} 2>/dev/null
echo "stage 2 staged exit: $?"
```

Expected: **양 stage stdout 모두 비어있음**. 1+ hits 발견 시 해당 file 의 token 을 적절히 교체:
- Stage 1 hit → synthetic 어휘 (P91 등) 로 교체 (test 안) 또는 schema placeholder (P##) literal 로 교체 (production code).
- Stage 2 hit (production file) → schema placeholder literal (`P##`) 또는 docstring 추상 형태로 교체.

- [ ] **Step 8.4: Failure handling**

만약 step 8.2 또는 8.3 에서 1+ hits 발견:
- 위치의 token 을 synthetic 어휘로 교체:
  - `수리영` / `민숙` / `인우` 등 → `C91` / `C92` / `C93` (synthetic character)
  - `옥탑방` / `조타실` / `갑판` → `L91` (indoor space) / `L92` (outdoor space) 등
  - `낡은 흑백 사진` / `낡은 액자 사진` → `P91 (photo prop)` / `P92 (frame prop)` synthetic
  - `S16` / `S27` / `S26` → 임의 scene_index (예: 91, 92, 93)
  - `P02` / `P03` / `P17` → `P91` / `P92` / `P97` (synthetic)
- 교체 후 step 8.2/8.3 재실행 → 0 hits 확인.
- manual canary docs (`docs/superpowers/plans/**`) 의 작품 fixture 는 보존 (gate target 밖 — 사용자 검증 ground truth).

- [ ] **Step 8.5: No commit (verification step only)**

본 Task 8 은 verification gate 라 별도 commit 없음. residue 0 hits 확인 후 implementation merge 진입 OK.

---

## 6. Regression Guards (사용자 §4)

### 6.1 Guard list

| ID | Guard | Test | Pass criterion |
|---|---|---|---|
| G1 | S16/14: P03 required + attach | Task 7 G1 | RPC `required_refs` 에 `{"kind":"prop","id":"P03"}` + resolver 가 P03 ref 를 attached_meta 에 추가 |
| G2 | S27/5: P02 required + attach | Task 7 G2 | RPC `required_refs` 에 `{"kind":"prop","id":"P02"}` + attached_meta `("prop","P02")` |
| G3 | S26/6: 기존 P02 attach 정상 보존 | Task 7 G3 | RPC build 결과 + resolver output 이 patch 전과 동등 (P02 ref 동일하게 attached) |
| G4 | 잡동사니 props (P01 스마트폰, P04 백팩 등) over-attach 안 됨 | Task 7 G4 | RPC `required_refs` 에 prop entry 0 |
| G5 | `kind='prop'` schema/validator membership check | Task 7 G5 | normalize/validate 모두 prop kind 정상 처리, missing 시 fail-fast |

### 6.2 broader regression

```bash
# Patch A 완료 후 전체 회귀 0 검증
cd backend
pytest -x  # 깨진 케이스 0 보장
```

### 6.3 manual canary 검증 (코드 review 통과 후)

이 plan 의 Task 1-7 코드가 review 통과한 후, 다음 절차로 **실제 데이터 단건 재생성** 진행 (사용자 §5):

1. **S26/6 checkpoint regression 검증 (deterministic 3-step trigger, v1.2 redesign)**

   PID `02829fe8` / EP `fc38cf03` 의 S26/6 shot 에 대해 RPC re-build 만 실행해 다음을 mechanically 분리 검사:

   **Trigger A — `required_refs` shape 변경만 (예: prop kind entry 추가)** → **재생성 X**.
   - 새 prop kind entry 추가가 기존 character_outlook / background entry 와 독립적이고, attached_meta 변화 없음, hash 변경 없음 (단, hash 변경 cp invalidation 시 force re-run 만 필요 — 이미지 재생성 아님).
   - 통과 판정: required_refs 에 신규 `(prop, P02)` entry 만 추가됐고, attached_meta diff = ∅.

   **Trigger B — `attached_meta` entry 변화 (추가/삭제/순서/identity)** → **단건 이미지 재생성**.
   - 예: Tier 2 강제 attach 분기가 작동해 ("prop", "P02") 가 attached_meta 에 신규 추가됐거나, 기존 attach 가 사라졌거나, identity 가 변경된 경우.
   - 시각 결과 변경 가능성 ↑ → 실제 이미지 재생성 후 시각 동등성 확인.

   **Trigger C — 단건 이미지 재생성 결과 시각 동등성 미충족** → **patch A revision**.
   - 동등성 기준: P02 사진 framing / 인우 표정 / 사진 정면 향 방향 동일.
   - 미충족 시 plan v4 로 escalate + 사용자 결재.

   ⚠️ 셋 모두 deterministic — "필요 시"가 아니라 위 A/B/C trigger 정확 분류.

   ⚠️ **v1.3 명시 (IMPORTANT 2 fix)** — 본 단락의 manual canary 검증 표현 (예:
   "P02 canonical (시골 식당 + 둥근 원 + 두 소녀)", "인우 표정", "사진 정면 향 방향")
   은 **plan / 시각 검증 내부 어휘** 이다. **prompt 문구로 복사 절대 금지**.
   production code (`backend/app/**`), prompt packs (`prompts/**`) 어디에도 들어가면
   안 된다. CLAUDE.md `scenario_dependency_ban` 적용 — Task 8 grep gate 가 강제.
2. **S16/14 단건 재생성**
   - scene_image_pipeline 으로 S16/14 만 force re-run. 결과 PNG 에서 액자가 frame 내에 온전히 보이는지 + P03 reference content (두 여성) 보존 확인.
3. **S27/5 단건 재생성**
   - 동일 절차. 결과 PNG 에서 P02 canonical (시골 식당 + 둥근 원 + 두 소녀) 보존 확인.

(S11, S26, S28 의 단건 재생성은 Patch B/C/D 적용 후로 보류.)

---

## 7. P3 Todo (이 Patch 범위 밖 — 미래 phase)

다음 항목은 Patch A 완료 후 별도 plan 에서 다룬다:

- **prop_role LLM tagger step**: entity_extractor 에 `prop_role: ('story_critical'|'set_dressing')` LLM 분류 step 추가. 이 step 도입 후 §2.1 의 hardcoded category 폐기.
- **resolver alias 확장 (G7)**: `scene_reference_service.py:544-587` 의 prop 매칭 후보를 `prop_name + entity_canon.description noun phrase + entity_alias` 까지 확장.
- **SOT-prompt semantic alignment validator (좁은 deterministic)**: entity_canon.t2i_prompt 의 `\bno\s+(vehicles?|cars?|boats?|...)` ↔ shot t2i_prompt 의 positive form 매치 시 fail (Patch D 영역).

---

## 8. Self-Review (writing-plans skill checklist)

### 8.1 Spec coverage (사용자 §1-§5)

- §1 Scope (in/out) — § 1.1-1.2 cover ✓
- §2 Story-critical prop 판정 — § 2.1-2.3 + Task 1 cover ✓
- §3 Contract (P## 유지 / common noun / required_refs / fail-fast) — § 3.1-3.4 + Task 2-5 cover ✓
- §4 Regression guards — § 6.1-6.3 + Task 7 cover ✓
- §5 Canary execution — § 6.3 cover ✓

### 8.2 Placeholder scan (v1.4 — placeholder 0, synthetic fixture 적용)

v1.4 redesign 에서 모든 placeholder 풀어쓰기 + tests 까지 synthetic fixture 전환:

- Task 1 본문: 9 test case 모두 synthetic fixture (P91~P99, L91~L99, C91~C99) ✓
- Task 2 본문: 6 test case 모두 synthetic fixture + visible_entities (string list) 보존 ✓
- Task 4 본문: 5 test case 모두 synthetic (P91~P93 + C93O92) — Tier 3 strict subset ✓
- Task 4.5 본문: 4 test case 모두 synthetic + 실 body (Tier 2 forced attach + mock-based resolver) ✓
- Task 5 본문: 5 test case 모두 synthetic (P91/P93) + full PRO-13 ✓
- Task 6 본문: 2 test case 모두 synthetic + 실 body (caller wiring + resolver propagation) ✓
- Task 7 본문: 5 regression guards 모두 synthetic + 실 body (G1~G5 contract 의미 보존) ✓
- Task 8 본문: residue gate (no source code, verification only) — synthetic IDs 는 tests scope 만 허용 ✓

placeholder `pass` body 잔재: **0건**. 작품 어휘 (수리영/민숙/인우/옥탑방/조타실/갑판/낡은 흑백 사진/낡은 액자 사진/S16/S27/S26/P02/P03/P17) 잔재: **0 hits in test code** (Task 8 grep gate 검증 대상).

### 8.3 Type consistency (v1.2 redesign 후)

- `visible_entities: List[str]` (sid string list) — **v1.2 보존**. 모든 caller (`build_render_prompt_card`, `build_id_policy`, `build_asset_requirements`, `detail_steps.py:2072+`) 시그니처 변경 없음.
- `visible_entity_details: List[Dict[str, Any]]` (신규 인자) — `build_render_prompt_card` / `build_asset_requirements` 에만 적용. `build_id_policy` 는 받지 않음 (sid list 만 필요).
- `story_critical_prop_filter` 시그니처: `visible_entity_details: list[dict]` (helper internal name 일관). 반환 shape `list[dict]` with `{short_id, id_or_name_evidence}`. Task 2 에서 `sp.get("short_id")` 로 소비 — 일관 ✓.
- `kind='prop'` entry shape: `{kind, id, policy}` — character_outlook 과 동일 ✓.
- `required_refs` 인자 (resolver Task 4.5 신규): `Optional[List[Dict[str, Any]]] = None` (default None = legacy 동작 보존).
- Error class: scene_detail Tier 1 fail = `AppError(code="step.scene_detail.contract_violation_prop_p_id_missing_in_prompt")`. Tier 3 fail = `RefContractError` (기존 ref_contract_validator 패턴 일관) — `AppError` 아님.

---

## 9. Execution Handoff (v1.4)

**Plan v1.4 final 도달 — `docs/superpowers/plans/2026-05-11-visual-qa-story-prop-binding-plan.md`.**

전제 (사용자 결재 직전 확인 사항):
- ✅ visible_entities (sid string list) 시그니처 보존 + visible_entity_details 별도 인자
- ✅ validate_attached_refs 대상 (Tier 3), validate_visible_entities_contract PRO-13 (Tier 1), resolver Tier 2
- ✅ test fixture 시나리오 어휘 0 (synthetic P91~P99 / C91~C99 / L91~L99)
- ✅ placeholder `pass` body 0
- ✅ Task 8 changed-files based residue gate
- ✅ id_policy 의 face vs physical prop split

implementation 진행 옵션:

**Option 1 — Subagent-driven (권장)**:
- superpowers:subagent-driven-development 패턴. Task 1 부터 fresh subagent 디스패치 + 각 task 완료 시 two-stage review.
- 메모리 패턴 (subagent-driven 으로 Phase 5 / Phase 7 등 진행한 전례) 일관.
- task isolation 으로 context window 보호.

**Option 2 — Inline executing-plans**:
- superpowers:executing-plans 패턴. Task 1 ~ Task 8 순차 inline. 사용자가 checkpoint 마다 review.
- 단일 conversation 안에서 진행 — review iteration 빠름. context window 사용 큼.

**Option 3 — Hybrid**:
- Task 1~5 (helper + producer + validator) inline 진행 후 commit. Task 6/7 (caller wiring + integration) subagent. Task 8 gate inline 최종 검증.

implementation 진입 전 mandatory step:
1. **Task 8 grep gate** (Step 8.2): 5 production paths 대상 0 hits 확인.
2. **사용자 final 결재**: plan v1.4 통독 + APPROVED.
3. (선택) **Codex review iter 4**: v1.4 를 codex 에 던져 BLOCKING 단독 확인.
