# 마일스톤 3 — 구현 게이트 기록

> 이 파일은 `/mccp:prp-implement` 의 감사 섹션 전용이다. **plan 본문에 쓰지 않는다** —
> plan 해시가 바뀌면 상위 `mccp-plan-codex` receipt 가 즉시 stale 이 되고 Phase 2.5.7
> read-back validate 가 떨어진다.

## Codex Implementation Review

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.23.0/scripts/lib/codex-invoke.js adversarial-review` (fail-closed Bash wrapper, v0.2.2)
- 라운드 수: 1
- 구조화 verdict: `needs-attention` → 게이트 verdict `divergent`
- 합치 결론: 2건 **둘 다 실재하는 구현 결함**이고 R1 에서 전부 흡수했다. 공통 축은 하나다 —
  **계획이 "이미 있는 것" 을 안 보고 새로 정한 결정 2개가 전부 잘못됐다.** 원자적 쓰기는 이미
  `writeDocs` 로 구현·테스트되어 export 돼 있었고, 재사용하기로 한 `stripComments` 는 이미
  backlog 에 결함으로 적혀 있었다. 둘 다 실물 대조로 확인했다(Codex 의 도구 호출은 이 환경에서
  실패했다 — `rg` 부재. 주장 그대로 받지 않았다).
- YAGNI Triage:
  | Finding | Severity | Verdict | Why |
  |---|---|---|---|
  | F1 순차 쓰기로는 계획이 약속한 all-or-nothing 이 성립하지 않는다 | HIGH | ACCEPT_NOW | 실물 확인. 첫 파일 rename 성공 + 둘째 실패면 세대가 어긋난 인덱스가 남고, **각 파일은 자기 안에서 일관하므로 검증기를 통과한다**. 그리고 이미 있는 것을 안 쓴 결정이었다 |
  | F2 재사용하려던 `stripComments` 가 정규식 리터럴을 모른다 | HIGH | ACCEPT_NOW (심각도 정정) | 결함은 실측 확인 — `stripComments` 는 1723 파일 중 **6 파일**에서 갈리고 **양방향 전부 실재**한다. 다만 Codex 가 전제한 "이 스캐너를 쓴다" 가 틀렸다: M2 가 같은 목적의 **행 단위** 대체물 `blankComments` 를 이미 만들어 뒀고 그쪽은 material 차이가 **0** 이다(아래). 그래도 15행으로 닫히므로 닫는다 |

### R1 흡수 내역 (구현 계약)

**F1 — `writeDocs` 를 import 한다. 새로 쓰지 않는다**

`build-code-index.mjs:1436` 의 `writeDocs(indexDir, docs, rename = renameSync)` 가 이미
Codex 가 요구한 프로토콜 전부를 구현하고 있다 — 그 함수의 docblock 이 존재 이유로 적어 둔 것이
정확히 이 실패 모양이다:

> 앞의 3개는 새 내용, 뒤의 4개는 옛 내용인 인덱스가 남고, 그 상태의 인덱스는 검증기를 통과할
> 수도 있다(각 파일은 자기 안에서 일관되므로).

3단계 계약: (1) 선행 검사 — 덮을 수 없는 목적지를 rename 전에 판정 (2) 전량 `.tmp` 쓰기 — 실패 시
목적지 무손상 (3) rename 중간 실패 시 이미 옮긴 것을 백업으로 되돌리고 tmp 정리. `rename` 을
주입받으므로 3단계 실패를 테스트할 수 있고, `build-code-index.mjs:1334-1383` 에 실패주입
selftest 2종(`"10 쓰기 도중 실패 시 부분 갱신도 .tmp 찌꺼기도 남지 않는다"` 포함)이 이미 있다.

계획의 결정 (2) "메모리 렌더 후 순차 write, tmpfile+rename 안 함" 은 **철회한다.**
`build-policy-values.mjs` 는 `writeDocs` 를 import 해 두 산출물을 한 번에 넘긴다.
selftest 에 "둘째 산출물 rename 실패 시 두 원본 모두 불변" 픽스처를 추가한다.

> 부수 효과: 재사용 가능한 export 를 다시 훑었더니 `walk` · `statementEnd` · `renderScalar` ·
> `renderFolded` · `renderDoc` · `assertRoundTrip` · `mergeCuration` · `assertNoIdCollision` ·
> `readRepos` · `readExcludeGlob` 이 전부 이미 export 돼 있다. 계획이 backlog 로 미룬
> "스캐폴딩 3중복" 은 실제로는 대부분 import 로 해소된다 — 새 파일이 자기 것으로 가져야 하는
> 것은 스캐너 2종과 스키마·판정뿐이다.

**F2 — 스트리퍼는 `blankCommentsSafe` 로 간다. 다만 Codex 의 전제와 심각도는 정정한다**

Codex 의 결함 지적은 옳다. `build-code-index.mjs:299-314` 의 `stripComments` 는 `//` · `/* */` ·
따옴표 문자열은 알지만 **정규식 리터럴을 모른다.** `/['"]/g` 를 만나면 그 안의 따옴표를 문자열
시작으로 읽고 짝을 잘못 맞춘다. 실물: `libs/drizzle/src/drizzle.utill.ts:30`
`keyName?.split('.').pop()?.replace(/['"]/g, '')`.

> 아래 두 측정의 파일 수 1723 은 **구현 시점 pin** 값이다. `bcb50ec` 재핀 후 같은 모집단은 1729 다
> (server 722 · site 450 · admin 557). 측정을 다시 돌리지 않았으므로 그때 값을 그대로 둔다.

**측정 1 — `stripComments`(문자 스트림, M1)** · 3저장소 `src_roots` · `exclude_glob` 적용 ·
`.ts`/`.tsx` **1723 파일**:

| 결과 | 파일 수 | 의미 |
|---|---|---|
| 정규식 인식 판본과 일치 | 1717 | — |
| `fix-strips-more` | **4** | 현행이 주석을 **안 지운다** → 주석 처리된 코드가 항목이 되는 **거짓 양성** |
| `fix-restores-code` | **2** | 현행이 **실코드를 지운다** → 항목이 조용히 사라지는 **거짓 음성** |

양방향 다 실재한다. 문자 스트림이라 짝이 어긋나면 **파일 끝까지** 상태가 오염된다.

**측정 2 — `blankComments`(행 단위, M2 `verify-policy-anchors.mjs:137`)** · 같은 1723 파일:

| 결과 | 값 |
|---|---|
| 갈리는 **행** | **3** (`ExcelDownloadButton.tsx:35` · `Popup.tsx:67` · `useServiceBlock.tsx:79`) |
| 그 중 **material** (스캔 대상 `process.env.X`/불변 수치 선언의 가시성을 바꾸는 것) | **0** |

행마다 따옴표 상태를 초기화하므로 오염이 **한 행에 갇힌다.** 그리고 M3 는 앵커가 행 번호라
행 길이를 보존하는 이 판본이 원래 더 맞다.

**그래서 Codex 의 "silently produces an incomplete index" 는 현 HEAD 에서는 성립하지 않는다** —
그것은 쓰지 않기로 한 스캐너의 성질이다. 심각도는 "실재하는 오염" 이 아니라 **"잠복"** 이다.

조치 (잠복이어도 닫는다 — 15행이다):

1. `build-policy-values.mjs` 가 `blankCommentsSafe(lines)` 를 **자기 파일에** 두고 export 한다.
   M2 `blankComments` 와 같은 행 단위 상태 기계에, 값이 시작될 수 있는 위치
   (`=(,:[!&|?{};+-*%^~<>` 또는 행두)의 `/` 를 정규식 리터럴로 해석하는 분기만 더한다.
   문자 클래스(`[...]`)와 이스케이프를 따라가고, **같은 행에서 안 닫히면 정규식이 아니라고
   판정해 되돌린다**(미종결 리터럴을 삼키지 않는다).
2. **`build-code-index.mjs` 도 `verify-policy-anchors.mjs` 도 건드리지 않는다.** 고치면
   마일스톤 1·2 산출물의 재생성 결과가 이 마일스톤에서 조용히 바뀐다 — 계획의 경계다.
3. backlog 의 2026-08-06 `stripComments` 항목을 **실측으로 승격**한다(이론 → 확인, 6파일·양방향)
   + 교체처를 `blankCommentsSafe` 로 지정한다. 세 판본이 공존하게 됐다는 사실도 같이 적는다.
   findings §1 에도 발견으로 올린다.
4. selftest 픽스처: 따옴표를 품은 정규식 리터럴 **바로 뒤 같은 행**에 `//` 주석이 오고 그 다음
   행에 `process.env.X` 와 불변 수치 선언이 오는 경우 — 주석은 지워지고 두 대상은 수집돼야 한다.
   `blankComments` 를 쓰면 그 행의 주석이 안 지워져 깨지는 픽스처다.

**자체 발견 S1 — 계획의 "양방향 대조" 는 M2 가 이미 불충분하다고 판정한 설계다**

Codex 는 못 짚었지만 사전 조사에서 나왔다. M3 계획 Task 1 은 축 완전성을 **2자 대조**로 적었다 —
"생성기가 `*_observed` 를 함께 쓰고, 검증기가 축 목록과 양방향 대조". 그런데 M2 의 Implement-Codex
R1 F3 이 정확히 그 설계를 기각했고 `verify-policy-anchors.mjs:20-32` 헤더에 사유가 박혀 있다:

> 생성기가 축 목록과 `*_observed` 를 **둘 다** 쓰면 스캐너 사각이 생겼을 때 양쪽이 같이 줄어들어
> 양방향 대조가 통과한다 — 검사가 소스가 아니라 자기 자신에 대해 성립하는 것이다.

M3 도 같다. 생성기가 게이트 독출을 놓치면 `gate_observed` 도 같이 줄어 통과한다.
계획의 "누락 error / 유령 error" 는 생성기 사각을 못 잡는다.

조치: `verify-policy-values.mjs` 가 `build-policy-values.mjs` 를 **import 하지 않고** 자기 코드로
소스를 다시 훑어 **3자 대조**한다 — 파일의 항목 목록 == 파일의 `*_observed` == 검증기 독립 재스캔.
구현 모양도 일부러 다르게 간다(생성기는 행 단위 상태 기계, 검증기는 파일 단위 정규식 전수 매칭).
`build-code-index.mjs` 에서 가져오는 것은 `readRepos` · `readExcludeGlob` · `walk` · `writeDocs` 뿐
— 설정 파싱과 파일 I/O 이지 정책 스캔이 아니다. M2 가 그은 선과 같다.

**R2 를 돌리지 않은 이유**: ACCEPT_NOW HIGH 2건이지만 R1 흡수가 둘 다 완결됐다(F1 은 기존 export
import 로, F2 는 새 함수 + 실측 픽스처로). Phase 2.5.4 의 escalate 조건 (b) "R1 흡수가 완전히
해소하지 못함" 이 불성립. S1 은 Codex 산출이 아니라 자체 발견이라 triage 표 밖이다.

### 재사용 실측 (계획의 "스캐폴딩 3중복" 재평가)

| 필요 기능 | 재사용 가능? | 출처 |
|---|---|---|
| manifest `repos`/`exclude_glob` 읽기 | 예 | `build-code-index.mjs` `readRepos` · `readExcludeGlob` |
| 디렉터리 훑기 | 예 | 동 `walk` |
| 원자적 다중 쓰기 | 예 | 동 `writeDocs` (F1) |
| 스칼라·접힘 블록 렌더 | 예 | 동 `renderScalar` · `renderFolded` |
| 렌더-재파싱 왕복 검사 | 예 | 동 `assertRoundTrip` (`parseYamlSubset` 기반, 스키마 무관) |
| id 충돌 검사 | 예 | 동 `assertNoIdCollision` |
| YAML 부분 파서 · 앵커 · digest | 예 | `verify-code-index.mjs` `parseYamlSubset` · `checkAnchor` · `sha256OfIds` |
| **문서 렌더** `renderDoc` | **아니오** | 모듈 스코프 `KEY_ORDER` 에 묶여 있다 — 키 집합이 다르다 |
| **큐레이션 병합** `mergeCuration` | **아니오** | 모듈 스코프 `PRESERVE` 에 묶여 있다 |

즉 새 파일이 자기 것으로 가져야 하는 것은 **스캐너 2종 · 스키마 · 판정 · 키 순서로 매개변수화한
렌더/병합 2개(약 30행)** 뿐이다. 계획이 backlog 로 미룬 "3중복" 은 실제로는 그 30행이고,
공유 lib 추출을 지금 하지 않는 판단은 유지한다(backlog 항목 그대로).

### Security Reviewer

`Task(subagent_type: security-reviewer)` 실행됨. **CRITICAL/HIGH 0건** — MCCP-GATE-STOP 없음.
아래 3건은 전부 실물로 재확인했다(에이전트 주장 그대로 받지 않았다).

**S-1 (실행 계약, ACCEPT_NOW) — git 추적 검사는 `execFileSync` 배열 인자로만 부른다**

계획 Task 1 은 "`git ls-files --error-unmatch` 로 추적 여부 확인" 이라고만 적었고 **호출 방식을
안 정했다.** 셸을 태우면 앵커 경로가 인자 주입 표면이 된다(`-C /etc/passwd` 형). 앵커는 스캔된
소스 경로 + **사람이 손으로 고치는 큐레이션 YAML** 에서 오므로 신뢰 경계 안쪽이 아니다.

확정 형태 — `verify-code-index.mjs:41` 이 이미 `execFileSync` 를 import 해 `:1484` 에서 쓰는 그 형태다:

```javascript
import { execFileSync } from "node:child_process";
try {
  execFileSync("git", ["ls-files", "--error-unmatch", anchorPath],
    { cwd: reposRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
} catch {
  errors.push(`앵커 파일이 git 추적 대상이 아니다: ${anchorPath}`);
}
```

배열 인자라 셸 파싱이 없고, `-` 로 시작하는 파일명도 리터럴 인자로 넘어간다. 문자열 결합·`shell:true`
금지를 검증기 주석에 못박는다.

**S-2 (사실 정정) — `DISEASE_PDF_HIDE_NAME_AGENCY_ID` 미설정 동작을 계획이 부정확하게 적었다**

계획 실측표와 Task 3 은 "NaN 은 어떤 `agcId` 와도 불일치 → 마스킹 미적용" 이라고 적었다.
**agcId 비교가 아니다.** 실물(`analyze.service.ts:1294-1298`):

```javascript
isB2c || !Number.isInteger(hideNameAgencyId) || hideNameAgencyId <= 0
  ? false
  : this.analyzeRepository.isOddInCorpScope(oddId, hideNameAgencyId)
```

미설정 → `Number(undefined)` = `NaN` → `Number.isInteger(NaN)` 이 `false` → **가드에서 단락되어
스코프 조회 자체를 안 한다.** 결론(마스킹 미적용 = fail-open)은 같지만 기전이 다르다.
`env-gates.yaml` 의 `unset_behavior` 는 정정된 기전으로 적고, 앵커를 **두 개** 단다 —
독출 `analyze.service.ts:1268` + 판정 `analyze.service.ts:1294`. 조건에 `<= 0` 도 있으므로
"정수이면서 0 초과일 때만 마스킹" 이 정확한 서술이다.

**S-3 (사실 보강) — `CAR_VIEW_ADMIN_IDS` 는 fail-open 이 아니라 fail-closed 다**

계획은 "빈 화이트리스트" 로만 적어 방향이 안 드러났다. 실물(`car-view-admin.guard.ts:29-37`)은
미설정 → 빈 `Set` → `allowed.has(...)` 가 false → **`admin` 역할이 거부된다.** 소스 주석(`:40`)이
"빈 allowlist는 admin에 대해 fail-closed" 로 명시한다. 그리고 같은 가드 `:27` 에서 `corp` 역할은
`return tokenOk` 로 **allowlist 를 우회**한다 — 게이트의 적용 범위가 역할마다 다르다.
`unset_behavior` 에 방향(fail-closed)과 `corp` 우회를 함께 적는다.

**판정 요약** (에이전트 결론 + 실물 확인):

| 질문 | 결론 |
|---|---|
| `.env` 4중 방어가 충분한가 | 충분. **4층 전부 load-bearing** — 장식이 아니다. 값이 산출물에 닿는 경로(`default_literal` · 큐레이션 · `--report` · throw 메시지 · `.tmp` 잔여) 전부 막힘. `writeDocs` 의 tmp 정리 경로(`build-code-index.mjs:1441`)가 마지막을 닫는다 |
| 인가 기본값을 커밋 파일에 적어도 되는가 | 된다. 네 게이트 전부 **이미 소스 주석에 적혀 있고** 인덱스는 그것을 한 곳에 모을 뿐이다. 새 노출면 없음 — 오히려 fail-open 을 grep 가능하게 만든다 |
| 도구 자체의 주입·traversal | `execFileSync` 배열 형태면 안전. S-1 로 계약 확정 |

- Deferred to backlog: 0
- Open Questions: 없음
- Codex session 참조: threadId `019fd9bb-ddb0-7242-9b6d-87fdaee6b03c`

## Santa Loop (dual reviewer escalation)

`codex_verdict: divergent` 로 `/mccp:pr` 가 막혀 있어 `/mccp:santa-loop` 로 올렸다.
리뷰 대상은 커밋 `916c831` 전체이며 rubric 8축(정확성 · 보안 · 오류 처리 · 완전성 · 내부 일관성 ·
회귀 · 결정성 · 자체검사 품질)에 PASS/FAIL 을 강제했다.

### 라운드 1

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 컨텍스트 격리) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`) | FAIL (critical 4) |

**둘 다 지목한 것: 없다.** 리뷰어 A 는 오류 처리 축에 "bare catch 없음" 이라고 적었는데
그게 틀렸다 — 아래 2건이 정확히 그것이다. 모델 다양성이 실제로 값을 냈다.

B 의 4건을 전부 실물로 재검증했다(주장 그대로 받지 않았다). **2건 실재 · 2건 기각.**

| B 의 지적 | 판정 | 근거 |
|---|---|---|
| `checkDotenvAndTracked` 의 `catch { ok = false }` 가 모든 git 실패를 "미추적" 으로 고쳐 읽는다 | **실재** | B 의 샌드박스에서 dubious-ownership(exit 128) 1건이 가짜 오류 95건이 됐다. exit 1(매치 없음)만 미추적이고 나머지는 검사 자체가 성립하지 않은 것이다 |
| `readExisting` 의 `catch { return [] }` 가 훼손된 기존 인덱스를 빈 것으로 만든다 | **실재** | 큐레이션 3필드가 조용히 사라지고, `existingEntries` 가 비므로 `:486` 의 "기존 id 가 사라졌다" 안전장치까지 같이 죽는다. 안전장치를 무력화하는 침묵이라 심각도가 단순 유실보다 높다 |
| 독립 재스캔 `DECL_RE` 가 다음 행 초기자를 못 잡아 3자 대조가 거짓이다 | **기각** | `=\s*([^;\n]+)` 의 `\s*` 가 개행을 넘는다. 실측: 같은 형태 입력에서 `CERTIFY_CLAIM_TTL_MS` 매치. 그리고 실물 3건이 `constants.yaml` 에 실려 있는 채로 3자 대조가 통과한다 — 못 잡았다면 거기서 FAIL 했다 |
| 첫 생성의 `unset_behavior: "unknown"` 을 검증기가 거부해 빈 디렉터리 빌드가 검증을 못 통과한다 | **기각** | 의도된 큐레이션 계약이고 M1·M2 의 `PRESERVE` 와 같은 모양이다(`verify-policy-values.mjs:401-402` 에 사유가 박혀 있다). 커밋된 파일 위 재생성은 `mergeCuration` 이 보존하며, 재생성 후 `git diff` 가 **0 파일**임을 확인했다 |

B 의 "자체검사 품질 FAIL"(다음 행 초기자 픽스처 없음)은 지적 자체는 기각됐지만 **커버리지
공백은 실재**했다 — 리뷰어가 소스만 읽고 같은 오판을 했다는 것이 그 증거다. 픽스처로 못박았다.
B 는 read-only 샌드박스에서 `mkdtemp` 가 `EPERM` 이라 selftest 를 못 돌렸고, 자체검사 축 판정은
그래서 소스 추론에 기대고 있다.

### 라운드 1 수정 (실재 2건 + 픽스처 3종)

1. `verify-policy-values.mjs` — `err.status === 1` 만 미추적. 그 밖은 stderr 를 실은 채 throw.
   CLI 는 `:756-762` 에서 이미 throw 를 잡아 메시지만 찍고 exit 1 한다.
2. `build-policy-values.mjs` — `readExisting` 이 파싱 실패와 `entries` 비배열을 throw 로 올린다.
3. 픽스처: 검증기 D14(git 실행 실패는 미추적으로 고쳐 읽지 않는다) · D15(독립 재스캔이 다음 행
   초기자를 잡는다 — 기각한 지적을 못박는다) · 생성기 D11(훼손된 기존 인덱스에 덮어쓰지 않는다).
   selftest 23+25 → **24+27 = 51 케이스**, 전부 통과.

검증 재확인: `verify-policy-values.mjs --repos-root ../..` 통과(entries=73 · scanned=1723 ·
rescan 117/470) · 재생성 후 `git diff docs/_meta/index/` **0 파일**.

### 라운드 2

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 2) |

또 겹치지 않았다. B 의 2건 **둘 다 실재**하고 실물로 확인했다.

**F1 — 게이트 축에는 상수 축이 가진 소스 대조가 없었다 (실재)**

상수 축은 `value_literal` 을 독립 재스캔이 소스에서 뜬 표기와 대조한다(`:450`). 게이트 축에는
그 대조가 없었고 `read_form` 은 `GATE_REQUIRED` 에도 없었다. `env-gates.yaml` 은 큐레이션 3필드
때문에 **손이 닿는 파일**이라, `default_literal` 을 다른 값으로 고치거나 `read_form` 을 지워도
검증이 통과했다. 두 필드는 `unset_behavior` 의 근거이므로 축의 존재 이유에 직결된다.

조치: `read_form` 을 필수 + 닫힌 어휘 3종으로 올리고, 독립 재스캔이 게이트마다 **운영 독출의
(형태, 리터럴) 집합**을 따로 모아 파일 값의 소속을 검사한다. 생성기 `readDefault` 가 `??` 와
`===` 를 두 정규식으로 나눠 보는 데 반해 이쪽은 교대분기 하나(`READ_AFTER_RE`)다 — 결론은 같고
구현은 다르다는 3자 대조 전제를 유지한다. 테스트 파일 제외 규칙(`\.(spec|test)\.tsx?$`)도
생성기와 맞췄다: 테스트에만 있는 `??` 가 운영 형태를 정당화하면 안 된다.

**F2 — findings 발견 23 의 `constants_matched_seed` 가 실측과 어긋났다 (실재)**

`18` 로 적혀 있었고 현행 `--report` 는 `21` 이다(`constants.yaml` 의 `matched_seed: true` 도 21).
"5패턴만 쓰면 항목이 18 로 줄고" 도 같은 수라 함께 정정했다. 논지(62 vs 21)는 그대로다.

픽스처 3종 추가 — D16(`read_form` 누락) · D17(어휘 밖) · D18(소스에 없는 `default_literal`).
검증기 selftest 27 → **30 케이스**. 생성기 24 유지. 전체 검증·재생성 결과는 라운드 1 과 동일하다
(entries=73 · `git diff docs/_meta/index/` 0 파일).

### 라운드 3

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 2) |

세 라운드 내내 겹친 지적이 **0건**이다. B 의 2건은 **라운드 2 에서 내가 넣은 검사 자체의 구멍**이고
둘 다 실재한다 — 새 검사를 넣으면 그 검사도 리뷰 대상이 된다는 것을 그대로 보여준다.

**F1 — 형태와 기본값을 따로 검사하면 소스에 없는 조합이 통과한다 (실재)**

라운드 2 는 `forms` 집합과 `literals` 집합을 따로 두고 각각 소속만 봤다. 그런데 둘은 **같은 독출
지점에서 함께** 나온다. 운영과 테스트에서 형태가 갈리는 게이트(`ADMIN_VIEW_TOKEN_TTL_MS` ·
`CAR_VIEW_ADMIN_IDS`)에서는 `read_form: bare` + 다른 독출의 기본값 같은 **실재하지 않는 조합**이
통과했다.

**F2 — `default_literal: null` 은 아예 검사를 빠져나갔다 (실재)**

`if (e.default_literal !== null)` 로 감싸 둔 탓에, `read_form: nullish-coalesce` 를 유지한 채
기본값만 `null` 로 지우면 무검사였다. 코드에 `?? 리터럴` 이 있는데 인덱스는 "기본값 없음" 이 되고,
그건 `unset_behavior` 서술을 정면으로 뒤집는다.

조치 — 두 구멍은 같은 하나다: **쌍으로 본다.** `pairs: Set<"form 리터럴">` 로 모으고
`(read_form, default_literal)` 조합의 소속을 검사한다. `null` 도 쌍의 일부라 F2 가 자동으로 닫힌다
(`===` 는 비교 대상이지 기본값이 아니므로 생성기 `readDefault` 와 같이 리터럴을 `null` 로 둔다).
픽스처 2종 — D19(`??` 인데 기본값만 null) · D20(형태·기본값을 다른 독출에서 섞은 조합).
검증기 selftest 30 → **32 케이스**, 생성기 24. 전체 검증 통과(entries=73).

### 수렴 상태

3라운드를 다 썼고 **NICE 에 도달하지 못했다**(A 는 3라운드 연속 PASS, B 는 3라운드 연속 FAIL).
라운드마다 B 가 새로 지목한 것은 전부 실재했고 전부 닫았지만, 라운드 3 의 수정은 **아직 어떤
리뷰어도 보지 않은 코드**다. santa-loop 규약대로 **push 하지 않는다.**

누적: 실재 6건 수정(침묵 2 · 게이트 축 소스 대조 2 · 문서 수치 1 · 쌍 검사 1) · 기각 2건 ·
픽스처 8종 추가(생성기 23→24 · 검증기 25→32).

## Santa Loop — 2차 (사용자 재실행, 카운터 리셋)

라운드 3 수정을 신규 리뷰어에 태우려 `/mccp:santa-loop` 를 다시 돌렸다.

### 라운드 4 (2차 R1)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 2) |

**실재 1 · 기각 1.**

**F1 — 이어지는 행을 `split(";")` 로 자르면 문자열 안의 `;` 에서 잘린다 (실재)**

`const X =` 다음 행이 `  'x-a; b';` 이면 `'x-a` 가 `value_literal` 이 된다. 초기자가 다음 행에서
시작하는 경로에만 있던 구멍이고, 같은 파일의 행 내 경로는 `DECL_LINE` 정규식이 `[^;\n]+` 로 처리해
같은 성질을 갖지만 이쪽은 명시적으로 잘라내고 있었다.

조치: **새로 쓰지 않는다.** M1 이 이미 `statementEnd` 를 export 하고 있고 따옴표·주석·괄호 깊이를
따라간다. import 해서 `lines[k].slice(0, statementEnd(lines[k]))` 로 바꿨다. 픽스처 D9c
(`RATE_LIMIT_HEADER = 'x-a; b'`) 추가 — 생성기 selftest 24 → **25**.
실측 산출물은 **바이트 동일**(재생성 후 `git diff docs/_meta/index/` 0 파일)이라 회귀 없음.

**F2 — "독립 재스캔 `DECL_RE` 가 다음 행 초기자를 못 잡는다" (기각 · 1차 R1 과 같은 주장)**

1차 라운드 1 에서 이미 실측 반증한 것과 **같은 지적**이다. `=\s*([^;\n]+)` 의 `\s*` 가 개행을
넘으므로 매치된다. 실측 재확인: `CERTIFY_CLAIM_TTL_MS` 3건이 `constants.yaml` 에 실린 채
`verify-policy-values.mjs --repos-root ../..` 가 통과한다(entries=73). B 는 자기 샌드박스에서
`git` safe-directory 때문에 검증기를 끝까지 못 돌렸고("After Git safe-directory is fixed…"),
그래서 소스 추론만으로 같은 오판을 두 번 했다. 다음 라운드 프롬프트에 이 사실을 **관측값으로**
적어 같은 라운드 소모를 막는다.

### 라운드 5 (2차 R2)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 2, 사실상 1축) |

**실재 1건 — 코드가 아니라 코드가 하는 말이 틀렸다.**

**F1 — `AUTH_TIMEOUT` 을 "싣는다" 고 세 곳에 적었는데 실물은 없다**

`constants.yaml` 헤더 주석 · `gap` · findings 발견 24 가 전부 "버리지 않고 `kind: non_numeric`
으로 싣는다" 로 적혀 있었다. 실측: `constants.yaml` 에 `AUTH_TIMEOUT` 항목 **0건**. 이유는
분명하다 — 실물이 `TreatErrorCategory` **enum 멤버**(`treat-error-category.ts:12`)이고 이 축은
불변 **선언** 4형태만 담는다. enum 멤버는 선언 키워드가 없어 `DECL_LINE`·`DECL_RE` 어느 쪽에도
안 잡힌다. 선택지 둘 중 **말을 고쳤다**:

- 스캐너를 enum 까지 넓히면 3저장소의 모든 enum 이 들어와 "정책 수치" 라는 축 정의가 무너진다.
  `TreatErrorCategory` 하나만 20여 멤버다.
- `kind` 규칙 자체는 실재한다 — 비수치 항목 5건(`CERTIFY_*_TTL_MS` 등, 값이 `Number(...)` 식)이
  실제로 `non_numeric` 으로 실려 있고 selftest D6·D10 이 그 경로를 못박는다. 틀린 것은 **예시**뿐이다.

조치: 세 곳을 "이 축에 없다 + 왜 없다 + 어디서 결정할 문제다" 로 정정하고, 실측 근거를 붙였다
(`policy-anchors.yaml`·`codes/*.yaml` 전부 0건 — **어느 축도 담고 있지 않다**). findings §2 에
"enum 멤버의 축 소속 규약" 개정 요청을 신설했다. 생성기 문자열이라 재생성으로 산출물이 바뀌었고
(주석·`gap` 11행), 2회 연속 재생성이 바이트 동일함을 확인했다. 검증 통과(entries=73) ·
selftest 25+32 유지.

**F2 — 같은 축의 파생 주장(검증기가 그 누락을 못 잡는다)**: F1 과 한 사안이라 함께 닫혔다.
축 정의상 항목이 아닌 것을 "누락" 으로 잡을 검사는 존재할 수 없다.

### 라운드 6 (2차 R3)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 1) |

B 는 8축 중 **7축 PASS**, 남은 1건은 자체검사 커버리지다.

**F1 — D10 이 원본 불변만 보고 `.tmp` 잔여를 안 본다 (실재, 3행)**

원자적 쓰기 계약은 "부분 갱신도 `.tmp` 찌꺼기도 없다" 인데 D10 은 앞쪽 절반만 검사했다.
`writeDocs` 가 원본은 되돌리면서 스테이징 `.tmp` 를 흘리는 회귀가 생기면 통과한다.
`existsSync(`${f}.tmp`)` 검사를 D10 에 붙였다. (M1 은 자기 산출물에 대해 같은 검사를 이미
갖고 있다 — 이 마일스톤 두 파일에 대해서는 없었다.)

**심각도 추세**: 침묵/인가 표면(1차 R1·R2) → 검사 자체의 구멍(1차 R3) → 파서 경계(2차 R1) →
문서-코드 불일치(2차 R2) → 픽스처 단언 1개(2차 R3). 라운드마다 실재 건수 1~2건으로 유지되지만
**심각도는 단조 감소**했다. B 가 무한히 새 결함을 만들어내는 것이 아니라 표면이 실제로 얕아지고 있다.

### 2차 수렴 상태

3라운드를 다 썼고 NICE 에 도달하지 못했다 — A 는 6라운드 연속 PASS, B 는 6라운드 연속 FAIL.
santa-loop 규약대로 **push 하지 않는다.** 라운드 6 수정(`.tmp` 단언 3행)은 아직 어떤 리뷰어도
보지 않았다.

**6라운드 누계**

| | 건수 |
|---|---|
| B 가 낸 critical | 11 |
| 실재로 확인해 수정 | 8 |
| 실물 대조로 기각 | 3 (그중 1건은 **같은 주장 2회** — 1차 R1 · 2차 R1) |
| A 가 낸 critical | 0 (6라운드 전부 PASS) |
| 두 리뷰어가 함께 지목 | **0** |
| 픽스처 증가 | 생성기 23→25 · 검증기 25→32 |

**축별 수정**: 침묵 2(git 오류 오분류 · 훼손 인덱스 삼킴) · 게이트 축 소스 대조 3(`read_form`
필수화 + 어휘 · 쌍 검사 · `null` 무검사) · 파서 경계 1(`statementEnd` 재사용) ·
문서-코드 불일치 2(`matched_seed` 수치 · `AUTH_TIMEOUT` 소속) · 자체검사 커버리지 1(`.tmp` 잔여).

**A 가 6라운드 내내 0건인 것 자체가 관측이다 (아래 3차에서도 이어진다).** 실재 8건 중 A 가 잡은 것은 없고, 오히려 1차 R1
에서 A 는 "bare catch 없음" 이라고 적었는데 그 시점에 침묵 2건이 있었다. 모델 다양성이 이 코드에
대해서는 컨텍스트 격리보다 크게 작동했다.

## Santa Loop — 3차 (사용자 재실행)

### 라운드 7 (3차 R1)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 1) |

**7라운드 중 가장 값비싼 지적이다 — 3자 대조의 핵심 보장이 비어 있었다.**

**F1 — 생성기와 검증기가 **같은 자리**에서 잘려 두 다리가 함께 줄어든다 (실재)**

초기자가 셋째 행까지 이어지면:

```ts
const MULTI_TTL_MS =
  Number(
    process.env.OMEGA_GATE ?? 170_000
  ) || 170_000;
```

- 생성기: 다음 행 **하나만** 읽어 `Number(` 를 값으로 삼았다.
- 검증기: `DECL_RE` 의 `([^;\n]+)` 가 개행에서 멈춰 똑같이 `Number(` 였다.

둘이 같은 답을 내므로 3자 대조가 **통과한다**. D1(검증기가 생성기 스캐너를 import 하지 않는다)이
막으려던 바로 그 실패 모양이 구현 차이에도 불구하고 남아 있었다 — 두 구현이 우연히 같은 경계
(첫 개행)를 쓰고 있었기 때문이다. 실물 산출물에는 이 형태가 없어(불균형 리터럴 0건) 증상은
없었지만, **보장 자체가 비어 있는 것**은 증상 유무와 별개다.

조치 — 양쪽 다 문장 끝까지 읽되 **기전을 다르게** 유지했다:

- 생성기: 문장이 닫힐 때까지 행을 이어 붙이고 M1 `statementEnd`(괄호 깊이·따옴표 추적)로 끊는다.
  12행 안에 안 닫히면 조각을 값으로 삼지 않고 `unterminated_excluded` 에 센다(현재 0).
- 검증기: 선언 머리까지만 정규식으로 잡고, 값의 끝은 **죽은 구간 밖의 첫 `;`** 로 따로 정한다
  (`deadSpans` 재사용, 800자 상한). 문자열 안 `;` 은 죽은 구간이라 건너뛴다.
- 표기 규칙 `collapseInitializer`(공백 접기)는 `isPolicyValueDecl` 과 같이 **한 벌만** 두고
  생성기가 import 한다. 규칙이 두 벌이면 표기 차이를 데이터 불일치로 오인해 상시 FAIL 한다.

픽스처 2종 — 생성기 D9d(셋째 행까지 온전히 수집) · 검증기 D21. **D21 은 "재스캔에 없다" 로는
판별이 안 된다**: 잘린 `Number(` 도 시드 이름이라 항목으로 남기 때문이다. 그래서 리터럴 대조
오류 메시지의 `소스 '…'` 가 온전한 표기인지로 못박았다. selftest 25+32 → **26+33**.

부수 정정: `gap` 산문이 "여러 줄로 걸친 초기자는 포함되지 않는다" 로 남아 있어 함께 고쳤다
(라운드 5 와 같은 문서-코드 불일치를 새로 만들 뻔했다). enum 멤버·클래스 필드로 목록을 정확히 했다.

검증: `verify-policy-values.mjs --repos-root ../..` 통과(entries=73 · scanned=1723 ·
rescan 117/**439**). 재스캔 선언 수가 470 → 439 로 줄었는데, 종결 `;` 를 못 찾는 매치를 더 이상
선언으로 세지 않기 때문이다(엄격해진 것). 산출물은 `gap` 3행만 바뀌었고 재생성 2회 바이트 동일.

### 라운드 8 (3차 R2)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | PASS (critical 0) |
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 1) |

> A 가 낸 "critical" 1건은 코드 결함이 아니라 "이 수정이 아직 커밋 안 됐다" 였다 — 편집 중인
> 워킹 트리를 본 것이다. 결함 0건으로 센다.

**F1 — 템플릿 보간이 두 스캐너에서 반대로 읽힌다 (실재, 양방향)**

라운드 7 과 같은 축(두 스캐너의 경계 불일치)인데 이번엔 **문자열 경계**다. 실측:

| 소스 | 검증기 | 생성기 | 옳은 판정 |
|---|---|---|---|
| `` `x ${process.env.OMEGA2_GATE} y` `` | DEAD (못 봄) | 봄 | **독출이다** — 보간 안은 코드다 |
| `"process.env.PHANTOM_GATE"` | DEAD (안 셈) | **셈** | **독출이 아니다** — 문자열 안 글자다 |

`deadSpans` 가 백틱을 평문 따옴표와 똑같이 다뤄 템플릿 전체를 죽은 구간으로 덮었고,
`blankCommentsSafe` 는 값 리터럴이 필요해 문자열 내용을 **보존**하므로 그 안의 글자까지 독출로
셌다. 어느 쪽이든 3자 대조가 **거짓 FAIL** 한다(실물에는 해당 게이트가 없어 증상은 없었다).

조치 — 판정은 맞추고 기전은 따로 둔다:

- 검증기 `deadSpans`: 백틱을 따로 처리해 문자열 텍스트만 죽은 구간으로 밀고 `${...}` 안은 살린다
  (보간 중첩 `{` 깊이 추적).
- 생성기 `inPlainString(line, idx)`: 행 단위 주사로 평문 문자열 안인지 보고, 보간 안은 코드로 본다.
  `process.env` 매치와 선언 매치 **두 지점**에 건다 — 문자열 안에 적힌 선언 모양
  (`"const MAX_X = 1;"`)도 검증기는 죽은 구간으로 거른다.

픽스처 4종 — 생성기 D3b(보간은 독출) · D3c(평문 문자열은 아님) · 검증기 D22 · D22b(같은 두 방향).
selftest 26+33 → **28+35**.

**부수 수정 — selftest 가 실패해도 `selftest PASS` 를 찍고 있었다**

`fail()` 이 `process.exitCode = 1` 만 세우고 마지막 줄은 무조건 PASS 를 출력했다. 종료 코드로는
실패인데 사람이 읽는 줄은 PASS 다 — 이 마일스톤이 계속 닫아 온 "침묵" 과 같은 부류라 두 파일 다
고쳤다(실패 시 `selftest FAILED (통과 N건)`). 실제로 이 라운드에서 D3b/D9b 가 깨졌을 때 PASS 로
찍혀 하마터면 넘어갈 뻔했다.

검증: 통과(entries=73 · scanned=1723 · rescan **118**/439 — 보간 수정으로 게이트 1건 더 보인다).
산출물 바이트 동일(`git diff docs/_meta/index/` 0 파일).

### 라운드 9 (3차 R3)

| 리뷰어 | 모델 | verdict |
|---|---|---|
| B | gpt-5.4 (`codex exec --sandbox read-only`, 신규 세션) | FAIL (critical 1) |

**F1 — 라운드 8 에서 내가 `ponytail:` 로 남긴 천장을 정확히 짚었다 (실재)**

라운드 8 의 `deadSpans` 백틱 처리에 이렇게 적어 뒀었다: *"보간 안의 따옴표는 안 따라간다.
그 안의 `}` 가 있으면 빨리 닫힌다."* B 가 그 입력을 만들어 왔다:

```ts
const M = `a ${'}' + process.env.ONE} z`;
```

문자열 `'}'` 의 `}` 를 보간 종료로 읽어 그 뒤 `process.env.ONE` 을 템플릿 텍스트로 삼킨다.
생성기 `inPlainString` 은 보간 안에서도 따옴표를 따라가므로 **두 스캐너가 갈라진다** — 라운드 7·8
과 같은 축이다. 표시해 둔 천장이라도 상대는 그것을 결함으로 가져온다는 것이 이 라운드의 교훈이다.

조치: `deadSpans` 의 백틱 처리를 `scanTemplate` ↔ `scanInterp` **상호 재귀**로 바꿨다. 보간 안의
`'`·`"` 는 문자열로 건너뛰고, 중첩 템플릿은 `scanTemplate` 으로 다시 들어간다. 4케이스 교차 실측
전부 AGREE(따옴표 `}` · 중첩 템플릿 · 평문 문자열 · 단순 보간). 남은 천장은 **보간 안의 주석**
하나로 좁혔고 주석으로 명시했다.

픽스처 2종 — 생성기 D3d · 검증기 D22c. selftest 28+35 → **29+36**.

검증: 통과(entries=73 · rescan 118/439) · 산출물 바이트 동일.

**라운드 9 리뷰어 A — 9라운드 만의 첫 실재 결함 (FAIL)**

| 리뷰어 | 모델 | verdict |
|---|---|---|
| A | Claude Opus (`code-reviewer`, 신규 컨텍스트) | **FAIL (critical 1)** |

**F2 — 라운드 4 수정이 만든 비대칭. 같은 행 초기자는 여전히 정규식으로 잘랐다**

라운드 4 에서 **다음 행** 경로를 `statementEnd` 로 바꿨는데 **같은 행** 경로(`DECL_LINE` 의
`([^;\n]+)`)는 그대로 뒀다. 그래서:

```ts
const RATE_LIMIT_HTTP = 'Cache-Control: max-age=60;x';
```

생성기는 `'Cache-Control: max-age=60` 로 자르고, 검증기는 죽은 구간 밖 첫 `;` 를 찾으므로
문자열 안 `;` 을 건너뛰어 온전한 리터럴을 얻는다 → 3자 대조 FAIL. 시드 이름(`RATE_LIMIT_*`)이면
잘린 값이 그대로 인덱스에 들어간다.

D9c 픽스처가 **다음 행** 경로의 같은 성질만 덮고 있었다는 것이 A 의 실제 지적이다 — 내가 라운드 4
에서 픽스처를 한쪽에만 달았다.

조치: 같은 행 경로도 `statementEnd` 로 끊는다(종결이 없으면 기존대로 행 끝까지 — 다중행 객체
리터럴 등의 기존 동작 보존). 픽스처 D9e 추가. 생성기 selftest 29 → **30**.
실측 산출물 **바이트 동일**(`git diff docs/_meta/index/` 0 파일) · verify 통과.

**이 라운드의 의미**: A 가 9라운드 만에 처음으로 실재 결함을 냈고, 그것이 **B 가 만들어 낸 수정의
잔여물**이었다. 두 리뷰어가 서로의 사각을 덮은 유일한 사례다.
