# Task 6 리포트 — `lane_conti_only` 권위 (Step1 참조 0 + lineage 정정)

- 상태: **완료** (커밋 `b07692bf`, 로컬만 — push 안 함)
- 브랜치: `feat/w19-w20-bg-planning-cleanup` (base `c05315f5`)

## 구현

### `backend/app/modules/pipeline/still_recipe.py`
- `LANE_CONTI_ONLY = "lane_conti_only"` 상수 추가 (`bgfirst_winner_lineage` 앞).
- `bgfirst_require_input_ids(..., authority_kind: str = "plate")` —
  `LANE_CONTI_ONLY` 에서만 플레이트 UUID 요구를 면제하고 `[conti]` 반환.
  같은 모드에서 플레이트 입력이 공급되면 "참조 0 계약 위반" ValueError
  (조용한 이중 권위 금지). 콘티 UUID 는 전 모드 필수, 다른 kind 는 기존
  fail-closed 그대로.
- `bgfirst_winner_lineage(..., conti_attached: bool = True)` — allowlist 에
  `LANE_CONTI_ONLY` 추가, 체인 승은 `conti_attached` 에 따라
  `["conti","bgfirst_bg"]` / `["bgfirst_bg"]`. `LANE_CONTI_ONLY` +
  `chain_won=False` 는 도달 불가 조합 → ValueError.
- `build_bgfirst_refs(conti: Optional[Path])` — `None` 이면 LAYOUT SKETCH
  슬롯 생략. **T8 계약 선반영**(사유는 아래 "의도적 편차").

### `backend/app/services/still_recipe_service.py`
- `_run_bgfirst_bg(plate_path: Optional[Path], ...)` — 지문 refs / `ref_paths`
  / 플레이트 UUID 조회를 전부 `plate_path is not None` 조건부로. 비-lane
  경로는 순서·라벨·지문 입력 모두 불변. `authority_kind` 를
  `bgfirst_require_input_ids` 로 전달.
- 권위 해석: `elif plate is None and lane_chain:` 블록을 typed
  `LANE_CONTI_ONLY` 로 교체 — `_authority_path=None`, `_authority_aid=None`,
  seed_bg 동반 시 단일 권위 계약 위반 fail-closed. **`master_png_path` /
  `master_asset_id` 조회 제거**(v14 미저작 키에 대한 fail-closed 해소).
- 권위 결손 검사는 `_authority_kind != LANE_CONTI_ONLY` 일 때만 적용.
- `_chain_only = lane_chain` 판정을 **B 후보 조립 앞**으로 이동, `refs_b` 는
  `not _chain_only` 일 때만 조립 — `LANE_CONTI_ONLY` 는 plate·seed_bg 가 모두
  None 이라 `build_ab_branch_refs` 가 "A/B 는 LOCATION 권위 필수" ValueError
  로 lane 샷을 전량 죽였을 자리.
- `refs_chain` = `conti=None if lane_chain else conti`.
- lineage 호출에 `conti_attached=not lane_chain` 전달.

### `backend/tests/unit/test_still_recipe_bgfirst.py`
신규 4건 (모두 `SAMPLE_FIXTURE_*`, 시나리오 고유명사 0):
`test_lane_conti_only_input_ids_exempts_plate_only_in_that_mode`,
`test_lane_conti_only_rejects_supplied_plate_input`,
`test_lane_conti_only_final_lineage_has_no_conti_edge`,
`test_bgfirst_refs_omit_sketch_slot_when_conti_is_none`.
TDD 순서 준수 — 구현 전 4건 FAIL 확인 후 구현.

## 검증

```
.venv/bin/python -m pytest tests/unit/test_still_recipe_bgfirst.py \
  tests/unit/test_still_recipe.py tests/core/test_shot_conti_light_lane.py -q
→ 140 passed
```
광역: `tests/unit tests/services tests/pipeline` 4223 passed / `tests/core` 는
10 failed·1607 passed 인데 **clean tree(stash) 대조에서 동일 10건 실패** —
선행 실패, 회귀 0.

## `master_png_path` / `master_asset_id` 잔존 리더 (`backend/app/`)

lane CP 엔트리를 읽던 유일한 fail-closed 소비자(`still_recipe_service.py`
2299-2300)는 이번 커밋으로 제거. 남은 것은 전부 **outdoor place canon CP**
(별도 산출, 여전히 저작됨) 이거나 producer:

| 위치 | 성격 |
|---|---|
| `app/core/steps/outdoor_place_canon_step.py:7,471-493` | producer (canon CP 저작) |
| `app/modules/pipeline/outdoor_place_canon.py:401` | producer (모듈 출력 dict) |
| `app/core/steps/shot_conti_light_step.py:584,1108-1122,1179,1243-1244,1356,1427-1428` | canon CP 리더 + lane entry writer. **`plate_look_on = LANE_SKETCH_PACK_VERSION in _plate_look_packs` 게이트 → v14 는 False** 이므로 lane entry 에 두 키를 쓰지 않는다(= 이번 결함의 근원) |
| `app/modules/pipeline/shot_conti_light.py:451` | lane entry 리더지만 **degrade-safe** — `input_paths=[p for p in (...) if p]` 로 falsy 필터, 키 부재 시 marker_map 단일 input 으로 축소(주석에 "master 미소비 팩(구 entry)은 키 부재 → 기존 단일 input 동일") |
| `app/services/scene_generation_coordinator.py:513-514` | outdoor canon CP(`od`) 리더 |
| `app/services/scene_checkpoint_loaders.py:545,621,690` | outdoor canon CP 리더 |

→ v14 가 더 이상 쓰지 않는 키를 **fail-closed 로 요구하는 코드는 남아 있지
않다**. 유일한 lane-entry 리더(shot_conti_light.py:451)는 lineage input 축소
로만 반응한다(=마커 맵 edge 는 유지).

## 의도적 편차 / 우려

1. **`build_bgfirst_refs` 의 `conti: Optional[Path]` 지원을 T6 에서 선반영.**
   브리프 Step 7 코드 블록이 서비스 쪽 `conti=None if lane_chain else conti`
   를 포함하는데(플랜상 T8 의 서비스 수정 지점은 `_chain_prompt` 뿐이라 이
   줄의 소유자는 T6), 함수가 `None` 을 못 받으면 `(sketch_label, None)` 참조
   튜플이 실려 lane 샷이 T6~T8 구간에서 깨진다. 그래서 슬롯 생략만 최소
   반영했다(도킹 docstring 문구는 플랜 T8 원문 그대로 — T8 은 멱등하게
   `stage_head_mannequin` / `build_bgfirst_final_prompt(mannequin=...)` 만
   추가하면 된다). 비-lane 호출 참조 순서·라벨은 불변.
2. **`LANE_CONTI_ONLY` 블록의 seed_bg 가드는 구조상 도달 불가.** 상위
   `if plate is None and seed_bg_path is not None:` 가 먼저 잡고, `seed_bg_path`
   는 `lane == "structure_plate"` 에서만, `lane_used` 는 `map_marker` 에서만
   세팅돼 두 값이 한 샷에서 공존하지 않는다. 브리프대로 계약 단언으로 남겼다.
3. **`canon_master` 권위는 서비스에서 사실상 사문화**(설정 지점이 이 블록
   뿐이었다). `bgfirst_winner_lineage` allowlist·role 분기·기존 테스트는
   그대로 뒀다 — 과거 record 재해석 호환용. 정리는 별건.
4. lane 체인의 Step1 지문에 `authority` 가 들어가므로(kind != "plate"),
   기존 `canon_master` 로 구운 Step1 산출은 지문 불일치 → stale 아카이브 후
   1회 재생성된다(의도된 재투영, 비용 1콜/샷).

---

## 리뷰 지적 수정

리뷰 결과 **Needs work** (Important 2 + Minor 4) — 6건 전부 반영.

### Important 1 — 금지된 사전 플레이트 경로가 lane 샷 부분집합에서 생존

`plate is None` 이 **분기 조건**이라 `lane_chain and plate is not None` 상태가
조용히 `_authority_kind = "plate"` 로 떨어졌다. 도달 가능한 상태였다 —
`:1657` 이 체인 ON 이면 `plate_map` 조회를 의도적으로 되살리고, `plate_map`
(`resolve_shot_plate_map`)은 lane 샷을 배제하지 않는다. 그 상태에서 Step1
참조가 `[마네킹 콘티, 외부 플레이트]` 가 되어(= 이 흐름이 없애려던 구도
불일치 그 자체) Step1 은 구 처우, Step2 는 신 처우를 받았다.

설계 문서(`…-design.md:97`)의 **진입 조건 assert** 대로 되돌렸다 — lane 판정을
plate/seed_bg 분기보다 **앞**으로 올리고, 공급 시 조용한 수용 대신 raise.

```python
if lane_chain:
    if plate is not None or seed_bg_path is not None:
        raise ValueError(f"{LANE_CONTI_ONLY} 샷에 플레이트/seed_bg 가 공급됨 …")
    _authority_kind = LANE_CONTI_ONLY
elif plate is None and seed_bg_path is not None:   # 이하 기존 사슬 그대로
```

raise 는 샷 단위 `try/except`(`:2218`/`:2820`) 안이라 `scene_cp.mark_failed`
로 격리된다(런 전체 abort 아님). 비-lane 은 `lane_chain=False` 라 사슬 평가가
이전과 동일 — plate/groupbg/seed_bg/prev 참조 순서·라벨·지문·fail-closed 불변.

**Minor 1 흡수**: 기존 `LANE_CONTI_ONLY` 본문 안 seed_bg 가드는 이중으로
도달 불가였다(상위 `elif plate is None and seed_bg_path is not None:` 이 먼저
잡아 seed_bg 권위로 조용히 넘어감). 진입 assert 로 접었다 — 이제 실제로
차단한다. 직전 보고의 "의도적 편차 2"는 이 수정으로 해소.

검증 테스트 `test_lane_chain_authority_is_entry_assert_not_branch_condition`:
실물 샷 재현은 LLM·이미지 전 구간이 필요해, 서비스 본문을 **AST** 로 잠갔다
(문자열 매칭보다 강한 구조 계약) — ① 사슬 머리 조건이 정확히 `lane_chain`
(`plate is None and lane_chain` 금지) ② 사슬의 나머지 어느 분기 조건에도
`lane_chain` 이 없음(= lane 샷이 plate/seed_bg/prev/groupbg 로 흘러갈 경로
부재) ③ 분기 첫 문장이 `plate is not None or seed_bg_path is not None` →
`Raise`.

### Important 2 — 하중을 받는 두 동작이 초록 스위트로 되돌려짐

신규 4개가 전부 `still_recipe.py` 순수 함수만 검증해 **서비스가 미고정**이던
지적 수용. 파일의 기존 관용(`inspect.getsource` + `index` 순서 잠금,
`:413`)으로 2개 추가.

되돌림 판별 실측 (각각 임시 적용 → 지정 3파일 실행 → 복원):

| 되돌림 | 결과 |
|---|---|
| `_chain_only = lane_chain` 을 `refs_b` 조립 **뒤**로 이동 | `test_chain_only_decided_before_b_candidate_assembly` **1건만** FAIL, 나머지 142 PASS |
| `conti_attached=not lane_chain` + `conti=None if lane_chain else conti` 삭제 | `test_service_threads_lane_lineage_and_ref_drop` **1건만** FAIL, 나머지 142 PASS |

두 경우 모두 **다른 테스트는 하나도 붉어지지 않았다** — 리뷰가 지적한
"초록 스위트로 되돌림 가능"이 실측으로 확인됐고, 신규 2개가 유일한 방벽이다.

### Minor 2 — `"canon_master"` 사문화

B 브랜치 plate 슬롯 튜플에서 제거(`("plate", "groupbg")`). 유일한 배정
지점이 T6 에서 `LANE_CONTI_ONLY` 로 대체돼 `_authority_kind` 가 다시 그 값을
가질 수 없다. 이유를 한국어 주석으로 병기. `bgfirst_winner_lineage` 의
allowlist·role 분기는 **그대로** — 구 record 해석 호환(리뷰 명시).

### Minor 3 — 테스트 이름 근접 충돌

`test_bgfirst_refs_omit_sketch_slot_when_conti_is_none` →
`…_omits_…` (플랜 T8 철자). T8 이 근접 중복을 만들지 않는다.

### Minor 4 — 낡은 docstring 첫 문장

`_run_bgfirst_bg` 첫 문장 "참조=[콘티, 플레이트] 순서 고정" 이 조건부로
거짓이 된 상태 — 참조 목록이 `authority_kind` 에 달렸음을 첫 문장에 명시하고
두 모드(권위 사진 / `LANE_CONTI_ONLY` 1장)를 병기했다.

### 검증

```
tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py
tests/core/test_shot_conti_light_lane.py  →  143 passed
```

광역 회귀 `tests/unit tests/services tests/pipeline` → **4226 passed**
(직전 4223 + 신규 3). 회귀 0.

---

## blocker 수정 (사전 플레이트 재유입)

진입 assert 를 공급하는 쪽이 같은 함수 안에 남아 있었다. `still_recipe_service.py`
플레이트 해석부(2026-07-25 판)는 체인 ON 이면 lane 샷의 `plate_map` 조회를
**되살렸고**(`None if (lane_used and not lane_chain) else plate_map.get(...)`),
그 값이 곧바로 `LANE_CONTI_ONLY` 진입 assert 에 걸려 샷을 죽였다. 실측
(`projects/` 전 체크포인트, 9 에피소드): bgfirst 대상 lane 샷 **57개 중 14개**
(에피소드 9개 중 7개, 최대 5/17·3/16·2/14)가 배경 그룹에 물려 있어 assert 대상.
이전에는 `[마네킹 콘티, 외부 플레이트]`로 (잘못) 렌더됐고, assert 도입 후에는
**아무것도 렌더되지 않은 채 run 이 초록으로 끝난다**.

### knock-on 조사 (수정 전 필수 확인)

`plate` 는 비-bgfirst 경로의 `build_still_refs`(:2188)에도 들어간다. 어떤 lane
샷이 그 경로에 non-None plate 로 도달하는지 코드·실측 양쪽으로 확인:

- **`bg_only` lane 샷 2개**(에피소드 2개)가 실제로 플레이트를 갖고 이 경로에
  도달한다. `bgfirst_eligible_full` 이 `bg_only` 를 제외하므로 이들은 assert 에
  **닿지 않는다**(현재도 정상 렌더).
- lane entry 는 `skipped_reason` 을 쓰지 않는다(step 이 status=ok/failed 만
  기록) → lane 샷의 `conti_present` 는 항상 True. 따라서 **LANE_CONTI_ONLY 도달
  집합 ≡ `lane_chain and not bg_only`**. 구조적 skip + 플레이트 lane 샷은 실측 0.
- 그 2개에서 플레이트를 끊으면 **참조 0(text-only)** 이 된다: 체인 ON 이면
  `lane_sketch=None` 으로 넘어가 스케치가 `conti` 슬롯으로 옮겨가는데,
  `build_still_refs` 의 `bg_only` 조기 return 이 `conti` 슬롯 **앞**에서 끝난다
  (`still_recipe.py:663~686`). seed_bg·structure_seed 는 map_marker lane 에
  없고, prev 는 share_plan 지휘 시에만 붙는다 — 대체 권위가 없다.

### 선택한 옵션 — B (LANE_CONTI_ONLY 도달 집합에만 적용)

옵션 A(전 `lane_used` 널링)는 위 2샷을 참조 0 으로 떨어뜨리는 회귀라 기각.

```python
plate = (
    None if (lane_used and not (lane_chain and bg_only))
    else plate_map.get(f"{si}_{shi}")
)
```

기존 `lane_used and not lane_chain` 절반을 그대로 품고, 예외를 "체인 비대상인
`bg_only` lane 샷" 하나로만 넓힌다. 2026-07-26 무-사전-플레이트 계약과 예외
사유를 변경 줄에 한국어 주석으로 병기. **assert 는 유지** — 이제 공급원이 아니라
계약이 무너졌을 때만 도는 이중 방어다(진입 assert 주석도 그 역할로 현행화).

비-lane 권위(`plate`/`groupbg`/`seed_bg`/`prev`)는 조건에 손대지 않았다 —
참조 순서·라벨·지문 입력·fail-closed 동일.

### 테스트

- **신규 `test_lane_shot_with_assigned_plate_resolves_lane_conti_only`** — 플레이트가
  배정된 lane 샷 1개를 **실제 레시피 루프**에 태운다(이미지/LLM 호출은 sentinel
  차단, 시나리오 의존 0). ①`build_still_refs` 의 `plate` 인자 `None` ②멈춤 사유가
  Step1 sentinel 뿐(플레이트 계약 위반 아님) ③Step1 참조 `[콘티]` 1장.
  **되돌림 실측**: 수정 전 코드로 되돌리면 `ValueError: … 사전 배경 플레이트 금지
  계약 위반 (fail-closed)` 로 FAIL — 실측 결함을 그대로 재현한다.
- **신규 `test_bg_only_lane_shot_keeps_its_plate_reference`** — knock-on 잠금.
  `bg_only` lane 샷은 플레이트를 유지하고 Step1 을 타지 않는다.
- **Minor(AST 잠금 본체)** — `test_lane_chain_authority_is_entry_assert_not_branch_condition`
  에 ④ 추가: 분기 **본체**가 `_authority_kind = LANE_CONTI_ONLY` /
  `_authority_path = None` / `_authority_aid = None` 을 배정하는지 확인. 순서·가드를
  지킨 채 본체만 `"plate"` 로 되돌리는 mutant 로 반증 — ①②③ 은 통과하고 ④만 FAIL.
- **Minor(substring 앵커)** — `test_service_threads_lane_lineage_and_ref_drop` 의 두
  `inspect.getsource` 비교를 `\n` + 20칸 들여쓰기로 앵커(:425 하우스 스타일) —
  같은 문구를 인용한 주석으로는 만족되지 않는다.

### 검증

```
tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py
tests/core/test_shot_conti_light_lane.py  →  145 passed
```

광역 `tests/unit tests/services` → 1368 passed. `tests/core tests/pipeline
tests/services` → 4470 passed / 10 failed 인데, **그 10건은 pristine HEAD
(5df61906)에서도 동일하게 실패**하는 기존 결함(d6 hash/visible_entities/
outdoor_place_spec/background_prompt_v7)으로 이번 변경과 무관하다.

**사후 재계측**(같은 스크립트, 같은 체크포인트): 체인 대상 lane 샷 중 플레이트를
해석하는 샷 = **0** (14 → 0). `bg_only` lane 샷의 플레이트 2건은 의도대로 보존.
