# Plan Review Panel — ops-doc-reconcile-intake-m2

**Plan**: `.claude/plans/ops-doc-reconcile-intake-m2.plan.md` · **Plan version**: `sha256:92c2186fcba9703b0b071c44b678ebcfe76c6cf91cd2be9720faf508dbfbc315`
**Verdict**: `divergent` via `hybrid`
**Quorum**: 4/3 responses · 4 distinct roles (of 4 fielded) · passed=true
**Layers**: L1 converged · L2 converged · L3 divergent
**Halted at**: `5.2e`

> Reason: L1+L2 converged but L3 (Codex) returned divergent

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | MEDIUM | Task 2 의 「`probe_row_sha_unverified` 앵커가 `row_sha` 를 PII 제외 경로에 넣는 것의 우회를 닫는다」는 주장이, 실제 제외 코드 경로와 맞지 않는다. `piiScan` 의 digestPath 제외 분기는 검증 결과(probe 색인 멤버십)를 **전혀 참조하지 않는다** — 경로 이름 + `SHA_RE` 형태만 본다. 앵커는 계획 자신이 「토큰을 새로 만들지 않고 정수만 낸다」고 못박은 soft counter 라 런타임에 raise 하지 않는다. 즉 불변식을 담는다고 이름 붙인 추상이 그 불변식을 런타임에 담지 않고, 강제는 마일스톤 종료 시 1회 도는 게이트 `#3` 으로 옮겨가 있다. (이미 `probe_sha` 한 필드에 같은 성질이 있다는 선례가 있어 신규 구멍은 아니고, 제외 표면이 1필드 → 2필드로 넓어지는 것이 이번 변화다.) | ops/reconcile/reconcile.mjs:415 `const isDigestField = digestPaths.has(norm(pathStr)) && SHA_RE.test(node);` — `resolvedShas` 를 안 본다. 대조적으로 자유텍스트 분기만 :428 `if (!resolvedShas.has(sp.sha)) continue;` 로 멤버십을 본다. 계획 Task 2: 「두 앵커는 기존 :728 과 같은 soft counter 다 … 토큰을 새로 만들지 않고 정수만 낸다(D5). 게이트 `#3` 이 그 정수를 0 으로 문다 … 이 앵커가 없으면 `row_sha` 를 제외 경로에 넣는 것이 PII 우회를 다시 연다」 |
| architect | MEDIUM | `row_sha` 재계산 검증의 **배치 순서**가 계획에 고정돼 있지 않은데, 이 파일은 그 순서가 계약이라고 명시하고 이미 두 번 틀렸던 자리다. Files to Change 는 reconcile.mjs 변경을 「`buildTargets` probe 적재 · probe 색인 검증 키 · `PROBE_DIGEST_PATHS` …」로 한 줄에 묶어 적는데, `buildTargets` 는 :795 에서 호출되고 `piiScan`(:767) 과 `settle()`(:791) 보다 **뒤**다. `row_sha` 검증을 `buildTargets` 안에 두면 확대된 `PROBE_DIGEST_PATHS` 제외가 검증 전에 적용되고 그 라운드의 settle 은 이미 끝나 있다. 검증은 반드시 :730 루프(= piiScan 앞)에 있어야 하는데 계획 본문에 그 제약이 없다. | ops/reconcile/reconcile.mjs:729-741(probe 검증 루프) · :765-767(`PROBE_DIGEST_PATHS` 와 `piiScan` 호출) · :791 `settle();` · :795 `const targets = buildTargets(root);`. 같은 파일 :743-756 「이 스캔의 자리는 문체가 아니라 계약이다 … 이 자리를 두 번 옮겼고 두 번 다 실측이 옮기게 했다」 |
| security | MEDIUM | 게이트 #9 가 `.claude/reviews/**` 를 자격·절대경로 스캔에서 통째로 제외하는데, 그 디렉터리는 gitignore 대상이 아니라 커밋되는 durable 산출물이다. 이 저장소에는 이미 같은 형태의 절대경로 유출 선례가 있고, 실측으로 기존 리뷰 파일 3건에 `/home/madsc/...` 가 박혀 있다. 이번 M2 가 만드는 두 리뷰 파일(`plan-review-ops-doc-reconcile-intake.md` · `-m2.md`)도 같은 경로를 실을 수 있고, 계획이 넓힌 유일한 스캔이 그 자리에서 이름으로 꺼진다. | gates.sh:638 `SECRET_EXCL_RE='^\\.claude/reviews/\|^\\.claude/plans/codex-findings-backlog\\.md$'` · gates.sh:643 `grep -vE "$SECRET_EXCL_RE" ... > "$W/s9base.txt"`. `.gitignore` 는 `.claude/state/`·`.claude/tmp/` 만 덮고 `.claude/reviews/` 는 없다(.gitignore:2-37). 실측: `/home/madsc` 가 `.claude/reviews/plan-review-ai-insight-report-m1.md`(1) · `plan-review-insuniverse-docs-60-history-deprecated-m9.md`(1) · `dual-review-insuniverse-docs-50-domain-m12-closure.md`(2) 에 이미 있다. 계획서 Acceptance 게이트 `#9` 줄은 이 제외를 `secret_scan_excluded` 계수로만 공시하고 잔여 유출 경로를 인수한 backlog 항목이 없다. |
| security | MEDIUM | Task 2 가 `PROBE_DIGEST_PATHS` 를 `{row_sha, provenance.sha}` 로 넓히면서 「검증 실패 행은 색인에 안 들어가 PII 제외도 안 걸리는 fail-closed」라고 적었으나, `piiScan` 의 digest 제외 분기는 색인 멤버십을 보지 않는다 — 경로 + `SHA_RE` 형태만 본다. 새 앵커(`probe_rows_unverified`·`probe_row_sha_unverified`)는 soft counter 라 `--check` 는 `verdict ok` 로 끝난다. 그래서 M2 완주 뒤 상시로 남는 것은 「주민번호/전화 자리수를 품은 64헥사를 `row_sha` 나 `provenance.sha` 에 적으면 조용히 제외된다」이고, 제외 경로가 1개에서 2개(그중 하나는 중첩 경로)로 늘어난다. 게이트 `#3` 이 두 계수를 0 으로 무는 것은 M2 1회 실행뿐이다. | ops/reconcile/reconcile.mjs:415 `const isDigestField = digestPaths.has(norm(pathStr)) && SHA_RE.test(node);` — `resolvedShas` 는 :428 자유텍스트 분기에서만 쓰인다. `:1153 emit("pii_excluded", …)` · `:1154 emit("probe_rows_unverified", …)` 는 raise 가 아니다(:725-728 주석이 「색인에 안 넣는다」를 fail-closed 근거로 들지만 digest 제외 분기에는 적용되지 않는다). 계획서 Task 2: 「검증 실패 행을 색인에 안 넣어 … PII 제외도 안 걸리는 fail-closed 이되」. |
| security | LOW | 게이트 #9 의 내용 스캔 범위가 `ops/`·`.claude/` pathspec 으로 한정되고, 파일명 검사는 `.env` 계열 이름만 문다. Task 7 이 만드는 자격 파일을 저장소 루트에 `.env` 가 아닌 이름(예: `inpas-creds`, `probe-creds.txt`)으로 두면 내용 스캔에도 이름 스캔에도 안 걸린다. | gates.sh:639-640 `git diff … -- ops/ .claude/` · `git status --porcelain -- ops/ .claude/` · gates.sh:719 `cnt -E '(^\|/)(\\.env([.].*)?\|[^/]*\\.env\|env)$'`. 계획 Task 7 은 「자격을 저장소 밖 env 파일로 받아」라고만 적고 위치·이름을 게이트가 강제하지 않는다. |
| test | MEDIUM | D5 의 「새 토큰을 만들지 않는다」 주장을 검사하는 것은 토큰 **집합 크기**뿐이고 이름 집합은 어떤 게이트도 안 본다. 기존 토큰 하나를 지우고 probe 토큰 하나를 더하면 21 이 유지돼 게이트가 초록인 채 주장이 거짓이 된다. 이름을 못박는 유일한 자리(`m0.sh` TOKEN_SET_SIZE)는 착수 전부터 붉고(G14) 이 계획의 어떤 게이트도 하네스를 실행하지 않는다. | .claude/plans/ops-doc-reconcile-intake-m2.gates.sh:773-775 `TOK11=$(node "$RC" --list-tokens \| cnt -E '^check ')` … `[ "$TOK11" = "21" ]` — 계수만 본다. 계획서 Risks: 「**토큰 집합 크기 21 고정**으로 문다(게이트 `#11`) — D5 의 "새 토큰 없음" 주장의 검사다」(plan.md:259) |
| test | MEDIUM | Task 10 은 실행 가능한 반사실 8건(`m2.sh` 5 · `m5.sh` 3, 그중 `pii-forged-probe-digest` 는 PII 제외 우회라는 과대허용 방향의 유일한 하네스 검사)을 편집하는데, 그 Validate·Gate 는 `row_sha\|provenance` 를 포함한 **줄 수 하한**과 케이스 이름 문자열 존재뿐이다. 반사실 본문을 주석으로 바꾸거나 단언을 지워도 하한이 충족돼 통과한다 — 「그 Task 가 바꾼 것을 실제로 돌리는 명령」이 Validate 에 없다. | gates.sh:190-203 `H2=$(acnt -E 'row_sha\|provenance' < ops/reconcile/_harness/m2.sh)` … `[ "$H2" -ge 5 ]`, 같은 자리 주석이 자인한다: 「**이 하한은 줄 수를 센다** — 주석으로도 채울 수 있다 … 게이트는 하네스를 실행하지 않으므로 … 갱신의 **정확성**은 여전히 사람 리뷰 몫이다」. plan.md:182 Task 10 Validate 는 `#1` 의 `probe_sha_literals 0` 하나다 |
| test | LOW | Task 9 의 Mirror 를 따르면 게이트 `#12` 가 통과할 수 없다. findings 정본의 절 표기는 `## F-INTAKE-NN · …` 인데 게이트는 `^## M2 ` 를 정확히 1건 요구한다 — Mirror 와 Gate 가 서로 다른 형식을 지시한다. | ops/findings/ops-doc-reconcile-intake.md:6 `## F-INTAKE-01 · …`(08까지 동일 관례) 대 gates.sh:825 `F12=$(cnt -E '^## M2 ' < "$FINDINGS")` · :829 `[ "$F12" = "1" ]`. plan.md:175 Task 9 Mirror: 「이 PRD 의 M1 이 `ops/findings/ops-doc-reconcile-intake.md` 를 쓴 절 구성」 |
| test | LOW | 검사 `#2` 는 공허 통과 하한이 없다. 재조회로 217행 전건의 `cols` 가 움직이면 `probe_content_unchanged 0 · legacy_drift 0` 으로 초록이고, Acceptance 가 적은 「내용이 안 바뀐 행의 인용 digest 가 착수본 값과 같다」는 아무 행에도 적용되지 않는다. 같은 파일의 `#5`(`NO_CITED_ROWS`) · `#7`(`LOCALITY_PROBE_VACUOUS`) · `#8`(`bound_expected != 0`) 은 전부 그 하한을 두는데 여기만 없다. | gates.sh:233 `[ "$(kv "$W/d2.txt" legacy_drift)" = "0" ] \|\| fail …` 뒤에 `probe_content_unchanged` 에 대한 단언이 없다. plan.md:279 Acceptance: 「재조회로 내용이 안 바뀐 probe 행의 인용 digest 가 착수본 값과 같다 (`legacy_drift 0`) — 게이트 `#2`」 |
| invariant | MEDIUM | 게이트 #3 의 `target_rows_dropped` 기대값이 한 축에서만 유도되는데, 그 계수는 buildTargets 전 축의 합산이다 — 보상 오차가 통과한다 | `ops/reconcile/reconcile.mjs:554` `let dropped = 0;` 이 `:577`(db) · `:583`(git) · `:593`(runtime) 에서 공통으로 증가하고 `:1169` `emit("target_rows_dropped", targets.dropped)` 로 하나의 정수가 된다. 반면 `.claude/plans/ops-doc-reconcile-intake-m2.gates.sh:272-279` 의 `DRX3` 는 `git show "$BASE:ops/db/rows.jsonl"` 에서 `row_sha` 없는 행만 세어 기대값을 만들고 `[ "$DR3" = "$DRX3" ]` 로 문다. Task 2 가 probe 축을 같은 `buildTargets` 에 넣으면 네 번째 기여자가 같은 계수에 든다 — db 축 1행이 새로 탈락하고 probe 축 1행이 덜 탈락하면 등식이 그대로 성립한다. 기대값 주석(`:269` 「기대값을 리터럴로 안 박고 착수본에서 유도한다」)은 이 축 혼합을 말하지 않는다 |
| invariant | LOW | 게이트 #1 의 투영 오라클이 투영 버전을 리터럴 `2` 로 박아, 코드가 쓰는 `CURRENT_PROJECTION_VERSION` 과 갈려도 초록이 유지된다 | gates.sh:122 `const proj = m.hashOf(m.projectTarget(r, 2, "db", "db").value, r.id);` 대 `ops/reconcile/reconcile.mjs:600` `projectTarget(t.rec, CURRENT_PROJECTION_VERSION, t.kind, t.axis)`. 계획 D3 는 「`row_sha` 축은 독립이 아니다 … 그 축에서 독립 오라클 역할을 하는 것은 G8·G10 의 착수 실측」이라고 스스로 적어, 이 축의 유일한 결박이 정지한 리터럴임을 인정한다. D1·NOT Building 이 버전 bump 를 금지하고 #3 의 `verdict ok` 가 MIGRATION_BACKLOG 을 받으므로 즉시 열리는 문은 아니다 |
| invariant | LOW | 재조회 산출이 잘못됐을 때의 롤백 경로가 없고, 게이트가 롤백을 구조적으로 거부한다 | gates.sh:759 `[ "$T10NOW" \\> "$T10BASE" ] \|\| fail "LOCK_TAKEN_AT_NOT_ADVANCED"` — `probe.lock` 의 `taken_at` 이 착수본보다 **엄격히** 뒤여야 한다. Task 7 의 운영 DB 재조회 산출을 착수본으로 되돌리는 것(가장 자연스러운 롤백)은 이 검사를 영구히 통과 불가로 만들고, 계획서에는 Rollback 절도 되돌림 절차도 없다(§Risks 표에 운영 DB 항목 2건이 있으나 둘 다 예방책만 적는다). 방향은 닫힌 쪽(HALT)이라 승인 우회는 아니다 |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | pass | 인용 전수 확인: reconcile.mjs:551(`buildTargets` load/put — 일치) · :978(원장 `rec.target` 경로에만 도는 것 맞음, D2 의 「앵커가 아니다」 주장 참) · :1009/:1026(dep 이 저장 `row_sha` 를 안 읽고 재계산하는 것 맞음) · :495-503(dep_targets 스키마 — `unresolvable` 금지·빈 배열 금지 맞음, G11 의 인용 74행 unresolvable 0 과 모순 없음) · :1989(`locality-target-db-mutated` + `resealDb` 형태 맞음) · :2312(`localityAxis` 시그니처 맞음) · db-probe.mjs:144(`sealRow` 자기 sha 제외 맞음). D3 의 「인용 digest 계산 입력 불변」을 sealRow 실제 입력과 대조해 성립함을 확인했고, 게이트 `#1` 의 legacy 재계산(gates.sh:124-127)이 그 입력과 정확히 같음도 확인했다. 게이트 `#0` 의 EXEMPT 12항목을 Files to Change 의 `ops/` 12행과 1:1 대조해 누락 0. D1 의 「투영 버전 2 유지」를 `buildTargets` 의 unknown-key 루프(:599-602)와 G8 실측에 걸어 반박 시도했으나 probe 키 집합이 선언 안에 들어가 성립. `target_rows_dropped 22` 불변을 db 로더의 `isStr(r.row_sha)` 탈락 경로(:577)와 대조해 모순 없음. `put` 선착순(:563-566)으로 probe/rows id 충돌 축을 쳤으나 G8 0건. 깨진 것은 위 두 자리 — 둘 다 「추상이 담는다고 주장한 불변식을 실제로는 다른 층이 담는다」 유형이고, HIGH 는 아니다: PII 제외의 성질은 `probe_sha` 에 이미 존재하는 선례라 계획이 신설한 구멍이 아니다. |
| security | pass | 1) R 집합 위조 경로 — 착수본 원장 blob(`git show $BASE:...`)에서 유도하고 `#6`·`#10` 이 같은 `$W/rset.txt` 를 읽으므로 정의 표류·부분상태 신뢰를 못 찾았다. probe 파일 공동 저작 위조는 NOT Building 이 공시하고 게이트가 막는다고 주장하지 않는다 — 정당한 잔여로 인정. 2) `dep_targets` 승격의 특권 상승 — `#5` 가 `unbound_citations`·`dep_sha_stale`·`dep_dangling` 을 0 으로 물고 `#6` 이 R 밖 `verdict/basis/by` 동결, `nonprobe_dep_lost 0` 으로 기존 결박 소실까지 덮는다. 약한 옛 필드로의 폴백 경로 못 찾음. 3) PII 제외 우회 — `piiScan`(:415) 을 직접 읽어 제외가 경로+형태뿐임을 확인, Task 2 의 fail-closed 문면이 그 코드와 어긋남을 실측(위 MEDIUM). M2 게이트 시점에는 `#3` 이 두 계수를 0 으로 막아 즉시 소비되는 결과는 없음. 4) 절대경로·머신명 유출 — `#9` 의 `SECRET_RE` 가 `/home/[a-z]`·`/Users/[A-Za-z]` 를 물고 범위를 PRD·계획서·게이트 본문까지 넓혔으나 `.claude/reviews/**` 제외가 커밋되는 산출물을 열어 둔 것을 실측으로 확인(위 MEDIUM). notes 선후 문제는 이미 Acceptance 와 backlog 가 인수 — 새 finding 아님. 5) traversal/경로 정규화 — `#0`·`#1`·`#9` 의 rename 정규화(`s/^.* -> //`)·따옴표 제거·`[ -f ]` 가드를 훑었고 `..`·절대경로가 스캔 목록에 들어올 경로를 못 만들었다. 6) `cnt()` fail-open(바이너리·rc2) 은 이미 반사실로 닫혀 있었고 `-a` 강제로 건너뜀 경로가 없다 — 재현 시도 실패. |
| test | pass | 게이트 본문 842행 전체와 소비 코드를 대조했다. 실측 확인한 것: `hashOf`·`projectTarget` 이 실제로 export 돼 있어 `#1` 의 import 재계산이 성립(reconcile.mjs:118,308) · `DB_TOP` 이 `provenance`·`row_sha` 를 IGNORED 로 두어 G8/D1 의 「선언 밖 키 0」 주장이 참(reconcile.mjs:209-213) · probe 행 id 가 실제로 `probe:` 접두라 `#5`·`#8` 의 `indexOf(\\"probe:\\")` 필터가 공허하지 않음(db-probe.jsonl:1) · `#4` 가 무는 머리말 4조각이 db-probe.mjs:12-16 문면과 정확히 일치 · `--fixture-tree/--case` 가 실재하고 트리 경로를 stdout 으로 냄(reconcile.mjs:2064-2096,2364) · `#12` 의 PRD 정규식이 실제 행 형상과 맞음(prd:96) · `#1` 리터럴 스캔이 데드락이 아님(`probe_sha` 보유 5파일 전건이 EXEMPT 안, `.claude/plans/*.gates.sh` 2건은 제외 집합) · `#9` 의 `SECRET_RE`/`IPV4_RE` 가 계획서·게이트 자기 본문에 자기적중하지 않음(`127.0.0.1` 은 loopback 필터가 먹는다). 통과 불가 게이트(층위 5)와 `#5`·`#6`·`#10` 의 R 집합 유도에서 과대허용 방향을 찾으려 했으나 결박 누락·소급·행 증감은 전부 fail-closed 였다. 남긴 4건 중 HIGH 는 없어 pass 로 낸다. |
| invariant | pass | 1) fail-open drift: gates.sh 전 검사의 `kv()` 부재 경로를 추적 — 키가 없으면 빈 문자열이 되어 `[ "" = "0" ]` 이 전건 실패로 떨어진다(#3 `PU3`/`PRU3`, #4 `unclassified`, #5·#6·#10 전건). 통과로 접히는 자리 없음. `$(( ))` 에 빈 값이 드는 #2 `SUM2` 도 산술 오류 → set -e 로 죽는다. 2) `cnt()` 래퍼: rc 2 되돌림·`-a` 강제·`${out:-0}` 경로를 읽고 바이너리/NUL 사각이 실제로 닫혔는지 확인. #9 의 IPv4 파이프 3단은 rc≤1 만 삼키고 rc≥2 는 `exit "$r"` + pipefail 로 `IPV4_SCAN_ERROR` 에 닿는다 — 앞 판의 `\|\| true` fail-open 이 실제로 제거됐다. 3) 게이트 기계 자체의 실패: `verify-ops.sh` 가 리포트 모드 rc 0 인 함정(`\|\| true` 뒤 `grep -qx "verdict ok"`)을 확인, 종료코드가 아니라 출력을 본다. `node -e` 블록 전건이 무가드라 예외 시 set -e 로 HALT. 4) anchoring: 계획서 §Validation 의 gates.sh sha256 핀이 완주 블록 안에 있어 구현 중 게이트 본문 교체가 막히는지 확인 — 막힌다. `#1` 의 legacy 재계산 입력이 착수본 `sealRow` 입력({cols,id,kind,pk,provenance})과 동치인지 db-probe.jsonl 실행 1을 읽어 `provenance` 에 `sha` 가 없음을 확인, D3 의 값 보존 주장이 성립. `m.hashOf`·`m.projectTarget` 이 실제 export 인지(reconcile.mjs:118·308) 및 `IS_ENTRY`(:2384) 가 import 시 main 을 안 돌리는지 확인. 5) skip predicate: R 집합이 `$W/rset.txt` 한 곳에서만 유도되고 `#6`·`#10` 이 같은 파일을 인자로 받는지, `#5` 가 R 을 안 읽는지 확인 — 문면대로다. R 위조 가능성은 NOT Building 이 공시. 6) `--list-tokens` 출력 형상을 읽어 `cnt -E '^check '` 가 21 을 낸다는 것을 검증(reconcile.mjs:2358, 토큰당 한 줄) — 「check 21 요약행 1건」 오독 가설은 반증됐다. 7) `dep_dangling`/`unbound` 의 id 접두(`probe:`)와 basis 인용 형식(`probe:sha256:` 74건 실측)이 맞물리는지 확인. 8) `target_index_missing` 이 `--check` 에서 계수만이고(:1170) 실패시키지 않는 점을 파고들었으나, probe 파일 부재는 `#1` 의 readFileSync 와 `#3` 의 `target_probe_rows == PR1` 에서 먼저 죽어 승인으로 끝나는 분기를 못 찾았다. 위 3건 외에 HIGH 로 올릴 자리를 찾지 못했다. |

## Measurement

<!-- Written by plan-review/cli.js record on EVERY exit path, pass or halt.
     Machine-readable; do not hand-edit. A null field means the axis was
     not observed, never that it was zero. -->

```json
{
  "verdict": "divergent",
  "source": "hybrid",
  "layers": {
    "l1": "converged",
    "l2": "converged",
    "l3": "divergent"
  },
  "quorum": {
    "responded": 4,
    "required": 3,
    "roles": 4,
    "of": 4,
    "passed": true
  },
  "wall_clock_ms": 464703,
  "halt_stage": "5.2e",
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:92c2186fcba9703b0b071c44b678ebcfe76c6cf91cd2be9720faf508dbfbc315",
  "plan_path": ".claude/plans/ops-doc-reconcile-intake-m2.plan.md",
  "recorded_at": "2026-09-16T12:04:40.349Z"
}
```
