# 2026-05-17 — Area #2: State / Gaze Separation Design

**Status**: design draft (Q1-Q7 closed, plan 진입 가능)
**Date**: 2026-05-17
**Track**: Track B — Semantic Debt Roadmap, Tier 1 (cross-cutting cancer), Area #2 (G1 본체)
**Brainstorm closure memo**: 본 세션 (Q1-Q6 + Approach A' 확정)
**Roadmap reference**: `docs/superpowers/specs/2026-05-16-track-b-semantic-debt-roadmap-design.md` §5.2 (Area #2)
**Patch B-min closure 재진입 대상**: `memory/session_20260512_patch_b_implementation.md` (commit `f2682d7`)
**Area #1 closure 참조 (pattern donor)**: `memory/session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure.md`

---

## §1. Motivation

### §1.1 본질 — `gaze_target` field 의 3차원 overload

`prompts/_base/shot_staging/12.202605161204/schema.json:30` 의 단일 string field `gaze_target` 안에 의미가 다른 3개 차원이 mix 되어 있다:

1. **시선 방향 (direction)** — `camera / down / up / distant / closed` (system.md line 121 prose + schema.json line 30 description 안 mixed). `looking_away` 는 angle field enum value (line 28), gaze_target 차원 아님 — 본 area scope 외.
2. **동적 target identity (ID / name)** — 다른 인물의 canonical name (예: `"강민준"`) / 사물의 보통명사 (예: `"휴대전화"`)
3. **character state (semantic state)** — `unconscious / dead / severely_injured`

이 overload 가 7+ production consumer 의 substring/literal comparison 의 source 가 된다 (Gate 1 위반):

| Audit Row | Location | 위반 형태 |
|---|---|---|
| P001 | `backend/app/core/asset_readiness.py:435` | `if gaze in ("dead", "severely_injured", "unconscious"):` |
| P013 | `backend/app/core/steps/image_steps.py:665` | `if gaze in self.STATE_DESCRIPTIONS:` (state dict key) |
| P014 | `backend/app/core/steps/image_steps.py:638-642` | `STATE_DESCRIPTIONS = { "dead": ..., "severely_injured": ..., "unconscious": ... }` (hardcoded prose dict) |
| P015 | `backend/app/core/steps/image_steps.py:639-641` | English visual prose hardcoded ("lying motionless, pale/ashen skin...") |
| P031 | `backend/app/core/steps/detail_steps.py:251-310` | `_STATE_VARIANT_GAZE_VALUES = ("unconscious", "dead", "severely_injured")` |
| P066 | `backend/app/modules/semantic_contract_router.py:22, 95-107` | `IMMOBILIZED_GAZE = frozenset({"dead", "unconscious", "severely_injured"})` + Rule 1 `gaze_target in IMMOBILIZED_GAZE` |
| P091 | `backend/app/services/scene_reference_service.py:932, 987` | `ca.get("gaze_target", "") in ("unconscious", "dead", "severely_injured")` |
| A074 | `prompts/_base/shot_staging/.../schema.json:30` | schema description 안 enum-like values mix |
| A077 | `prompts/_base/shot_staging/.../system.md:121-123` | prose 안 `"closed", "unconscious", "dead", "severely_injured"` mix |

`shot_visibility.py:321-393` 의 Path 1 (gaze_target → visible_entity_ids structured signal) 도 같은 overload 영향. Path 1 는 본 Area #2 scope 흡수, Path 2 (Korean lexicon / 4 regex / camera_direction offscreen) 는 Area #3 scope 유지.

### §1.2 Patch B-min 재진입 의무

`semantic_contract_router` (Patch B-min, commit `f2682d7`, 2026-05-12 closure) 는 wiring 만 완료된 상태. G1 본체 (gaze_target overload) 미해결. Track B roadmap §11 closure 재분류 의무.

### §1.3 4 Gate Policy Compliance ([[feedback_llm_based_judgment]])

본 spec 은 4 gate 모두 의무 적용:

- **Gate 1 (Semantic Regex Ban)**: gaze direction / target identity / character state semantic 판단 = LLM SOT. code regex = closed-world ID (`^C\d{2,3}$` / `^P\d{2,3}$` / enum literal) 만 허용.
- **Gate 2 (Prompt Closed-List Ban)**: active prompt (shot_staging v13) 안 body-part noun / 시나리오 character name / 작품 고유명사 / 특정 사물 예시 noun list 0. 추상 intent class 만 (`<registered character>`, `<visible object>`, `<off-frame target>` placeholder).
- **Gate 3 (Structured SOT Required)**: producer (shot_staging v13) 가 3 structured field emit: `gaze_direction_kind` enum + `gaze_target_id` optional ID + `subject_state` enum.
- **Gate 4 (No Silent Fallback)**: `staging or {}`, `ca.get("gaze_target", "")`, missing=alive 암묵 fallback 모두 차단. `_DEFAULT_SENTINEL` pattern / AppError fail-fast.

---

## §2. Decision Summary (Q1-Q6 closure)

### §2.1 Q1 — `gaze_direction_kind` enum (8 entries)

```
camera
down
up
distant
closed_eyes
off_screen
looks_at_character
looks_at_object
```

**Constraints**:
- `closed_eyes` = "눈 감음" only. character state 아님 (dead/unconscious 는 Q2 subject_state)
- `off_screen` = "eyes directed outside the camera frame, no specific registered target resolved". visibility 판정 SOT 아님 (Area #3 boundary 존중)
- `looks_at_character` vs `looks_at_object` enum 분리 = LLM producer 가 target type 분류 책임 (consumer side semantic inference 차단). 단일 `looks_at_target` + consumer ID pattern 재판정 비추 (Gate 1 위반 risk).

### §2.2 Q2 — `subject_state` enum (4 entries, required)

```
alive            (default — 명시 의무)
unconscious
dead
severely_injured
```

**Constraints**:
- `required` field. "missing = alive" 암묵 fallback 금지 (Gate 4).
- `unconscious / dead / severely_injured` 3 entry = production 5+ consumer literal carry (asset_readiness / image_steps / detail_steps / semantic_contract_router / scene_reference_service) — migration blast radius 최소.
- immobility (의식 있지만 못 움직임) = 별도 차원 — `render_prompt_card.continuity_elements_used.fixed_elements[element_type=="character_state"]` (Patch B-min Rule 2) cover. 본 enum 안 합치지 않음.
- `closed_eyes` (Q1) 와 합치지 않음.

### §2.3 Q3 — `gaze_target_id` conditional required

| `gaze_direction_kind` | `gaze_target_id` |
|---|---|
| `looks_at_character` | **required** |
| `looks_at_object` | **required** |
| `camera / down / up / distant / closed_eyes / off_screen` | **omitted / null** |

**Schema enforcement**: JSON Schema `oneOf` 로 conditional pairing 구조 검증. structural validation 만 — code 가 "왜 looks_at_*인지" / "target 이 character 인지 object 인지" semantic 재판정 금지 (Gate 1).

**Field shape decision** (Q7 closed, §2.7 참조): registered short_id only — `^C\d{2,3}$` (looks_at_character) / `^P\d{2,3}$` or `^B\d{2,3}$` (looks_at_object). outlook ID O## / canonical name / Korean noun / ASCII noun fallback / arbitrary noun 모두 forbidden. non-registered target case = looks_at_* enum 사용 금지, 대신 적절한 non-target gaze_direction_kind (off_screen / distant / down / up / camera / closed_eyes) 사용.

shot_visibility consumer dispatch helper signature: `resolve_visible_character_target(target_id, visible_character_ids, id_to_name, where)` (§3.2 Module 2 참조) — structural validation only (C## shape + visible_set membership). name/noun matching + character/object 재판정 금지.

### §2.4 Q4 — shot_staging **v13 신설**

```
prompts/_base/shot_staging/
  12.202605161204/   (deprecated, v12 — Area #1 W2)
  13.<YYYYMMDDHHmm>/ (new, v13)
```

**3-way sync** (Area #1 W2 패턴 정합):
- `backend/app/core/step_manifest.py:599` — shot_staging `schema_version`: 4 → 5
- `backend/app/core/version_registry.py:33` — `"shot_staging": "2.5.0"` → `"2.6.0"`
- `backend/app/core/version_registry.py:122` — `prompt_dependency "shot_staging/v12"` → `"shot_staging/v13"`

**v13 pack 신설**:
- `system.md`: v12 base + line 115-124 gaze section 전면 rewrite (3 field 분리 + Gate 2 정합 — noun list / 시나리오 예시 0)
- `schema.json`: v12 base + `character_angles[].items` 안 `gaze_direction_kind` enum + `gaze_target_id` string + `subject_state` enum 추가, `gaze_target` field 완전 제거, `oneOf` conditional schema

**Transition policy**: production fresh-run only. DB row legacy 변환 없음. Old v12 checkpoint = schema_version mismatch → stale/fail-fast 재실행. code-side migration / fallback 모두 금지.

### §2.5 Q5 — Patch B-min 즉시 폐기

- `IMMOBILIZED_GAZE` frozenset 제거 (semantic_contract_router.py:22)
- `IMMOBILIZED_STATES` constant 신설 (helper `subject_state.py` 안 — 모든 consumer 공유 SOT)
- `Rule 1` source string 변경: `"shot_staging.character_angles.gaze_target"` → `"shot_staging.character_angles.subject_state"`
- `Rule 1` 판정 = `subject_state in IMMOBILIZED_STATES`
- `evidence_list` source key 동일 변경
- `gaze_target` field v13 schema 완전 제거 (deprecated_* / legacy_* archival field 도 금지)
- 7+ consumer literal comparison 모두 helper-based enum check 로 migration
- **shot_visibility.py:321-393 Path 1** scope 흡수: `gaze_direction_kind == "looks_at_character"` + `gaze_target_id` 만 read. character/object 재판정 X. Path 2 (Korean lexicon / 4 regex / camera_direction offscreen) = Area #3 boundary 존중 (본 area 안 변경 X).
- Patch B-min closure memo wording 정정 의무 (W9 closure 단계, `[[session_20260512_patch_b_implementation]]` + Track B roadmap §11)

### §2.6 Q6 — `subject_state.py` helper module + visual descriptor lookup

신규 helper module: `backend/app/core/subject_state.py`

```python
SUBJECT_STATES: tuple[str, ...] = ("alive", "unconscious", "dead", "severely_injured")
IMMOBILIZED_STATES: frozenset[str] = frozenset({"unconscious", "dead", "severely_injured"})

SUBJECT_STATE_VISUAL_DESCRIPTOR: dict[str, str] = {
    "dead": "<generic visual prose — current STATE_DESCRIPTIONS carry>",
    "severely_injured": "<generic visual prose — current STATE_DESCRIPTIONS carry>",
    "unconscious": "<generic visual prose — current STATE_DESCRIPTIONS carry>",
}

def is_immobilized_state(state: str) -> bool: ...
def get_visual_descriptor(state: str) -> str: ...  # alive → AppError fail-fast
```

`alive` 는 `SUBJECT_STATE_VISUAL_DESCRIPTOR` 안 안 넣음 (state variant 생성 대상 X — `get_visual_descriptor("alive")` = AppError fail-fast).

`CharacterStateVariantStep.STATE_DESCRIPTIONS` (image_steps.py:638-642) 폐기 → helper import.

descriptor wording = generic visual prose 만 (시나리오 / 장면 예시 / noun list 금지).

prompt directory 신설 비추 (drift point 추가 + closed-world enum deterministic descriptor 이미 충분).

### §2.7 Q7 — `gaze_target_id` shape (**closed**)

**Status**: spec v1 commit 후 별도 Q7 brainstorm 진행 → closed-world registered short_id only 채택. Q7 fix-up reviewed (Codex iter 4 NEEDS_REVISION_MINOR) → schema oneOf kind 별 pattern branch 보강 + 오타 fix 완료.

**결정**: `gaze_target_id` = closed-world registered short_id only.

**Allowed pattern**:
- `^C\d{2,3}$` for `looks_at_character`
- `^P\d{2,3}$` or `^B\d{2,3}$` for `looks_at_object`

**Forbidden**:
- `O##` outlook ID (Area #1 identity/reference policy 축과 섞임 차단)
- canonical character name (Korean 이름 — gaze SOT 가 lexical matching 으로 회귀 차단)
- Korean noun fallback (예: `"휴대전화"` — semantic/lexical inference 부활)
- ASCII noun fallback (예: `"phone"` — open-world lexical regex Gate 1 제한적 위반)
- arbitrary object/common noun string

**Pairing** (Q3 + Q7 종합):
- `looks_at_character` → `gaze_target_id` required, must match `^C\d{2,3}$`
- `looks_at_object` → `gaze_target_id` required, must match `^P\d{2,3}$` or `^B\d{2,3}$`
- `camera / down / up / distant / closed_eyes / off_screen` → `gaze_target_id` omitted/null

**Non-registered target 정책**:
- LLM emit 시 등록 안 된 사물/인물 보는 경우 `looks_at_character` / `looks_at_object` enum 사용 금지
- 대신 closest non-target gaze_direction_kind 선택: `off_screen` (frame 밖) / `distant` (먼 곳) / `down` / `up` / `camera` / `closed_eyes`
- production v12 의 "사물 이름 / 인물 이름" 어휘 폐기 — LLM fresh-run 적응 의무 (적응 검증 의무 — canary 단계 v13 schema 정합 emit 비율 verify)

**Dispatch helper signature** (Module 2 `gaze_direction.py`):
```python
def resolve_visible_character_target(
    target_id: str,
    visible_character_ids: set[str],
    id_to_name: dict[str, str],
    where: str,
) -> tuple[str, str] | None:
    """target_id (C##) → (sid, canonical_name) lookup.
    structural validation only: C## shape + visible_set membership.
    None = visible 밖 → consumer 가 결정 (skip or warn).
    name matching / noun matching / character/object 재판정 금지 (Gate 1)."""
```

**근거**: (a) v12 의 본질 = `gaze_target` 안 인물 이름 / 사물 이름 / 방향 / state mix — generic noun fallback 으로 일부 유지 시 same problem 잔존, (b) outlook ID O## = Area #1 identity 축, gaze = "보는 대상" 축 — 혼합 시 ID/outlook SOT 흐림, (c) lexical fallback (Korean/ASCII noun) = consumer-side semantic inference 부활 (Q3 회피 목표 위반), (d) "등록 안 된 사물 보는" 정보 손실은 `off_screen / distant` 로 충분 (이 area 목표 = code 해석 가능 structured SOT 오염 없음).

**Plan 진입 trigger 충족**: §10.1 안 "Q7 closure 완료" 의무 충족 — 본 §2.7 record update 완료 후 fix-up commit + plan 진입.

---

## §3. Architecture (4 Layer)

### §3.1 Layer 1 — Producer (shot_staging v13)

**Path**: `prompts/_base/shot_staging/13.<YYYYMMDDHHmm>/`

**Schema additions** (`schema.json` `character_angles[].items.properties`):

```json
{
  "gaze_direction_kind": {
    "type": "string",
    "enum": ["camera", "down", "up", "distant", "closed_eyes", "off_screen",
             "looks_at_character", "looks_at_object"],
    "description": "Eye direction kind. Closed-world enum — LLM judges intent class only."
  },
  "gaze_target_id": {
    "type": ["string", "null"],
    "description": "Optional. Required when gaze_direction_kind ∈ {looks_at_character, looks_at_object}; null/omitted otherwise. Shape (Q7 closed, §2.7): closed-world registered short_id only — ^C\\d{2,3}$ for looks_at_character; ^P\\d{2,3}$ or ^B\\d{2,3}$ for looks_at_object. outlook ID O## / canonical name / Korean noun / ASCII noun fallback 모두 금지. Non-registered target case 는 looks_at_* 사용 금지, off_screen/distant 등 사용. Gate 1 정합."
  },
  "subject_state": {
    "type": "string",
    "enum": ["alive", "unconscious", "dead", "severely_injured"],
    "description": "Character state — separate axis from gaze direction. 'alive' explicit; missing = schema violation."
  }
}
```

**`required` 추가**: `gaze_direction_kind`, `subject_state`. `gaze_target_id` 는 `oneOf` conditional.

**`gaze_target` field 완전 제거** (deprecated_*, legacy_* archival 모두 금지).

**oneOf conditional pairing**:

```json
{
  "oneOf": [
    {
      "properties": {
        "gaze_direction_kind": {"enum": ["looks_at_character"]},
        "gaze_target_id": {"type": "string", "pattern": "^C\\d{2,3}$"}
      },
      "required": ["gaze_target_id"]
    },
    {
      "properties": {
        "gaze_direction_kind": {"enum": ["looks_at_object"]},
        "gaze_target_id": {"type": "string", "pattern": "^(P|B)\\d{2,3}$"}
      },
      "required": ["gaze_target_id"]
    },
    {
      "properties": {
        "gaze_direction_kind": {"enum": ["camera", "down", "up", "distant", "closed_eyes", "off_screen"]},
        "gaze_target_id": {"type": "null"}
      }
    }
  ]
}
```

**system.md rewrite scope** (line 115-124):
- 기존 prose ("시선 방향 (gaze_target) — 매우 중요" + 9 enum 어휘 mix) 전면 삭제
- 신규 prose: 3 field 의 의미 + LLM emit 규칙 + Gate 2 정합 (registered character placeholder 만, 작품 고유명사 0)

**Closed-world ID regex** (Gate 1 정합, Q7 closed §2.7):
- `^C\d{2,3}$` — registered character (looks_at_character 필수 pairing)
- `^P\d{2,3}$` — registered prop (looks_at_object 선택)
- `^B\d{2,3}$` — registered background (looks_at_object 선택)

**Forbidden in v13 schema**:
- `^O\d{2,3}$` outlook ID (Area #1 boundary)
- canonical character name (Korean / English prose)
- Korean noun (예: `"휴대전화"`) / ASCII noun fallback (예: `"phone"`)
- arbitrary object/common noun string

production v12 어휘 "사물 이름" / "인물 이름" 폐기. non-registered target case = `off_screen` / `distant` 등 다른 gaze_direction_kind enum 사용.

### §3.2 Layer 2 — Helper modules

**Module 1**: `backend/app/core/subject_state.py`

```python
"""Subject state enum SOT + visual descriptor lookup.

closed-world enum only — LLM producer (shot_staging v13) emit subject_state,
consumer = enum check via is_immobilized_state / get_visual_descriptor.
regex / substring / NL fallback 금지 (Gate 1).
"""

SUBJECT_STATES: tuple[str, ...] = ("alive", "unconscious", "dead", "severely_injured")
IMMOBILIZED_STATES: frozenset[str] = frozenset({"unconscious", "dead", "severely_injured"})

SUBJECT_STATE_VISUAL_DESCRIPTOR: dict[str, str] = {
    "dead": "lying motionless, pale/ashen skin, eyes fully closed, slack facial muscles, no signs of life",
    "severely_injured": "visible bruises and cuts, bloodied areas on face or clothing, pained or grimacing expression, disheveled appearance",
    "unconscious": "eyes closed, slack facial features, limp posture, head tilted to one side",
}

def is_immobilized_state(state: str) -> bool:
    """state ∈ IMMOBILIZED_STATES check. alive / 비enum 모두 False (silent — schema 우회 fixture 안 silent miss 차단은 validate_subject_state 가 별도 cover)."""
    return state in IMMOBILIZED_STATES

def validate_subject_state(state: str, where: str) -> None:
    """state ∈ SUBJECT_STATES 검증. enum 외 값 = AppError fail-fast.
    Area #1 normalize_subject_reference_policy_items 패턴 정합 (fixture / corrupt CP / migration tool 안 silent False 차단).
    production runtime primary defense 는 schema enum validation (§5.1); 본 helper = defense in depth."""
    if state not in SUBJECT_STATES:
        raise AppError(
            "step.contract_violation.subject_state.enum",
            f"{where}: invalid subject_state={state!r}, expected one of {SUBJECT_STATES}",
        )

def get_visual_descriptor(state: str) -> str:
    """state-variant image prompt 용 visual descriptor lookup.
    alive / 비enum = AppError fail-fast (Gate 4)."""
    if state not in SUBJECT_STATE_VISUAL_DESCRIPTOR:
        raise AppError(
            "step.contract_violation.subject_state.visual_descriptor_not_defined",
            f"subject_state={state!r} has no visual descriptor (valid: {sorted(SUBJECT_STATE_VISUAL_DESCRIPTOR.keys())})",
        )
    return SUBJECT_STATE_VISUAL_DESCRIPTOR[state]
```

**Module 2**: `backend/app/core/gaze_direction.py`

```python
"""Gaze direction enum SOT + target pairing validation.

closed-world enum only — LLM producer (shot_staging v13) emit
gaze_direction_kind + gaze_target_id, consumer = enum check via helper.
regex / substring / NL fallback 금지 (Gate 1).
"""

GAZE_DIRECTION_KINDS: tuple[str, ...] = (
    "camera", "down", "up", "distant", "closed_eyes", "off_screen",
    "looks_at_character", "looks_at_object",
)

TARGET_REQUIRED_KINDS: frozenset[str] = frozenset({"looks_at_character", "looks_at_object"})

def is_target_required(kind: str) -> bool:
    """kind ∈ TARGET_REQUIRED_KINDS check."""
    return kind in TARGET_REQUIRED_KINDS

def validate_pairing(kind: str, target_id: str | None, where: str) -> None:
    """gaze_direction_kind + gaze_target_id structural validation.
    pairing rule 위반 = AppError fail-fast (Gate 4).
    code 가 'kind 가 왜 looks_at_*인지' / 'target 이 character/object 인지' 판단 금지 (Gate 1)."""
    if kind not in GAZE_DIRECTION_KINDS:
        raise AppError(
            "step.contract_violation.gaze_direction_kind.enum",
            f"{where}: invalid kind={kind!r}, expected one of {GAZE_DIRECTION_KINDS}",
        )
    target_required = is_target_required(kind)
    if target_required and not target_id:
        raise AppError(
            "step.contract_violation.gaze_target_id.missing_for_target_kind",
            f"{where}: kind={kind!r} requires gaze_target_id",
        )
    if not target_required and target_id:
        raise AppError(
            "step.contract_violation.gaze_target_id.present_for_non_target_kind",
            f"{where}: kind={kind!r} forbids gaze_target_id (got {target_id!r})",
        )
    # Q7 closure: gaze_target_id shape validation (closed-world registered short_id only)
    if target_required:
        if kind == "looks_at_character" and not re.match(r"^C\d{2,3}$", target_id or ""):
            raise AppError(
                "step.contract_violation.gaze_target_id.shape",
                f"{where}: kind=looks_at_character requires ^C\\d{{2,3}}$ (got {target_id!r})",
            )
        if kind == "looks_at_object" and not re.match(r"^(P|B)\d{2,3}$", target_id or ""):
            raise AppError(
                "step.contract_violation.gaze_target_id.shape",
                f"{where}: kind=looks_at_object requires ^(P|B)\\d{{2,3}}$ (got {target_id!r})",
            )

def resolve_visible_character_target(
    target_id: str,
    visible_character_ids: set[str],
    id_to_name: dict[str, str],
    where: str,
) -> tuple[str, str] | None:
    """target_id (C##) → (sid, canonical_name) lookup (Q7 closure dispatch helper).
    structural validation only: C## shape + visible_set membership.
    None = visible 밖 → consumer 가 결정 (skip or warn).
    name matching / noun matching / character/object 재판정 금지 (Gate 1)."""
    if not re.match(r"^C\d{2,3}$", target_id or ""):
        return None
    if target_id not in visible_character_ids:
        return None
    name = id_to_name.get(target_id)
    if not name:
        return None
    return (target_id, name)
```

**Module 분리 이유**: Q1 (gaze direction) + Q2 (subject state) 가 서로 다른 차원 (Q2 결정 시 "immobility / pose_locked 별도 layer" 강조). 통합 helper = 차원 독립성 명시 약화 + 단일 module bloat. Area #1 패턴 정합 (`subject_reference_policy.py` 단일 차원 helper).

### §3.3 Layer 3 — Consumer migration (8 site)

| Site | File | Line | 현재 | Migration |
|---|---|---|---|---|
| C1 | `semantic_contract_router.py` | 22, 95-107 | `IMMOBILIZED_GAZE` + Rule 1 `gaze_target ∈ IMMOBILIZED_GAZE` | helper `is_immobilized_state(entry["subject_state"])`; source string `shot_staging.character_angles.subject_state` |
| C2 | `asset_readiness.py` | 435 | `gaze = ca.get("gaze_target", ""); if gaze in (...)` | `state = ca["subject_state"]; if is_immobilized_state(state):` |
| C3 | `image_steps.py CharacterStateVariantStep` | 638-642, 665, 894 | `STATE_DESCRIPTIONS` dict + `gaze in STATE_DESCRIPTIONS` | helper import; `state = ca["subject_state"]; if is_immobilized_state(state): ... descriptor = get_visual_descriptor(state)` |
| C4 | `detail_steps.py` | 251-310, 305, 2255 | `_STATE_VARIANT_GAZE_VALUES = (...)` + `ca.get("gaze_target") not in _STATE_VARIANT_GAZE_VALUES` | helper import; `is_immobilized_state(ca["subject_state"])` |
| C5 | `scene_consistency_step.py` | 741-743 | `_gaze = a.get("gaze_target", "")` + legacy raw prompt line injection (`eyes→{_gaze}` overloaded field 통째 → user_prompt) | legacy `gaze_target` injection 제거 + `gaze_direction_kind` / `gaze_target_id` / `subject_state` 3 structured field 를 labeled emit (`gaze={kind}→{target_id}, state={state}` 형식). enum literal / ID literal 그대로 노출 — code semantic translation (예: `looks_at_character` → "보고 있음" prose 변환) 금지 — W6 prompt injection consumers scope |
| C6 | `scene_reference_service.py` | 932, 987 | `ca.get("gaze_target", "") in ("unconscious", "dead", "severely_injured")` | helper import; `is_immobilized_state(ca["subject_state"])` |
| C7 | `render_prompt_card.py` | 1972, 2021 (+ derivation site, plan grep verify) | docstring 안 `staging gaze_target` 언급 + state_variant_chars derivation | docstring sync; state_variant_chars derivation 도 subject_state read (실제 derivation site = plan 단계 grep verify) |
| C8 | `shot_visibility.py` Path 1 | 321-393 | `gaze_target` field direction part read | `gaze_direction_kind == "looks_at_character"` + `gaze_target_id` read. character/object 재판정 X. Path 2 (regex/lexicon) Area #3 boundary 유지 |

**dispatch helper** (Q7 final, §2.7 + §3.2 Module 2): `resolve_visible_character_target(target_id, visible_character_ids, id_to_name, where) -> tuple[str, str] | None` — C8 (shot_visibility Path 1) 에서 `gaze_target_id` (C##) → registered character canonical name lookup 시 사용. structural validation only (C## shape + visible_set membership). name/noun matching + character/object 재판정 금지 (Gate 1).

### §3.4 Layer 4 — Prompt rewrite + alignment tests

**shot_staging v13 system.md** (line 115-124 영역 + 부수 mention):
- 기존 9-token mixed enum prose 전면 삭제
- 신규 3 field 의 의미 + LLM emit 규칙 + Gate 2 정합 prose

**alignment tests**:
- `backend/tests/test_prompt_versions.py` — shot_staging hardcoded version sync (v12 → v13, schema_version 4 → 5)
- 기존 `test_shot_staging_*` test 모두 v13 schema 정합 review
- 신규 `test_subject_reference_policy_e2e.py` 패턴 정합 = `test_state_and_gaze_e2e.py` (producer → router → state variant generation e2e)

---

## §4. Data Flow

### §4.1 Producer emit (shot_staging v13)

```
LLM (gpt) → schema.json validate (oneOf conditional)
         → character_angles[i] {
             "character": "<registered name>",
             "angle": "facing_camera",
             "body_pose": "<2-5 word English>",
             "gaze_direction_kind": "looks_at_character",
             "gaze_target_id": "C03",          // required for looks_at_*
             "subject_state": "alive"          // required always
           }
```

### §4.2 Consumer read (helper-only)

```python
# 모든 consumer 공통 패턴 (예: asset_readiness)
from app.core.subject_state import is_immobilized_state

for ca in shot["character_angles"]:
    state = ca["subject_state"]               # required field, KeyError = schema violation
    if is_immobilized_state(state):
        # state variant ref 필요
        ...

# gaze direction 사용 (예: shot_visibility Path 1)
from app.core.gaze_direction import is_target_required

for ca in shot["character_angles"]:
    kind = ca["gaze_direction_kind"]
    target_id = ca.get("gaze_target_id")
    if kind == "looks_at_character" and target_id:
        # registered character lookup
        ...
```

### §4.3 Routing decision (semantic_contract_router Rule 1)

```python
# semantic_contract_router.py W3 rewrite
from app.core.subject_state import is_immobilized_state

# Rule 1
for entry in (shot_staging or {}).get("character_angles") or []:
    state = entry["subject_state"]            # required by v13 schema, KeyError = schema violation (Gate 4 fail-fast)
    if not is_immobilized_state(state):
        continue
    sid = name_to_sid.get(entry.get("character") or "")
    if not sid:
        continue
    immobilized.add(sid)
    source_state_map[sid] = state
    evidence_list.append({
        "source": "shot_staging.character_angles.subject_state",  # ← 변경
        "entity_id": sid,
        "value": state,
    })

# Rule 2 (character_state element) 보존 — 본 area scope 외
```

---

## §5. Validation / Error Handling

### §5.1 Schema validation (Gate 4 fail-fast) — Primary defense

**Primary defense — JSON Schema `oneOf`** (producer LLM emit 시점, runtime path):
- `gaze_direction_kind` enum 외 값 → schema violation
- `looks_at_character / looks_at_object` 이고 `gaze_target_id` 없으면 → schema violation
- 비target kind 이고 `gaze_target_id` 있으면 → schema violation
- `subject_state` enum 외 값 → schema violation
- `subject_state` 누락 → schema violation (`required`)

### §5.2 Helper guard (Gate 4)

- `is_immobilized_state(state)` — state 가 enum 외 값이어도 `False` 반환 (consumer side fail-fast 는 schema 단에서 처리)
- `get_visual_descriptor(state)` — state ∉ `SUBJECT_STATE_VISUAL_DESCRIPTOR` 면 `AppError("step.contract_violation.subject_state.visual_descriptor_not_defined")` fail-fast
- `validate_pairing(kind, target_id, where)` — **defense in depth** (schema 우회 경로 — test fixture 수동 emit / migration tool / 단위 test 등). production runtime 의 primary defense 는 §5.1 schema `oneOf`. helper validate_pairing 는 fixture 작성 / 단위 test side 안전망. pairing rule 위반 시 `AppError` fail-fast
- `validate_subject_state(state, where)` — schema 우회 경로의 explicit subject_state enum 검증 (fixture / corrupt CP / migration tool). Area #1 `normalize_subject_reference_policy_items` 패턴 정합. enum 외 값 = AppError fail-fast (Gate 4). production runtime primary defense 는 §5.1 schema enum validation; `is_immobilized_state` 가 silent False 반환하는 경로 (fixture 우회) 안 silent miss 차단

### §5.3 Consumer migration safety

- 모든 consumer 가 `ca["subject_state"]` 직접 read (Gate 4 — `.get("subject_state", "alive")` 같은 silent default 금지). schema required 라 missing = AppError.
- shot_visibility Path 1: `ca["gaze_direction_kind"]` 직접 read. silent default 금지.
- shot_visibility Path 2 (Korean lexicon / regex) = Area #3 boundary — 본 area 안 변경 X. drift detector 의 false-positive 가능성은 Area #3 spec 에서 처리.

---

## §6. Removal Scope

### §6.1 Code symbols to remove (W3-W7)

| Symbol | Location | Wave |
|---|---|---|
| `IMMOBILIZED_GAZE` frozenset | semantic_contract_router.py:22 | W3 |
| `Rule 1` source string `"shot_staging.character_angles.gaze_target"` | semantic_contract_router.py:104 | W3 |
| `STATE_DESCRIPTIONS` dict | image_steps.py:638-642 | W4 |
| `CharacterStateVariantStep` 안 `gaze_target` read | image_steps.py:665, 894 | W4 |
| `_STATE_VARIANT_GAZE_VALUES` tuple | detail_steps.py | W5 |
| `ca.get("gaze_target", "")` 패턴 | 모든 consumer 8 site | W3-W7 |
| `_g41_*` 또는 기타 gaze_target dependent helper | plan 단계 grep verify 후 wave 결정 (Area #1 W7 cascade scope mismatch lesson 정합 — `[[session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure]]` lesson 2) | W5-W6 (조정 가능) |

### §6.2 Prompt content to remove (W2)

- `prompts/_base/shot_staging/12.202605161204/system.md:115-124` (gaze_target 매우 중요 section + 9 enum 어휘 mix)
- `prompts/_base/shot_staging/12.202605161204/schema.json:30` (gaze_target description)
- 부수 mention (system.md 다른 곳에 `gaze_target` reference 있으면) — grep 후 결정

v12 directory 자체는 archive (rm X) — v13 신설로 latest pick 자동 전환.

### §6.3 Test cleanup (W8)

- 기존 `gaze_target` literal compare test 모두 `subject_state` enum check 로 migration
- 기존 `IMMOBILIZED_GAZE` test → `IMMOBILIZED_STATES` (helper) test
- `STATE_DESCRIPTIONS` test → helper `SUBJECT_STATE_VISUAL_DESCRIPTOR` test
- 신규 fixture: shot_staging v13 shape (3 field) 으로 모든 staging fixture 갱신
- 폐기 후보: `gaze_target` field 가 string mixed 형식인 test (모두 v13 정합 fixture 로 갱신, 폐기 marker 는 cascade 정합 시 결정)
- 신규 test: `test_state_and_gaze_e2e.py` (producer → router → state variant generation e2e)
- 신규 unit: `test_subject_state.py` + `test_gaze_direction.py` (W1 helper 검증)

---

## §7. Testing / Closure

### §7.1 Wave-by-wave gates

W1: helper unit tests PASS, `is_immobilized_state` / `get_visual_descriptor` / `is_target_required` / `validate_pairing` 모두 cover.

W2: shot_staging v13 latest pick PASS (`test_prompt_versions.py` 갱신), oneOf schema validation PASS, fresh-run canary PASS.

W3: semantic_contract_router rewrite — Rule 1 source string 변경 verify, Rule 2 무영향 verify, evidence shape 보존, helper import.

W4: state-variant generation — image_steps unit + e2e PASS, STATE_DESCRIPTIONS 제거 verify, descriptor lookup 동일 prose 출력.

W5: scene ref + asset readiness + detail state mirror — 4 site migration PASS, state_variant ref 생성 PASS.

W6: prompt injection consumers — detail_steps prose 안 raw `gaze_target` substitution 제거, enum-based prose 출력.

W7: shot_visibility Path 1 — `gaze_direction_kind == "looks_at_character"` 만 read PASS, Path 2 무영향 verify (Area #3 boundary).

W8: full test cascade — 모든 fixture v13 정합, residue gate 모두 0 hits, baseline regression 0.

W9: closure — memo + Track B roadmap §5.1 (Area #1 carry 정합) + §11 (Area #2 closed 재분류) + Patch B-min memo wording 정정 + Codex range review APPROVED + push gate.

### §7.2 Residue gates (E1/E2/E3/D4 pattern, Area #1 정합)

| Gate | 의도 | 0 hits 의무 |
|---|---|---|
| E1 (production code identifier) | legacy `gaze_target` field read + 폐기 constants (`IMMOBILIZED_GAZE`, `STATE_DESCRIPTIONS`, `_STATE_VARIANT_GAZE_VALUES`) 모두 0. 신 field `gaze_target_id` 제외 (word-boundary regex) | W3-W7 모두 완료 후 |
| E1b (prose residue) | gaze + state literal proximity prose 0 | 동일 |
| E2 (active prompt residue) | v13 prompt 안 legacy `gaze_target` field 0 (신 field `gaze_target_id` 제외) | W2 후 |
| E3 (literal cascade) | exact key residue (`"gaze_target"` / `get("gaze_target")` / `shot_staging.character_angles.gaze_target`) 0 | W7 후 |
| D4 (hardcoded version live assertion) | shot_staging v12 hardcoded sites | W2 + W8 |

E1 의 exception: `_archive/`, historical archival test (W8 archival pattern, docstring 명시), v12 directory 자체.

#### §7.2.1 Residue gate 명령 (raw bash, copy-paste 가능)

⚠️ markdown table cell 안 `\|` escape 시 GNU grep `-E` 모드 안 alternation 미동작 (verified 2026-05-17, spec brainstorm iter 2). 아래 명령은 code block (escape 무관) — 사용 시 그대로 copy-paste.

```bash
# E1 production code identifier — legacy gaze_target + 폐기 constants 0 hits
grep -rEn '\bgaze_target([^_a-zA-Z0-9]|$)|\bIMMOBILIZED_GAZE\b|\bSTATE_DESCRIPTIONS\b|\b_STATE_VARIANT_GAZE_VALUES\b' backend/app/

# E1b prose residue — gaze + state literal proximity (BRE alternation \| OK)
grep -rn 'gaze.*dead\|gaze.*unconscious\|gaze.*severely_injured' backend/app/

# E2 active prompt residue — v13 directory only, 신 field gaze_target_id 제외
grep -rEn '\bgaze_target([^_a-zA-Z0-9]|$)' prompts/_base/shot_staging/13.*

# E3 literal cascade — exact key residue (신 field 제외)
grep -rEn '"gaze_target"([^_]|$)|get\("gaze_target"\)|shot_staging\.character_angles\.gaze_target' backend/
```

검증: stdin test (`echo 'gaze_target_id' | grep -E ...`) 결과 — 신 field `gaze_target_id` 매치 X, legacy `ca.get("gaze_target")` + `IMMOBILIZED_GAZE` 매치 O.

### §7.3 Production canary

fresh-run canary (custom_prompt 또는 default scenario) 실행:
- shot_staging v13 schema 안 3 field emit 확인 (LLM 적응 verify)
- state-variant reference image 생성 OK (CharacterStateVariantStep dead/unconscious/severely_injured 3 case)
- shot_visibility Path 1 looks_at_character + gaze_target_id 정합 사례 verify
- semantic_contract_router Rule 1 immobilized 진단 정합 (Patch B-min canary 패턴 정합 — `[[session_20260512_patch_b_implementation]]` canary)

### §7.4 Closure criteria

- 모든 9 wave 완료
- 모든 residue gate 0 hits
- Codex W1-W9 range review APPROVED
- pre-existing baseline 외 신규 fail 0
- Track B roadmap §5.1 (Area #1 carry 정합 reconfirm) + §11 (Area #2 closed 재분류)
- Patch B-min memo wording 정정 (`[[session_20260512_patch_b_implementation]]`)
- 사용자 명시 push 승인

---

## §8. Track B Roadmap §5.2 / §11 Wording 정정 (cross-ref)

### §8.1 Roadmap §5.2 의 본 spec 정합

Roadmap §5.2 carry note 와 본 spec 의 차이:
- §5.2 line 308 enum 후보: `closed / off_screen / looks_at_entity / looks_at_object` → 본 spec Q1 결정: `closed_eyes` (rename) + `looks_at_character` (rename — Codex 정합) + `looks_at_object`. `entity` 어휘 모호함 제거.
- §5.2 line 310 subject_state 후보: `alive_active / alive_immobilized / unconscious / dead / severely_injured` → 본 spec Q2 결정: `alive / unconscious / dead / severely_injured` (4 entries — alive_immobilized 폐기, immobility 별도 차원).

W9 closure 단계 roadmap §5.2 wording amend 의무.

### §8.2 Roadmap §11 (Closed Area 재분류)

W9 closure 시 §11 update:
- Area #2 = closed (commit `<W9 closure commit hash>`, `[[session_20260517_area_2_state_gaze_separation_closure]]`)
- Patch B-min closure 재분류 row update — "Area #2 (G1) 의 일부, 재진입 완료"

### §8.3 Patch B-min closure memo wording 정정

`memory/session_20260512_patch_b_implementation.md` W9 closure 시 fix-up:
- "Patch B-min closed" → "Patch B-min wiring closed, G1 본체 (gaze_target overload) Area #2 에서 해결 완료 (commit `<W9>`)"
- IMMOBILIZED_GAZE / Rule 1 source 변경 record

---

## §9. Related References

### §9.1 Audit row evidence

- Production code: P001, P013, P014, P015, P031, P066, P091
- Prompt: A074, A077
- File: `backend/tests/_audit_outputs/semantic_string_debt/full_20260516_0105/triage_report.md`

### §9.2 Memory cross-ref

- 진입점: `[[next_session_area_2_state_gaze_separation_brainstorm]]`
- Track B roadmap closure: `[[session_20260516_track_b_roadmap_closure]]` (§5.2 + §11)
- Area #1 closure (pattern donor): `[[session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure]]`
- Patch B-min closure (재진입 대상): `[[session_20260512_patch_b_implementation]]`
- 4 gate policy: `[[feedback_llm_based_judgment]]`
- Subagent dispatch: `[[feedback_subagent_model_opus]]`
- Audit entry policy: `[[feedback_audit_analysis_vs_spec_entry]]`

### §9.3 Closed area boundary (do-not-touch)

- `framing_scale.py` (C-2 closed)
- `keep_elements.py` (Area D-next-min closed)
- `frame_spatial_contract.py` (closed)
- `render_contracts visual_identity` (Area B-min closed)
- Area #1 `subject_reference_policy` (방금 closed — `render_prompt_card.id_policy` 4 sub-field + scene_detail v25 + scene_extractor_v2 v19 + shot_staging v12 → 본 area v13 신설 시 v12 deprecated)

Area #3 (shot_visibility, G3 본체) = 별도 Area, 본 spec scope 외. shot_visibility Path 1 만 본 area scope 흡수.

---

## §10. Implementation 단계 진입 조건

### §10.1 Plan 단계 진입 trigger

- 본 spec 사용자 review 완료 + 명시 승인
- **§2.7 Q7 closure 완료** ✅ (gaze_target_id shape = closed-world registered short_id only, fix-up commit 완료)
- writing-plans skill 단독 invoke (다른 implementation skill 금지)
- Wave 별 plan = atomic task (`task plan + reviewer + Codex iter + commit`)

### §10.2 Area #1 carry lesson 적용 (8개)

W1-W9 wave 진행 시 적용 의무:

1. **fix-up cascade pattern**: 매 wave Codex iter 1-2 (NEEDS_REVISION → fix-up → APPROVED) 통상. atomic + small fix-up commit 정책.
2. **W8 sub-commit 분할 (Codex 권장)**: cascade pattern 단위 — file 단위 아닌 의미 단위. Area #2 W8 도 fixture / rename / sub-field / version sweep 별 sub-wave 가능.
3. **hunk-split for mixed working tree**: 단일 file (test_g4_5a_spatial_integration.py 패턴) 의 mixed changes 를 sub-commit 으로 분산.
4. **paired-drift test 폐기 정당**: rename + literal value 양쪽 폐기 시 paired-drift 차단 의미 상실 → 폐기 marker.
5. **historical archival test 분리**: 이전 prompt version 검증 test (v12 archival) 는 docstring 에 "ARCHIVAL NOTICE" 명시.
6. **W8 scope 확장 (plan 명시 외 cascade carry)**: closure 조건 "신규 fail 0" 이 scope 확장 정당화.
7. **W9 roadmap final shape vs brainstorm 초안 mismatch**: W9 closure 단계 roadmap §5.2 wording amend 의무.
8. **W2 timestamp / shape 정확한 closure memo wording**: closure memo 시 actual prompt directory + schema 위치 + helper signature 정확히 record.

### §10.3 Subagent / Codex / Push policy 재확인

- 모든 subagent dispatch: `model=opus` (Opus 4.7) 강제 ([[feedback_subagent_model_opus]])
- Codex external review: 사용자 manual paste 패턴
- Push: 사용자 명시 후만 (Codex W1-W9 range review APPROVED 후 안전)
- 4 gate 정합 모든 wave 적용 ([[feedback_llm_based_judgment]])

### §10.4 Sequential ordering ([[session_20260516_track_b_roadmap_closure]] §8.1)

- 사용자 결정: Tier 1 Sequential (#1 → #2 → #3)
- Area #2 = 본 spec, Area #3 다음 area (별도 spec/plan)
- 절대 동시 진행 금지

---

## End of design draft

Plan 작성 = writing-plans skill 단독 invoke. 본 spec 사용자 승인 후만.
