# Area C — reproduction_surface_rule SOT Implementation Plan

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

**Goal:** `_ID_REPRODUCTION_SURFACES` noun list 와 그 consumer substring 매칭을 제거하고, Area A 의 `directionality_class` SOT (`content_surface` ∪ `reflective_surface`) 를 재사용해 `reproduction_surface_rule.applies: bool` minimal contract 로 마이그레이션.

**Architecture:** 3 module 변경 (producer `render_prompt_card.py` + consumer `visible_entities_validator.py` + prompt `scene_detail/22.202605122049/`). LLM 이 emit 하는 `directionality_class` enum 만 보고 producer 가 boolean derive, consumer 는 boolean 만 소비. 모든 contract 위반 fail-fast (Gate 4 정합). 8 task 시퀀셜.

**Tech Stack:** Python 3.11, pytest, AppError (`backend/app/core/errors.py`), prompt_loader (`backend/app/modules/llm/prompt_loader.py`), Area A SOT (`shot_staging/9.202605121441/schema.json`).

**Spec reference:** [`docs/superpowers/specs/2026-05-12-area-c-reproduction-surface-sot-design.md`](../specs/2026-05-12-area-c-reproduction-surface-sot-design.md)

**baseline HEAD:** `97d64ec` (spec push 직후).

**Subagent model policy:** 모든 subagent dispatch 는 `model=opus` (Opus 4.7). cheap-model 최적화 금지 ([[feedback_subagent_model_opus]] 정합).

---

## File Structure

### Production code (modify)

| 파일 | 변경 영역 | Task |
|---|---|---|
| `backend/app/core/steps/render_prompt_card.py` | module-level constant (delete `_ID_REPRODUCTION_SURFACES`, add `_ALLOWED_DIRECTIONALITY_CLASSES` + `_REPRODUCTION_SURFACE_CLASSES`) + helper `_extract_key_bg_elements_or_raise` + `build_id_policy` signature 확장 + `reproduction_surface_rule` builder 갱신 + `build_render_prompt_card` caller 갱신 | T1, T2 |
| `backend/app/core/visible_entities_validator.py` | line 230-241 substring 분기 → `applies` bool 검사 + non-bool fail-fast + line 164-197 docstring 갱신 | T3 |

### Prompt pack (create)

| 파일 | 변경 | Task |
|---|---|---|
| `prompts/_base/scene_detail/22.202605122049/system.md` (NEW) | v21 copy + line 49/57/438 noun list 제거, `applies` boolean 의미로 prose 재작성 | T4 |
| `prompts/_base/scene_detail/22.202605122049/detail_schema.json` (NEW) | v21 copy + line 16 "사진·거울" noun 제거 | T4 |

### Tests (create / modify)

| 파일 | 변경 | Task |
|---|---|---|
| `backend/tests/core/steps/test_render_prompt_card_helper.py` (NEW) | helper unit tests (5 case) | T1 |
| `backend/tests/core/steps/test_render_prompt_card.py` (modify) | build_id_policy unit tests 8 case 확장 + 기존 `applies_to_surfaces` 검사 제거 / 갱신 | T2 |
| `backend/tests/core/test_visible_entities_validator.py` (modify) | consumer unit tests 6 case 확장 + 기존 substring 매칭 테스트 갱신 | T3 |
| `backend/tests/_gate/test_reproduction_surface_rule_shape.py` (NEW) | RPC shape gate (deny-list + require) | T5 |
| `backend/tests/_gate/test_scene_detail_active_pack_reproduction.py` (NEW) | prompt drift gate (enumeration-only) | T6 |
| `backend/tests/_gate/test_orientation_enum_alignment.py` (modify) | 2 new test functions — render_prompt_card constant ↔ schema/ORIENTATION_REQUIRED_CLASSES | T7 |

### Canary

| 위치 | 내용 | Task |
|---|---|---|
| PID `02829fe8` ep1 S26/6 (content_surface element) | RPC build + consumer behavior + prompt_loader active 검증 — 4 closure criteria | T8 |

---

## Task 1: Producer constants + helper

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:130-160` (constant area)
- Create: `backend/tests/core/steps/test_render_prompt_card_helper.py`

**Why this task first:** Tasks 2, 7 모두 본 task 의 constant + helper 의존. 본 task 는 독립적으로 build 및 test 가능.

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

Create `backend/tests/core/steps/test_render_prompt_card_helper.py`:

```python
"""Unit tests for _extract_key_bg_elements_or_raise helper (Area C migration).

helper 가 staging / shot_info 의 4 시나리오를 fail-fast 정합으로 처리하는지
검증. Gate 4 (no silent fallback) 정합 — 모든 contract 위반은 AppError raise.
"""
import pytest

from app.core.errors import AppError
from app.core.steps.render_prompt_card import _extract_key_bg_elements_or_raise


def test_staging_not_applicable_returns_empty_list():
    """shot_info.staging_not_applicable=True → []."""
    result = _extract_key_bg_elements_or_raise(
        staging=None,
        shot_info={"staging_not_applicable": True},
    )
    assert result == []


def test_staging_none_without_flag_raises():
    """staging=None + staging_not_applicable 미설정 → AppError(staging_required)."""
    with pytest.raises(AppError) as exc_info:
        _extract_key_bg_elements_or_raise(staging=None, shot_info={})
    assert exc_info.value.code == "render_prompt_card.staging_required"


def test_staging_with_empty_key_bg_elements_returns_empty():
    """staging.key_bg_elements=[] → [] (정상 absence)."""
    result = _extract_key_bg_elements_or_raise(
        staging={"key_bg_elements": []},
        shot_info={},
    )
    assert result == []


def test_staging_with_populated_key_bg_elements_passes_through():
    """staging.key_bg_elements=[{...}] → list 그대로."""
    element = {"element": "photograph on the wall",
               "directionality_class": "content_surface",
               "orientation": "front face visible"}
    result = _extract_key_bg_elements_or_raise(
        staging={"key_bg_elements": [element]},
        shot_info={},
    )
    assert result == [element]


def test_staging_with_non_list_key_bg_elements_raises():
    """staging.key_bg_elements 가 list 아님 → AppError(invalid)."""
    with pytest.raises(AppError) as exc_info:
        _extract_key_bg_elements_or_raise(
            staging={"key_bg_elements": "not-a-list"},
            shot_info={},
        )
    assert exc_info.value.code == "render_prompt_card.staging_key_bg_elements_invalid"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card_helper.py -v`
Expected: 5 FAIL with `ImportError: cannot import name '_extract_key_bg_elements_or_raise'`

- [ ] **Step 3: Implement constants and helper**

Edit `backend/app/core/steps/render_prompt_card.py`:

**3a. Delete `_ID_REPRODUCTION_SURFACES`** (line 148-160 currently):

```python
# DELETE these lines (line 148-160):
# # G4.3 R1R2-B3 ground-truth: reproduction surface keywords (9).
# # canary `g4_3_reproduction_surface.py` ±50 chars window proximity source.
# _ID_REPRODUCTION_SURFACES: Tuple[str, ...] = (
#     "photograph",
#     "poster",
#     "painting",
#     "portrait",
#     "monitor",
#     "TV",
#     "mirror",
#     "window reflection",
#     "projection",
# )
```

**3b. Add Area C constants** (insert at the deleted location — module-level, near `_ID_*` siblings):

```python
# Area C (2026-05-12) — directionality_class enum drift defense + reproduction
# surface derivation. shot_staging/9.../schema.json:58-64 의 5 enum 과 정확
# 동일. drift 차단은 `test_orientation_enum_alignment.py` 가 검증.
_ALLOWED_DIRECTIONALITY_CLASSES: frozenset = frozenset({
    "content_surface",
    "reflective_surface",
    "transparent_surface",
    "directional_3d",
    "non_directional",
})

# reproduction surface = content_surface ∪ reflective_surface (Area C SOT).
# shot_staging.ORIENTATION_REQUIRED_CLASSES 와 정확 동일.
_REPRODUCTION_SURFACE_CLASSES: frozenset = frozenset({
    "content_surface",
    "reflective_surface",
})
```

**3c. Add helper** (insert directly above `build_id_policy` function, around line 1005):

```python
def _extract_key_bg_elements_or_raise(
    staging: Optional[Dict[str, Any]],
    shot_info: Dict[str, Any],
) -> List[Dict[str, Any]]:
    """Area C migration — resolve shot_staging.key_bg_elements for build_id_policy.

    Returns list 또는 raise AppError (Gate 4 정합).

    `build_render_strategy` (line 980) 이 이미 staging fail-fast 를 처리 —
    본 helper 는 그 fail-fast 와 분리된 단일 contract (Area C 의 reproduction
    derivation 영역). silent fallback 0.

    Returns:
        list: 정상 key_bg_elements (빈 list 포함 — staging_not_applicable 케이스).

    Raises:
        AppError(staging_required): staging=None + flag 미설정.
        AppError(staging_key_bg_elements_invalid): key_bg_elements 가 list 아님.
    """
    if shot_info.get("staging_not_applicable") is True:
        return []
    if staging is None:
        raise AppError(
            code="render_prompt_card.staging_required",
            message=(
                "_extract_key_bg_elements_or_raise: staging is None and "
                "staging_not_applicable is not set."
            ),
        )
    key_bg = staging.get("key_bg_elements")
    if not isinstance(key_bg, list):
        raise AppError(
            code="render_prompt_card.staging_key_bg_elements_invalid",
            message=(
                f"_extract_key_bg_elements_or_raise: key_bg_elements "
                f"expected list, got {type(key_bg).__name__}."
            ),
        )
    return key_bg
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card_helper.py -v`
Expected: 5 PASS

- [ ] **Step 5: Verify no broader regression**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card.py -v 2>&1 | tail -30`
Expected: 기존 test 회귀 0. (단 `_ID_REPRODUCTION_SURFACES` reference 에러 발생 가능 — 본 task 가 deletion 한 constant 가 다른 test 또는 build_id_policy 에 여전히 reference 되면 fail. Task 2 가 build_id_policy 갱신 시 해결. 본 step 에서는 fail count 기록만)

- [ ] **Step 6: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/core/steps/test_render_prompt_card_helper.py
git commit -m "feat(area-c): constants + helper for reproduction_surface_rule SOT

- Delete _ID_REPRODUCTION_SURFACES noun list
- Add _ALLOWED_DIRECTIONALITY_CLASSES (5 enum, Area A schema 정합)
- Add _REPRODUCTION_SURFACE_CLASSES (content/reflective subset)
- Add _extract_key_bg_elements_or_raise helper (Gate 4 fail-fast)
- 5 helper unit tests (TDD)

Task 2 가 build_id_policy 의 _ID_REPRODUCTION_SURFACES reference 제거.
"
```

---

## Task 2: build_id_policy 갱신 + caller

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py:1007-1156` (`build_id_policy`), `3263-3322` (`build_render_prompt_card` caller)
- Modify: `backend/tests/core/steps/test_render_prompt_card.py` (기존 `applies_to_surfaces` 검사 갱신 + 8 신규 case)

**Dependency:** Task 1 (constants + helper).

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

Add to `backend/tests/core/steps/test_render_prompt_card.py` (append after existing tests):

```python
# Area C — build_id_policy reproduction_surface_rule migration tests
import pytest
from app.core.errors import AppError
from app.core.steps.render_prompt_card import build_id_policy


def _make_element(dc: str) -> dict:
    """Test fixture — minimal valid key_bg_elements entry with directionality_class."""
    return {
        "element": f"test-{dc}",
        "state": "neutral",
        "orientation": "front" if dc in {"content_surface", "reflective_surface"} else "",
        "camera_use": "ambient",
        "directionality_class": dc,
    }


def test_reproduction_applies_false_when_no_content_or_reflective():
    """key_bg_elements 가 transparent / directional_3d / non_directional 만 → applies=False."""
    policy = build_id_policy(
        visible_entities=[],
        outlook_pairs=[],
        perception_mode=None,
        key_bg_elements=[
            _make_element("transparent_surface"),
            _make_element("directional_3d"),
            _make_element("non_directional"),
        ],
    )
    assert policy["reproduction_surface_rule"]["applies"] is False


def test_reproduction_applies_true_with_content_surface():
    """key_bg_elements 안 content_surface → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_make_element("content_surface")],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_reproduction_applies_true_with_reflective_surface():
    """key_bg_elements 안 reflective_surface → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_make_element("reflective_surface")],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_reproduction_applies_true_with_mixed():
    """content_surface + non_directional 혼합 → applies=True."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[
            _make_element("non_directional"),
            _make_element("content_surface"),
        ],
    )
    assert policy["reproduction_surface_rule"]["applies"] is True


def test_reproduction_applies_false_with_empty_key_bg_elements():
    """key_bg_elements=[] → applies=False."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[],
    )
    assert policy["reproduction_surface_rule"]["applies"] is False


def test_reproduction_directionality_class_missing_raises():
    """element 안 directionality_class 부재 → AppError(missing)."""
    bad_element = {"element": "test", "state": "", "orientation": "", "camera_use": ""}
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_missing"


def test_reproduction_directionality_class_empty_string_raises():
    """element 안 directionality_class='' → AppError(missing)."""
    bad_element = _make_element("content_surface")
    bad_element["directionality_class"] = ""
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_missing"


def test_reproduction_directionality_class_invalid_enum_raises():
    """element 안 directionality_class='unknown' → AppError(invalid)."""
    bad_element = _make_element("content_surface")
    bad_element["directionality_class"] = "unknown"
    with pytest.raises(AppError) as exc_info:
        build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
            key_bg_elements=[bad_element],
        )
    assert exc_info.value.code == "render_prompt_card.directionality_class_invalid"


def test_reproduction_surface_rule_shape():
    """RPC schema = {applies: bool, id_use: str, rationale_summary: str}.

    deny-list: applies_to_surfaces / source 부재."""
    policy = build_id_policy(
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        key_bg_elements=[_make_element("non_directional")],
    )
    rule = policy["reproduction_surface_rule"]
    assert "applies" in rule and isinstance(rule["applies"], bool)
    assert "id_use" in rule and isinstance(rule["id_use"], str)
    assert "rationale_summary" in rule and isinstance(rule["rationale_summary"], str)
    assert "applies_to_surfaces" not in rule
    assert "source" not in rule
```

Also delete or update any existing `applies_to_surfaces` assertion in the test file:

```bash
grep -n "applies_to_surfaces" backend/tests/core/steps/test_render_prompt_card.py
```

For each match, replace `"applies_to_surfaces" in ...` 단언 (있다면) 을 위 `test_reproduction_surface_rule_shape` 의 deny assertion 으로 변경.

- [ ] **Step 2: Run test to verify it fails**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card.py -v -k "reproduction"`
Expected: 9 FAIL with `TypeError: build_id_policy() missing 1 required keyword-only argument: 'key_bg_elements'`

- [ ] **Step 3: Implement build_id_policy signature 확장**

Edit `backend/app/core/steps/render_prompt_card.py`:

**3a. Update `build_id_policy` signature** (line 1007-1012):

```python
def build_id_policy(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
    key_bg_elements: List[Dict[str, Any]],   # Area C — new required arg
) -> Dict[str, Any]:
```

**3b. Replace `reproduction_surface_rule` builder** (line 1112-1121):

```python
    # G4.3 sub-field 4 (Area C migration 2026-05-12): noun list 제거.
    # directionality_class SOT (shot_staging) 에서 applies bool derive.
    applies = False
    for elem in key_bg_elements:
        dc = elem.get("directionality_class")
        if not isinstance(dc, str) or dc == "":
            raise AppError(
                code="render_prompt_card.directionality_class_missing",
                message=(
                    "shot_staging key_bg_elements element missing/empty "
                    "directionality_class — shot_staging is stale; "
                    "rerun shot_staging with Area A schema."
                ),
            )
        if dc not in _ALLOWED_DIRECTIONALITY_CLASSES:
            raise AppError(
                code="render_prompt_card.directionality_class_invalid",
                message=(
                    f"directionality_class={dc!r} not in allowed enum "
                    f"{sorted(_ALLOWED_DIRECTIONALITY_CLASSES)}."
                ),
            )
        if dc in _REPRODUCTION_SURFACE_CLASSES:
            applies = True
            # fall-through (break X) — 나머지 element enum 검증 도 진행.

    reproduction_surface_rule = {
        "applies": applies,
        "id_use": "forbidden — common noun + demographic descriptor only",
        "rationale_summary": (
            "C##O## 은 얼굴 reference 이미지를 원본 해상도로 inject 하므로 "
            "사진/화면/반사 표면 안 얼굴이 표면 밖 실물 크기로 합성됨. "
            "이 rule 은 표면 안 얼굴 (face) 에만 적용 — 물리적 사진/액자/문서 "
            "prop 자체는 P## ID 보존 (Patch A)."
        ),
    }
```

**3c. Update caller in `build_render_prompt_card`** (line 3307-3310):

Find:
```python
    card["id_policy"] = build_id_policy(
        visible_entities=visible_entities, outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
    )
```

Replace with:
```python
    key_bg_elements = _extract_key_bg_elements_or_raise(staging, shot_info)
    card["id_policy"] = build_id_policy(
        visible_entities=visible_entities, outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
        key_bg_elements=key_bg_elements,
    )
```

Note: `shot_info` is already in scope of `build_render_prompt_card` (signature line 3268).

- [ ] **Step 4: Run test to verify it passes**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card.py -v -k "reproduction"`
Expected: 9 PASS

- [ ] **Step 5: Run broader render_prompt_card tests**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card.py -v 2>&1 | tail -30`
Expected: 회귀 0. 기존 `applies_to_surfaces` 검사 (있었다면) 갱신 완료. 새 fail 발생 시:
- `key_bg_elements` arg 누락한 기존 test fixture → fixture 갱신.
- `_ID_REPRODUCTION_SURFACES` reference 남아있는 test → 본 task 가 cover.

- [ ] **Step 6: Run helper test (Task 1 carryforward)**

Run: `cd backend && pytest tests/core/steps/test_render_prompt_card_helper.py -v`
Expected: 5 PASS (Task 1 의 helper test 여전히 통과).

- [ ] **Step 7: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/core/steps/test_render_prompt_card.py
git commit -m "feat(area-c): build_id_policy reproduction_surface_rule SOT migration

- build_id_policy(key_bg_elements: List) signature 확장
- reproduction_surface_rule = {applies: bool, id_use, rationale_summary}
- strict enum validation (missing / empty / invalid → AppError)
- build_render_prompt_card caller 가 helper 경유 key_bg_elements forward
- 9 build_id_policy unit tests (TDD)
- 기존 applies_to_surfaces 검사 갱신 (deny shape)
"
```

---

## Task 3: Consumer 갱신 (visible_entities_validator)

**Files:**
- Modify: `backend/app/core/visible_entities_validator.py:164-241`
- Modify: `backend/tests/core/test_visible_entities_validator.py`

**Dependency:** Task 2 의 producer 갱신 (consumer 가 `applies` field 소비).

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

Add to `backend/tests/core/test_visible_entities_validator.py`:

```python
# Area C — consumer reproduction_surface_rule.applies migration tests
import pytest
from app.core.errors import AppError
from app.core.visible_entities_validator import _forward_enforcement_exempt


def _make_rpc(applies):
    """Test fixture — minimal RPC with reproduction_surface_rule."""
    return {
        "render_strategy": {"mode": "default"},
        "id_policy": {
            "reproduction_surface_rule": {
                "applies": applies,
                "id_use": "forbidden",
                "rationale_summary": "...",
            },
        },
    }


def test_consumer_applies_true_returns_exempt():
    """applies=True → (True, '...applies == True')."""
    exempt, reason = _forward_enforcement_exempt(
        rpc=_make_rpc(True),
        prompt="any prompt with no surface words",
    )
    assert exempt is True
    assert "reproduction_surface_rule.applies == True" in reason


def test_consumer_applies_false_falls_through():
    """applies=False → fall-through (다른 분기 검사 진행, 본 케이스 = False)."""
    exempt, reason = _forward_enforcement_exempt(
        rpc=_make_rpc(False),
        prompt="prompt without focus on / close on / tight on",
    )
    assert exempt is False
    assert reason == ""


def test_consumer_field_absent_falls_through():
    """reproduction_surface_rule field 부재 (legacy/stale card) → fall-through."""
    rpc = {"render_strategy": {"mode": "default"}, "id_policy": {}}
    exempt, reason = _forward_enforcement_exempt(rpc=rpc, prompt="any")
    assert exempt is False


def test_consumer_applies_none_raises():
    """applies=None → AppError(malformed)."""
    rpc = _make_rpc(None)
    with pytest.raises(AppError) as exc_info:
        _forward_enforcement_exempt(rpc=rpc, prompt="any")
    assert exc_info.value.code == "visible_entities_validator.reproduction_surface_rule_malformed"


def test_consumer_applies_string_raises():
    """applies='true' (string) → AppError(malformed)."""
    rpc = _make_rpc("true")
    with pytest.raises(AppError) as exc_info:
        _forward_enforcement_exempt(rpc=rpc, prompt="any")
    assert exc_info.value.code == "visible_entities_validator.reproduction_surface_rule_malformed"


def test_consumer_no_substring_match_when_applies_false():
    """Area C 핵심 — applies=False 면 prompt 안 'photograph' 등이 있어도 exempt X.

    기존 substring 매칭 제거 verify."""
    rpc = _make_rpc(False)
    prompt_with_surface_words = (
        "a photograph on the wall, mirror reflection visible, "
        "poster behind subject"
    )
    exempt, reason = _forward_enforcement_exempt(
        rpc=rpc, prompt=prompt_with_surface_words,
    )
    assert exempt is False, (
        "substring 매칭 분기 잔존 — Area C migration 미완"
    )
```

Also search and delete existing substring-based tests:

```bash
grep -n "applies_to_surfaces\|photograph.*lower\|mirror.*lower" \
    backend/tests/core/test_visible_entities_validator.py
```

For each match, replace with `applies` bool 검사 또는 delete (legacy substring 검사 시나리오는 제거).

- [ ] **Step 2: Run test to verify it fails**

Run: `cd backend && pytest tests/core/test_visible_entities_validator.py -v -k "consumer"`
Expected: 6 FAIL with assertion errors (substring 분기가 여전히 동작 또는 contract mismatch).

- [ ] **Step 3: Implement consumer migration**

Edit `backend/app/core/visible_entities_validator.py`:

**3a. Replace line 230-241 substring 분기**:

Find:
```python
    repro = id_policy.get("reproduction_surface_rule")
    if isinstance(repro, dict):
        surfaces = repro.get("applies_to_surfaces")
        if isinstance(surfaces, list):
            for s in surfaces:
                if isinstance(s, str) and s and s.lower() in prompt_lower:
                    return True, (
                        f"reproduction_surface_rule.applies_to_surfaces — "
                        f"{s!r} found in prompt"
                    )

    return False, ""
```

Replace with:
```python
    # Area C (2026-05-12) — directionality_class SOT 기반 boolean 검사.
    # substring 매칭 제거: producer (render_prompt_card.build_id_policy) 가
    # shot_staging.key_bg_elements[*].directionality_class 에서 derive 한
    # applies bool 만 소비.
    repro = id_policy.get("reproduction_surface_rule")
    if isinstance(repro, dict):
        applies = repro.get("applies")
        if applies is True:
            return True, "reproduction_surface_rule.applies == True"
        if applies is False:
            pass  # fall-through to remaining checks (현재 없음 — 함수 끝).
        else:
            # Gate 4 정합 — non-bool / None 은 silent 처리 금지.
            raise AppError(
                code="visible_entities_validator.reproduction_surface_rule_malformed",
                message=(
                    f"reproduction_surface_rule.applies must be bool, "
                    f"got {type(applies).__name__} ({applies!r})."
                ),
            )

    return False, ""
```

**3b. Update docstring** (line 164-197) — locate `_forward_enforcement_exempt` docstring section listing the 3 exemption conditions. Find condition 3:

```python
      3. reproduction surface keyword (photograph / poster / mirror 등) 이
         prompt 에 등장 — reproduction_surface_rule.id_use=forbidden 적용,
         보통명사 사용.
```

Replace with:
```python
      3. card 의 reproduction_surface_rule.applies 가 True — shot_staging
         directionality_class 가 content_surface 또는 reflective_surface 인
         element 를 LLM 이 명시 (Area C migration 2026-05-12). 표면 안 인물
         ID 사용 금지, 보통명사 + demographic descriptor 사용. substring
         매칭 제거 — applies bool 직접 소비.
```

Also update line 169 의 paragraph (`reproduction_surface_rule.applies_to_surfaces` 참조):

Find:
```python
    v21 prompt 의 다른 섹션 룰 (`render_strategy.mode == "partial_focus"` /
    `id_policy.body_part_focus_rule.trigger_phrases` / `id_policy.
    reproduction_surface_rule.applies_to_surfaces`) 이 visible_entities 안
```

Replace with:
```python
    v21 prompt 의 다른 섹션 룰 (`render_strategy.mode == "partial_focus"` /
    `id_policy.body_part_focus_rule.trigger_phrases` / `id_policy.
    reproduction_surface_rule.applies`) 이 visible_entities 안
```

**3c. Ensure `AppError` import**:

Check the top of `backend/app/core/visible_entities_validator.py` for:
```python
from app.core.errors import AppError
```

If missing, add it.

- [ ] **Step 4: Run test to verify it passes**

Run: `cd backend && pytest tests/core/test_visible_entities_validator.py -v -k "consumer"`
Expected: 6 PASS

- [ ] **Step 5: Run broader validator tests**

Run: `cd backend && pytest tests/core/test_visible_entities_validator.py -v 2>&1 | tail -30`
Expected: 회귀 0. 기존 substring 매칭 시나리오는 본 task 가 갱신.

- [ ] **Step 6: Run sanity check on dependent integrations**

Run: `cd backend && pytest tests/core/steps/ tests/core/ -v 2>&1 | tail -30`
Expected: producer (Task 2) + consumer (Task 3) 정합. 회귀 0.

- [ ] **Step 7: Commit**

```bash
git add backend/app/core/visible_entities_validator.py \
        backend/tests/core/test_visible_entities_validator.py
git commit -m "feat(area-c): visible_entities_validator applies bool migration

- substring 매칭 분기 (line 230-241) 제거 → applies bool 검사
- applies=True → exempt 부여
- applies=False / field 부재 → fall-through
- applies non-bool (None / string 등) → AppError fail-fast (Gate 4)
- _forward_enforcement_exempt docstring 갱신 (line 164-197)
- 6 consumer unit tests (TDD) + 기존 substring 시나리오 갱신
"
```

---

## Task 4: scene_detail v22 prompt pack

**Files:**
- Create: `prompts/_base/scene_detail/22.202605122049/system.md`
- Create: `prompts/_base/scene_detail/22.202605122049/detail_schema.json`

**Dependency:** 본 task 는 코드 무관 — Tasks 1-3 와 독립. Task 5+ canary 가 이 prompt 의 active 상태 검증.

- [ ] **Step 1: Create new pack directory**

```bash
mkdir -p prompts/_base/scene_detail/22.202605122049
```

- [ ] **Step 2: Copy v21 → v22**

```bash
cp prompts/_base/scene_detail/21.202605062217/system.md \
   prompts/_base/scene_detail/22.202605122049/system.md
cp prompts/_base/scene_detail/21.202605062217/detail_schema.json \
   prompts/_base/scene_detail/22.202605122049/detail_schema.json
```

- [ ] **Step 3: Modify system.md (line 49 / 57 / 438)**

Edit `prompts/_base/scene_detail/22.202605122049/system.md`:

**Line 49** — Find:
```
- `reproduction_surface_rule.applies_to_surfaces` (사진·포스터·모니터·거울·
```

Replace the full bullet (and its continuation lines until next `- ` bullet or section heading) with:
```
- `reproduction_surface_rule.applies` (boolean) — `true` 일 때 그 shot 안에 콘텐츠를 재현하는 표면 (한 면 콘텐츠 surface 또는 반사 surface) 이 존재함을 의미. 표면 분류는 shot_staging 의 `directionality_class` SOT 가 결정 — prompt 안에서 별도 noun 매칭 / 단어 판단 금지. `applies==true` 인 shot 에서는 그 surface 안 인물 ID 사용 금지 (보통명사 + demographic descriptor 사용).
```

**Line 57** (perception_mode reference) — Find:
```
- `perception_mode` 가 mirror / reflection / through_device / projection 이면 `reproduction_surface_rule` 와 별도로 perception_mode constraint 가 우선 적용.
```

Keep as-is — `perception_mode` 는 closed enum (LLM emit), 본 area 영역 밖. 그러나 `reproduction_surface_rule` 의미 paraphrase 가 필요하면 다음과 같이 갱신:
```
- `perception_mode` 가 mirror / reflection / through_device / projection 이면 `reproduction_surface_rule.applies` 와 별도로 perception_mode constraint 가 우선 적용.
```

**Line 438 area** — Find any reference to `reproduction_surface_rule` 안 surface keywords/applies_to_surfaces:
```
`body_part_focus_rule.trigger_phrases` (line 41-44) / `reproduction_surface_
```

Replace surface keyword references with `applies` boolean. Specifically search:
```bash
grep -n "applies_to_surfaces\|사진·\|거울\|포스터" prompts/_base/scene_detail/22.202605122049/system.md
```

Replace each match with `applies` boolean prose or remove the noun list inline. Do NOT remove `mirror / reflection / through_device / projection` from `perception_mode` line — that is a closed enum, outside Area C scope.

- [ ] **Step 4: Modify detail_schema.json (line 16)**

Edit `prompts/_base/scene_detail/22.202605122049/detail_schema.json`:

**Line 16** — Find:
```
"t2i_prompt": {"type": "string", "description": "T2I 프롬프트. 인물+아웃룩은 복합 ID(C01O02)를 사용 — 합성 단계가 'the character from Image N'으로 자동 치환. 신체 부위 클로즈업/사진·거울 속 인물 등 system prompt가 명시한 예외 구도에서는 보통명사로 대체. 소품은 P01, 배경은 [L01: 설명] 형태. (정책 상세는 system prompt 참조)"},
```

Replace with:
```
"t2i_prompt": {"type": "string", "description": "T2I 프롬프트. 인물+아웃룩은 복합 ID(C01O02)를 사용 — 합성 단계가 'the character from Image N'으로 자동 치환. 신체 부위 클로즈업, reproduction surface (한 면 콘텐츠 표면 또는 반사 표면 안 인물 — system prompt 가 명시) 등 예외 구도에서는 보통명사로 대체. 소품은 P01, 배경은 [L01: 설명] 형태. (정책 상세는 system prompt 참조)"},
```

"사진·거울 속 인물" → "reproduction surface (한 면 콘텐츠 표면 또는 반사 표면 안 인물 — system prompt 가 명시)". noun list 제거, 추상 용어로.

- [ ] **Step 5: Verify prompt_loader resolves v22 as active**

Run:
```bash
cd backend && python -c "
from app.modules.llm.prompt_loader import _resolve_stem_in_pack
import os
pack_dir = os.path.abspath('../prompts/_base/scene_detail')
# 구체적 API 는 prompt_loader 내부 — 단순 검증은 디렉토리 ordering
import pathlib
dirs = sorted(
    p.name for p in pathlib.Path(pack_dir).iterdir() if p.is_dir()
)
print('scene_detail pack dirs (sorted):', dirs)
print('latest (lexicographic):', dirs[-1] if dirs else None)
"
```

Expected: `latest = '22.202605122049'`. v21 archive 보존, v22 active.

- [ ] **Step 6: Run prompt-related broader test**

Run: `cd backend && pytest tests/ -v -k "prompt_loader" 2>&1 | tail -30`
Expected: 회귀 0. v22 디렉토리가 prompt_loader resolution 에 정상 통합.

- [ ] **Step 7: Commit**

```bash
git add prompts/_base/scene_detail/22.202605122049/
git commit -m "feat(area-c): scene_detail v22 prompt pack — reproduction_surface noun list 제거

- system.md: applies_to_surfaces 노운 리스트 (사진·포스터·모니터·거울·...) →
  applies boolean prose. shot_staging directionality_class SOT 참조 명시.
- detail_schema.json: line 16 '사진·거울 속 인물' → 'reproduction surface
  (한 면 콘텐츠 / 반사 표면 안 인물 — system prompt 가 명시)' 추상화.
- v21 archive 보존 (CLAUDE.md '프롬프트 파일 덮어쓰기 금지' 정합).
- prompt_loader resolution: v22 active.
"
```

---

## Task 5: RPC shape gate (deny-list)

**Files:**
- Create: `backend/tests/_gate/test_reproduction_surface_rule_shape.py`

**Dependency:** Task 2 (producer 가 새 shape 출력).

- [ ] **Step 1: Write the gate tests**

Create `backend/tests/_gate/test_reproduction_surface_rule_shape.py`:

```python
"""RPC reproduction_surface_rule shape gate (Area C migration 2026-05-12).

Producer (render_prompt_card.build_id_policy) 가 항상 다음 shape 출력 보장:
- require: {applies: bool, id_use: str, rationale_summary: str}
- deny: {applies_to_surfaces, source}
- 그 외 future extra key 허용 (gate fail 아님)

기존 noun list 회귀 (applies_to_surfaces) 와 debug field 회귀 (source) 차단.
"""
import pytest

from app.core.steps.render_prompt_card import build_id_policy


def _make_element(dc: str) -> dict:
    return {
        "element": "test",
        "state": "neutral",
        "orientation": "front" if dc in {"content_surface", "reflective_surface"} else "",
        "camera_use": "ambient",
        "directionality_class": dc,
    }


@pytest.fixture
def sample_policy():
    """Build a real id_policy via builder (Task 2 의 producer)."""
    return build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode=None,
        key_bg_elements=[_make_element("content_surface")],
    )


def test_reproduction_surface_rule_is_dict(sample_policy):
    """field 존재 + dict shape."""
    rule = sample_policy.get("reproduction_surface_rule")
    assert isinstance(rule, dict), "reproduction_surface_rule must be dict"


def test_applies_present_and_bool(sample_policy):
    """applies 필수 + bool."""
    rule = sample_policy["reproduction_surface_rule"]
    assert "applies" in rule
    assert isinstance(rule["applies"], bool)


def test_id_use_present_and_str(sample_policy):
    """id_use 필수 + str."""
    rule = sample_policy["reproduction_surface_rule"]
    assert "id_use" in rule
    assert isinstance(rule["id_use"], str)


def test_rationale_summary_present_and_str(sample_policy):
    """rationale_summary 필수 + str."""
    rule = sample_policy["reproduction_surface_rule"]
    assert "rationale_summary" in rule
    assert isinstance(rule["rationale_summary"], str)


def test_applies_to_surfaces_deny(sample_policy):
    """Area C migration 핵심 — applies_to_surfaces field 회귀 차단."""
    rule = sample_policy["reproduction_surface_rule"]
    assert "applies_to_surfaces" not in rule, (
        "applies_to_surfaces 가 다시 등장 = Area C migration regression. "
        "RPC contract 는 applies bool 만 보장."
    )


def test_source_deny(sample_policy):
    """debug field source 회귀 차단 — _card_metadata 영역으로 분리."""
    rule = sample_policy["reproduction_surface_rule"]
    assert "source" not in rule, (
        "source 가 id_policy 안 등장 = debug field 회귀. "
        "debug metadata 는 _card_metadata 영역 (LLM 입력 분리)."
    )


def test_extra_future_key_allowed(sample_policy):
    """deny-list 외 future extra key 허용 — gate fail 아님."""
    # 본 test 는 future 진화 가능성 명시 — 현 builder 가 명시 4 key 만 출력.
    rule = sample_policy["reproduction_surface_rule"]
    known_keys = {"applies", "id_use", "rationale_summary"}
    extra = set(rule.keys()) - known_keys
    # 현재 extra=set(). future schema 진화 시 extra 가 늘어나도 본 test 는 fail X.
    assert extra <= set(), (
        f"현 builder 는 3 key 만 출력해야 하는데 extra={extra}. "
        f"새 key 추가 시 본 test 를 의도적으로 완화 (gate fail 아님)."
    )
```

- [ ] **Step 2: Run gate tests**

Run: `cd backend && pytest tests/_gate/test_reproduction_surface_rule_shape.py -v`
Expected: 7 PASS (Task 2 의 producer 가 이미 정확한 shape 출력).

- [ ] **Step 3: Verify gate triggers on regression simulation**

To verify gate's regression-blocking value, manually inject a regression and check:

```bash
cd backend
# 임시 patch — applies_to_surfaces 다시 추가
python -c "
from app.core.steps import render_prompt_card as mod
# 직접 dict 조작 (시뮬레이션)
def fake_build(*args, **kwargs):
    result = original_build(*args, **kwargs)
    result['reproduction_surface_rule']['applies_to_surfaces'] = ['photograph']
    return result
original_build = mod.build_id_policy
mod.build_id_policy = fake_build
# run gate test
import subprocess
r = subprocess.run(['pytest', 'tests/_gate/test_reproduction_surface_rule_shape.py::test_applies_to_surfaces_deny', '-v'], capture_output=True, text=True)
print('STDOUT:', r.stdout[-500:])
print('STDERR:', r.stderr[-300:])
"
```

Expected: `test_applies_to_surfaces_deny` FAIL. 즉 gate 가 regression 을 catch. (실제 patch 는 dry-run, 본 step 후 적용 X.)

- [ ] **Step 4: Commit**

```bash
git add backend/tests/_gate/test_reproduction_surface_rule_shape.py
git commit -m "test(area-c): RPC reproduction_surface_rule shape gate (deny-list)

- require: {applies: bool, id_use: str, rationale_summary: str}
- deny: {applies_to_surfaces, source} — Area C migration regression 차단
- 7 gate tests (existence + type + deny + future extra)
"
```

---

## Task 6: Prompt drift gate (enumeration-only)

**Files:**
- Create: `backend/tests/_gate/test_scene_detail_active_pack_reproduction.py`

**Dependency:** Task 4 (scene_detail v22 active).

- [ ] **Step 1: Write the gate tests**

Create `backend/tests/_gate/test_scene_detail_active_pack_reproduction.py`:

```python
"""scene_detail active pack reproduction surface enumeration drift gate.

Area C migration (2026-05-12) — scene_detail prompt 안 reproduction surface 의
9 노운 키워드 (photograph / poster / painting / portrait / monitor / TV /
mirror / window reflection / projection) 의 enumeration 형태 회귀 차단.

자연 문장 안 단독 등장 ("a photograph on the wall") 은 false-positive 회피 위해
허용. enumeration 형태만 차단:
- 괄호 내 4+ comma-separated noun (`(A, B, C, D)`)
- 4+ consecutive bullet list items (`- A\n- B\n- C\n- D`)
- heading 직후 4+ noun nominal sequence
"""
import pathlib
import re

import pytest

REPRODUCTION_NOUNS = frozenset({
    "photograph", "poster", "painting", "portrait",
    "monitor", "TV", "mirror", "projection",
    "window reflection",
    # Korean 의미적 등가
    "사진", "포스터", "그림", "초상화",
    "모니터", "거울", "투영", "창문 반사",
})

PROMPT_ROOT = pathlib.Path(__file__).parent.parent.parent.parent / "prompts" / "_base" / "scene_detail"


def _resolve_active_pack() -> pathlib.Path:
    """latest numeric desc 디렉토리 = active pack."""
    dirs = sorted(p for p in PROMPT_ROOT.iterdir() if p.is_dir())
    assert dirs, f"no scene_detail packs found at {PROMPT_ROOT}"
    return dirs[-1]


def _count_enumeration_violations(text: str) -> list[tuple[int, str]]:
    """Return list of (line_no, snippet) for enumeration violations.

    Detection:
    1. 괄호 안 4+ comma 또는 ` / ` 또는 ` · ` 구분 + 3+ reproduction noun 등장.
    2. 4+ consecutive `- ` bullets, 각 bullet 가 reproduction noun 단독 또는
       short noun phrase.
    """
    violations = []
    lines = text.splitlines()
    for idx, line in enumerate(lines, start=1):
        # (1) 괄호 enumeration
        for m in re.finditer(r"[\(\[]([^\)\]]{1,200})[\)\]]", line):
            inside = m.group(1)
            # split by , / · 또는 한국어 ㆍ
            parts = re.split(r"[,/·ㆍ]\s*", inside)
            noun_hits = sum(
                1 for p in parts if p.strip().lower() in {n.lower() for n in REPRODUCTION_NOUNS}
            )
            if len(parts) >= 4 and noun_hits >= 3:
                violations.append((idx, line.strip()[:120]))
    # (2) consecutive bullet enumeration
    bullet_run = []
    for idx, line in enumerate(lines, start=1):
        stripped = line.strip()
        if stripped.startswith("- ") or stripped.startswith("* "):
            content = stripped[2:].strip().lower()
            if content in {n.lower() for n in REPRODUCTION_NOUNS}:
                bullet_run.append((idx, line.strip()[:120]))
            else:
                if len(bullet_run) >= 4:
                    violations.extend(bullet_run)
                bullet_run = []
        else:
            if len(bullet_run) >= 4:
                violations.extend(bullet_run)
            bullet_run = []
    if len(bullet_run) >= 4:
        violations.extend(bullet_run)
    return violations


def test_active_pack_system_md_no_enumeration():
    """scene_detail active pack 의 system.md 안 noun enumeration 0."""
    active = _resolve_active_pack()
    system_md = active / "system.md"
    assert system_md.exists(), f"{system_md} missing"
    violations = _count_enumeration_violations(system_md.read_text(encoding="utf-8"))
    assert not violations, (
        f"scene_detail/{active.name}/system.md 안 reproduction noun "
        f"enumeration 잔존 ({len(violations)} hit): {violations[:5]}"
    )


def test_active_pack_detail_schema_no_enumeration():
    """scene_detail active pack 의 detail_schema.json 안 noun enumeration 0."""
    active = _resolve_active_pack()
    schema = active / "detail_schema.json"
    assert schema.exists(), f"{schema} missing"
    violations = _count_enumeration_violations(schema.read_text(encoding="utf-8"))
    assert not violations, (
        f"scene_detail/{active.name}/detail_schema.json 안 reproduction noun "
        f"enumeration 잔존 ({len(violations)} hit): {violations[:5]}"
    )


def test_natural_sentence_allowed():
    """자연 문장 안 단독 등장 — false-positive 회피."""
    text = "A photograph on the wall reflects ambient light, "\
           "while the mirror behind subject shows partial view."
    violations = _count_enumeration_violations(text)
    assert not violations, (
        f"자연 문장 false-positive: {violations}. "
        f"enumeration 형태만 차단해야 함."
    )


def test_self_test_enumeration_caught():
    """gate 신뢰성 lock-in — 명백한 enumeration 은 catch."""
    text = "Reproduction surfaces (photograph, poster, painting, portrait, monitor)"
    violations = _count_enumeration_violations(text)
    assert violations, "gate 가 명백한 괄호 enumeration 미검출 = silent disarm"


def test_self_test_bullet_enumeration_caught():
    """gate 신뢰성 lock-in — bullet enumeration catch."""
    text = "\n".join([
        "Reproduction surfaces:",
        "- photograph",
        "- poster",
        "- painting",
        "- portrait",
        "- monitor",
    ])
    violations = _count_enumeration_violations(text)
    assert violations, "gate 가 bullet enumeration 미검출 = silent disarm"


def test_active_pack_is_v22_or_later():
    """active pack 이 v22 (Area C migration 직후) 이상."""
    active = _resolve_active_pack()
    # version prefix int
    version_str = active.name.split(".")[0]
    assert version_str.isdigit() and int(version_str) >= 22, (
        f"active pack = {active.name}, expected v22+ (Area C migration)."
    )
```

- [ ] **Step 2: Run gate tests**

Run: `cd backend && pytest tests/_gate/test_scene_detail_active_pack_reproduction.py -v`
Expected: 6 PASS (Task 4 의 v22 active, enumeration 0, self-test catch).

- [ ] **Step 3: Commit**

```bash
git add backend/tests/_gate/test_scene_detail_active_pack_reproduction.py
git commit -m "test(area-c): scene_detail prompt drift gate (enumeration-only)

- active pack resolve (latest numeric desc dir)
- 괄호 안 4+ noun enumeration 차단
- bullet list 4+ consecutive noun enumeration 차단
- 자연 문장 단독 등장 허용 (false-positive 회피)
- gate self-test 2 종 (silent disarm 차단)
- v22+ active pack 강제
"
```

---

## Task 7: Enum alignment gate 확장

**Files:**
- Modify: `backend/tests/_gate/test_orientation_enum_alignment.py` (add 2 test functions)

**Dependency:** Task 1 (constants 신설).

- [ ] **Step 1: Write the failing tests (append)**

Edit `backend/tests/_gate/test_orientation_enum_alignment.py` — append two new test functions after the existing test:

```python
# Area C (2026-05-12) — render_prompt_card constant ↔ schema/ORIENTATION_REQUIRED_CLASSES
# drift 차단. Area A enum alignment gate 의 sibling 확장.

import json
import pathlib


def _load_schema_directionality_enum() -> frozenset[str]:
    """shot_staging schema 의 directionality_class enum 5 value 추출."""
    here = pathlib.Path(__file__).resolve()
    repo = here.parent.parent.parent.parent
    schema_path = (
        repo / "prompts" / "_base" / "shot_staging" / "9.202605121441" / "schema.json"
    )
    raw = json.loads(schema_path.read_text(encoding="utf-8"))
    # navigate to directionality_class enum — shots[*].key_bg_elements[*].directionality_class
    # 단순화: 전체 JSON 안 enum=[...content_surface...] 찾기
    def _find_directionality_enum(obj):
        if isinstance(obj, dict):
            if obj.get("description", "").startswith("Semantic classification of this element"):
                return obj.get("enum")
            for v in obj.values():
                r = _find_directionality_enum(v)
                if r is not None:
                    return r
        elif isinstance(obj, list):
            for v in obj:
                r = _find_directionality_enum(v)
                if r is not None:
                    return r
        return None
    enum = _find_directionality_enum(raw)
    assert enum is not None, "directionality_class enum not found in shot_staging schema"
    return frozenset(enum)


def test_allowed_directionality_classes_aligned_with_schema():
    """render_prompt_card._ALLOWED_DIRECTIONALITY_CLASSES == shot_staging schema 의 5 enum."""
    from app.core.steps.render_prompt_card import _ALLOWED_DIRECTIONALITY_CLASSES
    schema_enum = _load_schema_directionality_enum()
    assert _ALLOWED_DIRECTIONALITY_CLASSES == schema_enum, (
        f"Area C drift: render_prompt_card._ALLOWED_DIRECTIONALITY_CLASSES="
        f"{sorted(_ALLOWED_DIRECTIONALITY_CLASSES)}, "
        f"shot_staging schema enum={sorted(schema_enum)}. "
        f"두 source 가 같은 5 enum (content_surface / reflective_surface / "
        f"transparent_surface / directional_3d / non_directional) 을 참조해야 함."
    )


def test_reproduction_surface_classes_aligned_with_orientation_required():
    """render_prompt_card._REPRODUCTION_SURFACE_CLASSES == shot_staging.ORIENTATION_REQUIRED_CLASSES.

    Area A 의 'orientation 필수' subset 과 Area C 의 'reproduction surface' subset
    이 의미상 같음 — 양쪽 모두 content/reflective. drift 시 fail-fast.
    """
    from app.core.steps.render_prompt_card import _REPRODUCTION_SURFACE_CLASSES
    from app.modules.pipeline.shot_staging import ORIENTATION_REQUIRED_CLASSES
    producer_area_c = frozenset(_REPRODUCTION_SURFACE_CLASSES)
    producer_area_a = frozenset(ORIENTATION_REQUIRED_CLASSES)
    assert producer_area_c == producer_area_a, (
        f"Area A vs Area C subset drift: "
        f"render_prompt_card._REPRODUCTION_SURFACE_CLASSES="
        f"{sorted(producer_area_c)}, "
        f"shot_staging.ORIENTATION_REQUIRED_CLASSES="
        f"{sorted(producer_area_a)}. "
        f"두 subset 은 의미상 동일 (content_surface, reflective_surface)."
    )
```

- [ ] **Step 2: Run new tests**

Run: `cd backend && pytest tests/_gate/test_orientation_enum_alignment.py -v`
Expected: 3 PASS (기존 1 + 신규 2). 신규 2 tests = Task 1 의 constants ↔ schema/Area A subset 동기 검증.

- [ ] **Step 3: Verify gate triggers on drift simulation**

```bash
cd backend
python -c "
# 시뮬레이션 — _ALLOWED_DIRECTIONALITY_CLASSES 에서 1 enum 제거하면 fail
from app.core.steps import render_prompt_card as mod
orig = mod._ALLOWED_DIRECTIONALITY_CLASSES
mod._ALLOWED_DIRECTIONALITY_CLASSES = orig - {'non_directional'}
import subprocess
r = subprocess.run(
    ['pytest', 'tests/_gate/test_orientation_enum_alignment.py::test_allowed_directionality_classes_aligned_with_schema', '-v'],
    capture_output=True, text=True
)
print('exit:', r.returncode)
print('stdout tail:', r.stdout[-400:])
mod._ALLOWED_DIRECTIONALITY_CLASSES = orig  # restore
"
```

Expected: 새 test FAIL (gate 가 drift catch). Restore 후 정상.

- [ ] **Step 4: Commit**

```bash
git add backend/tests/_gate/test_orientation_enum_alignment.py
git commit -m "test(area-c): enum alignment gate 확장 — render_prompt_card constants

- _ALLOWED_DIRECTIONALITY_CLASSES ↔ shot_staging schema 5 enum drift 차단
- _REPRODUCTION_SURFACE_CLASSES ↔ ORIENTATION_REQUIRED_CLASSES subset 동기
- Area A enum alignment gate 의 sibling 확장
"
```

---

## Task 8: Production canary contract verification + closure

**Files:**
- No code commit. Verification only.

**Dependency:** Tasks 1-7 모두 완료.

- [ ] **Step 1: Confirm baseline**

Run:
```bash
git log --oneline -10
```

Expected: 최근 7 commits 가 Tasks 1-7 (각 task 1 commit). main HEAD = Task 7 commit.

Run:
```bash
cd backend && pytest tests/_gate/ -v 2>&1 | tail -20
```

Expected: 모든 gate test pass (semantic regex ban + orientation enum alignment + reproduction shape + scene_detail enumeration).

- [ ] **Step 2: Identify canary shot**

Reference Area A canary result (`session_20260512_area_a_impl_and_audit_r1.md` line 44):
> S26/6 photograph: `dir_class='content_surface'`

본 task 의 canary shot = PID `02829fe8` ep1 scene 26 shot 6 (content_surface element 보유).

- [ ] **Step 3: Trigger RPC build via redo-shot endpoint**

backend 서버가 운영 중이어야 함. 직전 세션 노트:
> backend uvicorn `--reload` port 8000.
> cookie `/tmp/theroad_cookie.txt` (어제 만료 → `creator`/`creator123` 또는 `admin`/`admin123` 재로그인).

```bash
# 재로그인 (cookie 만료 시)
curl -c /tmp/theroad_cookie.txt -b /tmp/theroad_cookie.txt -X POST \
  http://localhost:8000/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"creator","password":"creator123"}'

# scene 26 shot 6 redo 호출
curl -b /tmp/theroad_cookie.txt -X POST \
  "http://localhost:8000/api/v1/projects/02829fe8/episodes/fc38cf03/steps/scene-detail/redo-shot?scene_index=26&shot_index=6&mode=force" \
  -H 'Content-Type: application/json' -d '{}'
```

Expected: HTTP 200 (정상 RPC build + scene_detail step 진행).

**Note**: HTTP 422 `VISIBLE_STAGING_DRIFT` (S12/4 false-positive) 발생 가능 — Area G 영역, 본 area 닫힘과 무관. canary 가 S26/6 자체에 대한 RPC build 까지 도달했는지 확인 (failure 가 shot_visibility 단계라면 본 area 검증은 step 1 만 으로 가능 — DB cp 안 RPC JSON 직접 inspect).

- [ ] **Step 4: Inspect RPC JSON for closure criteria 1+2**

Direct DB inspection (cp 안 saved RPC):

```bash
cd backend && python -c "
from app.modules.session_manager import session_local
from sqlalchemy import text
import json

with session_local() as db:
    rows = db.execute(text('''
        SELECT data FROM scene_checkpoint
        WHERE episode_id = :eid AND step = :step AND scene_index = :sidx
        ORDER BY created_at DESC LIMIT 1
    '''), {'eid': 'fc38cf03', 'step': 'scene_detail', 'sidx': 26}).fetchall()
    if not rows:
        print('NO checkpoint found — Step 3 redo failed or cp pending')
        exit(1)
    data = rows[0][0] if isinstance(rows[0][0], dict) else json.loads(rows[0][0])
    shots = data.get('shots', []) if isinstance(data, dict) else []
    for shot in shots:
        if shot.get('shot_index') == 6:
            rpc = shot.get('rpc') or shot.get('render_prompt_card')
            if not rpc:
                print('rpc field absent in shot 6')
                continue
            repro = rpc.get('id_policy', {}).get('reproduction_surface_rule', {})
            print('reproduction_surface_rule:', json.dumps(repro, ensure_ascii=False, indent=2))
            print()
            print('CLOSURE 1 (applies==True):', repro.get('applies') is True)
            print('CLOSURE 2a (applies_to_surfaces absent):', 'applies_to_surfaces' not in repro)
            print('CLOSURE 2b (source absent):', 'source' not in repro)
            break
"
```

Expected output:
```
reproduction_surface_rule: {
  "applies": true,
  "id_use": "forbidden — common noun + demographic descriptor only",
  "rationale_summary": "..."
}

CLOSURE 1 (applies==True): True
CLOSURE 2a (applies_to_surfaces absent): True
CLOSURE 2b (source absent): True
```

Record results.

- [ ] **Step 5: Verify consumer behavior — closure criterion 3**

```bash
cd backend && python -c "
from app.modules.session_manager import session_local
from app.core.visible_entities_validator import _forward_enforcement_exempt
from sqlalchemy import text
import json

with session_local() as db:
    rows = db.execute(text('''
        SELECT data FROM scene_checkpoint
        WHERE episode_id = :eid AND step = :step AND scene_index = :sidx
        ORDER BY created_at DESC LIMIT 1
    '''), {'eid': 'fc38cf03', 'step': 'scene_detail', 'sidx': 26}).fetchall()
    data = rows[0][0] if isinstance(rows[0][0], dict) else json.loads(rows[0][0])
    for shot in data.get('shots', []):
        if shot.get('shot_index') == 6:
            rpc = shot.get('rpc') or shot.get('render_prompt_card')
            prompt = (shot.get('t2i_variations') or [{}])[0].get('t2i_prompt', '')
            exempt, reason = _forward_enforcement_exempt(rpc=rpc, prompt=prompt)
            print('Consumer:', exempt, reason)
            print()
            print('CLOSURE 3 (consumer exempt via applies==True):', exempt and 'applies == True' in reason)
            break
"
```

Expected:
```
Consumer: True reproduction_surface_rule.applies == True

CLOSURE 3 (consumer exempt via applies==True): True
```

- [ ] **Step 6: Verify prompt_loader active pack — closure criterion 4**

```bash
cd backend && python -c "
import pathlib
p = pathlib.Path('../prompts/_base/scene_detail')
dirs = sorted(d.name for d in p.iterdir() if d.is_dir())
print('All packs:', dirs)
active = dirs[-1]
print('Active (latest):', active)
print()
print('CLOSURE 4 (v22 active):', active.startswith('22.'))
"
```

Expected:
```
Active (latest): 22.202605122049
CLOSURE 4 (v22 active): True
```

- [ ] **Step 7: Record closure decision**

If all 4 closure criteria PASS:
- Area C closure 확정.
- session memo 작성 (`session_20260512_area_c_closure.md` 또는 진입점 갱신).

If any criterion FAIL:
- 원인 진단 + 해당 task 회귀 / 추가 fix.
- Area C 미닫힘 — 추가 작업.

- [ ] **Step 8: Push commits**

If closure PASS:
```bash
git push origin main
```

Expected: 7 commits push (Tasks 1-7). main HEAD = origin/main 정합.

- [ ] **Step 9: Update memory entry point**

Update `/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/next_session_open_world_heuristic_eradication.md`:
- main HEAD = Task 7 commit SHA (확정 후 채움).
- "다음 = Area B (story_critical_prop_filter 폐기)" 로 갱신.
- Area C 닫힘 표시.

Update `MEMORY.md` index — Area C closure entry 추가.

---

## Self-Review

### Spec coverage

Spec 의 모든 section ↔ task 매핑:

| Spec section | Implementing task |
|---|---|
| §1 audit C3 background | (context only — no task) |
| §2 design decisions (5 Q + 8 보정) | T1-T7 전체에 분산 — 각 보정 명시적 적용 |
| §3.1 architecture (3 module) | T1 (constants), T2 (producer), T3 (consumer), T4 (prompt) |
| §3.2 caller chain | T2 step 3c (build_render_prompt_card caller 갱신) |
| §3.3 scope freeze | spec only — task scope 정합 |
| §4.1 SOT definition | T2 step 3b builder |
| §4.2 producer code | T1 (constants + helper) + T2 (build_id_policy 갱신) |
| §4.3 helper signature | T1 step 3c |
| §4.4 build_id_policy signature | T2 step 3a |
| §5 consumer | T3 |
| §6 prompt v22 | T4 |
| §7 error handling | T1-T3 의 fail-fast tests |
| §8.1 helper unit tests | T1 step 1 |
| §8.2 build_id_policy unit tests | T2 step 1 |
| §8.3 consumer unit tests | T3 step 1 |
| §8.4 RPC shape gate | T5 |
| §8.5 prompt drift gate | T6 |
| §8.6 enum alignment gate | T7 |
| §8.7 regression | T1-T7 의 broader test step |
| §9 canary | T8 |
| §10 out of scope | (no task — boundary 명시) |

Coverage: 100% — placeholder 없음.

### Placeholder scan

- "TBD" / "TODO" 0.
- "Add appropriate error handling" 0 — 모든 error handling 은 actual AppError code + message 명시.
- "Similar to Task N" 0 — 각 task 의 code block 자체 완결.
- 함수/타입 모두 정의됨: `_extract_key_bg_elements_or_raise` (T1), `build_id_policy` (T2), `_forward_enforcement_exempt` (T3 reference), `AppError` (existing).

### Type consistency

- `_ALLOWED_DIRECTIONALITY_CLASSES`: T1 정의 → T2 사용 → T7 검증. 일관 `frozenset`.
- `_REPRODUCTION_SURFACE_CLASSES`: T1 정의 → T2 사용 → T7 검증. 일관 `frozenset`.
- `_extract_key_bg_elements_or_raise`: T1 signature `(staging, shot_info) -> List[Dict]` → T2 caller 동일.
- `applies`: T2 producer = `bool` → T3 consumer = `bool` 검사 → T5 gate = `isinstance(bool)` 검증.
- `AppError code`: T1 (`staging_required`, `staging_key_bg_elements_invalid`), T2 (`directionality_class_missing`, `directionality_class_invalid`), T3 (`reproduction_surface_rule_malformed`). 모두 명시.

검토 통과.

---

## Closure criteria (Spec §9 정합)

Area C 닫힘 = 다음 4 모두 PASS:
1. RPC 안 `reproduction_surface_rule.applies == true` (content/reflective 보유 shot).
2. RPC 안 `applies_to_surfaces` field + `source` field 부재.
3. consumer `_forward_enforcement_exempt` 가 `applies==True` 분기로 exempt 부여 (substring 매칭 없이).
4. `scene_detail` active pack = `22.202605122049`.

이미지 시각 검증은 보조 — closure 의 필수 조건 아님 (spec §2.11).
