# Opik 기록 체계화 — 계층·이름·태그 설계

- 날짜: 2026-08-23 (KST)
- 대상: 자체 호스팅 Opik (`OPIK_URL_OVERRIDE=http://192.168.133.87:5173/api`,
  프로젝트 `theroad-scene-lab`)
- 기준 커밋: `bb2aca06`
- **개정**: 2026-08-23 Codex 코드 리뷰 6건(BLOCK) 반영 — 전부 file:line 으로
  직접 확인해 수용했다. 가장 큰 것은 **④ 의 전제가 틀렸다는 것**(샷 경계가
  프로덕션 본류에 없다). 나머지는 ⑧(접합·특수 갈래·반환 경로·계보) ·
  ⑩(v1 이중 계산) · ⑨(거르는 그물의 한계) · ⑫(진단이 프로덕션을 오염).

## ⓪ 한 줄

기록이 두 갈래(litellm 콜백 · `ImageTracer`)로 갈려 이름도 묶음도 축도 서로
다르다. **계층 3층(주행 → 작업 단위 → 호출)** 을 세우고 태그를 6축으로 가른
뒤, **`shot_run_uid`(UUIDv7) 하나로 Opik·`records.json`·`image_asset` 세
섬을 잇는다.** 「주행 훑기 · 프롬프트 감사 · 샷 계보 · 최종 샷 영향」 넷을
물을 수 있게 만드는 것이 목표다.

## ① 지금 모양 — 실측

측정 방법: `/v1/private/traces` 를 페이지 균등 표집(84페이지 중 21페이지,
**trace 4,200건 / 전체 16,569건**). 2026-08-23 KST 조회.

| 축 | 실측 |
|---|---|
| trace 이름 | 2,200건(52%)이 `chat.completion` 하나. 나머지 40종은 `{step}/{model}` |
| thread_id | 76%가 `None`, 서로 다른 값 **4개** |
| tag | **81종이 한 자루** — 스텝·모델·제공자·프로젝트·에피소드·상태가 섞임 |
| 계층 | trace 16,569 : span 16,566 ≈ **1:1** (평평하다) |
| trace metadata | litellm trace 마다 `user_api_key_*` 등 **60여 필드** |

### 뿌리 A — litellm 은 trace 이름을 못 받는다

`litellm/integrations/opik/opik_payload_builder/payload_builders.py`:

```python
def build_trace_payload(...):
    trace_name = response_obj.get("object", "unknown type")   # 항상 chat.completion
```

litellm 의 opik 통합에서 **동작을 제어하는** 키는 **넷뿐**이다 —
`project_name` · `current_span_data` · `tags` · `thread_id`
(`opik_payload_builder/api.py:51-60`). `trace_name` 은 소스에 **0회** 등장한다.

★엄밀히는 「넷만 읽는다」가 아니다. `extractors.py:171` 이

```python
metadata = {k: v for k, v in opik_metadata.items() if k != "current_span_data"}
```

로 **나머지 키를 전부 trace/span metadata 에 복사**한다. 그래서 `trace_name` 은
이름 제어 키로는 죽었지만 **metadata 에는 남아 있고**, 감사 도구가 지금까지
`metadata.trace_name` 을 읽어 버틴 것이 바로 그 덕이다.

그런데 `step_runner.py:1620` 은 `trace_name` 을 만들어 싣는다. **죽은 키다.**
(다만 litellm 이 opik metadata 를 통째로 trace `metadata` 에 넣어 주기 때문에,
감사 도구 `tools/opik_prompt_audit/audit/fetch.py:59` 의 `step_of` 가
`metadata.trace_name` 을 읽어 지금까지 버텨 왔다.)

### 뿌리 B — session 키 이름이 틀렸다

`step_runner.py:1621` 은 `meta["session_id"]` 를 넣는데 litellm 이 읽는 것은
**`thread_id`** 다. 그래서 텍스트 호출의 주행 묶음이 통째로 없다. 살아 있는
thread 4개는 전부 `ImageTracer.log` 가 자기 손으로
`thread_id=session_id` 를 넘겨 만든 것이다 (`image_tracer.py:182`).

### 뿌리 C — 주행 묶음이 재개마다 쪼개진다

`analysis_dispatch_service.py:47` 의 `build_opik_context` 는 부를 때마다
`run_tag` 를 새로 만든다(`ts` = 현재 시각, `uuid4()[:8]`). 그리고
`api/v1/steps.py:253` 이 **단일 스텝 호출마다** 이것을 부른다.
215샷 주행을 30번 재개하면 thread 가 31개로 갈린다. 키 이름만 고쳐서는
「주행 하나를 통째로」가 안 된다.

### 뿌리 D — 기록기가 셋인데 축이 다르다

| 기록기 | 어디로 | 이름 규칙 |
|---|---|---|
| litellm 콜백 | Opik | `chat.completion` 고정 |
| `ImageTracer` (`record_provider_call`) | Opik + DB | `{step}/{model}` |
| `PromptTracer` (`prompt_tracer_legacy.py`) | JSONL 파일 | `stage` |

### 뿌리 E — DB 와 Opik 이 서로 다른 부분집합이다

`llm_client.py`(litellm 경로 = 텍스트 주력)는 **`log_llm_call` 을 안 부른다.**
같은 기간(2026-08-14~) DB `llm_call_log` **4,722행** vs Opik **16,569 trace**.
반대로 i2i 경로는 Opik 엔 있고 DB 엔 **0행**이다.

> ★★**2026-08-24 정정 — 이 한 줄은 오독이었다.** Opik 의 i2i trace 84건이
> **전부 시험 기록**이다(`project_id="p-i2i"` 21건 · 신원 없이 `duration_ms=0`
> 63건). 프로덕션에서 그 경로가 안 돌았으므로 DB 0행이 **정상**이고, 「서로
> 다른 부분집합」의 근거로 쓸 수 없다. 뿌리 E 의 나머지(litellm 경로가
> `log_llm_call` 을 안 부른다)는 그대로 유효하다.
>
> ★시험이 프로덕션 Opik 에 쓰고 있었기 때문에 생긴 오독이다(단계 1 이 그것을
> 고쳤다). **오염된 자료로는 진단도 오염된다.**

그런데 **축은 DB 쪽이 낫다.** `llm_call_log.operation_type` 에는
`still_recipe_judge_openrouter:xai/grok4.6` · `still_cine_transform` ·
`confined_fp_mark` 같은 **진짜 단계**가 들어 있는데, Opik 에서는 span 의
`params.role` 에 묻혀 검색도 묶기도 안 된다.

### 뿌리 G — 샷 경계가 프로덕션 본류에 없다

★2026-08-23 Codex 리뷰로 드러났다. 상세는 ④.

`generation_context`(capture scope)는 14곳에서 열리는데 **정작 샷을 찍는
`still_recipe_service.py` 에는 0건**이다. `scene_image_service.py:404` 가
`run_still_recipe_generation` 을 scope 없이 바로 부르고, 그 안의 샷 루프
(2272줄)에도 없다.

실측이 그대로 말한다:

> still-recipe 계열 Opik trace **529건 전수에 `still_id` 가 없다(100%)**.

그래서 「이 기록이 어느 샷 것이냐」를 지금 **한 건도** 못 묻는다.

### 뿌리 F — 시험 기록이 프로덕션 감사 데이터에 섞여 있다

돈 가드(`backend/tests/netprobe.py`)는 **집 안(사설) 주소를 통과시킨다** —
요금이 없으니 그 자체는 옳다. 그런데 자체 호스팅 Opik 이 바로 집 안 주소라,
시험이 프로덕션 Opik 프로젝트에 쓴다.

발견 경로: `i2i_edit/m` 이라는 trace 를 쫓다
`backend/tests/services/image_capture/test_i2i_capture.py:53` 이
`_gemini_generate_content(api_key="k", model="m", prompt="x", input_images=[])`
로 진짜 `record_provider_call` 을 부르는 것을 찾았다.

측정: 표집 4,200건 중 **657건(15.6%)** 이
「이미지 기록 모양 + `project_id` 없음 + `duration_ms == 0`」 —
전체로 환산하면 **약 2,600건**. 프롬프트 앞머리가 결정적이다:

```
139  'xxxxxxxxxxxxxxxxxxxx'      49  'pppppppppppppppppppp'
109  'photo prompt'              45  'x'
 74  'p'                         26  'SAMPLE system' / 'SAMPLE prompt'
```

★**이 숫자는 총량이 아니라 하한이다.** 그물이 「신원 없음 + duration 0」만
잡는데, 시험 중에는 **가짜 신원을 달고 나가는 것**도 있다 —
`tests/services/image_capture/test_i2i_capture.py:34` 는
`generation_context("p-i2i", "e-i2i", "i2i_edit")` 안에서 부르므로
`project_id="p-i2i"` 가 실려 이 그물을 **통과한다**. litellm 경로를 타는 시험
기록도 한 건도 안 잡힌다.

「2,600건」을 「전부」로 읽으면 안 된다. 뒤에서 거를 때(⑩) 이 한계를 그대로
안고 간다 — 남는 것이 있을 수 있다.

## ② 목표 — 고친 뒤 물을 수 있어야 하는 것

1~3 은 사용자가 고른 것이고, 4 는 뒤이어 요구된 것이다.

1. **주행 하나를 통째로 훑기** — 주행 > 작업 단위 > 호출로 접었다 폈다
2. **프롬프트 감사** — 어느 스텝의 어느 팩이 얼마나 커졌나
3. **샷 하나의 계보** — 이 스틸 한 장이 어떤 호출들을 거쳐 나왔나
4. **최종 샷에 무엇이 영향을 미쳤나** — 어느 참조가 들어갔고, 무엇이 골랐고,
   무엇을 고쳤나 (3 이 「어떤 호출」이라면 4 는 「어떤 입력과 판단」이다)

돈·실패 추적은 이번 목표가 아니다(부수로 좋아지지만 설계를 그쪽에 맞추지
않는다).

## ③ 뼈대 — 3층

```
thread_id = 에피소드 1개 (재개해도 같다)
  trace   = 작업 단위 — 스텝 1회, 또는 샷 1개
    span  = 모델 호출 1회
```

### 계층을 만드는 유일한 통로

`opik_payload_builder/api.py`:

```python
current_span_data = opik_metadata.get("current_span_data")
trace_id, parent_span_id = extractors.extract_span_identifiers(current_span_data)
...
if trace_id is None:                    # ← 주면 trace 를 안 만든다
    trace_id = utils.create_uuid7()
    trace_payload = payload_builders.build_trace_payload(...)
# Always create a span
```

`metadata["opik"]["current_span_data"] = {"trace_id": <우리 trace id>, "id": None}`
을 실으면 litellm 은 **trace 를 만들지 않고 span 만 만들어 우리 trace 밑에
붙인다.** `chat.completion` 이 통째로 사라진다.

### thread_id — 에피소드 단위로 고정

뿌리 C 를 푸는 방식이다.

```
thread_id = f"{project_name}_{episode_title}_{episode_id[:8]}"
```

- 재개·재기동·단일 스텝 호출 전부 같은 값 → 주행이 안 쪼개진다
- 저장할 것이 없다(에피소드에서 바로 만든다)
- 한 dispatch 를 따로 보고 싶을 때를 위해 기존 `run_tag` 는
  **metadata `run_tag`** 로 내려 보존한다 — 묶음은 에피소드, 구분은 metadata

## ④ 경계 — 둘은 있고, 하나는 없다

> ★**2026-08-23 정정.** 이 절은 처음에 「세 경계가 전부 이미 코드에 있다」고
> 썼다. **틀렸다.** 스텝 경계는 있지만 **샷 경계는 프로덕션 본류에 없다.**
> 아래 실측 참조. 이 오독이 계획 Task 7·12 를 통째로 헛돌게 할 뻔했다.

| 층 | 자리 | 상태 |
|---|---|---|
| thread | `StepRunner.opik_context` | **있다** — 키를 `thread_id` 로, 값을 에피소드 단위로 |
| trace(스텝) | `StepRunner` claim~해제 (`step_runner.py:1253` / `1375`) | **있다** — 그 사이에 trace 를 열고 닫는다 |
| trace(샷) | still-recipe 샷 루프 (`still_recipe_service.py:2272`) | **★없다 — 새로 만들어야 한다** |
| span | litellm 콜백 · `record_provider_call` | **있다** — 부모 trace 에 붙인다 |

### 샷 경계가 없다 — 실측

`generation_context`(`image_capture/context.py:83`)는 14곳에서 열리지만
**`still_recipe_service.py` 에는 0건**이다. 그 파일의 샷 루프
(`for i, s in enumerate(ordered):`, 2272줄)에 capture scope 가 없다.
`scene_image_service.py:404` 가 `run_still_recipe_generation` 을 **scope 없이**
바로 부른다(638줄의 `generation_context` 언급은 주석뿐이다).

결과가 기록에 그대로 드러난다:

> still-recipe 계열 Opik trace **529건 전수에 `still_id` 가 없다(100%)**.

★내가 `grep generation_context` 로 14건을 세어 놓고 목록에 본류 파일이
**없다는 것을 안 봤다.** 「있는 것을 센 것」과 「필요한 자리에 있는지 본 것」은
다르다.

### 그래서 만드는 것 — ★capture 는 끈 채로

**샷 scope 를 실제 루프에 새로 넣는다** — `still_recipe_service.py:2272` 의
샷마다 `generation_context(…, still_id=…, scene_index=…, shot_index=…,
capture=False)` 를 연다. 이미 있는 그릇을 쓰되 **여는 자리는 새로 만든다.**

> ★**scope 를 여는 것은 그 자체로 capture 를 켜는 것이다** (2026-08-23 재리뷰).
> `sink.py` 문서화 문자열: 「★default-capture-off(중복 0 의 핵심): scope 가
> 열리지 않은 호출(=최종물 경로 등)은 ctx 가 None → 저장하지 않는다. 최종물은
> 기존 `_register_image_assets` 가 만들므로 sink 가 또 만들면 중복이 된다.」
>
> 그냥 열면 롤·수리·변환 성공분마다 `is_intermediate` 자산 행과 spool 파일이
> **새로 생긴다**(`queue.py:91-179`). **이 일은 기록 체계화지 자산 늘리기가
> 아니다.** 그래서 `capture=False` 를 새로 둔다 — 신원과 trace 만 얻고 자산
> 동작은 바이트 동일.
>
> (롤을 자산으로 등록하는 것은 그 자체로 값이 있을 수 있지만 **별도 판**이다.
> 그것에 기대던 ⑧-③ 도 함께 좁혔다.)

부수 이득: 지금 그 경로가 못 남기던 `still_id`·`scene_index`·`shot_index` 가
DB 호출 기록에도 함께 실린다(`ambient_call_meta` 가 이 scope 를 읽는다).

**여는 자리**: 완료 완전-skip / no-record skip(`2283-2305`)과
`jit_snapshot_before`(`2322-2323`)가 끝난 **직후**. 안 도는 샷에 trace 를 열
이유가 없고, 첫 유료 가능 호출(confined 판정 `2598` · bg plate 판정 `2697`)은
scope 안이어야 한다.

### ★`stage` 는 지금 쓰이는 스텝 이름 그대로 둔다

`resolve_step_name`(`image_tracer.py:316`)은 `ambient["stage"]` 를 **1순위**로
쓴다. 그래서 scope 의 `stage` 에 새 이름(`"still_recipe"` 같은)을 넣으면 이
경로의 `llm_call_log.step_name` 이 **통째로 바뀐다.**

실측: 그 이름(`scene_image_pipeline`)으로 쌓인 것이 **3,707행**이다. 바꾸면
신·구 대조가 끊긴다 — 이 일의 목적이 「비교할 수 있게」인데 정반대다.

→ `stage="scene_image_pipeline"` 로 연다.
**샷 신원은 `still_id` 가, 세부 단계는 `op:` 태그가 말한다.** `stage` 로
말하지 않는다.

부수 이득: 지금 `step_name` 이 **빈 327행**(⑪-3)도 이 값으로 채워진다 —
같은 이름이라 셈이 어긋나지 않는다.

**중첩 규칙**: 샷 trace 가 열려 있으면 그것이 부모다. 없으면 스텝 trace 가
부모다. 둘 다 없으면(스텝 밖 호출) 지금처럼 홀로 선 trace 를 만든다.

**전파 그릇**: trace 핸들은 `ContextVar` 에 담는다(스텝 컨텍스트가 쓰는
thread-local 이 아니라). `generation_context` 와 같은 그릇이라야 중첩·복원이
어긋나지 않는다.

### ★worker thread 전파를 반드시 함께 배선한다

`multiroll_select.py:1372-1385` 는 병렬 롤 worker 에 **budget 과
generation_context 둘만** 명시 전파한다:

```python
bound = bind_current_budget(bind_current_generation_context(gen_fn))
```

trace 핸들은 **세 번째 ContextVar** 다. 이 줄에 함께 싣지 않으면 worker 의
호출은 부모가 `None` 이라 **다시 낱개 trace 가 된다** — 계층이 병렬 구간에서만
조용히 무너진다. 같은 자리에 `bind_current_trace` 를 겹친다.

## ⑤ 이름 — 한 줄로 읽히게

| 층 | 이름 | 보기 |
|---|---|---|
| trace(스텝) | `step:{step_id}` | `step:scene_detail` |
| trace(샷) | `still:{still_key} · {stage}` | `still:S12sh3 · still_recipe` |
| span(직접 호출) | `{op} · {model}` | `judge · x-ai/grok-4.6` |
| span(litellm) | **우리가 못 정한다** | `gpt-5.6-sol_chat.completion_1755…` |

litellm 이 만드는 span 의 이름은 `f"{model}_{obj_type}_{created}"` 로 못박혀
있다(`payload_builders.build_span_payload`). 그래서 **span 이름에 기대지
않는다** — 무엇인지는 태그(`op:` `kind:`)와 metadata 로 가른다. trace 이름은
전부 우리가 정하므로 훑기·감사는 trace 층에서 이루어진다.

## ⑥ 태그 — 접두사로 축을 못박는다

지금 81종이 한 자루에 섞인 자리를 6축으로 가른다.

| 접두사 | 보기 | 근거 |
|---|---|---|
| `step:` | `step:scene_image_pipeline` | 프롬프트 감사의 묶음 단위 |
| `op:` | `op:still_recipe_judge` | DB `operation_type` 에 이미 있는 진짜 단계 |
| `kind:` | `kind:gen` `kind:judge` `kind:fix` `kind:transform` `kind:analysis` | 성격별 훑기 |
| `model:` | `model:x-ai/grok-4.6` | |
| `provider:` | `provider:openrouter` | |
| `status:` | `status:retry` `status:moderation_blocked` `status:error` | |

**태그에서 뺄 것**

- 프로젝트 이름 · 에피소드 제목 — 한글이고 카디널리티가 커진다 → metadata 로
- 맨 이름(`gemini` `openai` `gpt-image-2`) — 접두사를 붙여 축을 밝힌다

**모델이 굳은 이름은 쪼갠다**

```
still_recipe_judge_geminipro   →   op:still_recipe_judge + model:gemini-3.1-pro
still_recipe_fix_rejudge_qwenvlm → op:still_recipe_fix_rejudge + model:qwen3.8-max
```

지금은 판정 모델을 바꾸면 태그가 바뀌어 **이력이 끊긴다.** 쪼개면 `op:` 로
이어 보고 `model:` 로 갈라 볼 수 있다.

**span 에는 접두사 없는 태그가 하나 붙는다 — 못 막는다**

`opik_payload_builder/extractors.py` 의 `extract_tags` 가 우리 태그 뒤에
제공자 이름을 맨 이름으로 덧붙인다:

```python
tags = list(opik_metadata.get("tags", []))
if custom_llm_provider:
    tags.append(custom_llm_provider)     # 'gemini' · 'openai' … 접두사 없음
```

우리가 `current_span_data` 를 주면 trace 는 안 만들어지므로 이 맨 이름은
**span 에만** 붙는다. trace 층은 깨끗하다. 관문 4 를 trace 로 한정하는
이유다.

## ⑦ metadata — 한 벌로 고정

trace·span 양쪽에 같은 칸을 싣는다.

```
run_tag · project_id · episode_id · project_name · episode_title
step · op · kind · attempt
scene_index · shot_index · still_id · entity_id
prompt_version
```

trace 를 우리가 만들므로 litellm 의 `user_api_key_*` 등 60여 칸은
**span 에만** 남고 trace 는 깨끗해진다.

`prompt_version` 은 부를 수 있는 자리에서만 싣는다 — 없는 자리를 억지로
배선하지 않는다(빠뜨린 자리가 조용히 도는 것보다 빈 칸이 낫다).

## ⑧ 샷 영향 추적 — uid 로 세 섬을 잇는다

「최종 샷 한 장에 무엇이 영향을 미쳤나」를 물을 수 있게 만든다(목표 4).

### 지금 — 재료는 있는데 사슬이 끊겨 있다

`scene/recipe/records.json` 은 이미 풍부하다. 완주 판 하나를 세어 보니
**샷 192개 전수(192/192)** 에 이것이 다 있다:

`prompt` · `roll_prompts` · `refs` · `ref_mode` · `share_plan` ·
`selected` · `ranking` · `verdicts` · `totals` · `critique` ·
`input_fingerprint`

그리고 있는 샷에만: `judge_flip`(좌우 바꿔 2회, 50) · `readings`(VLM 관찰,
128) · `fix_*`(수리·건너뜀 사유, 136~174) · `roll_refs`(58) ·
`bgfirst`(51) · `lane_policy`(22) · `confined_fp`(7).
곁 기록으로 `::bgfirst_bg`(배경 자산 id + 그 입력들) · `::cine`(변환 모델·
팩·`source_sha256`) · `::signage`(간판 글자).

**끊김 A — 참조의 55%가 「자산 불명」이다**

`records.json` 의 `refs` 449건 중 **248건(55%)** 이 `<bytes:870689>` 꼴로,
크기만 남고 어느 자산인지 모른다. 그 대부분이 `CHARACTER REFERENCE`(222).
즉 **「이 샷에 어느 인물 참조가 들어갔나」를 못 묻는다.**

원인은 자료가 없어서가 아니다. `multiroll_select.py:1351`·`1526` 이
`labeled_refs` 를 적을 때, 원소가 `Path` 면 경로를 쓰고 `bytes` 면 길이만
쓴다. **`labeled_refs` 가 `(라벨, 소스)` 두 칸뿐**이라 실을 자리가 없다.

★그런데 asset id 는 **이미 손에 있다**. `still_recipe_service.py:2869` 바로
아래에서 `scene_ref_asset_id_map.get(key)` 를 `_attach()` 에 넘긴다.

**끊김 B — 최종 자산의 직접 입력 계보가 1.2% 만 남는다**

`image_asset` 의 최종 씬 자산 **510개 중 `input_image_ids` 가 있는 것은 6개
(1.2%)**. 변환(cine)이 적용되면 최종 bytes 의 직접 입력이 `_sel.png`
**파일**이고 등록 자산이 아니라, `unresolved_attached_refs` 로 빠진다:

```json
{"actual_attached_refs": [],
 "unresolved_attached_refs": [
   {"role": "cine_source_sel", "file": "S55sh8_sel.png", "sha256": "7c866c…"}]}
```

### 고칠 것 — uid 축 + 끊김 둘

**① `shot_run_uid`(UUIDv7) 하나를 세 곳에 같이 적는다**

```
shot_run_uid
   ├─ Opik          샷 trace 의 id (그리고 metadata.shot_run_uid)
   ├─ records.json  S12sh3.shot_run_uid
   └─ image_asset   pipeline_metadata_json.shot_run_uid
```

UUIDv7 이어야 한다(⑫ 실측). 만드는 자리는 샷 trace 를 여는 곳
(④ 에서 새로 넣는 샷 scope) 하나뿐이다 — 거기서 만들어 셋에 나눠 준다.

### ★기록하는 자리가 하나면 안 된다 — 나가는 문이 여럿이다

`multiroll_select` 의 샷 함수는 **여러 곳에서 빠져나간다**: 지문이 맞아 전부
건너뛰는 완결 반환(~1305, 「완결 — 전부 skip」), 그 뒤 두 곳(1313·1336),
critique 를 끄고 나가는 반환(1546), 그리고 정상 끝(1567).

uid 를 한 곳(문안 옆)에만 적고 영향 요약을 마지막 `_persist` 뒤에만 실으면:

- **재개/완결 건너뛰기** — 새 trace 는 열렸는데 record 에는 uid 가 안 적혀,
  Opik·`image_asset` 의 uid 와 `records.json` 의 uid 가 **갈린다**
- **critique 끔** — trace output(영향 요약)이 **빈 채로** 닫힌다

→ **모든 반환이 지나는 마무리 한 곳**을 만들고 거기서 적는다.

### ★산출을 재사용한 방문은 「만든 계보」가 아니다

지문이 맞아 아무것도 안 만든 방문에도 trace 는 열린다(그 방문이 있었다는
사실은 기록할 값이 있다). 그러나 그 uid 로 **`image_asset` 의 생성 계보를
덮으면 안 된다** — 그 자산을 만든 것은 이전 주행이다.

계약: 이번 방문이 **실제로 무언가를 만들었을 때만** `image_asset` 에 uid 를
쓴다. 안 만들었으면 `records.json` 과 Opik 에만 남기고 자산은 건드리지 않는다.

### ★★기록 신원이 「돈이 나갔다」로 읽히면 안 된다

`_jit_tag_snapshot`(`still_recipe_service.py:167-224`)은 샷 전후의 record
묶음을 직렬화해 비교하고, **달라지면 「지출」로 읽어** 재생성 제동을 건다
(`:2322-2323` · `:4382-4395`). 지금 걸러 내는 것은 `reused` 와 `*_this_run`
뿐이다.

`shot_run_uid` 는 **방문마다 바뀐다.** 지문이 맞아 아무것도 안 만든 재사용
방문도 스냅숏이 달라져 **완주 판의 래치를 거짓으로 올린다.** 지문 함수는 안
건드려도 **재개 계약이 깨진다.**

→ `shot_run_uid` · `shot_run_produced` 를 `_transient` 에 넣는다.
★거르기가 **너무 넓어도** 안 된다 — 일한 방문을 못 가려내는 쪽이 더 나쁘다.
선정·판정·수리 기록은 그대로 지출로 남아야 한다.

**그리고 「저자」와 「지출」은 다른 신호다.**

| 신호 | 뜻 | 쓰는 곳 |
|---|---|---|
| `shot_run_produced` | 이번 방문이 **최종 산출의 저자**인가 | `image_asset` 계보 |
| `shot_run_spend_attempt_count` | 이번 방문이 **유료 호출을 할 수 있는 구간에 들어갔나**(방문당 +1) ★「했나」가 아니다 | JIT 신호 |

갈리는 자리가 실제로 있다: 소급 critique 갈래
(`multiroll_select.py:1315-1336`)는 **유료**인데, 수리가 없었거나 졌으면 최종
`_sel` 의 저자는 **이전 방문**이다. 하나로 쓰면 둘 중 하나가 틀린다 — `True`
면 계보를 가로채고, `False` 면 지출을 놓친다.

`shot_run_spend_attempt_count` 는 `_transient` 에 **안 넣는다.** 그것이 유료 구간 진입을 보수적으로 세는 신호다.

★**이름이 곧 한계다** (2026-08-24 6차 리뷰). 표식은 **콜백을 부르기 직전**에
오르므로 콜백이 **바깥으로 안 나가고 끝나는 경로**도 센다 — 후보가 하나뿐인
판정(`multiroll_gemini.py:1276-1278`, 코드가 스스로 「**유료 호출 없이**」라고
적어 놨다)과 참조 결손 로컬 검증 실패가 그렇다.

그래서 **과금 횟수가 아니다.** ⑭ 의 상한 배선에서도 이것을 정확한 지출 수로
그대로 쓰면 안 된다 — 그때는 provider seam 이나 Opik 호출 기록으로 따로 센다.

★치우침의 방향은 안전한 쪽이다: 과다 계상은 래치 쪽으로 기울어 사람 확인을
부를 뿐 돈이 새지 않는다. 순수 재사용 방문은 표식 전에 반환하므로 안 센다.
덤으로 지금도 있는 구멍 하나가 닫힌다 — `sel` 만 없어져 판정만 다시 도는
복구 갈래(`1295-1300`)에서 판정 결과가 전과 같으면 record 가 안 움직여
지출을 놓쳤다.

### ★★돈을 쓰기 **전에** 적는다

계수를 반환 직전에 올리면 **성공한 지출만** 잡힌다.

과금 가능 호출(`multiroll_select.py:802` critique · `:1369-1403` 생성 ·
`:1433-1472` 판정)은 **예외가 날 수 있고**, 그 예외는
`still_recipe_service.py:4372-4380` 으로 빠져 `continue` 하므로 **JIT 전후
비교(`:4382-4395`) 자체를 건너뛴다.** 그 지출은 상한에 안 잡히고 다음 resume 이
같은 돈을 다시 쓴다.

→ **첫 유료 호출 직전**에 방문당 한 번 올리고 **곧바로 `_persist()`** 한다.

이 저장소가 이미 쓰는 규율이다 — 「선정 결정을 sel 물질화 **이전에** durable
persist」(`multiroll_select.py:1533-1535`). 종료 시점 stamp 나 `finally` 만으로는
예외·프로세스 사망 창을 못 닫는다.

### ★이 계수가 **안 하는 일** — 넘겨짚지 말 것

실패한 지출을 **재생성 상한에 넣지는 못한다.**

`jit_regen_count` 는 함수 호출마다 `0` 으로 초기화되고(`still_recipe_service.py:2240-2242`)
성공 경로에서만 오른다(`:4451`·`:4458`). 유료 호출이 실패하면 `except` 가
`continue` 해(`:4372-4380`) 전후 비교(`:4393-4395`)와 증가를 **통째로 건너뛴다.**
다음 resume 은 저장된 계수를 새 기준값으로 잡아 이전 실패의 변화량이 기준에
흡수된다.

★**지금도 그렇다** — 이 계수는 감사 흔적을 새로 만들 뿐 그 구멍을 닫지도
넓히지도 않는다.

★**조용히 지나가지는 않는다**: 실패 샷은 `jit_failed_tags` 로 모여
`StillJitVerifyIncomplete` 를 올리고(`:4767`) **스텝을 완료로 못 닫게** 한다.
다만 재생성 상한을 소비하지 않을 뿐이다.

상한에 넣는 것은 **래치가 언제 터지는지를 바꾸는 일**이라 별도 판이다(⑭).

### ★trace 가 없는 방문은 옛 uid 를 지운다

`shot_run_produced` 는 **언제나 현재 값**으로 덮고, `shot_run_uid` 는 이번
방문의 trace 가 없으면(v2 OFF·scope 누락) **지운다.**

안 그러면 record 에 남아 있던 옛 `uid` + `produced=True` 를 자산 metadata 가
읽어 **새 산출을 옛 trace 의 저자로** 기록한다.

**② 끊김 A** — `records.json` 의 `refs`·`roll_refs` 에 `asset_id` 칸을 보탠다.
★기존 `path` 칸은 **그대로 둔다** — 지금 그것을 읽는 것들이 있고, 빼면 그
자리가 조용히 빈다.

*접합은 라벨로도 순서로도 못 한다.* 라벨이 두 곳에서 다르고
(`_attach` 는 이름 `김선영`, `build_still_refs` 는 서식 문안
`CHARACTER REFERENCE — 김선영: …`), `handled_by` 갈래는 char_refs 의
**부분집합만** 싣는다. **참조 원본의 신원**(경로 또는 바이트 sha256)으로 잇는다
— 두 자리가 같은 객체를 주고받으므로 어긋날 수 없다.

*표준 한 벌만 덮으면 특수 갈래가 빈다.* `_run_branch`
(`still_recipe_service.py:3422`)는 **임의의 `branch_refs`** 를 받고,
confined(3549~)·bgfirst(3993~)·4택1(4120~)은 저마다 다른 `roll_refs` 를 만든다.
`build_still_refs` 가 만든 목록 하나만 보고 병렬 신원을 만들면 그 갈래들은
**위치가 어긋난 `asset_id`** 를 달거나 `roll_refs` 가 전부 비게 된다.
→ 신원 지도(`원본 신원 → asset_id`)를 **한 벌 만들어 두고**, 실제로 나가는
목록마다(그것이 `branch_refs` 든 `roll_refs[label]` 이든) **그 자리에서 조회**한다.
목록을 미리 정해 놓지 않는 것이 핵심이다. 조회하는 자리는 **`_run_branch`
하나** — 호출 8곳·`roll_refs` 4곳에 배선하면 하나만 빠뜨려도 그 갈래가 조용히
빈다.

*★신원 지도는 최종 계보(`_attach`)와 **다른 목록**이다* (2026-08-23 재리뷰).
`_attach` 로 지도를 채우면 **늦거나 빈다**:

| 자리 | 왜 |
|---|---|
| `prev_still`(`3037`) | `src` 도 `file_path` 도 **안 넘긴다** |
| A/B conti(`4144-4148`) | **승자일 때만** 부착. `_run_branch` 는 `4120` 에서 **먼저** 돈다 |
| bgfirst seed 계열 | 같은 「승자 확정 후 부착」 부류 |

`_attach` 는 **최종 계보(승자만·거짓 edge 금지)** 를 위한 것이고, 우리에게
필요한 것은 **「발송 가능한 참조 전부」의 신원**이다. 겹쳐 쓰면 loser 가 최종
계보에 붙거나 `roll_refs` 가 빈다.

→ **분리한다.** 참조가 정해지는 자리마다 승패와 무관하게 **미리** 지도에
등록하고, `_attach` 는 손대지 않는다. 같은 신원에 다른 자산 id 가 오면
마지막 값으로 덮지 말고 **`None` 으로 못박는다**(fail-closed) — 거짓 계보보다
빈 칸이 낫다.

**③ 끊김 B — 좁힘** — 변환의 직접 입력이 **원본 롤인지 수리본인지 밝혀
적는다**(`stage` · `roll_label` · `unresolved_reason`).

> ★**자산 id 로는 못 잇는다** (2026-08-23 재리뷰). 롤도 수리 산출도
> `image_asset` 이 **아니다** — 파일로만 존재한다. 위에서 `capture=False` 로
> 정한 이상 앞으로도 자산이 되지 않는다. **없는 것을 지어내지 않는다.**
>
> 대신 「무엇이 직접 입력이었나」를 정확히 적는다. 지금은
> `{"role":"cine_source_sel","file":"S55sh8_sel.png","sha256":"7c866c…"}` 뿐이라
> 읽는 사람이 **그 `_sel` 이 원본인지 수리본인지 모른다.** 그 한 칸이
> 「최종 샷에 무엇이 영향을 미쳤나」의 핵심이다.
>
> (자산으로 잇는 것은 롤을 등록할지 정한 뒤의 별도 판이다.)

★**「선정 롤」로 못박으면 거짓 계보가 된다.**
`_critique_and_fix`(`multiroll_select.py:800-815`, `991-1013`)는 원본이든
수리본이든 **승자를 언제나 canonical `_sel` 에 복사**한다. 그래서 cine 기록의
`source_file` 은 `cine_transform.py:164` 에서 **항상 `_sel.png`** 다 — 수리가
이겼는지 원본이 이겼는지 그 이름만으로는 못 가른다.

가르는 근거는 이미 있다: `fix_stage_won(record)`.

| 승자 | 직접 입력 | 이을 자산 |
|---|---|---|
| 원본 롤 | 그 롤 | 그 롤의 `image_asset.id` |
| 수리본 | 수리 i2i 산출 | 수리 자산 id (있으면). 없으면 **잇지 않는다** |

없는 것을 지어내지 않는다 — 잘못 이으면 계보 선이 엉뚱한 자산을 가리키고,
그것은 과다 기재보다 나쁘다.

**④ 샷 trace 에 「영향 요약」을 싣는다**

- trace `input`: `refs`(라벨 + `asset_id`) · `prompt` · `ref_mode` ·
  `share_plan` · `input_fingerprint`
- trace `output`: `selected` · `ranking` · `totals` · `verdicts` 요약 ·
  `fix_applied`/`fix_skip_reason` · `cine_applied`
- ★프롬프트 전문·판정 전문은 **span 에 이미 있다.** trace 에는 **요약만**
  싣는다 — 같은 것을 두 벌 저장하면 어느 쪽이 진짜인지 갈린다

**⑤ 읽기 도구 `tools/shot_influence.py`**

`still_id` 나 `shot_run_uid` 하나를 받아 세 곳을 합쳐 한 화면으로 낸다.
갤러리를 기다리지 않고 바로 물을 수 있게 하는 것이 목적이다.

### 보는 화면은 다음 판

갤러리(`process.html`)에 「영향」 절을 붙이는 것은 **이번에 안 한다.**
참조의 55%가 자산 불명인 채로 카드를 하나 더 그리면 깨진 자료를 다시 그리는
것이다. 사슬을 이은 뒤에 별도 판으로 한다.

## ⑨ 시험 기록 격리 — 뿌리 F

**프로덕션 프로젝트에 시험이 쓰지 못하게 한다.** 막는 방식이 아니라
**가르는 방식**이다(집 안 주소를 막으면 자체 호스팅 Opik 이 죽는다 —
2026-08-20 에 실제로 겪은 자리다).

`backend/tests/conftest.py` 의 **맨 위 env 설정 자리**(지금
`ENVIRONMENT`·`DATABASE_URL`·`PROJECTS_DIR` 을 놓는 46~77줄)에 한 줄 보탠다:

```python
os.environ["OPIK_PROJECT_NAME"] = "theroad-scene-lab-test"
```

자리가 중요하다. conftest 는 app 을 **env 설정 뒤에** import 한다
(112줄 주석 「env 설정 후라야 안전」). 실측으로 확인했다:

- `settings` 는 이 env 를 `.env` 보다 **먼저** 읽는다
  (`OPIK_PROJECT_NAME=… python -c "settings.opik_project_name"` → env 값)
- `_init_opik`(`llm_client.py:259`)는 `os.environ["OPIK_PROJECT_NAME"]` 에
  `settings.opik_project_name` 을 **되쓴다** — 같은 값이라 어긋나지 않는다
- `ImageTracer.__init__` 도 `settings.opik_project_name` 을 읽는다 — 같은 값

성질:

- 시험 기록은 갈 곳이 생기고(디버깅에 쓸 수 있다), 감사 데이터는 안 섞인다
- 끄는 값은 두지 않는다 — 「시험이 프로덕션에 쓸 이유」가 없다
- **막지 않는다.** 집 안 주소를 막으면 자체 호스팅 Opik 이 죽는다
  (2026-08-20 에 실제로 겪었다). 가르기만 한다
- 자체 점검을 conftest 에 둔다: `settings.opik_project_name` 이 시험 이름이
  아니면 **시험을 세운다**(fail-fast). 값이 조용히 프로덕션으로 돌아가는 것이
  이 조치가 막으려는 바로 그 실패다
- 검증: 시험 한 바퀴 뒤 프로덕션 프로젝트의 trace 수가 **안 늘어야 한다**

**이미 섞인 것은 지우지 않는다.** 감사 도구 쪽에서 걸러 낸다(⑩). 지우는 것은
되돌릴 수 없고, 이 저장소는 자료를 날린 이력이 있다.

★거르는 그물은 **완전하지 않다**(①-F). 가짜 신원을 달고 나간 시험 기록은
통과한다. 감사 리포트에 「걸러진 수」와 함께 **「이 그물이 못 잡는 모양」**을
같이 적어, 남은 것이 있을 수 있다는 것을 읽는 사람이 알게 한다.

## ⑩ 감사 도구 이관 — 공짜가 아니다

`tools/opik_prompt_audit/audit/fetch.py` 는 지금 **trace 의 `input` 과
`metadata.trace_name`** 을 읽는다. 고치면 본문이 **span 으로 옮겨 간다.**

- `/v1/private/traces` → `/v1/private/spans` 로 갈아탄다
- 스텝 이름은 `metadata.trace_name` 이 아니라 **`step:` 태그**에서 읽는다
- 시험 기록은 프로젝트가 갈리므로 앞으로는 자동으로 빠진다. 지난 것은
  거르되 **그물이 완전하지 않음을 리포트에 밝힌다**(⑨)

**이득**: span 에는 `usage`(토큰 수)와 `total_cost` 가 실려 있다 — 지금 trace
에는 없어서 못 보던 값이 **손 닿는 곳에 온다.**

★**이번 판에서 토큰으로 말하지는 않는다** (2026-08-23 3차 리뷰). 집계는
`metrics.per_step`(`metrics.py:22-67`)이 `messages_of` 로 **글자 수만** 세고
`report.py:47-68` 도 문자량만 낸다. 그 배선을 안 고치면 「토큰으로 말한다」는
**산출물 설명이 실제보다 강한 것**이고, 그것도 결과 오독이다.
토큰·비용 집계는 `metrics`·`report` 를 함께 고치는 **별도 판**이다.

### ★span 과 trace 를 그냥 합치면 지난 자료를 두 번 센다

`ImageTracer.log`(`image_tracer.py:176-198`)는 호출 하나마다 **trace 를 만들고
그 밑에 span 도 만든다.** litellm 도 `trace_id` 가 없으면 trace 를 만들고
span 은 **항상** 만든다(`api.py:81-99`). 즉 **v1 호출 한 건이 trace 1 + span 1
로 남아 있다.**

그래서 「span 도 읽고 trace 도 읽어 합친다」로 짜면 **과거분이 거의 전부 두 번
세어진다** — 프롬프트 크기·건수 통계가 통째로 어긋난다. 이 도구의 존재 이유가
「팩이 얼마나 커졌나」인데 그 숫자가 두 배가 되면 쓸모가 없다.

**가르는 법**: 세는 단위를 **호출 하나**로 잡는다.

- span 은 `trace_id` 를 갖는다. 같은 `trace_id` 에 span 이 **하나뿐이고**
  그 trace 가 v1 모양(이름이 `{step}/{model}` 꼴이거나 `chat.completion`)이면
  **trace 를 버리고 span 만 센다**
- v2 trace(이름이 `step:`·`still:`·`stage:` 로 시작)는 **작업 단위**라
  호출이 아니다 — 세는 대상에서 뺀다. 그 밑 span 들이 호출이다
- 결론: **세는 것은 언제나 span 이다.** trace 는 묶음·이름을 얻는 데만 쓴다

지난 v1 자료도 span 이 있으므로 이 규칙 하나로 신·구가 같은 잣대에 선다.

### ★소비처를 갈아 끼우지 않으면 죽은 코드다

지금 자료를 부르는 곳은 셋이다 — `main.py:44` · `cache_baseline.py:200` ·
`metrics.py:33`(각 행에 `step_of`). **`cache_baseline` 은 스스로 `fetch_traces`
를 부른다** — `step_of` 만 고쳐서는 안 따라온다.

그리고 기존 계약(`base_url` · `workspace` · **`project_name`**(`project_id`
아님) · `since` · `until`)을 **그대로 지켜야 한다.** 기간 인자를 빠뜨리면
전 기간을 센다.

### ★스텝 이름은 부모를 볼 수 있어야 한다

`step_of` 의 마지막 갈래가 `row["name"]` 인데 litellm span 의 이름은
`{model}_{obj}_{created}` 라 **절대 비지 않는다.** 그래서 「자기 태그 → 부모」
순서로 짜면 부모에 **영영 도달하지 못한다.** `step:` 태그만 따로 뽑아
① 자기 태그 → ② 부모 trace → ③ 자기 이름 순으로 본다.

## ⑪ 곁다리 수리

| # | 것 | 판정 |
|---|---|---|
| 1 | `i2i_edit/m` | 프로덕션 결함이 **아니다** — 시험 기록이었다. ⑨ 로 해소 |
| 2 | i2i 경로가 DB 에 0행 | `record_provider_call` 은 `to_db=True` 가 기본인데 `llm_call_log` 에 i2i 가 한 행도 없다. **원인 규명 후 수리** |
| 3 | DB `step_name` 빈 행 327건 | ④ 의 샷 scope 가 `stage="scene_image_pipeline"` 로 열리면 `resolve_step_name` 이 그 값을 써 **자연히 메워진다**. 실측으로 확인 후 남은 것만 손본다 |
| 4 | `trace_name` 죽은 키 | ⑤ 로 대체된 뒤 제거 |

## ⑫ 안전장치

- **설정 하나로 켠다**: `OPIK_TRACE_V2_ENABLED`, **기본 OFF**.
  꺼져 있으면 지금 동작 그대로다(바이트 동일 대조 가능)
- **기록 실패는 전부 non-fatal** — 지금 규약을 그대로 지킨다.
  trace 를 못 열면 지금처럼 홀로 선 trace 로 떨어진다(fail-soft)
- **지문에 안 접힌다 — 확인함**:
  `compute_input_fingerprint`(`multiroll_select.py:671`)는
  `prompt` + 참조 바이트 + `roll_count` + `critique_enabled` + `extra` 로만
  해시한다. `extra` 항목(`still_recipe_service.py:802~912`)은 전부
  모델·팩·정책 서명이며 **기록 관련 칸이 하나도 없다.**
  → 완주 판 넷의 `JIT_REGEN_LATCH.json` 을 건드릴 이유가 없다
- **worker thread**: trace 핸들을 `ContextVar` 로 두고
  `bind_current_generation_context` 와 **같은 줄에서** 전파한다
  (`multiroll_select.py:1372-1385`). 전파가 안 된 자리는 부모 없이 떨어질 뿐,
  죽지 않는다 — 다만 계층이 병렬 구간에서만 조용히 무너지므로 ④ 의 배선을
  빠뜨리면 안 된다
- **★진단·확인 절차도 프로덕션에 쓰면 안 된다**: `record_provider_call` 은
  `to_db=True` 가 기본이라 부르는 순간 **프로덕션 `llm_call_log` 와 Opik 에
  진짜 행이 남는다.** 원인을 재려고 손으로 한 번 부르는 것도 오염이다 —
  이 문서가 고치려는 바로 그 문제(⑨)를 진단이 반복하게 된다.
  확인은 **시험 DB + 시험 Opik 프로젝트**에서만 한다

### 순서 뒤집힘 위험 — 실측으로 닫았다

부모 trace 는 우리 `opik.Opik` SDK 가, 자식 span 은 litellm 의 `OpikLogger`
(자체 httpx 배치 전송)가 보낸다. 둘 다 배치라 **도착 순서가 뒤집힐 수 있다** —
span 이 먼저 닿으면 부모 trace 행이 아직 없다.

2026-08-23 KST 에 실물로 쟀다(`theroad-probe-uid` 프로젝트, 모델 호출 0건):

| 물음 | 결과 |
|---|---|
| 우리가 id 를 정할 수 있나 | **UUIDv7 만 된다.** uuid4 → `400 "Trace id must be a version 7 UUID"` / uuid7 → `201` |
| 정상 순서 결합 | 붙는다 (`span_count=3`) |
| **뒤집힌 순서** | **붙는다** — 고아 span 2개를 먼저 보내고 부모를 나중에 만들어도 `span_count=2` |
| metadata 로 되찾기 | `metadata.our_uid` 로 검색해 정확히 1건 |
| span 의 `usage` | 실린다(토큰 수) |

→ **강제 flush 가 필요 없다.** 다만 id 는 반드시 UUIDv7 로 만들어야 한다
(litellm 의 `utils.create_uuid7` 과 같은 방식).

## ⑬ 검증 관문

새 주행 첫 10~15샷에서 잰다. 앞의 관문이 깨지면 뒤는 보지 않는다.

| 관문 | 무엇 | 통과 기준 |
|---|---|---|
| 0 | 기록이 있나 | 새 trace 가 하나라도 생긴다 |
| 1 | `chat.completion` 이 없나 | 새 주행 구간 trace 이름에 `chat.completion` **0건** |
| 2 | 주행이 묶이나 | 재개를 1회 끼워도 thread_id 가 **1개** |
| 3 | 계층이 서나 | 샷 trace 하나에 span 이 여럿(1:1 이 아니다) |
| 4 | 축이 갈리나 | **trace** 태그가 전부 `접두사:` 꼴, 접두사 없는 것 0건 (★span 은 예외 — 아래) |
| 5 | 샷 계보 | `still:` trace 하나로 그 샷의 호출이 다 보인다 |
| 6 | 시험 격리 | 시험 한 바퀴 뒤 프로덕션 프로젝트 trace 수 **증가 0** |
| 7 | 감사 도구 | span 기반 리포트가 이전 리포트와 스텝 이름이 맞는다 |
| 8 | uid 가 셋에 다 있나 | 한 샷의 `shot_run_uid` 가 Opik·`records.json`·`image_asset` **셋 다**에서 같은 값으로 나온다 |
| 9 | 참조가 자산으로 이어지나 | `records.json` 의 `refs` 에서 `asset_id` 없는 항목이 **인물·소품 참조에는 0건** (파일 참조는 원래 경로만 있어도 된다) |
| 10 | 변환 입력 밝힘 | `cine_source_sel` 에 `stage`(roll·fix)·`unresolved_reason` 이 채워지고 **거짓 `asset_id` 는 없다** |
| 11 | 읽기 도구 | `tools/shot_influence.py` 가 **`still_id` 로도 `shot_run_uid` 로도** 세 자료원을 합쳐 낸다 |
| 12 | 병렬 구간 계층 | 병렬 롤이 도는 샷에서도 span 이 부모 밑에 있다(worker 전파 확인) |
| 13 | 재개 uid | 완결 건너뛰기로 재개한 샷의 `records.json` uid 와 Opik uid 가 같고, `image_asset` 계보는 **안 덮인다** |
| 14 | 감사 셈 | 같은 v1 호출이 두 번 세어지지 않는다(신·구 총건수 대조) |
| 15 | 샷 신원 | still-recipe trace 에 `still_id` 가 실린다(지금 529/529 가 없음) |
| 16 | 자산 불변 | 새 주행에 `stage='still_recipe'` + `is_intermediate=true` 행이 **0건**(capture=False 확인) |
| 17 | 래치 불발동 | uid 만 바뀐 재사용 방문이 JIT 스냅숏을 안 바꾼다 — 완주 판 래치가 거짓 발동 안 한다 |

### 단계 — 무엇이 함께 나가야 하나

| 단계 | 무엇 | 설정 | 함께 나가야 하는 이유 |
|---|---|---|---|
| 1 | 시험 격리(⑨) | 없음 | 시험 전용이라 프로덕션에 안 닿는다. 바로 나간다 |
| 2 | 계층·이름·태그·metadata (③~⑦) | `OPIK_TRACE_V2_ENABLED` | **한 덩어리다.** 반만 하면 이름과 태그가 어긋나 지금보다 나빠진다 |
| 3 | uid 사슬·영향 요약·읽기 도구(⑧) | 같은 설정 | 2 의 샷 trace 위에 얹힌다 |
| 4 | 감사 도구 이관(⑩) | — | 2 가 나간 뒤라야 span 에 읽을 자료가 있다 |
| 5 | 곁다리 수리(⑪) | — | 앞과 안 얽힌다. 아무 때나 |

관문은 단계별로 본다: 1 → 관문 6 · 2 → 관문 0~5, 12, 15, 16 ·
3 → 관문 8~11, 13, 17 · 4 → 관문 7, 14.

★단계 2 에 **샷 경계를 새로 넣는 일**(④)이 들어간다. 처음에는 「이미 있으니
겹치기만 하면 된다」로 봤는데 프로덕션 본류에 없었다 — 그만큼 일이 늘었고,
worker 전파(관문 12)까지 같이 나가야 계층이 반쪽이 되지 않는다.

## ⑭ 범위 밖 (이번에 안 한다)

- **`PromptTracer`(JSONL) 통합** — 파일로 쓰는 별개 채널이라 Opik 체계와
  겹치지 않는다. 접는 것은 따로 판단할 일이다
- **DB `llm_call_log` 와 Opik 의 합치** — 뿌리 E 는 크고, 손대면 돈·기록
  양쪽에 닿는다. 이번엔 **드러내기만** 한다(문서 ①-E)
- **이미 쌓인 약 2,600건 정리** — 지우지 않는다(⑨)
- **★실패한 지출을 재생성 상한에 넣기** — 기전과 근거는 ⑧ 의 「이 계수가 안
  하는 일」에 적었다. 래치 발동 조건을 바꾸는 일이라 실측 근거를 갖추고 따로
  한다. `shot_run_spend_attempt_count` 가 그때 쓸 durable 재료를 미리 남긴다 —
  ★다만 그것은 **시도 표식**이지 과금 횟수가 아니다. 정확한 지출 수가
  필요하면 provider seam 이나 Opik 호출 기록으로 따로 세야 한다
- **갤러리에 「영향」 절 붙이기** — 사슬을 이은 뒤 별도 판(⑧ 끝 절)
- **돈·실패 대시보드** — 목표가 아니다

## 관련

- 감사 도구: `tools/opik_prompt_audit/`
- 돈 가드: `backend/tests/netprobe.py` · `backend/tests/conftest.py`
- 지문: `backend/app/modules/pipeline/multiroll_select.py:671`
