# A4 — 검색 후보 라운드 불변 저장소 (구현 계획)

**상태**: ✅ **완료 — Codex `APPROVED` (2026-08-02, 8차 리뷰).**
커밋 8개(`1c85397e` journal → `e7d5ffb9`). 회귀 109 · 위반 주입 58 전부 잡힘 ·
전체 7991 passed / 70 failed 로 기준선과 실패 목록 diff 0.
**전제였던 "A4 완료 전 유료 호출 금지" 는 해제됐다 — 다만 유료 실행 자체는
사용자 직접 지시로만 한다.**

리뷰 8회 중 1~7차가 NEEDS_REVISION 이었고 지적 22건을 전부 재현·수용했다
(반박 0). 그 이력과 교훈은 아래 §3.5 에 라운드별로 남겼다.

## 1. 왜 — 실체는 "덮어쓰기"보다 크다

`outdoor_structure_form_reference_step.py:350` 이 그룹 디렉터리 안에
`cand_{n:02d}.png` 로 순번 고정 저장한다. 재실행이 같은 이름을 덮어쓴다
(344건 overwritten 실측). **그런데 더 큰 것은 계보다.**

`_upsert_ref_asset`(:159) 이 그룹당 한 row 를 찾아 `existing.file_path = rel_path`
로 **갱신**한다. 신규 UUID 는 row 가 없을 때만 만든다. DB 실측 = `structure_form_ref`
25건, 그룹당 중복 0(그룹당 한 행을 계속 덮어써 왔다는 뜻).

그리고 씨드 자산의 `input_image_ids` 가 그 asset_id 를 가리킨다(A3 배선).

> **재검색 한 번이 과거 씨드의 입력 간선을 소급해서 다른 이미지로 바꾼다.**
> 계보 기록이 조용히 거짓이 된다.

★초안의 "`latest` 포인터로 소비자 계약 유지"는 이 문제를 그대로 두는 안이었고
폐기했다.

## 2. 확정된 계약

### 2.1 저장 구조

```
<group>/rounds/index.json          순번 예약 + round manifest 경로만 소유
<group>/rounds/r001/manifest.json  ★그 라운드 상태의 유일한 SOT
<group>/rounds/r001/cand_NN.png
<group>/cand_NN.png                legacy flat — 이동·덮어쓰기 금지, read-only
```

- **journal 단일 SOT.** `index.json` 과 round manifest 가 같은 상태를 중복
  소유하지 않는다. 불일치 · journal 손상 · 중복 OPENED · round 디렉터리만 있고
  registry 없음 → **디렉터리 스캔 복원 없이 fail-closed.** legacy flat 만
  명시 adapter 예외.
- step CP 는 **최종 소비 projection** 일 뿐 journal 이 아니다.
  근거: `step_runner.py:1194` 가 force 에서 `clear_checkpoint()` 를 부르고
  바로 다음 줄에서 실행에 들어가며, `save_checkpoint` 는
  `_execute_and_finalize` 7단계 중 (6) 으로 `_execute`(2) 이후다. 즉 `_execute`
  안에서는 CP 가 durable 해지지 않는다.
- `latest` 포인터는 **이번 wave 에서 만들지 않는다.** 운영자 화면은 journal +
  최종 CP 로 만든다.

### 2.2 상태 머신

```
OPENED(round_id, preallocated_asset_id, input_fp, contract_version)
  → CANDIDATES / SELECTED   (staged — 이 구간에서만 같은 라운드 안 파일 교체 허용)
  → ASSET_BOUND             (미리 채번한 UUID 로 idempotent insert-or-verify)
  → FINALIZED               (이후 bytes·manifest·asset 불변)
  → step CP projection {round_id, asset_id, path, sha256, pack, policy}
ABANDONED                    (이후 쓰기 금지 — FINALIZED 와 동일한 불변 경계)
```

- **round_id 순번은 journal 의 기존 라운드 전부에서 max+1.** 열린 partial
  라운드도 이미 예약된 번호이므로 반드시 카운트한다. 디렉터리 스캔 금지.
- **asset UUID 를 OPENED 에서 미리 채번**해 journal 에 durable 하게 넣는다.
  없으면 crash resume 이 같은 라운드에 UUID 를 또 INSERT 한다.

### 2.3 resume / force 행렬

| 상황 | 동작 |
|---|---|
| explicit force | 기존 OPENED 를 ABANDONED 로 닫고 **반드시 새 라운드** |
| resume · input_fp/contract exact 일치 | OPENED/CANDIDATES/SELECTED/ASSET_BOUND 이어감 |
| resume · drift | 그 라운드 ABANDONED, 새 라운드 (번호는 소비된 채 남음) |
| **FINALIZED 인데 최종 CP 만 없음** | **검색·판정·다운로드·asset INSERT 0회**로 그 라운드를 CP projection 에 재사용 |

★마지막 행이 없으면 `_execute` 종료 후 `save_checkpoint` 직전 crash 가
**유료 전체 재실행**으로 바뀐다.

### 2.4 asset exact 검증

- `ImageAsset` 에 **sha 컬럼이 없다**(실측 — metadata 계열은
  `pipeline_metadata_json` 뿐). 따라서 `row.file_path` 를 `ImagePathType`
  resolve 한 **실제 파일 bytes SHA** 와 journal SHA 를 비교한다.
- 이미 row 가 있으면 project·episode·asset_type·entity_id·variant_type·path·sha
  를 **exact 검증** 후 재사용. 하나라도 다르면 **fail-closed**.
- **`annotate_generated_asset` 에 계약 검증을 맡기지 않는다.** 그 helper 는
  docstring 이 "non-fatal, no commit" 이고 `except Exception:` 으로 삼킨 뒤
  `logger.warning` 만 한다(실측 `annotate.py:25-54`). 계약 필드는 strict
  write/parse/verify 하고, role 주석만 helper 를 쓴다.

### 2.5 legacy

`round_id="legacy"` + `path_kind="legacy_flat"` + `round_contract_version=0`.
`r000` 은 새 계약의 정상 순번처럼 보이므로 쓰지 않는다. 기존 flat `cand_NN` 과
기존 25개 asset row 는 이동·덮어쓰기 없이 read-only adapter 로만 읽는다.

### 2.6 지문

`ROUND_STORAGE_CONTRACT_VERSION` 만 `_config_hash` 와 `_group_fingerprint`
양쪽에. **`round_id` 는 넣지 않는다** — 입력이 아니라 산출 identity라, 넣으면
재개가 항상 miss 된다.

### 2.7 동시성

모든 registry/manifest 갱신은 **atomic replace**. 현재 StepRunner 단일 claim 이
allocator 직렬화 전제임을 테스트와 주석에 명시한다.

## 3. 회귀 (최종 승인 조건)

1. force 2회가 서로 다른 bytes·path·UUID 를 보존
2. **1회차 씨드 입력 간선이 2회차 force 뒤에도 1회차 UUID·path·sha 를 가리킴**
   — 실제 DB + `ImagePathType` 왕복으로
3. 같은 그룹 form_ref row 2개를 만든 뒤 **CP asset_id 가 지정한 행만** 선택
4. opened round resume 이 UUID 를 중복 INSERT 하지 않음
5. input_fp drift 시 ABANDONED → 새 라운드
6. FINALIZED 이후 변경 시도 fail-closed
7. **FINALIZED 후 CP-save crash resume = 유료 0콜**
8. corrupt / multiple-open journal → fail-closed
9. annotate helper 실패가 contract metadata 성공으로 오인되지 않음

소비자 AST 전수(기존 `entity_id+variant_type` 으로 `.first()` 하는 곳을 CP
asset_id 직접 조회로 이행)는 병행하되 **그것만으로 끝내지 않는다** — 위 3번
통합 회귀가 함께 있어야 한다.

★관례: 모든 새 회귀는 **위반 주입으로 실효성 확인**.

## 3.5 구현 결과 (2026-08-02) — 계획에 없던 것

### ★A4 를 다 해도 유료 실행은 죽어 있었다

`_group_fingerprint` 가 **호출 즉시 NameError** 였다. `19fb2e65`(A6 마무리)가
`_config_hash` 의 물리 모델 블록을 그 함수로 옮기면서 `from app.core.config
import settings` 를 빠뜨렸고, 모듈 전역에도 그 이름이 없었다. 호출 지점은
`_execute` 의 그룹 `try` 안이라 결과는 **전 그룹 조용히 failed** 였다.
**그 함수를 태우는 테스트가 하나도 없어** 드러나지 않았다.

`_execute` 를 실제로 태워 `_run_group` **호출 0회 · failed 1** 을 재현한 뒤
봉합했다. 같은 결함군(함수 안 local import 누락)을 AST 로 전수하니 앱 전체에
2건이 더 있었다 — `search_grounded_ref` 의 `Tuple` 미import(`from __future__
import annotations` 덕에 런타임엔 안 터지지만 `get_type_hints` 에서 터진다),
`projects.py:548` 의 `logger` 미정의(llm_config 파싱 실패 시 NameError).
현재 앱 전체 **0건**. pyflakes 가 설치돼 있지 않아 이 결함군은 정적으로
잡히지 않는다.

### 1차 배선의 결함 — resume 이 이어가는 라운드의 후보를 덮어썼다

narrow 재시도만 이어붙이기로 막아 두었더니, **실패로 열린 채 남은 라운드를
다음 재개가 이어갈 때** 후보 번호가 1부터 다시 시작해 그 라운드의 이전 후보를
덮어썼다. `next_candidate_index()` 로 봉합.

★층이 다르다: **라운드 번호**는 journal 이 소유하는 전역 순번이라 디렉터리
스캔 복원이 위험하지만(이미 예약된 번호를 다시 내준다), **후보 번호**는 그
라운드 디렉터리 안에서만 의미가 있고 거기 있는 파일이 곧 사실이다.

### 추가로 고정한 계약

- 실패 entry 에도 `round_id`/`path_kind` 를 남긴다 — 남은 후보의 출처.
- CP projection 에 `ref_pack_version`·`target_policy_version` 을 **그 라운드
  시점의 값으로** 고정(그룹 공통 `data.target` 만으로는 팩이 바뀐 뒤 되짚을 수
  없다).
- **소스 잠금(AST)**: producer 는 기존 자산의 `file_path` 를 갱신하지 않고,
  UUID 를 제 손으로 만들지 않는다. 잠금 자체의 실효성(위반 표본 검출 + 정상
  표본 미검출)도 테스트에 내장했다.
- `SCHEMA_VERSION` 3→4 + `step_manifest` 핀 동기 (CP shape 이 바뀌었다).

### 검증

- 신규 회귀 **48건**(배선 34 · 실제 PG 5 · 소스 잠금 4 · journal +5).
  journal 유닛 10건은 `1c85397e` 에 이미 있던 것이라 여기 세지 않는다.
- **위반 주입 30건 전부 잡힘** — 계약을 하나씩 깨뜨려 지정 테스트가 실제로
  빨개지는지 확인. ★그중 2건은 처음에 **안 잡혔고**, 그래서 공허한 회귀를
  찾아 고칠 수 있었다.
- 실제 PG 로 계보 불변 확인. **옛 upsert 를 되살리는 위반 주입**으로 그 회귀가
  공허하지 않음도 확인(되살리면 실제로 경로가 소급 변조된다).

### Codex 코드 리뷰 반영 (NEEDS_REVISION — BLOCKING 2 · HIGH 2 · NARROW 2)

지적 6건을 전부 코드에서 재현한 뒤 수용했다. 반박한 것은 없다.

**BLOCKING-1 — journal FINALIZED 가 DB 자산보다 먼저 durable 했다.**
`_close_round` 가 자산을 `flush` 만 하고 FINALIZED 를 파일에 확정했는데, DB
commit 은 모든 그룹이 끝난 뒤 한 번이었다. 그래서 ①commit 전 crash ②**뒤
그룹 실패의 `db.rollback()`** 이 앞 그룹 row 까지 되돌려도 journal 은 완료로
남는다. 다음 재개는 자산 확인 없이 0콜 projection 을 내보내 **존재하지 않는
UUID 를 성공으로** 냈을 것이다.

수정 = **두 저장소의 쓰기 순서를 계약으로 고정**한다.

    ①SELECTED + 산출을 journal 에   (DB 를 만지기 전)
    ②자산 insert-or-verify → commit (DB durable · 그룹 단위)
    ③ASSET_BOUND                    (그 다음에야 "자산 있음")
    ④FINALIZED + 최종 산출

그리고 SELECTED/ASSET_BOUND 에서 재개하면 저장된 산출로 **유료 0콜** 바인딩
부터 이어간다(없으면 끊긴 지점마다 검색이 되풀이된다).

★**내 회귀가 이 창을 못 본 이유**가 더 중요하다 — 대역 DB 의 `rollback` 이
no-op 이었고, crash 재현이 `_execute` **반환 뒤**(=이미 최종 commit 이후)였다.
실제 PG 트랜잭션으로 옮겨 "그룹1 성공 → 그룹2 rollback" 과 commit 실패를
주입한다.

**BLOCKING-2 — CP·재투영이 journal 단일 SOT 를 우회했다.** `_reusable` 이
파일·sha·지문만 보고 통과하면 `resolve_round` 자체를 부르지 않아 journal
손상·결손과 자산 row 결손이 조용히 건너뛰어졌다. 재투영도 `rnd.result` 를
그대로 믿었다. 수정 = 라운드 계약 CP 는 journal FINALIZED result 와 **exact
대조**하고, 두 경로 모두 자산을 **verify-only**(INSERT 금지)로 검증한다.
legacy 만 예외 — journal 이 없다.

**HIGH-3 — 신규 INSERT 가 입력을 안 봤다.** bytes 검증이 기존 row 분기에만
있어, 선택 뒤 파일이 사라지거나 바뀌면 **잘못된 row 와 FINALIZED 산출이 최초
바인딩에서 확정**됐다. 공통 preflight 로 옮겼다.

**HIGH-4 — parser·전이가 손상을 너무 많이 받아들였다.** manifest 의
`round_id` 만 r999 로 바꾸자 `resolve_round` 가 디스크에 없는 r999 를 정상
반환했다(직접 재현). 그 상태는 **유료 `_run_group` 을 탄 뒤에야** 죽는다.
수정 = identity·shape·UUID·registry 경로·중복을 fail-closed 로 검증하고,
전이를 명시 그래프로 제한한다. `transition_round` 로 **빈 FINALIZED** 를
만드는 것도 막는다.

**NARROW 2건** — 라운드 번호 문자열 max 가 r1000 이후 뒤집힌다(직접 재현:
`max(["r999","r1000"]) == "r999"`) → 숫자 비교. 예외 경로가 `rec` 를 새 dict
로 갈아치워 라운드 표기를 잃던 것 → 보존.

★**공허하게 통과한 회귀 2건을 위반 주입이 잡아냈다.** 라운드 번호 회귀는
`_round_seq` helper 만 불러 실제로 고르는 `resolve_round` 를 안 태웠고, 실패
표기 회귀는 정상 반환 경로라 `except` 절을 안 탔다. 둘 다 경로를 태우는
형태로 바꿨다. **helper 단위 + 호출 경로를 쌍으로** 라는 규칙을 또 어겼다.

### Codex **재리뷰** 반영 (또 NEEDS_REVISION — BLOCKING 2 · HIGH 1 · NARROW 1)

1차 반영을 확인받았지만(순서·그룹 commit·preflight·숫자 선택·표기 보존·
round_id 비지문 판단) 더 깊은 구멍이 나왔다. 4건 전부 직접 재현했다.

**BLOCKING-1 — legacy 예외가 자산 검증까지 우회했고, 현행 CP 가 legacy 로
조용히 강등됐다.** `_round_marks` 가 "round_id 가 없으면 legacy" 였다. 그래서
**현행 스키마 CP 가 손상돼 그 필드만 사라져도** legacy 로 내려가고, legacy
예외가 journal 대조와 자산 검증을 통째로 건너뛰었다. Codex 재현 = 그 상태에서
DB row 까지 지웠는데 `status=ok · round_id=legacy · 유료 0 · 자산 0` 으로
"완료". ★**내가 이번 세션에 쓴 테스트가 그 동작을 정상 계약으로 잠그고
있었다**(v4 entry 에서 필드 3개만 지운 것을 legacy fixture 로 삼았다).

수정 = legacy 판정을 **CP 스키마 버전**으로만 한다(`schema < 4` 또는 부재).
현행 스키마인데 표기가 없거나 부분이면 **fail-closed**. 그리고 **자산
verify-only 는 legacy 에서도 항상** 한다 — legacy 예외는 journal 부재에만.

**BLOCKING-2 — "journal 과 exact 대조"가 아직 아니었다.** `_verify_round_cp`
가 `load_round` 를 직접 불러 `index.json` 을 우회했고(registry 를 깨뜨려도
통과), 대조 키가 asset_id/path/sha **3개뿐**이라 `path_kind`·계약 버전·팩·
정책을 변조해도 재사용됐다. 수정 = `load_registered_round`(registry membership
확인 후 로드) + `ROUND_CP_PROJECTION_KEYS` 전체 exact 비교(동적 필드
`reused`·`round_replayed` 만 제외).

**HIGH-3 — SELECTED/ASSET_BOUND 의 산출 결손이 유료 재검색으로 하강했다.**
nonempty result 요구가 FINALIZED 에만 있었다. manifest 의 `result` 를 `{}` 로
손상시키고 재개하자 `status=ok · paid=1` 로 다시 검색했다. 수정 =
`RESULT_REQUIRED_STATES` 로 SELECTED/ASSET_BOUND 도 요구(load·전이 양쪽).

**NARROW-4 — strict parser 가 identity 를 canonical 하게 안 봤다.** `bool` 은
`int` 의 서브클래스라 `contract_version=true` 가 1 로 통과했고(직접 재현),
UUID 는 canonical 변환만 하고 원문 비교가 없어 하이픈 없는 표현이 통과했다.
`read_index` 도 root/entry dict 와 계약 버전을 안 봤다. 전부 fail-closed 로.

★**같은 실수를 또 했다** — "테스트가 결함을 정상으로 잠갔는지 먼저 보라"는
A6·A7 에 이어 세 번째인데, 이번엔 **그 테스트를 내가 이번 세션에 썼다.**
그리고 위반 주입 M31 이 처음에 안 잡혀 또 한 번 걸렀다 — 실패 여부만 보고
**어떻게 실패했는지**를 안 봐서, legacy 강등이 자산 검증에 걸리는 것과
구분되지 않았다.

### Codex **3차 리뷰** 반영 (BLOCKING 2 · HIGH 1)

**BLOCKING-1 — malformed schema 가 여전히 legacy 로 강등됐다.** `cp_schema` 가
exact int 가 아니면 `schema=-1` 로 두어 **"부재"와 "존재하지만 malformed"가
다시 합쳐졌다.** 재현 = `schema_version` 을 문자열 `"4"` 로 바꾸고 registry 를
깨뜨렸는데 `status=ok · round_id=legacy · paid=0`. 수정 = **키 부재(None)만**
legacy, 존재하는데 bool/str/float 면 `round_marks_invalid` 로 fail-closed.

**BLOCKING-2 — manifest header 와 저장된 산출이 서로 묶이지 않았다.**
`load_round` 는 각 필드의 **형식만** 봤고 `_verify_round_cp` 는 result 와 CP 만
비교했다. 그래서 header 의 `input_fp`·`contract_version`·
`preallocated_asset_id` 를 변조해도 통과했다. ★가장 심각한 것 — **ASSET_BOUND
에서 예약 UUID 만 바꾸고 재개하자 자산을 또 INSERT 해 row 가 2개가 되고 새
UUID 로 완료됐다.** "라운드당 예약 UUID 불변" 계약이 실제로 깨진다.

수정 = `RESULT_REQUIRED_STATES` 에서 **header ↔ result 를 typed 로 결속**한다
(`result.round_id == round_id` · `result.group_fingerprint == input_fp` ·
`result.round_contract_version == contract_version`, ASSET_BOUND/FINALIZED 는
`result.form_ref_asset_id == preallocated_asset_id`). 그러려면 SELECTED→
ASSET_BOUND 전이가 **asset_id 를 포함한 산출을 같은 원자 쓰기로** 남겨야 한다.

**HIGH-3 — 계약 버전을 타입만 보고 값을 안 봤다.** `index.contract_version=999`
가 통과했고 manifest 999 도 CP fast path 를 지났다. index 는 현재 계약과 exact
일치, manifest 는 재사용 전에 확인. `_exact_int` 에 `code` 를 넘겨 index 손상이
manifest 손상으로 잘못 분류되지 않게 했다.

★**세 리뷰에서 지적 14건, 전부 재현·수용(반박 0).** 매번 내 구현보다 한 층
깊은 것이 나왔다: 덮어쓰기 → cross-store durability → legacy 강등 → header
identity 결속. **"검증을 넣었다"가 "그 검증이 모든 진입 경로에 도달한다"는
뜻이 아니다.**

### Codex **4차 리뷰** 반영 (BLOCKING 3 · HIGH 1)

**BLOCKING-1 — index 가 없으면 라운드 디렉터리를 아예 안 봤다.** `read_index`
가 `index.json` 부재에서 즉시 빈 registry 를 돌려줬다. manifest 를 쓰고 index
를 쓰기 전에 끊기면 **다음 실행이 같은 r001 을 다시 열어 예약 UUID 를
갈아치운다**(직접 재현: `UUID 보존 False`). 라운드/UUID 불변 계약이 실제로
깨진다. 수정 = index 부재에서도 `rounds/` 아래 canonical 디렉터리가 있으면
`round_registry_mismatch`.

**BLOCKING-2 — FINALIZED 산출의 shape 을 안 봤다.** CP 없는 재투영은 journal
산출을 **그대로 성공 CP** 로 내보내는데, header 결속 3~4필드만 보고 있었다.
실측 = `status='failed'` 인 산출이 `completed_count=1` 로 올라갔고,
`path_kind='legacy_flat'`·`ref_pack_version='tampered-pack'` 도 그대로 새 CP 가
됐다. 수정 = journal 이 `_validate_result_shape` 로 상태별 최소 shape 과 완료
projection 전체(존재·형식·의미)를 검증하고, 스텝은 재투영 전에 팩·정책이
**현재 값**인지 확인한다(`_verify_replay_stamps`).

**BLOCKING-3 — "키 부재만 legacy" 가 구현되지 않았다.** `dict.get()` 은 키
부재와 값 `None` 을 구분하지 못한다. `MISSING` sentinel 로 갈랐다.

**HIGH-4 — `True == 1` 이라 타입 계약이 뚫렸다.** 정수 필드에 `_exact_int` 를
직접 적용하고 결속·대조에 `typed_equal` 을 썼다. ★위반 주입에서 **CP 대조의
`typed_equal` 은 중복 방어**임이 드러났다(그 줄만 되돌려도 회귀가 여전히
잡는다) — 코드 주석에 그 사실을 적고 mutation 목록에서 뺐다. 반면 **SELECTED
구간**은 shape 검증이 정수 필드를 보지 않으므로 거기서는 단독 방어다.

★**네 번째 리뷰까지 지적 18건, 전부 재현·수용(반박 0).** 매번 한 층 더 깊은
곳이 나왔다: 덮어쓰기 → cross-store durability → legacy 강등 → header identity
결속 → **부분 쓰기(manifest 만 남은 상태)와 산출 shape**.

### Codex **5차 리뷰** 반영 (BLOCKING 1 · HIGH 1)

**BLOCKING-1 — 잘못된 전이 산출을 검증하기 **전에** manifest 를 durable 교체
했다.** 쓰고 나서 읽으며 검증했기 때문에, bad sha 로 전이를 요청하면 오류는
나지만 `state='selected'` 와 그 sha 가 영속되고 **이어지는 재개도 같은 오류로
막혀 그 라운드가 영구 재개 불능**이 됐다(직접 재현). "실패면 라운드를 열어
두고 이어간다"는 계약과 정반대다. 수정 = `parse_manifest` 를 추출해 **읽기와
쓰기가 같은 validator 를 쓰고**, 원자 쓰기 **전에** payload 전체를 검증한다.

**HIGH-2 — 내가 4차 이후에 추가한 "현재 값 대조"가 잘못된 방향이었다.**
`_verify_replay_stamps` 는 과거 산출의 팩·정책이 **현재 selector 와 같아야만**
재투영을 허용했다. 그런데 `_group_fingerprint` 는 대상 정책을 **의도적으로
제외**하고 팩도 이름이 아니라 `search_contract_sha` 만 접는다
(`test_search_grounded_ref_v3` 가 byte-identical 팩 승격의 hash 재사용을
잠근다). 그래서 정책 이름만 바뀌어도 **CP 가 있으면 재사용되고 CP 만 없으면
거부되는 비대칭**이 생겼다.

★**무효화 판단은 지문의 몫, stamp 는 기록의 몫이다.** 내가 그 둘을 섞었다.
수정 = 팩·정책을 **라운드를 열 때 header(`provenance`)에 고정**하고 산출의
같은 필드를 그 역사값과 결속한다. 현재 값과는 비교하지 않는다. 변조 방지는
header 결속이, 무효화는 지문이 맡는다. `_verify_replay_stamps` 는 폐기했다.

### Codex **6차 리뷰** 반영 (BLOCKING 1)

**provenance 를 *선택 필드*로 둬서 header 를 지우면 새 결속이 전부 비활성화
됐다.** `ROUND_STORAGE_CONTRACT_VERSION` 은 1 그대로였고, `parse_manifest` 가
`data.get("provenance")` 로 부재·null 을 `{}` 로 만들었으며, 결속도 "header 에
존재하는 키만" 돌았다. `open_round(provenance=None)` 도 정상 API 였다.

실측 = provenance 삭제 + 산출·CP 를 같은 거짓값으로 → `paid=0 · completed=1 ·
pack='tampered-pack'`. **새 계약을 세우면서 그 계약을 켜는 스위치를 옵션으로
남긴 셈**이다.

수정 5가지:
1. `ROUND_STORAGE_CONTRACT_VERSION` **1→2**. 이 상수는 이미 `_config_hash` ·
   `_group_fingerprint` 양쪽에 접혀 있어 실행 도달도 함께 해결된다.
2. v2 에서 provenance = **dict + key set exact 일치 + 두 값 nonblank string**.
   `MISSING` sentinel 로 부재와 explicit null 을 가른다.
3. `open_round`/`resolve_round` 의 `provenance` 를 **필수 인자**로 — 빈 header
   를 만드는 API 자체를 없앤다.
4. **구 계약(v1) journal 은 read-only.** 순번을 세려면 읽을 수 있어야 하지만
   재사용·재개는 계약 exact 일치 요구가 막는다(같은 v1 으로 조용히 현행
   취급하지 않는다).
5. 회귀 = (a) missing/null/빈dict/부분/여분키/공백/비문자열 **7종** typed
   failure (b) header 삭제 + 산출 변조가 공개 `_execute` 에서 paid=0·
   completed=0 (c) 저장 계약 변화가 **두 해시를 다 흔드는지**.

★위반 주입에서 provenance 결속의 "존재하는 키만" 분기가 **중복 방어**로
드러났다(필수화가 먼저 막는다). 주석에 적고 목록에서 뺐다 — 조건 자체는 v1
처리를 위해 필요하다.

### Codex **7차 리뷰** 반영 (BLOCKING 1) — v1 미지원 확정

**"v1 read-only" 라고 선언한 경로가 실제로는 실행되지 않았다.** 실제 v1
저장소는 index·manifest 둘 다 계약 1인데, `read_index` 가 index 계약을 현재
값과 exact 비교하므로 **manifest 를 읽기도 전에** 선다. 주석과 보고가
실행되지 않는 동작을 설명하고 있었다.

**게다가 내 회귀는 실제 v1 이 아니라 합성 상태였다**(manifest 만 v1, index 는
현재 계약). 그 상태에서도 read-only 가 아니었다 — `resolve_round` 가 계약
불일치를 보고 `abandon_round` 를 불러 **구 manifest 를 원자 교체**한다.
★내 회귀가 `is_resumable` helper 만 불러 그 쓰기를 놓쳤다. **helper 단언만으로
끝낸 같은 실수**다.

**방향 A(v1 미지원)를 택했다.** 전제를 넘겨짚지 않고 파일시스템에서 세었다 —
`rounds/` 디렉터리 **0개** · manifest **0개**. 실제 v1 저장소가 없으므로 이행
경로를 지어내는 대신 **거부를 계약으로 확정**한다:
- manifest 도 계약 exact 요구(`round_manifest_unsupported_contract`)
- `PROVENANCE_REQUIRED_CONTRACT` 분기 제거 — provenance 무조건 필수
- 회귀는 **실제 v1 모양(index=1 + manifest=1)** 을 공개 `list_rounds`/
  `resolve_round` 에 투입하고, resume·force 각각 **bytes exact 불변**을 잠근다

### ★A4 종료 조건 (2026-08-02 사용자 합의)

리뷰 7회가 전부 NEEDS_REVISION 이었다. 다만 **성격이 옮겨갔다**:
- 1~4차 = **실제 실행 결함**(crash·rollback 계보 파손, 재개 UUID 중복 생성)
- 5~7차 = **내 수정이 만든 빈틈**(검사 방향 오류 · 새 계약을 옵션으로 남김 ·
  회귀가 실제 모양을 모델링 못 함)

**A4 의 원래 목적은 달성됐다** — 재검색이 과거 씨드의 입력 간선을 바꾸지
못한다는 것을 실제 PG 트랜잭션으로 확인했고(옛 upsert 를 되살리면 실제로
깨지는 것까지), 운영에서 실제로 나는 실패(crash·그룹 rollback·부분 쓰기)는
1~5차에서 막았다.

6차부터는 **manifest 를 손으로 변조하는 시나리오**다 — 계약 엄밀성이지 운영
위험이 아니다(파일을 자유롭게 쓸 수 있는 사람은 DB 도 쓴다). 수확 체감 구간.

**합의**: 8차 답신이 또 **tamper 주입 계열만** 나오면 그 사실을 근거로 A4
종료를 제안하고 사용자가 판단한다.

### 리뷰를 기다리며 내가 찾은 것 (5차 대기 중 — 이후 HIGH-2 로 방향 수정됨)

Codex 가 매번 쓴 방법 — **공개 진입점에서 그 계약을 우회하는 경로를 세는 것**
— 을 선제적으로 적용했다. 나온 것:

**CP 와 journal 을 *둘 다* 변조하면 exact 대조가 통과한다.** 서로는 일관되기
때문이다. "현재 값인가" 검사(`_verify_replay_stamps`)가 **재투영 경로에만**
있어서, 빠른 경로(CP 재사용)로는 거짓 팩·정책이 그대로 새 CP 로 승격됐다
(직접 재현·봉합·위반 주입 확인). 감사 기록 자체가 거짓이 되는 결함이다.

나머지 경로는 각각 다른 검사가 막는 것을 확인했다 — force(검증 대상 없음),
BOUND 재개(`marks` 를 현재 값으로 다시 계산 + bind preflight 가 파일·sha 재
확인), legacy(`_reusable` 의 파일·sha + `_verify_bound_asset`).

### 범위 밖으로 남긴 것

`outdoor_structure_seed_step.py:781` 의 `structure_seed` 자산이 **같은 upsert
패턴**이다. A4 범위 밖이라 그대로 두었고, 그래서 소스 잠금도
`outdoor_structure_form_reference_step.py` **하나에만** 걸었다 — 넣었으면
통과할 수 없는 게이트가 된다.

## 4. 이 계획이 고쳐진 경위 — 반복하지 말 것

초안(R0~R7)은 **CP 를 journal 로 쓰는 전제**였고 BLOCKING 3건을 받았다.
CP 수명주기를 확인하지 않고 "다운로드 전에 CP durable" 이라고 썼기 때문이다.
같은 세션에서 **메커니즘을 주장하고 확인하지 않는 실수를 세 번** 했다(두 번은
구현, 한 번은 설계). 앞으로 "이 경로가 이걸 받는다/막는다"류 주장은 **그 경로를
실제로 태운 근거와 함께** 낸다.
