### Task 6: `lane_conti_only` 권위 — Step1 참조 0 + lineage 정정

**Files:**
- Modify: `backend/app/modules/pipeline/still_recipe.py:1437-1468`
  (`bgfirst_winner_lineage`), `:1505-1539` (`bgfirst_require_input_ids`)
- Modify: `backend/app/services/still_recipe_service.py:693-704`
  (`_run_bgfirst_bg` 시그니처), `:745-766` (지문), `:785-790` (참조),
  `:841-852` (input_ids), `:2229-2311` (권위 해석),
  `:2362-2398` (refs_b 조립 순서), `:2463-2467` (lineage 호출)
- Test: `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T5 의 소비자 검사
- Produces:
  - `LANE_CONTI_ONLY = "lane_conti_only"` (`still_recipe.py` 상수)
  - `bgfirst_require_input_ids(..., authority_kind: str = "plate")`
  - `bgfirst_winner_lineage(..., conti_attached: bool = True)`
  - `_run_bgfirst_bg(tag, still_id, conti_path, plate_path=None, ...)`

- [ ] **Step 1: 실패하는 테스트를 쓴다**

```python
def test_lane_conti_only_input_ids_exempts_plate_only_in_that_mode():
    import pytest
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_require_input_ids,
    )

    ids = bgfirst_require_input_ids(
        conti_asset_id="conti-uuid", plate_asset_id=None,
        plate_path=None, authority_kind=LANE_CONTI_ONLY)
    assert ids == ["conti-uuid"]

    # 콘티 UUID 는 여전히 필수
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id=None, plate_asset_id=None, plate_path=None,
            authority_kind=LANE_CONTI_ONLY)

    # 다른 kind 에서는 면제 없음 (기존 fail-closed 유지)
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id="conti-uuid", plate_asset_id=None,
            plate_path=None, authority_kind="plate")


def test_lane_conti_only_final_lineage_has_no_conti_edge():
    import pytest
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_winner_lineage,
    )

    # 확정 흐름: Step2 참조에 콘티가 없다 → final direct edge 도 없다
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind=LANE_CONTI_ONLY,
        structure_seed_attached=False,
        conti_attached=False) == ["bgfirst_bg"]
    # 콘티를 실제로 첨부하는 기존 체인은 그대로
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind="plate",
        structure_seed_attached=False) == ["conti", "bgfirst_bg"]
    # lane_conti_only + 무콘티 승은 도달 불가 조합 → fail-closed
    with pytest.raises(ValueError):
        bgfirst_winner_lineage(
            chain_won=False, authority_kind=LANE_CONTI_ONLY,
            structure_seed_attached=False, conti_attached=False)
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q -k "lane_conti_only"`
Expected: FAIL — `ImportError: cannot import name 'LANE_CONTI_ONLY'`

- [ ] **Step 3: 상수와 순수 로직을 고친다**

`still_recipe.py` 에 상수를 추가한다(`bgfirst_winner_lineage` 앞):

```python
# lane(map_marker) 확정 흐름의 위치 권위 종류 — 외부 사진 0, 콘티 자체가
# 유일한 Step1 입력이다. generic "none" 대신 typed 로 두어 "권위 없음"이
# 다른 경로로 번지지 않게 한다.
LANE_CONTI_ONLY = "lane_conti_only"
```

`bgfirst_require_input_ids` 를 바꾼다:

```python
def bgfirst_require_input_ids(
    *,
    conti_asset_id: Optional[str],
    plate_asset_id: Optional[str],
    plate_path: Any,
    seed_asset_id: Optional[str] = None,
    seed_attached: bool = False,
    authority_kind: str = "plate",
) -> List[str]:
```

docstring 끝에 추가:

```python
    authority_kind=LANE_CONTI_ONLY (2026-07-26 확정 흐름): 외부 사진이
    아예 없는 lane 체인 — 플레이트 UUID 요구를 **이 모드에서만** 면제
    하고 [conti] 만 반환한다. 콘티 UUID 는 여전히 필수(불완전 lineage 를
    성공 record 로 영속 금지 원칙 유지). 다른 kind 는 기존 fail-closed.
```

본문의 plate 검사를 조건부로 만든다:

```python
    if not conti_asset_id:
        raise ValueError("bgfirst 콘티 asset 미해결 — fail-closed")
    if authority_kind == LANE_CONTI_ONLY:
        if plate_asset_id or plate_path is not None:
            raise ValueError(
                f"{LANE_CONTI_ONLY} 인데 플레이트 입력이 공급됨 "
                f"({plate_path}) — 참조 0 계약 위반 (fail-closed)"
            )
        out = [conti_asset_id]
    else:
        if not plate_asset_id:
            raise ValueError(
                f"bgfirst 플레이트 asset 미해결({plate_path}) — 불완전 "
                "lineage 영속 금지 (fail-closed)"
            )
        out = [conti_asset_id, plate_asset_id]
    if seed_attached:
        ...
```

`bgfirst_winner_lineage` 를 바꾼다:

```python
def bgfirst_winner_lineage(
    *,
    chain_won: bool,
    authority_kind: str,
    structure_seed_attached: bool,
    conti_attached: bool = True,
) -> List[str]:
```

docstring 끝에 추가:

```python
    conti_attached (2026-07-26): 체인 승 final 이 콘티를 **실제로** 참조
    하는지. 확정 흐름의 lane 체인은 Step2 참조에서 콘티를 빼므로
    False — 이때 direct role 은 bgfirst_bg 뿐이고 콘티 UUID 는 중간
    bgfirst_bg 의 input edge 에만 남는다(conti → bgfirst_bg → final).
    True 로 두면 존재하지 않는 direct edge 가 생긴다.
```

allowlist 와 반환을 바꾼다:

```python
    if authority_kind not in (
        "plate", "groupbg", "seed_bg", "prev", "canon_master",
        LANE_CONTI_ONLY,
    ):
        raise ValueError(f"unknown authority_kind {authority_kind!r}")
    if authority_kind == LANE_CONTI_ONLY and not chain_won:
        raise ValueError(
            f"{LANE_CONTI_ONLY} 는 체인 단독 경로 — chain_won=False 는 "
            "도달 불가 조합 (거짓 lineage 대신 fail-closed)"
        )
    if chain_won:
        return ["conti", "bgfirst_bg"] if conti_attached else ["bgfirst_bg"]
```

- [ ] **Step 4: 순수 로직 테스트 통과 확인**

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

- [ ] **Step 5: `_run_bgfirst_bg` 를 참조 0 을 표현할 수 있게 고친다**

`still_recipe_service.py:693-704` 시그니처:

```python
    def _run_bgfirst_bg(
        tag: str,
        still_id: str,
        conti_path: Path,
        plate_path: Optional[Path],
        bg_prompt: str,
        conti_asset_id: Optional[str],
        plate_asset_id_override: Optional[str] = None,
        seed_path: Optional[Path] = None,
        seed_asset_id: Optional[str] = None,
        authority_kind: str = "plate",
    ) -> Tuple[Path, str]:
```

docstring 끝에 추가:

```python
        2026-07-26 확정 흐름: authority_kind=LANE_CONTI_ONLY 면
        plate_path=None 이고 참조는 [콘티] 1장이다 — 외부 사진 없이
        콘티 자체를 편집해 배경을 입힌다(장소 사실은 텍스트 권위).
```

지문 refs(`:747`):

```python
        _fp_refs = [("conti", conti_path)]
        if plate_path is not None:
            _fp_refs.append(("plate", plate_path))
```

참조 목록(`:789-790`):

```python
                        ref_paths=(
                            [conti_path]
                            + ([plate_path] if plate_path is not None
                               else [])
                            + ([seed_path] if seed_path is not None
                               else [])
                        ),
```

input_ids(`:841-852`):

```python
        _plate_aid = None
        if plate_path is not None:
            _plate_aid = plate_asset_id_override or (
                (map_conti.get(tag) or {}).get("asset_id")
                if (map_conti.get(tag) or {}).get("plate_path")
                == str(plate_path) else None
            ) or _plate_asset_id(plate_path)
        input_ids = bgfirst_require_input_ids(
            conti_asset_id=conti_asset_id,
            plate_asset_id=_plate_aid,
            plate_path=plate_path,
            seed_asset_id=seed_asset_id,
            seed_attached=seed_path is not None,
            authority_kind=authority_kind,
        )
```

- [ ] **Step 6: 권위 해석에서 lane 을 typed mode 로 바꾼다**

`still_recipe_service.py:2244-2260` 의 `elif plate is None and lane_chain:`
블록 전체를 교체한다:

```python
                    elif plate is None and lane_chain:
                        # 2026-07-26 사용자 확정: lane 콘티는 마커 맵
                        # 1장만 보고 마네킹으로 그려진다. 배경은 그 콘티
                        # 자체를 i2i 로 편집해 입히고 외부 사진은 쓰지
                        # 않는다(사전 배경 플레이트 금지 — 미리 만든 빈
                        # 배경과 콘티 구도가 어긋나 구조물이 겹쳐 보이던
                        # 실측). 장소 사실은 텍스트 권위(§4.3).
                        if seed_bg_path is not None:
                            raise ValueError(
                                f"{LANE_CONTI_ONLY} 샷에 seed_bg 가 함께 "
                                "공급됨 — 단일 권위 계약 위반 "
                                "(fail-closed)"
                            )
                        _authority_kind = LANE_CONTI_ONLY
                        _authority_path = None
                        _authority_aid = None
```

`:2306-2311` 의 권위 결손 검사를 조건부로 만든다:

```python
                if _authority_kind != LANE_CONTI_ONLY and (
                    _authority_path is None
                    or not _authority_path.is_file()
                ):
                    raise ValueError(
                        f"BGFIRST2 샷 LOCATION 권위 결손({_authority_path},"
                        f" kind={_authority_kind}) — 재투영 불가 "
                        "(fail-closed)"
                    )
```

`LANE_CONTI_ONLY` 를 이 블록에서 쓰려면 상단 import 목록에 추가한다
(`:2218-2224` 의 `from app.modules.pipeline.still_recipe import (...)`):

```python
                    LANE_CONTI_ONLY,
```

- [ ] **Step 7: `_chain_only` 판정을 B 조립 앞으로 옮긴다**

`still_recipe_service.py:2362-2398` 를 재배열한다. `refs_chain` 조립
직후에 `_chain_only` 를 정하고, B 후보는 `_chain_only` 가 아닐 때만
만든다:

```python
                refs_chain = build_bgfirst_refs(
                    bg=bg_path, conti=None if lane_chain else conti,
                    char_refs=char_refs, prop_refs=prop_refs,
                )
                # lane 체인은 2택1이 없다(사용자 확정) — B 후보를 조립할
                # 이유가 없고, LANE_CONTI_ONLY 는 plate·seed_bg 가 모두
                # None 이라 build_ab_branch_refs 가 "A/B 는 LOCATION
                # 권위 필수" ValueError 로 샷을 죽인다(still_recipe.py:737).
                # 그래서 판정을 B 조립보다 **앞**에 둔다.
                _chain_only = lane_chain
                refs_b: Any = None
                if not _chain_only:
                    if _authority_kind == "prev":
                        refs_b = refs
                    else:
                        _, refs_b = build_ab_branch_refs(
                            plate=(
                                _authority_path
                                if _authority_kind in (
                                    "plate", "groupbg", "canon_master")
                                else None
                            ),
                            conti=conti,
                            char_refs=char_refs, prop_refs=prop_refs,
                            structure_seed=structure_seed_path,
                            seed_bg=(
                                seed_bg_path
                                if _authority_kind == "seed_bg" else None
                            ),
                        )
```

기존 `_chain_only = lane_chain` 행(`:2398`)은 제거한다(위로 올렸다).

- [ ] **Step 8: lineage 호출에 `conti_attached` 를 넘긴다**

`still_recipe_service.py:2463-2467`:

```python
                for _role in bgfirst_winner_lineage(
                    chain_won=_chain_won,
                    authority_kind=_authority_kind,
                    structure_seed_attached=_chain_seed_path is not None,
                    conti_attached=not lane_chain,
                ):
```

- [ ] **Step 9: 스윕**

Run: `cd backend && 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`
Expected: PASS

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

```bash
git add backend/app/modules/pipeline/still_recipe.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "feat(lane): lane_conti_only 권위 — Step1 참조 0 + direct lineage 정정

콘티 자체만 편집해 배경을 입히므로 Step1 참조는 1장이다. Step2 에서 콘티를
빼는 만큼 final direct lineage 는 [bgfirst_bg] 뿐이고 콘티는 중간 bg 의
input edge 로만 남는다(거짓 direct edge 방지).

_chain_only 판정을 B 후보 조립 앞으로 옮긴다 — LANE_CONTI_ONLY 는 plate·
seed_bg 가 모두 None 이라 build_ab_branch_refs 가 ValueError 로 샷을
죽였다(현행 canon_master 는 plate 슬롯에 사진이 들어가 우회됐던 것)."
```

---

