# Attached Reference Identity Contract — Design Spec (D5)

**Date**: 2026-05-09
**Status**: DRAFT_R2 (self-audit R1 NEEDS_REVISION 적용 — M1 checkpoint name / M2 append site count / M3 custom_prompt out-of-scope / M4 close_framing strict / M5 batch line citation + I1 D2 strict 보존 / I2 bg_map entry 확장 / I3 prev_shot helper return 확장)
**Author**: Claude (Opus 4.7) + 사용자 directed review
**Trigger**: 2026-05-08 single-batch reference contract spec (D1~D4) push 후 production canary 에서 S8_Shot4 단건 재호출 → HTTP 200 통과 + 시각 결함 (출입문이 아닌 사람 가리킴 + bg 누락 + character outlook 미적용). 진단 결과 — D1~D4 fix 가 RPC inject + shape strict 까지 완료했으나 **`validate_attached_refs` 가 substring 으로 ref 존재를 추론** 해 character description 안 "plain white background" 같은 우연 단어로 false-positive 통과. **identity-level contract 부재** 가 드러남.

---

## §1. Background

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

PID `80f62523` C01 zero-gate + D1~D4 fix 완료 (commit `db6ec84`) 후 5 NG stills (S8/S13/S15/S19/S21) 단건 canary 재실행 시작. **S13_Shot6** 는 분기 (a) HTTP 422 `ref_contract.violation` 으로 fail-fast 차단 ✅. **S8_Shot4** 는 HTTP 200 통과 + 이미지 생성 — 하지만:

| 검증 항목 | manifest `asset_requirements.required_refs` | 실제 attached (Gemini 송신) | 결과 |
|---|---|---|---|
| character_outlook | `C01O02` (policy=required) | **C01 base** (composite 부재로 silent fallback) | identity mismatch — 통과 (잘못) |
| background | `bg_store_sales_floor_dusk_busy_exit_visible` (policy=required) | **없음** (chain_bg map 비어있음, prev_shot 도 없음) | 누락 — 통과 (잘못) |
| readiness_policy | `block_if_missing` | (validator 가 안 읽음) | dead string |
| extra refs | (forbidden_refs=[]) | P03 (lineage 메타에만) | 검사 안 함 |

→ S13 만 막힌 이유 = **labeled_refs 자체가 빈 리스트** (`[]`) → `_has_character_ref([])` = False → raise. S8 처럼 character ref 가 1개라도 붙으면 **substring "character" 매칭** 으로 통과.

### 1.2 D5 root cause (코드 라인 확정)

| # | Defect | Evidence (line citation) |
|---|---|---|
| **D5-1** | `_has_*_ref` 가 label 자유 텍스트 substring 매칭 | `ref_contract_validator.py:89-100`. `"character" in label.lower()` / `"background" in label.lower() or "previous shot" in label.lower()` / `"object" in label.lower()` |
| **D5-2** | label 이 `t2i_prompt` 자유 문장을 inline | `scene_reference_service.py:141-146` (`build_image_index`). `new_label = f"Image {i} (character reference): {name} — {desc}"` 의 `desc = entity.t2i_prompt` — character description 안 "background"/"object"/"character" 같은 단어가 자연스럽게 섞임. C01 t2i_prompt 는 "Passport-style ID photo, head and upper chest visible, **plain white background**." 포함 → false-positive |
| **D5-3** | `readiness_policy` 와 `forbidden_refs` 가 runtime gate 가 아님 | `ref_contract_validator.py:222-256`. `validate_attached_refs` 는 `required_refs` 와 prompt classifier 만 읽음. `readiness_policy=block_if_missing` 은 manifest 의 장식 문자열로만 존재 |
| **D5-4** | resolver 가 composite 부재 시 base ref silent fallback | `scene_reference_service.py:412-425`. composite_key 미존재 시 char_id base ref attach. validator 가 substring 통과시켜 **outlook mismatch 가 운영자에게 노출되지 않음** |
| **D5-5** | 같은 false-positive 패턴이 `prompt_service.py` 에서 이미 학습 + 수정됨 (RC-D, G4.6 Phase 1) | `prompt_service.py:43-72`. `_IMAGE_N_PAREN_HEADER_RE` + `_HEADER_CHARACTER_TAGS` 토큰 매칭. ref_contract_validator 는 그 교훈을 무시하고 substring 으로 회귀 작성 |

### 1.3 validator call site 비대칭

`grep validate_attached_refs` 결과 — `scene_generation_coordinator.py:1111` 단 1곳 (단건 경로). batch `generate_images` 경로는 contract 미검증. D1~D4 spec 의 `next_session_other_stills_canary.md` 후속 항목 "B. batch path validate_attached_refs 적용" 은 미해결. D5 가 새 helper return signature 도입하면 batch 경로도 동시 wiring 의무 (시그니처 일관성 / divergence 재발 방지).

### 1.4 사고가 드러낸 architectural gap

세 가지 ref 표현이 시스템에 섞여 있고, 셋이 같은 의미라고 가정하는 순간 검증이 무너짐:

| 표현 | 형식 | 용도 |
|---|---|---|
| Contract | `required_refs = [{kind, id, policy}]` | RPC producer 가 명시 |
| Attached | `(label, bytes)` | resolver/builder 가 만들어 Gemini 에 송신 |
| LLM-facing | `"Image N (character reference): name — free text"` | prompt 에 inline |

**LLM-facing label 은 LLM 에게 보여주기 위한 문장이지 검증용 메타데이터가 아님**. validator 가 LLM-facing label 을 contract evidence 로 쓰면 false-positive 가 구조적으로 반복됨.

---

## §2. Goals + Principles

### 2.1 Goals

1. **identity-level validation** — `required_refs` 의 (kind, id) 쌍이 attached 에 **exact match 로 존재** 해야 통과. substring/text 매칭 폐기.
2. **silent fallback 차단** — composite (C01O02) 부재 시 base (C01) 가 attached 되더라도 **별 kind** (`("character","C01")`) 로 기록되어 character_outlook 요구를 만족시키지 않음. validator 가 자연스럽게 fail-fast.
3. **`readiness_policy=block_if_missing` runtime enforce** — required 항목 누락 시 차단. 단순 장식 문자열 폐기.
4. **prev_shot lineage 검증** — prev_shot 이 background 요구를 만족시킬 때 location lineage 일치 확인. 무관한 prev_shot 으로 만족 척 차단.
5. **batch + single 동시 wiring** — 새 helper return signature `(prompt, labeled_refs, attached_meta)` 가 두 경로 모두 적용. 시그니처 일관성 의무.
6. **S8 회귀 차단 가시화** — fixture 로 "C01 t2i_prompt 안 'plain white background' 가 있어도 background 요구를 만족시키지 않는다" 명시 검증.

### 2.2 Principles (spec preamble, 모든 task 가 따름)

> **P1.** `attached_meta` 는 label 에서 **추론하지 않는다**. `labeled_refs.append` 시점에 resolver/builder 가 이미 보유한 source id (short_id, bg_id, location_id) 만 기록한다.
>
> **P2.** 어떠한 fallback 도 상위 `required_refs.id` 를 **만족한 척 meta 를 위조하지 않는다**. 다운그레이드 (composite→base, outlook→state) 시 별도 kind 로 기록하여 validator 가 자연스럽게 fail-fast 한다.

이 두 원칙은 spec 의 **invariant**. 모든 acceptance criteria + reviewer 점검 항목의 root.

### 2.3 Invariants (사용자 binding — spec preamble)

1. `len(labeled_refs) == len(attached_meta)` — 두 리스트는 항상 같은 순서, 같은 길이. mismatch 시 즉시 RefContractError fail-fast.
2. Gemini 호출부에는 `labeled_refs` 만 전달. `attached_meta` 는 validator/trace/debug 전용 (프롬프트에 inline 안 됨).
3. `image_asset.reference_image_ids` / `actual_attached_refs` 컬럼은 본 spec 에서 변경 안 함. D5 는 runtime validation contract fix 만.
4. `batch (generate_images)` 와 `single (generate_single_scene_image)` 두 경로 모두 같은 helper return signature 사용.
5. 기존 substring 의존 validator tests → meta 기반으로 update.
6. **S8 회귀 fixture 필수**: C01 label "plain white background" inline 상태 + meta 기반은 background 요구 차단.

---

## §3. Non-goals (명시적 제외)

다음 영역은 **본 spec 의 범위 외**:

- **`forbidden_refs` 의미 확장 / `allowed_refs` / extra ref 차단** — S8 직접 원인 아님 (P03 는 lineage 메타에만 있고 실제 첨부 0건). 별 spec.
- **`custom_prompt` 경로 (manual override bypass)** — `generate_single_scene_image(custom_prompt=...)` 분기 (`scene_image_service.py:648`) 는 사용자가 최종 prompt 를 직접 편집하는 raw override 경로. RPC contract 가 적용되지 않으며, `build_custom_labeled_refs` (`scene_reference_service.py:866`) 가 예외를 warning 후 빈 refs 로 진행. **D5 는 custom_prompt 경로를 변경하지 않음** — 정상 single/batch pipeline 의 contract 만 강화. custom_prompt 의 contract bypass 는 별 spec (F7 후속). **silent skip 표시 X — 명시적 out-of-scope**. operator 가 custom_prompt 사용 시 D5 enforce 안 됨을 인지해야 함.
- **`build_scene_attached_refs` 의 RPC-driven 재설계** — resolver 가 `required_refs` 를 입력으로 받아 prompt 대신 RPC 보고 attach 결정. 이상적이지만 D5 의 fail-fast 로 production 결함 닫고, 빈도 측정 후 별 spec. D5 는 validator 만 enforce, resolver 는 prompt 기반 그대로.
- **`AttachedRef` dataclass 도입** — 4-tuple 구조화 또는 dataclass. 본 spec 은 평행 리스트 `attached_meta = list[tuple[str, str]]` 로 시작. 후속 spec 에서 정식화.
- **state_variant ↔ character_outlook 대체 정책** — state_variant 가 outlook 보장 자산이 아니므로 D5 에서는 별 kind 로 기록 (character_outlook 요구 만족 X). state_variant 가 outlook 포함 composite 로 진화하면 별 spec 에서 정책 결정.
- **`reference_image_ids` 컬럼 alembic rename / actual_attached_refs 활성화** — D2 영역, 별 spec.
- **`classify_from_the_reference` (phantom classifier) 알고리즘 재설계** — phantom guard 는 그대로 유지하되, ref 존재 판정만 meta 기반으로 (alphabet substring → kind/id). 분류기 토큰 리스트 자체는 변경 안 함.
- **scene_detail manifest / RPC producer 변경** — D5 는 consumer (validator + resolver meta) 만 변경.
- **샷별 prompt 수동 보정** — 명시적 금지. system 결함 fix 후 자동 regen 으로만 검증.

### 3.1 Plan handoff enforcement

본 spec 의 plan drafting 시 plan header **반드시** 아래 non-goals checklist 포함 (literal 복사):

```markdown
> **Non-goals enforcement** (이 plan 의 모든 task 에서 절대 추가 금지):
> - [ ] forbidden_refs 의미 확장 / allowed_refs / extra ref 차단 X
> - [ ] build_scene_attached_refs 의 RPC-driven 재설계 X
> - [ ] AttachedRef dataclass / 4-tuple 구조화 X (평행 리스트만)
> - [ ] state_variant ↔ character_outlook 대체 정책 X (별 kind 만)
> - [ ] reference_image_ids / actual_attached_refs 컬럼 변경 X
> - [ ] classify_from_the_reference 알고리즘 재설계 X (phantom guard 그대로)
> - [ ] scene_detail manifest / RPC producer 변경 X
> - [ ] 샷별 prompt 수동 보정 X
> - [ ] custom_prompt 경로 (manual override bypass) 변경 X (별 spec F7)
>
> 매 task 시작 시 reviewer 가 위 9개 위반 안 했는지 confirm. 위반 발견 시 task 중단 + spec 변경 협의.
```

Plan drafting 시 위 block 누락은 plan 자체의 BLOCKING. self-review 단계에서 검사.

---

## §4. Architecture

### 4.1 데이터 shape

```python
# 기존 (변경 X)
labeled_refs: list[tuple[str, bytes]]  # Gemini 송신용 (label, image_bytes)

# 신규 (평행 리스트)
attached_meta: list[tuple[str, str]]   # validator 전용 (kind, id)
# - len(attached_meta) == len(labeled_refs)  (invariant 1)
# - 같은 인덱스의 ref 의 source identity
```

### 4.2 meta 매핑 표 (resolver/builder 의 6개 출처)

| ref 출처 | append site (resolver/builder) | label (변경 X) | meta (신규) |
|---|---|---|---|
| composite (C01O02) | `scene_reference_service.py:417` (short_id 경로) + `:446` (legacy 경로) | `"character C01O02 in outfit"` | `("character_outlook", "C01O02")` |
| base character (C01, O00 또는 composite 부재) | `scene_reference_service.py:408` + `:424` (short_id) + `:439` + `:455` (legacy) | `"character C01 identity"` 또는 `"character identity"` | `("character", "C01")` ← C01O02 요구 만족 X (P2) |
| state_variant | `scene_reference_service.py:391-394` | `"character C01O02 — unconscious state reference"` | `("character_state", "C01:unconscious")` ← outlook 요구 만족 X (P2) |
| prop | `scene_reference_service.py:496` | `"object P03"` | `("prop", "P03")` ← 기록 필수, 검사는 후속 spec |
| chain_bg | `scene_generation_coordinator.py:296` (단건) + `:555` (배치) | `"background chain ref (bg_… for L09)"` | `("background", "bg_store_…")` ← **bg_map entry 에서 직접 read (P1)** — 라벨 파싱 금지 |
| prev_shot | `scene_generation_coordinator.py:337` (단건) + `:590` (배치) | `"previous shot at same location — …"` | `("background_prev_shot", "L09")` ← **prev_shot helper 가 loc_id 동시 반환 (P1)** — caller 가 추적, 라벨 파싱 금지 |

**Append site count**: resolver 9 sites + coordinator 4 sites = **13 sites**. 출처 분류 6 종류 → 각 출처가 여러 site 에 매핑 (ex: base character 가 4 sites). plan task 1 (meta plumbing) 이 13 sites 모두 enumerate.

**P1 적용 (강화)**: 각 append site 가 이미 보유한 source id 사용. **라벨 파싱 명시 금지** — silent fallback 의 변형. 이를 위해 다음 두 helper 변경이 D5 scope:

#### 4.2.1 `background_chain_bg_map` entry 확장 (I2)

현재 (`scene_checkpoint_loaders.py:135-141`):
```python
bg_map[key] = {"image_bytes": image_bytes, "label": "..."}
```
신규:
```python
bg_map[key] = {
    "image_bytes": image_bytes, "label": "...",
    "bg_id": bg_id, "location_id": loc_id,  # P1 — caller 가 라벨 파싱 안 하도록
}
```
변경 위치: `_ingest_phase7_groups_shape` (line 93~143) + `_ingest_phase5_shape` (있으면) — bg_id/loc_id 는 이미 함수 안에서 변수로 보유 (line 105 `bg_id`, line 116 `loc_id`).

#### 4.2.2 `build_prev_shot_background_ref` return 확장 (I3)

현재 (`scene_reference_service.py:676-804`):
```python
def build_prev_shot_background_ref(...) -> Optional[Tuple[str, bytes]]:
    ...
    return (label, best_prev_bytes)
```
신규:
```python
def build_prev_shot_background_ref(...) -> Optional[Tuple[str, bytes, str]]:
    ...
    # loc_id 결정 우선순위 (helper 가 source-of-truth):
    #   1. dep_scene_id 가 있으면 그 dep_scene 의 location_id
    #   2. 아니면 current_location_ids 중 location_scene_history 에 첫 매칭된 loc_id
    # caller 의 current_location_ids[0] 을 단순 채택하지 않음 (helper 내부 선택과
    # 일치 보장 X — §4.2.2 대안 거부 사유 참조).
    return (label, best_prev_bytes, loc_id)
```
caller (`scene_generation_coordinator.py:314-337` 단건 / `:590` 배치) 가 3-tuple 받아서 attached_meta 에 `("background_prev_shot", loc_id)` insert.

**대안 거부**: caller 가 `current_location_ids[0]` 을 자체 추적하는 방식. 거부 이유 — `build_prev_shot_background_ref` 내부에서 `dep_scene_id` 우선 / `location_scene_history` 첫 매칭 fallback 으로 선택된 prev_still_data 의 loc 가 caller 의 current_location_ids 와 일치한다는 보장 없음. helper 가 source-of-truth.

### 4.3 helper return signature 변경 (단건/배치 동시)

```python
# 기존
def resolve_refs_for_prompt(...) -> list[tuple[str, bytes]]:
    ...
    return labeled_refs

def build_scene_attached_refs(...) -> tuple[str, list[tuple[str, bytes]]]:
    ...
    return prompt, labeled_refs

# 신규
def resolve_refs_for_prompt(...) -> tuple[list[tuple[str, bytes]], list[tuple[str, str]]]:
    ...
    return labeled_refs, attached_meta

def build_scene_attached_refs(...) -> tuple[str, list[tuple[str, bytes]], list[tuple[str, str]]]:
    ...
    return prompt, labeled_refs, attached_meta
```

`_build_image_index_helper` 는 label 만 rewrite, meta 는 그대로 평행 통과 (label 이 변형되어도 meta 는 변하지 않음 — P1 강화).

### 4.4 validator 재작성

```python
def validate_attached_refs(
    rpc: Optional[Dict[str, Any]],
    labeled_refs: List[Tuple[str, bytes]],
    attached_meta: List[Tuple[str, str]],
    prompt: str,
    is_close_framing: bool,
    *,
    chain_bg_lookup: Optional[Callable[[str], Optional[str]]] = None,
) -> None:
    """
    spec D5 §4.4 — identity-level required_refs vs attached_meta + prev_shot
    lineage + readiness_policy enforce.
    """
    # invariant 1: length match
    if len(labeled_refs) != len(attached_meta):
        raise RefContractError(
            f"meta length mismatch: labeled_refs={len(labeled_refs)} "
            f"attached_meta={len(attached_meta)}"
        )

    # rpc shape (기존 D2 strict extractor 그대로 보존 — `or {}` 금지)
    if rpc is None:
        required: Dict[str, List[str]] = {}
        readiness: Optional[str] = None
    else:
        # 기존 ref_contract_validator.py:222-256 의 strict presence + type checks
        # 모두 그대로 유지. `asset_req = rpc.get(...) or {}` 같은 silent skip 패턴
        # 도입 금지 (D2 회귀 방지 — Codex/사용자 binding).
        if not isinstance(rpc, dict):
            raise RefContractError(f"rpc malformed: must be dict, got {type(rpc).__name__}")
        if "asset_requirements" not in rpc:
            raise RefContractError("rpc malformed: 'asset_requirements' field missing")
        asset_req = rpc.get("asset_requirements")
        if asset_req is None:
            raise RefContractError("rpc malformed: 'asset_requirements' is None")
        if not isinstance(asset_req, dict):
            raise RefContractError(f"rpc malformed: 'asset_requirements' must be dict")
        if "required_refs" not in asset_req:
            raise RefContractError("rpc malformed: 'asset_requirements.required_refs' missing")
        raw_required_refs = asset_req.get("required_refs")
        if raw_required_refs is None:
            raise RefContractError("rpc malformed: 'asset_requirements.required_refs' is None")
        required = _normalize_required_refs(raw_required_refs)
        readiness = asset_req.get("readiness_policy")  # optional — None 허용

    attached_set = set(attached_meta)  # set of (kind, id)

    # 1. character_outlook strict — base/state_variant 불통.
    # close_framing 분기 없음: close framing 시에도 character_outlook 은 항상 strict
    # (close framing 의 면제는 background 만 적용 — §4.4 검사 2).
    for cid in required.get("character_outlook", []):
        if ("character_outlook", cid) not in attached_set:
            raise RefContractError(
                f"required character_outlook {cid!r} missing — "
                f"attached={sorted(attached_set)} "
                f"(base 'character' / 'character_state' do NOT satisfy)"
            )

    # 2. background — exact bg_id OR prev_shot lineage 일치
    if not is_close_framing:
        for bg_id in required.get("background", []):
            if ("background", bg_id) in attached_set:
                continue
            # prev_shot lineage substitute 허용 (chain_bg checkpoint 의 bg_id → loc_id 역참조)
            required_loc = chain_bg_lookup(bg_id) if chain_bg_lookup else None
            if required_loc and ("background_prev_shot", required_loc) in attached_set:
                continue
            raise RefContractError(
                f"required background {bg_id!r} missing "
                f"(expected exact ('background', {bg_id!r}) or "
                f"('background_prev_shot', {required_loc!r})) — "
                f"attached={sorted(attached_set)}"
            )

    # 3. readiness_policy=block_if_missing consistency
    if readiness == "block_if_missing" and not any(required.values()):
        raise RefContractError(
            "rpc drift: readiness_policy=block_if_missing but required_refs is empty"
        )

    # 4. phantom guard 유지 (label 검사 X — meta 기반 ref 존재 판정)
    classifier_matches = classify_from_the_reference(prompt)
    for match in classifier_matches:
        ref_type = match["type"]
        pos = match["position"]
        if ref_type == "ambiguous":
            logger.warning(...)
            continue
        if ref_type == "character" and not _meta_has_kind(attached_set, ("character", "character_outlook", "character_state")):
            raise RefContractError(...)
        if ref_type == "background" and not is_close_framing and not _meta_has_kind(attached_set, ("background", "background_prev_shot")):
            raise RefContractError(...)
        if ref_type == "object" and not _meta_has_kind(attached_set, ("prop",)):
            raise RefContractError(...)


def _meta_has_kind(attached_set, kinds) -> bool:
    return any(kind in {k for k, _ in attached_set} for kind in kinds)
```

### 4.5 chain_bg lineage lookup

`chain_bg_lookup(bg_id) -> loc_id | None` — production 의 actual checkpoint 이름 정확 인용:
- **primary**: Phase 7 `background_render` checkpoint (`scene_checkpoint_loaders.py:149`)
- **fallback**: Phase 5 `background_chain_render` checkpoint (`scene_checkpoint_loaders.py:176`)

두 checkpoint 모두 `data.groups[bg_id].location_id` 보존 (loader.py:116). 단순한 방법은 §4.2.1 의 bg_map entry 확장 (이미 D5 scope) 을 그대로 활용 — caller 가 별도 lookup 함수 build 안 해도 됨:

```python
def build_chain_bg_lookup(background_chain_bg_map):
    """이미 build 된 bg_map 으로부터 bg_id→loc_id 역참조 lambda 반환.
    bg_map entry 가 §4.2.1 형식 (bg_id, location_id 보존) 이므로 추가 IO 없음.
    """
    bg_to_loc = {
        entry["bg_id"]: entry["location_id"]
        for entry in background_chain_bg_map.values()
        if entry.get("bg_id") and entry.get("location_id")
    }
    return lambda bg_id: bg_to_loc.get(bg_id)
```

caller (`_build_single_scene_prompt_and_refs` + batch `generate_images`) 가 이 lookup 을 한 번 build 후 validator 에 전달. **§4.2.1 bg_map entry 확장이 본 lookup 의 prerequisite** — D5 task 1 (meta plumbing) 에 포함.

### 4.6 batch path wiring

`scene_generation_coordinator.generate_images` (배치) 도 단건 helper 와 같은 시그니처 사용. 정확한 변경 지점 (line citation 검증됨):

| line | 기존 동작 | 신규 동작 |
|---|---|---|
| `:526` | `labeled_refs = resolve_refs_for_prompt(...)` | `labeled_refs, attached_meta = resolve_refs_for_prompt(...)` |
| `:555` | `labeled_refs.insert(0, (_bc_bg["label"], _bc_bg["image_bytes"]))` | 동시 `attached_meta.insert(0, ("background", _bc_bg["bg_id"]))` |
| `:590` | `labeled_refs.insert(0, _prev_shot_ref)` (`_prev_shot_ref = (label, bytes)`) | helper 가 3-tuple 반환 (§4.2.2) → `(label, bytes, loc_id)` 분해 후 동시 `attached_meta.insert(0, ("background_prev_shot", loc_id))` |
| `:597` | `labeled_refs, _sid_to_img, _sid_info = _build_image_index_helper(labeled_refs, entity_lookup)` | label 만 rewrite, attached_meta 는 변경 없이 평행 통과 — helper 시그니처 변경 없음 (P1 강화) |

배치는 **씬별 N stills 루프** 안에서 RPC lookup + validator 호출. 한 씬 fail-fast 시 그 씬만 차단 (다른 씬은 계속) — 기존 fail-fast 정책 보존. validator 호출 자체가 배치에 신규 추가 (기존 D1~D4 spec 의 후속 항목 "B. batch path validate_attached_refs 적용" 을 D5 가 동시 해결).

---

## §5. Acceptance Criteria

### AC-1. S8 회귀 fixture 차단 (사용자 binding)

**Given**: S8_Shot4 manifest (`asset_requirements.required_refs = [character_outlook C01O02, background bg_store_…]`)
**And**: labeled_refs = `[("Image 1 (character reference): 수리영 — Passport-style ID photo … plain white background …", bytes)]`
**And**: attached_meta = `[("character", "C01")]` (composite 부재 → base fallback)
**And**: is_close_framing = False
**When**: `validate_attached_refs` 호출
**Then**: `RefContractError` raise — `"required character_outlook 'C01O02' missing"` (P2 적용 — base 가 outlook 만족 X)

### AC-2. C01O02 composite 정상 첨부 시 통과

**Given**: 같은 manifest
**And**: attached_meta = `[("character_outlook", "C01O02"), ("background", "bg_store_…")]`
**When**: `validate_attached_refs`
**Then**: raise 없음.

### AC-3. prev_shot lineage 일치 시 background 요구 통과

**Given**: required `background bg_kitchen_…` (bg_id → loc=L05)
**And**: attached_meta = `[("character_outlook","C01O02"), ("background_prev_shot","L05")]`
**And**: chain_bg_lookup("bg_kitchen_…") = "L05"
**When**: validator
**Then**: raise 없음.

### AC-4. prev_shot lineage 불일치 차단

**Given**: required `background bg_kitchen_…` (loc=L05)
**And**: attached_meta = `[("character_outlook","C01O02"), ("background_prev_shot","L08")]` (다른 location)
**When**: validator
**Then**: `RefContractError` — `"required background 'bg_kitchen_…' missing"`.

### AC-5. meta length mismatch fail-fast

**Given**: labeled_refs = 2개, attached_meta = 1개
**When**: validator
**Then**: `RefContractError` — `"meta length mismatch"`. 다른 검사 진입 안 함.

### AC-6. readiness_policy drift

**Given**: rpc.asset_requirements = `{required_refs: [], readiness_policy: "block_if_missing"}`
**When**: validator
**Then**: `RefContractError` — `"readiness_policy=block_if_missing but required_refs is empty"`.

### AC-7. close framing 시 background 면제 보존

**Given**: required `background bg_…`, attached_meta 에 background 없음, `is_close_framing=True`
**When**: validator
**Then**: raise 없음 (close framing 정책 보존).

### AC-8. state_variant 가 character_outlook 만족 X

**Given**: required `character_outlook C01O02`
**And**: attached_meta = `[("character_state","C01:unconscious")]`
**When**: validator
**Then**: `RefContractError` — `"required character_outlook 'C01O02' missing"` (P2 적용).

### AC-9. batch path 동시 wiring

`generate_images` 가 새 helper return signature `(prompt, labeled_refs, attached_meta)` 사용. 단건과 배치 모두 같은 validator 호출. 두 경로의 contract enforcement 동등.

### AC-10. label 변경 0

LLM-facing label 형식 (`"Image N (character reference): ..."` 등) 변경 안 함. Gemini 송신 payload 동일. 회귀 0.

### AC-11. tests update

기존 substring 의존 validator tests (`tests/core/test_ref_contract_validator.py` 등) → meta 기반 assertion 으로 update. 신규 tests:
- AC-1 ~ AC-8 각각 1개 이상
- meta 매핑 6 출처 (composite/base/state_variant/prop/chain_bg/prev_shot) 각각 unit
- chain_bg_lookup unit (checkpoint 부재/존재/bg_id 미스)
- helper return signature 변경 후 기존 호출 site 모두 update 검증

### AC-12. 회귀 0 (broader test suite)

전체 backend test suite 회귀 0건. token canary baseline 유지 (LLM prompt 변경 0).

### AC-13. bg_map entry 확장 contract (§4.2.1)

`load_background_chain_bg_map` 가 반환하는 dict 의 모든 entry 가 `bg_id` + `location_id` 키 보유. 라벨 파싱 없이 caller 가 직접 read. unit test: Phase 7 groups shape + Phase 5 fallback 모두 검증.

### AC-14. prev_shot helper 3-tuple contract (§4.2.2)

`build_prev_shot_background_ref` 가 None 또는 `(label, bytes, loc_id)` 반환. loc_id 결정 우선순위 보존: dep_scene_id 의 location → location_scene_history 첫 매칭. caller 2곳 (단건 line 314 + 배치 line 590) 의 unpacking 변경 검증.

### AC-15. custom_prompt 경로 변경 0 (out-of-scope 검증)

`generate_single_scene_image(custom_prompt="...")` 호출 시 `build_custom_labeled_refs` (`scene_reference_service.py:866`) 동작 변경 없음. validator 호출 안 됨. operator 가 D5 fail-fast 우회 가능 — 명시적 out-of-scope (§3 + F7 후속).

---

## §6. Migration / Rollback

### 6.1 Migration

데이터/스키마 변경 0. 코드 변경만:
1. helper return signature 확장 (resolver/builder/validator/single+batch caller) — 13 append site (§4.2)
2. validator 재작성 (substring → meta exact match, D2 strict extractor 보존)
3. `background_chain_bg_map` entry 확장 (§4.2.1) — `_ingest_phase7_groups_shape` + Phase 5 fallback 모두
4. `build_prev_shot_background_ref` return signature 확장 (§4.2.2) — caller 2곳 update
5. `chain_bg_lookup` helper (§4.5) — bg_map entry 의존

기존 배포 영향: production 에서 force regen 시 D5 fix 가 즉시 적용됨. composite 부재 / lineage mismatch 결함이 fail-fast 로 노출 → operator 가 chain_bg 재생성 또는 composite 재생성으로 복구.

### 6.2 Rollback

`validate_attached_refs` 의 substring 분기로 revert (단일 commit). meta 평행 리스트는 production payload 영향 없으므로 rollback 안 해도 무해. helper return signature 확장만 caller 와 함께 묶어서 revert.

### 6.3 Risk

- **Risk 1**: composite 부재 stills 가 D5 fix 후 fail-fast 로 차단 → operator 가 composite 재생성 필요. chain_bg 부재 stills 동일.
  - mitigation: production canary 1 still 부터 단계적. 실패 시 composite step / chain_bg step 의 상류 결함 진단.
- **Risk 2**: prev_shot lineage lookup 이 chain_bg checkpoint 부재 환경 (`background_mode_on=false`) 에서 모든 prev_shot 차단 가능.
  - mitigation: `chain_bg_lookup` 가 None 반환 시 (= checkpoint 부재) prev_shot 통과 허용 (background 요구가 RPC 에 없으므로 자연스럽게 hit 안 함). 단 background 요구 + chain_bg 부재 + prev_shot only 환경은 새 RefContractError 노출 — 이 경우는 의도 (operator 점검 필요).
- **Risk 3**: helper return signature 변경이 외부 caller (테스트/스크립트) 깨뜨림.
  - mitigation: AC-11 의 tests update 항목으로 모든 caller 갱신. grep 으로 caller site 전수 점검.

---

## §7. Out-of-Scope Followups (다음 spec 후보)

- **F1. forbidden_refs / extra ref 차단 정책** — D5 가 `("prop", "P03")` 등 모든 ref 출처를 meta 에 기록하므로, 후속 spec 에서 차단 규칙만 추가하면 즉시 enforce 가능.
- **F2. AttachedRef dataclass 정식화** — 평행 리스트가 production 운영 중 유지보수 부담 노출 시 4-tuple/dataclass 로 promote.
- **F3. resolver RPC-driven 재설계** — D5 fail-fast 가 production 에서 자주 trip 하면 resolver 가 prompt 대신 `required_refs` 입력으로 attach 결정.
- **F4. state_variant ↔ character_outlook 대체 정책** — outlook 포함 state_variant composite 자산이 도입되면 정책 재정의.
- **F5. `image_asset.reference_image_ids` / `actual_attached_refs` 활성화** — D2 영역, 별 spec.
- **F6. `classify_from_the_reference` phrase 분류기 개선** — 동일 substring false-positive 가 phrase 윈도우 ±60자 안에서도 발생 가능.
- **F7. `custom_prompt` (manual override bypass) 경로 D5 enforce** — `generate_single_scene_image(custom_prompt=...)` 분기 + `build_custom_labeled_refs` 도 meta 평행 리스트 + validator 통과. operator 가 prompt 편집 후에도 contract 우회 불가.

---

## §8. References

- `app/core/ref_contract_validator.py:89-100` — `_has_*_ref` substring 매칭 (D5-1)
- `app/core/ref_contract_validator.py:222-256` — D2 strict extractor (D5 R2 보존)
- `app/services/scene_reference_service.py:141-146` — `build_image_index` t2i_prompt inline (D5-2)
- `app/services/scene_reference_service.py:408-425` — composite/base fallback (D5-4)
- `app/services/scene_reference_service.py:676-804` — `build_prev_shot_background_ref` (return 확장 대상, §4.2.2)
- `app/services/scene_reference_service.py:866-879` — `build_custom_labeled_refs` (custom_prompt out-of-scope evidence)
- `app/services/scene_checkpoint_loaders.py:93-143` — `_ingest_phase7_groups_shape` (bg_map entry 확장 대상, §4.2.1)
- `app/services/scene_checkpoint_loaders.py:146-200` — `load_background_chain_bg_map` (Phase 7/5 dual loader)
- `app/services/scene_generation_coordinator.py:526/555/590/597` — 배치 path append/insert sites
- `app/services/prompt_service.py:43-72` — RC-D 토큰 매칭 (D5-5 학습 회귀 evidence)
- `app/core/steps/render_prompt_card.py:1690-1704` — RPC required_refs producer
- `docs/superpowers/specs/2026-05-08-single-batch-reference-contract-design.md` — D1~D4 직전 spec
- 진단 reproduction script: 본 spec 작성 시 사용한 직접 호출 — `_build_single_scene_prompt_and_refs(S8)` → `labeled_refs (count=1)` + validator 통과 확인
