# Area B-min — render_contracts[] 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:** shot-level `render_contracts[]` 구조 도입 + 첫 consumer 로 `visual_identity / preserve / use_entity_reference` requirement 를 `required_refs` 에 연결. 기존 `story_critical_prop_filter` noun-list / regex 하드코딩 전부 폐기.

**Architecture:** 7 component sequential (producer-first). entity-level SOT (`metadata_json.visual_identity.reference_required: bool`) + shot-level SOT (`render_contracts[]`) 둘 다 LLM-produced structured contract. 3 boundary fail-fast (production / persistence-consumption / contract). card hash 는 envelope 전체 minus `_card_metadata` / `render_prompt_card_hash` 방식이라 render_contracts 자동 포함 (deny-list 에서 제외되지 않기 때문).

**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy / jsonschema / pytest / OpenAI structured outputs (strict mode) / Gemini Pro (entity_t2i).

**Spec:** `docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md`

**Umbrella:** `docs/superpowers/specs/2026-05-12-llm-structured-sot-migration-design.md` v1.2 §4.1 row 3.

**Archive (superseded bool-only spec):** `archive/area-b-old-bool-spec` (1d99026).

---

## File Structure (사전 매핑)

| 파일 | 변경 형식 | 책임 |
|---|---|---|
| `backend/app/modules/pipeline/entity_extractor_v3.py:84-160` | Modify | ENTITY_DETAIL_SCHEMA — metadata_json 에 `visual_identity` nullable key. required=[location, visual_identity]. |
| `prompts/_base/entity_extractor_v2/9.<UTC_TS>/` | Create dir (v8 전체 copy) + Modify 2 files | `system.md` (visual_identity 의미 정의) + `turn_entity_detail.md` (entity_type 별 metadata_json 가이드). |
| `backend/app/core/version_registry.py:7,44` | Modify | MODULE_VERSIONS["entity_extractor"] 1.2.0 → 1.3.0 + prompt_dependency `entity_extraction/v7` → `entity_extractor_v2/v9`. |
| `backend/app/core/entity_metadata.py` | Create | helper module — `validate_entity_metadata_shape` + `get_visual_identity_reference_required`. |
| `backend/app/core/steps/entity_steps.py:775-825` | Modify | `_gen_t2i` attempt loop — location + prop 둘 다 helper post-validate + 3회 실패 None 반환. |
| `backend/app/services/checkpoint_sync/entity_sync_service.py:142-190` | Modify | all entity_type normalized shape DB write + helper validate. |
| `backend/app/core/steps/detail_steps.py:491-510` + `:2766-2772` | Modify | visible_entity_details 에 metadata_json dict 포함 + narrow build path stale 정리. |
| `backend/app/core/steps/render_prompt_card.py` | Add functions + Modify + Delete | build_render_contracts / validate_render_contracts / required_refs_from_render_contracts 신설 + build_asset_requirements 시그니처 변경 + canonicalize sort 추가 + story_critical_prop_filter 계열 7 식별자 폐기. |
| `backend/tests/core/test_d6_entity_metadata_json.py` | Modify (extend) | 7 test 축 중 축 1~4 (D6 contract 갱신 맥락). |
| `backend/tests/unit/test_render_prompt_card.py` | Modify (extend + replace) | 7 test 축 중 축 5~7 (render_contracts + integration + deletion gate). |

**Naming consistency lock**:
- helper module: `entity_metadata`
- helper functions: `validate_entity_metadata_shape` / `get_visual_identity_reference_required`
- contract functions: `build_render_contracts` / `validate_render_contracts` / `required_refs_from_render_contracts`
- error codes:
  - `entity_metadata.shape_violation` (Boundary 1 + 2)
  - `render_prompt_card.render_contracts_malformed` (Boundary 3)
- prompt pack stem: `entity_extractor_v2` (실제 사용 stem, version_registry stale entry `entity_extraction/v7` 갱신)

---

## Task 1: Schema + Prompt Pack v9 + Version Registry (C1)

**Files:**
- Modify: `backend/app/modules/pipeline/entity_extractor_v3.py:84-160`
- Create dir: `prompts/_base/entity_extractor_v2/9.<UTC_TS>/` (v8 전체 copy + 2 file 수정)
- Modify: `backend/app/core/version_registry.py:7,44`
- Test: `backend/tests/core/test_d6_entity_metadata_json.py` (extend)

### Step 1: Failing test — ENTITY_DETAIL_SCHEMA 의 metadata_json visual_identity shape

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

```python
def test_entity_detail_schema_metadata_json_has_visual_identity_key():
    """Area B: metadata_json schema 가 location + visual_identity 둘 다 nullable key 로 포함."""
    from app.modules.pipeline.entity_extractor_v3 import ENTITY_DETAIL_SCHEMA
    meta = ENTITY_DETAIL_SCHEMA["properties"]["metadata_json"]
    assert meta["required"] == ["location", "visual_identity"]
    assert meta["additionalProperties"] is False
    assert "visual_identity" in meta["properties"]
    vi_schema = meta["properties"]["visual_identity"]
    # anyOf null pattern (strict mode 호환)
    assert vi_schema["anyOf"][0] == {"type": "null"}
    # object branch
    obj_branch = vi_schema["anyOf"][1]
    assert obj_branch["type"] == "object"
    assert obj_branch["required"] == ["reference_required"]
    assert obj_branch["additionalProperties"] is False
    assert obj_branch["properties"]["reference_required"] == {"type": "boolean"}
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::test_entity_detail_schema_metadata_json_has_visual_identity_key -v`

Expected: FAIL — `"visual_identity" in meta["properties"]` AssertionError.

### Step 3: Implementation — ENTITY_DETAIL_SCHEMA 에 visual_identity 추가

Modify `backend/app/modules/pipeline/entity_extractor_v3.py:111-147`. Replace metadata_json schema:

```python
        # D6 T2 + Area B (2026-05-13): metadata_json 의 closed shape.
        # entity_type 별 instance:
        #   - character: {"location": null, "visual_identity": null}
        #   - location single_space: {"location": {"space_profile":
        #       {"kind": "single_space", "allowed_space_keys": ["main"],
        #        "default_space_key": null}}, "visual_identity": null}
        #   - location multi_space: {"location": {"space_profile": {...}},
        #                            "visual_identity": null}
        #   - prop: {"location": null,
        #            "visual_identity": {"reference_required": true|false}}
        # OpenAI structured outputs strict mode — additionalProperties=False +
        # 모든 nested object key required 의무.
        "metadata_json": {
            "type": "object",
            "properties": {
                "location": {
                    "anyOf": [
                        {"type": "null"},
                        {
                            "type": "object",
                            "properties": {
                                "space_profile": {
                                    "type": "object",
                                    "properties": {
                                        "kind": {
                                            "type": "string",
                                            "enum": ["single_space", "multi_space"],
                                        },
                                        "allowed_space_keys": {
                                            "type": "array",
                                            "items": {"type": "string"},
                                        },
                                        "default_space_key": {
                                            "type": ["string", "null"],
                                        },
                                    },
                                    "required": ["kind", "allowed_space_keys", "default_space_key"],
                                    "additionalProperties": False,
                                },
                            },
                            "required": ["space_profile"],
                            "additionalProperties": False,
                        },
                    ],
                },
                "visual_identity": {
                    "anyOf": [
                        {"type": "null"},
                        {
                            "type": "object",
                            "properties": {
                                "reference_required": {"type": "boolean"},
                            },
                            "required": ["reference_required"],
                            "additionalProperties": False,
                        },
                    ],
                },
            },
            "required": ["location", "visual_identity"],
            "additionalProperties": False,
        },
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::test_entity_detail_schema_metadata_json_has_visual_identity_key -v`

Expected: PASS.

### Step 5: 기존 D6 schema test 갱신 — visual_identity null key 도 required

Open `backend/tests/core/test_d6_entity_metadata_json.py` 기존 `test_entity_detail_schema_includes_metadata_json_required` (line 36 부근). Replace with:

```python
def test_entity_detail_schema_includes_metadata_json_required():
    """metadata_json required + location/visual_identity 둘 다 key 의무 (Area B)."""
    from app.modules.pipeline.entity_extractor_v3 import ENTITY_DETAIL_SCHEMA
    assert "metadata_json" in ENTITY_DETAIL_SCHEMA["required"]
    meta = ENTITY_DETAIL_SCHEMA["properties"]["metadata_json"]
    assert meta["required"] == ["location", "visual_identity"]
    # null branch 양쪽 다 strict mode 호환
    loc_null = meta["properties"]["location"]["anyOf"][0]
    vi_null = meta["properties"]["visual_identity"]["anyOf"][0]
    assert loc_null == {"type": "null"}
    assert vi_null == {"type": "null"}
```

- [ ] **Step 6: Run all D6 schema tests**

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "schema" -v`

Expected: ALL PASS (기존 + 신규).

### Step 7: Failing test — prompt pack v9 active

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

```python
def test_entity_extractor_v2_v9_pack_active_for_area_b():
    """Area B: v9 prompt pack 생성 + prompt_loader 가 v9 선택 + 의미 문구 + closed-list 금지 가이드."""
    from app.modules.prompt_loader import load_prompt
    system_text = load_prompt("entity_extractor_v2", "system")
    detail_text = load_prompt("entity_extractor_v2", "turn_entity_detail")

    # Area B 의미 문구 — 양쪽 파일 모두 reference_required 명시 (drift 차단)
    assert "reference_required" in system_text, (
        "system.md must define reference_required (Area B SOT field)"
    )
    assert "reference_required" in detail_text, (
        "turn_entity_detail.md must guide reference_required per entity_type"
    )

    # closed-world principle — turn_entity_detail.md 의 Area B 가이드 영역에 closed-list 금지 명시
    assert "fixed object category list" in detail_text, (
        "turn_entity_detail.md must explicitly forbid 'fixed object category list' "
        "inference (Area B closed-world principle)"
    )


def test_entity_extractor_v2_v9_pack_stem_completeness():
    """Area B: v9 pack 의 stem file 수가 v8 과 동일 (pack drift 차단).

    v8 전체 copy 방식이므로 v9 dir 의 file list 가 v8 dir 와 동일해야 함.
    누락 stem 발견 시 다른 stem (turn0_style / turn1~4 등) 의 prompt 가 v8
    버전으로 leak — prompt_loader 가 stem 별 최신 dir 선택하므로 v9 누락 시
    v8 fallback. 의도된 v9 변경 외 다른 stem 도 v9 dir 에 동일 copy 의무.
    """
    import os
    from pathlib import Path

    base = Path(__file__).resolve().parents[2] / "prompts" / "_base" / "entity_extractor_v2"
    v8_dirs = sorted([d for d in base.iterdir() if d.is_dir() and d.name.startswith("8.")])
    v9_dirs = sorted([d for d in base.iterdir() if d.is_dir() and d.name.startswith("9.")])
    assert v8_dirs, "v8 prompt pack must exist"
    assert v9_dirs, "v9 prompt pack must exist (Area B Task 1 Step 9)"

    latest_v8 = v8_dirs[-1]
    latest_v9 = v9_dirs[-1]

    v8_files = sorted(p.name for p in latest_v8.iterdir() if p.is_file())
    v9_files = sorted(p.name for p in latest_v9.iterdir() if p.is_file())
    assert v8_files == v9_files, (
        f"v9 pack stem files must match v8 (pack drift). "
        f"missing in v9: {set(v8_files) - set(v9_files)}, "
        f"extra in v9: {set(v9_files) - set(v8_files)}"
    )
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::test_entity_extractor_v2_v9_pack_active_for_area_b -v`

Expected: FAIL — v9 pack 미존재.

### Step 9: Create v9 prompt pack — v8 전체 copy

UTC timestamp = `date -u +%Y%m%d%H%M` (e.g., `202605131200`).

```bash
TS=$(date -u +%Y%m%d%H%M)
cp -r prompts/_base/entity_extractor_v2/8.202605121200 prompts/_base/entity_extractor_v2/9.${TS}
ls prompts/_base/entity_extractor_v2/9.${TS}/
```

Expected files: `system.md, turn_entity_detail.md, turn0_style.md, turn0_style_schema.json, turn1.md, turn1_7_detail_batch.md, turn1_7_detail_batch_schema.json, turn1_review_schema.json, turn2.md, turn3.md, turn4.md` (pack drift 차단).

### Step 10: Modify v9 `system.md` — Area B metadata_json schema description 추가

Append to `prompts/_base/entity_extractor_v2/9.<TS>/system.md`:

```markdown

## metadata_json schema (Area B)

`metadata_json` 은 closed shape `{"location": ..., "visual_identity": ...}` 출력 의무.

- `location` (entity_type==location only): nested `space_profile` 객체. 그 외 entity_type 은 null.
- `visual_identity` (entity_type==prop only): `{"reference_required": <boolean>}`. 그 외 entity_type 은 null.

### visual_identity.reference_required (prop entity only)

True when this prop has a visually unique identity that must remain stable across shots, so that text description alone is likely to lose important shape, markings, written/printed content, layout, or distinctive design.

False when the prop is generic, replaceable, or visually interchangeable, and a normal text description is enough for continuity.

**Do not infer from a fixed object category list.** Judge the specific prop described in this project.
```

### Step 11: Modify v9 `turn_entity_detail.md` — entity_type 별 metadata_json 가이드 갱신

Replace the existing D6 metadata_json block in `prompts/_base/entity_extractor_v2/9.<TS>/turn_entity_detail.md` (line 20-24 부근, `D6 (모든 entity 필수 — strict schema):` 시작 영역):

```markdown
D6 + Area B (모든 entity 필수 — strict schema):
- **location 만**: `metadata_json.location.space_profile` nested object — system prompt 의 controlled vocab 따라. `metadata_json.visual_identity` = null.
  - single_space → `{{"location": {{"space_profile": {{"kind": "single_space", "allowed_space_keys": ["main"], "default_space_key": null}}}}, "visual_identity": null}}`.
  - multi_space → `{{"location": {{"space_profile": {{"kind": "multi_space", "allowed_space_keys": [...], "default_space_key": "main"}}}}, "visual_identity": null}}`.
- **prop 만**: `metadata_json.visual_identity.reference_required` (boolean). `metadata_json.location` = null. system prompt 의 의미 정의 따라 — 이 specific prop 이 시각적으로 유니크한 정체성을 가져 reference image 가 필요한지 판단. **fixed object category list 추론 금지**.
  - 예: `{{"location": null, "visual_identity": {{"reference_required": true}}}}` 또는 `{{"location": null, "visual_identity": {{"reference_required": false}}}}`.
- **character / outlook**: 항상 `{{"location": null, "visual_identity": null}}` 출력.
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::test_entity_extractor_v2_v9_pack_active_for_area_b -v`

Expected: PASS.

### Step 13: Failing test — version_registry bump + prompt_dependency 갱신

```python
def test_version_registry_entity_extractor_bumped_for_area_b():
    """Area B: MODULE_VERSIONS bump + prompt_dependency 갱신."""
    from app.core.version_registry import MODULE_VERSIONS, _MODULE_INFO
    assert MODULE_VERSIONS["entity_extractor"] == "1.3.0"
    info = _MODULE_INFO["entity_extractor"]
    assert info["prompt_dependency"] == "entity_extractor_v2/v9"
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::test_version_registry_entity_extractor_bumped_for_area_b -v`

Expected: FAIL — version still 1.2.0, prompt_dependency still `entity_extraction/v7`.

### Step 15: Implementation — version_registry 갱신

In `backend/app/core/version_registry.py:7`:
```python
    "entity_extractor": "1.3.0",             # 2026-05-13 — Area B metadata_json.visual_identity SOT + render_contracts[] introduction
```

In `backend/app/core/version_registry.py:44-45`:
```python
    "entity_extractor": {
        "prompt_dependency": "entity_extractor_v2/v9",
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "version_registry_entity_extractor_bumped" -v`

Expected: PASS.

### Step 17: Commit

```bash
git add backend/app/modules/pipeline/entity_extractor_v3.py \
        backend/app/core/version_registry.py \
        prompts/_base/entity_extractor_v2/9.* \
        backend/tests/core/test_d6_entity_metadata_json.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 1 — schema + v9 prompt pack + registry bump (C1)

- ENTITY_DETAIL_SCHEMA: metadata_json 에 visual_identity nullable key 추가. required=[location, visual_identity], strict additionalProperties=false.
- prompts/_base/entity_extractor_v2/9.<TS>: v8 전체 copy + system.md (visual_identity.reference_required 의미 정의) + turn_entity_detail.md (entity_type 별 metadata_json 가이드, fixed object category list 추론 금지).
- version_registry: entity_extractor 1.2.0 → 1.3.0, prompt_dependency entity_extraction/v7 → entity_extractor_v2/v9.

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

---

## Task 2: entity_metadata helper module (C2)

**Files:**
- Create: `backend/app/core/entity_metadata.py`
- Test: `backend/tests/core/test_d6_entity_metadata_json.py` (extend)

### Step 1: Failing test — helper validate / get 분기 (test 축 1)

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

```python
class TestEntityMetadataHelper:
    """Area B test 축 1 — metadata schema/helper."""

    def test_validate_prop_valid_true(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "prop",
            {"location": None, "visual_identity": {"reference_required": True}},
            short_id="P01",
        )

    def test_validate_prop_valid_false(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        validate_entity_metadata_shape(
            "prop",
            {"location": None, "visual_identity": {"reference_required": False}},
            short_id="P02",
        )

    def test_validate_prop_missing_bool_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            validate_entity_metadata_shape(
                "prop",
                {"location": None, "visual_identity": {}},
                short_id="P03",
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_validate_prop_non_bool_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            validate_entity_metadata_shape(
                "prop",
                {"location": None, "visual_identity": {"reference_required": "yes"}},
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_validate_prop_with_non_null_location_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "prop",
                {"location": {"space_profile": {"kind": "single_space",
                                                "allowed_space_keys": ["main"],
                                                "default_space_key": None}},
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_character_with_non_null_visual_identity_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "character",
                {"location": None,
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_location_with_non_null_visual_identity_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "location",
                {"location": {"space_profile": {"kind": "single_space",
                                                "allowed_space_keys": ["main"],
                                                "default_space_key": None}},
                 "visual_identity": {"reference_required": True}},
            )

    def test_validate_unknown_entity_type_raises(self):
        from app.core.entity_metadata import validate_entity_metadata_shape
        from app.core.errors import AppError
        with pytest.raises(AppError):
            validate_entity_metadata_shape(
                "outlook",
                {"location": None, "visual_identity": None},
            )

    def test_get_visual_identity_reference_required_true(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        result = get_visual_identity_reference_required(
            {"location": None, "visual_identity": {"reference_required": True}},
            short_id="P01",
        )
        assert result is True

    def test_get_visual_identity_reference_required_false(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        result = get_visual_identity_reference_required(
            {"location": None, "visual_identity": {"reference_required": False}},
        )
        assert result is False

    def test_get_visual_identity_missing_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required({}, short_id="P01")
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_get_visual_identity_none_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required(
                {"location": None, "visual_identity": None},
                short_id="P02",
            )
        assert exc.value.code == "entity_metadata.shape_violation"

    def test_get_visual_identity_reference_non_bool_raises(self):
        from app.core.entity_metadata import get_visual_identity_reference_required
        from app.core.errors import AppError
        with pytest.raises(AppError) as exc:
            get_visual_identity_reference_required(
                {"location": None, "visual_identity": {"reference_required": 1}},
                short_id="P03",
            )
        assert exc.value.code == "entity_metadata.shape_violation"
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::TestEntityMetadataHelper -v`

Expected: ALL FAIL — `ModuleNotFoundError: No module named 'app.core.entity_metadata'`.

### Step 3: Implementation — entity_metadata.py

Create `backend/app/core/entity_metadata.py`:

> **NOTE (post-review patch, 2026-05-13)**: 아래 inline code 는 plan 초안 영역. 실제 implementation (commit `8383c20`) 에서 다음 영역이 review 흡수로 갱신됨 — plan body 와 implementation drift 시 implementation 이 source of truth.
> - `_ALLOWED_ENTITY_TYPES` 는 `tuple` → `frozenset({"character", "location", "prop"})` (M1 fix, O(1) membership + clarity).
> - `entity_type not in _ALLOWED_ENTITY_TYPES` 의 error message 의 `Allowed: {_ALLOWED_ENTITY_TYPES}` → `Allowed: {sorted(_ALLOWED_ENTITY_TYPES)}` (deterministic 로그).
> - character branch 의 합산 raise (`if loc is not None or vi is not None:`) → loc 와 vi 별도 raise 2개 (I2 fix, 호출자가 어느 키 위반인지 정확히 파악).
> - location 분기의 `validate_location_space_profile` 호출 인자 = outer `metadata_json` (line 633 fix 참조).
>
> 실제 코드: `backend/app/core/entity_metadata.py`.

```python
"""Area B — entity_canon.metadata_json shape helper (전 entity_type closed shape).

shape: {"location": <D6 obj | null>, "visual_identity": <Area B obj | null>}
- prop: location=None, visual_identity={"reference_required": bool}
- location: location={<space_profile>}, visual_identity=None
- character: location=None, visual_identity=None

sync (DB write 전) + consumer (render_contracts producer) 양쪽 공용 — drift 차단.

Spec: docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md
"""

from __future__ import annotations
from typing import Any

from app.core.errors import AppError

_ALLOWED_ENTITY_TYPES = ("character", "location", "prop")

_FORCE_RE_RUN_NOTE = (
    " Rerun entity_extract / entity_t2i for this episode with Area B schema "
    "(force re-run)."
)


def validate_entity_metadata_shape(
    entity_type: str,
    metadata_json: Any,
    short_id: str = "",
) -> None:
    """non-conforming shape 시 AppError raise.

    spec §3.1 contract:
    - metadata_json is dict; keys == {"location", "visual_identity"}; both present.
    - prop:      location is None; visual_identity is dict; reference_required is bool.
    - location:  location is dict; visual_identity is None.
    - character: location is None; visual_identity is None.
    - 그 외 entity_type (outlook 등) → raise (별도 path).
    """
    sid_hint = f" (short_id={short_id!r})" if short_id else ""

    if entity_type not in _ALLOWED_ENTITY_TYPES:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"validate_entity_metadata_shape: unsupported entity_type "
                f"{entity_type!r}{sid_hint}. Allowed: {_ALLOWED_ENTITY_TYPES}. "
                "outlook 등 다른 entity_type 은 별도 path 라 본 helper 도달 자체가 invariant 위반."
            ),
        )

    if not isinstance(metadata_json, dict):
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"validate_entity_metadata_shape: metadata_json must be dict, "
                f"got {type(metadata_json).__name__}{sid_hint}."
            ),
        )

    keys = set(metadata_json.keys())
    if keys != {"location", "visual_identity"}:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"validate_entity_metadata_shape: metadata_json keys must be "
                f"exactly {{'location', 'visual_identity'}}, got {sorted(keys)}"
                f"{sid_hint}."
            ),
        )

    loc = metadata_json["location"]
    vi = metadata_json["visual_identity"]

    if entity_type == "prop":
        if loc is not None:
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: prop entity must have "
                    f"location=None, got {type(loc).__name__}{sid_hint}."
                ),
            )
        if not isinstance(vi, dict):
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: prop entity must have "
                    f"visual_identity as dict, got {type(vi).__name__}{sid_hint}."
                ),
            )
        if "reference_required" not in vi:
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: prop visual_identity missing "
                    f"reference_required field{sid_hint}."
                ),
            )
        rbr = vi["reference_required"]
        # bool subclass check — int (1/0) 거부 (silent disarm 차단, Area C carry).
        if type(rbr) is not bool:
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: prop reference_required "
                    f"must be bool, got {type(rbr).__name__}{sid_hint}."
                ),
            )

    elif entity_type == "location":
        if not isinstance(loc, dict):
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: location entity must have "
                    f"location as dict, got {type(loc).__name__}{sid_hint}."
                ),
            )
        if vi is not None:
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: location entity must have "
                    f"visual_identity=None, got {type(vi).__name__}{sid_hint}."
                ),
            )
        # Area B (review fix): space_profile 내부 검증 통합 — D6 helper 호출.
        # 사용자 review 권고: EntitySyncService 가 location 의 top-level 만 보고
        # invalid space_profile 을 DB write 하지 않도록 single point of validation.
        # D6 helper 의 SpaceProfileError 는 그대로 propagate (D6 error code 보존).
        # NOTE: validate_location_space_profile signature 는 outer metadata_json 받음
        # (bg_state_vocab.py line 145). 내부에서 metadata_json.get("location").get("space_profile")
        # 로 lookup. inner `loc` 만 전달 시 .get("location") 가 None 반환 → SpaceProfileError.
        from app.core.bg_state_vocab import validate_location_space_profile
        validate_location_space_profile(metadata_json, short_id=short_id)

    elif entity_type == "character":
        if loc is not None or vi is not None:
            raise AppError(
                code="entity_metadata.shape_violation",
                message=(
                    f"validate_entity_metadata_shape: character entity must have "
                    f"location=None and visual_identity=None, got "
                    f"location={type(loc).__name__}, visual_identity={type(vi).__name__}"
                    f"{sid_hint}."
                ),
            )


def get_visual_identity_reference_required(
    metadata_json: Any,
    short_id: str = "",
) -> bool:
    """consumer 진입점 — prop entity 의 SOT bool 추출 + fail-fast.

    stale data / invalid shape 시 friendly raise + force re-run 안내.
    visible_entity_details 의 prop entry 에 대해 호출.
    """
    sid_hint = f" (short_id={short_id!r})" if short_id else ""

    if not isinstance(metadata_json, dict) or "visual_identity" not in metadata_json:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"Prop entity metadata_json missing or non-dict{sid_hint}." + _FORCE_RE_RUN_NOTE
            ),
        )

    vi = metadata_json["visual_identity"]
    if vi is None:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"Prop entity metadata_json.visual_identity is None{sid_hint}." + _FORCE_RE_RUN_NOTE
            ),
        )

    if not isinstance(vi, dict) or "reference_required" not in vi:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"Prop metadata_json.visual_identity missing reference_required field"
                f"{sid_hint}." + _FORCE_RE_RUN_NOTE
            ),
        )

    rbr = vi["reference_required"]
    if type(rbr) is not bool:
        raise AppError(
            code="entity_metadata.shape_violation",
            message=(
                f"Prop metadata_json.visual_identity.reference_required must be bool, "
                f"got {type(rbr).__name__}{sid_hint}." + _FORCE_RE_RUN_NOTE
            ),
        )

    return rbr
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py::TestEntityMetadataHelper -v`

Expected: ALL PASS (13 tests).

### Step 5: Commit

```bash
git add backend/app/core/entity_metadata.py \
        backend/tests/core/test_d6_entity_metadata_json.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 2 — entity_metadata helper module (C2)

- backend/app/core/entity_metadata.py 신규: validate_entity_metadata_shape + get_visual_identity_reference_required.
- entity_type 별 closed shape 검증 (prop/location/character) + non-bool fail-fast (bool subclass strict check, Area C carry).
- friendly message 안에 force re-run 안내.
- sync (B2 site) + consumer (B3 site) 양쪽 공용 helper — drift 차단.

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

---

## Task 3: entity_t2i post-validation 확장 (C3)

**Files:**
- Modify: `backend/app/core/steps/entity_steps.py:775-825`
- Test: `backend/tests/core/test_d6_entity_metadata_json.py` (extend)

### Step 1: Failing test — entity_t2i prop post-validation (test 축 4)

Add to `backend/tests/core/test_d6_entity_metadata_json.py` (기존 location post-validation test pattern 인용 — 기존 `test_entity_t2i_location_post_validation_rejects_missing_metadata` line 653 부근):

```python
def test_entity_t2i_prop_post_validation_rejects_missing_reference_required(monkeypatch):
    """Area B: entity_t2i 가 prop 의 visual_identity.reference_required missing 시 retry → None 반환."""
    from app.core.steps import entity_steps

    invalid_response = {
        "name": "Mock Prop",
        "entity_type": "prop",
        "description": "x",
        "visual_traits": [],
        "t2i_prompt": "y",
        "metadata_json": {"location": None, "visual_identity": {}},  # missing
    }

    def mock_call_structured(**kwargs):
        return invalid_response

    monkeypatch.setattr(
        "app.core.steps.entity_steps.call_structured", mock_call_structured
    )

    # _gen_t2i 의 attempt loop 직접 호출 패턴 — 기존 location test 와 동일 구조.
    # 3 attempt 실패 → return (idx, name, "prop", None) 검증.
    # (구체 fixture 는 기존 test_entity_t2i_location_post_validation_rejects_missing_metadata
    #  의 setup 인용 — `_gen_t2i` 내부 호출 또는 dispatch 함수 호출.)

    result = _invoke_gen_t2i_for_prop_entity(
        ename="Mock Prop", monkeypatch=monkeypatch,
    )
    assert result == (0, "Mock Prop", "prop", None)


def test_entity_t2i_prop_post_validation_rejects_non_bool(monkeypatch):
    """Area B: prop visual_identity.reference_required non-bool 시 retry → None."""
    from app.core.steps import entity_steps

    invalid_response = {
        "name": "Mock Prop",
        "entity_type": "prop",
        "description": "x",
        "visual_traits": [],
        "t2i_prompt": "y",
        "metadata_json": {
            "location": None,
            "visual_identity": {"reference_required": "yes"},  # non-bool
        },
    }

    def mock_call_structured(**kwargs):
        return invalid_response

    monkeypatch.setattr(
        "app.core.steps.entity_steps.call_structured", mock_call_structured
    )

    result = _invoke_gen_t2i_for_prop_entity(
        ename="Mock Prop", monkeypatch=monkeypatch,
    )
    assert result == (0, "Mock Prop", "prop", None)
```

`_invoke_gen_t2i_for_prop_entity` helper — 기존 location test 의 `_invoke_gen_t2i_for_location_entity` 함수 (test_d6_entity_metadata_json.py 안의 사적 fixture) 와 동일 패턴 — prop entity 로 적용. (test_d6_entity_metadata_json.py 의 line 653 location test 의 invoke pattern 그대로 copy 후 entity_type=prop 로 변경.)

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "entity_t2i_prop_post_validation" -v`

Expected: FAIL — current entity_t2i 가 location 만 post-validate, prop 은 invalid 도 done 에 들어감.

### Step 3: Implementation — entity_t2i._gen_t2i 의 location+prop 통합 post-validate

Modify `backend/app/core/steps/entity_steps.py:781-796` (location 분기 영역). Replace with:

```python
                    # Area B + D6 carry: location + prop 양쪽 metadata SOT post-validation.
                    # 3 attempts 모두 fail 시 done 에 안 넣음 → failed_count 증가.
                    # character 는 semantic post-validation 부재 (기존 빈-marker 패턴).
                    if etype in ("location", "prop"):
                        from app.core.entity_metadata import (
                            validate_entity_metadata_shape,
                        )
                        from app.core.errors import AppError
                        try:
                            validate_entity_metadata_shape(
                                etype, md, short_id=name_to_sid.get(ename, ""),
                            )
                        except AppError as exc:
                            logger.warning(
                                "entity_t2i %s post-validate failed (attempt %d/3) "
                                "for %r: %s — retry",
                                etype, attempt + 1, ename, exc.message,
                            )
                            raise

                    # Area B (review fix): location 의 D6 space_profile 내부 검증은
                    # validate_entity_metadata_shape 가 helper 내부에서 통합 호출
                    # (Task 2 Step 3). 본 site 별도 호출 불필요 — single point of
                    # validation. SpaceProfileError 는 raise 시 AppError 와 동일하게
                    # attempt loop 의 except 가 catch → retry.
```

Note: 기존 `validate_location_space_profile(md, ...)` direct 호출이 entity_steps 의 다른 site 에 있었다면, Area B 이후엔 `validate_entity_metadata_shape("location", md, ...)` 1 호출로 흡수. 별도 site 가 있는지 grep 확인 + 갱신.

또한 `backend/app/core/steps/entity_steps.py:824` (failure branch) — 기존 `if etype == "location":` 를 다음으로 변경:

```python
            # Area B + D6 carry: location + prop entity 는 failure 시 fail-fast.
            # SOT 손실 silent absorb 안 함. data=None 으로 caller 가 done 제외.
            # character 는 기존 빈-marker pattern 유지.
            if etype in ("location", "prop"):
                return (idx, ename, etype, None)
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "entity_t2i_prop_post_validation or entity_t2i_location_post_validation" -v`

Expected: ALL PASS.

### Step 5: Commit

```bash
git add backend/app/core/steps/entity_steps.py \
        backend/tests/core/test_d6_entity_metadata_json.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 3 — entity_t2i post-validation extends to prop (C3)

- _gen_t2i attempt loop: location + prop 양쪽 helper validate. 3회 실패 시 None 반환 → done 제외 → failed_count++.
- character 는 기존 빈-marker 패턴 유지 (semantic post-validation 부재).
- location 의 D6 space_profile 내부 detail 검증은 normalized shape 정합으로 md.get("location") 경로로 호출.

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

---

## Task 4: EntitySyncService normalized metadata write (C4)

**Files:**
- Modify: `backend/app/services/checkpoint_sync/entity_sync_service.py:142-190`
- Test: `backend/tests/core/test_d6_entity_metadata_json.py` (extend + replace 기존 character-skip test)

### Step 1: Failing test — all entity_type normalized shape DB write (test 축 2)

Add (또는 기존 `test_entity_sync_service_skips_metadata_json_for_character` line 366 부근 갱신):

```python
def test_entity_sync_service_writes_normalized_metadata_for_all_entity_types(project_seeded):
    """Area B: character/location/prop 모두 normalized {'location':..., 'visual_identity':...} 저장."""
    pid, eid, db = project_seeded
    cp_payload = {
        "schema_version": 3,
        "characters": [{
            "name": "Alice",
            "entity_type": "character",
            "description": "주인공",
            "visual_traits": ["20대"],
            "t2i_prompt": "Photorealistic ID photo of a woman ...",
            "metadata_json": {"location": None, "visual_identity": None},
        }],
        "locations": [],
        "props": [{
            "name": "오래된 사진",
            "entity_type": "prop",
            "description": "어린 시절 가족 사진",
            "visual_traits": ["빛바랜 흑백"],
            "t2i_prompt": "Product photo of a faded family photograph ...",
            "metadata_json": {
                "location": None,
                "visual_identity": {"reference_required": True},
            },
        }],
    }
    _write_entity_t2i_cp(Path(get_project_dir(pid)) / "episodes" / eid, pid, eid, cp_payload)

    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    svc = EntitySyncService(db=db, project_id=pid, episode_id=eid, now="2026-05-13T00:00:00Z")
    svc.sync()

    from app.models.project import EntityCanon
    char_row = db.query(EntityCanon).filter_by(name="Alice").one()
    prop_row = db.query(EntityCanon).filter_by(name="오래된 사진").one()

    char_md = json.loads(char_row.metadata_json)
    assert char_md == {"location": None, "visual_identity": None}

    prop_md = json.loads(prop_row.metadata_json)
    assert prop_md == {
        "location": None,
        "visual_identity": {"reference_required": True},
    }


def test_entity_sync_service_rejects_invalid_prop_metadata(project_seeded):
    """Area B: invalid prop metadata 는 sync 에서 fail-fast."""
    pid, eid, db = project_seeded
    cp_payload = {
        "schema_version": 3,
        "characters": [],
        "locations": [],
        "props": [{
            "name": "BadProp",
            "entity_type": "prop",
            "description": "bad",
            "visual_traits": [],
            "t2i_prompt": "",
            "metadata_json": {"location": None, "visual_identity": {}},  # missing reference_required
        }],
    }
    _write_entity_t2i_cp(Path(get_project_dir(pid)) / "episodes" / eid, pid, eid, cp_payload)

    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    from app.core.errors import AppError
    svc = EntitySyncService(db=db, project_id=pid, episode_id=eid, now="2026-05-13T00:00:00Z")
    with pytest.raises(AppError) as exc:
        svc.sync()
    assert exc.value.code == "entity_metadata.shape_violation"
```

기존 `test_entity_sync_service_skips_metadata_json_for_character` (line 366) — character 가 metadata write 안 함 검증 — 새 normalized shape 계약과 충돌. 다음으로 갱신:

```python
def test_entity_sync_service_writes_normalized_null_metadata_for_character(project_seeded):
    """Area B: character 도 normalized {'location': null, 'visual_identity': null} 저장. 기존 D6 skip 계약 갱신."""
    pid, eid, db = project_seeded
    cp_payload = {
        "schema_version": 3,
        "characters": [{
            "name": "Bob",
            "entity_type": "character",
            "description": "조연",
            "visual_traits": [],
            "t2i_prompt": "",
            "metadata_json": {"location": None, "visual_identity": None},
        }],
        "locations": [],
        "props": [],
    }
    _write_entity_t2i_cp(Path(get_project_dir(pid)) / "episodes" / eid, pid, eid, cp_payload)

    from app.services.checkpoint_sync.entity_sync_service import EntitySyncService
    svc = EntitySyncService(db=db, project_id=pid, episode_id=eid, now="2026-05-13T00:00:00Z")
    svc.sync()

    from app.models.project import EntityCanon
    row = db.query(EntityCanon).filter_by(name="Bob").one()
    md = json.loads(row.metadata_json)
    assert md == {"location": None, "visual_identity": None}
```

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "normalized_metadata or rejects_invalid_prop or normalized_null_metadata_for_character" -v`

Expected: FAIL — current sync skips character/prop metadata + no validate.

### Step 3: Implementation — EntitySyncService normalized write + helper validate

Modify `backend/app/services/checkpoint_sync/entity_sync_service.py:142-190`. Replace the entity_type loop body (특히 line 150-159 의 location-only 분기):

```python
        synced = 0
        for etype in ["characters", "locations", "props"]:
            singular = etype[:-1]
            prefix = _prefix_map[singular]
            for ent in data.get(etype, []):
                name = ent["name"]
                existing = existing_canons.get(name)

                # Area B (2026-05-13): all entity_type normalized {"location":..., "visual_identity":...} 저장.
                # validate_entity_metadata_shape 가 DB write 전 L2 fail-fast.
                from app.core.entity_metadata import validate_entity_metadata_shape

                _md_raw = ent.get("metadata_json")
                if _md_raw is None:
                    # Legacy cp (Area B 이전) — entity_t2i 가 normalized shape 안 만드는 path 보호.
                    # 그러나 strict mode + L1 schema 가 normalized 강제하므로 신 cp 에서는 도달 불가.
                    # 안전상 strict raise.
                    raise AppError(
                        code="entity_metadata.shape_violation",
                        message=(
                            f"EntitySyncService.sync: entity {name!r} "
                            f"({singular}) missing metadata_json in checkpoint. "
                            "Force re-run entity_extract / entity_t2i with Area B schema."
                        ),
                    )

                validate_entity_metadata_shape(
                    singular, _md_raw, short_id=ent.get("short_id", "")
                )
                metadata_json_str = json.dumps(_md_raw, ensure_ascii=False)

                if existing:
                    # UPDATE: 기존 canon 갱신 (short_id, description, t2i_prompt, metadata_json).
                    canon_id = existing.id
                    cp_short = ent.get("short_id", "")
                    if cp_short and cp_short != existing.short_id:
                        existing.short_id = cp_short
                    existing.description = ent.get("description", existing.description)
                    existing.t2i_prompt = ent.get("t2i_prompt", existing.t2i_prompt)
                    existing.stable_traits = json.dumps(ent.get("visual_traits", []), ensure_ascii=False)
                    existing.metadata_json = metadata_json_str  # Area B: 항상 write.
                    existing.updated_at = self.now
                else:
                    # INSERT: 새 canon 생성
                    canon_id = str(uuid.uuid4())
                    short = ent.get("short_id", "")
                    if not short:
                        _counters[prefix] += 1
                        short = f"{prefix}{_counters[prefix]:02d}"
                    insert_kwargs = dict(
                        id=canon_id, project_id=self.project_id, short_id=short,
                        name=name, entity_type=singular,
                        description=ent.get("description", ""),
                        t2i_prompt=ent.get("t2i_prompt", ""),
                        stable_traits=json.dumps(ent.get("visual_traits", []), ensure_ascii=False),
                        metadata_json=metadata_json_str,  # Area B: 항상 write.
                        created_at=self.now, updated_at=self.now,
                    )
                    self.db.add(EntityCanon(**insert_kwargs))
```

`AppError` import — file 상단 import 영역에 `from app.core.errors import AppError` 가 없으면 추가.

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

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -k "normalized_metadata or rejects_invalid_prop or normalized_null_metadata" -v`

Expected: ALL PASS.

### Step 5: Run all D6 entity_sync tests — 회귀 검증

Run: `cd backend && python -m pytest tests/core/test_d6_entity_metadata_json.py -v`

Expected: ALL PASS (또는 기존 character-skip 계약 갱신 정합 영역만 갱신된 테스트로 교체).

### Step 6: Commit

```bash
git add backend/app/services/checkpoint_sync/entity_sync_service.py \
        backend/tests/core/test_d6_entity_metadata_json.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 4 — EntitySyncService all entity_type normalized write (C4)

- sync loop 의 'if singular == "location"' 분기 폐기.
- character/location/prop 모두 normalized {"location":..., "visual_identity":...} shape DB write.
- DB write 전 validate_entity_metadata_shape 호출 (L2 fail-fast).
- 기존 D6 character-skip 계약 → all entity_type normalized shape 계약으로 갱신.

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

---

## Task 5: detail_steps wiring (C5)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:491-510` + `:2766-2772`
- Test: `backend/tests/unit/test_render_prompt_card.py` (또는 detail_steps test 신규 추가)

### Step 1: Failing test — visible_entity_details 에 metadata_json dict 포함 (test 축 3)

Add to `backend/tests/unit/test_render_prompt_card.py`:

```python
def test_visible_entity_details_carries_metadata_json_for_prop():
    """Area B: ctx.entities[props][i].metadata_json (JSON string) →
    visible_entity_details[i].metadata_json (dict)."""
    # detail_steps._build_prompt_card_for_shot 내부의 _patch_a_entity_by_sid build
    # 영역에 대한 unit test. ctx mock 또는 fixture 셋업.
    #
    # 기존 detail_steps test fixture 인용 — _patch_a_entity_by_sid 결과 dict 의
    # 한 prop entry 가 "metadata_json" key 를 가져야 함.

    from app.core.steps.detail_steps import _build_prompt_card_for_shot
    import json

    # 최소 ctx fixture — 실제 production fixture 와 호환.
    # ctx.entities = {"props": [{...metadata_json as JSON string...}], ...}
    ctx = _build_ctx_fixture_with_prop_entity(
        sid="P01",
        name="old photo",
        metadata_json_str=json.dumps({
            "location": None,
            "visual_identity": {"reference_required": True},
        }),
    )
    si, shi = 0, 0  # current scene/shot

    result = _build_prompt_card_for_shot(ctx, si, shi)

    # visible_entity_details[i] 가 metadata_json key 포함
    ved = result.get("visible_entity_details") or result.get("_visible_entity_details") \
          or []  # 실제 dict access 경로 — implementation 영역.
    p01_entry = next((e for e in ved if e.get("short_id") == "P01"), None)
    assert p01_entry is not None
    assert p01_entry.get("metadata_json") == {
        "location": None,
        "visual_identity": {"reference_required": True},
    }
```

`_build_ctx_fixture_with_prop_entity` 는 test helper — 기존 detail_steps test 의 ctx fixture 패턴 인용 (test_render_prompt_card.py 또는 test_detail_steps.py 의 fixture pattern). visible_entities 에 P01 sid 포함 + ctx.entities.props 에 metadata_json JSON string 포함.

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py -k "visible_entity_details_carries_metadata_json" -v`

Expected: FAIL — current build 의 `_patch_a_entity_by_sid` 가 metadata_json 미포함.

### Step 3: Implementation — detail_steps build 갱신

Modify `backend/app/core/steps/detail_steps.py:498-506`. Replace `_patch_a_entity_by_sid` build with:

```python
            for _e in (ctx.entities.get(_etype_key) or []):
                _sid = _e.get("short_id") if isinstance(_e, dict) else None
                if _sid:
                    # Area B (2026-05-13): metadata_json (JSON string in ctx.entities) → dict.
                    # consumer (build_render_contracts via entity_metadata helper) 가
                    # 직접 dict access. parse 실패 / missing 은 빈 dict — helper 가 fail-fast.
                    _md_raw = _e.get("metadata_json")
                    if isinstance(_md_raw, str):
                        try:
                            _md_dict = json.loads(_md_raw)
                        except (json.JSONDecodeError, TypeError):
                            _md_dict = {}
                    elif isinstance(_md_raw, dict):
                        _md_dict = _md_raw
                    else:
                        _md_dict = {}

                    _patch_a_entity_by_sid[_sid] = {
                        "short_id": _sid,
                        "name": _e.get("name", "") or "",
                        "entity_type": _etype_val,
                        "t2i_prompt": _e.get("t2i_prompt", "") or "",
                        "metadata_json": _md_dict,
                    }
```

`json` import 확인 (file 상단). 이미 있으면 추가 X.

### Step 4: stale narrow path 정리 — `detail_steps.py:2766-2772`

Open `backend/app/core/steps/detail_steps.py:2765-2772`. Replace:

```python
            # Patch A — narrow build 시점에는 LLM 응답 (t2i_vars) 사용 가능.
            # t2i_prompts 도 override 에 전달 → story_critical_prop_filter 가
            # variation 본문까지 등장 매칭 (Tier 1 PRO-13 검사 일관).
            _patch_a_narrow_t2i = [
                (v.get("t2i_prompt") or "")
                for v in (t2i_vars or [])
                if isinstance(v, dict)
            ]
```

with:

```python
            # Area B (2026-05-13): story_critical_prop_filter 폐기 정합.
            # render_contracts 는 build_render_contracts() 가 visible prop +
            # metadata_json.visual_identity.reference_required 만 본다 —
            # t2i_prompts override 불필요. _patch_a_narrow_t2i 삭제.
```

(또는 `_patch_a_narrow_t2i` 영역 자체 삭제 — `_narrow_card = _g41_build(...)` 호출에서 t2i_prompts 인자 전달도 동시 제거. 단 `_narrow_card` 의 다른 caller 가 `_patch_a_narrow_t2i` 의존하는지 grep 확인. Area C Critical 1 lesson.)

Grep 확인:
```bash
grep -n "_patch_a_narrow_t2i\|t2i_prompts" backend/app/core/steps/detail_steps.py | head -20
```

만약 `_patch_a_narrow_t2i` 가 `_g41_build` 또는 다른 caller 에 전달되면, 해당 호출에서도 t2i_prompts 인자 제거. (Task 6 의 build_asset_requirements 시그니처 변경과 정합.)

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py -k "visible_entity_details_carries_metadata_json" -v`

Expected: PASS.

### Step 6: Commit

```bash
git add backend/app/core/steps/detail_steps.py \
        backend/tests/unit/test_render_prompt_card.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 5 — detail_steps visible_entity_details metadata_json wiring (C5)

- _patch_a_entity_by_sid build: metadata_json (JSON string in ctx.entities) → dict.
- narrow build path (line 2766-2772) stale comment + _patch_a_narrow_t2i 영역 정리 (story_critical_prop_filter 폐기 정합).

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

---

## Task 6: render_prompt_card render_contracts integration + legacy deletion (C6)

가장 큰 task. 3 sub-step 으로 진행: (a) producer/validator/consumer 함수 신설, (b) build_asset_requirements 시그니처 변경 + canonicalize sort + caller 갱신, (c) legacy 삭제.

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py` (다수 site)
- Test: `backend/tests/unit/test_render_prompt_card.py` (extend + replace TestStoryCriticalPropFilter)

### Step 1: Failing test — build_render_contracts (test 축 5a-e)

Add to `backend/tests/unit/test_render_prompt_card.py`:

```python
class TestBuildRenderContracts:
    """Area B test 축 5 — render_contracts producer."""

    def test_visible_prop_with_reference_required_true_emits_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P01"],
            visible_entity_details=[{
                "short_id": "P01", "name": "old photo", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": True},
                },
            }],
            current_scene_index=26,
            current_shot_index=6,
        )
        assert len(result) == 1
        contract = result[0]
        assert contract["contract_id"] == "rc_001"
        assert contract["scope"] == {
            "scene_index": 26,
            "shot_indices": [6],
            "duration": "single_shot",
        }
        assert contract["targets"] == [{"entity_id": "P01", "role": "visual_target"}]
        assert contract["requirements"] == [{
            "dimension": "visual_identity",
            "operation": "preserve",
            "strength": "required",
            "reference_policy": "use_entity_reference",
        }]

    def test_visible_prop_with_reference_required_false_no_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P02"],
            visible_entity_details=[{
                "short_id": "P02", "name": "cup", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": False},
                },
            }],
            current_scene_index=10,
            current_shot_index=3,
        )
        assert result == []

    def test_non_visible_prop_no_contract(self):
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=[],  # P03 not visible
            visible_entity_details=[{
                "short_id": "P03", "name": "map", "entity_type": "prop",
                "t2i_prompt": "...",
                "metadata_json": {
                    "location": None,
                    "visual_identity": {"reference_required": True},
                },
            }],
            current_scene_index=0,
            current_shot_index=0,
        )
        assert result == []

    def test_character_and_location_no_contract(self):
        """B-min: character/location 은 render_contracts emit X (기존 path 보존)."""
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["C01", "L01"],
            visible_entity_details=[
                {"short_id": "C01", "name": "Alice", "entity_type": "character",
                 "t2i_prompt": "...",
                 "metadata_json": {"location": None, "visual_identity": None}},
                {"short_id": "L01", "name": "옥탑방", "entity_type": "location",
                 "t2i_prompt": "...",
                 "metadata_json": {"location": {"space_profile": {
                     "kind": "single_space",
                     "allowed_space_keys": ["main"],
                     "default_space_key": None,
                 }}, "visual_identity": None}},
            ],
            current_scene_index=0,
            current_shot_index=0,
        )
        assert result == []

    def test_multi_prop_sort_and_rc_id(self):
        """multi visible prop with true → contracts sort by target_entity_id, rc_001/rc_002."""
        from app.core.steps.render_prompt_card import build_render_contracts
        result = build_render_contracts(
            visible_entities=["P05", "P02", "P10"],
            visible_entity_details=[
                {"short_id": "P05", "name": "a", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {"location": None,
                                   "visual_identity": {"reference_required": True}}},
                {"short_id": "P02", "name": "b", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {"location": None,
                                   "visual_identity": {"reference_required": True}}},
                {"short_id": "P10", "name": "c", "entity_type": "prop",
                 "t2i_prompt": "...",
                 "metadata_json": {"location": None,
                                   "visual_identity": {"reference_required": True}}},
            ],
            current_scene_index=5,
            current_shot_index=1,
        )
        # lexicographic sort: P02 < P05 < P10
        assert [c["contract_id"] for c in result] == ["rc_001", "rc_002", "rc_003"]
        assert [c["targets"][0]["entity_id"] for c in result] == ["P02", "P05", "P10"]
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestBuildRenderContracts -v`

Expected: ALL FAIL — `ImportError: cannot import name 'build_render_contracts'`.

### Step 3: Implementation — build_render_contracts() 신설

Add to `backend/app/core/steps/render_prompt_card.py` (적절한 위치 — 기존 build_asset_requirements 직전 또는 함수 위 영역):

```python
# =========================================================================
# Area B (2026-05-13) — render_contracts[] introduction.
# shot-level visual requirement registry. case-name registry 가 아닌
# visual rendering requirement registry — dimension 은 시각 요구의 축.
# B-min: visual_identity dimension 1 개만 strict enum.
#
# spec: docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md
# =========================================================================

_AREA_B_DIMENSION = "visual_identity"
_AREA_B_OPERATION = "preserve"
_AREA_B_STRENGTH = "required"
_AREA_B_REFERENCE_POLICY = "use_entity_reference"
_AREA_B_TARGET_ROLE = "visual_target"
_AREA_B_DURATION = "single_shot"

_PROP_ENTITY_ID_RE = _re_module.compile(r"^P[0-9]+$")


def build_render_contracts(
    *,
    visible_entities: List[str],
    visible_entity_details: List[Dict[str, Any]],
    current_scene_index: int,
    current_shot_index: int,
) -> List[Dict[str, Any]]:
    """Area B — visible prop ∩ visual_identity.reference_required=true → contract list.

    B-min: dimension=visual_identity, single target (prop short_id ^P[0-9]+$),
    single requirement, single_shot scope. card-local rc_NNN.

    deterministic sort by target_entity_id (lexicographic).

    Raises:
        AppError (entity_metadata.shape_violation) — stale visible_entity_details
        (helper get fail-fast).
    """
    if visible_entities is None or visible_entity_details is None:
        raise AppError(
            code="render_prompt_card.render_contracts_malformed",
            message=(
                "build_render_contracts: visible_entities / visible_entity_details "
                "must not be None. caller contract violation."
            ),
        )

    from app.core.entity_metadata import get_visual_identity_reference_required

    visible_sids = set(visible_entities)

    # 후보 prop 수집 + deterministic sort.
    prop_candidates: List[str] = []  # short_id list
    sid_to_entry: Dict[str, Dict[str, Any]] = {}
    for entry in visible_entity_details:
        if not isinstance(entry, dict):
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"build_render_contracts: visible_entity_details entry must "
                    f"be dict, got {type(entry).__name__}."
                ),
            )
        if entry.get("entity_type") != "prop":
            continue
        sid = (entry.get("short_id") or "").strip()
        if not sid or sid not in visible_sids:
            continue
        if not _PROP_ENTITY_ID_RE.match(sid):
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"build_render_contracts: prop short_id {sid!r} does not "
                    f"match ^P[0-9]+$ pattern (B-min entity_id contract)."
                ),
            )
        rbr = get_visual_identity_reference_required(
            entry.get("metadata_json"), short_id=sid,
        )
        if rbr:
            prop_candidates.append(sid)
            sid_to_entry[sid] = entry

    prop_candidates.sort()  # lexicographic

    contracts: List[Dict[str, Any]] = []
    for idx, sid in enumerate(prop_candidates, start=1):
        contracts.append({
            "contract_id": f"rc_{idx:03d}",
            "scope": {
                "scene_index": current_scene_index,
                "shot_indices": [current_shot_index],
                "duration": _AREA_B_DURATION,
            },
            "targets": [{
                "entity_id": sid,
                "role": _AREA_B_TARGET_ROLE,
            }],
            "requirements": [{
                "dimension": _AREA_B_DIMENSION,
                "operation": _AREA_B_OPERATION,
                "strength": _AREA_B_STRENGTH,
                "reference_policy": _AREA_B_REFERENCE_POLICY,
            }],
        })
    return contracts
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestBuildRenderContracts -v`

Expected: ALL PASS.

### Step 5: Failing test — validate_render_contracts (test 축 5f)

```python
class TestValidateRenderContracts:
    """Area B test 축 5 — render_contracts validator."""

    def _valid_contract(self, sid="P01", scene=26, shot=6):
        return {
            "contract_id": "rc_001",
            "scope": {"scene_index": scene, "shot_indices": [shot], "duration": "single_shot"},
            "targets": [{"entity_id": sid, "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }

    def test_valid_contract_passes(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        validate_render_contracts(
            [self._valid_contract()], scene_index=26, shot_index=6,
        )

    def test_unknown_dimension_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract()
        c["requirements"][0]["dimension"] = "information_surface"
        with pytest.raises(AppError) as exc:
            validate_render_contracts([c], scene_index=26, shot_index=6)
        assert exc.value.code == "render_prompt_card.render_contracts_malformed"

    def test_unknown_operation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract()
        c["requirements"][0]["operation"] = "show_information_bearing_side"
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_invalid_entity_id_pattern_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract(sid="C01")  # not ^P[0-9]+$
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_scope_scene_mismatch_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract(scene=10)  # not current
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_targets_max_items_violation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract()
        c["targets"] = [
            {"entity_id": "P01", "role": "visual_target"},
            {"entity_id": "P02", "role": "visual_target"},
        ]
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)

    def test_shot_indices_multi_violation_raises(self):
        from app.core.steps.render_prompt_card import validate_render_contracts
        from app.core.errors import AppError
        c = self._valid_contract()
        c["scope"]["shot_indices"] = [6, 7]  # not exactly 1
        with pytest.raises(AppError):
            validate_render_contracts([c], scene_index=26, shot_index=6)
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestValidateRenderContracts -v`

Expected: ALL FAIL — `ImportError`.

### Step 7: Implementation — validate_render_contracts() 신설

Add to `backend/app/core/steps/render_prompt_card.py`:

```python
# Area B render_contracts jsonschema (B-min strict shape).
_RENDER_CONTRACTS_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "contract_id": {"type": "string", "pattern": "^rc_[0-9]{3}$"},
            "scope": {
                "type": "object",
                "properties": {
                    "scene_index": {"type": "integer", "minimum": 0},
                    "shot_indices": {
                        "type": "array",
                        "items": {"type": "integer", "minimum": 0},
                        "minItems": 1, "maxItems": 1,
                    },
                    "duration": {"type": "string", "enum": [_AREA_B_DURATION]},
                },
                "required": ["scene_index", "shot_indices", "duration"],
                "additionalProperties": False,
            },
            "targets": {
                "type": "array",
                "minItems": 1, "maxItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "entity_id": {"type": "string", "pattern": "^P[0-9]+$"},
                        "role": {"type": "string", "enum": [_AREA_B_TARGET_ROLE]},
                    },
                    "required": ["entity_id", "role"],
                    "additionalProperties": False,
                },
            },
            "requirements": {
                "type": "array",
                "minItems": 1, "maxItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "dimension": {"type": "string", "enum": [_AREA_B_DIMENSION]},
                        "operation": {"type": "string", "enum": [_AREA_B_OPERATION]},
                        "strength": {"type": "string", "enum": [_AREA_B_STRENGTH]},
                        "reference_policy": {"type": "string", "enum": [_AREA_B_REFERENCE_POLICY]},
                    },
                    "required": ["dimension", "operation", "strength", "reference_policy"],
                    "additionalProperties": False,
                },
            },
        },
        "required": ["contract_id", "scope", "targets", "requirements"],
        "additionalProperties": False,
    },
}


def validate_render_contracts(
    render_contracts: List[Dict[str, Any]],
    scene_index: int,
    shot_index: int,
) -> None:
    """Area B B-min strict — jsonschema validate + scope ↔ current scene/shot 검증.

    Raises:
        AppError(code="render_prompt_card.render_contracts_malformed") on violation.
    """
    import jsonschema
    try:
        jsonschema.validate(instance=render_contracts, schema=_RENDER_CONTRACTS_SCHEMA)
    except jsonschema.ValidationError as exc:
        raise AppError(
            code="render_prompt_card.render_contracts_malformed",
            message=(
                f"validate_render_contracts: jsonschema validation failed — "
                f"{exc.message} (path: {list(exc.absolute_path)})"
            ),
        )

    for c in render_contracts:
        scope = c.get("scope") or {}
        if scope.get("scene_index") != scene_index:
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"validate_render_contracts: contract {c.get('contract_id')!r} "
                    f"scope.scene_index={scope.get('scene_index')} != current "
                    f"scene_index={scene_index}."
                ),
            )
        if (scope.get("shot_indices") or [None])[0] != shot_index:
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"validate_render_contracts: contract {c.get('contract_id')!r} "
                    f"scope.shot_indices[0]={(scope.get('shot_indices') or [None])[0]} "
                    f"!= current shot_index={shot_index}."
                ),
            )
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestValidateRenderContracts -v`

Expected: ALL PASS.

### Step 9: Failing test — required_refs_from_render_contracts (test 축 5g)

```python
class TestRequiredRefsFromRenderContracts:
    """Area B test 축 5 — render_contracts consumer."""

    def test_contract_to_required_refs(self):
        from app.core.steps.render_prompt_card import required_refs_from_render_contracts
        contracts = [{
            "contract_id": "rc_001",
            "scope": {"scene_index": 26, "shot_indices": [6], "duration": "single_shot"},
            "targets": [{"entity_id": "P17", "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        result = required_refs_from_render_contracts(contracts)
        assert result == [{"kind": "prop", "id": "P17", "policy": "required"}]

    def test_unknown_dimension_consumer_raises(self):
        """방어적 second check — validator 우회 시 consumer fail-fast."""
        from app.core.steps.render_prompt_card import required_refs_from_render_contracts
        from app.core.errors import AppError
        contracts = [{
            "contract_id": "rc_001",
            "scope": {"scene_index": 26, "shot_indices": [6], "duration": "single_shot"},
            "targets": [{"entity_id": "P17", "role": "visual_target"}],
            "requirements": [{
                "dimension": "information_surface",  # unknown
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        with pytest.raises(AppError) as exc:
            required_refs_from_render_contracts(contracts)
        assert exc.value.code == "render_prompt_card.render_contracts_malformed"

    def test_empty_contracts_empty_refs(self):
        from app.core.steps.render_prompt_card import required_refs_from_render_contracts
        assert required_refs_from_render_contracts([]) == []
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestRequiredRefsFromRenderContracts -v`

Expected: ALL FAIL — `ImportError`.

### Step 11: Implementation — required_refs_from_render_contracts()

Add to `backend/app/core/steps/render_prompt_card.py`:

```python
def required_refs_from_render_contracts(
    render_contracts: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """Area B — schema-pass contract input 가정 + 방어적 second check.

    B-min: dimension=visual_identity, operation=preserve, reference_policy=use_entity_reference
    조합만 honor → {"kind": "prop", "id": entity_id, "policy": "required"} emit.

    Raises:
        AppError(code="render_prompt_card.render_contracts_malformed") on unknown
        dimension/operation/role/policy.
    """
    result: List[Dict[str, Any]] = []
    for c in render_contracts:
        targets = c.get("targets") or []
        requirements = c.get("requirements") or []
        for req in requirements:
            dim = req.get("dimension")
            op = req.get("operation")
            policy = req.get("reference_policy")
            if dim != _AREA_B_DIMENSION:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown dimension "
                        f"{dim!r} in contract {c.get('contract_id')!r}. "
                        f"B-min consumer honors only {_AREA_B_DIMENSION!r}."
                    ),
                )
            if op != _AREA_B_OPERATION:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown operation "
                        f"{op!r} in contract {c.get('contract_id')!r}. "
                        f"B-min consumer honors only {_AREA_B_OPERATION!r}."
                    ),
                )
            if policy != _AREA_B_REFERENCE_POLICY:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown "
                        f"reference_policy {policy!r} in contract "
                        f"{c.get('contract_id')!r}. B-min consumer honors only "
                        f"{_AREA_B_REFERENCE_POLICY!r}."
                    ),
                )
            for tgt in targets:
                entity_id = tgt.get("entity_id") or ""
                result.append({
                    "kind": "prop",
                    "id": entity_id,
                    "policy": "required",
                })
    return result
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestRequiredRefsFromRenderContracts -v`

Expected: ALL PASS.

### Step 13: Failing test — build_asset_requirements 신 시그니처 (test 축 6)

Replace 또는 add to `backend/tests/unit/test_render_prompt_card.py`:

```python
class TestBuildAssetRequirementsAreaB:
    """Area B test 축 6 — build_asset_requirements 시그니처 변경 + render_contracts integration."""

    def test_new_signature_no_old_args(self):
        """build_asset_requirements 시그니처에서 old 4 args 제거 검증."""
        import inspect
        from app.core.steps.render_prompt_card import build_asset_requirements
        sig = inspect.signature(build_asset_requirements)
        params = set(sig.parameters.keys())
        # Old args 제거 검증
        assert "visible_entity_details" not in params
        assert "shot_description" not in params
        assert "representative_moment" not in params
        assert "t2i_prompts" not in params
        # New arg 추가 검증
        assert "render_contracts" in params

    def test_render_contracts_propagates_to_required_refs(self):
        from app.core.steps.render_prompt_card import build_asset_requirements
        contracts = [{
            "contract_id": "rc_001",
            "scope": {"scene_index": 0, "shot_indices": [0], "duration": "single_shot"},
            "targets": [{"entity_id": "P01", "role": "visual_target"}],
            "requirements": [{
                "dimension": "visual_identity",
                "operation": "preserve",
                "strength": "required",
                "reference_policy": "use_entity_reference",
            }],
        }]
        result = build_asset_requirements(
            visible_entities=["P01"],
            outlook_pairs=[],
            bg_id=None,
            is_close_framing=False,
            background_mode_on=False,
            render_contracts=contracts,
        )
        # required_refs 에 kind=prop emit
        prop_refs = [r for r in result["required_refs"] if r.get("kind") == "prop"]
        assert prop_refs == [{"kind": "prop", "id": "P01", "policy": "required"}]
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirementsAreaB -v`

Expected: FAIL — current build_asset_requirements 가 old 4 args (visible_entity_details / shot_description / representative_moment / t2i_prompts) 받음 + render_contracts 인자 없음.

### Step 15: Implementation — build_asset_requirements 시그니처 변경

Modify `backend/app/core/steps/render_prompt_card.py:1940-2075` (build_asset_requirements 함수). Replace:

```python
def build_asset_requirements(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    bg_id: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
    used_outlook_pairs: Optional[Set[Tuple[str, str]]] = None,
    state_variant_chars: Optional[Set[str]] = None,
    # Area B (2026-05-13): render_contracts 가 prop required_refs 영역 책임.
    # old args (visible_entity_details / shot_description / representative_moment / t2i_prompts)
    # 모두 제거. _VED_NOT_PROVIDED sentinel 도 폐기.
    render_contracts: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """spec §4.5 — asset_requirements field.

    Q3 (spec §10): expected refs only. 실제 AssetReadiness resolver 호출은
    image stage 가 hard gate. card 는 LLM reminder.

    R2-B4 (spec R1-I12): visible_entities / outlook_pairs 가 None 이면
    producer missing 으로 간주 → AppError. 명시적 [] 만 valid.

    Area B (2026-05-13): prop required_refs 영역은 render_contracts argument
    에서 파생 (required_refs_from_render_contracts via). story_critical noun
    matching / shot text matching 모두 폐기.
    """
    # R2-B4: None vs [] fail-fast.
    if visible_entities is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: visible_entities is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    if outlook_pairs is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: outlook_pairs is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    if render_contracts is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: render_contracts is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    _state_variant: Set[str] = state_variant_chars if state_variant_chars else set()
    required: List[Dict[str, Any]] = []
    for pair in outlook_pairs:
        if not isinstance(pair, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    "build_asset_requirements: outlook_pairs entry is "
                    f"{type(pair).__name__} (expected dict)"
                ),
            )
        cid = pair.get("character_id") or ""
        oid = pair.get("outlook_id") or ""
        if not (cid and oid):
            continue
        if used_outlook_pairs is not None and (cid, oid) not in used_outlook_pairs:
            continue
        if cid in _state_variant:
            continue
        if oid == "O00":
            continue
        required.append({
            "kind": "character_outlook",
            "id": f"{cid}{oid}",
            "policy": "required",
        })
    forbidden: List[Dict[str, Any]] = []
    if is_close_framing and background_mode_on:
        forbidden.append({
            "kind": "background",
            "reason": "close framing skips chain_bg reference",
        })
    elif background_mode_on and bg_id and not is_close_framing:
        required.append({
            "kind": "background", "id": bg_id, "policy": "required",
        })

    # Area B (2026-05-13): prop required_refs = render_contracts 파생.
    # noun-list / shot text matching 폐기. visual_identity preserve contract 만.
    required.extend(required_refs_from_render_contracts(render_contracts))

    # readiness_policy — render_contracts 의 prop required 가 있으면 promote.
    _has_prop_required = any(r.get("kind") == "prop" for r in required)
    if _has_prop_required:
        readiness = READINESS_BLOCK
    elif not background_mode_on:
        readiness = READINESS_NA
    elif is_close_framing:
        readiness = READINESS_SKIPPED
    elif required:
        readiness = READINESS_BLOCK
    else:
        readiness = READINESS_NA
    return {
        "required_refs": required,
        "forbidden_refs": forbidden,
        "readiness_policy": readiness,
        "constraints": [
            "do not imply or describe a reference image that is not listed in "
            "required_refs (no phantom references)",
            "if a required character_outlook ref is missing, scene image "
            "generation must block before the image stage",
        ],
    }
```

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirementsAreaB -v`

Expected: ALL PASS.

### Step 17: Implementation — build_render_prompt_card orchestrator 갱신 + card field + canonicalize sort

Modify `backend/app/core/steps/render_prompt_card.py` 의 `build_render_prompt_card()` 함수 (line ~3388 부근 — orchestrator):

```python
# build_render_prompt_card 내부 — build_asset_requirements 호출 직전 영역:

    # Area B (2026-05-13): render_contracts build + validate.
    render_contracts = build_render_contracts(
        visible_entities=visible_entities,
        visible_entity_details=visible_entity_details,
        current_scene_index=scene_index,
        current_shot_index=shot_index,
    )
    validate_render_contracts(
        render_contracts, scene_index=scene_index, shot_index=shot_index,
    )

    asset_requirements = build_asset_requirements(
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        bg_id=bg_id,
        is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
        used_outlook_pairs=used_outlook_pairs,
        state_variant_chars=state_variant_chars,
        render_contracts=render_contracts,
    )

    card = {
        # ... 기존 envelope fields ...
        "render_contracts": render_contracts,  # Area B 신규 top-level field
        "asset_requirements": asset_requirements,
        # ...
    }
```

(`scene_index` / `shot_index` 변수가 build_render_prompt_card 의 local context 에 있는지 확인 — 없으면 caller 가 전달하는 인자. 실제 site 확인 후 정확한 변수명 사용.)

### Step 18: Implementation — canonicalize_render_prompt_card sort 1줄 추가

Modify `backend/app/core/steps/render_prompt_card.py:2127-2187` (canonicalize_render_prompt_card). Add to sort 영역 (e.g., asset_requirements sort 다음):

```python
    # Area B (2026-05-13): render_contracts list ordering 안정화.
    rc = payload.get("render_contracts")
    if isinstance(rc, list):
        payload["render_contracts"] = _sort_dict_list(
            rc, key_fields=["contract_id"],
        )
```

### Step 19: Failing test — card hash 결정성 (test 축 7 card hash 2 assertion)

```python
class TestRenderContractsCardHash:
    """Area B test 축 7 — render_contracts ↔ card hash 정합."""

    def _build_card_with_contracts(self, contracts):
        # 최소 card fixture — render_contracts 만 다른 두 card 비교.
        return {
            "schema_version": 1,
            "shot_key": "s0_h0",
            "render_contracts": contracts,
            "asset_requirements": {"required_refs": [], "forbidden_refs": [],
                                    "readiness_policy": "not_applicable", "constraints": []},
            "id_policy": {}, "background_binding": {},
            "continuity_elements_used": {},
        }

    def test_render_contracts_order_irrelevant_for_hash(self):
        from app.core.steps.render_prompt_card import compute_card_hash
        c1 = {"contract_id": "rc_001", "scope": {...}, "targets": [...], "requirements": [...]}
        c2 = {"contract_id": "rc_002", "scope": {...}, "targets": [...], "requirements": [...]}
        # (구체 contract 객체는 valid B-min shape 로 채움 — 위 helper 인용.)
        card_a = self._build_card_with_contracts([c1, c2])
        card_b = self._build_card_with_contracts([c2, c1])  # 순서만 다름
        assert compute_card_hash(card_a) == compute_card_hash(card_b)

    def test_render_contracts_content_change_hash_differs(self):
        from app.core.steps.render_prompt_card import compute_card_hash
        c1 = {...}  # valid contract with entity_id=P01
        c1_modified = {...}  # same contract_id but entity_id=P02
        card_a = self._build_card_with_contracts([c1])
        card_b = self._build_card_with_contracts([c1_modified])
        assert compute_card_hash(card_a) != compute_card_hash(card_b)
```

- [ ] **Step 20: Run test to verify it fails (initially), then passes after canonicalize sort**

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestRenderContractsCardHash -v`

Expected: First assertion FAIL without sort (different orders → different hash). After Step 18 sort 적용 PASS.

### Step 21: Failing test — deletion gate (test 축 7)

```python
class TestAreaBDeletionGate:
    """Area B closure: legacy noun-list 식별자가 render_prompt_card.py 에서 0."""

    def test_legacy_story_critical_identifiers_removed(self):
        from pathlib import Path
        import app.core.steps.render_prompt_card as mod
        src_path = Path(mod.__file__)
        text = src_path.read_text(encoding="utf-8")
        for ident in (
            "_STORY_CRITICAL_CATEGORY_GROUPS",
            "_STORY_NOUN_TO_GROUP",
            "_STORY_CRITICAL_PROP_NOUNS",
            "_STORY_PROP_MIN_NAME_LEN",
            "_noun_matches_text",
            "story_critical_prop_filter",
            "_VED_NOT_PROVIDED",
        ):
            assert ident not in text, (
                f"Area B legacy identifier {ident!r} not removed from "
                f"render_prompt_card.py"
            )

    def test_build_asset_requirements_signature_area_b(self):
        """Area B: build_asset_requirements signature 검증 — old 4 args 없음."""
        import inspect
        from app.core.steps.render_prompt_card import build_asset_requirements
        sig = inspect.signature(build_asset_requirements)
        params = set(sig.parameters.keys())
        for old_arg in ("visible_entity_details", "shot_description",
                        "representative_moment", "t2i_prompts"):
            assert old_arg not in params, f"old arg {old_arg!r} should be removed"
        assert "render_contracts" in params

    def test_v9_prompt_pack_area_b_context_no_fixed_category_list(self):
        """Area B: v9 system.md + turn_entity_detail.md 의 Area B 변경 영역에 fixed category list 0."""
        from app.modules.prompt_loader import load_prompt
        system_text = load_prompt("entity_extractor_v2", "system")
        detail_text = load_prompt("entity_extractor_v2", "turn_entity_detail")
        # Area B 영역 substring assert — closed-list 부재
        # (specific noun nouns 들이 visual_identity / reference_required 영역 부근에 없어야 함.
        #  여기서는 가이드 문구가 명시 — Do not infer from a fixed object category list.)
        for area_b_text in (system_text, detail_text):
            if "reference_required" in area_b_text:
                # Area B 영역만 검사 — 다른 stem 자연 문장 false-positive 차단.
                # 단순 substring assert.
                # closed-list 가 있으면 안 됨 — 본 검사는 plan implementer 가 v9 작성 시 의도적으로 noun 나열 안 함으로 보장.
                pass  # 의미상 검증은 사람 review (Codex round) 영역.
```

(Note: prompt pack closed-list 검사는 strict substring 자동화가 어렵다 — implementer 가 v9 작성 시 의도적으로 noun list 미포함 + Codex review 가 확인. test 는 식별자 grep 만 자동화.)

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

Run: `cd backend && python -m pytest tests/unit/test_render_prompt_card.py::TestAreaBDeletionGate -v`

Expected: FAIL — legacy identifiers still in file.

### Step 23: Implementation — legacy 식별자 + story_critical_prop_filter 폐기

Modify `backend/app/core/steps/render_prompt_card.py`. **Delete** 영역:

1. **Line 317-364** — Patch A story-critical comment block + 5 module-level constants:
```python
# 통째 삭제:
# =========================================================================
# Patch A — story-critical prop binding ...
# ... (이 영역 전체)
# =========================================================================
# _STORY_CRITICAL_CATEGORY_GROUPS = {...}
# _STORY_NOUN_TO_GROUP = {...}
# _STORY_CRITICAL_PROP_NOUNS_EN = (...)
# _STORY_CRITICAL_PROP_NOUNS_KO = (...)
# _STORY_CRITICAL_PROP_NOUNS = (...)
# _STORY_PROP_MIN_NAME_LEN = 3
```

2. **Line 1799-1809** — `_noun_matches_text` function 통째 삭제.

3. **Line 1812-~1940** — `story_critical_prop_filter` function 통째 삭제 (정확한 closing line 은 함수 body 끝까지).

4. **Line 1949 부근** — `_VED_NOT_PROVIDED` sentinel + 관련 사용 코드 삭제 (build_asset_requirements 시그니처 변경에서 이미 사라짐).

5. **Line 2054-2072** — build_asset_requirements 안의 `_story_props = story_critical_prop_filter(...)` 영역 삭제 (Step 15 에서 이미 `required.extend(required_refs_from_render_contracts(...))` 로 교체).

### Step 24: Grep 확인 — 다른 caller / 참조 site

Run:
```bash
grep -n "_STORY_CRITICAL\|_STORY_NOUN_TO_GROUP\|_noun_matches_text\|story_critical_prop_filter\|_VED_NOT_PROVIDED" backend/ 2>/dev/null
```

Expected: 0 hits in production code. test 파일에서 deletion gate 자체의 식별자 grep 만 (string 으로 hard-coded).

만약 다른 caller 발견 — 동시 갱신 (Area C Critical 1 lesson).

- [ ] **Step 25: Run all Area B tests — task 1~6 회귀 검증**

Run:
```bash
cd backend && python -m pytest \
  tests/core/test_d6_entity_metadata_json.py \
  tests/unit/test_render_prompt_card.py \
  -v
```

Expected: ALL PASS.

### Step 26: Commit

```bash
git add backend/app/core/steps/render_prompt_card.py \
        backend/tests/unit/test_render_prompt_card.py
git commit -m "$(cat <<'EOF'
feat(area-b): Task 6 — render_contracts integration + legacy deletion (C6)

- build_render_contracts: visible prop ∩ visual_identity.reference_required=true → contract list, deterministic sort by target_entity_id, card-local rc_NNN.
- validate_render_contracts: B-min strict jsonschema + scope ↔ current scene/shot 검증.
- required_refs_from_render_contracts: schema-pass contract → kind=prop required_refs. unknown dimension/operation/policy fail-fast (방어적 second check).
- build_asset_requirements 시그니처 변경: old 4 args (visible_entity_details/shot_description/representative_moment/t2i_prompts) 제거 + render_contracts 추가. _VED_NOT_PROVIDED sentinel 폐기.
- build_render_prompt_card orchestrator: render_contracts top-level field 추가.
- canonicalize_render_prompt_card: render_contracts list sort by contract_id (hash 결정성).
- Legacy deletion: _STORY_CRITICAL_CATEGORY_GROUPS / _STORY_NOUN_TO_GROUP / _STORY_CRITICAL_PROP_NOUNS_* / _STORY_PROP_MIN_NAME_LEN / _noun_matches_text / story_critical_prop_filter 모두 폐기.

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

---

## Task 7: Tests/Gates + Canary + Codex Review (C7)

**Files:**
- Test: `backend/tests/core/test_d6_entity_metadata_json.py` + `backend/tests/unit/test_render_prompt_card.py` (final verification)
- Canary: PID `02829fe8` ep1 force re-run

### Step 1: Final test suite run — 7 test 축 모두 PASS

Run:
```bash
cd backend && python -m pytest \
  tests/core/test_d6_entity_metadata_json.py \
  tests/unit/test_render_prompt_card.py \
  -v
```

Expected: ALL PASS. 7 test 축:
1. metadata schema/helper ✓
2. EntitySyncService 저장 ✓
3. detail_steps wiring ✓
4. entity_t2i prop post-validation ✓
5. render_contracts producer/validator/consumer ✓
6. build_asset_requirements integration ✓
7. deletion gate + card hash ✓

### Step 2: Run broader regression — entity / detail / sync 영역

Run:
```bash
cd backend && python -m pytest \
  tests/core/ \
  tests/unit/ \
  -v --tb=short
```

Expected: ALL PASS — Area B 범위 + pre-existing fail (umbrella P3 carry: test_d6_* background-chain 외, test_provenance, test_variation_pipeline 등) 만 잔존.

### Step 3: Codex 외부 review (Area C lesson — closure 직전 의무)

```bash
git log --oneline -10
git diff main~6..HEAD --stat
```

Codex review 진입 (수동 paste 또는 codex:rescue agent):
- 검토 대상: Area B 6 commits (Task 1~6).
- 검토 영역:
  - cascade silent leakage (Area B 변경이 다른 site 에 silent drift 도입했는지)
  - helper drift (sync L2 와 consumer L3 의 helper 호출 일관성)
  - prompt pack drift (v9 전체 pack 정합 + Area B 영역의 fixed category list 부재)
  - version_registry stale entry 갱신 검증
  - card hash 결정성 (canonicalize sort + render_contracts 자동 포함)
  - render_contracts schema 의 B-min strict lock (1 값씩) 정합
  - error code 2 종 (entity_metadata.shape_violation + render_prompt_card.render_contracts_malformed) 사용 일관성

review 결과 NEEDS_REVISION_* 흡수 → fix commits + 재 review 1 round.

### Step 4: Canary closure — PID 02829fe8 ep1 force re-run

> **⚠️ EXECUTION GATE — placeholder 갱신 의무**:
> 본 Step 의 `EID` / API endpoint URL 은 placeholder. canary 실행 **전** 운영 환경에서 실제 값으로 갱신 의무. 갱신 전 실행 시 canary 가 형식만 통과 (404 / 빈 episode 등) 후 false-positive closure 위험.
>
> 갱신 항목 (필수):
> 1. `EID=<actual_ep_id>` — `02829fe8` 프로젝트의 실제 episode_id (DB query `SELECT id FROM episode WHERE project_id='02829fe8'` 또는 운영자 confirm).
> 2. `curl` endpoint URL — `http://localhost:8000/api/...` 부분 — 실제 backend 가 running 인 host:port + actual route path (FastAPI route 확인 `grep -r "force-rerun\|run-pipeline" backend/app/api/`).
> 3. 갱신 완료 확인 후 (placeholder 잔존 0) 실행.

```bash
# entity_extract / entity_t2i force re-run — Area B 신 SOT 생성.
# 실제 명령은 운영 환경의 step runner API 호출. PID/EID 사용자 환경.

PID="02829fe8"
# (EID 는 운영 환경의 episode id — 위 EXECUTION GATE 의 갱신 의무 확인)
EID="<actual_ep_id_REPLACE_BEFORE_EXEC>"

# Step runner force re-run (예시 — 실제 endpoint 확인 후 갱신):
curl -X POST "http://localhost:8000/api/projects/${PID}/episodes/${EID}/steps/entity_extract/force-rerun"
curl -X POST "http://localhost:8000/api/projects/${PID}/episodes/${EID}/steps/entity_t2i/force-rerun"

# downstream cascade — scene_detail / shot_director / render_prompt_card 도 force re-run.
curl -X POST "http://localhost:8000/api/projects/${PID}/episodes/${EID}/run-pipeline"
```

검증 4 closure 기준 (spec §9):

**1. prop metadata visual_identity.reference_required 생성** — entity_t2i checkpoint inspect:
```bash
cat /Users/manta/Documents/Projects/TheRoad-I1/projects/02829fe8/episodes/${EID}/entity_t2i.json | python -c "
import json, sys
d = json.load(sys.stdin)
for e in d.get('props', []):
    print(e['name'], e.get('metadata_json'))
"
```
Expected: 각 prop 의 `metadata_json` 에 `{"location": null, "visual_identity": {"reference_required": <bool>}}` 포함.

**2. DB EntityCanon row 의 normalized metadata 저장** — sqlite query:
```bash
sqlite3 /Users/manta/Documents/Projects/TheRoad-I1/backend/db.sqlite "
SELECT name, metadata_json
FROM entity_canon
WHERE project_id='02829fe8' AND entity_type='prop';
"
```
Expected: 각 row 의 metadata_json 이 normalized shape JSON string.

**3. card render_contracts 생성** — scene_detail / shot_dependency cp inspect:
```bash
grep -r '"render_contracts"' /Users/manta/Documents/Projects/TheRoad-I1/projects/02829fe8/episodes/${EID}/scene_detail*.json | head -5
```
Expected: scene_detail 또는 관련 cp 에 `render_contracts` field 출현 + visible prop + true 만 emit.

**4. asset_requirements.required_refs 가 render_contracts 에서만 파생** — sample card inspect:
```bash
# scene_still 또는 shot card 에서 required_refs 의 kind=prop 항목이 render_contracts 의 entity_id 와 정합 확인.
python -c "
import json
with open('/path/to/scene_still.json') as f:
    card = json.load(f)
rc_ids = {c['targets'][0]['entity_id'] for c in card.get('render_contracts', [])}
prop_refs = {r['id'] for r in card['asset_requirements']['required_refs'] if r['kind']=='prop'}
assert rc_ids == prop_refs, f'mismatch: contracts {rc_ids} != prop refs {prop_refs}'
print('PASS')
"
```
Expected: render_contracts entity_id set == required_refs kind=prop id set. noun-list / regex path 0 (이미 deletion gate test 가 자동 검증).

### Step 5: Codex review 통과 + Canary 통과 후 final commit

```bash
git add backend/tests/  # final test suite 갱신 (necessary case 만)
git commit -m "$(cat <<'EOF'
feat(area-b): Task 7 — final tests/gates + canary closure (C7)

7 test 축 모두 PASS:
1. metadata schema/helper
2. EntitySyncService 저장
3. detail_steps wiring
4. entity_t2i prop post-validation
5. render_contracts producer/validator/consumer
6. build_asset_requirements integration
7. deletion gate + card hash

Canary 4 기준 PASS (PID 02829fe8 force re-run):
1. prop metadata visual_identity.reference_required 생성
2. DB normalized metadata 저장
3. card render_contracts 생성
4. asset_requirements.required_refs render_contracts 파생 (noun-list/regex path 0)

Codex review APPROVED — cascade silent leakage 0, helper drift 0, prompt pack v9 정합, version_registry 갱신.

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

git push origin main
```

### Step 6: 진입점 메모 갱신

Update `/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/MEMORY.md` + create `next_session_area_d_scene_reference_service.md`:

- Area B 닫힘 (7 task + canary + Codex review).
- main HEAD = `<Area B 마지막 commit>`.
- 다음 = Area D (scene_reference_service, audit C8+C9 + U2) — Area B 의 prop kind required_refs 가 Area D 의 attached_refs wire 영역.

---

## Self-Review (spec coverage / placeholder / type consistency)

### Spec coverage
- spec §3.1 metadata_json shape → Task 1 Step 3 ✓
- spec §3.2 render_contracts schema → Task 6 Step 7 ✓
- spec §3.3 contract_id 규칙 → Task 6 Step 3 ✓
- spec §3.4 scope code validation → Task 6 Step 3 + Step 7 ✓
- spec §3.5 card 통합 + canonicalize sort → Task 6 Step 17-18 ✓
- spec §4 components (C1~C7) → Task 1~7 ✓
- spec §4.1 C6 detail (build/validate/consumer + asset_requirements 시그니처 + legacy deletion) → Task 6 ✓
- spec §4.2 helper naming → Task 2 ✓
- spec §5 3 boundary fail-fast → Task 2 (B1+B2) + Task 6 (B3) ✓
- spec §6 7 test 축 → Task 1~6 + Task 7 ✓
- spec §6.1 deletion gate 식별자 7 + signature 검증 → Task 6 Step 21-22 ✓
- spec §6.2 canary 4 기준 → Task 7 Step 4 ✓
- spec §7 Gates 1~4 → architecture 자동 정합 (Task 1~6 collective)
- spec §8 implementation plan summary (7 task = 7 component) → Task 1~7 ✓
- spec §9 out-of-scope → Task 외 영역 (별도 patch)

### Placeholder scan
- `<UTC_TS>` (Task 1 Step 9 prompt pack dir) — implementation 시 결정.
- `<actual_ep_id_REPLACE_BEFORE_EXEC>` (Task 7 Step 4 canary) — 운영 환경 episode id 확인 후 갱신. Task 7 Step 4 의 EXECUTION GATE 명시 — 갱신 전 실행 시 false-positive closure 위험.
- API endpoint URL (Task 7 Step 4) — 운영 환경 endpoint 확인 후 갱신.

위 3 placeholder 는 implementation/canary 시점 운영 정보로 채움.

### Type consistency
- helper module / function 이름 — Task 2 정의 ↔ Task 3/4/6 호출 동일 ✓
- error codes 2 종 — Task 2 정의 ↔ Task 3/4/6 assert 동일 ✓
- build_render_contracts / validate_render_contracts / required_refs_from_render_contracts 시그니처 — Task 6 정의 일관 ✓
- B-min enum constants (`_AREA_B_DIMENSION` 등) — Task 6 Step 3 정의 ↔ Step 7 schema ↔ Step 11 consumer 동일 ✓
- card "render_contracts" field 이름 — Task 6 Step 17 ↔ Step 18 ↔ Step 19 ↔ Step 4 (canary) 동일 ✓

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-13-area-b-render-contracts-implementation.md`. Two execution options:

1. **Subagent-Driven (recommended)** — fresh subagent per task (Task 1~7), 2-stage review (spec 정합 + quality) between tasks. Area C 의 닫힘 패턴. model=opus 강제 ([[feedback_subagent_model_opus]] 정합).
2. **Inline Execution** — 현재 세션에서 Task 별 직접 진행, checkpoint review.

Which approach?
