# Deterministic bg_id & Cross-Step Catalog Lineage — Design Spec (D6)

**Date**: 2026-05-09
**Status**: DRAFT_R3.1 — APPROVED_FOR_PLAN_DRAFTING (R2 dual review NEEDS_REVISION 적용 — N1 §4.7 intent audit 전면 폐기 (phantom defect — scene_detail LLM 은 bg_id 출력 안 함, ctx.chain_bg_id_by_shot 그대로 소비) / N2 space_profile 위치 변경 → `entity_canon.metadata_json` 새 column / N3 hash 분리 (`bg_catalog_hash` + `shot_binding_hash`) + consumer 별 consumed table / N4 `custom_prompt` entry preflight 의무, ref-builder 개편 out-of-scope / N5 `_SAFE_ID_RE` 분리 + I1~I5 + M1~M7 + A1~A5 적용. R3.1 cleanup 3건 — §4.4 enum prompt 위치 master_plan only / OQ-8 scene_detail 제외 / §4.8 HTTP 응답 예시 통일)
**Author**: Claude (Opus 4.7) + 사용자 directed brainstorming + R1/R2 review
**Trigger**: D5 push (commit `b4de4c8`) 후 S8 단건 canary 가 422 ref_contract.violation 으로 차단 → 진단에서 (a) `background_render` stale + (b) 단건 path still_data 결손 두 결함 분리. (b) 는 D5 single path wiring fix 로 해소 (commit `699a41c`). (a) 는 force re-run 으로 임시 복구되었으나 **근본 결함 = LLM-generated bg_id 의 nondeterminism + cross-step lineage 부재**. D5 의 fail-fast 가 처음으로 노출. 재발 방지를 위한 system-level 설계.

---

## §1. Background

### 1.1 노출 사고 (2026-05-09)

PID `80f62523` / EID `9a5e0862` 의 `background_master_plan` 이 6 회 재생성됨 (5/6 21:30 ~ 5/7 15:43). 매 재생성마다 같은 semantic background (마트 sales floor, dusk) 에 대해 **LLM 이 다른 bg_id 를 생성**:

| timestamp | 생성된 bg_id |
|---|---|
| 5/6 21:30/22:14/22:45 | `bg_sales_floor_dusk_normal` |
| 5/7 14:40 | `bg_sales_floor_dusk_staff_exit_direction` |
| 5/7 15:42 | `bg_store_interior_dusk_open` |
| 5/7 15:43 (latest) | `bg_store_sales_floor_dusk_busy_exit_visible` |

→ 6 archive / **4 종 ID drift**. `applies_to_shots`/`sub_location`/`state_label` 셋 다 LLM free-form 으로 변동.

### 1.2 cascade 결손

`background_master_plan` 5/7 15:43 갱신 후:
- `background_prompt` 17:37 cascade 됨 (latest bg_id picked up) ✓
- **`background_render` 는 14:40 archive 만 보유, manifest.json 부재** ✗
- `scene_detail` 5/8 17:53 latest bg_id 박음 (`bg_store_sales_floor_dusk_busy_exit_visible`) — RPC required_refs 에 명시

→ scene_detail 이 RPC 에서 요구하는 bg_id 와 background_render 가 산출한 bg_id 가 **세대 불일치**. D5 의 (kind, id) exact match validator 가 422 차단.

### 1.3 결함 라인 (코드 인용 + verify)

| # | Defect | Evidence |
|---|---|---|
| **D6-1** | LLM 이 `bg_id` 를 free-form 으로 생성. 같은 semantic 에 안정 ID 보장 X | master_plan prompt 가 ASCII snake_case + unique 만 강제. catalog/normalize 부재 |
| **D6-2** | `_config_hash` 가 upstream content 변경 미감지 | `background_render_step.py:70-77` — `background_mode + schema_version + prompt_version` 만. master_plan 의 bg_id 카탈로그 변경이 자체 hash 에 반영 안 됨 |
| **D6-3** | cross-step lineage 검증 부재 | master_plan/prompt/render/scene_detail 사이 `bg_catalog_hash` 또는 `master_plan_run_id` 전파 없음. 세대 mismatch 자동 감지 불가 |
| **D6-4** | `scene_checkpoint_loaders` 가 archive 미열람 | `scene_checkpoint_loaders.py:30-35` `_ep_checkpoint_path` 가 `manifest.json` 만. step_runner 의 archive auto-restore 매커니즘 일관 적용 X |
| **D6-5** | 422 메시지가 stale upstream 인지 binding mismatch 인지 분간 어려움 | `ref_contract_validator.py:273-278` — "required background X missing" 만. 운영자 진단 비용 큼 |
| **D6-6** | ~~`scene_detail` LLM 도 bg_id 자유 생성~~ — **R2 review B2 / R3 N1 폐기**: scene_detail LLM 은 bg_id 출력 안 함. `detail_steps.py:432` 의 `ctx.chain_bg_id_by_shot` 로 코드가 이미 부여 중. master_plan 만 deterministic 하면 scene_detail 자동 정합 — 새 audit 매커니즘 불필요 |

### 1.4 drift 4 원본 분석

| 원본 | 변동 예 | normalization 필요성 |
|---|---|---|
| `sub_location` | `sales_floor` / `store_interior` / `store_sales_floor` | ★ 필수 — 같은 공간 다른 단어 |
| `state_label` | `dusk_normal` / `dusk_staff_exit_direction` / `dusk_busy_exit_visible` | ★ 필수 — 자유 문장 |
| `time_phase` | `dusk` (일관) | enum 자연 안정 |
| `applies_to_shots` | `[S8_Shot4]` / `[S8_Shot4, S8_Shot5]` | metadata only — key 제외 |

→ semantic_key 에 raw `sub_location`/`state_label` 그대로 넣으면 drift 가 key 로 옮겨감. **둘 다 normalize 의무**.

---

## §2. Goals + Principles

### 2.1 Goals

1. **deterministic bg_id** — 같은 semantic 에 대해 master_plan 재실행마다 동일 ID. LLM 이 ID 자체를 만들지 않음.
2. **cross-step lineage detection** — master_plan 갱신 시 downstream step 의 stale 상태 자동 감지. silent stale path 차단.
3. **operator-facing STALE_UPSTREAM error** — stale upstream 인 경우 422 ref_contract.violation 이 아니라 명확한 `STALE_UPSTREAM` 으로 노출. force re-run 결정 즉시.
4. **legacy migration** — 기존 longform `bg_*` ID episode 는 force re-run 으로 새 ID 재발급. compatibility map 안 만듦.

### 2.2 Principles (사용자 binding)

- **P1**. LLM 은 설명을 만든다. 코드는 ID 와 key 를 만든다. downstream 은 코드가 만든 ID 만 참조한다.
- **P2**. semantic_key 에 `applies_to_shots` 제외. shot grouping 변동에 ID 흔들리지 않음.
- **P3**. raw `sub_location` 는 key 에 들어가지 않음. `space_key` 로 normalize 후 key 사용.
- **P4**. raw `state_label` 도 key 에 들어가지 않음. `state_class` (controlled vocab enum) 로 normalize 후 key 사용. enum 밖이면 fail-fast (nearest-match 아님).
- **P5 (R3 N1 단순화)**. `scene_detail` 의 LLM 은 background_binding 출력 안 함 — 기존 `ctx.chain_bg_id_by_shot` chain 그대로 (코드가 이미 부여). master_plan 만 deterministic 하면 자동 정합. 새 LLM 출력 schema 도입 0.
- **P6**. catalog scope = episode-level. 같은 location 도 episode 간 ID 별도 (ID conflict 방지).

---

## §3. Non-goals

- **cross-episode ID 일관성**. episode 간 같은 (loc, space_key, state_class) 가 같은 ID 가질 필요 없음. episode-local 결정론만 보장. (※ A5: `EntityCanon.short_id` 는 project-scoped 라 두 episode 가 같은 `L09B01` 을 가지면서도 다른 semantic_key 일 수 있음 — episode-local consumer 만 처리하므로 conflict 없음.)
- **legacy compatibility map**. 기존 longform `bg_*` ID episode 는 force re-run 으로 새 ID 발급. 두 형식 동시 지원하는 dispatcher 분기 안 만듦.
- **embedding-based fuzzy clustering**. nearest-match / similarity 매칭 은 nondeterministic 위험. enum strict 만.
- **state_class enum 의 사용자 노출 UI**. enum 은 코드 SOT. UI 노출은 후속.
- **floor_plan ID (`fp_*`) 결정론화**. 별도 후속 spec 후보. D6 의 `bg_catalog_hash` 는 fp 영향 없음 (M4).
- **`custom_prompt` path 의 ref-builder 개편 (N4)**. `scene_image_service.py:655` 의 `build_custom_labeled_refs` 는 chain_bg/prev_shot/state_variant 미주입 — operator-explicit override. 단, **entry preflight (§4.8) 는 통과 의무** (catalog hash freshness 검증). custom_prompt path 의 ref schema 보강은 별도 spec.
- **`floor_plan_render` 의 lineage stamp (A3)**. fp_id 가 D6 외 라 `consumed_bg_catalog_hash` stamp 대상 아님. master_plan 의 `fp_id` 가 stale 인 경우는 별도 후속 spec.

---

## §4. Design

### §4.1 bg_id 포맷

**규칙**: `L{N}B{N}` — `f"L{loc_num:02d}B{var_num:02d}"` (최소 2 자리 zero-pad, 100 이상은 자연 확장).

```
L09B01      # 99 까지 zero-padded 2자리
L09B02
L04B01
L113B07     # 100+ location 자연 확장 (zero-pad 무영향)
L09B100     # 100+ variant 도 동일
```

- `L09`: 기존 `loc_id` (entity catalog 의 location short_id, project-scoped).
- `B01`: 해당 location 안의 background variant 번호 (1-based, episode-local).
- LLM 이 만들 수 없는 형식 — code-assigned bg_id 전용 validator 가 **`^L\d{2,3}B\d{2,3}$`** (M5) 강제. 그 외 형식은 fail-fast.
- **Invariant**: entity catalog 의 location `short_id` 가 `^L\d{2,3}$` 형식 보장 의무. entity_extractor 단계에서 enforce.

**번호 부여**:
- master_plan 후처리에서 코드가 부여 (algorithm 의 ordering 은 §4.5).
- 같은 episode 안에서 새 semantic_key 가 등장한 순서대로 `B01, B02, ...`.
- 기존 catalog 에 있는 semantic_key 는 기존 번호 재사용 (재실행 안정성).
- pad 함수 `_format_bg_id(loc_num, var_num)`: 두 숫자 모두 2 자리 zero-pad. ≥100 일 때 자연 확장 (`f"{n:02d}"` 가 `113` 같은 ≥3 자리 자연 출력).
- **A5 명시**: `EntityCanon.short_id` 는 project-scoped 이므로 두 episode 가 같은 `loc_id="L09"` 를 공유. 두 episode 의 catalog 가 모두 `L09B01` 을 발급할 수 있고, semantic_key 는 다를 수 있음 — episode-local consumer 가 catalog 를 episode 단위로만 read 하므로 conflict 없음.

### §4.2 semantic_key 구성

**규칙**: `loc_id|space_key|time_phase|state_class`

```
L09|main|dusk|busy_exit
```

- `loc_id`: 기존 안정 ID (entity_canon 의 location id)
- `space_key`: code-normalized 공간 key (다음 §4.3 참고)
- `time_phase`: enum {`dawn`, `morning`, `day`, `dusk`, `night`}
- `state_class`: controlled vocab enum (다음 §4.4 참고)

**제외**:
- `applies_to_shots` — metadata 만. shot grouping 변동에 키 안 흔들림.
- `state_label_raw` (LLM 이 만든 풍성한 텍스트) — metadata 만.
- `sub_location_label` (LLM raw) — metadata 만.

### §4.3 sub_location → space_key normalization (R3 N2 — 위치 변경)

**핵심 원칙**: lowercase/underscore 만으로는 `"store sales floor"` / `"sales floor"` / `"store_interior"` 같은 다른 단어 표현을 같은 공간으로 묶지 못함. SOT 는 **entity catalog 의 location 이 갖는 `space_profile`**.

**Schema — `EntityCanon.metadata_json` 새 column (R3 N2)**:

R2 의 `EntityCanon.stable_traits` 안 dict 추가는 writer 계약 충돌 (R2 review B1). `entity_sync_service.py:132,146` 가 항상 `json.dumps(visual_traits)` 로 list 만 write — dict subkey 추가 시 12+ writer/reader atomic migration 필요. **R3 결정**: `EntityCanon` 에 새 column `metadata_json TEXT default '{}'` 추가.

**Migration (D6 prerequisite — 단계 §6 T0)**:
```sql
ALTER TABLE entity_canon ADD COLUMN metadata_json TEXT DEFAULT '{}';
```

**Shape (location entity 의 metadata_json)**:
```json
{
  "location": {
    "space_profile": {
      "kind": "single_space",
      "allowed_space_keys": ["main"]
    }
  }
}
```

또는 multi:

```json
{
  "location": {
    "space_profile": {
      "kind": "multi_space",
      "allowed_space_keys": ["main", "kitchen", "rooftop", "stairs", "yard", "exterior"],
      "default_space_key": "main"
    }
  }
}
```

**기존 `stable_traits` 영향 0** — list 계약 그대로. 새 column 만 추가, 기존 writer/reader (`entity_sync_service.py:132,146` / `entity_steps.py:776,796` / `t2i_visual_converter.py:80-88` 등) 변경 없음.

**writer/reader 추가**:
- writer (1 곳 신설): entity_extractor 후처리 — location entity 만 `metadata_json.location.space_profile` 채움. character/prop entity 는 `metadata_json={}` (기본값).
- reader (D6 internal): master_plan 후처리 (§4.3 normalization 시 location 의 `metadata_json` JSON parse).
- 기존 entity consumer 들은 `metadata_json` 미참조 → 영향 0.

entity_extractor 단계에서 LLM 이 location 의 space_profile 을 결정 — 새 location 마다 1 회 결정 후 stable.

**LLM 출력 (master_plan)**:
```json
{
  "loc_id": "L09",
  "space_key_hint": "main",
  "sub_location_label": "store sales floor"
}
```

- `space_key_hint`: location 의 `allowed_space_keys` 중 하나 (enum 강제, prompt 에 inject).
- `sub_location_label`: free-form (한국어 OK, 사람 읽기용 metadata).

**code normalization**:
```python
def normalize_space_key(loc_id: str, hint: str, profile: dict) -> str:
    if profile["kind"] == "single_space":
        return "main"  # hint 무시 — 강제 main
    allowed = set(profile["allowed_space_keys"])
    if hint in allowed:
        return hint
    raise SemanticKeyError(
        f"space_key {hint!r} not in {sorted(allowed)} for {loc_id}"
    )
```

**규칙**:
- single_space location 은 hint 와 무관하게 항상 `main`. LLM 이 `store_sales_floor`/`sales_floor`/`매장` 어떤 단어 적어도 `main` 으로 normalize.
- multi_space location 은 enum 밖 hint = fail-fast (nearest-match 안 함).
- `(loc_id, space_key)` 1:1 — 같은 공간을 다른 단어로 부르면 같은 space_key.
- raw `sub_location_label` 은 **catalog metadata only**. semantic_key 에 절대 안 들어감.

**Prerequisite**: D6 도입 시 모든 active episode 의 location entity 에 `space_profile` 채우기 — entity_extractor 출력 schema 변경 + 기존 location 일괄 backfill (force re-run 또는 1회 migration script).

### §4.4 state_class controlled vocab

**enum** (초기, 작게):
```
normal
quiet
busy
busy_exit
ransacked
clean_after
blood_scene
intrusion
arrival
evidence_display
dream_or_vision_state
```

(M2 R3 — `vision_or_hallucination` 에서 명칭 변경 — 기존 `SceneStill.hallucination_entity_ids` / `audio_entity_ids` 같은 entity-level field 와 의미 충돌 회피.)

**LLM 출력 형식**: master_plan prompt 에 enum 명시 + 강제 (R3.1 — N1 정합. scene_detail prompt 변경 0).

```
"state_class": "busy_exit"
"state_label_raw": "dusk busy with employee pointing toward exit"  // metadata only
```

**code 검증**:
- enum 밖이면 **fail-fast** (nearest-match 안 함). 운영자가 enum 추가 또는 prompt 수정 결정.
- `unknown` 도 금지.
- enum 추가 시 코드 + prompt 동시 갱신 (catalog version bump).

### §4.4-bis fp ↔ bg link invariant (T4-fix3 — R4)

**배경**: D6 R3 시점 floor_plans[] schema 에 covering location 정보 부재 (`fp_id` / `sub_location` / `scope` / `depends_on_fp` 만). LLM 이 다른 sub-room (예: L09 마트 외부 입구 의 `fp_exterior_entrance`) 의 fp 를 L10 마트 매장 내부 background 의 `depends_on_fp` 에 silent inject 가능. `bg_catalog.assign_bg_ids` 의 `SemanticKeyError` 는 *같은 sem_key 가 다른 fp 두 번* 인 경우만 catch — *다른 sem_key + wrong fp* (예: `L10|main|day|quiet → fp_exterior_entrance` + `L10|main|morning|busy → fp_office`) 는 silent pass → 잘못된 floor plan 위에 background render.

**Invariant (T4-fix3)**:
> 모든 background entry `b` 의 `b.depends_on_fp[*]` 는 `floor_plans[]` 안의 fp 를 참조해야 한다. 그 fp 가 declare 한 `(loc_id, space_key_hint)` (normalized) 가 `b.(loc_id, space_key_hint)` (normalized) 와 정확히 일치해야 한다. 일치 안 하면 raw validator 가 **`FpLinkMismatchError`** (`SemanticKeyError` subclass) raise → LLM retry path.

**Schema 변경 (D6 v3 prompt pack)**:
- `floor_plans[]` 에 `loc_id` (`^L\d+$`) + `space_key_hint` (controlled vocab enum) 추가 — 둘 다 required.
- 한 fp = 한 (loc_id, space_key_hint) (single 강제, list 허용 X — D6 scope 단순화).
- `additionalProperties: false` 유지.

**검증 위치**: `validate_master_plan_raw_intent` (line 78-181) — invariant E (`depends_on_fp` 가 자체 plan 의 fp_id 참조) 직후 추가. `location_profiles: Dict[str, dict]` parameter 추가하여 `normalize_space_key` 적용.

**why raw validator**:
- LLM retry path 진입 가능 (`run_background_master_plan` 의 max_retries loop).
- post-process 단계 (`assign_bg_ids`) 의 `SemanticKeyError` 보다 빠른 catch + 더 명확한 진단 (fp 와 bg 의 (loc, space) 양쪽 노출).

**Anti-pattern**: `semantic_key` 에 `depends_on_fp` 추가하여 충돌 회피 — 잘못된 fp 참조를 다른 sem_key 로 분리해서 silent 통과시키는 결과. spec §4.2 의 semantic_key 정의 (`loc_id|space_key|time_phase|state_class`) **불변**.

**SCHEMA_VERSION bump**: `BackgroundMasterPlanStep.SCHEMA_VERSION = 2 → 3` + `PROMPT_VERSION = "3.YYYYMMDDHHMM"`. checkpoint contract 변경 (floor_plans[] 의 새 required field).

**회귀 fixture**:
- A. 같은 sem_key + 다른 fp (T4 의 기존 가드 catch).
- B. 다른 sem_key + wrong fp link (T4-fix3 가 새로 catch).
- C. pass (정상 link).

### §4.5 catalog manifest shape (master_plan)

`background_master_plan/manifest.json`:
```json
{
  "data": {
    "bg_catalog_version": 1,
    "bg_catalog_hash": "abc123...",
    "background_catalog": {
      "L09B01": {
        "loc_id": "L09",
        "space_key": "main",
        "time_phase": "dusk",
        "state_class": "busy_exit",
        "semantic_key": "L09|main|dusk|busy_exit",
        "sub_location_label": "store sales floor",
        "state_label_raw": "dusk busy with employee pointing toward exit",
        "applies_to_shots": ["S8_Shot4"],
        "depends_on_bg": [],
        "depends_on_fp": ["fp_supermarket_sales_floor"]
      },
      "L09B02": { "...": "..." }
    },
    "shot_background_map": {
      "S8_Shot4": "L09B01",
      "S8_Shot5": "L09B01"
    }
  }
}
```

**field 의미**:
- `bg_catalog_version`: enum/format breaking change 시 bump (정수). 같은 version 안에서 hash 만 바뀜.
- `bg_catalog_hash`: catalog 전체 SHA-256 (catalog dict 의 sorted JSON). downstream stamp 의 비교 근거.
- `background_catalog[bg_id]`: 1 entry = 1 background variant.
- `shot_background_map`: shot_id → bg_id (1:1 또는 N:1). 같은 bg 가 여러 shot 에 적용 가능.

**ID 부여 알고리즘 — 실행 ordering (R3 B4 N5)**:

`master_plan` step 의 실행 시퀀스:
1. LLM 호출 — raw intent (loc_id, space_key_hint, time_phase, state_class, sub_location_label, state_label_raw, applies_to_shots, depends_on_fp) 출력.
2. **`validate_master_plan_raw_intent()`** — LLM raw 출력 검증. 기존 `_SAFE_ID_RE` 는 LLM-produced field (`fp_id`, `sub_location_label`, `state_label_raw`, ...) 만 검사. `bg_id` 는 검사 대상 아님 (이 시점엔 아직 없음).
3. `normalize_space_key()` — entity catalog 의 `metadata_json.location.space_profile` 보고 space_key normalize.
4. `compute_semantic_key()` — `loc_id|space_key|time_phase|state_class`.
5. **`assign_bg_ids(prev_catalog, new_intents)`** — code 가 deterministic ID 부여:
   ```python
   def assign_bg_ids(prev_catalog, new_intents):
       catalog = {}
       # M4: prev_catalog index — O(N×M) 회피. semantic_key → entry.
       prev_by_sk = {e["semantic_key"]: e for e in prev_catalog.values()}
       next_b_per_loc = {
           loc: max_b(prev_catalog, loc) + 1
           for loc in {i.loc_id for i in new_intents}
       }
       for intent in new_intents:
           sk = compute_semantic_key(intent)
           # 1. 기존 catalog 에 같은 sk 가 있으면 재사용
           existing = prev_by_sk.get(sk)
           if existing:
               catalog[existing["bg_id"]] = {
                   **existing,
                   **metadata_from(intent),  # applies_to_shots / state_label_raw 등 갱신
               }
           else:
               # 2. 새 ID 부여
               new_id = _format_bg_id(intent.loc_id_num, next_b_per_loc[intent.loc_id])
               next_b_per_loc[intent.loc_id] += 1
               catalog[new_id] = {**intent, "bg_id": new_id, "semantic_key": sk}
       return catalog
   ```
6. **`validate_master_plan_assigned()`** — code-assigned bg_id 만 검사. `^L\d{2,3}B\d{2,3}$` 정규식. duplicate / missing 검출.
7. `compute_bg_catalog_hash()` + `compute_shot_binding_hash()` (§4.6).
8. manifest write.

**`_SAFE_ID_RE` 분리 (R3 B4 N5)** — `backend/app/modules/pipeline/background_master_plan.py:22,118-124` 의 invariant 1 update:
- `_LLM_INTENT_ID_RE = ^[a-z0-9][a-z0-9_]*$` — 기존 `_SAFE_ID_RE`. LLM-produced field (`fp_id`, `sub_location_label_canon`, `state_label_raw_canon`) 검사.
- `_BG_ID_RE = ^L\d{2,3}B\d{2,3}$` — code-assigned bg_id 전용.
- 두 검증은 **다른 step** (raw intent → assignment) 에 적용. 같은 dict 의 같은 field 를 두 정규식이 동시에 보지 않음.

**삭제 정책**: prev_catalog 에 있던 bg_id 가 new_intents 에 없으면 — **catalog 에서 제거** (episode-local, force re-run 시 깨끗한 재생성). tombstone 보존 안 함.

### §4.6 hash 분리 + downstream stamp (R3 N3 — hash 분리)

**핵심 (R3 N3)**: 단일 hash 면 catalog 변동 vs shot grouping 변동 구분 불가 — P2 가 보호하려는 "shot 재배치 시 bg_id 안정" 의 의도가 hash mismatch 로 cascade. 또 background_prompt 가 `applies_to_shots` 별 shot_guides 를 검증 (`background_prompt.py:160`), floor_plan_prompt 가 bg_id 집합 검사 (`floor_plan_prompt.py:103`) — consumer 별 소비 hash 가 다름. **두 hash 분리**.

**두 hash**:

| hash | payload | sorted JSON SHA-256 |
|---|---|---|
| **`bg_catalog_hash`** | catalog 의 render-relevant field — `bg_id`, `loc_id`, `space_key`, `time_phase`, `state_class`, `semantic_key`, `depends_on_bg`, `depends_on_fp`. **제외**: `applies_to_shots`, `sub_location_label`, `state_label_raw` (metadata only) | catalog 안정 = "bg semantic 변경 없음" |
| **`shot_binding_hash`** | `shot_background_map` 전체 (sorted by shot_id) | shot grouping 안정 = "어떤 shot 이 어떤 bg 사용하는지 변경 없음" |

**Consumer table** — 각 step 이 무엇을 소비:

| step | consumed_bg_catalog_hash | consumed_shot_binding_hash | 비고 |
|---|---|---|---|
| `floor_plan_prompt` | ✓ | ✗ | bg_id 집합 + camera_recommendations 매핑만 사용 (`floor_plan_prompt.py:103`). shot_binding 무관. |
| `background_prompt` | ✓ | ✓ | `applies_to_shots` 기반 shot_guides 검증 (`background_prompt.py:160`). 둘 다 의존. |
| `background_render` | ✓ | ✗ | bg_id → PNG 1:1. shot 매핑은 무관 (downstream 의 `shot_ids` 는 metadata). |
| `scene_detail` | ✓ | ✓ | `ctx.chain_bg_id_by_shot` 가 shot_background_map 결과를 소비. catalog 의 bg_id 도 RPC 에 박힘. |

**Manifest stamp**:
```json
{
  "data": {
    "consumed_bg_catalog_hash": "abc123...",
    "consumed_shot_binding_hash": "def456..."
  }
}
```

step 의 manifest 에 자기가 소비하는 hash 만 stamp (위 표 기준). soft default — 미소비 step 에 hash field 가 없으면 검증 skip.

**현재 `_config_hash` 정책 (verify 결과 — R2 M2)**:
- `step_runner._evaluate_contract_drift:739-769` 가 `config_hash` mismatch 를 모든 step BLOCK 처리.
- `_LEGACY_SCHEMA_BUMP_ALLOWLIST = {"entity_t2i"}` 만 schema bump RERUN_SELF — config_hash 는 어떤 step 도 auto-rerun 허용 X.
- 사용자 binding: project_config 변경의 자동 재실행은 의도와 다른 결과 위험이라 BLOCK 정책 유지.

**drift 감지 — 단일 source (R3 I5 단일 결정)**:

`_config_hash` 에 hash 미포함 (R3 결정 — R2 의 "선택 포함" 양수 폐기). detection = **dispatcher preflight 단일 source** (§4.8).

이유:
- `_config_hash` 포함 시 BLOCK 동작은 reason 노출 안 함 (catalog 인지 binding 인지 prompt 인지 schema 인지 불명).
- preflight 는 structured `STALE_UPSTREAM` error 로 expected/observed/remediation 명확히 노출.
- `_config_hash` 의 단순화 — 기존 schema_version + prompt_version + config 패턴 유지.

**B+C 패치 핵심**:
- master_plan 산출에 `bg_catalog_hash` + `shot_binding_hash` 두 hash 계산 + 저장.
- downstream step 산출에 자기 소비 hash 만 stamp (consumer table 참조).
- dispatcher (single regen / batch generate-image) 가 호출 직전 hash 비교 → mismatch 시 `STALE_UPSTREAM`.

**Implication (R2 review I1+I4 정합)**:
- LLM 이 shot grouping 만 다르게 출력 (`applies_to_shots` 만 변동) → `bg_catalog_hash` 안정, `shot_binding_hash` 만 mismatch → 운영자에게 "shot grouping 변경, scene_detail + background_prompt force 권장" 안내. 다른 step (floor_plan_prompt / background_render) 영향 0.
- 같은 (loc_id, space_key, state_class) 의 새 entry 추가 → `bg_catalog_hash` mismatch (full re-run).

### §4.7 scene_detail = chain_bg_id_by_shot 소비자 (R3 N1 — 전면 단순화)

**R2 review B2 발견 (양 reviewer 일치)**: scene_detail LLM 은 `bg_id` 를 출력하지 않는다. 현재 코드는 이미 코드가 catalog → loader → RPC 로 흐름 (`detail_steps.py:432`):
```python
bg_id = (ctx.chain_bg_id_by_shot or {}).get((si, shi))
```

`ctx.chain_bg_id_by_shot` 의 source = `scene_context_loader._load_chain_bg_id_by_shot` (`scene_context_loader.py:607-674`) — `background_prompt` checkpoint 의 `data.backgrounds[bg_id].applies_to_shots` (또는 `shot_background_map`) read 후 (scene_index, shot_index) → bg_id 매핑.

**R3 결정 — §4.7 의 LLM intent audit / `BackgroundBindingAuditError` / `background_binding_intent` schema 모두 폐기**. master_plan 만 deterministic 하면 scene_detail 자동 정합 — 새 LLM 출력 schema 도입 시 추가 failure surface 만 늘어남.

**scene_detail 의 D6 변경 — 최소**:
1. **schema_version bump** (`render_prompt_card` 의 `background_binding.bg_id` 형식 변경 — `bg_*` longform → `L##B##`).
2. **`consumed_bg_catalog_hash` + `consumed_shot_binding_hash` stamp** (manifest data, §4.6 consumer table).
3. **prompt 변경 0** (B2 review 결정 — LLM 출력 schema 변경 없음).

**최종 manifest** (코드가 기존 chain 그대로 채움):
```json
{
  "render_prompt_card": {
    "background_binding": {"bg_id": "L09B01", "...": "..."},
    "asset_requirements": {
      "required_refs": [
        {"kind": "background", "id": "L09B01", "policy": "required"}
      ]
    }
  }
}
```

**chain_bg required 가 아닌 케이스 (A4 명시)**:

`shot_background_map[shot_id] = None` 의 결정 책임:
- **master_plan 후처리** 가 shot 의 close framing / outdoor / 저빈도 등을 읽어 None 결정 — **불가**. master_plan 시점엔 staging (camera_direction) 이 없음 (shot_staging step 이 master_plan 후 수행).
- **scene_context_loader / detail_steps** 의 close framing carve-out — **현재 동작**. `_CLOSE_FRAMING_RE.search(cam_dir)` 검사 후 chain_bg ref 의도적 skip (Phase 9.1).
- **D6 의 결정**: `master_plan.shot_background_map` 은 **shot 이 chain_bg 를 사용하는 모든 매핑** 를 담고, close framing 으로 인한 의도적 skip 은 downstream (scene_detail / scene_image_pipeline) 의 staging-aware carve-out 로 처리. 즉 `shot_background_map` 에는 `S8_Shot4 → L09B01` 처럼 mapping 이 있어도 staging close framing 이면 RPC 의 `forbidden_refs` + `readiness_policy: skipped_by_policy` 로 fall-through (D5 와 동일).

**`chain_bg_lookup` (D5 §4.5) 영향 (A1 명시)**:
- `scene_generation_coordinator.py:200-208` 의 `build_chain_bg_lookup` 은 `background_chain_bg_map.values()` 의 `bg_id` 를 read 하는 passive lookup — bg_id 형식이 `bg_*` → `L##B##` 로 바뀌어도 코드 변경 0. D5 의 (kind, id) exact match validator 는 형식 무관.

**custom_prompt path (R2 review B3 — N4)**:
- `scene_image_service.py:655` 의 `custom_prompt` 분기는 `build_custom_labeled_refs` 만 호출. chain_bg/prev_shot/state_variant 미주입 — operator-explicit override.
- D6 의 영향: §4.8 entry preflight 는 통과 의무 (catalog/shot_binding hash freshness 검증). custom_prompt path 의 **ref-builder 개편은 out-of-scope** (§3 Non-goals).

### §4.8 STALE_UPSTREAM error (R3 N4 — entry 위치 명시 + custom_prompt 포함)

**감지 — 책임 분리**:

1. **dispatcher preflight (필수 / primary / single source)** — **endpoint entry** 에서 동작. `generate_single_scene_image` / `generate_images` 호출 **이전**:
   - 단건: `api/v1/images.py:387` `/stills/{id}/generate-image` 의 router function entry.
   - 배치: `api/v1/images.py:599` `/episodes/{id}/generate-images` 의 router function entry.
   - **custom_prompt 포함** — `custom_prompt` body 가 있더라도 preflight 통과 의무 (R2 B3 N4). custom_prompt 도 chain_bg/prev_shot 의 dependency 가 stale upstream 에 박혀있을 수 있음.
   - 검증 로직:
     - master_plan 의 `bg_catalog_hash` + `shot_binding_hash` (latest) read.
     - 각 consumer step (`background_prompt` / `background_render` / `scene_detail`) 의 manifest 에서 `consumed_*_hash` read.
     - consumer table (§4.6) 기준 mismatch 검사.
     - 하나라도 mismatch → 즉시 `STALE_UPSTREAM` HTTP 응답.

2. **`_config_hash` 정책 (R3 I5 단일결정 — 미포함)** — `consumed_*_hash` 는 `_config_hash` 에 포함하지 않음. step_runner 의 contract_drift BLOCK 은 schema_version / prompt_version mismatch 로만 발동 (D6-D 의 force re-run 트리거 역할).

3. **validator / build_scene_attached_refs (tertiary fallback)** — preflight 가 빠진 path 로 호출되면 (예: 신규 endpoint 추가 시 누락 / direct service 호출) `required_refs` 의 bg_id 가 render manifest 부재 → error code 를 `ref_contract.violation` 이 아닌 `STALE_UPSTREAM` 으로 분리. preflight 의 fallback safety net.

**HTTP 응답 형식 (단건/배치 동일)**:
```json
{
  "error": {
    "code": "STALE_UPSTREAM",
    "upstream": "background_render",
    "expected_bg_catalog_hash": "abc123",
    "observed_bg_catalog_hash": "def456",
    "expected_shot_binding_hash": "ghi789",
    "observed_shot_binding_hash": "jkl012",
    "missing_in_render": ["L09B01", "L09B02"],
    "remediation": "POST /steps/background_render?mode=force (or full ordered re-run — see operator runbook §6 D6-D)"
  }
}
```

**custom_prompt 의 preflight 동작 (R3.1 — 응답 형식 동일, 강제 범위만 다름)**:
- catalog hash freshness 만 검증 — `shot_binding_hash` 는 강제 안 함 (custom_prompt 는 standard shot binding 우회 의도, R3 N4).
- 검증 결과 mismatch 시 위 동일 응답 형식으로 STALE_UPSTREAM. operator 가 force re-run 후 custom_prompt 재시도.

---

## §5. Acceptance Criteria

### AC-1 (deterministic ID)

- master_plan 같은 episode 에 대해 5 회 재실행 → bg_id 변동 0. 같은 (loc_id, space_key, time_phase, state_class) 가 모두 동일 bg_id 부여.
- bg_id 형식 **`^L\d{2,3}B\d{2,3}$`** (R3 M5 정합 — §4.1 과 일치) 정규식 통과. 그 외 형식은 fail-fast.
- LLM raw intent 검사 (`_LLM_INTENT_ID_RE = ^[a-z0-9][a-z0-9_]*$`) 와 code-assigned bg_id 검사 (`_BG_ID_RE`) 가 다른 step (assignment 전 / 후) 에 분리 적용.

### AC-2 (semantic_key key-only fields)

- semantic_key 에 `applies_to_shots`/`state_label_raw`/`sub_location_label` 미포함. raw fields 변동 시 semantic_key 안정.
- `bg_catalog_hash` payload 도 metadata-only field 제외 (R3 I1+I4 정합) — `applies_to_shots` 변동만으로 catalog hash mismatch 발생 0.

### AC-3 (controlled vocab strict)

- `state_class` 가 enum 밖이면 fail-fast (nearest-match 시도 0 회). LLM 이 enum 강제 prompt 위반 시 master_plan step 차단.
- `space_key` 는 entity catalog 의 `metadata_json.location.space_profile` 기반 normalize. single_space → 항상 main, multi_space → enum strict. 매칭 실패 시 fail-fast.

### AC-4 (cross-step lineage hash) — R3 N3 hash 분리

- master_plan 산출 manifest 에 `bg_catalog_hash` + `shot_binding_hash` 두 field 존재.
- consumer step manifest 에 자기 소비 hash 만 stamp (consumer table §4.6 기준):
  - `floor_plan_prompt`: `consumed_bg_catalog_hash` only
  - `background_prompt`: `consumed_bg_catalog_hash` + `consumed_shot_binding_hash`
  - `background_render`: `consumed_bg_catalog_hash` only
  - `scene_detail`: `consumed_bg_catalog_hash` + `consumed_shot_binding_hash`
- dispatcher preflight 가 endpoint entry 에서 hash 비교 → mismatch 시 `STALE_UPSTREAM`.
- `_config_hash` 에 hash 미포함 (R3 I5 단일결정).

### AC-5 (scene_detail = chain_bg_id_by_shot 소비자) — R3 N1 단순화

- **scene_detail prompt 변경 0** (R2 B2 review 결정 — LLM 은 bg_id 출력 안 함, 기존 structure 그대로).
- D6 변경: schema_version bump (`bg_id` 형식 변경) + manifest 에 `consumed_bg_catalog_hash` + `consumed_shot_binding_hash` stamp.
- master_plan 재실행 후 `_load_chain_bg_id_by_shot` 가 같은 (scene_index, shot_index) 에 같은 bg_id 반환 (catalog 안정 + shot_binding 안정 시).
- `BackgroundBindingAuditError` / `background_binding_intent` schema 도입 0 (R3 N1 폐기).

### AC-6 (STALE_UPSTREAM error)

- 단건/배치 endpoint entry preflight 가 `consumed_*_hash` mismatch 또는 required bg_id 가 render manifest 부재 시 — HTTP 응답 code = `STALE_UPSTREAM` (not `ref_contract.violation`). metadata 에 expected/observed `bg_catalog_hash` + `shot_binding_hash` + `missing_in_render` + `remediation` 포함.
- `custom_prompt` body 가 있어도 preflight 통과 의무 (R3 N4) — catalog hash freshness 만 검증, shot_binding 강제 0.

### AC-7 (legacy migration)

- 기존 longform `bg_*` ID episode 는 force re-run 후 새 `L##B##` ID 로 재발급. compatibility map 미존재.
- **ordered-force runbook (R2 I2)** — operator 가 master_plan → background_prompt → background_render → scene_detail 순으로 force. 잘못 순서 시 STALE_UPSTREAM 으로 명확히 노출 (force in wrong order smoke test 포함).
- legacy `bg_*` 와 new `L##B##` 는 같은 episode 의 catalog 에 절대 coexist 안 함 (force re-run 시 catalog 전체 재발급).

### AC-8 (regression)

- D5 의 (kind, id) exact match validator (`ref_contract_validator.py:178-318`) 코드 변경 0. ID 형식만 변경 (`bg_*` → `L##B##`).
- master_plan 의 invariant validator 분리 — `_LLM_INTENT_ID_RE` (raw) vs `_BG_ID_RE` (code-assigned). LLM-produced field 는 기존 정규식, code-assigned bg_id 는 새 정규식.
- 기존 backend tests pass (3000+ tests).
- A1 정합: `chain_bg_lookup` (`scene_generation_coordinator.py:200-208`) 코드 변경 0 — bg_id 형식 무관한 passive lookup.

---

## §6. Migration / Rollout

**단계** (R3 — N1~N5 반영, T 번호 조정):

### D6-B+C (동시) — deterministic ID + lineage hash

T0. **prerequisite — `entity_canon.metadata_json TEXT default '{}'` column 추가** (alembic migration). 기존 row 의 default 가 `'{}'` 라 backfill 불필요.
T1. `state_class` enum + `_BG_ID_RE` / `_LLM_INTENT_ID_RE` 분리 정규식 + normalization util 추가 (코드 SOT).
T2. **entity_extractor prompt redesign** — location entity 출력에 `metadata_json.location.space_profile` (kind + allowed_space_keys) 의무 추가. character/prop 은 `metadata_json={}` (default).
T3. **master_plan prompt redesign** — raw intent 출력 (loc_id/space_key_hint/time_phase/state_class/applies_to_shots/sub_location_label/state_label_raw/depends_on_fp).
T4. **master_plan 후처리 — 6 단계 ordering** (§4.5):
  - 4a. raw intent validation (`_LLM_INTENT_ID_RE` for fp_id, sub_location_label, state_label_raw).
  - 4b. `normalize_space_key` (entity `metadata_json` 보고).
  - 4c. `compute_semantic_key`.
  - 4d. `assign_bg_ids` (prev_catalog reuse + new var).
  - 4e. `validate_master_plan_assigned` (`_BG_ID_RE` for bg_id).
  - 4f. `compute_bg_catalog_hash` + `compute_shot_binding_hash` (§4.6 분리, metadata 제외).
T5. consumer step manifest 에 자기 소비 hash stamp (consumer table §4.6 — `floor_plan_prompt`/`background_prompt`/`background_render`/`scene_detail`).
T6. **scene_detail 변경 — schema_version bump only** (R3 N1 — prompt 변경 0). manifest 에 `consumed_bg_catalog_hash` + `consumed_shot_binding_hash` stamp.
T7. test — semantic_key dedup / ID 안정성 / hash 분리 정합 (catalog stable + shot grouping 변동 시 binding hash 만 mismatch).

### D6-A — STALE_UPSTREAM preflight

T8. **dispatcher preflight at endpoint entry** (R3 N4):
  - `api/v1/images.py:387` `/stills/{id}/generate-image` router function 직접에서 preflight.
  - `api/v1/images.py:599` `/episodes/{id}/generate-images` router function 도 동일.
  - **custom_prompt 포함** — body 와 무관하게 catalog hash 검증 의무.
T9. validator / build_scene_attached_refs 의 error code 분리 (ref_contract.violation vs STALE_UPSTREAM, fallback safety net).
T10. HTTP response shape + remediation hint (§4.8 example 형식).

### D6-D — legacy migration + ordered-force runbook (R3 I2)

T11. **operator runbook 문서화** (`docs/runbooks/d6-migration.md`):
  - force order: `master_plan` → `background_prompt` → `background_render` → `scene_detail`.
  - `floor_plan_prompt` 는 `master_plan` 직후 force (catalog 의 fp 의존성).
  - 잘못 순서로 force 시 STALE_UPSTREAM HTTP 422 으로 노출.
T12. (no code) — compatibility map 미도입. 단, 기존 episode 가 `bg_*` longform ID 인 동안 새 D6 코드는 schema_version mismatch 로 자동 BLOCK — 운영자가 force 의무.
T13. test — "force in wrong order produces STALE_UPSTREAM" smoke test (master_plan force 안하고 scene_detail 만 force 시 preflight 차단 검증).

**ETA (R3 — M3 정정)**: 8~12 일.
- B+C: T0~T7 ≈ 6~9 일 (T0 alembic + T2 entity_extractor + T4 master_plan post-processing + T7 test 가 큰 work)
- A: T8~T10 ≈ 1.5~2 일 (preflight 매커니즘 + endpoint wiring + error code 분리)
- D: T11~T13 ≈ 0.5~1 일 (runbook + smoke test, 코드 변경 거의 없음)

T0 의 alembic migration 는 production deploy 시 `alembic upgrade head` 후 backend startup 순서 엄격 (CLAUDE.md 규칙 준수).

---

## §7. Open Questions

| # | 항목 | 옵션 / 결정 필요 |
|---|---|---|
| **OQ-1** | `state_class` enum 의 SOT 위치 | (a) 코드 상수 (`backend/app/core/bg_state_vocab.py`) (b) config (settings/env) (c) DB table. — **권장 (a)**: 코드 review 가 enum 변경 트리거 |
| **OQ-2** | `space_key` controlled vocab 범위 | location 별 다른 vocab? 또는 globally 단일? — **권장**: globally 단일 enum + 단일실 location 은 `main` 강제 |
| **OQ-3** | catalog scope 정확히 | episode-local 단일? 또는 project-level 도 보존? — **권장**: episode-local. project 차원 재사용 X |
| **OQ-4** | `_config_hash` 에 `consumed_bg_catalog_hash` 포함 vs preflight 만 | **R3 단일결정**: 미포함. preflight = single source of detection. `_config_hash` 미포함 — reason 노출 + structured error 가 운영자 UX 우월 |
| **OQ-5** | shot_background_map 의 N:M 가능성 | 한 shot 에 복수 bg (예: split-screen 시) ? — **현재 N:1 강제**. split-screen 등은 별도 spec 후속 |
| **OQ-6** | sub_location_label 의 fuzzy match 허용 범위 | **R3 결정**: lowercase/underscore fuzzy 폐기. SOT = entity catalog 의 `metadata_json.location.space_profile` (§4.3 N2). single_space → 항상 main, multi_space → enum strict. raw label 은 metadata only |
| **OQ-7** | legacy episode migration 의 트리거 | (a) D6 deploy 후 첫 force re-run 시 자동 (b) 운영자 명시 명령 — **권장 (b)**: data 변경 visible. ordered-force runbook (R3 I2) 의무 |
| **OQ-8** | schema_version vs prompt_version bump 정책 | **R3.1 결정 (M6 + N1 정합)**: schema_version (manifest 형식 변경) bump = enum 제거/이름변경/field 추가. enum 단순 추가는 schema no-bump 가능. prompt_version bump = enum 변경 시 **enum 을 prompt 에 inject 하는 step 만** 의무 — 즉 `master_plan` 단독 (R3.1 — N1 으로 scene_detail prompt 변경 0). version 두 축 분리 |
| **OQ-9** | scene_detail prompt redesign 의 영향 범위 | RPC schema_version bump 의무? — **권장**: bump (background_binding 형식 변경 = breaking) |
| **OQ-10** | STALE_UPSTREAM error 의 frontend 노출 | UI 가 `code=STALE_UPSTREAM` 인지 후 force re-run 버튼 표시? — **후속 spec** (D6 scope 외) |

---

## §8. Schema versions / breaking changes

- `background_master_plan` schema_version bump (catalog + `bg_catalog_hash` + `shot_binding_hash` + `shot_background_map` 추가) — 기존 manifest 자동 reject.
- `floor_plan_prompt` schema_version bump (`consumed_bg_catalog_hash` stamp 추가).
- `background_prompt` schema_version bump (`consumed_bg_catalog_hash` + `consumed_shot_binding_hash` 추가).
- `background_render` schema_version bump (`consumed_bg_catalog_hash` 추가).
- `scene_detail` schema_version bump (`bg_id` 형식 변경 `bg_*` → `L##B##` + `consumed_*_hash` stamp). **prompt 변경 0** (R3 N1).
- prompt_version bump: `entity_extractor` (T2 metadata_json.location.space_profile 의무화), `master_plan` (T3 raw intent schema 변경). `scene_detail` prompt_version 은 변경 없음 (R3 N1).
- DB schema: `entity_canon.metadata_json TEXT default '{}'` column 추가 (alembic migration, T0).

기존 episode 의 manifest 는 자동 reject → force re-run 의무 (legacy migration 의 정상 path).

---

## §9. Test plan

### unit

- `bg_state_vocab` enum strict 검증 (enum 밖 입력 → raise).
- `normalize_space_key` 동치 매칭 — single_space location 의 `metadata_json.location.space_profile` 보고 hint 와 무관하게 항상 `main` 반환. multi_space 의 enum 밖 hint → raise.
- `compute_semantic_key` 의 field 조합 + applies_to_shots/sub_location_label/state_label_raw 미포함.
- `assign_bg_ids` — prev_catalog 재사용 + 새 ID next_b 증가 + 삭제 시 제거 + M4 index O(N+M).
- `_format_bg_id` zero-pad — 99 까지 2자리, 100+ 자연 확장.
- `bg_catalog_hash` deterministic (같은 catalog → 같은 hash). `applies_to_shots` 만 변경 → catalog hash 안정.
- `shot_binding_hash` 분리 — `shot_background_map` 만 변경 시 catalog hash 안정 + binding hash mismatch.
- master_plan validator 분리 — `_LLM_INTENT_ID_RE` (raw fp_id 등) vs `_BG_ID_RE` (assigned bg_id) 가 다른 step 에 적용.

### integration

- master_plan 5 회 재실행 → bg_id 변동 0, hash 안정 (`AC-1`).
- LLM 이 같은 (loc_id, space_key, state_class) 의 shot grouping 만 변경 → `bg_catalog_hash` 안정 + `shot_binding_hash` mismatch → background_prompt + scene_detail 만 STALE (consumer table §4.6 정합).
- master_plan 갱신 후 dispatcher preflight 가 STALE_UPSTREAM 으로 단건 generate-image 차단 (`AC-4` + `AC-6`).
- scene_detail prompt 변경 0 — 기존 chain_bg_id_by_shot chain 그대로 (`AC-5`).
- ordered force `master_plan → background_prompt → background_render → scene_detail` 후 단건 호출 HTTP 200.
- **wrong-order force smoke (R3 I2)** — master_plan force 안하고 scene_detail 만 force → preflight STALE_UPSTREAM 422.
- `custom_prompt` body 의 단건 호출 도 preflight 통과 의무 (`AC-6` R3 N4) — catalog stale 시 STALE_UPSTREAM.

### regression

- D5 의 (kind, id) exact match validator (`ref_contract_validator.py:178-318`) 코드 변경 0.
- `chain_bg_lookup` (`scene_generation_coordinator.py:200-208`) 코드 변경 0 (A1).
- 기존 broader test suite (3000+ tests) 회귀 0 (legacy episode 는 별도 fixture).

---

## §10. Binding refs

- D5 spec (substring fallback 폐기, attached_meta plumbing): `docs/superpowers/specs/2026-05-09-attached-reference-identity-contract-design.md`
- D5 push: commit `b4de4c8`
- 단건 path wiring fix: commit `699a41c`
- 직전 진단 메모리: `next_session_s8_background_recovery.md`
- session 종합: `session_20260509_d5_t3_t4_t5.md`

---

## §11. Summary

D6 는 **bg_id 가 LLM-generated free-form** → **code-assigned deterministic ID** 로의 contract 전환. R1~R3 진화 후 핵심 4 원칙:

1. **LLM 은 설명, 코드는 ID/key** (P1) — `bg_id` 는 master_plan 후처리에서 코드가 부여, LLM 은 raw intent 만 출력.
2. **semantic_key 에서 `applies_to_shots` / raw `sub_location_label` / raw `state_label_raw` 제외** (P2) — shot grouping / 자유 텍스트 변동에 ID 불변.
3. **`space_key` 는 entity location 의 `metadata_json.location.space_profile` SOT** (P3 + R3 N2) — single_space → main 강제, multi_space → enum strict. raw label 은 metadata only.
4. **`state_class` 는 controlled vocab enum** (P4) — enum 밖이면 fail-fast (nearest-match 없음).

추가 — **R3 N1 단순화 (R2 review B2 발견 적용)**: scene_detail 은 `ctx.chain_bg_id_by_shot` 그대로 소비. LLM 출력 schema 변경 0 — master_plan 만 deterministic 하면 자동 정합. R2 의 `BackgroundBindingAuditError` / `background_binding_intent` schema 모두 폐기.

**lineage hash (R3 N3)**: 두 hash 분리:
- `bg_catalog_hash` (catalog 의 render-relevant field, metadata 제외).
- `shot_binding_hash` (shot_background_map 전용).
- consumer 별 stamp (consumer table §4.6) — prompt/render/detail 이 무엇 소비하는지 표 고정.

**STALE_UPSTREAM detection (R3 N4)**: dispatcher preflight = single source. **endpoint entry** (`api/v1/images.py`) 에서 `consumed_*_hash` 정합 검증. **`custom_prompt` 포함** (catalog hash freshness 의무) — ref-builder 개편은 out-of-scope.

**`_SAFE_ID_RE` 분리 (R3 N5)**: LLM raw intent 는 `_LLM_INTENT_ID_RE` (lowercase snake_case), code-assigned bg_id 는 `_BG_ID_RE` (`^L\d{2,3}B\d{2,3}$`). master_plan validation step 에서 ordering 분리 — assignment 이전 (raw intent) vs 이후 (bg_id).

**`entity_canon.metadata_json` column 추가 (R3 N2)**: `stable_traits` 의 list 계약 보존 — 새 column 으로 12+ writer/reader atomic migration 회피. T0 alembic migration prerequisite.

**legacy migration (R3 I2)**: ordered-force runbook 의무 (master_plan → background_prompt → background_render → scene_detail). 잘못 순서 시 STALE_UPSTREAM 으로 명확 노출. compatibility map 안 만듦.

이 4 원칙 + N1~N5 + 두 hash 분리 + dispatcher preflight 가 D6 의 core scope. **R3 status = plan drafting 진입 가능** (NEEDS_REVISION 5 BLOCKING + IMPORTANT/MINOR/추가발견 모두 적용).
