# 0단계 진행 기록 — 결제 신원과 최종 출처

> 감사 보고서 `artifact/20260826_full_audit/` 의 0단계 실행 기록.
> PR [#6](https://github.com/jedikim/theroad-i1/pull/6) ·
> 브랜치 `fix/audit-stage0-billing-identity`

## 무엇을 했나

| | 항목 | 상태 | 커밋 |
|---|---|---|---|
| 0-A | 보냈는지 모르는 요청을 다시 보내지 않는다 (한 호출 안) | ✅ | `91d4a9b8` |
| 0-A3 | 상태를 아는 거부와 모르는 실패를 가른다 (3갈래) | ✅ | 이 커밋 |
| 0-A4 | 본문에 담겨 온 오류도 같은 정책을 쓴다 | ✅ | 이 커밋 |
| 0-A2 | 재시도 가부를 제공자별로 가른다 | ✅ | `f6b934aa` |
| 0-B | 최종 이미지 출처를 실행과 맞춘다 + backfill | ✅ | `14d1e316` |
| 0-C | reve 405 진단 정정 | ✅ | `91d4a9b8` |
| 0-D | Opik 신원 봉투 | ⏸ 이미지 client 3종으로 한정 (Codex 권고) |
| 0-E | 로그 10,000자 상한 | ⏸ 이번 커밋에서 제외 (Codex 권고) |

| 0-A5 | 맵 재투영도 접수 불명이면 다시 안 산다 (Codex BLOCK) | ✅ | `28232a64` |
| 1-H | 최종 i2i 를 끄면 시네마틱 마감이 t2i 로 간다 (사용자 지시) | ✅ | `3efc1347` `13027858` `59ff5738` |
| 1-A | signage 팩 v2 승격 | ↩︎ **철회** (Codex BLOCK — v3 와 한 번에) | `e6dcbb6e` → `13027858` |

Codex 최종 판정 **APPROVE** (head `59ff5738`).

실측 (2026-08-27 최종, **다섯 디렉토리**):

```
tests/unit + services + modules + core + pipeline  →  7,283 passed / 35 failed
main 을 별도 worktree 로 꺼내 같은 명령         →  같은 35건
HEAD 에만 있는 실패                              →  0건
```

**이 판이 만든 리그레션은 0.** 35건은 전부 main 에서도 실패하는 기존 부채다
(`episode_projection_strict` 12 · `openai_key_failover` 7 ·
`build_opik_metadata` 6 · `visible_entities_validator` 3 ·
`call_structured_fallback` 2 · `shot_conti_light_lane` 2 · 나머지 3).
backfill 717건 중 14건 적용 → primary 7/7 이 `reve/2.1/edit` + 실제 문안.

> ★**재는 범위를 두 번 넓혔고 두 번 다 더 나왔다.**
> 처음엔 `tests/unit` 만 돌리고 "리그레션 0"이라 적었다 →
> `services`·`modules` 를 더하니 내가 만든 실패 2건이 나왔다.
> 그 다음 `core`·`pipeline` 을 더하니 실패가 14 → 35 로 늘었다
> (이번 것은 전부 기존 부채였다).
> 「재는 도구가 덜 잡으면 0 이 없다로 읽힌다」를 한 판에서 두 번 했다.

### 재는 범위를 넓히다 나온 별건 둘 (이 PR 밖)

1. **`tests/core` 가 통째로 안 돌고 있었다.**
   `tests/core/test_p8_inc3b_pose_sketch.py` 가 수집 단계에서 죽어
   (`build_immobilized_pose_sketch_prompt` 를 import 하는데 그 함수가
   코드에 없다) **디렉토리 전체가 `Interrupted: 1 error during collection`**
   으로 끝났다. `git log --all` 에 없는 untracked 파일 —
   2026-06-25 stash 잔재다. 지우지 않고 scratchpad 로 옮겼다.
   그 사이 `tests/core` 의 실패 21건이 아무에게도 안 보였다.
2. **시험이 돈을 쓴다.** netprobe 가
   `tests/pipeline/test_outdoor_site_layout_plan.py` 의
   `test_layout_failure_is_non_blocking` ·
   `test_side_conflict_shot_excluded_from_overrides` 두 건에서
   `gemini-3.1-pro-preview:generateContent` 실호출 시도를 잡았다.

### 0-A5 — 같은 파일 안에서 한 쪽만 막았다

자체 리뷰가 찾은 MEDIUM-4 를 `_make_nb2_fn` 에서 고치고
**`_make_gpt_edit_fn` 을 놓쳤다.** 기본
`marker_map_image_model = "nb2"` 라 그쪽이 **기본 경로**다:

```
gpt_edit → draw_marker_map_via_model → is_gemini_model("nb2")
        → render_via_gemini → GeminiImageClient.generate_image
```

`except Exception` 이 접수 불명을 삼키고 `sanitize_for_safety` 로
문안만 바꿔 다시 그렸다. 여기는 **모델 × attempt 두 겹 루프**라
한 칸을 최대 네 번 산다.

내 AST 스캔은 `generate_image` 를 **직접** 감싼 except 11곳만 찾았고
(전부 확인 — 재구매하는 곳 없음), 이 자리는 세 단계 건너뛴 간접 경로라
못 잡았다. **도구의 사각지대였다.** Codex 가 `generate_image` 의 호출자
50곳을 거꾸로 훑어 찾았다.

양성 대조로 확인했다 — 전용 절을 빼고 돌리면 로그가 그대로 나온다:
`nb2 attempt=1` / `nb2 attempt=2` / `gpt-image-2 attempt=1` / `attempt=2`.
시험 둘 다 「예외가 나갔다」가 아니라 **부른 횟수가 1** 인지를 잰다 —
전파만 보면 `raise last_exc` 로 끝나는 코드도 같이 통과하기 때문이다.

## 설계에서 내가 틀렸고 Codex 가 맞았던 것

이 판에서 **네 번** 내 판단을 바꿨다. 근거는 전부 코드·문서로 확인했다.

### ① 벽시계 마감의 근거

내 설계: 「connect 실패면 그 전에 URLError 가 나므로, wall 까지 갔다는 건
send 가 끝났다는 뜻에 가깝다」

Codex: `Future.result(timeout)` 은 worker 가 DNS·연결·TLS·업로드·읽기 중
어디에 있는지 알려주지 않는다. 도는 스레드는 취소되지도 않는다.
**결론은 맞지만 근거가 틀렸다 — 보수적 기본값이라서 맞는 것이다.**

→ 수용. 문서와 코드 주석을 「모르니까 닫는다」로 고쳤다.

### ② 429·503 을 「받지 않았다」로 묶은 것

내 설계: 429 는 rate limit, 503 은 service unavailable 이라 둘 다
「요청을 받지 않았다」가 표준 의미다.

Codex: 429 는 요청을 **받고 나서** 되돌린 응답이다. 503 도 side effect 가
시작되지 않았다는 보장이 아니다. RFC 9110 §9.2.2 는 non-idempotent POST 를
원 요청 미적용이 확인되지 않는 한 자동 재시도하지 말라고 한다.
**제공자별 계약으로 갈라야 한다** — Gemini 는 공식 문서가 backoff 를 권하고,
OpenRouter 503 은 라우터가 provider 까지 갔는지 알 수 없다.

→ 수용. `http_status_is_resend_safe(code, via=...)` 로 경로를 명시적으로 받고,
모르는 경로는 fail-closed.

### ③ 「프로세스가 죽으면 재전송도 안 일어난다」

내 설계: 중복 결제 차단과 기록 durability 는 다른 목표다. 재전송을 안 하면
중복 결제는 예외만으로 막히고, 프로세스가 죽는 경우는 재전송도 안 일어난다.

Codex: **코드상 반례가 확정이다.**

```
provider 가 요청 접수 → 백엔드 kill -9 → 기록 없음
→ startup reclaim(running→failed) → resume → 산출 파일 없음
→ 같은 유료 요청 재전송
```

근거로 이 저장소의 코드가 그렇게 **직접 말하고 있었다**:

- `step_lock.py:526` — "풀기 = `running` → `failed`. 다음 `resume` 이 바로 가져간다."
- `multiroll_select.py:1278` — "과금 후 실패하면 그 시도가 기록에 안 남고
  다음 resume 이 같은 자리를 다시 탄다."

→ 수용. 내 전제가 틀렸다. PR 의 **주장을 좁혔다**.

★그리고 그 좁힌 문장도 아직 넓었다(Codex BLOCK-4). 정확한 범위는
**같은 `generate_image()` 호출 안의 자동 재시도 차단**이다 — 그 함수가 끝난 뒤
상위가 다시 부르면(resume·JIT 재방문) 또 보낸다. `submission_unknown` 기록은
`llm_call_log`·Opik 에 남을 뿐 **다음 호출을 막지 않는다**.

### ④ `dispatching` 행만으로 막힌다고 본 것

내가 `begin_llm_call`/`finish_llm_call` 을 구현했는데, Codex 지적:
**재개된 client 가 새 UUID 로 또 INSERT 하고 호출한다.** 결정적
`logical_request_key` + unresolved 조회/CAS 가 없으면 아무것도 안 막힌다.

→ 수용. **그 코드를 되돌렸다**(커밋 안 함). 불완전한 안전장치는 안전하다는
착각을 준다. 후속 PR 로 분리했다.

## 남은 구멍 (후속 PR)

`logical_request_key` 로 같은 논리 호출을 다시 식별하고 unresolved 상태면
유료 호출을 막는다.

```
logical_request_key = sha256(
  project_id + episode_id + step_name + still_id
  + operation + call_slot + provider + model + effective_request_sha)
```

- `call_slot` 이 `roll_A`/`roll_B`/`fix`/`cine_transform` 을 갈라야 한다 —
  같은 body 를 정상적으로 여러 번 보내는 자리가 있다. 안 가르면 B 롤이 막힌다.
- `logical_request_key` 는 실제 column + partial unique index
  (`WHERE status IN ('dispatching','submission_unknown')`).
- 기동 시 일괄 상태 변경은 **하지 않는다** — 다중 프로세스에서 살아 있는
  `dispatching` 을 죽은 것으로 오인한다. 두 상태 다 unresolved 로 보고 막으면
  목적은 달성된다.
- 사람이 해제하는 경로가 필요하다 — 잘못 잡으면 샷이 영영 안 나온다.
- DB 실패 시 유료 호출 자체를 막는다(fail-closed).

alembic 마이그레이션이 들어가므로 별도 PR.

## 이번 판에서 안 건드린 것

- `IMAGE_RESEND_ON_UNKNOWN_SEND` 플래그 — 방금 막은 위험을 전역 플래그 하나로
  다시 여는 구조라 아예 만들지 않았다.

> ★**정정**: 앞선 판에서 여기에 「OpenRouter body 오류 재시도는 안 건드렸다」고
> 적었는데 **코드와 반대**였다(Codex 재리뷰 지적). BLOCK-2 로 이미 고쳤다 —
> 본문 오류도 같은 제공자 정책을 쓰고, 코드를 못 읽으면 `submission_unknown`
> 이다. 문서가 코드보다 뒤처져 「아직 위험이 남았다」로 읽히고 있었다.

## 추가 반영 — 재리뷰에서 남은 BLOCK 1건

`grok_image_client.py` 가 HTTP 200 본문 오류의 **정수 code 를 못 읽으면**
`terminal` 로 뒀다. 그런데 200 을 받았다는 것은 요청이 OpenRouter 까지
**닿았다**는 뜻이고, 코드가 없거나 예상 밖 형식(문자열 `"502"`·bool)이면
상류가 처리했는지·요금이 나갔는지 알 수 없다. `terminal` 로 두면
`possible_charge` 가 사라져 다음 방문이 오독한다.

→ 코드 부재·형식 이상은 `submission_unknown`. 검열 문구는 `is_moderation_text`
가 걸러 `terminal` 로 남는다 — 그쪽은 **무슨 일이 있었는지 아는** 경우다.


## 자체 code-review 로 찾은 것 (Codex APPROVE 뒤)

`/code-review` 스킬로 같은 diff 를 독립 검토했다. **셋은 이 판이 막으려던
중복 결제가 그대로 열려 있던 자리**다.

### HIGH-1 — 기본 경로에서 재요청 차단이 안 걸렸다

`cine_transform.py` 가 예외를 클래스 **이름**으로 판정했다
(`type(exc).__name__ == "ReveSubmissionUnknown"`). 이 판이 Grok·Gemini 에
던지게 한 것은 이름이 다른 `ImageSubmissionUnknown` 이고, **grok 이 기본
변환 제공자**(`config.still_cine_provider`)다. `rec["submission_unknown"]` 이
안 세워져 다음 걷기가 같은 변환을 또 산다.

→ `ReveSubmissionUnknown` 을 공용 예외의 **하위**로 만들고 `isinstance` 판정.

★상속으로 바꾸자 `cause` 가 전부 빈 문자열이 됐다 — 자식이
`super().__init__(message)` 만 불러 **부모 기본값이 덮어썼다.** 자식
`__init__` 을 지워 해결.

### HIGH-2 — 흔한 망 장애에 샷이 죽게 됐다

`never_sent` 를 두 예외로만 좁힌 탓에, 커널이 「보낼 길이 없다」고 되돌린
경우(`EHOSTUNREACH`·`ENETUNREACH`·`EHOSTDOWN`·`ENETDOWN`)까지 `unknown` 이
됐다. 그런 경우는 제공자가 요청을 **본 적이 없다** — 요금이 안 나간다.

→ errno 다섯을 `never_sent` 에. `ECONNRESET`·`EPIPE`·읽기 시간 초과는
  **안 넣는다**(이미 보낸 뒤일 수 있다).

### HIGH-3 · MEDIUM-4 · MEDIUM-6 · LOW-7

- `str(call_model_name(...))` → 문자열 아닌 것이 모델 이름으로 박힌다. 검사 추가.
- `outdoor_place_canon_step.nb2_generate` 가 **어떤 예외든** 순화본으로 다시
  보낸다 — 클라이언트가 막은 것이 한 칸 위에서 열렸다. 그 예외만 통과.
- backfill 이 잘린 프롬프트(`...[truncated]`)를 실제 문안으로 적던 것 → 건너뜀.
- 죽은 상수·헬퍼 주석이 옛 계약을 설명하던 것 정정.

## 이 판에서 낸 사고

`git stash pop` 이 **2026-06-25 의 옛 stash** 를 꺼내 충돌을 냈다. 작업
트리가 깨끗해 새 stash 가 안 만들어졌는데 `pop` 을 부른 탓이다.
`git reset --hard HEAD` 로 즉시 되돌렸고 HEAD·origin 일치, 옛 stash 3개
보존, 잃은 것 없음. **`git stash` 는 이 저장소에서 금지**인데 썼다.

## 1-H — 최종 i2i 를 끄면 시네마틱 마감이 통째로 사라진다

사용자 지시(2026-08-27): "최종 i2i 가 disable 되면 카메라 앵글이나 기타
시네마틱하게 만드는 추가 프롬프트는 최종 이미지 생성 프롬프트에 추가되어야
해".

카메라 값은 이미 지켜지고 있었다 — `cine_stage_dir_on` 이 `cine_on` 을
곱해서, 변환이 꺼지면 연출 재료를 앞단에서 걷지 않는다. 사라지는 것은
**마감 계약**이다: 다시 잡기·다시 켜기·앞뒤 층 나누기·공기감·필름 그레인,
그리고 "연출 사진처럼 보이면 안 된다".

★**그 문안은 이미 있었다.** `still_recipe/17/cinematic_finish.md`(336B)가
`cine_transform.md` 의 마감 부분을 t2i 용으로 다시 쓴 것이고, CAMERA 계약을
권위로 두고 "미지정 선택지만 영화적으로" 라는 조건까지 들어 있다. 새로
저작할 것이 없었고 **붙는 조건이 틀려 있었다.**

    grok2 + 변환 ON  → roll·i2i 양쪽   (그대로)
    grok2 + 변환 OFF → roll 에만       (그대로)
    nb2   + 변환 ON  → i2i 에만        (그대로)
    nb2   + 변환 OFF → 어디에도 없다   ← 이것만 바뀐다

★**지금 `.env` 는 마지막 줄이 아니다.** `STILL_IMAGE_BACKEND=nb2` 이면서
`STILL_CINE_TRANSFORM_ENABLED=true` 라 실제 조합은 세 번째 줄(i2i 에만)이다.
이 수정은 변환을 끄는 순간 열리는 구멍을 미리 막는 것이고 그때까지 잠들어
있다 — 앞서 「nb2 라 마지막 줄이 기본」이라 적은 것은 **두 플래그 중 하나만
보고 쓴 문장**이었다(자체 리뷰가 정정).

### 여기서 세 번 반쪽만 고쳤다 (전부 Codex 가 잡았다)

**① 두 축을 갈라 놓고 호출부는 옛 축을 넘기고 있었다.**
`build_cinematic_finish_clause` 의 기본 selector 가 grok 컴팩트 지도 팩
(`STILL_COMPACT_PROMPT_VERSION`)이었고 호출부는 `_guidance` 를 넘겼다.
전용 상수를 만들어 놓고도 grok 갈래에서는 마감 절이 컴팩트 selector 를 타고
갔다 — 지금 두 값이 같아서(둘 다 17) 나가는 바이트만 보면 안 드러난다.

**② 시험이 조건만 재고 나가는 문안은 한 번도 안 태웠다.**
붙이는 줄이 통째로 빠져도 초록이었다. 가르는 것과 붙이는 것을 한 함수
(`attach_cinematic_finish`)에 넣어 시험이 **프로덕션이 부르는 그 함수**를
태우게 했다. 호출부 형태는 AST 시험이 따로 본다.

**③ 붙는데 지문에 안 접혔다.**
문안을 고쳐도 완주한 에피소드가 clean skip 해서 새 문안이 영영 안 닿는다.
그런데 접는 조건이 두 갈래로 갈린다:

| 조합 | 지문에 접나 | 왜 |
|---|---|---|
| nb2 + 변환OFF | 언제나 | 컴팩트 키가 아예 없다 |
| nb2 + 변환ON | 안 접는다 | 절이 안 붙는다 |
| grok + 17/17 | 안 접는다 | `still_compact_pack_content` 가 팩 디렉토리를 통째로 덮는다 |
| grok + 18/17 | 접는다 | 갈라지면 안 덮인다 — 문안은 바뀌는데 지문이 그대로다 |

grok 에 무조건 키를 넣으면 완주한 grok 판이 전량 재실행된다. 그래서
**두 selector 가 갈라질 때만** 접는다.

시험은 해시가 달라졌는지가 아니라 **어느 selector 로 스템 해시가 불렸는지**를
잰다 — 달라졌다만 보면 "왜 달라졌는지"를 못 가른다.

## 1-A — signage 팩 v2 승격을 올렸다가 되돌렸다

v1 이 완주 판 7샷에서 6샷(86%)에 글자를 지어내고 v2 는 0% 다. 그것만 보면
올리는 것이 맞다. **되돌린 이유는 돈을 두 번 쓰기 때문이다** — 팩 문자열이
스텝 지문에 들어가므로 승격 한 번에 표기 저작이 걸린 샷이 다시 돌고, v3 를
올릴 때 또 돈다. 그리고 v2 는 아직 자가신고만 믿는다(v2 를 낸 판이 스스로
적어 둔 반례가 그대로다).

→ v3(`source_quote` 를 별도 칸으로 받아 코드가 입력에 있는지 검사 + 버린
항목을 records 에)까지 닫고 **한 번에** 올린다.

시험에서는 **selector 명시**만 남겼다 — 기본값을 따라가면 재는 대상이 같이
움직여, 옛 계약이 안 지켜져도 초록이 된다.

## 재는 범위를 넓히다 나온 것

- **`tests/core` 가 통째로 안 돌고 있었다.** stash 잔재 파일 하나가 수집
  단계에서 죽어 디렉토리 전체가 `Interrupted: 1 error during collection` 으로
  끝났다. 그 사이 그 안의 실패 21건이 아무에게도 안 보였다.
  → `scratchpad/archived_tests/` 로 옮기고 출처·막힌 이유를 적었다
  (그 디렉토리는 `.gitignore` 라 추적은 안 된다).
- **시험이 돈을 쓴다.** `tests/pipeline/test_outdoor_site_layout_plan.py` 의
  두 건이 `gemini-3.1-pro-preview` 실호출을 시도한다. Codex 가 원인까지
  확인했다 — layout/rewrite 만 override 하고 나머지는 provider 로 채운다.
- **재는 도구가 흔들린다.**
  `test_litellm_failover_coverage::test_key_level_429_does_not_retry_the_dead_key`
  가 전체 실행에서 간헐적으로 뜬다(한 판에서 5회 중 2회, 다음 판에서 6회 중
  0회). 단독 실행은 통과하고 `openai_keys.reset()` autouse fixture 가 앞뒤로
  있는데도 그렇다. **원인 미상.** 리그레션 판정을 실패 집합 일치 하나에만
  기대지 않도록, 만진 파일별 시험을 따로 확인한다.

## 내가 틀렸던 것 (추가)

### ⑤ OpenAI SDK 가 Idempotency-Key 를 보낸다고 했다

"SDK 내부 재시도는 같은 키를 재사용하니 중복 결제가 안 난다"고 적었다.
틀렸다. openai 1.109.1 은 `_base_client.py:389` 에서
`self._idempotency_header = None` 이고 패키지 전체에 다른 할당이 없다
(`:368` 선언 · `:389` 대입 · `:441` 사용, 셋뿐). `:441` 이 그 값이 truthy 일
때만 헤더를 싣는다 — 키를 만들기는 하나 **실제로 전송되지 않는다.**
SDK 기본 2회 재시도도 중복 결제다.

### ⑥ AST 스캔이 간접 경로를 놓쳤다

`generate_image` 를 **직접** 감싼 광범위 except 11곳을 찾아 하나씩 확인하고
"재구매하는 곳은 없다"고 보고했다. Codex 가 찾은 자리는
`draw_marker_map_via_model` → engine → `render_via_gemini` → `generate_image`
로 세 단계 건너뛴 경로라 내 스캔이 못 봤다. **직접 호출만 세면 한 단만
멀어져도 안 보인다.** Codex 는 반대 방향으로 — `generate_image` 의 호출자
50곳을 거꾸로 훑어 — 찾았다.

## 자체 code-review 2차 — 1-H 범위 (2026-08-27)

`/code-review` 를 `3efc1347..59ff5738` 에 다시 돌렸다. Codex APPROVE 를
받은 뒤였는데 **14건**이 나왔고, 그중 하나는 **내가 사용자에게 잘못 보고한
문장**이었다. 전부 실측(mutation·base↔HEAD 해시 대조·실제 설정 실행)으로
확인된 것들이다.

### 이번 판에 반영한 것

| | 무엇 |
|---|---|
| ① | AST 시험이 상대 경로를 열어 저장소 루트에서 pytest 를 부르면 죽었다 → `Path(__file__)` 로 |
| ② | 그 AST 시험이 `ast.Name` 만 봐서 `_guidance or ""`·`f(_guidance)`·`sr.attach_…(…)` 가 다 빠져나갔다 → 인자 **하위 트리 전체**를 훑고 `Attribute` 호출도 잡는다 |
| ③ | 네 조합 지문 시험이 **공허**했다 — 마감 키를 통째로 빼도 통과한다(기존 두 플래그가 이미 네 값을 가른다). 지우지 않고 **무엇을 재는 시험이 아닌지**를 docstring 에 못박았다 |
| ④ | selector 를 팩에 스템 없는 값으로 올리면 `_config_hash_base` 가 죽는다(fail-closed). 갈림 시험은 해시 계산을 가로채므로 그 위험을 못 본다 → **실제 파일 존재**를 보는 시험을 따로 |
| ⑤ | 지문 쪽은 `!= "nb2"`, 조립 쪽은 `== "grok2"` 였다. 지금 같은 값인 것은 validator 덕이지 두 줄이 같은 뜻이어서가 아니다 → 표현을 맞추고 **AST 로 두 식이 같은지** 잠갔다 |
| ⑥ | **근거 문장이 틀렸다** — 아래 |

### ⑥ 내가 두 플래그 중 하나만 보고 썼다

「`.env` 가 nb2 라 **nb2 + 변환 OFF** 가 실제 기본 조합」이라고 코드 주석·
문서·커밋 메시지·사용자 보고에 적었다. `.env` 는

    STILL_IMAGE_BACKEND=nb2
    STILL_CINE_TRANSFORM_ENABLED=true     ← 이걸 안 봤다

라 실제 조합은 **nb2 + 변환 ON = i2i 에만** 이고, 그것은 이 판이
**안 바꾸는 줄**이다. 실측으로도 확인된다 — 실제 `.env` 에서
`_config_hash_base()` 가 base 와 HEAD 에서 바이트 동일(`caf13a0779718046`).

수정 자체는 유효하다. 다만 **지시하신 상황(변환을 끔)이 켜질 때까지
잠들어 있다.** 읽는 사람이 「고쳐졌다」로 믿게 두면 안 된다.

### 별건으로 넘긴 것 — 셋은 실재하는 결함이다

**(가) 변환이 포기·기각되면 그 샷은 마감이 어디에도 없다.**
`cine_on` 이 참이면 roll 에 마감을 안 붙이는데, 그 샷이 `declined`(검열
포기) 또는 `rejected`(검증 관문)면 원본이 최종본이 된다
(`still_recipe_service.py:4670~4690`).

> ★**앞선 판에서 두 가지를 틀리게 적었다** (2026-08-27 Codex BLOCK).
>
> 1. **일반 실패는 여기 안 들어간다.** `:5208` 이
>    `StillCineTransformIncomplete` 를 올려 스텝을 안 닫고 resume 이
>    실패한 변환만 다시 시도한다. 확정 원본 fallback 은 포기·기각 둘뿐이다.
> 2. **「성공 2 · 기각 3」은 지금 계약이 아니다.** reve 2.1 로 갈아탄
>    직후 옛 5축 검증 계약의 초기 표본이다. 지금(plausibility 계약)
>    records 실측은 최근 여섯 판이 `applied` 7/7 · 7/7 · 4/4 · 190/192 ·
>    187/188 · 213/213 — 기각·포기 **0**.
>
> 시점과 계약을 안 밝히고 옛 수를 현재처럼 썼다. 구멍은 **구조적으로**
> 남아 있지만(검열 포기는 언제든 난다) 지금 빈도는 그 수가 아니다.

붙일지는 프롬프트를 만들 때 정해지고 변환 결과는 그 뒤에 나오므로 지금
자리에서는 못 고친다. 방향 둘:
  - roll 에 **언제나** 붙인다 → 변환 성공 시 중복. 마감 절은 CAMERA 권위를
    안 건드리므로 구도를 뒤집지는 않는다. 대신 nb2 완주본이 전량 재실행.
  - 실패한 샷만 원본에 마감을 다시 입힌다 → 그림을 더 산다.
어느 쪽이든 **육안 확인이 먼저**다.

**(나) 가리킬 CAMERA 계약이 없는데 그것을 참조한다.**
마감 절 끝이 "Within the CAMERA contract above" 인데, CAMERA & FRAME
블록이 없는 경로가 셋이다 — confined-fp(`strip_camera_block` 이 걷는다) ·
lane 샷(`camera_frame_on and not lane_used`) · staging 이 없을 때
(`build_camera_frame_clause` 가 `""`). 실측:

    CAMERA 절 길이: 0B
    걷어낸 뒤 — CAMERA & FRAME 남음: False / CINEMATIC FINISH 남음: True

기존 grok 갈래에 이미 있던 결함이지만 **이 판이 nb2 로 넓혔다.**
`broll_composition_variation.md` 도 같은 자리를 참조한다. 고치려면 팩을
두 조각(공통 마감 + CAMERA 참조)으로 갈라야 하므로 새 팩 버전이 든다.

**(다) 변환을 끄면 nb2 완주본이 전량 재실행된다.**
지문뿐 아니라 **roll 프롬프트 바이트**가 바뀌고, 그것을
`compute_input_fingerprint`(`multiroll_select.py:722`)가 직접 접는다 →
`_clear_outputs` 가 `_a`..`_e`/`_sel`/`_fix`/`_cine` 를 지우고 다시 산다.
종전에는 그 토글이 i2i 단계만 건너뛰었다. 주석에 적었다.

### 정리로 넘긴 것

- 마감 절만 `build_still_prompt` **밖에서** 이어 붙는다(다른 절은 전부
  인자로 들어간다). 안으로 넣으면 절 순서·CAMERA 전제·걷기 규칙이 한
  주인 아래 모이고 AST 시험도 필요 없어진다.
- `test_attach_uses_its_own_selector_not_the_compact_pack` 은 구조상 항상
  참이고 85줄 아래 시험과 겹친다.
- 같은 메서드가 `STILL_COMPACT_PROMPT_VERSION` 을 세 이름으로 묶는다.
- 「grok 백엔드인가」가 네 곳에 펼쳐져 있다 — `applicability.py` 가 그런
  술어의 제자리다.
- `attach_cinematic_finish` 가 호출마다 팩 파일을 읽는다(`load_prompt` 는
  캐시가 없다). 종전에는 한 번 읽어 두 프롬프트에 같은 문자열을 붙였다.

## 1-H 최종형 — roll 에 **언제나** 붙인다 (2026-08-27 사용자 결정)

사용자 결정: "roll 에 언제나 붙이는 쪽으로 가". Codex 가 순서를 제안했고
그대로 따랐다 — **CAMERA 없는 경로를 같은 팩에서 함께 닫고 selector 를
한 번만 올려** 전량 재생성도 한 번만 친다.

### 왜 조건을 없앴나

「최종 i2i 가 마감을 하느냐」라는 전제가 **변환이 성공할 때만** 참이다.
검열 포기(`declined`)·검증 기각(`rejected`)이면 원본이 최종본이 되는데
그때 `cine_on` 은 여전히 참이라 roll 에 마감이 없다. 붙일지는 프롬프트를
만들 때 정해지고 변환 결과는 그 뒤에 나온다 — **순서가 반대라** 조건으로는
못 닫는다.

중복은 해롭지 않다: 마감 절은 CAMERA·layout 권위를 건드리지 않고 미지정
선택지만 다룬다. grok 백엔드는 이미 그 조합으로 돌고 있었다.

### 팩 v25 — 문안이 없는 것을 가리키던 것

옛 문안은 "Within the CAMERA contract above" 로 끝났는데 CAMERA & FRAME
블록이 없는 경로가 셋이다(confined-fp · lane 샷 · staging 부재). 위치를
가리키는 대신 **이 브리프와 첨부 참조가 이미 정한 것**을 보존 대상으로
말해 세 경로를 한 문안으로 닫았다. 스템을 둘로 쪼개지 않았다.

    Whatever this brief and its reference images already fix — the framing,
    the layout, where each thing stands — stays exactly as fixed; make only
    the choices they leave open, and make those the more cinematic ones.

★팩 디렉토리를 만드는 것만으로는 안 된다 — `PROMPT_VERSION_MAP` 에
등록해야 `resolve_prompt_version` 이 푼다. 시험이 그것을 잡았다
(`KeyError: '25'`).

### 이 판에서 새로 찾은 것 — **CAMERA 뒤가 통째로 사라지는 경로**

`confined_fp.strip_camera_block` 은 `CAMERA & FRAME` 을 만나면 버리기
시작해 **`Compose the frame` 으로 시작하는 줄**에서 멈춘다. 그런데

    prompts/_base/still_recipe/6.202607192140/camera_frame_clause.md  → 있다
    prompts/_base/still_recipe/22.202608180100/camera_frame_clause.md → **없다**

컴팩트 팩(v22)에는 그 문장이 없다. 그 팩으로 조립한 브리프를 confined
경로에 넣으면 **CAMERA 이후 프롬프트 전부**가 사라진다 — 마감 절뿐 아니라
그 뒤의 모든 절이. 실측으로 확인했다.

이 판에서 안 고쳤다(범위가 다르고 컴팩트 팩 계약을 건드린다). 시험을
써 두었다가 **빨간 채로 남기지 않으려고 뺐다** — 별건에서 고치며 함께
넣는다.

### 지출

조건이 바뀌어 nb2 로 완주한 샷은 롤부터 다시 산다
(`compute_input_fingerprint` 가 프롬프트를 직접 접는다). 문안 교체와 같은
판에 넣어 **한 번만** 치게 했다.

### 컴팩트 팩 걷기 결함도 같이 고쳤다 (사용자 지시)

사용자: "컴팩트 팩 걷기 결함도 이번에 같이 고쳐."

`confined_fp.strip_camera_block` 은 `CAMERA & FRAME` 을 만나면 버리기
시작해 **`Compose the frame` 으로 시작하는 줄에서만** 멈췄다. 그 문장은
팩 v6 에만 있고 컴팩트 팩 v22 에는 없다 — grok 조립 + confined 경로에서
CAMERA 이후 프롬프트 **전부**가 잘린 채 유료 호출로 갔다. 마감 절만이
아니라 그 뒤의 모든 절이.

    still_recipe_service.py:3615  attach_cinematic_finish
    still_recipe_service.py:3814  같은 prompt → build_confined_gen_prompt
    confined_fp.py:335            strip_confined_brief → strip_camera_block
    confined_fp.py:128            "Compose the frame" 에서만 종료
    22.202608180100/…:1-3         그 문장이 없다
    still_recipe_service.py:3862  잘린 프롬프트가 유료 roll 로

**고친 자리는 팩이 아니라 sanitizer** 다(Codex 제안). 컴팩트 스템에 종료
문구를 넣으면 나가는 v22 바이트가 바뀌어 완주본이 재생성된다. 다음
top-level 헤딩(`_HEADER_RE`)에서도 끝내되 **그 헤딩은 보존**하면 나가는
문안은 그대로고 잘리던 것만 안 잘린다.

`Compose the frame` 갈래는 손대지 않았다 — v6 로 조립한 브리프의 바이트가
한 자도 안 바뀐다(옛 구현을 시험 안에 다시 적어 대조했다. 구현을 부르면
같이 틀렸을 때 둘 다 초록이다).

시험 다섯: 컴팩트 팩이 뒤를 지키는가 · **실제 `build_confined_gen_prompt`
흐름**으로도 지키는가 · v6 바이트 동일 · CAMERA 없으면 손 안 댐 · 블록을
끝낸 헤딩을 안 삼킴. 양성 대조 둘 — 헤딩 종료를 빼면 3건, 헤딩을 버리게
하면 2건이 빨갛다.

> ★앞선 판에서 이 결함을 찾고도 **시험을 뺐다**(빨간 채로 남기지 않으려고).
> 사용자가 같이 고치라고 해서 이번 판에 넣었다 — 결함을 알면서 시험을
> 지우는 것은 다음 사람에게 없는 일로 만드는 것이다.
