# 최종 전수 리뷰 대응 — lane 마네킹 wave 봉합 6건

브랜치 `feat/w19-w20-bg-planning-cleanup` / 기반 HEAD `69519474`.
유료 이미지 캔ary 직전 수정이라 I-1·I-2 는 캔ary 산출 자체를 보호한다.

## I-1 — 소비자가 lane 팩 버전을 검증하지 않음 (최고 가치)

`backend/app/services/still_recipe_service.py` `_require_mannequin_chain`.

`chain_ready=False` 한 방향만 검사하던 가드를 **양방향**으로 만들었다.
`chain_ready=True` 일 때 `status == "ok"` 인 모든 lane entry 의
`lane_sketch_pack` 이 `resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION)`
과 exact 일치해야 한다. 키 부재·값 불일치 둘 다 fail-closed
(`still_recipe.lane_sketch_pack_mismatch`, 422 — 형제
`bgfirst_conti_pack_mismatch` 와 같은 모양, "shot_conti_light 를 먼저
재실행하세요").

실패 시나리오: 이 wave 이전에 lane 콘티를 구운 프로젝트의 entry 에는
스탬프 키가 **아예 없다**. 운영자가 conti 를 다시 굽지 않고
`still_recipe`/이미지 스텝만 재개하면 — 파일 상단 주석이 경고하는 부분
실행 — 구 v13 인물 그림에 Step1 이 `bg_fill_head`("마네킹을 회색 그대로
두라")를, Step2 가 `stage_head_mannequin`("모든 마네킹을 실제 인물로
바꾸라")을 건다. 전부 조용히, 유료 렌더를 태우면서. 일반 콘티 팩
(`conti_pack`)을 보는 기존 가드는 이 wave 가 건드리지 않은 다른 키라
잡지 못한다.

`status != "ok"` entry(structure_plate 정책·실패 격리)는 팩 스탬프 대상이
아니므로 검사 대상에서 제외 — writer 쪽 `_pack_stamp` 적용 지점과 정확히
같은 집합이다.

테스트(`tests/unit/test_still_recipe_bgfirst.py`):
`test_service_requires_exact_sketch_pack_when_chain_ready`(키 부재 /
구 팩 값 / selector 접두만 같은 값 / 현행 팩 통과 / 비 ok entry 면제),
`test_run_still_recipe_raises_on_stale_sketch_pack_with_chain_on`(세 플래그
ON 런타임 — 앞선 `conti_pack` 검사를 통과한 뒤 이 가드에 걸린다).

## I-2 — 체인 단독 분기의 critique 프롬프트가 미첨부 스케치를 가리킴

`still_recipe_service.py` `_chain_only` 분기의 `_run_branch(...)` 에
`critique_selected_prompt_only=True` 추가(A/B 분기는 이미 넘기고 있었다 —
비대칭 자체가 결함).

없으면 `multiroll_select` 가 critique 프롬프트를
`f"{_chain_prompt}\n\n{prompt}"` 로 합성한다. `prompt` 는
`lane_ref_mode="sketch"` 조립이라 `location_lock_lane_sketch`("첨부된
STORYBOARD SKETCH 가 배치를 고정한다 / 장소 사진은 첨부되지 않았다")를
싣는데, 이 wave 에서 스케치는 **의도적으로 미첨부**이고 `_chain_prompt` 는
`location_lock_chain_bg` 로 배경본을 장소 권위로 선언한다 — 서로 배타인
LOCATION lock 2개, 그중 하나는 없는 참조를 가리킨다. 그렇게 매긴 VLM
지적은 unfixable 마킹이 없어 `build_fix_prompt` 로 흘러 정상 스틸을
고친다. `roll_prompts` 가 공급돼 있고 `_chain_prompt` 가 base 전문을
품으므로 소실 없음.

테스트: `test_chain_only_branch_critiques_selected_prompt_only` — AST 로
`if _chain_only:` **body 안의** `_run_branch` 호출 keyword 만 본다
(orelse=A/B 분기 제외).

## I-3 — `lane_chain and bg_only` 샷의 참조/프롬프트 불일치

`lane_ref_mode` 를 plate 해석과 **같은 조건**으로 맞췄다:
`"sketch" if (lane_used and not (lane_chain and bg_only)) else ""`.

`73a7ec33` 이 이 케이스의 플레이트를 되살렸고(체인 비대상 + `bg_only` 조기
return 이라 그게 유일한 참조), `build_still_refs` 가 LOCATION PHOTOGRAPH
라벨("건축·재질·고정물·조명이 공간 진실")로 붙인다. 그런데 프롬프트는
"장소 사진은 첨부되지 않았다"고 단언해 방금 붙인 참조를 부정했다. wave
이전엔 참조가 진짜로 0 이라 자기정합이었고, 모순은 wave 가 만들었다.
`camera_frame_en` 은 lane 샷에서 항상 "" 라 상호 배타 가드와 충돌 없음.

테스트: `test_bg_only_lane_shot_prompt_matches_its_attached_plate`
(실제 레시피 루프 — 참조=플레이트 1장 + `lane_ref_mode==""` +
"the attached LOCATION PHOTOGRAPH shows the exact spot." 동시 확인),
`test_person_visible_lane_shot_keeps_sketch_location_lock`(반대편 — 참조 0
본 경로는 sketch 권위 유지, 조건을 넓게 잡아 되돌리는 것도 차단).
`_run_lane_shot` 하네스에 `build_still_prompt` 스파이를 추가했다.

## I-4 — `CHAIN_BG_LOCATION_PROMPT_VERSION` 이 우연히만 hash 에 덮임

`image_steps._config_hash_base` 의 lane 분기에 `chain_bg_location_pack`,
`still_recipe_service` 의 런 `extra_fingerprint` 에 같은 키를 병행 스탬프
(둘 다 `still_lane_prev_bgfirst_enabled` ON 경로 안 — OFF 는
byte-identical).

이 selector 는 샷 팩과 **의도적으로 분리된** 독립 축인데, 지금 값이
`BGFIRST_LANE_PROMPT_VERSION` 과 같은 "12" 라 같은 디렉토리로 풀려 우연히
덮이고 있었다. 이 스템만 새 팩에 발행하고 selector 를 올리면(분리의 존재
이유가 바로 그것이다) 완주한 프로젝트가 clean-skip 으로 새 계약을 영영 못
본다 — 이 프로젝트가 이미 겪은 dormant 팩 결함 그대로.

테스트: `test_scene_pipeline_hash_stamps_chain_bg_location_pack`(lane 팩·
계약을 고정한 채 이 selector 만 올려도 hash 가 움직인다 + OFF 경로 불변),
`test_chain_bg_location_pack_in_run_fingerprint_source_contract`.

## M-8 + 이월 T2 — 서식을 계약으로 오인한 테스트

- `test_bg_fill_prompt_order_and_stems` 의
  `p.endswith("markers anywhere in the\nimage.")` → 공백 정규화 후 tail
  문장 대조. 하드랩 위치는 팩 재래핑으로 움직이는 서식이고, "tail 이
  마지막"이라는 순서 계약은 인접 `positions` assert 가 이미 담당한다.
- `tests/unit/test_still_recipe_bgfirst.py` 의 죽은 `or` 우회 2건 제거
  (`.replace("\n", "\n")` no-op 포함). 뒷항이 항상 참이라 사실상 무검사
  였다 → `" ".join(x.split())` 정규화 단일 assert 로 교체.
- 이월 T2 동일 패턴: `tests/pipeline/test_outdoor_marker_map.py` 의
  `"do NOT\ndraw the plan itself" in p or "NOT CONTENT" in p` → 같은
  정규화로 교체.
- `test_outdoor_marker_map.py` facing enum 항등식 2곳 →
  `["toward_camera", "away_from_camera", "profile", "unspecified"]`
  리터럴 고정(구현이 읽는 상수로 기대값을 되만들면 값이 바뀌어도 초록).

## M-9 — `app/core/world_context.py` 테스트 신설

`backend/tests/unit/test_world_context.py` (신규, 5 tests). 픽스처는 전부
`SAMPLE_FIXTURE_*` 일반 소재.

- `build_world_facts_block`: region/era + `PLACE_RULE_TYPES` 규칙의
  `visual_guideline` **만** — 행 전수 고정. `description` 미주입 확인.
- `PLACE_RULE_TYPES` 값 리터럴 고정 + 선언 밖 종류(인물/현상 계열)는 한
  줄도 안 실림 / 선언 안 종류는 전부 실림을 종류별 전수 확인.
- CP 부재·`{"data": None}`·공백만 있는 값·guideline 없는 규칙·비 dict
  규칙 = `""`(호출부 fail-closed 의 '사실 없음' 신호라 degrade 모양이
  계약), region 단독은 성립.
- `build_world_rules_text`: rule_type 필터 **없음** — 이미지 블록이
  걸러내는 인물/현상 규칙도 실린다는 점을 행 전수로 고정 + 부분 결손
  (type 없음/guideline 없음/둘 다 없음/비 dict) 관용.

## 검증

```
.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 \
  tests/pipeline/test_outdoor_marker_map.py tests/unit/test_world_context.py -q
→ 248 passed
```

full `tests/` = 7646 passed / 64 failed — 실패 64건은 기존군 동수
(step manifest 카운트·ordering 베이스라인 계열, 이 diff 와 무관: 변경을
stash 한 상태에서 같은 파일들이 동일하게 14 failed).

### discrimination (수정 임시 되돌림 → 테스트 실패 확인 → 복원)

- **I-1**: `chain_ready` 검사를 조기 return 으로 되돌림 →
  `test_service_requires_exact_sketch_pack_when_chain_ready`,
  `test_run_still_recipe_raises_on_stale_sketch_pack_with_chain_on`
  **2건 모두 FAIL**(런타임 쪽은 raise 없이 루프를 통과해버림). 복원 후
  2 passed.
- **I-2**: `critique_selected_prompt_only=True` 한 줄 제거 →
  `test_chain_only_branch_critiques_selected_prompt_only` **FAIL**
  (`assert None == 'True'`, keywords={roll_prompts, roll_refs}). 복원 후
  1 passed.
- (참고) I-3·I-4 도 같은 방식으로 확인 — 각각 되돌리면
  `test_bg_only_lane_shot_prompt_matches_its_attached_plate`,
  `test_scene_pipeline_hash_stamps_chain_bg_location_pack` 이 FAIL.

## 제약 준수

- 프롬프트 팩 편집·덮어쓰기 0 (6건 모두 팩 변경 불요).
- 비 lane 경로 byte-identical: I-2/I-3 는 `lane_chain` 분기 안, I-4 는
  `still_lane_prev_bgfirst_enabled` ON 분기 안, I-1 은 lane entry 부재 시
  no-op.
- 시나리오 의존 데이터 0, 픽스처는 `SAMPLE_FIXTURE_*` 일반 소재.
- LLM 전달 텍스트 절단 없음.
- 로컬 커밋만 — push 하지 않음.
