# GROUNDING-V2 · D — 다섯 갈래 owner 표

> 2026-08-31. **D 원자적 cutover 의 선행 문서**다. 여기서 정한 것만 구현한다.
> 근거 없이 지어낸 칸은 **없다** — 없는 것은 「없음」으로 적고 사람에게 물었다.

## 1. owner 표

| owner | entity SOT type | part_of 부모 | 하류 참조 kind/slot | 근거 |
|---|---|---|---|---|
| `character` | `character` ✅ 있음 | — | `character` (id `C##`) | `render_prompt_card.research_forced_refs` · `ref_contract_validator._ALLOWED_KINDS` |
| `prop` | `prop` ✅ 있음 | — | `prop` (id `P##`) | 〃 |
| `location` | `location` ✅ 있음 | — | `background` (id `L##`) | `ref_contract_validator:72` 의 `{"background": ["L01"]}` · `render_prompt_card:2371` |
| `outlook` | `outlook` ✅ 있음 | `character` | `character_outlook` (id `C##O##`) | `ref_contract_validator:70,72` · `visible_entities_validator:701` |
| `location_part` | ★**없다** — 새로 넣어야 한다 | `location` | **`background`** — 부모 장소의 자리에 **같이** 붙는다. id 는 `LP##` 를 그대로 쓴다 | ★**사용자 확정 2026-08-31** (코드에 근거가 0건이라 물었다) |

★`FACET_PARENT` = `{"outlook": "character", "location_part": "location"}`
(`grounding_facet_binding`). **이 표가 관계다 — 이름을 안 본다.**

★금지: `else prop` · `LP##` → location 강등 · `outlook` → character 승격 ·
base location 을 prop 으로 우회 등록(계약 §7 이 이미 닫았다).

## 2. 참조 여러 장 — ★**제 앞선 보고가 과했다**

사용자 확정: 「참조 이미지 여러 개로 해도 돼」.

★내가 「개수 제한 없이 이미 된다」고 적었는데 **틀렸다** (Codex 정정 2026-08-31).
근거로 든 `image_asset.reference_image_ids` 의 1~6장은 **모든 종류를 합친 수**
이지 배경 묶음이 아니다. 실제로는 —

    되는 것    `_normalize_required_refs` → `Dict[kind, List[id]]`
               `LabeledRefPayload` 의 평행 list 도 복수를 받는다
               야외 직접 경로는 이미 **사진+지도 두 장**을 같은 `background`
               kind 로 붙이는 **선례**가 있다
    ★안 되는 것 일반 씬 배경 경로는 `background_chain_ref` **이냐**
               prev-shot ref **이냐** 하나를 넣는 **단일 분기**다
               (`scene_generation_coordinator:901,940`).
               `ref_contract_validator:318` 이 못박는다 —
               「shot 의 **background ref slot 은 1개**이므로 required 복수여도
                전체 충족 처리」
               shot-aware background DAG 에는 **max 2** 제한도 따로 있다

### 그래서 어떻게 하나

**새 슬롯을 만들지 않는다.** 기존 `background` kind/list 를 **넓힌다**.
대신 `ref_role_metadata` 에 갈래를 남긴다 —

    grounding_subject_id     이 참조가 어느 조사 대상에서 왔나
    lp_final_id              `LP##`
    purpose                  `context` | `detail`
    parent_relation          `part_of` 관계 (있을 때)
    asset_lineage            어느 자산에서 왔나

`background_general` **역할**을 재사용할 수 있는지는 **실제 프롬프트 조립
끝점**에서 확인한다. 모자라면 **역할(role)** 을 더하는 것은 되지만 **슬롯을
새로 만드는 것은 아니다**.

★활성화 **전에** 무료 끝점으로 본다 —
`기존 plate/prev + context + detail` 이 네 평행 list 에서 **보존되고**
validator 를 통과하나 · 이미지 프롬프트에 각 `Image N` 과 역할이 **다 실리나**.
그다음 **실제 provider canary** 로 들어가는 이미지 **수**를 확인한다.
★상한은 다른 파이프의 2를 베끼거나 아무 수나 박지 말고 **provider/dispatch
계약 한 곳**에서 정한다. 넘으면 **조용히 자르지 않는다**.

## 3. `reference_unavailable` 처리 — 다섯 갈래 **모두 같다**

    selected               참조 의무가 생긴다. 그 참조를 소비한다
    reference_unavailable  **감사에 남고 하류를 막지 않는다**

★raw `status`(`no_match_after_retry` · `retryable`)를 **지우지 않는다** —
왜 못 구했는지는 감사에 필요하다. `outcome` 은 그 위에 얹는 **하류가 보는 한
칸**이다(`reference_acquisition_rounds.acquire_one` 이 둘 다 적는다,
2026-08-31 R2 에서 다섯 갈래 전부 확인).

★사람을 기다리는 상태는 **없다**(사용자 확정: 「궁극적 목적은 자동화이니
HITL 을 무조건 필요한 요소로 하면 안 된다」).

## 4. 부모에 포괄된 부분 — ★**부분 전체를 건너뛰지 않는다**

★★앞 판 문서가 §4 와 §5 에서 **서로 다른 말을 했다** (Codex BLOCK 2026-08-31).
§4 는 「부모가 같이 대상이면 부분을 **통째로** 안 산다」(`parts_skipped`),
§5 는 「hard+notice `location_part` 는 **늘** 맥락+상세 둘로 넓힌다」였다.
사용자 확정에 맞는 것은 **후자**다.

    부모가 이미 대상이다
      → 묶음의 **맥락 멤버만** `reused_parent_reference` /
        `covered_by_parent` 로 **재사용**한다
      → ★LP 의 **상세 멤버는 계속 따로 산다**
      → 같은 bytes 면 **묶음 조립에서** dedupe

★`covered_by_parent` 는 **LP 행 전체의 처분이 아니다** — 묶음의
**맥락 멤버 처분**이다. 행을 통째로 없애면 부분의 생김새를 영영 못 얻는다.

남기는 것 —

    disposition            covered_by_parent   ← **맥락 멤버에만**
    parent_final_id        부모의 정본 ID
    reference_lineage      부모가 어느 참조를 얻었나

★그래야 「다섯 갈래가 모두 screen 을 거쳤다」를 **증거로** 말할 수 있다.

## 5. 부모가 대상이 아니거나 **행에 아예 없을 때** — 닫음

실측 (얼어붙은 1960년대 원고 판독, 대상 12개) —

    location 갈래에서 대상이 된 것은 「길 건너 국밥집」 **하나뿐**이다.
    「이발소」는 **행에 아예 없다.**

그래서 이런 부분들이 **홀로** 대상이 됐다 —

    c0#2   이발소 회전 간판   부모 **없음**
    c0#22  벽에 붙은 종이     부모 c0#21 「벽」이 대상 아님
    c0#40  값을 적은 나무판   부모 c0#39 「벽」이 대상 아님
    c0#37  창              부모 **없음**

사용자 확정: 「저건 당연히 1960년대 이발소 해서 하나로 검색 조사해야 해 …
아님 **둘을 찾아서 참조 두 개 이상으로** … 참조 이미지 여러 개로 해도 돼!」

### ★★결론 (Codex 2026-08-31) — **부모를 지어내지 않는다**

앞서 나는 「부모를 대상으로 끌어올린다」로 생각했는데, 그것은
**엔티티 관계와 검색 맥락을 같은 것으로 본 설계 잘못**이다. 원문에 독립된
부모가 없는데 엔티티를 만들면 **없는 신원을 지어내고** 같은 장소를 구간마다
중복 생성한다.

**대신 producer 가 `location_part` 마다 구조화된 `host_context` 를 낸다** —

    state            bound_parent | explicit_context_only | unresolved
    parent_local_id  있을 때만
    search_subject   자립적인 거친 부류 이름 (부모 이름을 자르지 않는다)
    evidence         원문 근거 span

    bound_parent            원문에 부모가 독립 대상으로 있다 → 기존 row + part_of
    explicit_context_only   「이발소 회전 간판」처럼 **장소 맥락은 있는데 부모
                            row 가 없다** → 부모 entity/final_id 를 **안 만들고**
                            그 맥락을 LP01 묶음의 **보조 검색 대상**으로 쓴다
    unresolved              맥락이 정말 없다 → 기록하고 **부분만 사서 자동 완료**

★고정 명사 하드코딩·이름 substring 분해 **0건**. HITL **없음**.

### 어느 자리에서 하나

    producer                 의미를 낸다 (`host_context` · `part_of`)
    reduce_episode           **검증·합치기만**. 새 부모 의미를 **만들지 않는다**.
                             conflict·unresolved 를 보존한다
    build_reference_bundle_plan   ★새 결정적 planner. hard+notice 인
                             `location_part` 를 **맥락 의무 + 상세 의무**로 넓힌다
    targets_from             이 planner 를 부르는 **얇은 adapter**. 여기서
                             이름을 해석하거나 부모를 발명하면 **안 된다**

★부모 row 가 있으나 독립 hard+notice 대상이 **아니어도** 맥락 의무는
**새 screen 판정 없이** 부분 의무에서 파생된다. 이것은 **새 엔티티 등록이
아니라** LP 참조를 위한 **보조 의무**다. 부모가 이미 획득 대상이면
**신원·캐시로 재사용**해 중복 구매를 막는다.

### 한 장인가 두 장인가

**둘 다 찾는다.** 맥락과 상세를 각각 검색·거친 선택하고 —

    URL/내용 해시가 같으면   한 장으로 합친다(dedupe)
    다르면                  **두 장 다** 같은 background 묶음에 붙인다

★「한 장이면 충분한가」를 **VLM 에게 묻지 않는다.** 각 선택은 여전히
「이 사진이 **장소 종류인가 / 부분 종류인가** + 보이는가」만 본다.
시대·정확한 형태·좋고 나쁨은 **검색 좌표와 생성 후단**의 몫이다.

`selected`/`reference_unavailable` 어느 조합이든 **terminal** 이고 하류를
막지 않는다. 묶음은 **0~2장의 선택 참조와 각각의 raw 처분**을 durable 로 남긴다.

★`host_context` 는 **판독 schema 변경**이다 → **새 팩 버전**과
**chunk acquisition identity 변경**이 따라온다.

## 6. D 범위 — **둘을 갈라 적는다** (Codex 2026-08-31 정정)

**(가) 활성 접두 소비자 — 2곳**

    episode_reference_policy_step.py:174    C/P 만 받는다
    render_prompt_card.py:2197              C/P 만 받는다

같은 규칙이 두 곳이라 **한 계약으로 모은다**. `owner_of_final_id` 한 벌을
쓴다(지금 사용처는 3곳, 전부 adapter).

★`background_planner`(19.55)는 manifest 가 `applicability='disabled'` 인
**죽은 스텝**이라 D BLOCK 이 아니다. `export_service.py:96` 은 파이프라인 밖
API 라 **별도 debt**. (내가 처음 「셋」으로 센 것을 정정한다.)

**(나) 원자적 cutover 전체** — 파서 둘보다 크다

    새 producer/adapter ON
    옛 grounding_plan / grounding_research **no-call**
      ★`episode_reference_policy` 가 아직 `grounding_research` CP/DB revision 과
       `grounding_plan._short_id` 를 **정본으로 읽고** manifest 도 그것에
       의존한다. source 와 dependency 를 **새 중앙 acquisition CP 의
       `final_id` + raw `status` + `outcome`** 으로 같이 바꿔야 한다
    filter / protection
    acquisition projection **한 벌** — render card 도 같은 것을 소비
    `generation_difficulty` 임시 다리 제거 — 새 SOT 와 소비자가 **동시에 선** 뒤에만
    stamp bump · invalidation

**(다) D 선행** — `kind_name_of` 의 **조용한 fallback** 제거 (Codex NON-BLOCK).
빈 부류 이름이면 심판에게 표기를 보내지 말고 raw 사유를 적은
`reference_unavailable` 로 **자동 완료**한다. 파이프라인을 막지 않는다.

## 7. `location_part` 를 DB 에 넣으려면 — 실측한 손댈 자리

**마이그레이션은 필요 없다.** `entity_canon.entity_type` 은 **제약 없는 `text`**
칸이다(DB 제약 0건). 막는 것은 **코드 상수**뿐이다.

### (가) 늘려야 할 것 — 셋

    grounding_entity_contract.MATERIALIZABLE_OWNER_TYPES
      = ("character", "location", "outlook", "prop")   ← `location_part` 없음
      소비처: grounding_chunk_adapter(2) · grounding_facet_binding.CANON_TYPES

    entity_metadata._ALLOWED_ENTITY_TYPES
      = frozenset({"character", "location", "prop"})   ← outlook 도 없다
      ★밖이면 `validate_entity_metadata_shape` 가 **AppError 로 선다**.
       `location_part` canon 은 저장 자체가 막힌다.
       그리고 이 함수는 갈래마다 `location`·`visual_identity` **모양을 달리**
       본다 — `location_part` 의 모양을 정해서 한 갈래 더해야 한다.

    entity_sync_service:50
      EntityCanon.entity_type.in_(["character", "location", "prop"])
      ★밖이면 **기존 canon 조회에서 빠진다** → 같은 것을 매 주행 다시 만든다
       (조용한 중복). 실패도 안 난다.

### (나) 안 건드려도 되는 것 — 긍정 필터

`entity_type == "character"` / `== "location"` 류 **20여 곳**은 「인물만 고른다」
「장소만 고른다」는 **긍정 필터**다. `location_part` 가 안 걸리는 것이 **옳다**.
전수 리팩터를 하지 않는다(Codex).

★가르는 법: **없는 것을 걸러 내는 목록**(`in (...)` · `frozenset` · `_ALLOWED`)
인지, **있는 것을 골라 내는 비교**(`== "character"`)인지 본다. 앞의 것만 고친다.

### (다) 접두 오독 — 잠재

`LP01` 은 `L` 로 시작한다. 날것 접두로 가르는 곳 중 **활성 소비자 2곳**은
`C`/`P` 만 보므로 오독하지 않고 **막기만** 한다(그것이 고칠 대상).
오독하는 둘(`background_planner_step.py:315` 죽은 스텝 ·
`export_service.py:96` 파이프라인 밖)은 위 §6 에 적었다.
계약 함수 `owner_of_final_id` 는 **가장 긴 접두 + 뒤가 전부 숫자**로 가른다 —
`LP01 → location_part`, `L03 → location`(실측 확인).

## 8. 활성화 전 무료 확인 ① — 계약 층은 **셋을 받는다** (실측)

`tests/grounding/test_background_bundle_capacity.py` (8개 통과, 유료 0).
끝점은 `ref_contract_validator.validate_attached_refs` 다.

    배경 1장                       ○ 지난다
    배경 2장 (야외 선례: 지도+사진)   ○ 지난다 — production 에서 이미 돈다
    ★배경 3장 (기존 판 + 맥락 + 상세) ○ **지난다**

★★그리고 **새로 안 사실** — **맥락·상세는 덧붙는 것이지 기존 배경판을
대신하지 못한다.** 요구된 `background` id 를 안 붙이고 조사 참조만 넣으면
`StaleUpstreamError` 로 **선다**(「위 스텝이 낡았다」로 읽는다).
D 는 이것을 **더하는** 설계여야 한다 — 바꾸는 설계가 아니다.

양성 대조 둘도 같이 잠갔다 — 평행 목록 짝이 어긋나면 서고, 선언만 하고
안 붙여도 선다.

### 확인 ②③ — **끝났다** (2026-08-31, 무료 9개 통과)

`tests/grounding/test_ref_role_reaches_the_prompt.py`.
끝점은 `prompt_service.resolve_ref_roles` — 참조를 프롬프트 문구
(`Reference image N: …`)와 지시문으로 바꾸는 자리.

**②각 `Image N` 과 역할이 다 실리나 → ○.** 배경 셋을 넣으면 셋 다
프롬프트 줄과 지시문을 얻고 차례도 지켜진다.

**③`background_general` 재사용 → ★절반만.** 그 역할이 내는 지시문은 —

    「image N 의 **빛·건축·분위기**를 쓰라」

**그 장소의 공기**를 가져오라는 말이다. `location_part` 의 —

    맥락 멤버(그 장소)      → ○ 맞다. **재사용한다**
    상세 멤버(회전 간판 자체) → ★틀리다. 근접 사진에서 「분위기」를
                             가져오라는 말이 된다

견줌: 물건 역할(`prop_ref`)은 「**그 물건을 넣어라**」고 한다 — 상세에
필요한 것은 이쪽 결이다.

★그래서 **상세는 새 역할**이 필요하다. `REF_ROLE_VALUES` 는 **닫힌 목록**
이라 모르는 역할이면 `RefRoleError` 로 **즉시 선다**(조용히 안 흘러간다).
선언하고 갈래를 쓰는 것 말고는 길이 없다 — 이것이 Codex 가 말한
「**slot 이 아니라 role 을 더한다**」의 뜻이다.

### 남은 확인 — 유료 하나 (Codex)

    ②이미지 프롬프트에 각 `Image N` 과 **역할**이 다 실리나 — 조립 끝점에서
    ③기존 `background_general` 역할을 재사용할 수 있나 — 못 하면 **역할**을
      더한다(슬롯은 안 만든다)
    ④그다음 **실제 provider canary** 로 들어가는 이미지 **수**를 센다.
      ★상한은 다른 파이프의 2를 베끼거나 임의로 박지 말고
       **provider/dispatch 계약 한 곳**에서 정한다. 넘으면 조용히 안 자른다

## 9. ★DESIGN BLOCK 넷 (Codex 2026-08-31) — 실측과 함께

### BLOCK 1 — §4/§5 모순 → §4 를 고쳤다 (위)

### BLOCK 2 — sync 계약을 **너무 작게 셌다**

내가 「늘릴 목록 셋」이라고 적었는데 `EntitySyncService.sync_from_checkpoint`
는 **전체가 C/L/P 전용**이다 (실측) —

    :33   is_cp_syncable(t2i_cp, ["characters","locations","props"])
    :77   for etype in ["characters","locations","props"]
    :97   cp_short_ids prepass — 같은 셋
    :108  conflicting query
    :128  _TYPE_DEFS  (etype, singular, prefix)
    :73   ★**접두 counter**

★★:73-76 이 가장 나쁘다 —

    prefix = e.short_id[0]                       # LP01 → "L"
    _counters[prefix] = max(..., int(e.short_id[1:]))   # int("P01") → ValueError
    except (ValueError, IndexError):
        logger.warning("malformed existing short_id skipped: %s", ...)

즉 **죽지 않는다.** 예외를 잡아 「형식이 이상하다」로 **건너뛴다** — 그러면
`L` counter 가 실제보다 작아져 **이미 있는 `L##` 를 다시 발급**할 수 있다.
필터 하나만 늘리면 저장·재개·충돌 정리가 **갈린다**.

★그리고 이 서비스 주석이 「outlook 은 **OutlookSyncService 별도 관할**」이라고
명시한다 — `_ALLOWED_ENTITY_TYPES` 나 base sync 에 outlook 을 **그냥 더하면
안 된다**.

**문서에 적어야 할 것** — 아래 사슬 **전체**와 `location_part` 의 소유자 —

    entity_merge / adapter
      → entity_detail·t2i CP 의 `location_parts` 모양
        → canonical sync / upsert
          → episode link

`location_part` 가 이 sync 를 **넓힐지**, **별도 sync 소유자**를 둘지 한 벌로
정한다. 기존 canon 재사용 키도 **이름 단독이면 안 된다** — 최소
`(entity_type, name)` 이거나 정본 short_id 여야 `location` 과
`location_part` 의 **같은 이름**이 조용히 충돌하지 않는다.
chunk 의 `O` 행이 기존 `OutlookSyncService`/phase 와 어떻게 결속돼
**중복 O 엔티티를 안 만드는지**도 **producer 소유권**으로 닫는다.

### BLOCK 3 — ★현재 validator 는 **조사 배경이 없어도 지난다**

실측 —

    ref_contract_validator:300   `if not is_close_framing:`
      → ★**클로즈업이면 배경 검사가 통째로 사라진다.**
        부분 클로즈업에서 LP 상세가 안 붙어도 안 걸린다
    ref_contract_validator:318   「shot 의 background ref slot 은 1개이므로
      required 복수여도 **전체 충족 처리**」
      → ★`space_set_bg` / `outdoor_canon` **한 장**이 있으면 요구된 배경
        **전부**를 충족한 것으로 면제된다.
        `selected` 인 LP 맥락/상세가 **안 붙어도 기존 판 하나로 통과**한다

**그래서** — 새 슬롯은 안 만들되, 기존 `background` kind **안에서**
**조사에서 온 `selected` 의무는 exact attachment 를 요구**한다.
기존 plate/space/outdoor 면제가 **대신 만족시키지 못하게** 구조화된
source/purpose 를 보존한다. `reference_unavailable` 멤버는 required 로
만들지 않으므로 **막지 않는다**. ★LP 상세는 **클로즈업에서도** 검증·첨부된다.

★★그리고 내가 지은 무료 시험(`test_background_bundle_capacity`)은
**「그릇이 셋을 받는다」까지만** 잰다. 반드시 production
`build_scene_attached_refs` — 또는 거기서 뽑아낸 **실제 additive helper** —
를 태워 `기존 chain/prev + 맥락 + 상세` 가 **Image N · roles · meta 까지**
가는 **끝점**이어야 한다.

### BLOCK 3 — 끝점으로 확인한 것 (2026-08-31, 무료 14개 통과)

`tests/grounding/test_background_bundle_capacity.py` 를 **production 조립
helper 를 태우도록** 고쳤다. `_insert_outdoor_canon_refs` 는 이미 production
에서 도는 **덧붙임 helper** 다(야외 직행 — 사진+지도 두 장을 같은
`background` kind 로). D 의 묶음도 **같은 모양**으로 짓는다.

잠근 계약 —

    네 평행 목록이 **같이** 늘어난다 (1 → 3, 길이 전부 일치)
    기존 배경판이 **안 사라진다**
    셋이 **같은 `background` kind** 로 붙는다
    참조마다 제 `pipeline_role`·`asset_id` 를 **따로** 갖는다
    그 산출이 **그대로** validator 를 지난다
    ★그 위에 맥락+상세를 **같은 모양으로 더 얹어도**(5장) 지나고,
     `purpose` 로 갈라 볼 수 있다

★그리고 **두 구멍이 실제로 있다**는 것을 시험으로 잠갔다 —

    구멍 ① 클로즈업이면 요구된 배경이 **없어도 지난다**
    구멍 ② 합성 배경 **한 장**이 요구된 배경 **둘**을 다 면제한다

막는 것은 D 다.

### BLOCK 4 — `host_context` 상태 계약을 **글에서 코드로**

    bound_parent            `parent_local_id` **필수** · rows 에 **존재** ·
                            owner **= location** · `part_of` **정확히 1개**
    explicit_context_only   `parent_local_id` **금지** ·
                            `search_subject`/`evidence` **필수**
    unresolved              의미 필드를 **몰래 쓰지 않는다**

`evidence` 는 **canonical span/quote 대조**를 탄다(다른 칸과 같은 문).
★같은 LP 가 여러 구간에서 **서로 다른 host_context** 를 내면 **한쪽을 고르지
않는다** — `conflict → unresolved` 로 남긴다.

묶음/멤버 신원도 결정적으로 —

    LP final_id + purpose(context|detail) + **실제 나가는 획득 신원**

그래야 **부모 재사용**과 **상세 재구매**가 안 갈린다.

### BLOCK 4 — **닫음** (2026-08-31, 무료 29개 통과)

`app/modules/pipeline/grounding_host_context.py` + 시험.
`grounding_shot_catalog` 의 세 상태 계약과 **같은 모양**이다 — 어기면
「그럴듯한 기본값」으로 접지 않고 **`unresolved` + 사유**로 내린다.

    bound_parent           `parent_local_id` 필수 · rows 에 존재 ·
                           owner=location · `part_of` 정확히 1개 ·
                           선언한 부모와 `part_of` 부모가 **같아야** 한다
    explicit_context_only  `parent_local_id` **금지** ·
                           `search_subject`·`evidence` 필수 ·
                           근거가 원문 그 자리에 **없으면** 떨어진다
    unresolved             의미 칸을 **몰래 쓰면** 떨어진다

★근거 대조는 **기존 함수**(`grounding_chunk._find_span`)가 한다 — 이 모듈은
원문을 **안 뒤진다**(AST 로 잠갔다). 두 벌이 되면 한쪽만 고쳐진다.

★구간 간 충돌은 `reconcile()` 이 **한쪽을 고르지 않고** `conflict` 사유와
함께 `unresolved` 로 남긴다 — 상태가 다르거나, 부모가 다르거나,
맥락 대상이 다르면 전부.

★묶음 멤버 신원 = `member_identity(lp_final_id, purpose, 실제 나가는 획득
신원)`. **쓰임을 빼면** 부모를 재사용했다고 **상세까지 안 사는** 일이 난다.

### BLOCK 2 — sync 소유권 **확정** (Codex 승인 + A·B·C 조건)

실측한 꼴 — **한 CP = 한 sync 서비스** —

    EntitySyncService     ← `entity_t2i` CP        → EntityCanon + EpisodeLink (C/L/P)
    OutlookSyncService    ← `outlook_phase3` CP    → EntityCanon(outlook) + CharacterOutlook
    RelationSyncService   ← `entity_relation` CP   → RelationFact / RelationParticipant
    SceneStillSyncService ← `scene_detail`+shot CP → SceneStill

`outlook` 이 제 서비스를 가진 이유는 **다른 CP 에서 오기 때문**이다.
`location_part` 는 같은 `entity_t2i` CP 로 오므로 **`EntitySyncService` 가
소유**한다. LP→location 구조 관계는 `entity_relation` CP →
`RelationSyncService` 로 보낸다. ★`orchestrate_full_sync` 가
EntitySync → RelationSync 차례라 **같은 판에서 새 LP canon 을 관계 sync 가
본다**(실측 `orchestrator.py:92-95`).

#### ★A — 「한 CP = 한 **producer**」가 먼저다

실측: `entity_relation` CP 는 **활성** `EntityRelationStep`(13.6,
`applicability='always'`)이 만든다. `grounding_chunk_adapter` 가 그 CP 를
**따로 쓰면** `visual_variant` 와 `part_of` 가 실행·재개 차례에 따라 **서로
덮는다**.

→ `v2_chunk` 에서는 **`EntityRelationStep` 자체를 결정적 no-call
  projection 으로 바꾼다.** chunk/adapter 가 준 구조 관계를 **읽어**
  `entity_relation` CP **한 벌**을 만든다. ★adapter 가 **다른 스텝의 CP 를
  직접 쓰지 않는다.**
→ CP schema 는 `relation_type` 으로 `visual_variant | part_of` 를 **명시**하고,
  `RelationSyncService` 도 **타입별로** desired/existing/delta 를 가른다.
  ★`part_of` sync 가 `visual_variant` 를 stale 로 지우거나 그 반대가 되면 안 된다.

#### ★B — `RelationFact` 의 `part_of` 는 **LP→location 만**

`outlook → character` 는 이미 `OutlookSyncService` + `CharacterOutlook` 이
SOT 다. 그것까지 `RelationFact` 에 다시 쓰면 구조 관계가 **두 벌**이 된다.
★그리고 실측으로 orchestrator 가 **RelationSync(2) → … → OutlookSync(4)**
차례라, 관계를 쓸 때 **outlook canon 이 아직 없을 수 있다**.

→ chunk 의 `O` 관계는 **phase3/OutlookSync 입력으로 구조적으로 이관**한다.
  O canon·`CharacterOutlook` 을 **중복 생성하지 않는** 끝점을 시험으로 잠근다.

#### ★C — canon 재사용 열쇠는 **`(entity_type, name)` 만으로 부족**

C(c) 의 `final_id` 가 정본인데 **이름이 바뀌면** 같은 short_id 의 옛 canon 을
비우고 **새 canon 을 만들어 중복**시킬 수 있다.

    ①`existing_by_short_id` 를 **먼저** 본다
    ②없으면 `(entity_type, name)` 로 **legacy fallback**
    ★두 열쇠가 **서로 다른 canon** 을 가리키면 **임의로 고르지 않고 fail-closed**

접두 counter 는 **새 파서를 복사하지 않는다** — `owner_of_final_id` /
`OWNER_PREFIX` **한 계약**으로 owner 와 숫자를 얻는다.
`is_cp_syncable` · data key · prepass · conflict query · `_TYPE_DEFS` ·
stale cleanup 까지 `location_parts` 를 **한꺼번에** 늘리고 **outlook 은 넣지
않는다**.

### `location_part` metadata 모양 — **중립 shape** (Codex 결론)

★내 앞 제안(`visual_identity.coarse_type_label`)은 **접는다**.

이유 (Codex 2026-08-31) —

    `visual_identity` 의 **production 의미**는 prop 의 `reference_required` 다
    거친 부류 이름은 **chunk/acquisition provenance 에 이미 있다**
    ★DB metadata 에 **복사하면** invalidation 이 **서로 다른 두 SOT** 가 된다

→ 이번 D 의 `location_part` 는 **기존 닫힌 키를 유지**해 **중립 shape** 로
  검증한다 —

    {"location": None, "visual_identity": None}     ← `character` 와 같은 꼴

    entity_type   `location_part`
    부모          `RelationFact(part_of)`            ← metadata 문자열이 **아니다**
    참조 처분      **중앙 acquisition CP** 가 소유     ← DB metadata 가 아니다

★DB 소비자가 **실제로** 거친 부류 이름을 요구하는 때에 **별도 계약**으로
연다. 미리 복사해 두면 두 곳이 되고 한쪽만 고쳐진다.

## 10. 참조 개수 상한의 SOT — **실측** (2026-08-31)

Codex: 「참조 개수는 임의 숫자를 안 박고 **고른 provider 의 capability 계약**
을 SOT 로 삼아라」. 그 자리를 찾았고, **한 벌이 없다**는 것이 답이다.

### 경로가 **둘**이고 client 가 다르다

    씬 이미지  (묶음이 실리는 곳)
      `scene_image_generator` → **`GeminiImageClient`**
      `labeled_references` 를 **돌면서 다 싣는다** — ★**상한 선언 없음**

    cine 변환
      `still_recipe_service` → `cine_provider.build_cine_client()`
        grok → `GrokImageClient`  참조를 다 싣는다 (상한 없음)
        reve → `ReveImageClient`  ★**정확히 1장**
               `reve/2.1/edit` 의 입력이 `image_url` **단수**라
               「여러 장이 오면 조용히 버리지 않고 **크게 실패**시킨다」
               (client 주석 그대로)

★지금 설정은 `still_cine_provider = reve` 다.

### 그래서

**묶음은 씬 이미지 경로에만 실린다** — 거기는 Gemini 라 목록을 받는다.
cine 변환은 **다른 경로**이고 reve 면 1장뿐이라, 묶음이 그쪽으로 새면
**크게 실패한다**(조용히 안 잘린다 — 그건 좋은 성질이다).

### 상한을 어떻게 쓰나

    provider 가 cap 을 **선언했으면**   → 조립이 만든 N 과 **대조**
    선언이 **없으면**(지금 Gemini)      → ★상한을 **발명하지 않는다**.
                                          observed N 과 provider acceptance 를
                                          **기록**한다

★이미지 **생성 호출** 상한(canary 1회)과 **참조 개수**는 **별개**다.

★★그리고 이 계약이 **한 곳에 없다** — 각 client 안에 흩어져 있다.
D 에서 `cine_provider` 처럼 「지금 무엇을 쓰는지 아는 자리」가
`capability()` 를 내게 하는 것이 맞다. 그 전까지는 **observed 를 기록**한다.
