### Task 5: 마네킹 유출 3층 가드

v14 콘티는 배경/엔티티의 마네킹 교체 계약과 **짝으로만** 유효하다.
`lane_chain` 은 `still_bgfirst_enabled` · `still_bgfirst_full_enabled` ·
`still_lane_prev_bgfirst_enabled` 세 플래그에 걸려 있고 기본값이 전부
False 다(`config.py:725,733,741`). 그리고 `_config_hash` 는 앞의 둘만
접는다(`:105-117`, `:219-226` 실측) — v14 CP 를 만든 뒤 lane-prev 만
내리면 CP 가 재사용되면서 `lane_chain=False` 로 마네킹이 legacy 경로에
들어간다.

**Files:**
- Modify: `backend/app/core/steps/shot_conti_light_step.py:100-117`
  (`_config_hash`), `:219-226` (게이트 헬퍼), `:1300-1319` (CP entry)
- Modify: `backend/app/services/still_recipe_service.py:228-249`
  (콘티 팩 검사부 — lane 무조건 검사 추가)
- Test: `backend/tests/core/test_shot_conti_light_lane.py`,
  `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T4 의 `_MANNEQUIN_SKETCH_PACKS`, `LANE_SKETCH_PACK_VERSION`
- Produces:
  - `ShotContiLightStep._mannequin_chain_ready() -> bool`
  - `MANNEQUIN_CHAIN_CONTRACT_VERSION: str` = `"1"`
  - CP lane entry 신규 필드: `lane_sketch_pack`, `lane_geometry_pack`,
    `mannequin_chain_contract` (resolved 값)
  - 서비스 헬퍼 `_require_mannequin_chain(lane_entries) -> None`

- [ ] **Step 1: 실패하는 테스트를 쓴다 — producer 거부 + hash + entry**

`backend/tests/core/test_shot_conti_light_lane.py`:

```python
def test_mannequin_pack_requires_all_three_chain_flags(monkeypatch):
    from app.core.config import settings
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    assert ShotContiLightStep._mannequin_chain_ready() is True

    # 어느 하나라도 꺼지면 마네킹 팩은 준비되지 않았다
    for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
        assert ShotContiLightStep._mannequin_chain_ready() is False
        monkeypatch.setattr(settings, off, True, raising=False)


def test_config_hash_folds_lane_prev_flag(monkeypatch):
    """lane-prev 플래그가 hash 에 접혀야 플래그만 내린 CP 재사용이 막힌다."""
    from app.core.config import settings
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    step = ShotContiLightStep.__new__(ShotContiLightStep)
    step.project_config = {}
    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    h_on = step._config_hash()
    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", False, raising=False)
    h_off = step._config_hash()
    assert h_on != h_off
```

`backend/tests/unit/test_still_recipe_bgfirst.py`:

```python
def test_service_rejects_mannequin_cp_when_chain_flag_off():
    """소비자 검사는 lane_chain 분기 **밖**에서 무조건 돌아야 한다."""
    import pytest
    from app.core.errors import AppError
    from app.services.still_recipe_service import (
        _require_mannequin_chain,
    )

    entries = {"S1sh1": {"lane": "map_marker", "status": "ok",
                         "lane_sketch_pack": "14.202607261530"}}
    # 체인 준비 안 됨 → fail-closed
    with pytest.raises(AppError) as ei:
        _require_mannequin_chain(entries, chain_ready=False)
    assert "mannequin" in str(ei.value.code)
    # 체인 준비됨 → 통과
    _require_mannequin_chain(entries, chain_ready=True)
    # 구 v13 콘티는 마네킹 검사 대상이 아니다
    _require_mannequin_chain(
        {"S1sh1": {"lane": "map_marker", "status": "ok",
                   "lane_sketch_pack": "13.202607260218"}},
        chain_ready=False)
```

- [ ] **Step 2: 실패 확인**

Run: `cd backend && python -m pytest tests/core/test_shot_conti_light_lane.py tests/unit/test_still_recipe_bgfirst.py -q -k "mannequin or lane_prev_flag"`
Expected: FAIL — `AttributeError: _mannequin_chain_ready`

- [ ] **Step 3: 계약 상수와 게이트 헬퍼를 추가한다**

`shot_conti_light_step.py` 의 `LANE_SKETCH_PACK_VERSION` 아래:

```python
# 마네킹 콘티 ↔ 배경/엔티티 마네킹 교체 계약의 짝 버전. 어느 한쪽만
# 바뀌면 CP 를 무효화해야 하므로 config hash 에 접힌다.
MANNEQUIN_CHAIN_CONTRACT_VERSION = "1"
```

`_no_plate_conti_on` 옆에 staticmethod 를 추가한다:

```python
    @staticmethod
    def _mannequin_chain_ready() -> bool:
        """마네킹 콘티를 만들어도 되는 조건 — 하류 교체 계약이 살아 있나.

        마네킹 산출은 배경 i2i·엔티티 단계의 교체 계약과 짝으로만
        유효하다. 세 플래그 중 하나라도 꺼지면 서비스가 lane_chain=False
        로 계산해 마네킹을 legacy 조립 참조로 넣고, 그 경로엔 교체 계약이
        없어 마네킹이 최종 스틸까지 유출된다(2026-07-26 설계 실측).
        """
        from app.core.config import settings

        return all(
            bool(getattr(settings, name, False))
            for name in (
                "still_bgfirst_enabled",
                "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled",
            )
        )
```

- [ ] **Step 4: config hash 에 3플래그와 계약을 접는다**

`shot_conti_light_step.py:115-116` 의 `_no_plate_conti_on()` 블록
**뒤**에 추가한다:

```python
        # 2026-07-26: 마네킹 팩은 하류 교체 계약과 짝이다 — lane-prev
        # 플래그까지 접지 않으면 v14 CP 를 만든 뒤 그 플래그만 내려
        # CP 재사용+legacy 경로 유출이 가능하다(구 hash 는 bgfirst/full
        # 만 접었다).
        if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs():
            payload["lane_mannequin_chain"] = {
                "contract": MANNEQUIN_CHAIN_CONTRACT_VERSION,
                "bgfirst": bool(getattr(
                    settings, "still_bgfirst_enabled", False)),
                "bgfirst_full": bool(getattr(
                    settings, "still_bgfirst_full_enabled", False)),
                "lane_prev": bool(getattr(
                    settings, "still_lane_prev_bgfirst_enabled", False)),
            }
```

같은 파일 상단 import 근처에 헬퍼를 둔다(모듈 로드 순환 방지를 위한
지연 import):

```python
def _mannequin_sketch_packs():
    from app.modules.pipeline.outdoor_marker_map import (
        _MANNEQUIN_SKETCH_PACKS,
    )

    return _MANNEQUIN_SKETCH_PACKS
```

- [ ] **Step 5: producer 가드를 넣는다**

lane 콘티 루프 진입 전(`run_shot_conti_light` 의 lane 분기 시작 지점,
`shot_conti_light_step.py:890` 근처 `geometry_sys` 로딩 앞)에 추가:

```python
        if (LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs()
                and not self._mannequin_chain_ready()):
            from app.core.errors import AppError

            raise AppError(
                code="step.config.lane_mannequin_chain_off",
                message=(
                    f"lane sketch 팩 v{LANE_SKETCH_PACK_VERSION}(마네킹)"
                    "인데 still_bgfirst_enabled/"
                    "still_bgfirst_full_enabled/"
                    "still_lane_prev_bgfirst_enabled 가 모두 ON 이 아님 "
                    "— 마네킹 콘티는 배경/엔티티 교체 계약과 짝으로만 "
                    "유효하다 (마네킹 유출 방지, fail-closed)"
                ),
                status_code=422,
            )
```

- [ ] **Step 6: CP entry 에 resolved 팩을 영속한다**

`shot_conti_light_step.py:1300-1312` 의 `entry.update(status="ok", ...)`
에 3필드를 추가한다:

```python
            entry.update(
                status="ok",
                image_path=str(sketch_path),
                base_map_path=base_map,
                base_map_asset_id=base_asset_id,
                marker_map_path=str(marker_map_path),
                marker_check=marker_check,
                marker_prompt=_marker_prompt,
                geometry=geometry,
                geometry_attempts=g_out["attempts"],
                leakage=verdict,
                prompt=sketch_prompt,
                # 2026-07-26: 소비자(still_recipe_service)가 팩 계약을
                # exact revalidate 할 수 있도록 resolved 팩명을 entry 에
                # 영속한다 — 이전에는 lane_records sidecar 지문 extra
                # 에만 있어서 구 v13 콘티를 신 계약으로 조용히 소비할 수
                # 있었다.
                lane_sketch_pack=resolve_sketch_pack_version(
                    LANE_SKETCH_PACK_VERSION),
                lane_geometry_pack=resolve_prompt_version(
                    LANE_GEOMETRY_PACK_VERSION),
                mannequin_chain_contract=(
                    MANNEQUIN_CHAIN_CONTRACT_VERSION
                    if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs()
                    else ""
                ),
            )
```

- [ ] **Step 7: 서비스 소비자 무조건 검사를 넣는다**

`still_recipe_service.py` 에 헬퍼를 추가한다(모듈 레벨, `_now` 근처):

```python
def _require_mannequin_chain(
    lane_entries: Dict[str, Any], *, chain_ready: bool,
) -> None:
    """마네킹 콘티 CP ↔ 체인 플래그 정합 — lane_chain 분기 **밖**에서
    무조건 호출한다.

    lane_chain=True 안에서만 검사하면 플래그를 내린 뒤 v14 CP 를 재사용
    하는 경로가 그대로 남는다(그때 lane_chain 은 False 라 검사 자체가
    실행되지 않는다).
    """
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        _MANNEQUIN_SKETCH_PACKS,
    )

    if chain_ready:
        return
    for tag, entry in (lane_entries or {}).items():
        if not isinstance(entry, dict):
            continue
        pack = str(entry.get("lane_sketch_pack") or "")
        selector = pack.split(".", 1)[0]
        if selector in _MANNEQUIN_SKETCH_PACKS:
            raise AppError(
                code="still_recipe.lane_mannequin_chain_off",
                message=(
                    f"lane 콘티({tag})가 마네킹 팩 {pack} 로 생성됐는데 "
                    "bgfirst/bgfirst_full/lane_prev 플래그가 모두 ON 이 "
                    "아님 — 마네킹이 legacy 조립으로 유출된다 "
                    "(fail-closed)"
                ),
                status_code=422,
            )
```

호출 위치는 `lane_conti` 가 로드되는 `:310` **직후**다(실측: CP 키는
`lane_conti`, 서비스 지역변수도 `lane_conti`). `lane_chain` 은 `:1591`
에서 계산되므로 이 위치는 lane 분기보다 한참 앞이고, 마네킹 CP 는
플래그 상태와 무관하게 검사된다:

```python
    lane_conti: Dict[str, Any] = conti_data.get("lane_conti", {}) or {}
    # 2026-07-26: 마네킹 CP ↔ 체인 플래그 정합을 lane_chain 분기와 무관
    # 하게 여기서 무조건 검사한다(분기 안에서 하면 플래그를 내린 뒤
    # lane_chain=False 가 되어 검사 자체가 실행되지 않는다).
    _require_mannequin_chain(
        lane_conti,
        chain_ready=bool(
            getattr(settings, "still_bgfirst_enabled", False))
        and bool(getattr(settings, "still_bgfirst_full_enabled", False))
        and bool(getattr(
            settings, "still_lane_prev_bgfirst_enabled", False)),
    )
```

- [ ] **Step 8: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/core/test_shot_conti_light_lane.py tests/unit/test_still_recipe_bgfirst.py -q`
Expected: PASS

- [ ] **Step 9: 커밋**

```bash
git add backend/app/core/steps/shot_conti_light_step.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/core/test_shot_conti_light_lane.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "fix(lane): 마네킹 유출 3층 가드 — producer 거부/hash 접기/소비자 무조건 검사

_config_hash 가 still_lane_prev_bgfirst_enabled 를 접지 않아, v14 CP 생성
후 그 플래그만 내리면 CP 재사용+lane_chain=False 로 마네킹이 legacy 조립
참조로 흘러 최종 스틸까지 유출될 수 있었다. 소비자 검사는 lane_chain 분기
밖에서 무조건 돈다 — 안에서만 하면 같은 구멍이 남는다."
```

---

