# G4.2 Background-Binding Lift Implementation Plan

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

**Goal:** `build_background_binding()` 가 G4.2 spec §7.2 의 작업 순서에 따라 `_card_metadata.lift_status` / `_card_metadata.rule_source` 작성 + sub-case D fallback constraint + `skipped_close_framing` constraints 에 R1-I2 6 ground-truth + R2-B3 Rule A 4 forms 추가 + `detail_steps.py` inject 직전 `_card_metadata` strip (R2-I4). v17 prompt 신규 디렉토리에 Rule A/C/E prose 삭제 + "Background Binding" compact section 신설. canary scripts 3종 작성. unit + integration test suite 작성. 단일 commit + 듀얼 리뷰.

**Architecture:** render_prompt_card.py producer-side: `build_background_binding()` 에 `_card_metadata` 작성 로직 추가 (sub-case D fallback, 6+4 constraint strings). detail_steps.py consumer-side: inject 직전 `_card_metadata` key strip (G4.1 injects full card — G4.2 strips debug-only field before LLM). prompt-side: prompts/_base/scene_detail/17.<timestamp>/system.md 신규 생성 (v16 Rule A/C/E prose 83 lines 삭제 → 22-line "Background Binding" compact section). canary scripts: 3종 독립 스크립트 (`g4_2_camera_wording.py` / `g4_2_close_forbidden.py` / `g4_2_token_count.py`). tests: 22 unit + 9 integration.

**Spec:** `docs/superpowers/specs/2026-05-04-g4.2-background-binding-lift-design.md` (771 lines, R1+R2 audit complete, 17 Override rows). G4.2 entry condition 충족 (G4.1 commit 5d045f3 + c656ce3 + 86473df green).

**Out of scope:** ID-policy lift (G4.3) / continuity lift (G4.4) / prompt slimming (G4.5) / background_prompt step / chain_bg loader / G3.2 sentinel logic / scene_image_pipeline.

---

## Round Override (audit-driven, applied before Task body)

**Round 1 Override (spec R1 rows — 1 BLOCKING + 7 IMPORTANT + 2 MINOR)** — 본 plan 의 기존 task 내용보다 **우선** 적용.

| 항목 | task 영향 |
|---|---|
| **R1-B1** | Task 1.1 — `_card_metadata.lift_status` + `_card_metadata.rule_source` 둘 다 envelope top-level sibling으로 격리, hash payload 에서 제외. `canonicalize_render_prompt_card()` (render_prompt_card.py:563-566) 가 `_card_metadata` top-level pop — 이미 구현됨. G4.2 에서 `build_render_prompt_card()` wrapper 가 두 sub-field 채워 넣는 것이 신규 작업. |
| **R1-I1** | Task 1.2 — `build_background_binding()` 의 sub-case D (`bg_owned==[] and cam_ref==None and mode==background_ref_attached`) 에 fallback constraint 1 row 추가. 현재 G4.1 코드는 이 분기에서 constraints=[] 빈 list 반환 → fallback missing. |
| **R1-I2** | Task 1.2 — `skipped_close_framing` constraints 에 6 ground-truth pattern 모두 명시. §9 Glossary single source reference. |
| **R1-I3** | Task 4.1 — canary `g4_2_camera_wording.py` 의 regex 4종: `r"match.*reference.*(camera|framing|position)"`, `r"same.*(angle|position).*reference"`, `r"(push in|pull out|deviation).*from.*reference"`, `r"reference.*camera_position"`. non-close framing shot 만 scan (R2-B4). |
| **R1-I4** | Task 4.2 — canary `g4_2_close_forbidden.py` 에서 close-framing forbidden wording count == 0 strict exit criteria. close framing shot 만 scan (R2-B4). |
| **R1-I5** | Task 4.1 / 4.2 — canary JSON pinning block 7 field 필수: `pid`, `scene_index_list`, `shot_index_list_per_scene`, `model_routing`, `prompt_source_mode`, `card_commit_hash`, `chain_bg_card_snapshot_hash`. |
| **R1-I6** | Task 1.3 — `CARD_SCHEMA_VERSION` 유지 (= 1). `SCENE_DETAIL_SCHEMA_VERSION` 유지 (= 7). `_card_metadata` 추가는 hash 외 envelope 외 debug-only → backward compat. |
| **R1-I7** | Task 5.1 — `test_not_applicable_constraints_no_reference_language` + `test_not_applicable_card_metadata_lift_status` unit test 추가. Task 5.2 — `test_not_applicable_shot_card_carries_no_owned_no_camera_no_reference` integration test 추가. |
| **R1-M1** | Task 4.3 — `g4_2_token_count.py` 가 `tiktoken cl100k_base` 로 실측. canary JSON 에 `system_prompt_token_count_tiktoken_cl100k` field. |
| **R1-M2** | Task 5.1 risk register test 에서 §8.1 G4.2-unique / §8.2 carry 구분. test docstring 에 해당 risk ID reference. |

**plan R1 Override (audit-driven, applied before Task body — 2 BLOCKING + 9 IMPORTANT)** — plan 본문 결정 모두 본 표 우선 적용.

| 항목 | 결정 |
|---|---|
| **plan-R1-B1** | Task 2.1 의 strip insertion 은 inject 직전 1 point 한정. `_g41_render_prompt_card` 원본은 CP 저장 path 에서 `_card_metadata` 포함 상태로 유지 (CP 저장 위치는 별도 — G4.1 의 `_analyze_one()` return / save_checkpoint write path). plan Task 2.1 본문에 "CP 저장은 본 strip 영향받지 않음 — `_g41_render_prompt_card` 변수 자체는 변경 X, inject 용 별 dict copy 만" 명시. |
| **plan-R1-B2** | Rule C `owned_violations_total` 측정용 4번째 canary script 신설 — `scripts/canary/g4_2_owned_violations.py` (Task 4.4 신규). G3.2 sentinel CP 의 `owned_validation.violations` list count 합산. baseline / candidate JSON 에 `owned_violations_total` + `owned_violations_per_shot` 출력. spec §5.3 unified schema 와 일관. |
| **plan-R1-I1** | Task 4.3 token canary JSON 에 `pinning` block 추가 (R2-B6 1:1 일치). token-only 라도 pinning field 7종 명시. baseline/candidate 비교 가능. |
| **plan-R1-I2** | integration test count 통일 = **10** (Task 5.2 header / 본문 / Phase 5 summary / Task 7.1 commit message 모두 동일). spec §6.2 base 9 + R1-I7 not_applicable 1 = 10. `test_v15_checkpoint_escalates_on_v17_resume` 는 G4.1 carry 라 G4.2 plan 에 추가 X (G4.1 integration 에 이미 존재). |
| **plan-R1-I3** | SCENE_DETAIL_PROMPT_VERSION pin: **`backend/app/core/steps/detail_steps.py:105`** — `SCENE_DETAIL_PROMPT_VERSION = "16.202605041200"`. Task 2.2 가 line 105 의 string literal 만 변경 (line 713 사용처는 그대로 — config_hash 는 자동 변경 반영). |
| **plan-R1-I4** | unit test fixture `_make_full_card()` 에 `_DEFAULT_SENTINEL = object()` pattern 적용. `bg_owned=_DEFAULT_SENTINEL` 로 받아 `if bg_owned is _DEFAULT_SENTINEL: bg_owned = ["door", "window"]` 처리 — None 과 [] 와 default 셋 구분. G4.1 R3-B2 fixture trap 회피. |
| **plan-R1-I5** | Task 2.1 modify-scope guard 명시: G4.1 의 `_g41_locals = locals()` (`detail_steps.py:1839`) 는 Point 1 직후 snapshot — strip insertion 은 Point 3 직전 (line 1937-1942) 이므로 `_g41_locals` 영향 없음. `_g41_locals` 본체 변경 금지. plan Task 2.1 본문에 명시. |
| **plan-R1-I6** | SCHEMA_VERSION 유지 검증: `backend/app/core/step_manifest.py:632` 의 `scene_detail.schema_version = 7` 변경 없음. alignment test (`backend/tests/prompts/test_scene_detail_id_policy_alignment.py` 또는 동급) green 유지 검증 — Task 5.3 regression check 에 포함. |
| **plan-R1-I7** | Task 2.2 dependency 명시: Phase 3 Task 3.1 (v17 디렉토리 생성 + `<timestamp>` 결정) 완료 *후* Task 2.2 진입. plan 의 Phase 2 / Phase 3 numbering 유지 (Wave 1-A render_prompt_card.py / Wave 1-B v17 prompt parallel — Task 2.2 는 Wave 2 의 마지막 작업으로 Phase 3 timestamp 결정 후 실행). Phase 2 헤더에 cross-wave dependency note 추가. |
| **plan-R1-I8** | unit test count = **23** 확정 — `test_card_hash_stable_across_lift_status_change` 를 22번 다음 별도 23번 row 로 추가. R2-M1 의 "최소 22" 만족. Phase 5 expected count = +33 (23 unit + 10 integration). |
| **plan-R1-I9** | baseline PID 결정 절차: Task 6.1 에서 사용자 confirm 단계 명시. plan 본문에 후보 PID enumeration 금지 (시나리오 의존 차단 + 실행 시점 사용자 결정). pinning.pid 는 사용자가 canary 실행 시 입력. 후보 source: G3.x 검증된 PID memory (`next_session_*` 계열) 또는 신규 PID. |

### plan R1 메타룰

- 본 표 가 plan §Phase / Task 본문보다 우선 적용.
- Task 본문 동기화는 본 round 패치에서 inline 수행 (G4.1 R1 패턴 답습).
- spec ↔ plan drift 가 추가 발견되면 plan R2 round 진행.

---

**plan R2 Override (audit-driven, 4 BLOCKING + 6 IMPORTANT)** — plan R1 보다 **우선** 적용. spec back-propagate 동반.

| 항목 | 결정 |
|---|---|
| **plan-R2-B1** | unit test count = **23** 단일 진실 (plan-R1-I8 carry 강화). Task 5.1 header / R2-M1 row / Task 5.3 regression count 모두 23 통일. Task 5.3 "2025+31" → "2025+33" 정정 (23 unit + 10 integration). |
| **plan-R2-B2** | spec §6.2 v15 escalation row 가 plan-R1-I2 로 G4.2 plan 에서 제외 (G4.1 carry — `test_g4_1_card_consumer_wiring.py::test_step_runner_actually_escalates_to_force_on_v15_resume` 이미 존재). spec §6.2 본문에 inline note "v15 escalation row 는 G4.1 carry — G4.2 plan 에 미추가" 추가. plan Task 5.2 prose 도 동일 cross-reference. |
| **plan-R2-B3** | **spec §5.3 + §7.2 back-propagate (spec drift fix inline)**: spec §7.2 step 4 의 canary scripts 3종 → **4종** (`g4_2_owned_violations.py` 추가). spec §5.3 의 `measurement_scripts` block 에 `owned_violations` script reference 추가. spec §5.3 metrics block 의 `card_token_count_avg_tiktoken_cl100k` field 는 **제거** (산출 책임 없음 — 별 측정 불필요). |
| **plan-R2-B4** | (plan-R2-B3 와 통합 — spec §7.2 step 4 4-script 명시 후 본 row 자동 해결) plan Task 4.4 + commit table + commit message 모두 4 scripts 일관. |
| **plan-R2-I1** | Task 4.4 + Task 6.3 의 owned violation 비교는 **absolute count** (rate 아님). 근거: pinning 7 field 가 baseline / candidate scene-set / shot-count 동일 보장 — 동일 분모이므로 absolute count = rate equivalent. plan Task 4.4 / Task 6.3 본문에 명시. spec §5.3 R2-I3 rate 표현은 "동일 pinning 하 absolute count = rate" 로 명확화 (spec §5.1 inline note). |
| **plan-R2-I2** | Data Flow comment "5 strings: R1-I2 6 patterns + R2-B3 4 forms" → **"5 constraint strings (string 3 = R1-I2 6 patterns inline, string 4 = R2-B3 Rule A 4 forms inline)"** 로 명확화. |
| **plan-R2-I3** | spec / plan 의 "envelope contract 5 field" → **"7 contract field"** 정정 (spec carry — `_REQUIRED_TOP_FIELDS` 가 7 entries: schema_version / shot_key / render_strategy / id_policy / background_binding / continuity_elements_used / asset_requirements). plan Task 1.3 docstring 갱신. spec §2.2 + §7.2 step 3 + R2-I5 row 모두 동일 정정. |
| **plan-R2-I4** | Task 4.3 token canary CLI 에 pinning 7 args 추가: `--pid <PID>`, `--scene-index-list <list>`, `--shot-index-list-per-scene <dict>`, `--model-routing <model>`, `--prompt-source-mode <file\|db>`, `--card-commit-hash <hash>`, `--chain-bg-card-snapshot-hash <hash>`. CLI 가 stdin 또는 별 config file 지원도 가능. Task 6.3 비교 시 token JSON 의 pinning block 도 동일 검증 대상. |
| **plan-R2-I5** | Task 4.4 `g4_2_owned_violations.py` 가 sentinel 합산 전에 **`assert_owned_sentinel_shape()` 호출 필수** (`backend/app/core/steps/_owned_helpers.py` carry). sentinel missing / shape 위반 시 measurement 실패로 처리 (silent 0 false-pass 차단). false-pass 발생 시 baseline / candidate 둘 다 동일 분기 — 비교 불가능 처리. |
| **plan-R2-I6** | Task 2.2 commit target 에 **`backend/app/core/version_registry.py`** 추가 (line 34 의 `MODULE_VERSIONS["scene_detail_composer"]` minor bump + line 125-127 의 `_MODULE_INFO["scene_detail_composer"].prompt_dependency` v17 갱신). alignment test (`backend/tests/prompts/test_scene_detail_id_policy_alignment.py:338-351, 374-391`) green 유지. Task 5.3 regression check 에 명시. |

### plan R2 메타룰

- 본 표 가 plan R1 row 와 §Phase / Task 본문보다 우선.
- spec back-propagate (pB3) 는 plan R2 patch 와 동일 commit 에서 진행 (spec drift 차단 + 세션 분기점 일관성).
- 추가 audit 결과 발생 시 plan R3 round 진행.

---

**Round 2 Override (spec R2 rows — 6 BLOCKING + 5 IMPORTANT + 1 MINOR)** — Round 1 보다 **우선** 적용.

| 항목 | task 영향 |
|---|---|
| **R2-B1** | Task 1.1 — envelope mode-by-mode JSON snippet 에서 `_card_metadata` 표기 금지. `canonicalize_render_prompt_card()` 가 top-level pop 만 수행 — nested 구조 도입 금지. `_card_metadata.lift_status` matrix 는 별도 dict 로만. |
| **R2-B2** | Task 1.1 / 1.2 — `build_background_binding()` 내부 로직 변경은 G4.2 in-scope. sub-case D fallback / `_card_metadata` 작성 / 6+4 constraint strings 모두 build 함수 내부 변경. |
| **R2-B3** | Task 1.2 — `skipped_close_framing` constraints 에 Rule A close-framing 무효화 4 forms 추가: `"matching the reference camera"` / `"deviation from reference"` / `"same … angle as the reference"` / `"match the reference framing"`. §9 Glossary single source. |
| **R2-B4** | Task 4.1 / 4.2 — metric scope 상호 배타: camera wording = non-close framing shot 만, forbidden wording = close framing shot 만. 동일 prompt 가 두 metric 에 동시 포함 금지. |
| **R2-B5** | Task 4.2 / Task 6.3 entry — close-framing forbidden wording `== 0 strict`. §7.3 G4.3 entry 조건에도 propagate. |
| **R2-B6** | Task 4.1 / 4.2 / 4.3 — canary JSON `pid` 가 `pinning` block 안에 위치 (top-level redundant 제거). §5.2 표 7 field 와 1:1. |
| **R2-I1** | Task 6.3 exit criteria — token delta gate `≥ -850 tokens` (estimate -915 대비 65 token slack). |
| **R2-I2** | Task 5.1 — sub-case D unit test `test_background_ref_attached_neither_owned_nor_camera_fallback` 유지 (R2-B2 옵션 A 채택으로 자동 valid). |
| **R2-I3** | Task 5.2 — Rule C exit criteria test 는 owned_violation rate ≤ baseline (absolute count 아님). |
| **R2-I4** | Task 2.1 — `detail_steps.py` inject 직전 `_card_metadata` strip 추가. card 의 `_card_metadata` 는 LLM 입력 제외. hash 격리 + inject 격리 일관성. |
| **R2-I5** | Task 1.3 — `assert_card_shape()` 는 envelope contract 5 field 만 strict 검증. `_card_metadata` 는 free-form (extra-key 허용, schema 검증 없음). |
| **R2-M1** | Task 5.1 expected count 정정 — 22 unit tests. |

---

## Phase 1 (Wave 1-A): render_prompt_card.py 변경

**Phase 목표:** `build_background_binding()` 에 G4.2 신규 constraint strings + `_card_metadata` 작성 로직 추가. `build_render_prompt_card()` wrapper 가 envelope top-level 에 `_card_metadata` 삽입. `CARD_SCHEMA_VERSION` / `SCENE_DETAIL_SCHEMA_VERSION` 유지 확인.

**Phase 1 expected test pass delta:** 0 (신규 test 는 Phase 5 에서 추가 — Phase 1 은 코드 변경만).

---

### Task 1.1 — `build_render_prompt_card()` wrapper: `_card_metadata` 삽입

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Dependency:** 없음 (G4.1 base 위에서 직접 수정)
- [ ] **Spec cross-reference:** spec §7.2 step 1 / R1-B1 / R2-B1 / R1-I6
- [ ] **Expected diff size:** ~30 lines added

**변경 내용:**

`build_render_prompt_card()` 의 현재 return 직전에 다음 로직을 추가:

1. `background_binding.mode` 에서 `lift_status` dict 결정:
   - `background_mode_off`: `{"rule_a_lifted": True, "rule_c_lifted": True, "rule_e_lifted": True}`
   - `skipped_close_framing`: `{"rule_a_lifted": True, "rule_c_lifted": True, "rule_e_lifted": True}`
   - `not_applicable`: `{"rule_a_lifted": True, "rule_c_lifted": True, "rule_e_lifted": False}`
   - `background_ref_attached`: `{"rule_a_lifted": True, "rule_c_lifted": True, "rule_e_lifted": False}`

2. `rule_source` dict (모든 mode 공통):
   ```python
   {"camera_rule": "A", "owned_rule": "C", "close_skip_rule": "E"}
   ```

3. 최종 card 에 top-level sibling key `_card_metadata` 삽입:
   ```python
   card["_card_metadata"] = {
       "lift_status": lift_status,
       "rule_source": rule_source,
   }
   ```

4. `_card_metadata` 는 `canonicalize_render_prompt_card()` 가 이미 pop 하므로 hash 영향 0 (R1-I11 carry — `render_prompt_card.py:565` `payload.pop(k, None)` 에서 처리됨).

5. `CARD_SCHEMA_VERSION = 1` 변경 없음 (R1-I6).

**주의:**
- `_card_metadata` 를 `background_binding` nested dict 안에 넣지 말 것 (R2-B1 — top-level only). `canonicalize_render_prompt_card()` 는 top-level pop 만 수행하므로 nested 구조는 hash 제외 되지 않음.
- `assert_card_shape()` 는 `_card_metadata` key 에 대해 free-form 허용 (R2-I5) — validator 변경 불필요.

---

### Task 1.2 — `build_background_binding()`: G4.2 constraint strings 추가

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Dependency:** Task 1.1 완료 (동일 파일 — 순서 지키되 같은 Wave 내 작업)
- [ ] **Spec cross-reference:** spec §2.2 / §7.2 step 1 / R1-I1 / R1-I2 / R2-B2 / R2-B3
- [ ] **Expected diff size:** ~55 lines (constraint strings 교체 + sub-case D 추가)

**변경 내용:**

**A. `skipped_close_framing` branch constraint 확장 (line 320 부근):**

현재 G4.1 constraints (2 strings):
```
"this shot is close framing — do not mention the reference image, the existing room, or any chain_bg owned object"
"the background ref is intentionally skipped (mode = skipped_close_framing); compose the close subject without anchoring to the wider room"
```

G4.2 교체 (5 strings — R1-I2 6 ground-truth + R2-B3 Rule A 4 forms 통합):
```python
constraints = [
    "this shot uses close framing — the background reference is intentionally omitted at composition time",
    "do not mention the existing room, the chain_bg reference image, or any item from the owned_objects list",
    (
        "do not use any of: 'the existing X', 'from the reference', "
        "'use the X from the reference', 'preserving the same room perspective', "
        "'maintaining the reference\\'s framing', 'do not generate a new X' "
        "— these phrases are invalid when the reference is absent"
    ),
    (
        "do not use any of: 'matching the reference camera', 'deviation from reference', "
        "'same … angle as the reference', 'match the reference framing' "
        "— Rule A close-framing invalidation: close framing has no reference camera to match or deviate from"
    ),
    "compose the close subject from its immediate surroundings only — use [L##: ...] block for frame-edge surface description",
]
```

**B. `not_applicable` branch constraint 확장 (line 340 부근):**

현재 G4.1 (1 string):
```
"no chain_bg is bound to this shot — describe the location freely without referencing a background image"
```

G4.2 교체 (2 strings):
```python
constraints = [
    "no chain_bg is bound to this shot — describe the location freely without referencing a background image",
    "do not use 'from the reference' / 'the existing room' / 'preserving the room' style phrasing — there is no reference image to refer to",
]
```

**C. `background_ref_attached` branch: sub-case D fallback 추가 (line 354-378 부근):**

현재 G4.1: `bg_owned` 없고 `cam_ref` 없으면 `constraints = []` 빈 list.

G4.2: 4 sub-case 명시:

```python
constraints: List[str] = []
if bg_owned and cam_ref:
    # sub-case A: owned + camera 둘 다
    constraints.extend([
        "owned_objects lists all environment objects already drawn in the background reference — do not create new ones",
        "when focusing on an owned object, anchor it explicitly: 'the door from the reference'",
        "camera_reference specifies the framing under which the background was painted — match camera_position, camera_height, and framing",
        "if deviating from the reference framing (push-in, angle shift, ECU), state the deviation explicitly so the compositor can account for the scale difference",
    ])
elif bg_owned and not cam_ref:
    # sub-case B: owned 만
    constraints.extend([
        "owned_objects lists all environment objects already drawn in the background reference — do not create new ones",
        "when focusing on an owned object, anchor it explicitly: 'the door from the reference'",
    ])
elif not bg_owned and cam_ref:
    # sub-case C: camera 만
    constraints.extend([
        "camera_reference specifies the framing under which the background was painted — match camera_position, camera_height, and framing",
        "if deviating from the reference framing (push-in, angle shift, ECU), state the deviation explicitly so the compositor can account for the scale difference",
    ])
else:
    # sub-case D: 둘 다 부재 — fallback (R1-I1)
    constraints.append(
        "the chain_bg reference is attached but carries no owned-object list and no camera metadata "
        "— describe the scene freely while staying consistent with the reference image overall mood"
    )
```

**주의:**
- 기존 G4.1 의 old constraint strings ("do not create new objects in the owned_objects list", "if focusing on an owned object, anchor it as 'from the reference' (e.g., 'the door from the reference')", "match the reference image's camera_position and framing — do not rotate, change height, or zoom independently") 를 위의 새 strings 으로 **교체** (덮어쓰기 아닌 diff 기반 교체).
- `bg_owned` / `cam_ref` 조건 분기는 이미 G4.1 에 일부 있으나 sub-case D 미처리 — 전체 4분기 재작성.

---

### Task 1.3 — 버전 상수 유지 확인 + docstring 갱신

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Dependency:** Task 1.1 / 1.2
- [ ] **Spec cross-reference:** spec §7.2 step 7 / R1-I6
- [ ] **Expected diff size:** ~10 lines (docstring only)

**변경 내용:**

1. `CARD_SCHEMA_VERSION = 1` — 변경 없음. 확인 체크만.
2. `build_background_binding()` docstring 상단에 G4.2 변경 기록 추가:
   ```
   G4.2 (2026-05-04): _card_metadata.lift_status / rule_source 추가 (R1-B1 격리).
   sub-case D fallback constraint 추가 (R1-I1). skipped_close_framing constraints
   6 ground-truth patterns + Rule A 4 forms (R1-I2 / R2-B3). detail_steps.py
   inject 직전 _card_metadata strip (R2-I4 — Task 2.1 에서 처리).
   ```
3. Module docstring 상단 `G4.1 RenderPromptCard helper` → `G4.1/G4.2 RenderPromptCard helper` 로 갱신. G4.2 scope 한 줄 summary 추가.
4. `assert_card_shape()` docstring 에 `_card_metadata` free-form 허용 note 추가 (R2-I5 / plan-R2-I3 정정):
   ```
   _card_metadata: free-form (extra-key 허용, schema 검증 없음 — R2-I5).
   envelope contract 7 field 만 strict 검증 (schema_version / shot_key /
   render_strategy / id_policy / background_binding / continuity_elements_used /
   asset_requirements — _REQUIRED_TOP_FIELDS).
   ```

   **plan-R2-I3 정정 근거**: spec / plan 의 "5 field" 표현은 spec carry 결함. 실제
   `backend/app/core/steps/render_prompt_card.py:87-91` 의 `_REQUIRED_TOP_FIELDS`
   는 7 entries — docstring 도 정확한 숫자 사용.

---

## Phase 2 (Wave 2): detail_steps.py inject strip + SCENE_DETAIL_PROMPT_VERSION bump

**Phase 목표:** LLM 에 inject 되는 card JSON 에서 `_card_metadata` 를 strip. `SCENE_DETAIL_PROMPT_VERSION` bump v16 → v17 (prompt 디렉토리가 Phase 3 에서 생성됨 — 이 Phase 에서는 상수 갱신 준비).

**Phase 2 expected test pass delta:** 0 (기존 test 회귀 없어야 함 — inject 형태 변경만).

---

### Task 2.1 — `detail_steps.py` inject 직전 `_card_metadata` strip

- [ ] **File:** `backend/app/core/steps/detail_steps.py`
- [ ] **Dependency:** Phase 1 완료 (Task 1.1 — `_card_metadata` 가 card 에 추가된 후 여기서 strip)
- [ ] **Spec cross-reference:** spec §7.2 step 1 (R2-I4) / §3.4 token delta 가정
- [ ] **Expected diff size:** ~8 lines

**변경 내용:**

`detail_steps.py:1937-1942` (`_g41_card_block` 조립 직전) 에 다음 strip 추가:

```python
# Point 3 / G4.2 R2-I4: inject 직전 _card_metadata strip.
# LLM 입력에는 envelope contract 5 field 만. debug-only _card_metadata 는 제외.
# (hash 는 이미 canonicalize 에서 제외됨 — inject 도 동일 원칙 적용)
_g41_card_for_inject = {
    k: v for k, v in _g41_render_prompt_card.items()
    if k != "_card_metadata"
}
_g41_card_block = (
    "[RenderPromptCard v1]\n"
    + json.dumps(
        _g41_card_for_inject,
        sort_keys=True, ensure_ascii=False, separators=(",", ":"),
    )
    + "\n\n"
)
```

기존 `_g41_render_prompt_card` 를 직접 `json.dumps` 하던 부분을 `_g41_card_for_inject` 로 교체.

**주의 (plan-R1-B1 / plan-R1-I5):**
- **`_g41_render_prompt_card` 원본 변수는 변경 X — inject 용 별 dict copy `_g41_card_for_inject` 만 strip**. CP 저장 path (`_analyze_one()` return + `save_checkpoint` write) 는 본 strip 영향 받지 않음 — 본 strip 은 inject 직전 line 1937~1942 한정.
- `render_prompt_card_hash` 는 `_g41_render_prompt_card` 에 없음 (별도 `_g41_render_prompt_card_hash` 변수로 저장) — 이 strip 과 무관.
- **modify-scope guard (G4.1 5-point carry, plan-R1-I5)**: `_g41_locals = locals()` (`detail_steps.py:1839` 부근) snapshot 은 Point 1 직후 — 본 strip insertion 은 Point 3 직전 (line 1937~1942) 이므로 `_g41_locals` 영향 없음. **`_g41_locals` 본체 / 변수명 / iteration 순서 변경 절대 금지**. 신규 `_g41_card_for_inject` 변수가 `_g41_locals` snapshot 에 포함되지 않음 — 이는 의도된 동작 (snapshot 은 G4.1 의 _analyze_one 인풋 검증용).

---

### Task 2.2 — `SCENE_DETAIL_PROMPT_VERSION` 상수 bump

- [ ] **File:** **`backend/app/core/steps/detail_steps.py:105`** (plan-R1-I3 pin)
- [ ] **Dependency:** Phase 3 Task 3.1 완료 (v17 `<timestamp>` 결정 *후*) — plan-R1-I7 cross-wave dependency
- [ ] **Spec cross-reference:** spec §7.2 step 7 / R1-I6
- [ ] **Expected diff size:** ~1 line (string literal 만 변경)

**변경 내용:**

line 105 의 정확한 변경:
```python
# Before (G4.1):
SCENE_DETAIL_PROMPT_VERSION = "16.202605041200"  # G4.1: RenderPromptCard primary contract — 5 semantic field 명시 inject + canonical hash drift 차단.

# After (G4.2):
SCENE_DETAIL_PROMPT_VERSION = "17.<timestamp>"  # G4.2: Rule A/C/E lift to background_binding.constraints — Rule prose 삭제 + Background Binding compact section.
```

`<timestamp>` 는 Phase 3 Task 3.1 에서 결정된 실제 `YYYYMMDDHHmm` 값.

line 713 의 사용처 (`"prompt_version": SCENE_DETAIL_PROMPT_VERSION`) 는 자동 반영 — 별도 변경 불필요. 본 변경은 cp `config_hash` 자동 변경 → resume 시 v15/v16 cp 가 force escalate path 로 진입 (G4.1 R1-B1 patten carry).

**주의 (plan-R1-I6 SCHEMA alignment + plan-R2-I6 version_registry 동기화):**
- `SCENE_DETAIL_SCHEMA_VERSION` (= 7) 은 **변경 없음** (R1-I6). `backend/app/core/step_manifest.py:632` 의 `scene_detail.schema_version = 7` 도 변경 없음.
- alignment test (`backend/tests/prompts/test_scene_detail_id_policy_alignment.py:338-351, 374-391`) 는 latest prompt version ↔ `SCENE_DETAIL_PROMPT_VERSION` ↔ `version_registry.MODULE_VERSIONS` ↔ `_MODULE_INFO.prompt_dependency` 4-way 동기화 검증.
- `CARD_SCHEMA_VERSION` (= 1) 은 변경 없음 (R1-I6 — `_card_metadata` 추가는 hash 외 + envelope contract 외이므로 v1 backward compat 유지).

**plan-R2-I6 — `backend/app/core/version_registry.py` 동시 변경 (Task 2.2 in-scope)**:

다음 두 위치 동시 수정:

```python
# backend/app/core/version_registry.py:34
# Before (G4.1):
"scene_detail_composer": "1.16.0",  # G4.1 RenderPromptCard

# After (G4.2):
"scene_detail_composer": "1.17.0",  # G4.2 background-binding lift (Rule A/C/E prose → card)
```

```python
# backend/app/core/version_registry.py:125-127
# Before (G4.1):
_MODULE_INFO["scene_detail_composer"] = ModuleInfo(
    ...
    prompt_dependency="scene_detail/16.202605041200",
    ...
)

# After (G4.2):
_MODULE_INFO["scene_detail_composer"] = ModuleInfo(
    ...
    prompt_dependency="scene_detail/17.<timestamp>",  # Task 3.1 결정 timestamp
    ...
)
```

본 변경 누락 시 `test_scene_detail_id_policy_alignment.py` alignment test 가 fail (latest prompt v17 vs registry v16 mismatch). Task 7.1 commit target 에 `backend/app/core/version_registry.py` 명시 포함.

---

## Phase 3 (Wave 1-B parallel): v17 prompt 신규 디렉토리

**Phase 목표:** `prompts/_base/scene_detail/17.<timestamp>/` 신규 디렉토리 생성. v16 system.md 를 복사한 후 Rule A/C/E prose 83 lines 삭제 + "Background Binding" compact section 신설. `detail_schema.json` 은 v16 verbatim 복사 (schema 변경 없음).

**Phase 3 expected test pass delta:** 0 (test 는 Phase 5 에서 작성).

**중요 — 덮어쓰기 금지:** CLAUDE.md `feedback_prompt_versioning.md` 규칙. `16.202605041200/system.md` 수정 금지. 반드시 새 버전 디렉토리 (`17.<timestamp>/`) 생성.

---

### Task 3.1 — v17 디렉토리 생성 + system.md Rule A/C/E 삭제

- [ ] **File:** `prompts/_base/scene_detail/17.<timestamp>/system.md` (신규 생성)
- [ ] **Dependency:** 없음 (Phase 1/2 와 병렬 가능)
- [ ] **Spec cross-reference:** spec §3.1 / §3.2 / §3.3
- [ ] **Expected diff size:** -83 lines (삭제) + +22 lines (compact section) = net -61 lines

**변경 내용:**

1. 타임스탬프 결정: 작업 시점의 `YYYYMMDDHHmm` 형식 (예: `17.202605041300`).
2. `cp -r prompts/_base/scene_detail/16.202605041200 prompts/_base/scene_detail/17.<timestamp>`.
3. v17 `system.md` 에서 다음 sections 삭제 (v16 line 기준):
   - line 197-222: `## chain_bg reference 카메라 일관성 (Rule A)` — 26 lines
   - line 223-246: `## chain_bg 객체 중복 묘사 금지 (Rule C — G3.2 contract)` — 24 lines
   - line 247-279: `## close framing 시 reference image 부재 가정 (Rule E)` — 33 lines
   - 총 83 lines 삭제.

4. 삭제된 자리에 spec §3.3 compact section 삽입:

```markdown
## Background Binding (Rules A / C / E)

`[RenderPromptCard v1]` 의 `background_binding` field 가 이 shot 의 배경 binding 을
결정한다 (card 가 primary contract — 이 섹션 prose 와 충돌 시 card 우선).

**mode 별 동작:**

- `background_ref_attached`: `owned_objects` 의 항목은 배경 PNG 에 이미 그려져 있다.
  새로 그리도록 지시 금지 — 이중 합성 발생. 집중하려면 "from the reference" 앵커 사용.
  `camera_reference` 가 지정한 카메라 설정에 맞춰 t2i_prompt 를 작성. deviation 은
  명시적으로 기술 (합성 scale 불일치 방지). owned/camera 둘 중 하나라도 부재면
  card constraints 가 conditional 로 표현하므로 prompt 도 그 분기 따른다.
- `skipped_close_framing`: 배경 reference 가 합성에서 제외된다. 다음 6개 표현은
  절대 출력 금지 — `the existing X` / `from the reference` / `use the X from the reference` /
  `preserving the same room perspective` / `maintaining the reference's framing` /
  `do not generate a new X`. Rule A 의 일치형/deviation형 표현도 close framing 에서는
  무효 (reference 자체 없음). [L##: ...] block 으로 frame edge surface 직접 묘사.
- `background_mode_off` / `not_applicable`: reference 자체 없음 — reference 관련
  표현 전부 금지, 공간을 직접 묘사.
```

**삽입 위치:** v16 line 196 (`## chain_bg reference 카메라 일관성` 바로 위) 와 v16 line 280 (Rule E section 끝 다음 줄) 사이의 83 lines 를 위 22-line compact section 으로 교체.

---

### Task 3.2 — v17 `detail_schema.json` 복사

- [ ] **File:** `prompts/_base/scene_detail/17.<timestamp>/detail_schema.json` (신규 생성)
- [ ] **Dependency:** Task 3.1 (디렉토리 생성 후)
- [ ] **Spec cross-reference:** spec §7.2 step 6 — schema 변경 없음
- [ ] **Expected diff size:** 0 (verbatim copy)

**변경 내용:**

`cp prompts/_base/scene_detail/16.202605041200/detail_schema.json prompts/_base/scene_detail/17.<timestamp>/detail_schema.json`

`detail_schema.json` 내용 변경 없음. G4.2 는 LLM output schema 를 변경하지 않는다.

---

### Task 3.3 — v17 디렉토리 완성 확인

- [ ] **Dependency:** Task 3.1 + 3.2
- [ ] **Spec cross-reference:** spec §7.2 step 6

**확인 사항:**

```bash
# v17 디렉토리에 두 파일만 존재해야 함
ls prompts/_base/scene_detail/17.<timestamp>/
# → system.md  detail_schema.json

# Rule A section 부재 확인
grep -n "chain_bg reference 카메라 일관성" prompts/_base/scene_detail/17.<timestamp>/system.md
# → (no output)

# Rule C section 부재 확인
grep -n "chain_bg 객체 중복 묘사 금지" prompts/_base/scene_detail/17.<timestamp>/system.md
# → (no output)

# Rule E section 부재 확인
grep -n "close framing 시 reference image 부재 가정" prompts/_base/scene_detail/17.<timestamp>/system.md
# → (no output)

# Background Binding section 존재 확인
grep -n "Background Binding (Rules A / C / E)" prompts/_base/scene_detail/17.<timestamp>/system.md
# → <line_number>:## Background Binding (Rules A / C / E)

# v16 원본 변경 없음 확인
wc -l prompts/_base/scene_detail/16.202605041200/system.md
# → 동일 line count (변경 없어야 함)
```

---

## Phase 4 (Wave 2): canary scripts 4종

**Phase 목표:** `scripts/canary/` 에 4개 측정 스크립트 작성 (plan-R1-B2: Rule C 측정용 4번째 script 신설). baseline + candidate JSON schema 확정. 각 스크립트는 독립 실행 가능하며 stdout 에 JSON 출력 (파일 저장은 caller 책임).

**스크립트 4종**:
- Task 4.1 `g4_2_camera_wording.py` — Rule A non-close framing 측정
- Task 4.2 `g4_2_close_forbidden.py` — Rule E close framing 측정
- Task 4.3 `g4_2_token_count.py` — system.md token delta (tiktoken cl100k_base)
- Task 4.4 `g4_2_owned_violations.py` — **신규 (plan-R1-B2)** — Rule C owned violation rate 측정 (G3.2 sentinel CP read)

---

### Task 4.1 — `g4_2_camera_wording.py` (Rule A non-close framing 측정)

- [ ] **File:** `scripts/canary/g4_2_camera_wording.py` (신규 생성)
- [ ] **Dependency:** 없음 (데이터 shape 은 G4.1 CP format 사용)
- [ ] **Spec cross-reference:** spec §5.3 / R1-I3 / R2-B4
- [ ] **Expected diff size:** ~120 lines (신규)

**스크립트 역할:**

주어진 PID + scene_index_list 의 scene_detail CP 를 읽어, **non-close framing shot 의 `t2i_variations[].t2i_prompt`** 에서 camera consistency wording 을 regex 검출. `g4_bg_binding_<role>_<timestamp>.json` 형식 으로 저장.

**regex 4종 (R1-I3 — §9 Glossary single source):**
```python
CAMERA_WORDING_PATTERNS = [
    r"match.*reference.*(camera|framing|position)",
    r"same.*(angle|position).*reference",
    r"(push in|pull out|deviation).*from.*reference",
    r"reference.*camera_position",
]
```

**pinning block (R1-I5, R2-B6):**
```python
pinning = {
    "pid": pid,
    "scene_index_list": scene_index_list,
    "shot_index_list_per_scene": shot_index_list_per_scene,
    "model_routing": model_routing,
    "prompt_source_mode": prompt_source_mode,
    "card_commit_hash": card_commit_hash,
    "chain_bg_card_snapshot_hash": chain_bg_card_snapshot_hash,
}
```

**출력 JSON schema (§5.3 / R2-B6):**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "16.202605041200",
  "pinning": {
    "pid": "...",
    "scene_index_list": [],
    "shot_index_list_per_scene": {},
    "model_routing": "...",
    "prompt_source_mode": "file",
    "card_commit_hash": "...",
    "chain_bg_card_snapshot_hash": "..."
  },
  "scene_set": [
    {"scene_index": 3, "shot_index": 1, "is_close_framing": false}
  ],
  "metrics": {
    "total_shots": 0,
    "non_close_framing_shots": 0,
    "close_framing_shots": 0,
    "camera_consistency_wording_present_non_close_only": 0,
    "camera_consistency_wording_patterns_matched": []
  },
  "measurement_scripts": {
    "camera_wording": "scripts/canary/g4_2_camera_wording.py (non-close framing shots only — R2-B4)"
  }
}
```

**close framing 판별:** CP 의 `background_binding.close_framing_skips_background_ref == true` 또는 `background_binding.mode == "skipped_close_framing"`.

**exit criteria threshold:** `candidate.metrics.camera_consistency_wording_present_non_close_only >= baseline.metrics.camera_consistency_wording_present_non_close_only`.

---

### Task 4.2 — `g4_2_close_forbidden.py` (Rule E close framing 측정)

- [ ] **File:** `scripts/canary/g4_2_close_forbidden.py` (신규 생성)
- [ ] **Dependency:** 없음
- [ ] **Spec cross-reference:** spec §5.3 / R1-I2 / R1-I4 / R2-B3 / R2-B4 / R2-B5
- [ ] **Expected diff size:** ~130 lines (신규)

**스크립트 역할:**

주어진 PID + scene_index_list 의 scene_detail CP 를 읽어, **close framing shot 의 `t2i_variations[].t2i_prompt`** 에서 forbidden wording 검출.

**forbidden patterns (§9 Glossary single source — R1-I2 6 ground-truth + R2-B3 Rule A 4 forms):**
```python
CLOSE_FORBIDDEN_PATTERNS = [
    # R1-I2: 6 ground-truth patterns
    r"the existing \w",
    r"from the reference",
    r"use the \w+ from the reference",
    r"preserving the same room perspective",
    r"maintaining the reference'?s framing",
    r"do not generate a new \w",
    # R2-B3: Rule A close-framing invalidation 4 forms
    r"matching the reference camera",
    r"deviation from reference",
    r"same\s+[^\n]{0,20}angle as the reference",
    r"match the reference framing",
]
```

**exit criteria threshold (R1-I4 / R2-B5 strict):**
`candidate.metrics.rule_e_forbidden_wording_count_close_only == 0`.

baseline 비0 이어도 candidate 0 강제. baseline 비0 이면 P1 follow-up 등록 (G4.2 scope 밖).

**출력 JSON schema (§5.3 / R2-B6 — pinning block 동일):**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "...",
  "pinning": { "...": "..." },
  "scene_set": [],
  "metrics": {
    "total_shots": 0,
    "non_close_framing_shots": 0,
    "close_framing_shots": 0,
    "rule_e_forbidden_wording_count_close_only": 0,
    "rule_e_forbidden_wording_per_shot": {}
  },
  "measurement_scripts": {
    "rule_e_forbidden": "scripts/canary/g4_2_close_forbidden.py (close framing shots only — R2-B4)"
  }
}
```

---

### Task 4.3 — `g4_2_token_count.py` (tiktoken cl100k_base 실측)

- [ ] **File:** `scripts/canary/g4_2_token_count.py` (신규 생성)
- [ ] **Dependency:** Phase 3 (v17 prompt 존재해야 candidate 측정 가능 — baseline 만이라면 Phase 3 이전 실행 가능)
- [ ] **Spec cross-reference:** spec §5.3 / R1-M1 / R2-I1
- [ ] **Expected diff size:** ~80 lines (신규)

**스크립트 역할:**

`prompts/_base/scene_detail/<version>/system.md` 를 `tiktoken cl100k_base` 로 인코딩, 토큰 수 실측. v16 vs v17 delta 계산. exit criteria `≥ -850 tokens` (R2-I1) 판정.

**출력 JSON schema (plan-R1-I1: pinning block 추가):**
```json
{
  "timestamp": "ISO8601",
  "pinning": {
    "pid": "...",
    "scene_index_list": [],
    "shot_index_list_per_scene": {},
    "model_routing": "...",
    "prompt_source_mode": "file",
    "card_commit_hash": "...",
    "chain_bg_card_snapshot_hash": "..."
  },
  "baseline_prompt_version": "16.202605041200",
  "candidate_prompt_version": "17.<timestamp>",
  "baseline_token_count": 0,
  "candidate_token_count": 0,
  "token_delta": 0,
  "exit_criteria_threshold": -850,
  "exit_criteria_pass": false,
  "measurement_tool": "tiktoken cl100k_base (R1-M1)"
}
```

**주의:**
- `tiktoken` 이 backend 의존성에 없으면 별도 venv 또는 `pip install tiktoken` 필요. 스크립트 상단에 dependency 명시.
- `token_delta = candidate_token_count - baseline_token_count`. 음수여야 함. `token_delta <= -850` 이면 pass.
- pinning block 은 token 측정 자체에 영향 없으나 baseline / candidate 비교 일관성 위해 동일 schema (R2-B6 1:1 carry).

**CLI args (plan-R2-I4 — pinning 7 field 모두 받음):**
```bash
python scripts/canary/g4_2_token_count.py \
  --baseline-version <v16> --candidate-version <v17> \
  --pid <PID> \
  --scene-index-list "[3,7,12,18,24]" \
  --shot-index-list-per-scene '{"3":[1,2],"7":[4],...}' \
  --model-routing <model> \
  --prompt-source-mode <file|db> \
  --card-commit-hash <hash> \
  --chain-bg-card-snapshot-hash <hash> \
  --output <path>
```

또는 `--config <json-file>` 한 번에 받음 — pinning args 가 5+ 개라 json file 형식이 사용성 좋음. Task 6.1/6.2 의 baseline/candidate JSON 자체를 input 으로 받는 방식 권장.

token 측정은 system.md 만 영향 — pinning 자체는 비교 일관성용 metadata. token script 가 pinning args 무시하지 않고 그대로 output JSON 에 echo.

---

### Task 4.4 — `g4_2_owned_violations.py` (Rule C owned violation rate 측정, plan-R1-B2 신규)

- [ ] **File:** `scripts/canary/g4_2_owned_violations.py` (신규 생성)
- [ ] **Dependency:** 없음 (G3.2 sentinel CP format 사용)
- [ ] **Spec cross-reference:** spec §5.1 Rule C exit criteria / §5.3 owned_violations metrics / R2-I3 / plan-R1-B2
- [ ] **Expected diff size:** ~110 lines (신규)

**스크립트 역할:**

주어진 PID + scene_index_list 의 scene_detail CP 를 읽어, **모든 t2i_variation 의 `owned_validation.violations` list count 합산**. G3.2 sentinel CP field 직접 read — script 자체가 새 violation 판정을 하지 않음 (sentinel 이 이미 LLM judge 결과 carry).

**핵심 로직 (plan-R2-I5 — sentinel shape strict 검증 필수):**
```python
# CP path: <projects_dir>/<pid>/checkpoints/episodes/<episode>/scene_detail/manifest.json
# CP shape: {"data": {"scenes": [{"scene_index": N, "_shot_index": M,
#   "t2i_variations": [{"owned_validation": {"violations": [...], ...}}]}]}}

from app.core.steps._owned_helpers import assert_owned_sentinel_shape

owned_violations_total = 0
owned_violations_per_shot = {}
measurement_failures: list = []  # plan-R2-I5: silent 0 false-pass 차단

for scene in cp["data"]["scenes"]:
    si = scene["scene_index"]
    shi = scene.get("_shot_index")
    if (si, shi) not in pinning_scene_set:
        continue  # scene_set 외 skip
    shot_count = 0
    for var in scene.get("t2i_variations", []):
        sentinel = var.get("owned_validation")
        # plan-R2-I5: silent {} fallback 차단 — sentinel 부재/malformed 는 measurement
        # 실패로 분류 (silent 0 false-pass 가 baseline / candidate 양쪽에 발생하면 비교
        # 자체가 불가능 — exit criteria 통과 표시되는 결함 차단).
        if sentinel is None:
            measurement_failures.append(
                f"s{si}_sh{shi}: owned_validation sentinel 부재"
            )
            continue
        try:
            assert_owned_sentinel_shape(
                sentinel, where=f"canary.owned_violations s{si}_sh{shi}",
            )
        except Exception as exc:
            measurement_failures.append(
                f"s{si}_sh{shi}: sentinel shape 위반 — {exc}"
            )
            continue
        violations = sentinel.get("violations") or []
        shot_count += len(violations)
    owned_violations_total += shot_count
    owned_violations_per_shot[f"{si}_{shi}"] = shot_count

# measurement_failures 가 비어있지 않으면 canary JSON 의 별 field 에 기록 + Task 6.3
# 가 exit criteria 판정 시 measurement_failures 비교 — baseline / candidate 모두 빈
# list 일 때만 owned_violations_total 비교 valid.
```

**pinning block (R1-I5, R2-B6 — 다른 canary script 와 동일):**
```python
pinning = {
    "pid": pid,
    "scene_index_list": scene_index_list,
    "shot_index_list_per_scene": shot_index_list_per_scene,
    "model_routing": model_routing,
    "prompt_source_mode": prompt_source_mode,
    "card_commit_hash": card_commit_hash,
    "chain_bg_card_snapshot_hash": chain_bg_card_snapshot_hash,
}
```

**출력 JSON schema:**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "16.202605041200",
  "pinning": { "...": "(7 fields per R1-I5)" },
  "scene_set": [
    {"scene_index": 3, "shot_index": 1, "is_close_framing": false}
  ],
  "metrics": {
    "total_shots": 0,
    "owned_violations_total": 0,
    "owned_violations_per_shot": {}
  },
  "measurement_scripts": {
    "owned_violations": "scripts/canary/g4_2_owned_violations.py (G3.2 sentinel CP read — plan-R1-B2)"
  }
}
```

**exit criteria threshold (plan-R2-I1 — absolute count vs rate equivalence):**
- 비교: `candidate.metrics.owned_violations_total <= baseline.metrics.owned_violations_total`.
- **absolute count = rate equivalent 보장**: pinning 7 field (특히 `scene_index_list` + `shot_index_list_per_scene`) 가 baseline / candidate 동일 scene-set 강제 → 분모 동일 → absolute count 비교 = rate 비교. 분모 다른 경우 발생 시 pinning 검증 단계 (Task 6.2 step 3) 가 reject.
- **plan-R2-I5 measurement_failures 통합**: baseline / candidate 양쪽 `measurement_failures` 가 빈 list 일 때만 `owned_violations_total` 비교 valid. 한 쪽이라도 measurement 실패 시 비교 reject + canary 재실행.

**주의:**
- script 가 새 LLM judge 호출 안 함 — CP 의 sentinel 결과만 read. 즉 baseline 측정 시점의 sentinel 이 이미 결정됨.
- close framing shot 은 G3.2 sentinel 의 `OWNED_VALIDATOR_CLOSE_SKIP` 으로 marked — violations 0 자연 발생. measurement scope 분리 (R2-B4) 영향 X — 모든 shot 의 violations 합산으로 충분.

---

## Phase 5 (Wave 3): unit + integration test

**Phase 목표:** `backend/tests/unit/test_g4_2_background_binding_lift.py` (**23 unit tests** — plan-R1-I8) + `backend/tests/integration/test_g4_2_bg_binding_integration.py` (**10 integration tests** — plan-R1-I2) 작성. G4.1 regression suite green 유지.

**Phase 5 expected total test count delta:** **+33** (23 unit + 10 integration).

---

### Task 5.1 — unit tests (23 tests, plan-R2-B1 통일)

- [ ] **File:** `backend/tests/unit/test_g4_2_background_binding_lift.py` (신규 생성)
- [ ] **Dependency:** Phase 1 완료 (Task 1.1 / 1.2)
- [ ] **Spec cross-reference:** spec §6.1 / R1-I7 / R1-B1 / R2-B3 / R2-M1 / plan-R1-I8 / plan-R1-I4
- [ ] **Expected diff size:** ~360 lines (신규 — +1 test row, +sentinel pattern boilerplate)
- [ ] **Expected test count:** **23** (plan-R1-I8 — R2-M1 의 "최소 22" 만족 + lift_status hash stability 별도)

**test list (spec §6.1 + R1-I7 2종 추가 + plan-R1-I8 1종 추가 = 23):**

| # | test name | assert |
|---|---|---|
| 1 | `test_bg_ref_attached_constraints_cover_rule_a` | camera match string present in constraints |
| 2 | `test_bg_ref_attached_constraints_cover_rule_c` | "do not create new" + "anchor" both in constraints |
| 3 | `test_skipped_close_framing_constraints_cover_rule_e_six_patterns` | 6 ground-truth pattern substrings all in constraints |
| 4 | `test_skipped_close_framing_constraints_cover_rule_a_four_forms` | 4 Rule A forms all in constraints |
| 5 | `test_skipped_close_framing_no_owned_objects` | `owned_objects == []` |
| 6 | `test_skipped_close_framing_no_camera_reference` | `camera_reference is None` |
| 7 | `test_background_mode_off_no_constraints_reference` | "camera_reference" / "owned_objects" not in constraints strings |
| 8 | `test_not_applicable_no_bg_id` | `mode == "not_applicable"` |
| 9 | `test_not_applicable_constraints_no_reference_language` | reference wording guard string present (R1-I7) |
| 10 | `test_not_applicable_card_metadata_lift_status` | `_card_metadata.lift_status == {"rule_a_lifted": True, "rule_c_lifted": True, "rule_e_lifted": False}` (R1-I7) |
| 11 | `test_background_ref_attached_owned_only_no_camera_constraints` | sub-case B: owned 2 constraints, camera 0 |
| 12 | `test_background_ref_attached_camera_only_no_owned_constraints` | sub-case C: camera 2 constraints, owned 0 |
| 13 | `test_background_ref_attached_neither_owned_nor_camera_fallback` | sub-case D: fallback 1 row present |
| 14 | `test_owned_objects_english_canonical_only` | list 1:1 preserve |
| 15 | `test_card_hash_drifts_on_owned_change` | hash_A != hash_B |
| 16 | `test_card_hash_drifts_on_camera_change` | hash_A != hash_B |
| 17 | `test_card_hash_stable_on_constraints_reorder` | hash_A == hash_B |
| 18 | `test_non_close_owned_list_in_card_only` | owned list present in injected JSON |
| 19 | `test_close_framing_card_mode_forbids_reference_wording` | "from the reference" guard string in constraints |
| 20 | `test_lift_status_in_card_metadata` | all modes: correct lift dict (rule_e_lifted=False for not_applicable + background_ref_attached) |
| 21 | `test_rule_source_in_card_metadata` | `{"camera_rule":"A","owned_rule":"C","close_skip_rule":"E"}` |
| 22 | `test_card_metadata_excluded_from_hash` | `"_card_metadata" not in canonicalized` |
| 23 | `test_card_hash_stable_across_lift_status_change` (plan-R1-I8) | 동일 envelope 5 contract field 에서 `_card_metadata.lift_status` 만 다르면 hash 동일 — `_card_metadata` 격리 invariant 검증 |

**fixture helper (plan-R1-I4 — `_DEFAULT_SENTINEL` pattern 필수):**

```python
# G4.1 R3-B2 패턴 carry — `or []` silent absorb 차단
_DEFAULT_SENTINEL = object()


def _make_full_card(
    bg_id="cb_test_001",
    bg_owned=_DEFAULT_SENTINEL,
    bg_camera_meta=_DEFAULT_SENTINEL,
    is_close_framing=False,
    background_mode_on=True,
) -> dict:
    """build_render_prompt_card() 전체 call — None / [] / default 명시 구분.

    Sentinel 의도 (G4.1 함정 1 carry):
      - default value 가 사용자 의도가 아닐 때 → caller 가 explicit 인자 전달
      - `bg_owned=None` → builder 가 None 처리 (현재는 AppError raise — G4.1 제약)
      - `bg_owned=[]` → builder 가 빈 list 로 처리 (sub-case D 분기 진입)
      - `bg_owned=_DEFAULT_SENTINEL` (default) → fixture 가 ["door", "window"] 채움
    """
    if bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"]
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = {
            "camera_position": "south",
            "camera_height": "eye-level",
            "lens_hint": "35mm",
            "framing_notes": "wide",
        }
    # ... 나머지 inputs 구성
```

**중요 (G4.1 함정 1 carry — Wave subagent trap 차단):**

- **절대 금지** 패턴: `bg_owned or []`, `bg_owned or default`, `bg_owned if bg_owned else []`. silent absorb 패턴은 production 에서 false drift cascade 발생 (G4.1 R4 BLOCKING fix 기록).
- **반드시** 사용: `_DEFAULT_SENTINEL` object pattern. `is _DEFAULT_SENTINEL` 비교 (값 비교 아님).
- 모든 None vs [] vs default 셋이 명시적 구분되어야 unit test 가 production behavior 와 일치.

---

### Task 5.2 — integration tests (9 tests)

- [ ] **File:** `backend/tests/integration/test_g4_2_bg_binding_integration.py` (신규 생성)
- [ ] **Dependency:** Phase 1 + Phase 2 + Phase 3 완료
- [ ] **Spec cross-reference:** spec §6.2 / R1-I7 / R2-I3 / plan-R1-I2
- [ ] **Expected diff size:** ~310 lines (신규)
- [ ] **Expected test count:** **10** (plan-R1-I2 — base 9 + R1-I7 not_applicable 1 = 10)

**test list (spec §6.2 base 9 + R1-I7 1종 = 10):**

| # | test name | assert |
|---|---|---|
| 1 | `test_v17_system_prompt_missing_rule_a_section` | "## chain_bg reference 카메라 일관성" heading not present |
| 2 | `test_v17_system_prompt_missing_rule_c_section` | "## chain_bg 객체 중복 묘사 금지" heading not present |
| 3 | `test_v17_system_prompt_missing_rule_e_section` | "## close framing 시 reference image 부재 가정" heading not present |
| 4 | `test_v17_system_prompt_has_background_binding_section` | "## Background Binding" heading present |
| 5 | `test_v17_prompt_token_reduction_vs_v16` | `len(v17_lines) < len(v16_lines) - 60` |
| 6 | `test_non_close_shot_card_carries_owned_and_camera` | `_collect_card_inputs()` + `build_render_prompt_card()` + inject → `background_binding.owned_objects` non-empty |
| 7 | `test_close_framing_shot_card_skips_owned_and_camera` | `mode=skipped_close_framing`, `owned_objects=[]`, `camera_reference=null` |
| 8 | `test_card_hash_drift_on_owned_change_in_verify` | owned 변경 후 `verify_completion()` → status partial |
| 9 | `test_g3_2_sentinel_coexists_with_g4_2_card` | `background_binding` + `owned_validation` 둘 다 CP 에 존재 |

**+ R1-I7 1종 (10번째):**

| # | test name | assert |
|---|---|---|
| 10 | `test_not_applicable_shot_card_carries_no_owned_no_camera_no_reference` (R1-I7) | mode="not_applicable", owned_objects=[], camera_reference=null, constraints 에 reference 차단 가이드 포함, `_card_metadata.lift_status.rule_e_lifted=False` |

총 integration test count: **10** (plan-R1-I2 확정).

**`test_v15_checkpoint_escalates_on_v17_resume` 는 G4.2 plan 에 추가 X (plan-R1-I2 결정):**

spec §6.2 의 v15 escalation row 는 G4.1 R1-B1 carry — G4.1 integration test 에 이미 존재 (`test_g4_1_card_consumer_wiring.py::test_step_runner_actually_escalates_to_force_on_v15_resume`). G4.2 의 v17 prompt 는 G4.1 의 force escalate path 위에 동일하게 작동 — 별도 신규 test 불필요. G4.2 integration test 는 v17 prompt 의 새 contract 만 검증.

---

### Task 5.3 — regression 확인

- [ ] **Dependency:** Task 5.1 + 5.2
- [ ] **Spec cross-reference:** spec §6.3

**실행:**
```bash
pytest backend/tests/ -x --timeout=120
```

기준: G4.1 baseline (2025 passed) 대비 0 regressions. G4.2 신규 test 추가 후 total count ≥ **2025 + 33** (unit 23 + integration 10 — plan-R2-B1 통일).

G3.2 sentinel test suite (`test_g3_2_*.py`) 가 G4.2 이후에도 모두 pass 해야 함 (owned sentinel 로직 변경 없음).

**추가 확인:**
```bash
# G3.2 sentinel test
pytest backend/tests/ -k "g3_2" -v

# G4.1 test
pytest backend/tests/ -k "g4_1" -v

# G4.2 신규 test
pytest backend/tests/ -k "g4_2" -v

# alignment test (plan-R2-I6 — version_registry.py / step_manifest.py / prompt 일관)
pytest backend/tests/prompts/test_scene_detail_id_policy_alignment.py -v
# 위 test 가 latest prompt version (v17) ↔ SCENE_DETAIL_PROMPT_VERSION ↔
# version_registry MODULE_VERSIONS / _MODULE_INFO.prompt_dependency 모두 동기화 검증.
```

---

## Phase 6 (Wave 4): canary execution + exit criteria 판정

**Phase 목표:** Phase 4 스크립트로 baseline (v16) + candidate (v17) capture. exit criteria 5개 항목 판정. 통과 시 단일 commit 진행.

---

### Task 6.1 — baseline canary capture (v16)

- [ ] **Dependency:** Phase 4 스크립트 완성 + background_mode_on PID 선택
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 Step 1

**실행 순서:**

1. **PID 결정 (plan-R1-I9 — 사용자 confirm 단계)**: 본 task 진입 시 사용자 확인 필요. plan 본문에 specific PID 박지 않음 (시나리오 의존 차단). PID 결정 기준:
   - background_mode_on + chain_bg assigned shot 최소 5개 + close framing 최소 2개 포함.
   - G3.x / G4.1 검증된 PID (memory `next_session_*` 계열 후보) 또는 신규 PID.
   - 선택 후 `pinning.pid` 에 명시.

2. 선택 scene/shot set 을 `pinning.scene_index_list` + `pinning.shot_index_list_per_scene` 에 명시 (실제 PID 의 scene/shot 구성에 따라 결정).

3. v16 prompt (G4.1 card) 로 force scene_detail 실행 (명시된 scene/shot 만 — 시나리오 무관 절차).

4. canary scripts 실행 (4종 — plan-R1-B2 4번째 script 포함):
   ```bash
   # Rule A camera wording (non-close framing scope)
   python scripts/canary/g4_2_camera_wording.py \
     --pid <PID> --role baseline --prompt-version 16.202605041200 \
     --output docs/canary/g4_bg_binding_baseline_camera_$(date +%Y%m%d%H%M).json

   # Rule E close-framing forbidden wording (close framing scope)
   python scripts/canary/g4_2_close_forbidden.py \
     --pid <PID> --role baseline --prompt-version 16.202605041200 \
     --output docs/canary/g4_bg_binding_baseline_close_$(date +%Y%m%d%H%M).json

   # Rule C owned violation rate (G3.2 sentinel CP read — plan-R1-B2)
   python scripts/canary/g4_2_owned_violations.py \
     --pid <PID> --role baseline --prompt-version 16.202605041200 \
     --output docs/canary/g4_bg_binding_baseline_owned_$(date +%Y%m%d%H%M).json

   # token count (system.md tiktoken cl100k_base)
   python scripts/canary/g4_2_token_count.py \
     --baseline-version 16.202605041200 --candidate-version 17.<timestamp> \
     --output docs/canary/g4_token_count_$(date +%Y%m%d%H%M).json
   ```

5. baseline JSON 저장 경로 (4종 분리 저장 — Task 6.3 가 통합 비교).

---

### Task 6.2 — candidate canary capture (v17)

- [ ] **Dependency:** Task 6.1 + Phase 3 (v17 prompt 존재) + Phase 2 (Task 2.2 version bump)
- [ ] **Spec cross-reference:** spec §5.3 Step 2

**실행 순서:**

1. Task 6.1 과 **동일 PID / 동일 scene/shot set** force 재실행 (v17 prompt 적용 상태).

2. canary scripts 실행 (4종 — Task 6.1 과 동일 set):
   ```bash
   python scripts/canary/g4_2_camera_wording.py \
     --pid <PID> --role candidate --prompt-version 17.<timestamp> \
     --output docs/canary/g4_bg_binding_candidate_camera_$(date +%Y%m%d%H%M).json

   python scripts/canary/g4_2_close_forbidden.py \
     --pid <PID> --role candidate --prompt-version 17.<timestamp> \
     --output docs/canary/g4_bg_binding_candidate_close_$(date +%Y%m%d%H%M).json

   # plan-R1-B2: Rule C owned violation candidate measurement
   python scripts/canary/g4_2_owned_violations.py \
     --pid <PID> --role candidate --prompt-version 17.<timestamp> \
     --output docs/canary/g4_bg_binding_candidate_owned_$(date +%Y%m%d%H%M).json
   ```

3. pinning block 7 field 가 baseline 과 일치하는지 확인 (`pid`, `scene_index_list`, `shot_index_list_per_scene`, `model_routing`, `prompt_source_mode`, `card_commit_hash`, `chain_bg_card_snapshot_hash`). 불일치 시 baseline 재측정.

---

### Task 6.3 — exit criteria 판정

- [ ] **Dependency:** Task 6.1 + 6.2
- [ ] **Spec cross-reference:** spec §5.1 / R1-I4 / R2-B5 / R2-I1

**5가지 exit criteria 체크리스트 (각 metric 의 source script 명시 — plan-R1-B2):**

- [ ] **Rule A camera wording** (`g4_2_camera_wording.py` 출력): `candidate.camera_consistency_wording_present_non_close_only >= baseline.camera_consistency_wording_present_non_close_only` (degradation 없음)
- [ ] **Rule C owned violation** (`g4_2_owned_violations.py` 출력 — plan-R1-B2 4번째 script): `candidate.owned_violations_total <= baseline.owned_violations_total`
- [ ] **Rule E close forbidden** (`g4_2_close_forbidden.py` 출력, **STRICT**): `candidate.rule_e_forbidden_wording_count_close_only == 0` (baseline 비0 이어도 candidate 0 강제 — R1-I4 / R2-B5)
- [ ] **Token delta** (`g4_2_token_count.py` 출력, R2-I1): `baseline_token_count - candidate_token_count >= 850` (tiktoken cl100k_base 실측)
- [ ] **pytest G4.2 suite:** `pytest backend/tests/ -k "g4_2" → 0 failures` (33 신규 test all green — 23 unit + 10 integration)

모두 통과 → Task 7.1 commit 진행.
실패 항목 있음 → 해당 constraints sub-field 또는 compact section 문구 수정 후 재canary.

**비교 절차 (Task 6.3 inline)**:
1. baseline JSON 4개 + candidate JSON 4개 모두 load.
2. 각 JSON 의 `pinning` block 동일 비교 (R2-B6 — dict equality).
3. 위 5 gate 각각 비교 + 통과 여부 기록.
4. 통과 시 통합 결과 JSON 저장: `docs/canary/g4_bg_binding_summary_<timestamp>.json` (선택사항).

---

## Phase 7 (Wave 4): commit + dual review

**Phase 목표:** 단일 commit push. Codex + Claude 듀얼 리뷰. BLOCKING 없음 확인 후 final merge.

---

### Task 7.1 — commit 준비 + single commit push

- [ ] **Dependency:** Phase 6 exit criteria 모두 통과 + Phase 5 regression green
- [ ] **Spec cross-reference:** spec §7.2 step 11

**commit 대상 파일:**

| 파일 | 변경 유형 | Phase |
|---|---|---|
| `backend/app/core/steps/render_prompt_card.py` | Modified | Phase 1 |
| `backend/app/core/steps/detail_steps.py` | Modified | Phase 2 |
| `backend/app/core/version_registry.py` | Modified (plan-R2-I6) | Phase 2 |
| `prompts/_base/scene_detail/17.<timestamp>/system.md` | New | Phase 3 |
| `prompts/_base/scene_detail/17.<timestamp>/detail_schema.json` | New | Phase 3 |
| `scripts/canary/g4_2_camera_wording.py` | New | Phase 4 |
| `scripts/canary/g4_2_close_forbidden.py` | New | Phase 4 |
| `scripts/canary/g4_2_token_count.py` | New | Phase 4 |
| `scripts/canary/g4_2_owned_violations.py` | New (plan-R1-B2) | Phase 4 |
| `backend/tests/unit/test_g4_2_background_binding_lift.py` | New (23 tests) | Phase 5 |
| `backend/tests/integration/test_g4_2_bg_binding_integration.py` | New (10 tests) | Phase 5 |
| `docs/canary/g4_bg_binding_baseline_*.json` (4종) | New | Phase 6 |
| `docs/canary/g4_bg_binding_candidate_*.json` (4종) | New | Phase 6 |
| `docs/canary/g4_token_count_*.json` | New | Phase 6 |

**commit message format:**
```
feat(g4.2): background-binding lift — Rule A/C/E prose → v17 compact section

- build_background_binding(): sub-case D fallback + 6 ground-truth patterns
  + Rule A 4 forms (skipped_close_framing) + _card_metadata.lift_status/rule_source
- detail_steps.py:1937-1942: _card_metadata strip before inject (R2-I4)
- detail_steps.py:105: SCENE_DETAIL_PROMPT_VERSION 16→17 bump
- v17 system.md: Rule A/C/E sections removed (-83 lines) → Background Binding
  compact section (+22 lines). net -61 lines / est. -915 tokens (≥-850 actual)
- canary scripts: g4_2_camera_wording / g4_2_close_forbidden / g4_2_token_count
  / g4_2_owned_violations (plan-R1-B2)
- +23 unit + +10 integration tests (plan-R1-I8 / plan-R1-I2)
- CARD_SCHEMA_VERSION=1, SCENE_DETAIL_SCHEMA_VERSION=7 (no bump — R1-I6)
```

---

### Task 7.2 — Codex 리뷰

- [ ] **Dependency:** Task 7.1 (commit 후)
- [ ] **Spec cross-reference:** CLAUDE.md `feedback_dual_code_review.md`

**Codex 리뷰 dispatch 포인트:**

1. `render_prompt_card.py` — `build_background_binding()` sub-case D fallback 올바른지, 6+4 constraint strings 완전한지, `_card_metadata` 격리 top-level sibling 맞는지.
2. `detail_steps.py` — `_card_metadata` strip 이 hash 저장 후 inject 전에 적용됐는지 (원본 card 는 CP 에 `_card_metadata` 포함 상태로 저장되어야 함).
3. `test_g4_2_background_binding_lift.py` — 22/23 tests 가 실제 `build_background_binding()` 를 call 하는지 (hardcoded empty inputs 금지 — G4.1 Wave subagent 트랩 재발 방지).
4. v17 `system.md` — Rule A/C/E heading 완전히 삭제됐는지, compact section 이 spec §3.3 과 동일한지.
5. canary scripts — pinning block 7 field 모두 포함됐는지, metric scope 분리 (R2-B4) 됐는지.

BLOCKING 발견 시 → fix → 재commit. IMPORTANT / MINOR 는 수정 후 동일 commit 에 amend (push 전) 또는 별도 follow-up commit.

---

### Task 7.3 — Claude 리뷰

- [ ] **Dependency:** Task 7.1 (Task 7.2 와 병렬 가능)
- [ ] **Spec cross-reference:** CLAUDE.md `feedback_dual_code_review.md`

**Claude 리뷰 focus:**

1. `build_background_binding()` 의 sub-case 분기 (A/B/C/D) 가 spec §2.2 table 과 1:1 일치.
2. `skipped_close_framing` constraints 의 6 ground-truth patterns (R1-I2) + Rule A 4 forms (R2-B3) — §9 Glossary single source 와 비교.
3. `_card_metadata` 가 inject 시 strip 됐고 CP 저장 시 포함됐는지 (두 가지 모두 확인).
4. integration test `test_not_applicable_shot_card_carries_no_owned_no_camera_no_reference` (R1-I7) 의 4 assert 모두 있는지.
5. v17 prompt token reduction 이 integration test `test_v17_prompt_token_reduction_vs_v16` 에서 `> 60 lines` 기준으로 검증됐는지.

---

## Data Flow

```
build_render_prompt_card(inputs)
  │
  ├─ build_background_binding(...)
  │     ├─ mode = background_mode_off → constraints=[1 string]
  │     ├─ mode = skipped_close_framing → constraints=[5 strings] (string 3 = R1-I2 6 patterns inline / string 4 = R2-B3 Rule A 4 forms inline) — plan-R2-I2
  │     ├─ mode = not_applicable → constraints=[2 strings]
  │     └─ mode = background_ref_attached
  │           ├─ sub-case A (owned + camera) → 4 constraints
  │           ├─ sub-case B (owned only) → 2 constraints
  │           ├─ sub-case C (camera only) → 2 constraints
  │           └─ sub-case D (neither) → 1 fallback constraint [NEW G4.2]
  │
  ├─ [build_render_strategy / build_id_policy / build_continuity / build_asset (G4.1 unchanged)]
  │
  └─ _card_metadata assembly [NEW G4.2]
        ├─ lift_status: mode → {"rule_a_lifted": bool, "rule_c_lifted": bool, "rule_e_lifted": bool}
        └─ rule_source: {"camera_rule": "A", "owned_rule": "C", "close_skip_rule": "E"}
        card["_card_metadata"] = {...}  ← top-level sibling only (R2-B1)

canonicalize_render_prompt_card(card)
  └─ pop("_card_metadata") → hash payload = 5 semantic fields only (R1-I11 / R1-B1 carry)

compute_card_hash(card) → sha256[:16]

detail_steps.py _analyze_one()
  ├─ _g41_render_prompt_card = build_render_prompt_card(...)  ← _card_metadata 포함
  ├─ _g41_hash = compute_card_hash(...)
  ├─ cp["render_prompt_card"] = _g41_render_prompt_card  ← CP 저장: _card_metadata 포함 (debug)
  ├─ cp["render_prompt_card_hash"] = _g41_hash
  │
  ├─ [NEW G4.2] _g41_card_for_inject = {k: v for k,v in card.items() if k != "_card_metadata"}
  └─ _g41_card_block = "[RenderPromptCard v1]\n" + json.dumps(_g41_card_for_inject, ...) + "\n\n"
       └─ user_prompt = _g41_card_block + user_prompt  ← LLM 입력: _card_metadata 제외

LLM (scene_detail / v17 prompt)
  ├─ system.md: Background Binding compact section (Rule A/C/E prose 삭제 → 22-line reminder)
  └─ user_prompt: [RenderPromptCard v1] + {5 contract fields only, no _card_metadata} + ...

canary measurement
  ├─ g4_2_camera_wording.py → non-close framing shot t2i_prompt scan
  ├─ g4_2_close_forbidden.py → close framing shot t2i_prompt scan
  └─ g4_2_token_count.py → tiktoken cl100k_base system.md v16 vs v17
```

---

## Critical Details

### Error Handling

- `build_background_binding()` 의 `bg_owned is None` → `AppError(code="step.contract_violation")` — G4.1 에 이미 있음, G4.2 에서 변경 없음.
- sub-case D (fallback) 은 error 아님 — valid path. `constraints` 에 fallback string 1개 채움.
- `_card_metadata` 누락 시 canary 측정 실패 가능 — G4.2 구현 후 단일 shot 로컬 test 로 `_card_metadata` 존재 확인 필수.

### State Management

- CP 에 저장되는 `render_prompt_card` dict 은 `_card_metadata` 포함 (debug 목적).
- LLM inject 에는 `_card_metadata` 제외 (token budget + LLM confusion 방지).
- `canonicalize_render_prompt_card()` 는 hash 계산 시 `_card_metadata` pop (G4.1 이미 구현).
- 세 가지 isolation 이 동일 원칙 적용 — 이 중 하나라도 누락 시 hash drift 또는 token 증가 발생.

### Testing

- unit test fixture 에서 `None` vs `[]` 명시 구분 (R2-B4 carry from G4.1). `bg_owned or []` 패턴 금지.
- integration test 는 실제 `build_render_prompt_card()` 전체 call path 사용 (hardcoded empty inputs 트랩 방지 — G4.1 교훈).
- canary script test 는 PyTest 로 별도 작성하지 않음 — canary 는 production CP 데이터 의존이므로 script 자체 unit test 는 mock 기반 단순 smoke 만.

### Performance

- `_card_metadata` 추가는 CP write 시 ~100 bytes 증가 — 무시 가능.
- LLM inject strip 은 `dict comprehension` 1회 — 측정 불필요.
- v17 system.md token 감소 (-915 est. / ≥850 measured) 은 per-shot 비용 직접 감소.

### Security / Correctness

- `_card_metadata.rule_source` 의 rule letter ("A"/"C"/"E") 는 hardcoded — G4.2 scope 내에서 변경 불필요. 미래 Rule 추가 시 hash drift 없이 `_card_metadata` 만 갱신 가능 (격리 목적).
- v17 prompt 의 "card 가 primary contract — 이 섹션 prose 와 충돌 시 card 우선" 문구는 R1-I4 card-wins precedence 의 prompt-side enforcement.

### G4.3 Entry Condition (spec §7.3)

G4.2 완료 후 G4.3 진입을 위한 조건 (spec §7.3 / R2-B5 propagate):

- [ ] G4.2 owned violation rate ≤ G4.1 baseline (canary JSON 기록)
- [ ] **G4.2 close framing forbidden wording count == 0 strict** (candidate canary JSON 기록)
- [ ] G4.2 unit + integration test suite green
- [ ] v17 prompt push + tag 완료

G4.3 scope: ID-policy lift (C##O## composite, body-part, screen/photo/mirror, demographic fallback).

---

## Implementation Checklist Summary

### Phase 1 (Wave 1-A) — render_prompt_card.py
- [ ] Task 1.1: `_card_metadata` 삽입 + lift_status matrix
- [ ] Task 1.2: sub-case D fallback + 6 ground-truth + Rule A 4 forms
- [ ] Task 1.3: 버전 상수 유지 + docstring

### Phase 2 (Wave 2) — detail_steps.py
- [ ] Task 2.1: inject 직전 `_card_metadata` strip
- [ ] Task 2.2: `SCENE_DETAIL_PROMPT_VERSION` bump v16 → v17

### Phase 3 (Wave 1-B) — v17 prompt
- [ ] Task 3.1: v17 디렉토리 + Rule A/C/E 삭제 + compact section
- [ ] Task 3.2: `detail_schema.json` verbatim copy
- [ ] Task 3.3: 확인 checklist

### Phase 4 (Wave 2) — canary scripts (4종)
- [ ] Task 4.1: `g4_2_camera_wording.py`
- [ ] Task 4.2: `g4_2_close_forbidden.py`
- [ ] Task 4.3: `g4_2_token_count.py`
- [ ] Task 4.4: `g4_2_owned_violations.py` (plan-R1-B2 신규)

### Phase 5 (Wave 3) — tests
- [ ] Task 5.1: **23 unit tests** (plan-R1-I8 확정)
- [ ] Task 5.2: **10 integration tests** (plan-R1-I2 확정)
- [ ] Task 5.3: regression 확인

### Phase 6 (Wave 4) — canary execution
- [ ] Task 6.1: baseline capture (v16)
- [ ] Task 6.2: candidate capture (v17)
- [ ] Task 6.3: exit criteria 판정 (5 gates)

### Phase 7 (Wave 4) — commit + dual review
- [ ] Task 7.1: single commit push
- [ ] Task 7.2: Codex 리뷰
- [ ] Task 7.3: Claude 리뷰
