# 0단계 설계안 — 결제 신원과 최종 출처

> 감사 보고서 `artifact/20260826_full_audit/index.html` 0단계.
> 브랜치 `fix/audit-stage0-billing-identity`.
> **그림을 다시 사지 않는다** — 기록·재시도 판정만 고친다.

## 0-A. timeout 뒤 중복 결제 차단

### 지금 무슨 일이 일어나는가

동기 이미지 요청은 **서버가 요청을 받은 뒤 응답만 유실**될 수 있다.
지금 두 클라이언트는 그 상태를 「접수 실패」로 보고 새 요청을 보낸다.

```
grok_image_client.py:200-240
    _roundtrip()  →  urlopen(req)          # body 는 이미 나갔다
    _fut.result(timeout=timeout + 60)      # 벽시계 마감
    _ex.shutdown(wait=False, …)            # 스레드를 버린다 — 소켓은 살 수 있다
    except _futures.TimeoutError:
        if attempt <= max_retries: continue   # ★두 번째 유료 요청

gemini_image_client.py:251-263
    except (urllib.error.URLError, socket.timeout):
        api_key = self._get_api_key()      # 키를 바꿔
        req = Request(url, data=…)         # 같은 body 를 새 요청으로
        continue
```

`reve_image_client.py:557-561` 은 **이미 옳게 갈라 놓았다**:

> 연결이 아예 안 됐다 → 요금 안 나갔다 → 다시 보낸다
> 답이 오다가 끊겼다 → **요금이 나갔을 수 있다** → 멈춘다

두 클라이언트만 이 계약을 안 따라갔다.

### 계약

전송 여부를 **세 갈래**로 가른다. 판정은 예외의 원인으로만 하고,
글자를 뒤져 뜻을 재지 않는다.

| 갈래 | 근거 | 처리 |
|---|---|---|
| `never_sent` | **요청이 서버에 닿지 못한 것이 확정된 경우만** — 이름 못 찾음(`socket.gaierror`) · 연결 거부(`ConnectionRefusedError`) | 자동 재시도 (지금과 같다) |
| `unknown` | **그 밖의 모든 실패** — 읽기 시간 초과 · 연결 끊김 · 원격 조기 종료 · 벽시계 마감 | **재전송 금지.** 기록만 남기고 실패 |
| `sent` | HTTP 응답을 받았다 (4xx/5xx 포함) | 지금 정책 그대로 (검열은 재시도 안 함 등) |

★**판정은 열거로 하지 않는다 — 「닿지 못한 것이 확정된 둘」만 세고 나머지는
전부 `unknown`** 이다(fail-closed). 이미 `reve_image_client.classify_submit_failure`
가 그 계약이다(`_NEVER_SENT` 두 종류 + `URLError` 가 감싼 원인까지 5단계
따라 들어감). 그것을 공용으로 올려 세 클라이언트가 함께 쓴다 — **새로 만들지 않는다.**

★grok 의 벽시계 마감(`_futures.TimeoutError`)도 `unknown` 이다. 다만 근거는
「거기까지 갔으니 body 는 나갔을 것」이 **아니다** — `Future.result()` 는
worker 가 DNS·connect·TLS·업로드·읽기 중 어디에 있는지 알려주지 않는다.
**모르니까 보수적으로 `unknown`** 인 것이다(2026-08-26 Codex 지적, 수용).

`unknown` 을 재전송으로 내리지 않는 이유 — 그렇게 하면 같은 이미지에
요금이 두 번 나가고, 기록에는 마지막 한 건만 남아 **첫 시도의 비용과
결과 신원을 잃는다**. Gemini 는 키까지 바꾸므로 서버 쪽에서도 같은
요청인지 알 수 없다.

### 기록 — ~~attempt ledger~~ (superseded)

> **★이 절은 구현되지 않았다.** 아래 설계는 Codex 검토에서 두 번 반려됐다:
> ① 호출이 끝난 뒤 남기므로 프로세스가 죽으면 행이 없고,
> ② `dispatching` 행을 미리 INSERT 해도 재개된 client 가 **새 UUID 로 또 보낸다**.
> 실제로 필요한 것은 결정적 `logical_request_key` + unresolved 조회/CAS +
> partial unique index 이고, 별도 판의 몫이다. 지금 남는 것은
> `status="submission_unknown"` + `possible_charge` **표시뿐**이며 그것은
> 다음 호출을 막지 않는다.

### (원안 — 참고용)

새 테이블을 만들지 않는다. 이미 있는 `llm_call_log` + `metadata_json` 을 쓴다.

```
status            = "submission_unknown"
metadata_json     = {
    "attempt_id":         "<uuid>",
    "parent_attempt_id":  "<앞 시도 uuid 또는 null>",
    "attempt_no":         2,
    "send_state":         "unknown",
    "possible_charge":    true,
    "effective_request_sha": "<body sha256 16>",
    "provider_request_id": "<있으면>",
}
```

`possible_charge=true` 인 행을 세면 **요금이 나갔을 수 있는데 결과를
못 받은 건**이 바로 나온다. Opik 에도 같은 봉투를 보낸다.

### ~~정책 플래그~~ (구현 안 함)

> `IMAGE_RESEND_ON_UNKNOWN_SEND` 는 **만들지 않았다** — 방금 막은 위험을
> 전역 플래그 하나로 다시 여는 구조다(Codex 권고, 수용). 필요해지면
> 「특정 시도를 운영자가 명시적으로 재실행」으로 둔다.

### (원안 — 참고용)

기본은 **재전송 금지**(돈 보호). 운영자가 「일시 장애에 한 번 더 태워도
좋다」고 정할 수 있어야 하므로 플래그 하나를 둔다.

```
IMAGE_RESEND_ON_UNKNOWN_SEND = false   # 기본 false
```

`true` 여도 재전송분은 **별도 유료 attempt 로 기록**한다
(`possible_charge=true` + `parent_attempt_id`).

### 테스트 (양성·음성 둘 다)

- **양성** — 테스트 서버가 body 를 받은 뒤 응답만 늦춘다 →
  provider submit 이 **1회**, `submission_unknown` 기록이 남는지
- **음성** — connection refused → 정책대로 재시도되는지
- DNS 실패 → 재시도되는지
- 플래그 `true` → 재전송되고 `parent_attempt_id` 가 이어지는지

---

## 0-B. 최종 이미지 출처 기록

### 지금

7/7 primary asset 이 이렇게 기록돼 있다.

| 칸 | 기록 | 실제 |
|---|---|---|
| `generation_model` | `x-ai/grok-imagine-image-2.0` | `reve/2.1/edit` |
| `prompt_used` | 빈 문자열 | `"Rework this still into a cinematic key frame…"` |
| `original_prompt` | NULL | — |
| `generation_call_id` | 올바름 | reve 호출을 정확히 가리킴 |

```
still_recipe_service.py:4892
    "generation_model": (
        settings.grok_image_model        ← 실제는 reve
        if _cine_applied else …)

바로 위 주석: "변환 적용 자산은 변환 모델 유지"   ← 주석과 코드가 어긋나 있다
```

프롬프트도 같다 — 실제 전송본은 샷마다 만드는 `_cine_prompt`(`:4569`)인데
저장은 바깥 `cine_prompt`(`:4820`)를 쓰고, stage-direction 모드에서 그
바깥 값은 **의도적으로 빈 값**(`:1202`)이다.

### 계약

변환이 **자기가 무엇을 했는지 한 덩어리로** 돌려준다. 저장하는 쪽은
그 덩어리만 읽는다 — 설정값을 다시 추측하지 않는다.

```python
{
    "provider":      "fal",
    "model":         "reve/2.1/edit",
    "effective_prompt": "<실제 보낸 문안 전문>",
    "provider_request_id": "…",
    "input_image_sha":  "…",
    "output_image_sha": "…",
}
```

`_cine_applied` 일 때 `generation_model`·`prompt_used` 는 **이 덩어리에서만**
온다. 변환이 없으면 지금 경로 그대로.

### 기존 7건

`generation_call_id` → `llm_call_log` 조인으로 backfill 한다.
**그림은 다시 사지 않는다.**

---

## 0-C. Reve 405 진단 문구

같은 저장소의 두 주석이 정면으로 충돌한다.

```
reve_image_client.py:290-294   ← 진단 문구
    "fal endpoint id 는 `fal-ai/` 접두를 포함한 전체 경로다"

config.py:987-993              ← 설정 주석
    "fal 이 직접 호스팅하는 모델만 `fal-ai/` 접두를 쓰고, 제휴 모델은
     제공자 이름으로 시작한다. … 멀쩡한 값을 틀린 것으로 바꾼 것이다.
     405 의 원인은 여기가 아니다."
    reve_image_model: str = "reve/2.1/edit"
```

실측이 설정 쪽 손을 들어 준다 — `reve/2.1/edit` 로 **15건 성공**했다.

진단이 모델명을 바꾸라고 권하면 **정상 설정을 다시 망가뜨린다**.
이미 한 번 그렇게 잃었던 함정을 코드가 다시 놓고 있다.

### 수정

접두 단정을 지우고 **본 것만** 출력한다 — 보낸 URL · model id ·
HTTP status · 응답 body/header. 무엇을 고치라고 권하지 않는다.

---

## 0-D. Opik 신원 봉투

모든 provider 호출에 같은 봉투를 싣는다.

```
project · episode · step · still · shot · operation
model_alias · model_physical · request_sha · attempt_id · parent_trace_id
```

지금은 OpenRouter span 은 샷 신원을 직접 갖고 있는데 Gemini judge/critique
span 은 부모 trace 를 따라가야 나온다. 그리고 trace 11,800건 중 **10,918건이
`step:` 접두 없는 태그**라 비용의 87%가 어느 스텝인지 모른다
(프로젝트 이름이 태그로 12,390회 들어가 있다).

## 0-E. 로그 상한

`llm_logger.py:46` 의 `MAX_PROMPT_CHARS = 10000` 때문에 185건이 잘려 있다.
**가장 긴 프롬프트일수록 감사에서 사라진다** — 문제가 있을 확률이 가장
높은 것부터 안 보인다. 상한을 올릴지, 잘린 사실을 별도 칸에 남길지 정한다.
