# Area #1 — ID/Outlook Reference Policy SOT v1 Implementation Plan

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

**Goal:** Replace static `body_part_focus_rule` / `close_framing_face_phrasing` rules with per-shot, per-subject identity reference policy emitted by `shot_staging v12` producer, validated by helper module SOT, consumed by `render_prompt_card` + `visible_entities_validator` + `scene_detail v25` prompt + `scene_extractor_v2 v19` prompt.

**Architecture:** 4-layer (Producer → Helper SOT → Consumer → Prompt). `shot_staging v12` emits `shots[].items.subject_reference_policy[]` (per-shot item field, NOT root-level); helper `subject_reference_policy.py` validates + serializes; `render_prompt_card` normalizes + injects `id_policy.subject_reference_policy`; `visible_entities_validator` + `scene_detail` prompt consume per-subject via `IdUsageRule` 3-field matrix; `scene_extractor_v2` weakens upstream prompt.

**Tech Stack:** Python 3 (FastAPI backend), pytest, JSON schema validation, prompt_loader (stem-independent latest pick — directory creation = version sync 와 atomic 의무), step_manifest / version_registry dual sync.

**Spec reference:** `docs/superpowers/specs/2026-05-16-area-1-id-outlook-reference-policy-sot-v1-design.md` (commit `1e89907`).

---

## Wave Ordering + Dependencies

```
W1 (Helper) ─┬─→ W3 (render_prompt_card)
             ├─→ W4 (validator)
             └─→ W7 (test sweep)
W2 (shot_staging) ─→ (W3, W4, W5 schema 정합 가정)
W3, W4 ─→ W5 (scene_detail)
W5, W6 ─→ W7
W7 ─→ W8a (closure) + W8b (doc-only fix-up, 별도 commit)
```

각 wave = atomic commit (wave 안 모든 변경 한 commit). wave 간 의존성 위 도식.

---

## W1: Helper module + helper unit tests

Foundation wave — 다른 모든 wave 가 helper API 의존.

**Files:**
- Create: `backend/app/core/subject_reference_policy.py`
- Create: `backend/tests/unit/test_subject_reference_policy_helper.py`

### W1.1: Write failing unit tests

- [ ] Create `backend/tests/unit/test_subject_reference_policy_helper.py`:

```python
"""Area #1 — subject_reference_policy helper unit tests."""
import logging
import pytest

from app.core.errors import AppError


def test_normalize_subject_id_base():
    from app.core.subject_reference_policy import normalize_subject_id
    assert normalize_subject_id("C01") == "C01"
    assert normalize_subject_id("C123") == "C123"


def test_normalize_subject_id_outlook_to_base():
    from app.core.subject_reference_policy import normalize_subject_id
    assert normalize_subject_id("C01O02") == "C01"
    assert normalize_subject_id("C123O456") == "C123"


def test_normalize_subject_id_invalid_shape():
    from app.core.subject_reference_policy import normalize_subject_id
    for bad in ["CAT", "Cfoo", "C01XYZ", "", "P05", 42, None]:
        with pytest.raises(AppError) as exc:
            normalize_subject_id(bad)
        assert "invalid_subject_id_shape" in exc.value.code


def test_normalize_subject_id_warn_opt_in(caplog):
    from app.core.subject_reference_policy import normalize_subject_id
    with caplog.at_level(logging.WARNING):
        normalize_subject_id("C01O02", warn_on_outlook=True, where="test")
    assert any(
        "outlook_id_normalized" in (rec.__dict__.get("event") or "")
        for rec in caplog.records
    )


def test_normalize_subject_id_no_warn_default(caplog):
    from app.core.subject_reference_policy import normalize_subject_id
    with caplog.at_level(logging.WARNING):
        normalize_subject_id("C01O02", where="test")
    assert not any(
        "outlook_id_normalized" in (rec.__dict__.get("event") or "")
        for rec in caplog.records
    )


def test_derive_visible_subject_ids_basic():
    from app.core.subject_reference_policy import derive_visible_subject_ids
    assert derive_visible_subject_ids(["C01", "C02O03", "P05", "B01"]) == {"C01", "C02"}


def test_derive_visible_subject_ids_filters_noise():
    from app.core.subject_reference_policy import derive_visible_subject_ids
    assert derive_visible_subject_ids(["CAT", "Cfoo", "C01XYZ", "C01"]) == {"C01"}


def test_derive_visible_subject_ids_empty():
    from app.core.subject_reference_policy import derive_visible_subject_ids
    assert derive_visible_subject_ids([]) == set()
    assert derive_visible_subject_ids(None) == set()


def test_normalize_items_none_graceful():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    assert normalize_subject_reference_policy_items(
        None, visible_subject_ids=None, where="test"
    ) == {}


def test_normalize_items_empty_array_graceful():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    assert normalize_subject_reference_policy_items(
        [], visible_subject_ids=None, where="test"
    ) == {}


def test_normalize_items_valid_single():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{
        "subject_id": "C01",
        "policy_type": "identity_reference",
        "policy": "id_and_outlook_required",
        "reason": "primary subject",
    }]
    result = normalize_subject_reference_policy_items(
        items, visible_subject_ids={"C01"}, where="test"
    )
    assert "C01" in result
    assert result["C01"].policy == "id_and_outlook_required"


def test_normalize_items_outlook_id_normalized():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{
        "subject_id": "C01O02",
        "policy_type": "identity_reference",
        "policy": "base_id_required",
        "reason": "outlook context absent",
    }]
    result = normalize_subject_reference_policy_items(
        items, visible_subject_ids={"C01"}, where="test"
    )
    assert "C01" in result
    assert "C01O02" not in result


def test_normalize_items_duplicate_subject():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [
        {"subject_id": "C01", "policy_type": "identity_reference",
         "policy": "id_and_outlook_required", "reason": "a"},
        {"subject_id": "C01", "policy_type": "identity_reference",
         "policy": "base_id_required", "reason": "b"},
    ]
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01"}, where="test"
        )
    assert "duplicate_subject" in exc.value.code


def test_normalize_items_duplicate_after_normalization():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [
        {"subject_id": "C01", "policy_type": "identity_reference",
         "policy": "id_and_outlook_required", "reason": "a"},
        {"subject_id": "C01O02", "policy_type": "identity_reference",
         "policy": "base_id_required", "reason": "b"},
    ]
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01"}, where="test"
        )
    assert "duplicate_subject" in exc.value.code


def test_normalize_items_invalid_policy_enum():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{"subject_id": "C01", "policy_type": "identity_reference",
              "policy": "bogus", "reason": "x"}]
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01"}, where="test"
        )
    assert "invalid_enum_policy" in exc.value.code


def test_normalize_items_invalid_policy_type_enum():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{"subject_id": "C01", "policy_type": "future_type",
              "policy": "id_and_outlook_required", "reason": "x"}]
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01"}, where="test"
        )
    assert "invalid_enum_policy_type" in exc.value.code


def test_normalize_items_missing_required_key():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{"subject_id": "C01", "policy_type": "identity_reference",
              "policy": "id_and_outlook_required"}]  # missing 'reason'
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01"}, where="test"
        )
    assert "invalid_shape" in exc.value.code


def test_normalize_items_unknown_subject():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{"subject_id": "C99", "policy_type": "identity_reference",
              "policy": "id_and_outlook_required", "reason": "phantom"}]
    with pytest.raises(AppError) as exc:
        normalize_subject_reference_policy_items(
            items, visible_subject_ids={"C01", "C02"}, where="test"
        )
    assert "unknown_subject" in exc.value.code


def test_normalize_items_visible_set_none_skips_unknown_check():
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    items = [{"subject_id": "C99", "policy_type": "identity_reference",
              "policy": "id_and_outlook_required", "reason": "no visible set"}]
    result = normalize_subject_reference_policy_items(
        items, visible_subject_ids=None, where="test"
    )
    assert "C99" in result


def test_serialize_exceptions_first():
    from app.core.subject_reference_policy import (
        normalize_subject_reference_policy_items,
        serialize_subject_reference_policy_map,
    )
    items = [{"subject_id": "C01", "policy_type": "identity_reference",
              "policy": "id_and_outlook_required", "reason": "test"}]
    pmap = normalize_subject_reference_policy_items(
        items, visible_subject_ids={"C01", "C02"}, where="test"
    )
    arr = serialize_subject_reference_policy_map(pmap)
    assert len(arr) == 1  # C02 default 행 생성 X
    assert arr[0]["subject_id"] == "C01"


def test_serialize_empty():
    from app.core.subject_reference_policy import serialize_subject_reference_policy_map
    assert serialize_subject_reference_policy_map({}) == []


def test_serialize_deterministic_ordering():
    from app.core.subject_reference_policy import (
        SubjectReferencePolicy,
        serialize_subject_reference_policy_map,
    )
    pmap = {
        "C03": SubjectReferencePolicy("C03", "identity_reference", "id_and_outlook_required", "c"),
        "C01": SubjectReferencePolicy("C01", "identity_reference", "id_and_outlook_required", "a"),
        "C02": SubjectReferencePolicy("C02", "identity_reference", "id_and_outlook_required", "b"),
    }
    arr = serialize_subject_reference_policy_map(pmap)
    assert [r["subject_id"] for r in arr] == ["C01", "C02", "C03"]


def test_get_or_default_omitted_returns_default():
    from app.core.subject_reference_policy import get_subject_reference_policy_or_default
    policy = get_subject_reference_policy_or_default({}, "C99", where="test")
    assert policy.policy == "id_and_outlook_required"
    assert policy.policy_type == "identity_reference"


def test_get_or_default_present():
    from app.core.subject_reference_policy import (
        SubjectReferencePolicy,
        get_subject_reference_policy_or_default,
    )
    pmap = {"C01": SubjectReferencePolicy(
        "C01", "identity_reference", "base_id_required", "test"
    )}
    policy = get_subject_reference_policy_or_default(pmap, "C01", where="test")
    assert policy.policy == "base_id_required"


def test_get_or_default_invalid_subject_id_shape():
    from app.core.subject_reference_policy import get_subject_reference_policy_or_default
    with pytest.raises(AppError) as exc:
        get_subject_reference_policy_or_default({}, "CAT", where="test")
    assert "invalid_subject_id_shape" in exc.value.code


def test_get_or_default_outlook_id_normalized_to_base():
    from app.core.subject_reference_policy import (
        SubjectReferencePolicy,
        get_subject_reference_policy_or_default,
    )
    pmap = {"C01": SubjectReferencePolicy(
        "C01", "identity_reference", "base_id_required", "test"
    )}
    policy = get_subject_reference_policy_or_default(pmap, "C01O02", where="test")
    assert policy.policy == "base_id_required"


def test_policy_to_id_usage_rule_matrix():
    from app.core.subject_reference_policy import policy_to_id_usage_rule

    r = policy_to_id_usage_rule("id_and_outlook_required")
    assert (r.base_required, r.outlook_required, r.outlook_forbidden) == (True, True, False)

    r = policy_to_id_usage_rule("base_id_required")
    assert (r.base_required, r.outlook_required, r.outlook_forbidden) == (True, False, True)

    r = policy_to_id_usage_rule("generic_descriptor_allowed")
    assert (r.base_required, r.outlook_required, r.outlook_forbidden) == (False, False, True)


def test_policy_to_id_usage_rule_invalid():
    from app.core.subject_reference_policy import policy_to_id_usage_rule
    with pytest.raises(AppError) as exc:
        policy_to_id_usage_rule("bogus")
    assert "invalid_enum_policy" in exc.value.code
```

### W1.2: Run tests (verify all fail)

```bash
cd backend && pytest tests/unit/test_subject_reference_policy_helper.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'app.core.subject_reference_policy'`

### W1.3: Implement helper module

- [ ] Create `backend/app/core/subject_reference_policy.py`:

```python
"""Area #1 — subject_reference_policy SOT helper.

Single source of truth for ID/outlook reference policy validation + default.
All consumers (render_prompt_card, visible_entities_validator) must call this
helper; consumer-local default 절대 금지.

Spec: docs/superpowers/specs/2026-05-16-area-1-id-outlook-reference-policy-sot-v1-design.md
"""

import logging
import re
from dataclasses import dataclass
from typing import Optional

from app.core.errors import AppError

logger = logging.getLogger(__name__)

_CHAR_ID_RE = re.compile(r"^(C\d{2,3})(?:O\d{2,3})?$")

REQUIRED_KEYS = ("subject_id", "policy_type", "policy", "reason")
ALLOWED_POLICIES = frozenset({
    "id_and_outlook_required",
    "base_id_required",
    "generic_descriptor_allowed",
})
ALLOWED_POLICY_TYPES = frozenset({"identity_reference"})
DEFAULT_POLICY = "id_and_outlook_required"


@dataclass(frozen=True)
class SubjectReferencePolicy:
    subject_id: str
    policy_type: str
    policy: str
    reason: str


@dataclass(frozen=True)
class IdUsageRule:
    base_required: bool
    outlook_required: bool
    outlook_forbidden: bool


_POLICY_TO_ID_USAGE: dict[str, IdUsageRule] = {
    "id_and_outlook_required": IdUsageRule(
        base_required=True, outlook_required=True, outlook_forbidden=False
    ),
    "base_id_required": IdUsageRule(
        base_required=True, outlook_required=False, outlook_forbidden=True
    ),
    "generic_descriptor_allowed": IdUsageRule(
        base_required=False, outlook_required=False, outlook_forbidden=True
    ),
}


def policy_to_id_usage_rule(policy: str) -> IdUsageRule:
    if policy not in _POLICY_TO_ID_USAGE:
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_enum_policy",
            message=(
                f"invalid policy {policy!r} "
                f"(allowed: {sorted(ALLOWED_POLICIES)})"
            ),
        )
    return _POLICY_TO_ID_USAGE[policy]


def normalize_subject_id(
    raw,
    *,
    warn_on_outlook: bool = False,
    where: str = "",
) -> str:
    if not isinstance(raw, str) or not _CHAR_ID_RE.match(raw):
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_subject_id_shape",
            message=f"invalid subject_id {raw!r} (expected C## or C##O##) {where}",
        )
    base = raw.split("O")[0]
    if warn_on_outlook and "O" in raw:
        logger.warning(
            "subject_reference_policy: outlook_id form detected, normalized to base",
            extra={
                "event": "subject_reference_policy.outlook_id_normalized",
                "where": where,
                "raw_subject_id": raw,
                "normalized_subject_id": base,
            },
        )
    return base


def derive_visible_subject_ids(visible_entities) -> set:
    ids = set()
    for sid in visible_entities or []:
        if isinstance(sid, str):
            m = _CHAR_ID_RE.match(sid)
            if m:
                ids.add(m.group(1))
    return ids


def normalize_subject_reference_policy_items(
    items: Optional[list],
    *,
    visible_subject_ids: Optional[set],
    where: str,
) -> dict:
    # Gate 4 — graceful = items is None 또는 빈 list 만. falsy non-list (e.g.
    # "", {}, 0, False) 는 invalid_type AppError. silent passthrough 차단.
    if items is None:
        return {}
    if not isinstance(items, list):
        raise AppError(
            code="step.contract_violation.subject_reference_policy.invalid_type",
            message=f"items must be list, got {type(items).__name__} {where}",
        )
    if not items:  # 이 시점에는 items 가 list — empty list 만 도달.
        return {}

    result: dict = {}
    raw_by_base: dict = {}

    for idx, item in enumerate(items):
        if not isinstance(item, dict):
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_type",
                message=(
                    f"item[{idx}] expected dict, got "
                    f"{type(item).__name__} {where}"
                ),
            )
        missing = [k for k in REQUIRED_KEYS if k not in item]
        if missing:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_shape",
                message=(
                    f"item[{idx}] missing keys {missing!r} "
                    f"(required: {REQUIRED_KEYS}) {where}"
                ),
            )
        for k in REQUIRED_KEYS:
            if not isinstance(item[k], str):
                raise AppError(
                    code="step.contract_violation.subject_reference_policy.invalid_type",
                    message=(
                        f"item[{idx}].{k} expected str, got "
                        f"{type(item[k]).__name__} {where}"
                    ),
                )
        if item["policy_type"] not in ALLOWED_POLICY_TYPES:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_enum_policy_type",
                message=(
                    f"item[{idx}].policy_type {item['policy_type']!r} "
                    f"invalid (v1 allowed: {sorted(ALLOWED_POLICY_TYPES)}) {where}"
                ),
            )
        if item["policy"] not in ALLOWED_POLICIES:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_enum_policy",
                message=(
                    f"item[{idx}].policy {item['policy']!r} invalid "
                    f"(allowed: {sorted(ALLOWED_POLICIES)}) {where}"
                ),
            )
        if not item["reason"].strip():
            raise AppError(
                code="step.contract_violation.subject_reference_policy.invalid_shape",
                message=f"item[{idx}].reason empty string {where}",
            )

        base = normalize_subject_id(
            item["subject_id"],
            warn_on_outlook=True,
            where=f"{where}.item[{idx}]",
        )
        raw_by_base.setdefault(base, []).append(item["subject_id"])
        if base in result:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.duplicate_subject",
                message=(
                    f"duplicate normalized subject_id {base!r} "
                    f"(raw forms: {raw_by_base[base]!r}) {where}"
                ),
            )
        result[base] = SubjectReferencePolicy(
            subject_id=base,
            policy_type=item["policy_type"],
            policy=item["policy"],
            reason=item["reason"],
        )

        if visible_subject_ids is not None and base not in visible_subject_ids:
            raise AppError(
                code="step.contract_violation.subject_reference_policy.unknown_subject",
                message=(
                    f"subject {base!r} not in visible_subject_ids "
                    f"{sorted(visible_subject_ids)!r} {where}"
                ),
            )

    return result


def serialize_subject_reference_policy_map(policy_map: dict) -> list:
    return [
        {
            "subject_id": pol.subject_id,
            "policy_type": pol.policy_type,
            "policy": pol.policy,
            "reason": pol.reason,
        }
        for _, pol in sorted(policy_map.items(), key=lambda kv: kv[0])
    ]


def get_subject_reference_policy_or_default(
    policy_map: dict,
    subject_id: str,
    *,
    where: str,
) -> SubjectReferencePolicy:
    base = normalize_subject_id(subject_id, warn_on_outlook=False, where=where)
    if base in policy_map:
        return policy_map[base]
    return SubjectReferencePolicy(
        subject_id=base,
        policy_type="identity_reference",
        policy=DEFAULT_POLICY,
        reason="(default — omitted from shot_staging emit)",
    )
```

### W1.4: Run tests (verify all pass)

```bash
cd backend && pytest tests/unit/test_subject_reference_policy_helper.py -v
```
Expected: PASS (all)

### W1.5: Commit W1 (atomic)

```bash
git add backend/app/core/subject_reference_policy.py backend/tests/unit/test_subject_reference_policy_helper.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W1 — helper module + unit tests

신설 helper backend/app/core/subject_reference_policy.py:
- normalize_subject_id (C##O##→C##, warn_on_outlook opt-in)
- derive_visible_subject_ids (regex _CHAR_ID_RE, Gate 1 closed-world)
- normalize_subject_reference_policy_items (full array validation)
- serialize_subject_reference_policy_map (exceptions-first, deterministic)
- get_subject_reference_policy_or_default (per-subject lookup + default)
- policy_to_id_usage_rule (IdUsageRule 3-field matrix SOT)

Spec: docs/superpowers/specs/2026-05-16-area-1-id-outlook-reference-policy-sot-v1-design.md

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

---

## W2: shot_staging v12 atomic (Producer)

shot_staging v12 prompt directory + schema + step_manifest + version_registry + schema/version tests — **한 commit** (prompt_loader stem 독립 탐색 위험 차단).

**Files:**
- Create: `prompts/_base/shot_staging/12.<TIMESTAMP>/system.md` (v11 base + LLM instruction)
- Create: `prompts/_base/shot_staging/12.<TIMESTAMP>/schema.json` (v11 base + `shots[].items.subject_reference_policy`)
- Modify: `backend/app/core/step_manifest.py:599` schema_version 3 → 4
- Modify: `backend/app/core/version_registry.py:33` shot_staging "2.4.0" → "2.5.0"
- Modify: `backend/app/core/version_registry.py:122` prompt_dependency "shot_staging/v11" → "shot_staging/v12"
- Modify: `backend/tests/test_prompt_versions.py` — v11 test → v12 current schema test 교체

**TIMESTAMP** = 작성 시각 (YYYYMMDDHHmm, 12-digit). 예: `12.202605161430`.

### W2.1: 기존 v11 schema 복사 + subject_reference_policy 추가

- [ ] Determine TIMESTAMP:
  ```bash
  date -u +%Y%m%d%H%M
  ```

- [ ] Copy v11 pack:
  ```bash
  LATEST=$(ls -1 prompts/_base/shot_staging/ | sort -V | tail -1)
  TS=<paste from above>
  cp -r prompts/_base/shot_staging/$LATEST prompts/_base/shot_staging/12.$TS
  ```

- [ ] Edit `prompts/_base/shot_staging/12.<TS>/schema.json` — `properties.shots.items.properties` 에 `subject_reference_policy` 추가:

```jsonc
{
  "type": "object",
  "properties": {
    "shots": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          // ... 기존 필드 (scene_index, shot_index, framing_scale, ...) ...
          "subject_reference_policy": {
            "type": "array",
            "description": "Per-subject identity reference policy (exceptions-first). Default 'id_and_outlook_required' if subject omitted.",
            "items": {
              "type": "object",
              "properties": {
                "subject_id": {
                  "type": "string",
                  "pattern": "^C\\d{2,3}(?:O\\d{2,3})?$",
                  "description": "Base C## recommended. C##O## form is accepted and normalized to base (outlook context not available in shot_staging input)."
                },
                "policy_type": {
                  "type": "string",
                  "enum": ["identity_reference"],
                  "description": "v1 single value. Other values forbidden (envelope future hook only)."
                },
                "policy": {
                  "type": "string",
                  "enum": [
                    "id_and_outlook_required",
                    "base_id_required",
                    "generic_descriptor_allowed"
                  ]
                },
                "reason": {
                  "type": "string",
                  "minLength": 1
                }
              },
              "required": ["subject_id", "policy_type", "policy", "reason"],
              "additionalProperties": false
            }
          }
        },
        "required": [
          // 기존 required (e.g. "scene_index", "shot_index", "framing_scale", ...) +
          "subject_reference_policy"   // Area #1 — field 자체 required, empty array 허용
        ]
      }
    }
  },
  "required": ["shots"]
}
```

`subject_reference_policy` 는 `shots[].items.required` 에 **추가 의무** — empty array `[]` 는 허용 (**exceptions-first = array row 생략**, NOT field 생략). spec §5.2 정합 — "v12 staging + field 누락 → caller AppError `.field_missing`" 정책과 schema-level required 가 정합한 단일 contract. caller (build_render_prompt_card) 는 second-line defense 로 field 누락 재검증 (schema validation 우회 path 보호).

### W2.2: shot_staging v12 prompt (system.md) 갱신

- [ ] Edit `prompts/_base/shot_staging/12.<TS>/system.md` — v11 base 그대로 두고 마지막에 신규 instruction 섹션 추가:

```markdown
## subject_reference_policy[] (per-shot, v12 신규)

각 shot 안 인물 (`character_angles[].character`) 중에서 default policy 와 다른 경우만 `subject_reference_policy[]` 안에 명시.

- `subject_id`: base `C##` 만 emit. outlook 형식 (`C##O##`) 절대 금지 — 본 단계는 entity_merge `characters[].name` 만 input 으로 받음.
- `policy_type`: 항상 `"identity_reference"`. 다른 값 emit 금지.
- `policy`:
  - `id_and_outlook_required` (default — 명시 안 해도 됨): T2I prompt 에서 `C##O##` (base + outlook) 형식 ID 의무.
  - `base_id_required`: T2I prompt 에서 `C##` (base only) 의무. outlook ref 금지.
  - `generic_descriptor_allowed`: T2I prompt 에서 ID 없이 generic descriptor (e.g. "a man in robe") 허용. outlook ref 금지.
- `reason`: 결정 근거 free text (1~3 문장).

**emit rule (exceptions-first)**: default `id_and_outlook_required` 인 subject 는 emit 하지 말 것. default 와 다른 subject 만 명시.

예시 (shot 안 C01 = default, C02 = body-part close-up — outlook 매칭 어려움):
```json
"subject_reference_policy": [
  {
    "subject_id": "C02",
    "policy_type": "identity_reference",
    "policy": "base_id_required",
    "reason": "Body-part close-up frames C02's hand only; outlook reference cannot align."
  }
]
```
```

### W2.3: Write failing schema/version tests

- [ ] Edit `backend/tests/test_prompt_versions.py` — **v11 test → v12 current schema test 교체** (사용자 정책: historical_v11 보존 비채택):

Replace `def test_shot_staging_v11_schema_has_framing_scale_required_enum()` (line 63) + `def test_step_manifest_shot_staging_schema_version_3()` (line 76) with:

```python
def test_shot_staging_v12_schema_has_subject_reference_policy_array():
    """shot_staging v12 schema: shots[].items 에 subject_reference_policy[] 추가 (Area #1).
    
    framing_scale enum (v11 carry) 유지 verify.
    """
    base = REPO_ROOT / "prompts" / "_base" / "shot_staging"
    v12_dirs = sorted([d for d in base.iterdir() if d.name.startswith("12.")])
    assert v12_dirs, "shot_staging v12 디렉토리 없음 (W2 atomic 미실행)"
    schema_file = v12_dirs[-1] / "schema.json"
    schema = json.loads(schema_file.read_text())
    item = schema["properties"]["shots"]["items"]
    item_props = item["properties"]

    # v11 carry — framing_scale 유지
    assert item_props["framing_scale"]["enum"] == ["close", "medium", "wide", "insert"]
    assert "framing_scale" in item["required"]

    # Area #1 신규 — subject_reference_policy (field-level required + schema-level fail-fast)
    assert "subject_reference_policy" in item_props
    assert "subject_reference_policy" in item["required"], (
        "shots[].items.required 에 subject_reference_policy 추가 의무 "
        "(Area #1: field 자체 required, empty array 허용; spec §5.2 fail-fast 정합)"
    )
    srp = item_props["subject_reference_policy"]
    assert srp["type"] == "array"
    srp_item = srp["items"]
    assert srp_item["type"] == "object"
    assert set(srp_item["required"]) == {"subject_id", "policy_type", "policy", "reason"}
    assert srp_item["properties"]["policy"]["enum"] == [
        "id_and_outlook_required",
        "base_id_required",
        "generic_descriptor_allowed",
    ]
    assert srp_item["properties"]["policy_type"]["enum"] == ["identity_reference"]
    assert srp_item["properties"]["subject_id"]["pattern"] == "^C\\d{2,3}(?:O\\d{2,3})?$"


def test_step_manifest_shot_staging_schema_version_4():
    """step_manifest.py shot_staging entry 에 schema_version == 4 (Area #1).
    v3 cp 와 mismatch → v11 cp invalidation (subject_reference_policy 없는 cp 차단).
    """
    from app.core.step_manifest import STEP_MANIFEST
    assert STEP_MANIFEST["shot_staging"].get("schema_version") == 4, (
        "shot_staging schema_version = 4 필수 (subject_reference_policy 추가)"
    )
```

### W2.4: Run tests (fail)

```bash
cd backend && pytest tests/test_prompt_versions.py::test_shot_staging_v12_schema_has_subject_reference_policy_array \
  tests/test_prompt_versions.py::test_step_manifest_shot_staging_schema_version_4 -v
```
Expected: FAIL — v12 directory 없음 / schema_version 3 (4 기대)

### W2.5: Bump step_manifest schema_version 3→4

- [ ] Edit `backend/app/core/step_manifest.py:599`:

```python
        "schema_version": 4,  # 2026-05-16 (Area #1): shots[].items.subject_reference_policy 추가. 이전: 3 (framing_scale).
```

### W2.6: Bump version_registry MODULE_VERSIONS + _MODULE_INFO

- [ ] Edit `backend/app/core/version_registry.py:33`:

```python
    "shot_staging": "2.5.0",              # 2026-05-16 — v12 prompt + schema_version 4: shots[].items.subject_reference_policy 추가 (Area #1).
```

- [ ] Edit `backend/app/core/version_registry.py:122`:

```python
    "shot_staging": {
        "prompt_dependency": "shot_staging/v12",
        "updated_at": "2026-05-16",
    },
```

### W2.7: Run tests (verify pass)

```bash
cd backend && pytest tests/test_prompt_versions.py -v
```
Expected: PASS (v12 schema test + schema_version 4 test) + 다른 기존 test 그대로 PASS (단, scene_detail 관련 hardcoded test 는 W5 까지 fail 가능 — Area #1 scope 외 fail 은 W2 안에서 무시; W5 에서 일괄 처리)

### W2.8: Commit W2 (atomic)

```bash
git add prompts/_base/shot_staging/12.*/ \
        backend/app/core/step_manifest.py \
        backend/app/core/version_registry.py \
        backend/tests/test_prompt_versions.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W2 — shot_staging v12 (Producer) atomic

신규 prompt/schema pack: prompts/_base/shot_staging/12.<TS>/
- system.md: subject_reference_policy[] LLM instruction (exceptions-first)
- schema.json: shots[].items.subject_reference_policy enum (3 policies × identity_reference policy_type)

step_manifest shot_staging schema_version 3 → 4 (cp v11 invalidation).
version_registry shot_staging 2.4.0 → 2.5.0 / v11 → v12 (dual sync).

Tests: v11 historical test 폐기 → v12 current schema test 교체 + schema_version 4 assert.

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

---

## W3: render_prompt_card 전체 rewrite atomic

constants 4 개 삭제 + build site 1056-1075 제거 + `build_id_policy` required kwarg + `id_policy.subject_reference_policy` inject + `_assert_id_policy_shape` cascade + spatial validator rename + prose rewrite + literal rewrite + emit site rename + direct caller test sweep — **한 commit** (constants ↔ build site 의존성 차단).

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py` (constants + build + signature + shape + spatial + prose + literal + emit)
- Modify: 모든 `build_id_policy` direct callers (`rg -n "build_id_policy\(" backend/tests backend/app/core/steps/render_prompt_card.py` 결과 전부)
- Test: `backend/tests/unit/test_render_prompt_card.py` (grep gate 결과 전부 + 신규 tests; 수 단정 X)
- Test: `backend/tests/_gate/test_reproduction_surface_rule_shape.py:28` direct caller
- Test: `backend/tests/unit/test_g4_3_id_policy_lift.py:103` wrapper

### W3.1: Discover all direct callers (grep gate)

- [ ] Run:
  ```bash
  rg -n "build_id_policy\(" backend/tests backend/app/core/steps/render_prompt_card.py
  ```
- [ ] Save complete caller list — every hit must be updated in W3.

### W3.2: Write failing tests (helper integration + signature)

- [ ] Add to `backend/tests/unit/test_render_prompt_card.py` (anywhere — append to file):

```python
# ─────────────────────────────────────────────
# Area #1 — build_id_policy required subject_reference_policies kwarg
# ─────────────────────────────────────────────

def test_build_id_policy_requires_subject_reference_policies_kwarg():
    """build_id_policy 가 subject_reference_policies kwarg 없으면 TypeError (required)."""
    from app.core.steps.render_prompt_card import build_id_policy
    with pytest.raises(TypeError):
        build_id_policy(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
            perception_mode="direct",
            key_bg_elements=[],
            # subject_reference_policies 누락
        )


def test_build_id_policy_injects_subject_reference_policy_empty_array():
    """exceptions-first empty array 도 항상 inject (Gate 4 정합)."""
    from app.core.steps.render_prompt_card import build_id_policy
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "subject_reference_policy" in ip
    assert ip["subject_reference_policy"] == []


def test_build_id_policy_injects_subject_reference_policy_populated():
    from app.core.steps.render_prompt_card import build_id_policy
    items = [{
        "subject_id": "C01",
        "policy_type": "identity_reference",
        "policy": "base_id_required",
        "reason": "test",
    }]
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=items,
    )
    assert ip["subject_reference_policy"] == items


def test_build_id_policy_no_longer_emits_body_part_focus_rule():
    """폐기 entry — body_part_focus_rule / close_framing_face_phrasing 0."""
    from app.core.steps.render_prompt_card import build_id_policy
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "body_part_focus_rule" not in ip
    assert "close_framing_face_phrasing" not in ip
```

### W3.3: Run new tests (fail)

```bash
cd backend && pytest tests/unit/test_render_prompt_card.py::test_build_id_policy_requires_subject_reference_policies_kwarg \
  tests/unit/test_render_prompt_card.py::test_build_id_policy_injects_subject_reference_policy_empty_array \
  tests/unit/test_render_prompt_card.py::test_build_id_policy_injects_subject_reference_policy_populated \
  tests/unit/test_render_prompt_card.py::test_build_id_policy_no_longer_emits_body_part_focus_rule -v
```
Expected: 3 PASS (현재 signature 가 `*args` 받음 — TypeError fail X) + 1 FAIL (body_part_focus_rule 아직 inject 됨) — 실제 결과는 구현 진행 후 확인

(미세 결과: 현재 signature 가 `subject_reference_policies=None` 받아도 inject 안 함 → 두 inject test fail, 한 no-body-part test fail. required kwarg test = 현 시점 fail 가능 / 미실현 따라)

### W3.4: Delete 폐기 module-level constants

- [ ] Edit `backend/app/core/steps/render_prompt_card.py` lines 115-146:

Delete:
- `_ID_BODY_PART_TRIGGERS = (...)` (line 115-119)
- `_ID_BODY_PART_FOCUS_APPLIES_TO = "..."` (line 121-125)
- `_ID_CLOSE_FACE_FORBIDDEN_PHRASES = (...)` (line 131-138)
- `_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS = (...)` (line 141-146)

(Edit with `replace_all=false`; provide enough surrounding context to make unique. After deletion verify with `grep -n "_ID_BODY_PART_TRIGGERS\|_ID_BODY_PART_FOCUS_APPLIES_TO\|_ID_CLOSE_FACE_FORBIDDEN_PHRASES\|_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS" backend/app/core/steps/render_prompt_card.py` — expect 0 hits.)

### W3.5: Delete `body_part_focus_rule` + `close_framing_face_phrasing` build (line 1056-1080)

- [ ] Edit `backend/app/core/steps/render_prompt_card.py` line 1056-1080 — delete both rule dicts entirely. id_policy 안 다른 sub-field (face_identifiability_rule, reproduction_surface_rule, demographic_descriptor_policy) 는 유지.

### W3.6: Change `build_id_policy` signature — required kwarg

- [ ] Edit `backend/app/core/steps/render_prompt_card.py` line 974-980:

```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]],
    subject_reference_policies: List[Dict[str, Any]],  # NEW: required (no default; Gate 4)
) -> Dict[str, Any]:
```

`build_id_policy` body 안 마지막 (return 이전) `id_policy["subject_reference_policy"] = subject_reference_policies` 라인 추가. `body_part_focus_rule` / `close_framing_face_phrasing` dict assignment 라인 제거 (이미 W3.5 에서 dict 자체 삭제).

### W3.7: Update `_ID_POLICY_SUB_FIELD_REQUIRED_KEYS` (line 2734-2750)

- [ ] Edit module-level dict — remove `body_part_focus_rule` + `close_framing_face_phrasing` entries:

```python
_ID_POLICY_SUB_FIELD_REQUIRED_KEYS: Dict[str, Tuple[str, ...]] = {
    "face_identifiability_rule": (
        "face_required_for_id_lock",
        "ots_no_face_exception",
        "id_use_summary",
        "rationale",
    ),
    # body_part_focus_rule — REMOVED (Area #1: subject_reference_policy SOT)
    # close_framing_face_phrasing — REMOVED (Area #1: subject_reference_policy SOT)
    "reproduction_surface_rule": (
        "applies",
        "id_use",
        "rationale",
    ),
    "demographic_descriptor_policy": (
        "required_on_first_appearance",
        "applies_to_id_forms",
        "components",
    ),
}
```

### W3.8: Add `subject_reference_policy` list[dict] case to `_assert_id_policy_shape` (line 2753-)

- [ ] After existing loop (line 2801-) add separate case for `subject_reference_policy`:

```python
    # Area #1 — subject_reference_policy: list[dict] structural validation.
    # _ID_POLICY_SUB_FIELD_REQUIRED_KEYS loop 는 dict sub-field 전용이므로
    # 별도 case. Full semantic validation 은 상위 normalize_*() 에서 이미 끝남.
    if "subject_reference_policy" not in ip:
        raise AppError(
            code="step.contract_violation.id_policy.field_missing",
            message=(
                f"render_prompt_card.id_policy missing "
                f"'subject_reference_policy' (Area #1) {where}"
            ),
        )
    _assert_dict_list_with_keys(
        ip["subject_reference_policy"],
        "id_policy.subject_reference_policy",
        required_keys=("subject_id", "policy_type", "policy", "reason"),
        where=where,
    )
```

### W3.9: Rewrite `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` value (line 263)

- [ ] Find module-level constant at line ~263. Change value:

```python
_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL = (
    "see card.id_policy.subject_reference_policy"  # Area #1
)
```

(이전 value = `"see card.id_policy.body_part_focus_rule"` 또는 유사 wording — 정확 string 은 read 후 확인.)

### W3.10: Rename `body_part_focus_cross_ref` → `subject_reference_policy_cross_ref` (4 sites)

- [ ] Edit `backend/app/core/steps/render_prompt_card.py` line 453 — frozenset:

```python
_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_subject_only_fully_visible",
    "other_entities_appearance_options",
    "forbidden_combinations",
    "subject_reference_policy_cross_ref",  # Area #1 rename — was body_part_focus_cross_ref
})
```

- [ ] Edit line 460 — same rename:

```python
_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_and_secondary_both_visible",
    "body_part_close_up_forbidden",
    "fg_bg_separation_requires_shared_anchor",
    "subject_reference_policy_cross_ref",  # Area #1 rename
})
```

- [ ] Edit lines 3234, 3245 (close validator) + 3282, 3293 (wide_medium validator):

```python
    cfr_xref = cfr["subject_reference_policy_cross_ref"]   # was body_part_focus_cross_ref
    # ...
    if _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL not in cfr_xref:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"primary_framing_rule.close_framing_rules."
                f"subject_reference_policy_cross_ref must contain substring "
                f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL!r} (RO-6 paired-string) {where}"
            ),
        )
```

(Same pattern for wide_medium at line 3282/3293.)

### W3.11: Rename emit sites (line 724, 742)

- [ ] Edit `close_framing_rules` emit dict at line ~724:

```python
        "close_framing_rules": {
            # ... 기존 fields ...
            "subject_reference_policy_cross_ref": (    # was body_part_focus_cross_ref
                f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} "
                "(subject_reference_policy SOT — policy enum 에 따라 ID/outlook 사용)"
            ),
        },
```

- [ ] Edit `wide_medium_rules` emit dict at line ~742 — same rename + same wording.

### W3.12: Rewrite prose at lines 1548, 1630, 1691

- [ ] Edit line ~1548 `body_part_cross_ref` (zoom_in_detail ref_usage):

```python
        "body_part_cross_ref": (
            f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} — per-subject policy "
            "applies; policy enum 에 따라 base C##/outlook C##O##/generic descriptor 사용"
        ),
```

- [ ] Edit line ~1630 — rename field name `id_policy_cross_ref_for_body_part_focus` → `subject_reference_policy_cross_ref` (consistency with W3.10):

```python
        "subject_reference_policy_cross_ref": (
            f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} — policy = "
            "'base_id_required' or 'generic_descriptor_allowed' 인 subject 에 outlook ref 금지"
        ),
```

- [ ] Edit line ~1691 constraints string:

```python
        (
            "one t2i_prompt = one camera position — third-person full body 와 "
            "동일 인물의 close-up 합성 금지; 'Focus on' 은 frame area 명시이지 "
            "view switch 아님 "
            f"({_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} — per-subject policy 적용)"
        ),
```

### W3.13: Update ALL direct callers (sweep)

- [ ] For each line in W3.1 grep output:
  - Add `subject_reference_policies=[]` (exceptions-first default) or explicit array kwarg
  - Examples:
    - `test_render_prompt_card.py:210, 223, 231, 238, 252, 259, 267, 284, 294, 306, 827, 1093, 1114, 1125, 1153, 1168, 1177, 1186, 1198, 1209, 1221, 1233, 1244, 1280, 1310, 1324, 1350` + any other hit
    - `test_g4_3_id_policy_lift.py:103` wrapper — add `subject_reference_policies=[]`
    - `test_reproduction_surface_rule_shape.py:28` — add `subject_reference_policies=[]`
  - Pattern:
    ```python
    # Before:
    build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[...],
        perception_mode="direct",
        key_bg_elements=[],
    )
    # After:
    build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[...],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    ```

### W3.14: Verify residue gate

```bash
grep -rn "_FACE_CLOSE_UP_PATTERNS\
\|body_part_focus_rule\
\|close_framing_face_phrasing\
\|_ID_BODY_PART_TRIGGERS\
\|_ID_CLOSE_FACE_FORBIDDEN_PHRASES\
\|_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS\
\|body_part_focus_cross_ref\
\|id_policy_cross_ref_for_body_part_focus" backend/app/core/steps/render_prompt_card.py
```
Expected: 0 hits.

```bash
rg -n "build_id_policy\(" backend/tests backend/app/core/steps/render_prompt_card.py
```
Expected: 모든 caller 가 `subject_reference_policies=` kwarg 포함.

### W3.15: Run tests (verify pass)

```bash
cd backend && pytest tests/unit/test_render_prompt_card.py tests/_gate/test_reproduction_surface_rule_shape.py tests/unit/test_g4_3_id_policy_lift.py -v
```
Expected: PASS (모든 direct caller test 통과 + 신규 W3.2 tests 통과)

### W3.16: Commit W3 (atomic)

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/unit/test_render_prompt_card.py \
        backend/tests/_gate/test_reproduction_surface_rule_shape.py \
        backend/tests/unit/test_g4_3_id_policy_lift.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W3 — render_prompt_card 전체 rewrite

폐기 (Area #1 deprecation):
- _ID_BODY_PART_TRIGGERS / _ID_BODY_PART_FOCUS_APPLIES_TO
- _ID_CLOSE_FACE_FORBIDDEN_PHRASES / _ID_CLOSE_FACE_RECOMMENDED_PHRASINGS
- body_part_focus_rule + close_framing_face_phrasing build (line 1056-1080)

신설:
- build_id_policy(..., subject_reference_policies: List[Dict]) — required kwarg
- id_policy["subject_reference_policy"] = serialized array 항상 inject (Gate 4)
- _assert_id_policy_shape: list[dict] 별도 case 추가

Rename:
- body_part_focus_cross_ref → subject_reference_policy_cross_ref
  (4 sites: _SPATIAL_*_REQUIRED_SUBKEYS frozenset + close/wide_medium validator key access)
- id_policy_cross_ref_for_body_part_focus → subject_reference_policy_cross_ref

Prose rewrite (line 1548/1630/1691): body-part 어휘 제거, per-subject policy 기준.
Emit site rewrite (line 724/742): subject_reference_policy SOT 명시.
_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL value: id_policy.body_part_focus_rule → id_policy.subject_reference_policy

Direct caller sweep: backend/tests + render_prompt_card.py 전체 build_id_policy(
호출에 subject_reference_policies=[] (exceptions-first) 추가.

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

---

## W4: visible_entities_validator atomic

body-part substring branch 제거 + per-subject loop + structured exemption 별도 helper 보존 (Area B/C 회귀 차단) — **한 commit**.

**Files:**
- Modify: `backend/app/core/visible_entities_validator.py`
- Test: `backend/tests/core/test_visible_entities_validator.py` (또는 동등 위치)

### W4.1: Write failing tests — per-subject matrix + structured exemption preservation

- [ ] Add to `backend/tests/core/test_visible_entities_validator.py` (append to file):

```python
# ─────────────────────────────────────────────
# Area #1 — per-subject policy validator matrix + structured exemption 보존
# ─────────────────────────────────────────────

from typing import Any, Dict, List
import pytest
from app.core.errors import AppError


def _make_shot_for_validator(
    visible: List[str],
    srp_array: List[Dict[str, Any]],
    prompt: str,
    render_strategy_mode: str = "direct",
    reproduction_applies: bool = False,
) -> Dict[str, Any]:
    """Test fixture — validate_visible_entities_contract 가 받는 minimal shot dict.

    실제 production cp 의 모든 required fields 를 최소 형태로 채움.
    """
    return {
        "scene_index": 1,
        "_shot_index": 1,
        "shot_index": 1,
        "visible_entities": visible,
        "render_strategy": {"mode": render_strategy_mode},
        "id_policy": {
            "allowed_base_entity_ids": visible,
            "allowed_outlook_pairs": [
                {"character_id": sid, "outlook_id": "O01"} for sid in visible
            ],
            "subject_reference_policy": srp_array,
            "reproduction_surface_rule": {
                "applies": reproduction_applies,
                "id_use": "see reproduction_surface_rule",
                "rationale": "test fixture",
            },
            "face_identifiability_rule": {
                "face_required_for_id_lock": False,
                "ots_no_face_exception": False,
                "id_use_summary": "test",
                "rationale": "test",
            },
            "demographic_descriptor_policy": {
                "required_on_first_appearance": False,
                "applies_to_id_forms": [],
                "components": {
                    "ethnicity": ["unspecified"],
                    "gender": ["unspecified"],
                    "age_band": ["unspecified"],
                },
            },
        },
        "t2i_variations": [
            {
                "t2i_prompt": prompt,
                "applied_frame_spatial_constraint_ids": [],
            },
        ],
    }


def _srp(subject_id: str, policy: str, reason: str = "test") -> Dict[str, Any]:
    return {
        "subject_id": subject_id,
        "policy_type": "identity_reference",
        "policy": policy,
        "reason": reason,
    }


def test_validator_id_and_outlook_required_base_missing_fail():
    """policy = id_and_outlook_required, base C## 누락 → AppError ".base_id_missing"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="a man speaks at length",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".base_id_missing" in exc.value.code


def test_validator_id_and_outlook_required_outlook_missing_fail():
    """policy = id_and_outlook_required, base 있지만 outlook 없음 → ".outlook_id_missing"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="C01 speaks at length",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_id_missing" in exc.value.code


def test_validator_id_and_outlook_required_both_present_pass():
    """policy = id_and_outlook_required, base + outlook 모두 → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "id_and_outlook_required")],
        prompt="C01O01 speaks at length",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_base_id_required_outlook_forbidden_fail():
    """policy = base_id_required + C##O## present → ".outlook_forbidden"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "base_id_required")],
        prompt="C01O01 speaks",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_forbidden" in exc.value.code


def test_validator_base_id_required_base_present_pass():
    """policy = base_id_required + C## present (outlook 없음) → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "base_id_required")],
        prompt="C01 speaks",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_generic_descriptor_allowed_no_id_pass():
    """policy = generic_descriptor_allowed + no ID → PASS."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "generic_descriptor_allowed")],
        prompt="a hooded figure speaks in shadow",
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_generic_descriptor_allowed_outlook_present_fail():
    """policy = generic_descriptor_allowed + C##O## present → ".outlook_forbidden"."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[_srp("C01", "generic_descriptor_allowed")],
        prompt="C01O01 speaks",
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".outlook_forbidden" in exc.value.code


def test_validator_per_subject_exception_does_not_leak():
    """한 subject 의 예외가 다른 subject 의 누락을 풀지 못함 (Gate 4)."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01", "C02"],
        srp_array=[
            _srp("C01", "generic_descriptor_allowed"),  # C01: ID 없어도 OK
            _srp("C02", "id_and_outlook_required"),     # C02: ID + outlook 의무
        ],
        prompt="a hooded figure stands while another silhouette watches",  # C02 누락
    )
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(
            shot, name_by_short_id={"C01": "Alice", "C02": "Bob"}
        )
    assert ".base_id_missing" in exc.value.code
    # C02 누락이 fail-fast — C01 generic 허용이 leak 하지 않음.


def test_partial_focus_render_strategy_still_exempts_forward():
    """Area B/G4 보존 — render_strategy.mode == 'partial_focus' → exempt 유지."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],  # exceptions-first empty (모든 subject default)
        prompt="a hand reaches into frame",  # C01 누락
        render_strategy_mode="partial_focus",
    )
    # partial_focus → forward exempt; missing_bases 검사 면제.
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_reproduction_surface_rule_applies_still_exempts_forward():
    """Area C 보존 — reproduction_surface_rule.applies == True → exempt 유지."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],
        prompt="a photograph propped on the desk",  # C01 누락
        reproduction_applies=True,
    )
    validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})


def test_validator_card_field_missing_fail_fast():
    """id_policy.subject_reference_policy field 부재 → caller AppError ".id_policy.field_missing" (Gate 4)."""
    from app.core.visible_entities_validator import validate_visible_entities_contract
    shot = _make_shot_for_validator(
        visible=["C01"],
        srp_array=[],
        prompt="C01O01 speaks",
    )
    # 강제로 field 제거 (validator caller-side fail-fast 검증)
    del shot["id_policy"]["subject_reference_policy"]
    with pytest.raises(AppError) as exc:
        validate_visible_entities_contract(shot, name_by_short_id={"C01": "Alice"})
    assert ".id_policy.field_missing" in exc.value.code
```

**주의**: `validate_visible_entities_contract` 는 `backend/app/core/visible_entities_validator.py:350` 의 entry point. fixture 의 `id_policy` 안 sub-field 5종 (allowed_base_entity_ids / allowed_outlook_pairs / subject_reference_policy / reproduction_surface_rule / face_identifiability_rule / demographic_descriptor_policy) 은 W3 의 `_assert_id_policy_shape` 가 요구하는 minimal shape. body_part_focus_rule / close_framing_face_phrasing 은 제거되었으므로 fixture 에서도 0.

### W4.2: Run tests (fail)

```bash
cd backend && pytest tests/core/test_visible_entities_validator.py -v -k "id_and_outlook_required or base_id_required or generic_descriptor or per_subject or partial_focus_render_strategy or reproduction_surface_rule_applies"
```
Expected: FAIL — 신규 logic 미구현

### W4.3: Delete `_FACE_CLOSE_UP_PATTERNS` + `_is_face_close_up()`

- [ ] Delete lines 139-145 (`_FACE_CLOSE_UP_PATTERNS`) + 148-161 (`_is_face_close_up`).

### W4.4: Replace `_forward_enforcement_exempt()` (line 195-) with split helpers

- [ ] In `backend/app/core/visible_entities_validator.py`, after line ~190 (existing function definition), replace entire `_forward_enforcement_exempt` (line 195-280 또는 함수 끝) with two split helpers:

```python
def _is_forward_exempt_by_render_strategy(rpc: Dict[str, Any]) -> Tuple[bool, str]:
    """Area B/G4 보존 — render_strategy.mode == 'partial_focus' 면 forward 면제."""
    rs = rpc.get("render_strategy")
    if isinstance(rs, dict):
        mode = rs.get("mode")
        if isinstance(mode, str) and mode == "partial_focus":
            return True, "render_strategy.mode == 'partial_focus'"
    return False, ""


def _is_forward_exempt_by_reproduction_surface_rule(
    rpc: Dict[str, Any],
) -> Tuple[bool, str]:
    """Area C 보존 — id_policy.reproduction_surface_rule.applies == True 면 면제."""
    id_policy = rpc.get("id_policy")
    if not isinstance(id_policy, dict):
        return False, ""
    repro = id_policy.get("reproduction_surface_rule", _MISSING)
    if repro is _MISSING:
        return False, ""
    if isinstance(repro, dict):
        applies = repro.get("applies", _MISSING)
        if applies is True:
            return True, "reproduction_surface_rule.applies == True"
        elif applies is False or applies is _MISSING:
            return False, ""
        else:
            # non-bool, non-missing → fail-fast (Gate 4)
            raise AppError(
                code="step.contract_violation.id_policy.reproduction_surface_rule.invalid_applies",
                message=(
                    f"reproduction_surface_rule.applies must be bool, "
                    f"got {type(applies).__name__}"
                ),
            )
    raise AppError(
        code="step.contract_violation.id_policy.reproduction_surface_rule.invalid_type",
        message=f"reproduction_surface_rule must be dict, got {type(repro).__name__}",
    )

# NOTE: _forward_enforcement_exempt() — REMOVED (Area #1 W4).
# body-part substring branch 폐기 + structured exemption 위 2 helper 로 분리.
# missing subject 검사 = per-subject policy loop (W4.6 call site).
```

### W4.5: Replace inline `visible_bases` 패턴 (line 375-378) with helper call

- [ ] Edit lines 373-378:

```python
    visible_list = _require_field(shot, "visible_entities", list, shot_label)
    visible = set(visible_list)
    from app.core.subject_reference_policy import derive_visible_subject_ids
    visible_bases = derive_visible_subject_ids(visible_list)
```

(`startswith("C") + split("O")[0]` inline 패턴 폐기.)

### W4.6: Rewrite forward enforcement call site (line ~510-540) — per-subject loop

- [ ] Find lines 510-540 (현재 `_forward_enforcement_exempt(rpc, prompt)` 호출 위치) and replace with per-subject loop:

```python
        used_ids = set(_ENTITY_ID_PATTERN.findall(prompt))
        used_bases = {sid.split("O")[0] for sid in used_ids}

        # Used outlook detect: f"{base}O" prefix.
        used_outlook_for_base = {
            base
            for base in used_bases
            if any(sid.startswith(f"{base}O") for sid in used_ids)
        }

        for sid in used_ids:
            base = sid.split("O")[0]
            if base not in visible_bases:
                raise AppError(
                    code="step.scene_detail.contract_violation_id_not_visible",
                    message=(
                        f"{shot_label} (Source 1, RO-2): t2i_variations[{vidx}]."
                        f"t2i_prompt uses '{sid}' (base={base}) but "
                        f"visible_entities={sorted(visible)} does not include it."
                    ),
                    status_code=400,
                )

        missing_bases = visible_bases - used_bases
        if missing_bases:
            # Area #1 W4: structured exemption 우선 검사 (Area B/C 보존).
            exempt_rs, reason_rs = _is_forward_exempt_by_render_strategy(rpc)
            exempt_rep, reason_rep = _is_forward_exempt_by_reproduction_surface_rule(rpc)
            if exempt_rs:
                logger.debug(
                    "%s (Source 1 forward exempt, variation %d): %s",
                    shot_label, vidx, reason_rs,
                )
            elif exempt_rep:
                logger.debug(
                    "%s (Source 1 forward exempt, variation %d): %s",
                    shot_label, vidx, reason_rep,
                )
            else:
                # Per-subject policy loop (Area #1).
                from app.core.subject_reference_policy import (
                    normalize_subject_reference_policy_items,
                    get_subject_reference_policy_or_default,
                    policy_to_id_usage_rule,
                )
                id_policy = rpc.get("id_policy") or {}
                srp_array = id_policy.get("subject_reference_policy")
                if srp_array is None:
                    raise AppError(
                        code="step.contract_violation.id_policy.field_missing",
                        message=(
                            f"{shot_label}: card.id_policy missing required field "
                            f"'subject_reference_policy' (Area #1)"
                        ),
                    )
                policy_map = normalize_subject_reference_policy_items(
                    srp_array,
                    visible_subject_ids=visible_bases,
                    where=f"validator:{shot_label}.variation[{vidx}]",
                )
                for base in sorted(missing_bases):
                    policy = get_subject_reference_policy_or_default(
                        policy_map, base, where=f"validator:{shot_label}",
                    )
                    rule = policy_to_id_usage_rule(policy.policy)
                    if rule.base_required:
                        raise AppError(
                            code="step.scene_detail.contract_violation.subject_reference_policy.base_id_missing",
                            message=(
                                f"{shot_label} (variation {vidx}): subject {base!r} "
                                f"policy={policy.policy!r} requires base C## in "
                                f"t2i_prompt, but missing. visible_entities="
                                f"{sorted(visible)}."
                            ),
                            status_code=400,
                        )

        # Per-subject outlook required + outlook forbidden 검사 (used_ids 기준).
        from app.core.subject_reference_policy import (
            normalize_subject_reference_policy_items,
            get_subject_reference_policy_or_default,
            policy_to_id_usage_rule,
        )
        id_policy_for_outlook = rpc.get("id_policy") or {}
        srp_for_outlook = id_policy_for_outlook.get("subject_reference_policy")
        if srp_for_outlook is None:
            raise AppError(
                code="step.contract_violation.id_policy.field_missing",
                message=(
                    f"{shot_label}: card.id_policy missing required field "
                    f"'subject_reference_policy' (Area #1)"
                ),
            )
        policy_map_outlook = normalize_subject_reference_policy_items(
            srp_for_outlook,
            visible_subject_ids=visible_bases,
            where=f"validator:{shot_label}.variation[{vidx}].outlook",
        )
        for base in sorted(used_bases):
            policy = get_subject_reference_policy_or_default(
                policy_map_outlook, base, where=f"validator:{shot_label}",
            )
            rule = policy_to_id_usage_rule(policy.policy)
            has_outlook = base in used_outlook_for_base
            if rule.outlook_required and not has_outlook:
                raise AppError(
                    code="step.scene_detail.contract_violation.subject_reference_policy.outlook_id_missing",
                    message=(
                        f"{shot_label} (variation {vidx}): subject {base!r} "
                        f"policy={policy.policy!r} requires outlook C##O## but "
                        f"only base form present."
                    ),
                    status_code=400,
                )
            if rule.outlook_forbidden and has_outlook:
                raise AppError(
                    code="step.scene_detail.contract_violation.subject_reference_policy.outlook_forbidden",
                    message=(
                        f"{shot_label} (variation {vidx}): subject {base!r} "
                        f"policy={policy.policy!r} forbids outlook C##O## but "
                        f"prompt uses outlook form."
                    ),
                    status_code=400,
                )
```

### W4.7: Run tests (verify pass)

```bash
cd backend && pytest tests/core/test_visible_entities_validator.py -v
```
Expected: PASS — 신규 W4.1 tests + 기존 partial_focus / reproduction_surface_rule regression tests 모두 통과.

### W4.8: Verify residue

```bash
grep -n "_FACE_CLOSE_UP_PATTERNS\|_is_face_close_up\|_forward_enforcement_exempt\|body_part_focus_rule" backend/app/core/visible_entities_validator.py
```
Expected: 0 hits.

### W4.9: Commit W4 (atomic)

```bash
git add backend/app/core/visible_entities_validator.py \
        backend/tests/core/test_visible_entities_validator.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W4 — validator per-subject + structured exemption

폐기:
- _FACE_CLOSE_UP_PATTERNS / _is_face_close_up (substring matching, Gate 1)
- _forward_enforcement_exempt() (shot 단위 boolean toggle, Gate 4)

신설 (structured exemption 보존, Area B/C 회귀 차단):
- _is_forward_exempt_by_render_strategy (render_strategy.mode == 'partial_focus')
- _is_forward_exempt_by_reproduction_surface_rule (id_policy.reproduction_surface_rule.applies)

Per-subject policy loop (Area #1):
- normalize_subject_reference_policy_items + get_subject_reference_policy_or_default
- policy_to_id_usage_rule (IdUsageRule matrix)
- base_id_missing / outlook_id_missing / outlook_forbidden AppError per subject

visible_bases inline 패턴 → derive_visible_subject_ids helper 통합.

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

---

## W5: scene_detail v25 full atomic

v25 directory creation (v24 pack **2 stem** 복사 + system.md 9 곳 rewrite) + `SCENE_DETAIL_SCHEMA_VERSION` 9→10 + `SCENE_DETAIL_PROMPT_VERSION` 24→25 + step_manifest schema 9→10 + version_registry dual sync + alignment tests — **한 commit**.

**Files:**
- Create: `prompts/_base/scene_detail/25.<TS>/detail_schema.json` (v24 그대로 복사)
- Create: `prompts/_base/scene_detail/25.<TS>/system.md` (v24 base + 9 곳 rewrite)
- Modify: `backend/app/core/steps/detail_steps.py` (line 104-105)
- Modify: `backend/app/core/step_manifest.py:707`
- Modify: `backend/app/core/version_registry.py:34, 126`
- Modify: `backend/tests/prompts/test_scene_detail_spatial_alignment.py` (line 66/98/117/122/161/191)
- Modify: `backend/tests/prompts/test_scene_detail_continuity_alignment.py` (line 63/95/110/151/180)
- Modify: `backend/tests/prompts/test_scene_detail_id_policy_alignment.py` (logic 추가)
- Modify: `backend/tests/test_prompt_versions.py` (line 111/116/119/126)

### W5.1: Copy v24 pack → v25 (2 stem)

- [ ] TIMESTAMP determination (re-use W2 timestamp or new):
  ```bash
  date -u +%Y%m%d%H%M
  ```
- [ ] Copy pack:
  ```bash
  TS=<paste from above>
  LATEST=$(ls -1 prompts/_base/scene_detail/ | sort -V | tail -1)
  cp -r prompts/_base/scene_detail/$LATEST prompts/_base/scene_detail/25.$TS
  ```
- [ ] Verify 2 stem:
  ```bash
  ls -1 prompts/_base/scene_detail/25.$TS/
  # Expected: detail_schema.json, system.md
  ```

### W5.2: Rewrite `prompts/_base/scene_detail/25.<TS>/system.md` 9 곳

각 line 의 원본 내용은 v24 system.md 안에서 read 후 다음 원칙으로 rewrite:

- [ ] Line ~41-44 — `body_part_focus_rule.trigger_phrases` substring instruction 대체:
  ```markdown
  ## subject_reference_policy (per-subject identity policy SOT)

  card.id_policy.subject_reference_policy[] 안 각 subject 의 policy enum 에 따라:
  - `id_and_outlook_required`: t2i_prompt 안 base C## + outlook C##O## 둘 다 emit
  - `base_id_required`: base C## 만 emit; C##O## (outlook) 금지
  - `generic_descriptor_allowed`: ID 없이 generic descriptor (e.g. "a man in robe") OK; outlook 금지
  ```

- [ ] Line ~45-48 — `close_framing_face_phrasing.forbidden_phrases` 삭제 (해당 section 통째 제거).

- [ ] Line ~120 — `ref_usage=zoom_in_detail` body-part Focus 대체:
  ```markdown
  ref_usage=zoom_in_detail (앞 shot 의 부분 확대) 시에도 per-subject policy 그대로 적용. policy = generic_descriptor_allowed 인 subject 만 descriptor 로 묘사.
  ```

- [ ] Line ~123 — `view_consistency.single_camera_rule` body-part Focus rewrite:
  ```markdown
  하나의 t2i_prompt = 하나의 camera position. 동일 subject 의 full body + close-up 합성 금지. "Focus on" 은 frame area 지시이지 view switch 아님 (per-subject policy 와 직교).
  ```

- [ ] Line ~156 — `primary_framing_rule.close_framing_rules` body-part focus 대체:
  ```markdown
  close framing: primary subject 만 fully visible. 다른 subject 는 partial / absent / soft bg. per-subject policy 별 ID 사용 의무 (subject_reference_policy_cross_ref).
  ```

- [ ] Line ~157 — `primary_framing_rule.wide_medium_rules` rewrite (동일 pattern).

- [ ] Line ~357-361 — sub-region wording 원칙 (closed-list 예시 인용 금지):
  ```markdown
  Sub-region wording (frame 안 특정 부위 강조) 은 card 의 `id_policy.subject_reference_policy[].policy` enum 에 따라 결정. policy 가 허용하는 형태 (base C##, outlook C##O##, 또는 generic descriptor) 만 사용.
  ```

  **주의** (plan 실행자): prompt 안에 "Focus on C##'s eyes/hand/jaw" / "common noun + demographic descriptor" 같은 구체적 phrase 를 예시로 인용하면 LLM 이 다시 그 패턴을 학습 — 본 area 가 폐기하려는 closed-list 어휘 재도입 위험. 원칙 wording 만 (policy enum 참조), closed-list phrase 인용 절대 금지.

- [ ] Line ~487 cross-ref rewrite — `body_part_focus_rule.trigger_phrases (line 41-44)` → `subject_reference_policy (line 41-44)` 변경 (line 41-44 의 신규 section 가리키도록).

### W5.3: Write failing alignment tests

먼저 D4 sweep (W7) 의 일부를 W5 에서 처리 — alignment tests 4 파일 의 hardcoded version 갱신:

- [ ] Edit `backend/tests/prompts/test_scene_detail_spatial_alignment.py`:
  - Line 66: `assert version == "1.25.0"` (was `"1.24.0"`)
  - Line 98: `assert pdep == "scene_detail/v25"` (was `"scene_detail/v24"`)
  - Line 117: `assert SCENE_DETAIL_PROMPT_VERSION.startswith("25.")` (was `== "24.202605151451"`)
  - Line 122: `assert SCENE_DETAIL_PROMPT_VERSION.startswith("25.")` (was `"24."`)
  - Line 161: `assert SCENE_DETAIL_SCHEMA_VERSION == 10` (was `== 9`)
  - Line 191: `assert latest_major == "25"` (was `"24"`)

- [ ] Edit `backend/tests/prompts/test_scene_detail_continuity_alignment.py`:
  - Line 63: `assert MODULE_VERSIONS["scene_detail_composer"] == "1.25.0"`
  - Line 95: `assert info["prompt_dependency"] == "scene_detail/v25"`
  - Line 110: `assert SCENE_DETAIL_PROMPT_VERSION.startswith("25.")` (loose match, allows new timestamp)
  - Line 151: `assert SCENE_DETAIL_SCHEMA_VERSION == 10`
  - Line 180: `assert latest_major == "25"`

- [ ] Edit `backend/tests/test_prompt_versions.py`:
  - Line 111: `assert STEP_MANIFEST["scene_detail"]["schema_version"] == 10`
  - Line 116: `assert SCENE_DETAIL_SCHEMA_VERSION == 10`
  - Line 119: `assert SCENE_DETAIL_PROMPT_VERSION.startswith("25.")`
  - Line 126: `assert MODULE_VERSIONS["scene_detail_composer"] == "1.25.0"`

- [ ] Edit `backend/tests/prompts/test_scene_detail_id_policy_alignment.py` — add new test (after existing tests):

```python
def test_card_payload_has_subject_reference_policy_field():
    """Area #1 — id_policy.subject_reference_policy 신규 sub-field 가 card payload 안 항상 존재."""
    from app.core.steps.render_prompt_card import build_id_policy
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=[],
    )
    assert "subject_reference_policy" in ip
    assert isinstance(ip["subject_reference_policy"], list)
    # 폐기 확인
    assert "body_part_focus_rule" not in ip
    assert "close_framing_face_phrasing" not in ip
```

### W5.4: Run tests (fail)

```bash
cd backend && pytest tests/prompts/ tests/test_prompt_versions.py -v
```
Expected: FAIL — schema/version 아직 v24 (W5.5/5.6/5.7 미실행)

### W5.5: Bump `detail_steps.py` SCHEMA_VERSION + PROMPT_VERSION

- [ ] Edit `backend/app/core/steps/detail_steps.py:104`:

```python
SCENE_DETAIL_SCHEMA_VERSION = 10  # 2026-05-16 (Area #1): id_policy.subject_reference_policy 추가 + body_part_focus_rule/close_framing_face_phrasing 제거. 이전: 9 (area-frame-spatial-contract T5).
```

- [ ] Edit `backend/app/core/steps/detail_steps.py:105`:

```python
SCENE_DETAIL_PROMPT_VERSION = "25.<TS>"  # 2026-05-16 (Area #1) — v25 prompt: subject_reference_policy 정책 consume.
```
(Replace `<TS>` with actual W5.1 TIMESTAMP.)

### W5.6: Bump `step_manifest.py:707`

- [ ] Edit:

```python
        "schema_version": 10,  # 2026-05-16 (Area #1): subject_reference_policy. 이전: 9.
```

### W5.7: Bump `version_registry.py:34, 126`

- [ ] Edit line 34:
  ```python
      "scene_detail_composer": "1.25.0",    # 2026-05-16 (Area #1) — v25 prompt + schema 10.
  ```
- [ ] Edit line 126:
  ```python
      "scene_detail_composer": {
          "prompt_dependency": "scene_detail/v25",
          "updated_at": "2026-05-16",
      },
  ```

### W5.8: Run tests (verify pass)

```bash
cd backend && pytest tests/prompts/ tests/test_prompt_versions.py -v
```
Expected: PASS (모든 alignment + version test)

### W5.9: Verify residue (active prompt + version consistency)

```bash
# Active prompt residue (v25 만 검사)
grep -rn "body_part_focus_rule\|close_framing_face_phrasing\|trigger_phrases\|body_part_focus_cross_ref" \
    prompts/_base/scene_detail/25.*/
# Expected: 0 hits

# F1 sync gate
sed -n '681,712p' backend/app/core/step_manifest.py
grep -n "SCENE_DETAIL_SCHEMA_VERSION\|SCENE_DETAIL_PROMPT_VERSION" backend/app/core/steps/detail_steps.py
grep "scene_detail_composer\|scene_detail/v" backend/app/core/version_registry.py
ls -1 prompts/_base/scene_detail/ | sort -V | tail -1
# Expected: schema=10, PROMPT="25.<TS>", composer="1.25.0", "scene_detail/v25", latest="25.<TS>"
```

### W5.10: Commit W5 (atomic)

```bash
git add prompts/_base/scene_detail/25.*/ \
        backend/app/core/steps/detail_steps.py \
        backend/app/core/step_manifest.py \
        backend/app/core/version_registry.py \
        backend/tests/prompts/test_scene_detail_spatial_alignment.py \
        backend/tests/prompts/test_scene_detail_continuity_alignment.py \
        backend/tests/prompts/test_scene_detail_id_policy_alignment.py \
        backend/tests/test_prompt_versions.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W5 — scene_detail v25 5-way sync

신규 prompt pack: prompts/_base/scene_detail/25.<TS>/ (v24 pack 2 stem 복사 + system.md rewrite)
- subject_reference_policy LLM instruction (per-subject policy enum 별 ID/outlook/descriptor 사용)
- body-part Focus 패턴 9 곳 rewrite (trigger_phrases / forbidden_phrases / close_framing_rules / wide_medium_rules / anchor prose / cross-ref)

5-way version sync (BLOCKING):
- detail_steps.py SCENE_DETAIL_SCHEMA_VERSION 9 → 10 (verify_completion card check gate)
- detail_steps.py SCENE_DETAIL_PROMPT_VERSION 24.* → 25.<TS> (_config_hash invalidation)
- step_manifest.py:707 schema_version 9 → 10
- version_registry.py:34 MODULE_VERSIONS 1.24.0 → 1.25.0
- version_registry.py:126 prompt_dependency v24 → v25

Alignment tests 갱신:
- test_scene_detail_spatial_alignment.py (6 hardcoded sites)
- test_scene_detail_continuity_alignment.py (5 hardcoded sites)
- test_scene_detail_id_policy_alignment.py (subject_reference_policy field 검증 추가)
- test_prompt_versions.py (4 hardcoded sites)

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

---

## W6: scene_extractor_v2 v19 full atomic

v19 directory creation (v18 pack **5 stem** 전체 복사 + turn_scene_detail.md rewrite) + loader latest pick verify — **한 commit**.

**Files:**
- Create: `prompts/_base/scene_extractor_v2/19.<TS>/` (5 stem)
- Test: loader latest pick (smoke test)

### W6.1: Copy v18 pack → v19 (5 stem)

- [ ] TS determination + copy:
  ```bash
  TS=<timestamp>
  LATEST=$(ls -1 prompts/_base/scene_extractor_v2/ | sort -V | tail -1)
  cp -r prompts/_base/scene_extractor_v2/$LATEST prompts/_base/scene_extractor_v2/19.$TS
  ls -1 prompts/_base/scene_extractor_v2/19.$TS/
  # Expected 5 stems: system.md, turn0_context.md, turn1_split_long.md, turn_scene_detail.md, scene_detail_schema.json
  ```

### W6.2: Rewrite `turn_scene_detail.md`

- [ ] Edit `prompts/_base/scene_extractor_v2/19.<TS>/turn_scene_detail.md`:
  - Lines `:34-38` (closed-list body-part 예시) — 원칙 wording 으로 약화:
    ```markdown
    identity-bearing visibility (얼굴/체형/의상 등) 가 충분히 보이지 않은 subject 는 generic descriptor 를 사용할 수 있다. 본 단계는 upstream prompt hygiene 만 담당하며, 최종 per-subject SOT = `shot_staging.subject_reference_policy` (Area #1). 구체적 descriptor 예시 인용 금지 (LLM 패턴 학습 차단).
    ```
  - **card.subject_reference_policy 직접 참조 금지** 명시 — scene_extractor_v2 < shot_staging order 이므로 card field 가 존재하지 않음.

### W6.3: Write smoke test for loader latest pick

- [ ] Append to `backend/tests/test_prompt_versions.py` (or appropriate test file):

```python
def test_scene_extractor_v2_v19_latest_loaded():
    """scene_extractor_v2 turn_scene_detail loader 가 v19 자동 pick."""
    from app.modules.prompt_loader import load_prompt
    content = load_prompt("scene_extractor_v2", "turn_scene_detail")
    # v19 신규 wording 확인 (closed-list 약화 marker)
    assert "shot_staging.subject_reference_policy" in content, (
        "scene_extractor_v2 v19 prompt 가 loader 의 latest pick 가 아님"
    )


def test_scene_extractor_v2_v19_directory_exists():
    base = REPO_ROOT / "prompts" / "_base" / "scene_extractor_v2"
    v19_dirs = sorted([d for d in base.iterdir() if d.name.startswith("19.")])
    assert v19_dirs, "scene_extractor_v2 v19 디렉토리 없음 (W6 미실행)"
    # 5 stem 확인
    files = sorted(p.name for p in v19_dirs[-1].iterdir() if p.is_file())
    assert "system.md" in files
    assert "turn0_context.md" in files
    assert "turn1_split_long.md" in files
    assert "turn_scene_detail.md" in files
    assert "scene_detail_schema.json" in files
```

### W6.4: Run tests

```bash
cd backend && pytest tests/test_prompt_versions.py::test_scene_extractor_v2_v19_latest_loaded \
  tests/test_prompt_versions.py::test_scene_extractor_v2_v19_directory_exists -v
```
Expected: PASS.

### W6.5: Verify (manual)

```bash
ls -1 prompts/_base/scene_extractor_v2/ | sort -V | tail -1
# Expected: 19.<TS>
cd backend && python -c "from app.modules.prompt_loader import load_prompt; print(load_prompt('scene_extractor_v2', 'turn_scene_detail')[:300])"
# Expected: v19 wording (shot_staging.subject_reference_policy 명시)
```

### W6.6: Commit W6 (atomic)

```bash
git add prompts/_base/scene_extractor_v2/19.*/ \
        backend/tests/test_prompt_versions.py
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W6 — scene_extractor_v2 v19 (upstream hygiene)

신규 prompt pack: prompts/_base/scene_extractor_v2/19.<TS>/ (v18 5 stem 전체 복사)
- turn_scene_detail.md:34-38 closed-list 약화 → 원칙 wording
- 최종 SOT = shot_staging.subject_reference_policy 명시
- card.subject_reference_policy 직접 참조 금지 (파이프라인 순서 정합)

Loader latest pick verify (app.modules.prompt_loader.load_prompt).

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

---

## W7: Full test sweep cleanup

D1 11 files (helper-based 재작성) + D4 hardcoded version sweep (4+ files including `test_g3_2_manifest_dag.py`) + 신규 e2e integration test — **한 commit**.

**Files:**
- Modify D1 (helper-based 재작성):
  - `backend/tests/_gate/test_semantic_regex_ban.py` (`_CHAR_ID_RE` exempt rule 추가)
  - `backend/tests/core/test_visible_entities_validator.py` (W4 에서 일부 처리; 잔여)
  - `backend/tests/services/test_face_close_up_exemption.py` (helper-based 재작성 또는 폐기 + helper unit test 로 대체)
  - `backend/tests/services/test_c01_zero_gate_regression.py` (helper-based)
  - `backend/tests/integration/test_g4_3_id_policy_integration.py` (subject_reference_policy 검증)
  - `backend/tests/integration/test_g4_4_continuity_integration.py` (literal rewrite 정합)
  - `backend/tests/integration/test_g4_5a_spatial_integration.py` (rename 정합 + line 602/604/628/637)
  - `backend/tests/unit/test_g4_3_id_policy_lift.py` (W3 에서 일부 처리; subject_reference_policy 검증 추가)
  - `backend/tests/unit/test_g4_4_continuity_lift.py` (literal 정합)
  - `backend/tests/unit/test_g4_5a_spatial_lift.py` (rename 정합)
  - `backend/tests/prompts/test_scene_detail_v21_rule_x2_semantic.py` (v25 정합)
- Modify D4 추가:
  - `backend/tests/unit/test_g3_2_manifest_dag.py` (line 63 함수명 + line 72 assert)
- Create: `backend/tests/integration/test_subject_reference_policy_e2e.py`

### W7.1: D4 grep gate — discover all remaining hardcoded version sites

- [ ] Run:
  ```bash
  rg -n '1\.24\.0|scene_detail/v24|"24\.[0-9]+"|startswith\("24|SCENE_DETAIL_SCHEMA_VERSION == 9|"schema_version"\] == 9|latest_major == "24"|shot_staging.*schema_version.*3|shot_staging[ _/-]?v11|v11_dirs|startswith\("11\.' \
    backend/tests --glob '!**/_audit_outputs/**'
  ```
- [ ] Document complete list of files + lines. Every hit must be updated.

**`_audit_outputs/` = protected untracked evidence; 수정 절대 금지.**

### W7.2: Edit `test_g3_2_manifest_dag.py` (line 63 함수명 + line 72 assert)

- [ ] Edit line 63 — rename function:

```python
def test_scene_detail_schema_version_10():
    """Area #1 (2026-05-16): schema 9 → 10 (id_policy.subject_reference_policy 추가, body_part_focus_rule / close_framing_face_phrasing 제거).
    
    Prior history: G3.2 5→6 / G4.1 6→7 / D6 T5d 7→8 / area-frame-spatial-contract T5 8→9.
    """
    assert STEP_MANIFEST["scene_detail"].get("schema_version") == 10
```

- [ ] Edit line 72 — `== 10`.

### W7.3: Edit `test_g4_5a_spatial_integration.py:602, 604, 628, 637`

- [ ] Lines 602/604/628/637 — replace `1.24.0` → `1.25.0` and `scene_detail/v24` → `scene_detail/v25`.

### W7.4: Edit remaining D1 11 files (helper-based)

For each D1 file, follow this pattern:

- [ ] **`test_semantic_regex_ban.py`** — add exempt rule for `backend/app/core/subject_reference_policy.py` ( `_CHAR_ID_RE` = closed-world ID syntax; Gate 1 exempt):

```python
# Closed-world ID syntax exempt (Area #1 helper module)
EXEMPT_FILES = {
    "backend/app/core/subject_reference_policy.py",
    # ... 기존 exempt
}
```

- [ ] **`test_face_close_up_exemption.py`** — body-part 폐기 후 의미 상실 시 폐기 + W1 helper unit test 가 대체. 폐기 시:
  ```bash
  git rm backend/tests/services/test_face_close_up_exemption.py
  ```
  단 helper-based 재작성 가능하면 재작성 (per-subject policy 검증).

- [ ] **`test_c01_zero_gate_regression.py`** — regression 시나리오를 per-subject policy + helper API 로 재작성.

- [ ] **`test_g4_3_id_policy_integration.py`** — `id_policy.subject_reference_policy` sub-field 신규 검증 추가. body_part_focus_rule 검증 라인 제거.

- [ ] **`test_g4_4_continuity_integration.py`** — `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` rewrite 정합 검증.

- [ ] **`test_g4_5a_spatial_integration.py`** — `subject_reference_policy_cross_ref` rename 정합 + W7.3 의 version sweep.

- [ ] **`test_g4_3_id_policy_lift.py`** — subject_reference_policy 검증 추가 (W3.13 에서 wrapper 처리; 본 W7 에서 추가 logic).

- [ ] **`test_g4_4_continuity_lift.py`** — literal rewrite 정합.

- [ ] **`test_g4_5a_spatial_lift.py`** — rename 정합.

- [ ] **`test_scene_detail_v21_rule_x2_semantic.py`** — v25 정합 (또는 파일명 정합 review — 다음 area carry).

### W7.5: Create e2e integration test

- [ ] Create `backend/tests/integration/test_subject_reference_policy_e2e.py`:

```python
"""Area #1 — producer → card → consumer e2e integration.

shot_staging v12 emits subject_reference_policy[] → render_prompt_card normalizes +
injects id_policy.subject_reference_policy → visible_entities_validator + scene_detail
prompt consume via per-subject IdUsageRule matrix.
"""
import pytest
from app.core.errors import AppError


def test_e2e_producer_to_card_inject():
    """Producer emit → card payload 안 sub-field 존재."""
    from app.core.steps.render_prompt_card import build_id_policy
    from app.core.subject_reference_policy import (
        normalize_subject_reference_policy_items,
        serialize_subject_reference_policy_map,
    )
    raw_items = [{
        "subject_id": "C01",
        "policy_type": "identity_reference",
        "policy": "base_id_required",
        "reason": "test",
    }]
    policy_map = normalize_subject_reference_policy_items(
        raw_items, visible_subject_ids={"C01"}, where="e2e_test"
    )
    serialized = serialize_subject_reference_policy_map(policy_map)
    ip = build_id_policy(
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode="direct",
        key_bg_elements=[],
        subject_reference_policies=serialized,
    )
    assert "subject_reference_policy" in ip
    assert len(ip["subject_reference_policy"]) == 1
    assert ip["subject_reference_policy"][0]["policy"] == "base_id_required"


def test_e2e_v12_staging_missing_field_caller_appends_error_marker():
    """v12 staging dict + subject_reference_policy field 누락 → caller AppError ".field_missing" (Gate 4 second-line defense).

    W2 atomic 에서 v12 schema 에 field required 추가했으므로 producer LLM emit
    단계에서 schema validation 으로 1차 차단. caller (build_render_prompt_card)
    의 second-line defense — schema validation 우회 path (legacy / mocked dict)
    에서도 field 누락 시 fail-fast.
    """
    from app.core.steps.render_prompt_card import build_render_prompt_card
    # Per-shot staging dict — subject_reference_policy field 자체 누락 (contract violation).
    staging_missing_field = {
        "scene_index": 1,
        "shot_index": 1,
        "framing_scale": "close",
        "character_angles": [
            {
                "character": "Alice",
                "angle": "facing_camera",
                "body_pose": "leaning against doorframe",
                "gaze_target": "down",
            },
        ],
        "key_bg_elements": [],
        # subject_reference_policy 누락 — v12 contract violation
    }
    with pytest.raises(AppError) as exc:
        build_render_prompt_card(
            scene_index=1,
            shot_index=1,
            seg={"scene_text": "test scene"},
            shot_info=staging_missing_field,
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
            perception_mode="direct",
            staging=staging_missing_field,
            bg_id=None,
            bg_owned=[],
            bg_camera_meta=None,
            bg_guide=None,
            is_close_framing=True,
            background_mode_on=False,
            fixed_elements=[],
            previous_shot_refs=[],
            forward_zoom_targets=[],
        )
    assert "field_missing" in exc.value.code, (
        f"caller-side fail-fast 누락 — Gate 4 위반 위험: {exc.value.code}"
    )


def test_e2e_legacy_staging_not_applicable_path_graceful():
    """shot_info.staging_not_applicable=True marker → helper graceful empty."""
    from app.core.subject_reference_policy import normalize_subject_reference_policy_items
    # caller 가 명시적으로 items=None pass (legacy path)
    result = normalize_subject_reference_policy_items(
        None, visible_subject_ids=None, where="legacy_path"
    )
    assert result == {}


def test_e2e_per_subject_no_leak():
    """한 subject 의 policy 가 다른 subject 의 default 를 덮어쓰지 않음."""
    from app.core.subject_reference_policy import (
        normalize_subject_reference_policy_items,
        get_subject_reference_policy_or_default,
    )
    items = [{
        "subject_id": "C01",
        "policy_type": "identity_reference",
        "policy": "generic_descriptor_allowed",
        "reason": "C01 only",
    }]
    pmap = normalize_subject_reference_policy_items(
        items, visible_subject_ids={"C01", "C02"}, where="test"
    )
    # C01: emit 된 policy
    p1 = get_subject_reference_policy_or_default(pmap, "C01", where="test")
    assert p1.policy == "generic_descriptor_allowed"
    # C02: default (omitted, exceptions-first)
    p2 = get_subject_reference_policy_or_default(pmap, "C02", where="test")
    assert p2.policy == "id_and_outlook_required"
```

### W7.6: Run full test suite + verify residue gates

```bash
# 1. 전체 test 실행 (regression 검사용)
cd backend && pytest tests/ -q --ignore=tests/_audit_outputs/

# 2. 결과 fails 가 Pre-existing baseline (test_evidence_consumer_wiring 1 +
#    test_analysis_dispatch_service 1 + test_text_cleanup 3 = 5 fails) 외
#    신규 fail 0 확인 의무.

# 3. D4 residue gate (audit 제외):
rg -n '1\.24\.0|scene_detail/v24|"24\.[0-9]+"|startswith\("24|SCENE_DETAIL_SCHEMA_VERSION == 9|"schema_version"\] == 9|latest_major == "24"|shot_staging.*schema_version.*3|shot_staging[ _/-]?v11|v11_dirs|startswith\("11\.' \
  backend/tests --glob '!**/_audit_outputs/**'
# Expected: 0 hits (audit 제외, 정상 대상 모두 갱신)

# 4. E1 production code residue:
grep -rn "_FACE_CLOSE_UP_PATTERNS\|body_part_focus_rule\|close_framing_face_phrasing\|_ID_BODY_PART_TRIGGERS\|_ID_CLOSE_FACE_FORBIDDEN_PHRASES\|_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS\|body_part_focus_cross_ref\|_is_face_close_up\|id_policy_cross_ref_for_body_part_focus" \
    backend/app/
# Expected: 0 hits

# 5. E1b prose residue:
grep -rn "body-part Focus\|focused sub-region is a body part\|common noun + demographic descriptor\|C##O## 금지\|얼굴 ref 합성 차단" \
    backend/app/core/steps/render_prompt_card.py
# Expected: 0 hits

# 6. E2 active prompt residue (identifier + prose pattern 둘 다 차단):
grep -rEn "body_part_focus_rule|close_framing_face_phrasing|trigger_phrases|body_part_focus_cross_ref|Focus on C##|body-part Focus|focused sub-region is a body part|common noun \+ demographic descriptor" \
    prompts/_base/scene_detail/25.*/ \
    prompts/_base/scene_extractor_v2/19.*/
# Expected: 0 hits — identifier (4) + prose pattern (4) 모두 차단 (LLM 이 closed-list 어휘 재학습 차단)

# 7. E3 literal cascade:
grep -rn "id_policy.body_part_focus_rule" \
    backend/app/ \
    prompts/_base/scene_detail/25.*/ \
    prompts/_base/scene_extractor_v2/19.*/
# Expected: 0 hits
```

### W7.7: Commit W7 (atomic)

```bash
git add backend/tests/
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W7 — full test sweep cleanup

D1 (11 files) — helper-based 재작성:
- _gate/test_semantic_regex_ban (subject_reference_policy exempt rule)
- core/test_visible_entities_validator (W4 carry)
- services/test_face_close_up_exemption (폐기 또는 helper-based)
- services/test_c01_zero_gate_regression
- integration/test_g4_3/g4_4/g4_5a (sub-field / literal rewrite / rename 정합)
- unit/test_g4_3/g4_4/g4_5a_lift
- prompts/test_scene_detail_v21_rule_x2_semantic (v25 정합)

D4 hardcoded version sweep:
- test_prompt_versions / test_scene_detail_*_alignment 4 files (W2/W5 일부; W7 잔여)
- test_g3_2_manifest_dag.py:63 함수명 + :72 assert (schema 9 → 10)
- test_g4_5a_spatial_integration:602/604/628/637 (1.24.0 / v24 → 1.25.0 / v25)
- rg --glob '!**/_audit_outputs/**' 결과 추가 발견 위치 모두

신규 integration test:
- tests/integration/test_subject_reference_policy_e2e.py (producer → card → consumer)

Residue gate 0:
- E1 production code / E1b prose / E2 active prompt / E3 literal / D4 hardcoded

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

---

## W8a: Closure (구현)

archival + residue gates 전체 + regression baseline 검증 + Codex external review prep — **한 commit**.

### W8a.1: Archival — g4_6 fixture 분리

- [ ] Create archival directory or move:
  ```bash
  mkdir -p backend/tests/fixtures/_archive/g4_6_2026-05-16-area-1
  git mv backend/tests/fixtures/g4_6/s1_shot5_scene_detail.json \
         backend/tests/fixtures/_archive/g4_6_2026-05-16-area-1/
  git mv backend/tests/fixtures/g4_6/s2_shot4_scene_detail.json \
         backend/tests/fixtures/_archive/g4_6_2026-05-16-area-1/
  ```

(Naming convention follows `session_20260515_framing_scale_enum_sot_v1_closure` archival pattern.)

### W8a.2: Run all residue gates (W7.6 commands again)

- [ ] Confirm 0 hits across E1/E1b/E2/E3/D4.

### W8a.3: Regression baseline verification

```bash
cd backend && pytest tests/ -q --ignore=tests/_audit_outputs/ 2>&1 | tee /tmp/area_1_closure_pytest.log
```

- [ ] Extract failures list from log. Compare against pre-existing baseline:
  - `test_evidence_consumer_wiring.py` 1
  - `test_analysis_dispatch_service.py` 1
  - `test_text_cleanup.py` 3
  - **Total pre-existing = 5 fails**
- [ ] New failures = 0 (외 pre-existing). 신규 회귀 있으면 W8a 중단, 해당 wave 로 돌아가서 fix.

### W8a.4: Codex external review prep

- [ ] Prepare review prompt:
  - Spec path: `docs/superpowers/specs/2026-05-16-area-1-id-outlook-reference-policy-sot-v1-design.md`
  - Plan path: `docs/superpowers/plans/2026-05-16-area-1-id-outlook-reference-policy-sot-v1.md`
  - Implementation commit range: W1 ~ W8a commits
  - Expected verdict: APPROVED. If NEEDS_REVISION → fix-up commit per round.

### W8a.5: Commit W8a (closure)

```bash
git add backend/tests/fixtures/_archive/
git commit -m "$(cat <<'EOF'
feat(area-1-id-outlook-reference-policy): W8a — closure (archival + gates verified)

Archival: backend/tests/fixtures/g4_6/{s1_shot5,s2_shot4}_scene_detail.json
  → backend/tests/fixtures/_archive/g4_6_2026-05-16-area-1/
  (framing_scale closure 패턴 정합).

Residue gates: E1 / E1b / E2 / E3 / D4 / F1 / F2 / F3 모두 PASS (0 hits).

Regression baseline: pre-existing 5 fails 외 신규 fail 0.
- test_evidence_consumer_wiring 1
- test_analysis_dispatch_service 1
- test_text_cleanup 3

Codex external review prep — APPROVED gate 대기.

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

---

## W8b: doc-only fix-up — Track B roadmap §5.1 wording correction

별도 commit (구현 closure 와 분리; "Area #1 closed" 갱신 ↔ "model wording 정정" 섞이지 않게).

**Files:**
- Modify: `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` §5.1

### W8b.1: Edit Track B roadmap §5.1 wording

- [ ] In `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` find §5.1 section. Locate wording about shot_staging producer model:

  - **Find**: `shot_staging Producer (gemini-pro 기존 동일, model 변경 X)` or similar.
  - **Replace with**: `shot_staging Producer (default_model = "gpt", provider: openai; step_manifest.py:591 정합; CLAUDE.md "scene_director 만 gemini-pro 강제" 정합)`

- [ ] Closed Area 재분류 갱신 (Track B roadmap §11 pattern):
  - Find `Area #1 (ID/outlook reference policy SOT) — in progress` or similar
  - Replace with `Area #1 — closed (commit <W8a hash>, session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure.md)`
  
  (Closure memo file will be created externally; reference name is fixed.)

### W8b.2: Commit W8b (doc-only)

```bash
git add docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md
git commit -m "$(cat <<'EOF'
docs(track-b-roadmap): §5.1 wording correction + Area #1 closed reclassification

§5.1 정정 (Area #1 carry, [[session_20260516_area_1_id_outlook_reference_policy_brainstorm]] 명시):
- shot_staging Producer model 정확화 — default_model = "gpt" (provider: openai).
  step_manifest.py:591 + CLAUDE.md "scene_director 만 gemini-pro 강제" 정합.

§11 Closed Area 재분류 — Area #1 (ID/outlook reference policy SOT) closed.

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

---

## Post-implementation: Closure memo (external)

After all wave commits + Codex APPROVED:

- [ ] Write external closure memo at `/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure.md`.
- [ ] Update MEMORY.md index (one-line, < 200 chars).
- [ ] **Repo 밖 — git commit 대상 아님.**

After push approval:
- [ ] `git push origin main`

---

## Wave Summary Table

| Wave | Commits | Files (Create/Modify) | Tests | Gate |
|---|---|---|---|---|
| W1 | 1 | Create 2 (helper + unit test) | helper unit tests PASS | residue 0 |
| W2 | 1 | Create 2 (v12 prompt+schema) + Modify 3 (manifest/registry/test) | schema test + version test PASS | sync gate |
| W3 | 1 | Modify render_prompt_card.py + all callers | direct caller tests PASS | E1 residue 0 |
| W4 | 1 | Modify visible_entities_validator.py + test | per-subject + structured exemption tests PASS | E1 residue 0 |
| W5 | 1 | Create v25 pack (2 stem) + Modify 6 files | alignment tests PASS | F1 5-way + E2 |
| W6 | 1 | Create v19 pack (5 stem) + Modify test | loader test PASS | F3 latest pick |
| W7 | 1 | Modify D1 11 + D4 4+ + Create e2e | baseline matched (pre-existing 5 fails 외 new fails 0) + e2e PASS | D4 + E1/E1b/E2/E3 residue 0 |
| W8a | 1 | Archival move | regression baseline | all residue gates final |
| W8b | 1 | Modify roadmap doc | — | doc-only fix-up |

Total commits: 9 atomic. Push: 1.

---

**End of plan.**
