# Patch B — Sanitizer Polarity Guard (B-min) 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:** moderation sanitize 가 움직일 수 없거나 자세 고정 subject 를 "살아 움직이는 감정 장면" 으로 바꾸지 못하게 차단 (S11/14·S11/20 결함 영역).

**Architecture:** 신규 `SemanticContractRouter` (LLM-produced structured SOT → deterministic consumer) + `PromptSanitizer.sanitize()` 에 `semantic_constraints` optional 인자 + final sanitized_prompt 에 deterministic `SEMANTIC OVERRIDE` block append. 2-layer defense (user prompt SEMANTIC CONSTRAINTS 섹션 + final override block). post-check / LVM gate / regex 의미 판단 없음.

**Tech Stack:** Python 3.12 + FastAPI + dataclasses + pytest + monkeypatch.

**Spec reference:** `docs/superpowers/specs/2026-05-11-patch-b-sanitizer-polarity-design.md` (v1, 802 lines, 11 sections, Codex 6 round review APPROVED_FOR_PLAN).

---

## File Structure

**신규 production**:
- `backend/app/modules/semantic_contract_router.py` — `SemanticContract` dataclass + `build_semantic_contract()` + `_build_sanitizer_constraints()` private helper.

**수정 production**:
- `backend/app/modules/prompt_sanitizer.py` — `sanitize()` 새 optional 인자 `semantic_constraints` + 2 신규 helper (`_render_semantic_constraints_block`, `_apply_semantic_override`) + `_STATE_GUIDANCE` table + `_SEMANTIC_OVERRIDE_MARKER` 상수.
- `backend/app/modules/pipeline/scene_image_pipeline.py:204` — `generate_and_validate_scene()` 새 optional 인자 + line 260 sanitize 호출 propagation.
- `backend/app/services/scene_generation_coordinator.py` — `_generate_variation_in_loop()` 호출 직전 (around line ~595) build_semantic_contract 호출 + `_generate_variation_in_loop()` signature 에 `semantic_constraints` 추가 + line 1404 `generate_and_validate_scene` 호출 시 propagate.

**신규 prompt pack** (CLAUDE.md 버전 형식 `2.YYYYMMDDHHmm`):
- `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_system.md` — v1 그대로 복사 (본문 변경 0).
- `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_user.md` — v1 본문 + `{semantic_constraints_block}` slot 1 줄 추가.

**신규 test**:
- `backend/tests/unit/test_semantic_contract_router.py` — Test 1+2 (router 단위).
- `backend/tests/unit/test_prompt_sanitizer_constraints.py` — Test 3 (sanitize 2-layer).
- `backend/tests/integration/test_scene_image_pipeline_immobilized.py` — Test 4 (pipeline propagation).

---

## Tasks

### Task 1: Prompt pack v2 신규 dir + sanitize_user.md slot

**Files:**
- Create: `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_system.md`
- Create: `prompts/_base/prompt_sanitizer/2.202605112104/sanitize_user.md`

- [ ] **Step 1: 신규 dir 생성 + sanitize_system.md = v1 복사**

```bash
mkdir -p prompts/_base/prompt_sanitizer/2.202605112104
cp prompts/_base/prompt_sanitizer/v1/sanitize_system.md \
   prompts/_base/prompt_sanitizer/2.202605112104/sanitize_system.md
```

sanitize_system.md 는 v1 그대로 복사 (본문 변경 0 — 2-layer defense 의 sanitize LLM 입력 system layer 는 변경 없이도 final block 으로 충분).

- [ ] **Step 2: sanitize_user.md = v1 본문 + slot 1 줄 추가**

새 파일 작성 (`prompts/_base/prompt_sanitizer/2.202605112104/sanitize_user.md`):

```markdown
원본 프롬프트:
{original_prompt}

거절 사유: {block_reason}
거절 카테고리: {block_categories}
수정 시도: {attempt}차

적용 전략: {strategy_name} — {strategy_description}
전략 프리픽스:
{strategy_prefix}

{semantic_constraints_block}

위 전략의 프리픽스를 포함하여, 원본 프롬프트를 T2I 안전 정책을 충족하도록 포토리얼리스틱 스타일로 수정해주세요.
```

`{semantic_constraints_block}` slot 은 전략 프리픽스 다음, 최종 지시 직전. constraints=None 일 때 `_render_semantic_constraints_block` 가 빈 문자열 반환 → format 결과에 빈 줄 1개 (LLM 무영향).

- [ ] **Step 3: prompt_loader 가 v2 pack 을 latest 로 인식 검증**

Run:
```bash
cd backend && .venv/bin/python -c "
from app.modules.prompt_loader import load_prompt
sys_txt = load_prompt('prompt_sanitizer', 'sanitize_system')
user_txt = load_prompt('prompt_sanitizer', 'sanitize_user')
assert '{semantic_constraints_block}' in user_txt, 'slot 누락'
print('v2 pack OK. sys len=', len(sys_txt), 'user len=', len(user_txt))
"
```

Expected: `v2 pack OK. sys len= ... user len= ...` (slot 검증 통과).

- [ ] **Step 4: Commit**

```bash
git add prompts/_base/prompt_sanitizer/2.202605112104/
git commit -m "feat(prompt_sanitizer): v2 pack with semantic_constraints_block slot

Patch B-min — sanitize_user.md 에 {semantic_constraints_block} slot
추가. sanitize_system.md 는 v1 그대로 복사 (2-layer defense 의 final
override block 으로 충분, system 본문 변경 0)."
```

---

### Task 2: SemanticContract + Rule 1 (immobilized via gaze_target) + Test 1

**Files:**
- Create: `backend/app/modules/semantic_contract_router.py`
- Create: `backend/tests/unit/test_semantic_contract_router.py`

- [ ] **Step 1: Test 1 작성 — failing test 먼저**

새 파일 `backend/tests/unit/test_semantic_contract_router.py`:

```python
"""Patch B-min — SemanticContractRouter 단위 테스트.

시나리오 의존 0: synthetic identifier (Subject Alpha / C91) 만 사용.
"""
from app.modules.semantic_contract_router import (
    SemanticContract,
    SEMANTIC_PRIMARY_MODES,
    IMMOBILIZED_GAZE,
    build_semantic_contract,
)


def test_gaze_target_dead_produces_immobilized():
    """staging.character_angles.gaze_target='dead' → immobilized contract.

    visible_entities 의 character 이름 exact match 로 C## 추출.
    """
    staging = {"character_angles": [{"character": "Subject Alpha", "gaze_target": "dead"}]}
    visible = [{"name": "Subject Alpha", "short_id": "C91", "entity_type": "character"}]

    contract = build_semantic_contract(
        shot_staging=staging,
        render_prompt_card=None,
        visible_entities=visible,
    )

    assert contract.primary_mode == "immobilized"
    assert contract.immobilized_entity_ids == ("C91",)
    assert contract.pose_locked_entity_ids == ("C91",)           # immobilized ⊆ pose_locked invariant
    assert contract.source_states == (("C91", "dead"),)
    assert contract.tags == ()
    constraints = contract.sanitizer_constraints
    assert constraints is not None
    assert constraints["semantic_mode"] == "immobilized"
    assert constraints["entity_ids"] == ["C91"]
    assert constraints["source_states"] == {"C91": "dead"}
    assert constraints["preserve_pose"] is True
    assert constraints["preserve_subject_state"] is True
    assert constraints["forbid_state_polarity_rewrite"] is True
    assert constraints["forbid_unharmed_rewrite"] is True
    assert constraints["forbid_active_reaction"] is True
    assert constraints["override_strategy_prefix"] is True
```

- [ ] **Step 2: Run test to verify FAIL**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_semantic_contract_router.py::test_gaze_target_dead_produces_immobilized -v
```

Expected: FAIL with `ModuleNotFoundError: No module named 'app.modules.semantic_contract_router'`.

- [ ] **Step 3: 최소 구현 — semantic_contract_router.py 신규 모듈**

새 파일 `backend/app/modules/semantic_contract_router.py`:

```python
"""SemanticContractRouter — moderation sanitize 시점의 polarity 보존 인터페이스.

LLM-produced structured SOT (shot_staging / render_prompt_card) 만 읽어
SemanticContract 를 deterministic 하게 산출. 호출자 (scene_image_pipeline 등) 가
contract.sanitizer_constraints 를 PromptSanitizer.sanitize() 에 전달하면 user
prompt 의 SEMANTIC CONSTRAINTS 섹션 + final sanitized_prompt 의 SEMANTIC
OVERRIDE block 으로 2-layer defense.

Patch B-min 영역 (Rule 1 = gaze_target, Rule 2 = character_state). B-next 는
shot_staging 에 subject_state.immobility_state structured field 신규 + router
primary SOT 전환.

regex / LLM judge / LVM gate 없음 — 다국어 + 정확도 한계 (spec §1.3).
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Any


SEMANTIC_PRIMARY_MODES: tuple[str, ...] = ("none", "pose_locked", "immobilized")
IMMOBILIZED_GAZE: frozenset[str] = frozenset({"dead", "unconscious", "severely_injured"})


@dataclass(frozen=True)
class SemanticContract:
    primary_mode: str                                # SEMANTIC_PRIMARY_MODES 중 하나
    tags: tuple[str, ...]                            # 미래 비배타 mode 누적 hook. B-min 빈 tuple.
    pose_locked_entity_ids: tuple[str, ...]          # 정렬된 C## 목록
    immobilized_entity_ids: tuple[str, ...]          # ⊆ pose_locked
    source_states: tuple[tuple[str, str], ...]       # (("C91", "dead"), ...) deterministic order
    evidence: tuple[dict[str, Any], ...]             # ({"source", "entity_id", "value"}, ...)
    sanitizer_constraints: dict[str, Any] | None     # primary_mode=="none" 면 None


def _build_sanitizer_constraints(
    primary_mode: str,
    pose_locked_entity_ids: tuple[str, ...],
    source_state_map: dict[str, str],
    evidence: tuple[dict[str, Any], ...],
) -> dict | None:
    """sanitizer_constraints 산출. SOT = dataclass field 하나만 (이중 SOT 차단)."""
    if primary_mode == "none":
        return None
    constraints: dict[str, Any] = {
        "semantic_mode": primary_mode,
        "entity_ids": list(pose_locked_entity_ids),
        "source_states": dict(source_state_map),
        "preserve_pose": True,
        "override_strategy_prefix": True,
        "evidence": [dict(e) for e in evidence],
    }
    if primary_mode == "immobilized":
        constraints.update(
            preserve_subject_state=True,
            forbid_state_polarity_rewrite=True,
            forbid_unharmed_rewrite=True,
            forbid_active_reaction=True,
        )
    else:                                            # pose_locked
        constraints.update(
            preserve_subject_state=False,
            forbid_state_polarity_rewrite=False,    # character_state 과탐 방지 (sleep/rest/injury/death 혼재)
            forbid_unharmed_rewrite=False,
            forbid_active_reaction=False,
        )
    return constraints


def build_semantic_contract(
    *,
    shot_staging: dict | None,
    render_prompt_card: dict | None,
    visible_entities: list[dict[str, Any]],
) -> SemanticContract:
    """LLM-produced structured SOT 만 읽어 SemanticContract 산출.

    Rule 1 — gaze_target ∈ IMMOBILIZED_GAZE → immobilized.
    Rule 2 — render_prompt_card.continuity_elements_used.fixed_elements 의
             element_type=='character_state' → pose_locked (단, Rule 1 이 잡지
             않은 entity 만).
    """
    name_to_sid = {
        e["name"]: e["short_id"]
        for e in (visible_entities or [])
        if e.get("entity_type") == "character" and e.get("name") and e.get("short_id")
    }

    immobilized: set[str] = set()
    source_state_map: dict[str, str] = {}
    evidence_list: list[dict[str, Any]] = []

    # Rule 1
    for entry in (shot_staging or {}).get("character_angles", []) or []:
        gaze = entry.get("gaze_target")
        if gaze not in IMMOBILIZED_GAZE:
            continue
        sid = name_to_sid.get(entry.get("character") or "")
        if not sid:
            continue
        immobilized.add(sid)
        source_state_map[sid] = gaze
        evidence_list.append({
            "source": "shot_staging.character_angles.gaze_target",
            "entity_id": sid,
            "value": gaze,
        })

    # Rule 2 → Task 3 에서 추가.
    pose_locked: set[str] = set()

    # 결합
    if immobilized:
        primary_mode = "immobilized"
    elif pose_locked:
        primary_mode = "pose_locked"
    else:
        primary_mode = "none"

    pose_locked.update(immobilized)                  # immobilized ⊆ pose_locked invariant

    pose_locked_ids = tuple(sorted(pose_locked))
    return SemanticContract(
        primary_mode=primary_mode,
        tags=(),
        pose_locked_entity_ids=pose_locked_ids,
        immobilized_entity_ids=tuple(sorted(immobilized)),
        source_states=tuple(sorted(source_state_map.items())),
        evidence=tuple(evidence_list),
        sanitizer_constraints=_build_sanitizer_constraints(
            primary_mode, pose_locked_ids, source_state_map, tuple(evidence_list),
        ),
    )
```

- [ ] **Step 4: Run test to verify PASS**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_semantic_contract_router.py::test_gaze_target_dead_produces_immobilized -v
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/semantic_contract_router.py backend/tests/unit/test_semantic_contract_router.py
git commit -m "feat(semantic_contract_router): Rule 1 immobilized via gaze_target

Patch B-min Task 2 — SemanticContract dataclass + Rule 1 (gaze_target ∈
{dead, unconscious, severely_injured} → immobilized). Test 1 PASS. Rule 2
는 Task 3 에서 추가."
```

---

### Task 3: Rule 2 (pose_locked via character_state) + Test 2

**Files:**
- Modify: `backend/app/modules/semantic_contract_router.py:build_semantic_contract` (Rule 2 추가)
- Modify: `backend/tests/unit/test_semantic_contract_router.py` (Test 2 추가)

- [ ] **Step 1: Test 2 작성 — failing test 먼저**

`backend/tests/unit/test_semantic_contract_router.py` 끝에 추가:

```python
def test_character_state_only_produces_pose_locked():
    """render_prompt_card.continuity_elements_used.fixed_elements 의
    element_type='character_state' 만 있을 때 → pose_locked.

    character_state 는 sleep/rest/injury/death 혼재 가능 → state polarity 단정 X,
    pose 만 lock.
    """
    card = {
        "continuity_elements_used": {
            "fixed_elements": [
                {
                    "element_type": "character_state",
                    "character_name": "Subject Beta",
                    "element_id": "char_state_seated",
                },
            ],
        },
    }
    visible = [{"name": "Subject Beta", "short_id": "C92", "entity_type": "character"}]

    contract = build_semantic_contract(
        shot_staging=None,
        render_prompt_card=card,
        visible_entities=visible,
    )

    assert contract.primary_mode == "pose_locked"
    assert contract.pose_locked_entity_ids == ("C92",)
    assert contract.immobilized_entity_ids == ()
    assert contract.source_states == ()
    constraints = contract.sanitizer_constraints
    assert constraints is not None
    assert constraints["semantic_mode"] == "pose_locked"
    assert constraints["preserve_pose"] is True
    assert constraints["preserve_subject_state"] is False
    assert constraints["forbid_state_polarity_rewrite"] is False  # 과탐 방지
    assert constraints["forbid_unharmed_rewrite"] is False
    assert constraints["forbid_active_reaction"] is False
    assert constraints["override_strategy_prefix"] is True       # weaker block 도 final 에 넣음


def test_none_when_no_signals():
    """staging/card 둘 다 없을 때 → mode=none + sanitizer_constraints=None."""
    contract = build_semantic_contract(
        shot_staging=None,
        render_prompt_card=None,
        visible_entities=[],
    )
    assert contract.primary_mode == "none"
    assert contract.sanitizer_constraints is None
```

- [ ] **Step 2: Run tests to verify Test 2 FAIL (Rule 2 미구현)**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_semantic_contract_router.py -v
```

Expected: `test_gaze_target_dead_produces_immobilized` PASS, `test_character_state_only_produces_pose_locked` FAIL (primary_mode == "none" 인데 "pose_locked" 기대), `test_none_when_no_signals` PASS.

- [ ] **Step 3: Rule 2 구현**

`backend/app/modules/semantic_contract_router.py` 의 `build_semantic_contract()` 안에서 `# Rule 2 → Task 3 에서 추가.` 자리를 다음으로 교체:

```python
    # Rule 2 — character_state via render_prompt_card.continuity_elements_used.fixed_elements.
    # 주의: shot 별 card 이므로 fixed_elements 는 이미 이 shot 에 적용되는 것만 옴
    # (detail_steps:364/2346 가 applies_to_shots 로 pre-filter).
    fixed_elements = (
        (render_prompt_card or {})
        .get("continuity_elements_used", {})
        .get("fixed_elements", []) or []
    )
    pose_locked: set[str] = set()
    for element in fixed_elements:
        if element.get("element_type") != "character_state":
            continue
        char_name = element.get("character_name") or ""           # 단일 필드 (schema.json:25)
        sid = name_to_sid.get(char_name)
        if sid and sid not in immobilized:
            pose_locked.add(sid)
            evidence_list.append({
                "source": "render_prompt_card.continuity_elements_used.fixed_elements.character_name",
                "entity_id": sid,
                "value": element.get("element_id") or "character_state",
            })
```

기존 `pose_locked: set[str] = set()` 두 곳이 중복되지 않도록 Rule 2 영역만 유지 (Task 2 의 stub 라인 삭제).

- [ ] **Step 4: Run all tests in module — 3 PASS**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_semantic_contract_router.py -v
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/semantic_contract_router.py backend/tests/unit/test_semantic_contract_router.py
git commit -m "feat(semantic_contract_router): Rule 2 pose_locked via character_state

Patch B-min Task 3 — fixed_elements.element_type=='character_state' 의
character_name 단일 필드 (schema.json:25) → pose_locked. character_state 가
sleep/rest/injury/death 혼재 가능 → forbid_state_polarity_rewrite=False
로 과탐 방지. immobilized ⊆ pose_locked invariant 유지. Test 2+3 PASS."
```

---

### Task 4: PromptSanitizer expansion (semantic_constraints + 2 helpers) + Test 3

**Files:**
- Modify: `backend/app/modules/prompt_sanitizer.py`
- Create: `backend/tests/unit/test_prompt_sanitizer_constraints.py`

- [ ] **Step 1: Test 3 작성 — failing test 먼저**

새 파일 `backend/tests/unit/test_prompt_sanitizer_constraints.py`:

```python
"""Patch B-min — PromptSanitizer 2-layer defense 단위 테스트.

Layer 1: user prompt 의 SEMANTIC CONSTRAINTS 섹션 (sanitize LLM 지시).
Layer 2: final sanitized_prompt 의 SEMANTIC OVERRIDE block (T2I 모델 지시).

시나리오 의존 0: synthetic identifier (C91 / dead) 만 사용.
"""
from app.modules.prompt_sanitizer import PromptSanitizer


def test_sanitize_two_layer_defense(monkeypatch):
    """user prompt + final prompt 둘 다 검증 + 중복 append 차단."""
    captured_kwargs: dict = {}

    def fake_call_structured(**kwargs):
        captured_kwargs.update(kwargs)
        return {
            "sanitized_prompt": "safe rewritten prompt body",
            "changes": "removed unsafe terms",
            "strategy": "film_previs",
        }

    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        fake_call_structured,
    )

    sanitizer = PromptSanitizer()
    constraints = {
        "semantic_mode": "immobilized",
        "entity_ids": ["C91"],
        "source_states": {"C91": "dead"},
        "preserve_pose": True,
        "preserve_subject_state": True,
        "forbid_state_polarity_rewrite": True,
        "forbid_unharmed_rewrite": True,
        "forbid_active_reaction": True,
        "override_strategy_prefix": True,
        "evidence": [],
    }

    result = sanitizer.sanitize(
        original_prompt="unsafe original",
        block_reason="SAFETY",
        block_categories=["violence"],
        attempt=1,
        semantic_constraints=constraints,
    )

    # Layer 1 — user prompt 에 SEMANTIC CONSTRAINTS 섹션 + entity/state line
    user_prompt = captured_kwargs["user_prompt"]
    assert "SEMANTIC CONSTRAINTS" in user_prompt
    assert "C91" in user_prompt
    assert "dead" in user_prompt
    assert "forbid_state_polarity_rewrite: True" in user_prompt

    # Layer 2 — final sanitized_prompt 에 SEMANTIC OVERRIDE block append
    sanitized = result["sanitized_prompt"]
    assert "SEMANTIC OVERRIDE:" in sanitized
    assert "C91 (dead):" in sanitized

    # block 위치 = strategy prefix 뒤 (final position)
    prefix_marker = "pre-visualization concept art"               # film_previs prefix substring
    assert sanitized.index(prefix_marker) < sanitized.index("SEMANTIC OVERRIDE:")

    # 중복 append 차단 검증 (retry 입력 시뮬레이션)
    result2 = sanitizer.sanitize(
        original_prompt=sanitized,                                # 이전 retry output 을 다음 입력으로
        block_reason="SAFETY",
        block_categories=["violence"],
        attempt=2,
        semantic_constraints=constraints,
    )
    assert result2["sanitized_prompt"].count("SEMANTIC OVERRIDE:") == 1


def test_sanitize_no_constraints_unchanged():
    """semantic_constraints=None → 기존 동작 보존 (override block 미append)."""
    # 외부 LLM 호출 fixture 가 없을 때 — 옵션: monkeypatch 로 stub.
    # 본 test 는 helper level 검증으로 충분 — _apply_semantic_override(prompt, None) == prompt.
    from app.modules.prompt_sanitizer import _apply_semantic_override
    sample = "any prompt body"
    assert _apply_semantic_override(sample, None) == sample
    assert _apply_semantic_override(sample, {"override_strategy_prefix": False}) == sample
```

- [ ] **Step 2: Run tests to verify FAIL**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_prompt_sanitizer_constraints.py -v
```

Expected: FAIL — `sanitize()` 의 `semantic_constraints` 인자 미존재 + `_apply_semantic_override` 미정의.

- [ ] **Step 3: prompt_sanitizer.py 확장**

`backend/app/modules/prompt_sanitizer.py` 끝에 helper 함수 + 상수 추가, 그리고 `sanitize()` method 확장.

먼저 module 상단 import 보강 (이미 있을 가능성 — 확인):
```python
from typing import Any, Dict, List, Optional   # 이미 존재 — 확인만
```

module 끝 (class 정의 밖, 또는 helper 영역) 에 추가:

```python
_SEMANTIC_OVERRIDE_MARKER = "SEMANTIC OVERRIDE:"

_STATE_GUIDANCE: Dict[str, str] = {
    "dead":             "Do not rewrite as alive, unharmed, asleep, or emotionally recovering.",
    "unconscious":      "Do not rewrite as conscious, reacting, emotionally processing, or standing.",
    "severely_injured": "Do not rewrite as unharmed, recovered, active, or standing/walking.",
    # B-next 확장: cryosleep / asleep / fainted / coma / vegetative_state / restrained / paralyzed / sedated.
    # asleep 은 B-next 에서 legit immobility state — 공통 forbidden list 에 절대 박지 않음.
}

_POSE_LOCKED_BLOCK = (
    "SEMANTIC OVERRIDE:\n"
    "Preserve the fixed pose and body position for affected subjects.\n"
    "Do not introduce a new gesture, standing pose, walking pose, active reaction, or changed limb position.\n"
    "This override takes priority over the sanitizer strategy prefix.\n"
    "Affected entities: {entity_list}."
)


def _render_semantic_constraints_block(constraints: Optional[Dict[str, Any]]) -> str:
    """user.md {semantic_constraints_block} slot 렌더. constraints=None → 빈 문자열."""
    if not constraints:
        return ""
    lines = [
        "SEMANTIC CONSTRAINTS (이 섹션이 전략 프리픽스와 충돌 시 우선합니다):",
        f"- semantic_mode: {constraints['semantic_mode']}",
    ]
    if constraints.get("source_states"):
        lines.append("- 보존 대상 entity 와 상태:")
        for sid, state in sorted(constraints["source_states"].items()):
            lines.append(f"  - {sid}: {state}")
    for key in (
        "preserve_pose", "preserve_subject_state",
        "forbid_state_polarity_rewrite", "forbid_unharmed_rewrite",
        "forbid_active_reaction",
    ):
        if key in constraints:
            lines.append(f"- {key}: {constraints[key]}")
    return "\n".join(lines)


def _build_immobilized_block(constraints: Dict[str, Any]) -> str:
    source_states = constraints.get("source_states") or {}
    lines = [
        "SEMANTIC OVERRIDE:",
        "Preserve each affected subject's immobilized state and fixed posture.",
        "This override takes priority over the sanitizer strategy prefix.",
        "",
        "Per-entity state contracts:",
    ]
    for sid in sorted(source_states):
        state = source_states[sid]
        guidance = _STATE_GUIDANCE.get(state, "Preserve the structured source state; do not rewrite.")
        lines.append(f"- {sid} ({state}): {guidance}")
    return "\n".join(lines)


def _apply_semantic_override(prompt: str, constraints: Optional[Dict[str, Any]]) -> str:
    """final sanitized_prompt 끝에 deterministic SEMANTIC OVERRIDE block append.

    중복 append 차단: scene_image_pipeline 의 moderation retry 루프 (line 261)
    는 `current_prompt = sanitize_result.get("sanitized_prompt", ...)` 로 갱신
    하므로, 다음 retry 의 sanitize() 입력 prompt 에 이미 override block 이 포함
    돼 있다. exact literal marker 기반 검색 (의미 판단 아님) 으로 기존 block
    tail 을 잘라낸 후 새 block append — retry 마다 1 block 만 유지.
    """
    if not constraints or not constraints.get("override_strategy_prefix"):
        return prompt
    mode = constraints.get("semantic_mode")
    if mode == "immobilized":
        block = _build_immobilized_block(constraints)
    elif mode == "pose_locked":
        entity_list = ", ".join(constraints.get("entity_ids") or []) or "(none)"
        block = _POSE_LOCKED_BLOCK.format(entity_list=entity_list)
    else:
        return prompt

    idx = prompt.find(_SEMANTIC_OVERRIDE_MARKER)
    if idx >= 0:
        head = prompt[:idx].rstrip()
        return head + "\n\n" + block
    return prompt.rstrip() + "\n\n" + block
```

- [ ] **Step 4: PromptSanitizer.sanitize() signature + body 확장**

`backend/app/modules/prompt_sanitizer.py` 의 `PromptSanitizer.sanitize` method 를 수정.

기존 signature (line ~90):
```python
def sanitize(
    self,
    original_prompt: str,
    block_reason: str,
    block_categories: List[str],
    attempt: int = 1,
) -> Dict[str, Any]:
```

다음으로 변경:
```python
def sanitize(
    self,
    original_prompt: str,
    block_reason: str,
    block_categories: List[str],
    attempt: int = 1,
    semantic_constraints: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
```

기존 body (line ~113-148) 의 format() 호출부 + final prompt 빌드 부분을 다음으로 변경:

```python
        system_prompt = _load_prompt("sanitize_system.md")
        user_prompt = _load_prompt("sanitize_user.md").format(
            original_prompt=original_prompt,
            block_reason=block_reason,
            block_categories=", ".join(block_categories) if block_categories else "N/A",
            attempt=attempt,
            strategy_name=strategy["name"],
            strategy_description=strategy["description"],
            strategy_prefix=strategy["prefix"],
            semantic_constraints_block=_render_semantic_constraints_block(semantic_constraints),  # 신규
        )

        result = call_structured(
            step="prompt_sanitize",
            system_prompt=system_prompt,
            user_prompt=user_prompt,
            response_schema=SANITIZE_SCHEMA,
            project_config=self._project_config,
            schema_name="prompt_sanitize",
        )

        # 기존 strategy prefix 강제 prepend (line 133~136)
        sanitized = result.get("sanitized_prompt", "")
        if not sanitized.startswith(strategy["prefix"].strip()[:40]):
            sanitized = strategy["prefix"] + sanitized

        # 신규: final sanitized_prompt 끝에 deterministic SEMANTIC OVERRIDE block append
        sanitized = _apply_semantic_override(sanitized, semantic_constraints)
        result["sanitized_prompt"] = sanitized

        # Record which strategy was used
        result["strategy"] = strategy_key

        logger.info(
            "Prompt sanitized: attempt=%d, strategy=%s, semantic_mode=%s, changes=%s",
            attempt,
            strategy_key,
            (semantic_constraints or {}).get("semantic_mode") if semantic_constraints else "none",
            result.get("changes", "")[:100],
        )

        return result
```

- [ ] **Step 5: Run tests to verify PASS**

Run:
```bash
cd backend && .venv/bin/pytest tests/unit/test_prompt_sanitizer_constraints.py -v
```

Expected: 2 passed.

- [ ] **Step 6: 기존 sanitize 테스트 회귀 확인 (semantic_constraints=None default 호환성)**

Run:
```bash
cd backend && .venv/bin/pytest tests/test_moderation.py tests/test_moderation_retry_strategies.py -v
```

Expected: 모두 PASS (semantic_constraints default None → 기존 동작 그대로).

- [ ] **Step 7: Commit**

```bash
git add backend/app/modules/prompt_sanitizer.py backend/tests/unit/test_prompt_sanitizer_constraints.py
git commit -m "feat(prompt_sanitizer): semantic_constraints + 2-layer defense

Patch B-min Task 4 — PromptSanitizer.sanitize() 에 optional
semantic_constraints 인자 추가. _render_semantic_constraints_block 으로
user prompt 의 SEMANTIC CONSTRAINTS 섹션 (Layer 1) + _apply_semantic_override
로 final sanitized_prompt 의 SEMANTIC OVERRIDE block append (Layer 2).
override block 위치 = strategy prefix 뒤 (final). 중복 append 차단 (exact
marker find/replace). _STATE_GUIDANCE per-state 분기 (dead / unconscious /
severely_injured). Test 3+helper 2 PASS. broader regression 회귀 0."
```

---

### Task 5: scene_image_pipeline pass-through + Test 4

**Files:**
- Modify: `backend/app/modules/pipeline/scene_image_pipeline.py:204` (signature) + `:260` (sanitize 호출)
- Create: `backend/tests/integration/test_scene_image_pipeline_immobilized.py`

- [ ] **Step 1: Test 4 작성 — failing test 먼저**

새 파일 `backend/tests/integration/test_scene_image_pipeline_immobilized.py`:

```python
"""Patch B-min — scene_image_pipeline 의 semantic_constraints propagation 검증.

Gemini moderation block → sanitizer 호출 시 semantic_constraints 가 동일 인자로
전달되는지만 검증. PNG metadata + LVM 은 본 patch 무관 — monkeypatch 우회.

시나리오 의존 0: synthetic identifier (C91 / dead) 만 사용.
"""
from pathlib import Path

import pytest

from app.modules.pipeline.scene_image_pipeline import generate_and_validate_scene
from app.modules.llm.gemini_image_client import ModerationError


def test_pipeline_propagates_constraints_to_sanitizer(tmp_path: Path, monkeypatch):
    captured_calls: list[dict] = []

    class FakeSanitizer:
        def sanitize(self, *args, **kwargs):
            captured_calls.append(kwargs)
            return {
                "sanitized_prompt": "sanitized output",
                "changes": "removed",
                "strategy": "film_previs",
            }

    class FakeGeminiClient:
        _attempt = 0
        def set_context(self, **kw): pass
        def generate_image(self, prompt, labeled_references=None, aspect_ratio=None):
            self._attempt += 1
            if self._attempt == 1:
                raise ModerationError(block_reason="SAFETY", block_categories=["violence"])
            # PNG metadata embed 가 monkeypatch 되므로 dummy bytes 안전.
            return (b"\x89PNG\r\n\x1a\n_dummy_png_bytes_for_test_", None)

    # PromptSanitizer 클래스 자체를 source module 에서 monkeypatch
    # (scene_image_pipeline.py:241 는 function-local import 이므로 source module 패치가 정확)
    monkeypatch.setattr(
        "app.modules.prompt_sanitizer.PromptSanitizer",
        lambda *a, **kw: FakeSanitizer(),
    )
    # PNG metadata embed 우회 (본 patch 무관)
    monkeypatch.setattr(
        "app.modules.pipeline.scene_image_pipeline.embed_png_metadata",
        lambda *a, **kw: None,
    )
    # LVM validation skip (본 patch 무관)
    monkeypatch.setattr(
        "app.modules.pipeline.scene_image_pipeline._decide_scene_lvm",
        lambda *a, **kw: (False, "test_skip"),
    )

    constraints = {
        "semantic_mode": "immobilized",
        "entity_ids": ["C91"],
        "source_states": {"C91": "dead"},
        "override_strategy_prefix": True,
        "preserve_pose": True,
        "preserve_subject_state": True,
        "forbid_state_polarity_rewrite": True,
        "forbid_unharmed_rewrite": True,
        "forbid_active_reaction": True,
        "evidence": [],
    }

    generate_and_validate_scene(
        gemini_client=FakeGeminiClient(),
        t2i_prompt="prompt",
        beat_title="beat",
        output_dir=tmp_path,
        semantic_constraints=constraints,
    )

    assert len(captured_calls) == 1
    assert captured_calls[0]["semantic_constraints"] == constraints
    assert captured_calls[0]["attempt"] == 2                       # moderation 1차 fail → sanitize attempt=2


def test_pipeline_no_constraints_default(tmp_path: Path, monkeypatch):
    """semantic_constraints 미전달 → sanitizer 도 None 으로 받음 (기존 동작 보존)."""
    captured_calls: list[dict] = []

    class FakeSanitizer:
        def sanitize(self, *args, **kwargs):
            captured_calls.append(kwargs)
            return {"sanitized_prompt": "x", "changes": "", "strategy": "film_previs"}

    class FakeGeminiClient:
        _attempt = 0
        def set_context(self, **kw): pass
        def generate_image(self, prompt, labeled_references=None, aspect_ratio=None):
            self._attempt += 1
            if self._attempt == 1:
                raise ModerationError(block_reason="SAFETY", block_categories=["violence"])
            return (b"\x89PNG\r\n\x1a\ndummy", None)

    monkeypatch.setattr("app.modules.prompt_sanitizer.PromptSanitizer", lambda *a, **kw: FakeSanitizer())
    monkeypatch.setattr("app.modules.pipeline.scene_image_pipeline.embed_png_metadata", lambda *a, **kw: None)
    monkeypatch.setattr("app.modules.pipeline.scene_image_pipeline._decide_scene_lvm", lambda *a, **kw: (False, "test_skip"))

    generate_and_validate_scene(
        gemini_client=FakeGeminiClient(),
        t2i_prompt="prompt",
        beat_title="beat",
        output_dir=tmp_path,
    )

    assert captured_calls[0].get("semantic_constraints") is None
```

- [ ] **Step 2: Run test to verify FAIL**

Run:
```bash
cd backend && .venv/bin/pytest tests/integration/test_scene_image_pipeline_immobilized.py -v
```

Expected: FAIL — `generate_and_validate_scene()` 에 `semantic_constraints` 인자 없음 (`TypeError: unexpected keyword argument`).

- [ ] **Step 3: generate_and_validate_scene signature 확장**

`backend/app/modules/pipeline/scene_image_pipeline.py:204` 의 함수 signature 에 `semantic_constraints` 추가:

```python
def generate_and_validate_scene(
    gemini_client: GeminiImageClient,
    t2i_prompt: str,
    beat_title: str,
    output_dir: Path,
    reference_images: Optional[List[Tuple[str, bytes]]] = None,
    previous_scene_bytes: Optional[bytes] = None,
    trace_meta: Optional[Dict[str, Any]] = None,
    semantic_constraints: Optional[Dict[str, Any]] = None,   # 신규
) -> Dict[str, Any]:
```

그리고 line 260 부근 sanitize 호출 propagation:

```python
            try:
                sanitizer = PromptSanitizer(OpenAIClient())
                sanitize_result = sanitizer.sanitize(
                    current_prompt, exc.block_reason, exc.block_categories,
                    attempt=attempt+1,
                    semantic_constraints=semantic_constraints,      # 신규
                )
                current_prompt = sanitize_result.get("sanitized_prompt", current_prompt)
                logger.info("Sanitized scene prompt (strategy: %s)", sanitize_result.get("strategy", ""))
            except Exception:
                raise exc
```

- [ ] **Step 4: Run test to verify PASS**

Run:
```bash
cd backend && .venv/bin/pytest tests/integration/test_scene_image_pipeline_immobilized.py -v
```

Expected: 2 passed.

- [ ] **Step 5: 기존 pipeline 테스트 회귀 확인**

Run:
```bash
cd backend && .venv/bin/pytest tests/pipeline/ -v --tb=short
```

Expected: 기존 테스트 모두 PASS (semantic_constraints default None → 기존 동작 그대로).

- [ ] **Step 6: Commit**

```bash
git add backend/app/modules/pipeline/scene_image_pipeline.py backend/tests/integration/test_scene_image_pipeline_immobilized.py
git commit -m "feat(scene_image_pipeline): semantic_constraints pass-through

Patch B-min Task 5 — generate_and_validate_scene() 에 optional
semantic_constraints 인자 + Gemini moderation retry 시 PromptSanitizer.sanitize()
에 동일 인자 propagation. Test 4 (propagation + default None) PASS. pipeline
broader regression 회귀 0."
```

---

### Task 6: scene_generation_coordinator caller wiring

**Files:**
- Modify: `backend/app/services/scene_generation_coordinator.py` — `_generate_variation_in_loop` signature + caller (around line ~595~746) + line 1404 `generate_and_validate_scene` 호출.

- [ ] **Step 1: import 추가 + outer 함수에서 build_semantic_contract 호출**

`scene_generation_coordinator.py` 의 outer 함수 (`_generate_variation_in_loop` 의 caller, line ~595 영역) 안에서 `_stg` 가 결정된 직후 (line ~598 이후) build_semantic_contract 호출.

먼저 module 상단 import 영역에 추가:

```python
from app.modules.semantic_contract_router import build_semantic_contract
```

그리고 line ~598 (state_variant_sids logging 뒤) 에 contract build 추가:

```python
        # Patch B-min — moderation sanitize 시점의 polarity 보존 contract.
        # _stg (shot_staging) + still_data.render_prompt_card + visible_entities
        # 모두 이 scope 에 있음. variation 별 무관 (shot 단위) → 1 회 build.
        _semantic_contract = build_semantic_contract(
            shot_staging=_stg,
            render_prompt_card=still_data.get("render_prompt_card"),
            visible_entities=visible_entities,
        )
        _semantic_constraints = _semantic_contract.sanitizer_constraints
```

- [ ] **Step 2: `_generate_variation_in_loop` signature + 호출 site 에 semantic_constraints 추가**

line 1325~1346 의 `_generate_variation_in_loop` signature 에 `semantic_constraints` 추가:

```python
    def _generate_variation_in_loop(
        self,
        *,
        still_data: Dict[str, Any],
        var_t2i: str,
        var_theme: str,
        var_theme_label: str,
        visible_entities: list,
        labeled_refs: list,
        attached_meta: List[Tuple[str, str]],
        rpc: Optional[Dict[str, Any]],
        is_close_framing: bool,
        chain_bg_lookup: Optional[Callable[[str], Optional[str]]],
        gemini_client: Any,
        sanitizer: Any,
        validator: Optional[ImageValidator],
        scene_dir: Path,
        cached_style_context: str,
        cached_entity_text_map: Dict[str, str],
        world_guide: Dict[str, Any],
        episode_id: str,
        semantic_constraints: Optional[Dict[str, Any]] = None,   # 신규
    ) -> Optional[Dict[str, Any]]:
```

그리고 line 746~768 의 `var_executor.submit` 호출에서 `semantic_constraints=_semantic_constraints` 인자 전달:

```python
                var_futures[var_executor.submit(
                    self._generate_variation_in_loop,
                    still_data=still_data,
                    var_t2i=var_t2i,
                    var_theme=var_theme,
                    var_theme_label=var_theme_label,
                    visible_entities=visible_entities,
                    labeled_refs=labeled_refs,
                    attached_meta=attached_meta,
                    rpc=rpc,
                    is_close_framing=_is_close_framing,
                    chain_bg_lookup=chain_bg_lookup,
                    gemini_client=gemini_client,
                    sanitizer=sanitizer,
                    validator=validator,
                    scene_dir=scene_dir,
                    cached_style_context=cached_style_context,
                    cached_entity_text_map=cached_entity_text_map,
                    world_guide=world_guide,
                    episode_id=episode_id,
                    semantic_constraints=_semantic_constraints,  # 신규
                )] = (vi, var, var_t2i, var_theme, var_theme_label)
```

(기존 `var_executor.submit` 의 마지막 dict 키 line — `episode_id=episode_id,` 뒤에 추가.)

- [ ] **Step 3: line 1404 `generate_and_validate_scene` 호출에 propagate**

`_generate_variation_in_loop` 내부의 `generate_and_validate_scene` 호출 (line ~1404~1420):

```python
                pipe_result = generate_and_validate_scene(
                    gemini_client=gemini_client,
                    t2i_prompt=_current_prompt,
                    beat_title=still_data.get("beat_title", ""),
                    output_dir=scene_dir,
                    reference_images=labeled_refs if labeled_refs else None,
                    previous_scene_bytes=None,
                    trace_meta={
                        "project_id": self._project_id,
                        "episode_id": episode_id,
                        "operation_type": "scene_image_gen",
                        "scene_index": still_data.get("scene_index"),
                        "shot_index": still_data.get("shot_index") or still_data.get("_shot_index"),
                        "still_id": still_data.get("id"),
                    },
                    semantic_constraints=semantic_constraints,    # 신규
                )
```

- [ ] **Step 4: 기존 coordinator 테스트 회귀 확인**

Run:
```bash
cd backend && .venv/bin/pytest tests/services/test_scene_generation_coordinator.py -v --tb=short
```

Expected: 기존 테스트 모두 PASS (semantic_constraints default None → 기존 동작 그대로 — coordinator 가 None 을 build 한 경우 도 fall through).

Run broader:
```bash
cd backend && .venv/bin/pytest tests/services/ -v --tb=short
```

Expected: 기존 services 테스트 모두 PASS.

- [ ] **Step 5: Commit**

```bash
git add backend/app/services/scene_generation_coordinator.py
git commit -m "feat(coordinator): wire semantic_contract to scene_image_pipeline

Patch B-min Task 6 — _generate_variation_in_loop caller 에서
build_semantic_contract(shot_staging=_stg, render_prompt_card=...,
visible_entities=...) 1 회 호출 + variation 별 _generate_variation_in_loop 에
semantic_constraints 전달 + 내부 generate_and_validate_scene 호출에
propagate. moderation block 발생 시 PromptSanitizer.sanitize 가 polarity
보존 constraint 받음. coordinator broader regression 회귀 0."
```

---

### Task 7: Production grep gate + broader regression verification

**Files:** (변경 없음 — verification only)

- [ ] **Step 1: Production grep gate (작품 고유명사 zero-hit 확인)**

Run:
```bash
grep -rE '수리영|혜수|민숙|인우|금월도|두 소녀|옥탑방|조타실|갑판|P0[2-3]|P17|L17|S(11|16|26|27|28)' \
  backend/app/modules/semantic_contract_router.py \
  backend/app/modules/prompt_sanitizer.py \
  backend/app/modules/pipeline/scene_image_pipeline.py \
  backend/app/services/scene_generation_coordinator.py \
  prompts/_base/prompt_sanitizer/2.202605112104/
```

Expected: 0 hit (작품 고유명사 없음). coordinator 는 본 patch 의 신규 line 만 검사 대상이지만, 전체 파일 grep 시 기존 코드의 다른 영역에서 hit 가능 — 만약 발견 시 본 patch 신규 line 인지 확인 (신규 line 에 0 hit 면 통과). 안전한 방식은 `git diff main` 의 추가된 line 만 검사:

```bash
git diff main -- backend/app/services/scene_generation_coordinator.py | grep '^+' | grep -E '수리영|혜수|민숙|인우|금월도|두 소녀|옥탑방|조타실|갑판|P0[2-3]|P17|L17'
```

Expected: 0 line (added line 에 작품 token 없음).

- [ ] **Step 2: 본 patch 전체 새 test 4종 PASS 확인**

Run:
```bash
cd backend && .venv/bin/pytest \
  tests/unit/test_semantic_contract_router.py \
  tests/unit/test_prompt_sanitizer_constraints.py \
  tests/integration/test_scene_image_pipeline_immobilized.py \
  -v
```

Expected: 모든 test PASS (Test 1, 2, 3, helper test 2개, Test 4, default None test) — 총 ~7 case.

- [ ] **Step 3: Broader regression — sanitize / pipeline / coordinator 회귀 0**

Run:
```bash
cd backend && .venv/bin/pytest \
  tests/test_moderation.py \
  tests/test_moderation_retry_strategies.py \
  tests/pipeline/ \
  tests/services/test_scene_generation_coordinator.py \
  tests/services/ \
  -v --tb=short
```

Expected: 기존 테스트 모두 PASS. 본 patch 가 default None 호환성 보존 → 회귀 0.

- [ ] **Step 4: 전체 backend 회귀 (선택)**

전체 backend 회귀 (Patch A 의 idiom — broader regression 2616 회귀 0 까지 확인):

```bash
cd backend && .venv/bin/pytest -v --tb=short 2>&1 | tail -50
```

Expected: 본 patch 영향 받지 않는 영역 모두 PASS. 만약 fail 발견 시 본 patch 와 무관한 pre-existing fail 인지 (carry: `test_analysis_dispatch_service.py::test_select_steps_for_category_planning_doc_inclusion` 등) 확인.

- [ ] **Step 5: Codex inline review 요청 — BLOCKING 0 까지 iterate**

본 patch 의 모든 변경 사항을 Codex 에 review 요청. Patch A idiom:
- BLOCKING + IMPORTANT + MINOR 분류
- BLOCKING 0 까지 iterate (Patch A 는 3 iteration)
- IMPORTANT/MINOR 는 별도 follow-up commit OK

review 항목 focus:
- semantic_contract_router 의 SOT 경계 (LLM-produced field 만 읽는지 / regex 사용 0).
- `_apply_semantic_override` 중복 append 차단 정확성.
- prompt_sanitizer pack v2 의 `{semantic_constraints_block}` slot 이 v1 호환 path 도 깨지지 않는지 (constraints=None 시 빈 문자열).
- coordinator wiring 의 `_semantic_contract` 빌드 위치가 staging/rpc/visible 모두 안정적인 scope 인지.

- [ ] **Step 6: Manual canary — S11/14·S11/20 단건 redo-shot (사용자 검증)**

운영 episode 에서 본 patch 효과 검증:

```bash
PID=02829fe8-af47-4dda-9cfe-af9457a4cd5b
EID=fc38cf03-3863-4cdb-936a-3ef99438242c

# cookie 갱신 (필요시)
curl -s -c /tmp/theroad_cookie.txt -X POST -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123"}' \
  http://localhost:8000/api/v1/auth/login

# S11/14 단건 shot scene_detail re-run (Patch A f31dfa5 redo-shot endpoint)
curl -s -b /tmp/theroad_cookie.txt -X POST \
  "http://localhost:8000/api/v1/projects/$PID/episodes/$EID/steps/scene-detail/redo-shot?scene_index=11&shot_index=14&mode=force"

# 그 후 S11/14 의 still_id 로 단건 image regen
curl -s -b /tmp/theroad_cookie.txt -X POST -H "Content-Type: application/json" \
  -d '{"custom_prompt": null, "variation_index": null}' \
  "http://localhost:8000/api/v1/projects/$PID/stills/{S11/14_STILL_ID}/generate-image"
```

검증 기준:
- 생성된 image 의 PNG metadata 또는 server log 에서 sanitized_prompt 에 `SEMANTIC OVERRIDE:` block 존재 확인.
- 생성된 PNG 시각 검증 (Patch A canary idiom): 시신/포즈락 prop 의 자세 무결성 유지 — 살아 움직이는 자세 없음.

- [ ] **Step 7: Push** (manual canary OK 후)

```bash
git push origin main
```

---

## Self-Review

### Spec coverage

| Spec § | 영역 | Task 매핑 |
|--------|------|-----------|
| §1 Problem | 결함 정의 / root cause | (배경, plan body 없음) |
| §2 Scope B-min | in/out scope | Task 1~7 전체 영역 |
| §2.3 시나리오 의존성 0 | grep gate | Task 7.1 |
| §2.4 AC (Automated) | 4 항목 | Task 4 (1-3) + Task 5 (4) |
| §3 Contract Router | SemanticContract + build + _build_sanitizer_constraints | Task 2 + 3 |
| §4 Sanitizer Integration | sanitize() + 2 helper + prompt pack + registry 정책 | Task 1 (pack) + Task 4 (helper + sanitize body) |
| §5 Caller Wiring | scene_image_pipeline 1 site | Task 5 (pipeline signature) + Task 6 (coordinator) |
| §6 Tests | 4 자동 + manual visual QA + grep gate | Task 2 + 3 + 4 + 5 (test) + Task 7 (regression + canary) |
| §7 Out of Scope | 한 문단 | (plan 에서 명시적으로 안 함 — task 0) |
| §8 변경 파일 요약 | 9 파일 | Task 1 (pack 2) + Task 2 (router 1 + test 1) + Task 3 (router edit + test edit) + Task 4 (sanitizer 1 + test 1) + Task 5 (pipeline 1 + test 1) + Task 6 (coordinator 1) |
| §10 Patch A 관계 | f31dfa5 redo-shot | Task 7.6 manual canary |
| §11 Codex review | BLOCKING 0 까지 iterate | Task 7.5 |

전 spec coverage. gap 없음.

### Placeholder scan

- "TBD" / "TODO" / "implement later": 0.
- "Add appropriate error handling": 0.
- "Write tests for the above (without code)": 0 — 모든 test code 명시.
- "Similar to Task N": 0 — 각 task 자체 완결.
- 모호한 token / placeholder literal: pack timestamp `2.202605112104` 는 작성 시점 고정 (구현 시 변경 가능, 단 본 plan 본문에서는 일관).

### Type consistency

- `SemanticContract` dataclass field names: `primary_mode` / `tags` / `pose_locked_entity_ids` / `immobilized_entity_ids` / `source_states` / `evidence` / `sanitizer_constraints` — Task 2, 3, 그리고 spec §3.2 정합.
- `build_semantic_contract()` signature: keyword args `shot_staging`, `render_prompt_card`, `visible_entities` — Task 2, 3, 6 일관.
- `_build_sanitizer_constraints()` private — Task 2 정의 + Task 3 확장 (Rule 2 branch).
- `_render_semantic_constraints_block()` / `_apply_semantic_override()` / `_build_immobilized_block()` — Task 4 정의.
- `PromptSanitizer.sanitize()` 새 param: `semantic_constraints: Optional[Dict[str, Any]] = None` — Task 4, 5, 6 일관.
- `generate_and_validate_scene()` 새 param: 동일 — Task 5, 6.
- `_generate_variation_in_loop()` 새 param: 동일 — Task 6.

타입/이름 일관성 확인.

### 추가 발견 — 없음

Spec 5 차 Codex review 가 모든 BLOCKING + IMPORTANT 영역 cover 함. plan 은 spec 의 직접 번역.

---

## Execution Handoff

Plan 작성 완료. 저장 위치: `docs/superpowers/plans/2026-05-11-patch-b-sanitizer-polarity-implementation.md`.

**두 가지 실행 옵션**:

1. **Subagent-Driven (recommended)** — task 별 fresh subagent dispatch + 2 stage review. Patch A 도 이 방식. 빠른 iteration.
2. **Inline Execution** — 본 세션에서 task 차례로 실행 + checkpoint review. context 큰 변동 없으면 무리 없음.

선택해주세요.
