# ops-10-cells M2 — 구현 노트

계획서 본문은 `mccp-plan-codex/ops-10-cells-m2.json` 의 앵커이므로 이 절을 계획서에 쓰지 않는다.

## O-M2-1 판정 (사용자, 2026-08-24)

**(가) 레코드 합성.** 앵커가 걸린 빈 셀 8건에 `v:null` · `t:null` 레코드를 합성하고
`img_synth: true` 를 싣는다. UI11 문면 이탈을 계획서 §D3 이 이미 명시했고 그 대가를 사용자가
받았다. (나) 별도 앵커 원장은 채택하지 않는다 — Task 3b · Task 5 · §산출 기대값 ·
Validation 9~10 은 계획서 문면 그대로 간다.

O-M2-2(F-CELL-17 세대 혼합)는 **출항 선행조건**이고 구현 차단이 아니다. Task 6 이 상신하고
판정은 00 이 한다. M2 는 `/mccp:pr` 로 가지 않는다.

## Codex Implementation Review

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.32.1/scripts/lib/codex-invoke.js adversarial-review` (fail-closed Bash wrapper, v0.2.2)
- 라운드 수: 1 (R1). 첫 호출은 focus 1817바이트에서 timeout — 985바이트로 압축해 재호출했다
  (advisory 로 접지 않았다)
- 합치 결론: 3건 중 **1 흡수 · 2 반려**. 흡수분은 두 프로그램이 `customFormat` 을 똑같이
  `=== '1'` 로 읽어 xsd:boolean 의 `"true"` 를 함께 놓치는 **공통 오답 표면**이다 —
  독립 재도출 규약(`ops/cells/check-cells.mjs:14`)이 정확히 막으려던 부류가 실물로 있었다
- YAGNI Triage:

  | Finding | Severity | Verdict | Why |
  |---|---|---|---|
  | F1 워킹트리에 구현이 없다 | HIGH | REJECT_YAGNI | 게이트 구조 산물이다 — Implement-Codex 는 Phase 3 EXECUTE **이전**에 돈다. 구현 후 파일 경로를 명시해 재호출하는 것이 이 게이트의 정상 경로이고 Phase 3.6 뒤에 그렇게 한다 |
  | F2 18건 순차 교체가 소비자 원자성이 아니다 | HIGH | REJECT_YAGNI | 계획 봉인이다 — §D7 이 같은 사실을 정수 3개(2→18 · 1→2 · `img` 교차 참조 신설)로 이미 적었고, O-M2-2 · §Acceptance 가 그것을 **출항 선행조건**으로 올렸다. Task 6 이 F-CELL-17 로 상신한다. 수정 대상이 아니라 이미 상신된 항목이다 |
  | F3 두 파서가 `customFormat="true"` 를 함께 놓친다 | MEDIUM | ACCEPT_NOW | 대조했고 사실이다 — `ops/cells/extract-cells.mjs` 503 행과 `ops/cells/check-cells.mjs` 353 행이 글자까지 같은 `a.customFormat === '1'` 이다. 합성 좌표의 행 스타일 상속(§D3 · Task 3b)이 정확히 이 술어에 걸리므로 두 판독이 함께 틀려도 통과한다 |

- Deferred to backlog: 0 → F2 는 backlog 가 아니라 `ops/findings/ops-10-cells.md` F-CELL-17 로
  간다 (Task 6). 계획서 §Acceptance 가 이미 출항을 막고 있어 더 약한 자리로 옮기지 않는다
- Open Questions: O-M2-2 F-CELL-17 조정 축을 00 이 정한다 — severity HIGH, **출항 차단**
  (구현 차단 아님)
- Codex session 참조: threadId `01a032f5-0a4c-7fd2-9701-52c99d5a56ca`

### F3 흡수안 (계획서 대비 편차)

계획서 Task 0b 는 워크북 fixture 를 **11종**으로 못 박았다. F3 흡수가 그것을 **12종**으로
넓힌다. 편차를 숨기지 않고 여기 적는다.

1. 두 프로그램이 `customFormat` 을 xsd:boolean 으로 각자 판독한다 — `1`·`true` 는 참,
   `0`·`false` 는 거짓, 그 밖의 철자는 `OPS_INV_ROW_CUSTOM_FORMAT` 으로 **멈춘다**.
   판독을 공유하지 않는다(같은 규약을 두 번 구현한다).
2. fixture `row-customformat-true` 를 세운다 — `synth-row-style` 과 같은 형태이되
   `customFormat="true"` 다. 합성 좌표가 행 `fill_id` 2 를 내야 하고, 열 값 1 이나 기본 0 을
   내면 `OPS_INV_CHECK_SYNTH_STYLE` 로 갈린다.
3. Validation 4b (다) 의 fixture 이름 열거가 11종에서 **12종**이 된다.

### Security Reviewer

> security-reviewer unavailable, skipped (auto-fallback): this session forbids the Agent tool (system prompt: "Do not call the AgentTool unless the user requested it"), so subagent_type security-reviewer cannot be dispatched.

이 게이트는 zip 해제(zip-slip) · media basename 정규식(경로 조작 · Windows 대소문자 충돌) ·
`git check-attr` 호출을 건드리므로 security-sensitive 다. 판정을 대신 세우지 않는다 —
receipt 가 `security_skipped=true` 로 봉인되고 `/mccp:pr` 가 기계적으로 막힌다. M2 는 어차피
O-M2-2 로 출항이 막혀 있어 이 봉인이 새로 잃는 것은 없다.

### Design Review

> impeccable silent-skip (no-signal): `impeccable-detect.js --mode implement` 가 렌더 표면을
> 하나도 못 찾았다. M2 산출은 `.mjs` · `.jsonl` · 이미지 바이트이고 UI 가 없다.

## Codex Implementation Review — R2 (구현 후 재실행)

R1 은 게이트가 Phase 3 EXECUTE **이전**이라 워킹트리에 구현이 없었다(F1 이 그 사실을 지적했고
반려했다). 구현이 끝난 뒤 같은 러너를 **파일 경로를 명시해** 다시 돌렸다.

- 호출: `codex-invoke.js adversarial-review` · threadId `01a03333-1ecd-7bf3-8d1d-dfcc6ff78c16`
- 1차 시도는 focus 900바이트인데도 timeout — 리뷰 대상 diff 가 1528행이라 무거웠다.
  advisory 로 접지 않고 축을 **롤백 하나로** 좁혀 재호출했다.
- 판정: `needs-attention` · "Do not ship: rollback can itself corrupt or strand a mixed generation"
- 3건 전건 **ACCEPT_NOW**. 전부 M2 가 2건→18건으로 넓힌 바로 그 축이다.

| Finding | Severity | Verdict | Why |
|---|---|---|---|
| F1 롤백이 안 건드린 대상까지 다시 쓰고 첫 실패에서 멈춘다 | HIGH | ACCEPT_NOW | 사실이다. `catch` 가 `targets` 전건을 순회해 rename 되지 않은 파일에도 `writeFileSync` 를 걸었다 — 옛 바이트를 그대로 다시 쓰지만 **truncate 창**이 열리므로 롤백이 자기가 막으려던 손상을 스스로 만든다. 그리고 한 건이 실패하면 `invFail` 이 즉시 exit 해 나머지가 통째로 미실행이었다 |
| F2 트랜잭션 설정이 가드 밖에 있다 | MEDIUM | ACCEPT_NOW | 사실이다. mkdir · 임시 쓰기 · 마커 쓰기가 `try` **앞**에 있었고, `tempFiles.add` 가 `writeFileSync` **뒤**라 부분 기록 뒤 throw 하면 exit 훅이 그 파일을 모른다 |
| F3 정리 실패를 삼키면서 롤백을 성공으로 보고한다 | MEDIUM | ACCEPT_NOW | 사실이다. unlink 실패해도 등록을 지웠고(exit 훅이 재시도 못 한다), 마커를 정리 **전에** 지워 덜 끝난 트리를 `--check` 가 통과시킬 수 있었다 |

**흡수 내용** (`publish()` 재작성):

1. `renamed[]` 를 따로 들고 **실제로 rename 된 것만** 역순으로 되돌린다. 한 건이 실패해도
   멈추지 않고 전건을 시도한 뒤 `problems[]` 를 모아 한 줄로 낸다.
2. mkdir · 임시 쓰기 · 마커 쓰기를 전부 `try` **안**으로 옮겼다. 임시 경로는 **쓰기 전에**
   등록한다.
3. unlink 가 성공(또는 `ENOENT`)했을 때만 등록을 지운다. 마커는 **맨 마지막**에, 그리고
   되돌리기·임시·디렉터리가 전부 깨끗할 때만 내린다 — 덜 끝났으면 마커가 남아
   `--check` 가 `OPS_INV_PUBLISH_INCOMPLETE` 로 거부하고 정상 실행이 전량 재생성으로 복구한다.
4. 되돌리기가 미완이면 원인 토큰(`OPS_INV_CELL_DIGEST_INDEPENDENT` 등) 대신
   `OPS_INV_PUBLISH_ROLLBACK` 을 낸다 — 그 시점의 사실은 "롤백이 미완" 이다.

실측 확인: `after-path=ops/media/s1-simwon-items/image9.jpg`(정렬상 마지막 대상) 주입에서
18/18 되돌림 · 18파일 sha256 전건 동일 · 마커 0건.

R2 는 여기서 닫는다 — 3건 모두 흡수됐고 새 층위 지적이 없다. 남은 HIGH 는
F-CELL-17(소비자 원자성)뿐이고 그것은 계획 봉인·출항 선행조건이다.

## Codex Implementation Review — R3·R4 (게시 축 심화)

R2 흡수 뒤 같은 축을 두 라운드 더 돌렸다. 층위가 매 라운드 바뀌었으므로 건수가 아니라
**부류**로 종료를 판정했다.

| Round | 층위 | 건수 | 결과 |
|---|---|---|---|
| R2 | 복원·정리 절차 | 3 | 전건 흡수 |
| R3 | 마커의 **소유** (내 것인가 이전 것인가) | 2 | 전건 흡수 |
| R4 | 마커의 **내용** (대상 집합이 덮이는가) | 1 | 흡수 |

### R3 흡수 2건

- **F1 HIGH** — 실패한 **복구**가 이전 마커를 지운다. 강제 종료가 남긴 미완결 세대를 복구하러
  들어온 실행은 `prev` 스냅샷 자체가 이미 섞인 상태다. 그 시도가 실패하면 `unwind()` 가
  섞인 상태를 되돌려 놓고 마커까지 지워, 미완결 세대가 **신호 없이** 남는다 —
  `--check` 의 `OPS_INV_PUBLISH_INCOMPLETE` 가 그 순간 무력해진다.
  → `priorMarker` 를 트랜잭션 **전에** 기록하고, 이 시도가 만든 마커일 때만 내린다.
- **F2 MEDIUM** — 성공 경로의 마지막 `unlink` 가 모든 오류를 삼켰다. 실패하면 **성공으로
  보고하면서 마커를 남긴다** — 다음 `--check` 는 거부하는데 자동화는 방금 성공 신호를 받는다.
  → `ENOENT` 만 성공으로 보고 그 밖은 `OPS_INV_PUBLISH_INCOMPLETE` 로 rc 1.

### R4 흡수 1건

- **F1 HIGH** — 마커는 존재만이 아니라 `targets` 를 싣는다. 중단된 트랜잭션의 대상이 이번
  대상 집합에 다 들어 있지 않으면(락 개정 등으로 집합이 갈리면) 이번 게시가 성공해도 그
  나머지는 반쪽인 채 남고, 마커만 사라진다.
  → **시작하기 전에** 덮이는지 보고 안 덮이면 트리를 건드리지 않고 거부한다. 판독 불가
  마커도 같다 — 모르는 집합은 덮인다고 볼 수 없다.

반사실 3종 실측:

| 마커 상태 | rc | 결과 |
|---|---|---|
| 대상 1건이 이번 집합 밖 | 1 | `OPS_INV_PUBLISH_INCOMPLETE`, 트리 무변경 |
| 판독 불가 (`not-json`) | 1 | `OPS_INV_PUBLISH_INCOMPLETE`, 복구 절차를 문구가 지명 |
| 같은 18 대상 (정상 복구) | 0 | 게시 완료, 마커 제거 |

그리고 이전 마커가 있는 상태에서 결함을 주입하면 되돌린 뒤 **마커가 살아남는다** (실측 확인).

### 종료 판정

R4 는 R3 과 같은 축(마커)의 한 겹 아래였고, 흡수 뒤 남은 지적은 **소비자 원자성**
하나뿐이다 — 그것은 계획 §D7 이 정수 3개로 이미 적었고 O-M2-2 · §Acceptance 가
**출항 선행조건**으로 올린 항목이며 M2 가 고치는 대상이 아니다. 여기서 닫는다.

`resolution.codex_verdict` 는 receipt 에 **`divergent` 로 남긴다.** 지적 0건 라운드가 아직
없었고(R4 가 1건을 냈다), M2 는 어차피 O-M2-2 로 출항이 막혀 있어 fail-closed 가 정확한 상태다.
`converged` 로 바꾸면 게이트가 본 적 없는 것을 주장하게 된다.

## 로컬 코드 리뷰 (`/mccp:code-review` Local Review Mode, 2026-08-25)

게이트 receipt 체인 밖의 커밋 전 자문 리뷰다. 사용자가 **전건 수용**을 지시해 8건을 다 흡수했다.
지적을 문면으로만 받지 않고 **반사실을 실행해** 확인한 뒤 고쳤다.

| ID | Severity | 축 | 흡수 |
|---|---|---|---|
| H-1 | HIGH | `--seal --under-publish-lock` 이 6단계 중 1단계만 잠금 안 | 잠금을 `runSeal` 로 승격 |
| M-1 | MEDIUM | 이전 세대 media 잔재가 게시를 영구 차단 | 잔재 삭제를 같은 트랜잭션에 |
| M-2 | MEDIUM | `img_synth` 문서 정의(차집합) ≠ 구현(`!c && !mi`), `m1Coords` 사문 | 주석 정정 + 죽은 변수 제거 |
| M-3 | MEDIUM | `isOwnedPath()` 가 `ops/media/` 를 모른다 | 접두 확장 |
| L-1 | LOW | 세고 나서 즉시 `invFail` 하는 계수기는 0 밖을 못 낸다 | 전건 훑고 실패 + 주장 정정 |
| L-2 | LOW | `mediaBytes.size > 0 && imgAnchors === 0` 도달 불가 분기 | 제거 |
| L-3 | LOW | selftest 가 M1 레코드 수 42428 을 박고 있다 | 판정 성질과 무관한 값으로 |
| L-4 | LOW | 문서 id 를 경로 세그먼트로 쓰며 문자 집합 미검증 | 두 프로그램에 각자 상한 |
| L-5 | LOW | 중복 `<row r>` 에서 두 판독이 갈린다(첫 것 vs 마지막) | 추출기를 Map 으로 통일 |
| L-6 | LOW | 파일·함수 길이 | 기록만 — UI3·오라클 독립의 산물이라 분할하지 않는다 |

### H-1 상세 — 실측이 문면보다 강했다

`runDigest` 가 반환 **전에** `releaseLock()` 해서, `--seal --under-publish-lock` 의 나머지
다섯 단계가 잠금 밖에서 돌았다. 그 중 셋(`verifyPublishedMediaBytes` · `runImgTuples` ·
`runImgSynthSet`)이 **M2 가 새로 더한 축**이고 전부 디스크를 읽는다.

5ms 간격 폴링 실측:

| 명령 | 총 시간 | 잠금 창 | 잠금 비율 |
|---|---|---|---|
| 고치기 전 `--seal --under-publish-lock` | 2751ms | 198..1167ms | 42% |
| 고친 뒤 `--seal --under-publish-lock` | 4554ms | 388..4443ms | **95%** |
| 고친 뒤 `--seal` (플래그 없음) | 4731ms | 없음 | 0% (게시 경로가 이미 쥔다) |

계획 §Validation 1118행이 오타 난 플래그를 "**잠금 없이 봉인하는** 경로" 라고 부르며 Task 0b 를
세웠는데, 오타가 아니라 정상 경로가 그 상태였다.

### M-1 상세 — fail-closed 였지만 자기복구가 없었다

원본 워크북에서 이미지가 하나 줄면 `publish()` 는 새 18건만 쓰고 잔재를 남기는데, 직후
`runIndependentDigest('publish')` 가 `OPS_INV_CHECK_MEDIA_SHA` 로 그 잔재를 물어 방금 쓴 세대를
통째로 롤백한다 — 정상 실행이 **영원히** rc 1 이다. 트랜잭션 마커 축은 "정상 실행이 전량
재생성으로 복구한다" 를 약속하는데 media 축에는 그 대칭이 없었다.

지우는 것은 `--ownership-check --accept-owned` 가 통과시키는 **M2 산출 형태**뿐이다
(`ops/media/<lock parsed id>/image<n>.<소문자확장자>`). 그 밖의 파일은 M2 가 만든 적이 없으므로
지우지 않고 봉인이 계속 물어 사람이 판정하게 둔다.

반사실 2종 실측:

| 상황 | rc | 결과 |
|---|---|---|
| 잔재 1건 존재 + 정상 실행 | 0 | 19 대상(교체 18 + 삭제 1), 잔재 제거, `--check` 0 |
| 같은 상황 + `after-path=<잔재 경로>` 주입 | 1 | 19/19 되돌림, **삭제분이 바이트 동일하게 되살아남**, 마커·잠금 0 |

### 산출 불변

10건 전부 **산출 바이트를 건드리지 않는 변경**이다. 리뷰 전후 `s1-healthy-master.jsonl`
`dec6bfc7…` · `s1-simwon-items.jsonl` `80ec524f…` 로 동일하고 media 16건은 `diff -r` 동일이다.
L-5 의 Map 전환도 실물 원본에 중복 `<row r>` 이 없어 산출이 안 움직인다.

### 흡수 뒤 재검증 (전건 실행)

`--check` 0 · `--selftest` 0 (fixture 20) · `--schema-fixtures` 0 · `--check-media-attr` 0 ·
`--classifier-diff` 0 (`classifier_symmetric_difference 0`) · `--ownership-check --accept-owned` 0
(`ownership_violations 0`) · `--branch-scan` `branch_count_static 0` ·
`--seal --under-publish-lock --print-inputs` 0 (`seal_input` 18행 · 불일치 지표 6종 전부 0 ·
`seal_media_verified 16`) · 층 2 `ops/statements/check-statements.mjs --check` 0.

media fixture 11종을 두 프로그램에 다 태워 토큰이 리뷰 전과 같음을 확인했다 — L-1 의 2단계
분리가 토큰 순서(이름 → 고아 → 도달 → 끊어진 관계)를 안 바꿨다는 증거다. `synth-row-style` ·
`row-customformat-true` 는 여전히 행 `fill_id` **2** 를 내고 두 프로그램이 일치한다.

`--expect-media` 의 `orphan_media` 와 `dangling_image_rels` 는 fixture 에서 실제로 **1** 이 나온다
— L-1 주석이 "이 둘만 성공 경로에서도 0 이 아닐 수 있는 진짜 관측" 이라고 적은 근거다.

### 게이트 상태 (바뀌지 않음)

로컬 리뷰는 receipt 체인 밖이라 `code-reviewer` receipt 를 쓰지 않는다. `security_skipped=true`
봉인과 O-M2-2 출항 차단은 그대로다 — **M2 는 여전히 `/mccp:pr` 로 가지 않는다.**
