# 구현 기록 — ops-doc-reconcile M0 게이트 실효화 · 계약 정정

계획: `.claude/plans/ops-doc-reconcile-m0-gates.plan.md`
슬러그: `ops-doc-reconcile-m0-gates`

이 파일에 리뷰를 적는 이유: `mccp-plan-codex` receipt 가 계획 본문 해시(`plan_hash`)에 결박돼
있어, 계획 본문에 절을 더하면 그 receipt 가 stale 로 떨어지고 하류 게이트가 막힌다. mccp
`prp-implement` 2.5.4 가 허용하는 대체 자리가 `.claude/notes/<topic>.md` 다.

## Codex Implementation Review

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.29.0/scripts/lib/codex-invoke.js adversarial-review` (fail-closed Bash wrapper, v0.2.2)
- 라운드 수: 1 (`effectiveRoundCap` = 1, `MCCP_REVIEW_SINGLE_PASS=deadline_pressure` 로 pinned)
- 합치 결론: 4건 중 2건이 "게이트가 아무것도 재지 않으면서 초록을 내는" 부류였고 둘 다 파일
  대조로 사실 확정됐다. 계획 Task 2·3 의 구현 형태를 바꿔 흡수했다. 나머지 2건 중 1건은
  계획의 Validation 블록 자체가 못 도는 결함이라 흡수, 1건은 열린 집합 규칙 요구라 부분 흡수
  후 이월.
- Codex session 참조: threadId `01a01db5-d66a-78c0-b275-8ab90d43dced`
- 원시 verdict: `needs-attention`

### YAGNI Triage

| Finding | Severity | Verdict | Why |
|---|---|---|---|
| F1 protocol 전건 해시가 golden 오라클을 자기참조로 만든다 | HIGH | ACCEPT_NOW | 대조 확정. `ownership.protocol_prefixes = ["ops/_meta/"]` 이므로 `classify("ops/_meta/golden.txt") === "protocol"` 이고, `--update` 는 `mktemp "$(dirname "$GOLDEN")/.golden.XXXXXX"` 를 node 실행 **전에** 만든다. 계획대로 넣으면 `--update` 직후 `--check` 가 반드시 깨진다 |
| F2 reproduce 실행이 검증 전에 산출물을 덮어쓴다 | HIGH | ACCEPT_NOW | 대조 확정. `ops/db/normalize-db.mjs` 의 `main()` 은 `default: return modeEmit()` 이고 머리말이 *"방출 (유일한 쓰기 모드)"* 라고 적었다. manifest `reproduce.db` 가 플래그 없는 그 명령이다 — golden 해시 앞에서 돌리면 잘못 커밋된 `rows.jsonl` 이 정상본으로 교체된 뒤 해시된다 |
| F3 reverify 분기가 baseline 요구를 못 넘고 검증 명령도 그 모드에 못 들어간다 | MEDIUM | ACCEPT_NOW | 대조 확정. 하네스 usage 는 `<snapdir>` 를 필수로 받아 없으면 exit 2 이고, `validate` 는 `$SNAP/head`·`$SNAP/before` 가 없으면 `NO_BASELINE` 이다. 계획 Validation 227·228 행은 인자 없이 부른다 — 계획의 검증 블록이 그대로는 안 돈다 |
| F4 새 statement 규칙이 계약 없이 예제 3건만 봉인한다 | MEDIUM | ACCEPT_NOW(부분) + REJECT_YAGNI(인명 정규식) | qty·unit 도출과 수 토큰 경계는 **폐쇄형 규칙**으로 닫았다. 인명 추측 정규식은 거부한다 — 이름은 열린 집합이고, `text` 는 규칙 2 가 이미 앵커 셀 도출을 강제하므로 새 인명은 애초에 못 들어간다. 원천 문서 쪽 비식별화는 이월 |
| L2-architect: 세 모듈이 서로 다른 오류 형태(OpsFail·RcFail·invFail)를 쓴다 | HIGH | DEFER_TO_BACKLOG | 이번 M0 의 Files to Change 어디에도 통합이 없다. 통합은 세 파일의 모든 실패 경로를 건드리는 별건이고, 이 M0 는 그 파일들에 검사를 **더하기만** 한다 |
| L2-test: 반사실이 산문에만 있고 Validation 스크립트에 없다 (4건) | MEDIUM | ACCEPT_NOW | 반사실을 실제로 실행하고 findings 에 실측값으로 적는다. Validation 블록을 반사실까지 담게 늘리지는 않는다 — 반사실은 1회성 관측이고 상시 게이트가 아니다 |

### 흡수 내역 (계획 대비 변경)

**F1 흡수 — protoOut 은 자기참조 경로를 빼고 개행 정규화 해시로 뜬다.**
계획은 `protoOut = hashAll(opsFiles.filter(f => classify(f.rel) === "protocol"))` 였다. 두 가지를
바꿨다.

1. `golden.txt` 와 `--update` 임시파일(`ops/_meta/.golden.*`)을 집합에서 뺀다. golden 이 자기
   해시를 담으면 안정점이 없다.
2. `hashAll` 의 원바이트 해시 대신 기존 `sha256Text`(CRLF → LF 정규화 후 해시)를 쓴다.
   protocol 산출물은 전부 텍스트고(`node_modules/` 는 `prune_prefixes` 로 이미 순회 밖),
   원바이트로 뜨면 `sha256Text` 주석이 적어 둔 CRLF 회귀가 그대로 재발한다.
   `manifest.sha256` · `sources.lock.sha256` 전용 행은 그대로 두고 protoOut 에서는 그 둘을
   빼서 같은 값을 두 번 싣지 않는다.

**F2 흡수 — reproduce 는 읽기 전용 계약이고, 그것을 기계가 다시 문다.**
계획은 "각 명령을 실제로 실행하고 rc 와 산출 해시를 판정에 싣는다" 였다. 두 가지를 더했다.

1. `ops-manifest.yaml` 의 `reproduce.db` 를 `node ops/db/normalize-db.mjs` 에서
   `node ops/db/normalize-db.mjs --check` 로 바꾼다. `--check` 가 *"재방출본과 디스크본 바이트
   대조"* 이고 그것이 재현성 계약 본체다. 나머지 3개는 이미 읽기 전용이다.
2. 문자열 하나를 고치는 것으로는 다음 편집이 같은 구멍을 다시 연다. 그래서 reproduce 실행
   **전후로 `ops/` 파일 집합의 해시를 떠서 다르면** `OPS_INV_REPRODUCE_MUTATED_TREE` 로
   죽인다. 읽기 전용을 사람 약속이 아니라 기계가 문다.

**F3 흡수 — reverify 는 세 번째 위치 인자 모드다.**
계획은 `OPS_HARNESS_MODE=reverify` 환경변수였다. 환경변수로는 `usage` 가드(snapdir 없으면
exit 2)와 `NO_BASELINE` 가드를 못 넘는다 — 두 가드가 환경변수를 읽기 전에 선다. `reverify` 를
`preflight`/`validate` 와 나란한 세 번째 위치 모드로 두고 snapdir 를 선택 인자로 만든다.
기본 경로(기준선 신규 수립)는 한 글자도 안 바꾼다. 증명 범위도 계획보다 낮춰 명시한다 —
reverify 는 **기능과 동결본 무결성만** 증명하고 소유 경계(provenance)는 증명하지 않는다.
그 사실을 실행 시 stdout 에 `provenance_proven 0` 으로 낸다.

**F4 부분 흡수 — 값 계약은 폐쇄형으로, 인명은 거부.**
`qty` 는 앵커 셀의 수 토큰 중 하나와 값이 같아야 하고(`OPS_INV_VALUE_QTY_NOT_DERIVABLE`),
`unit` 은 폐쇄 enum 소속이며(`OPS_INV_VALUE_UNIT_UNKNOWN`) 그 enum 이 선언한 표기 중 하나가
앵커 셀에 있어야 한다(`OPS_INV_VALUE_UNIT_NOT_DERIVABLE`). 수 토큰 경계는
`OPS_INV_NUMBER_TOKEN_BOUNDARY` 로 따로 문다 — 정규화 사다리는 봉인 fixture 4단계가 단언하고
있어 한 글자도 안 바꾼다.

인명 정규식은 만들지 않는다. 근거 두 가지: (가) 한국어 인명은 열린 집합이라 어떤 정규식도
정상 문구를 대량 거부하면서 실제 이름을 놓친다, (나) `text` 는 규칙 2 가 앵커 셀 값 도출을
이미 강제하므로 원천 셀에 없는 이름은 애초에 못 들어간다 — 즉 남는 위험은 원천 문서 자체의
PII 이고 그것은 층 1 소관이다. 판독 규약 문면은 `ops/findings/ops-20-statements.md` 에 적고
원천 쪽 비식별화는 backlog 로 이월한다.

### Security Reviewer

> security-reviewer unavailable, skipped (auto-fallback): 이 세션은 Agent(Task) tool 호출이
> 금지돼 있어 `subagent_type: "security-reviewer"` 를 부를 수 없다.

보완: 이 계획의 L2 승인 패널에서 `security` 관점은 `verdict: pass` 였고, 그 refutation 기록이
신뢰 경계·PII 처리·경로 순회·manifest 변조 표면을 전부 훑고 *"No concrete input→consequence
path found for defect"* 로 닫았다(`.claude/state/plan-review/l2.json`). 그리고 이번 구현이
보안 표면에 하는 일은 **검사를 더하는 쪽 하나**다 — 값 도출 결박 3종과 수 토큰 경계 1종은
전부 fail-closed 방향이고, 완화하는 규칙은 없다. `security_skipped=true` 는 receipt 에 정직하게
봉인되고 하류 `/mccp:pr` 를 막는다.

### Design Review

> impeccable silent-skip: 이 M0 의 Files to Change 에 렌더 표면(`.tsx`/`.css`/`.html`,
> `.claude/cache/*`)이 0건이다. 계획의 Design Routing Guide 도 같은 관측을 적어 두었다 —
> design signal 은 `.claude/cache/STATUS.md` 한 파일에서 왔고 그 파일은 이 M0 의 산출물이
> 아니다.

## Open Questions

- 없음 (F1~F4 전건 R1 에서 해소, 이월 4건은 backlog 등재)

## 계획 대비 편차 (WHAT / WHY)

| # | WHAT | WHY |
|---|---|---|
| D1 | Task 1 의 *"커밋된 CR 을 0으로 만들어 다시 커밋한다"* 를 하지 않았다. `.gitattributes` 4줄 + 워킹트리 3본 `tr -d '\r'` 로 끝냈다 | 전제가 틀렸다. 8개 파일 전건 `git show HEAD:<path> \| tr -dc '\r' \| wc -c` 가 0 — 커밋된 CR 은 없고 `core.autocrlf=true` 가 체크아웃 때 넣는다. blob 재작성은 할 일이 없다. 계획이 지목한 파일과 계수(669·437·531)는 맞았고 귀속만 틀렸다. Gate/Validate 단언(`checkout-index` 사본 CR 합계 0)은 그대로 성립한다 |
| D2 | `manifest.reproduce.db` 를 `node ops/db/normalize-db.mjs` → `--check` 로 바꾸고, 실행 전후 `ops/` 전건 해시를 대조하는 `OPS_INV_REPRODUCE_MUTATED_TREE` 를 더했다. 자식에게 부모 env 를 넘기지 않는다 | Implement-Codex R1 F2. 무플래그 기본 모드가 `rows.jsonl` 을 덮어쓰는 유일한 쓰기 모드라, golden 해시 앞에서 돌면 결함이 자가 치유된다. env 를 넘기면 `OPS_DB_CSV_ROOT` 유무에 따라 golden 이 머신마다 갈린다 |
| D3 | `protoOut` 에서 `golden.txt`·`--update` 임시파일·manifest·lock 을 빼고, `hashAll`(원바이트) 대신 `sha256Text`(개행 정규화)를 썼다 | Implement-Codex R1 F1. `protocol_prefixes` 가 `ops/_meta/` 통째라 golden 이 자기 해시를 담게 되고, `--update` 는 node 실행 전에 `.golden.XXXXXX` 를 같은 디렉터리에 만든다. 원바이트 해시는 `sha256Text` 가 막아 둔 CRLF 회귀를 되돌린다 |
| D4 | `reverify` 를 `OPS_HARNESS_MODE` 환경변수가 아니라 **세 번째 위치 인자 모드**로 만들었다. 증명 범위도 낮춰 `provenance_proven 0` 으로 낸다 | Implement-Codex R1 F3. usage 가드(snapdir 필수, exit 2)와 `NO_BASELINE` 가드가 환경변수를 읽기 전에 서므로 그 형태로는 두 문을 못 넘는다. 기준선 없이 소유 경계를 증명한 척하지 않으려면 범위를 명시해야 한다 |
| D5 | 축 D 의 *"`text` 안의 인명을 거부"* 를 정규식으로 구현하지 않았다. 판독 규약을 `ops/findings/ops-20-statements.md` `F-OPS-20-D4` 에 명시하고 원천 쪽은 이월했다 | Implement-Codex R1 F4. 한국어 인명은 열린 집합이라 정규식은 정상 문구를 대량 거부하면서 실제 이름을 놓친다. 그리고 규칙 2 가 `stated` 의 `text` 를 앵커 셀 도출로 이미 결박하므로 셀에 없는 이름은 못 들어온다 — 남는 위험은 원천 셀이고 그것은 층 1 소관이다 |
| D6 | 계획 `## Validation` 블록을 그대로 돌리지 않고 정정본으로 완주했다. 계획 본문은 고치지 않았다 | 계획 블록 2건이 실행 불가다 — `check-statements.mjs --selftest` 는 없는 서브커맨드(usage exit 2)고, 두 하네스의 `validate` 는 `<snapdir>` 필수 + 기준선 필요다. 계획 본문 편집은 `mccp-plan-codex` receipt 의 `plan_hash` 를 흔들어 선행 게이트를 stale 로 떨어뜨린다. backlog `F-OPS-00-V1` 에 등재했다 |
| D7 | `ops/reconcile/_harness/m0.sh` 에는 `reverify` 를 넣지 않았다 | 계획 Task 5 (3) 이 `ops/statements/_harness/m0.sh` 하나만 지목했다. ops-30 쪽 같은 축은 backlog `F-OPS-30-H7` 의 회부다 |

## 정정 Validation 블록 (정본)

계획 `## Validation` 의 실행 가능한 판본이다. 전건 `ALL OK` 로 완주했다.

```bash
set -euo pipefail
export LC_ALL=C
cd "$(git rev-parse --show-toplevel)"
export OPS_SOURCES_ROOT="$(cd ../../.samples && pwd)"   # 이 워크트리의 .samples 는 비어 있다

# 1. 축 A — 신선 체크아웃 사본에 CR 이 없다
TMPD=$(mktemp -d); git checkout-index -a --prefix="$TMPD/"
CR_TOTAL=0
while IFS= read -r f; do
  n=$(tr -dc '\r' < "$TMPD/$f" | wc -c); CR_TOTAL=$((CR_TOTAL + n))
done < <(git ls-files 'ops/**/*.sh' 'ops/**/*.jsonl' 'ops/**/*.txt' 'ops/**/*.sql')
echo "eol_cr_total $CR_TOTAL"; test "$CR_TOTAL" -eq 0; rm -rf "$TMPD"

# 2. 축 B·C — 우산 게이트
bash ops/_meta/verify-ops.sh --check > /dev/null
bash ops/_meta/verify-ops.sh | grep -E '^(reproduce_ran|reproduce_unavailable|unclassified|children_present|protocol_present) '

# 3. 축 D — 판독층 게이트와 토큰 계약
#    `--selftest` 는 이 체커에 없다. 자기시험 상대는 하네스의 TOKEN_COVERAGE 다
#    (`--list-tokens` 선언 집합 == 음성 fixture 가 실제로 문 집합, 전수 대조).
node ops/statements/check-statements.mjs --check > /dev/null
node ops/statements/check-statements.mjs --list-tokens \
  | grep -E 'VALUE_QTY_NOT_DERIVABLE|VALUE_UNIT_UNKNOWN|VALUE_UNIT_NOT_DERIVABLE|NUMBER_TOKEN_BOUNDARY'

# 4. 축 E — 층 3 게이트 진단문과 하네스
#    `grep -qv` 는 행 단위 부정이라 쓰지 않는다. 부정은 grep 바깥에 둔다.
! node ops/reconcile/reconcile.mjs --check 2>&1 | grep -q '정상 상태다'
#    이 호스트 접두 2개: `env` 가 uv 잔재에 가려 있고(F-OPS-20-E1) git-bash /tmp 는 경로 형이 갈린다.
PATH="/usr/bin:$PATH" TMPDIR="C:/tmp" bash ops/statements/_harness/m0.sh reverify
#    ops-30 하네스는 기준선 없이 못 돈다. 술어 반사실로 대신한다.
PRE=$(sed -n "/^TOOL_PREFIXES='/,/'$/p" ops/reconcile/_harness/m0.sh | sed "s/^TOOL_PREFIXES='//; s/'$//")
node -e '
  const lines = (s) => s.split("\n").map((x) => x.trim()).filter(Boolean);
  const mk = (pre) => { const P = lines(pre); return (p) => P.some((x) => p.startsWith(x)); };
  const tool = mk(process.argv[1]);
  const recs = [".claude/cache/STATUS.md", ".claude/cache/status.html"];
  const bad = recs.filter((p) => !tool(p));
  if (bad.length) throw new Error("cache not tool-classified: " + bad.join(","));
  console.log("cache_tool_paths_ok " + recs.length);
' "$PRE"

# 5. 축 E — 마일스톤 표가 기계로 읽힌다
GOAL_DETECT=$(ls -d "$HOME"/.claude/plugins/cache/mccp/mccp/*/scripts/lib/goal-detect.js | sort -V | tail -1)
test -n "$GOAL_DETECT"
for p in .claude/prds/ops-00-protocol.prd.md .claude/prds/ops-10-cells.prd.md; do
  node -e 'const g=require(process.argv[1]);const t=require("fs").readFileSync(process.argv[2],"utf8");
    const r=g.parseMilestoneTable(t);
    if(!r.headerFound) throw new Error("headerFound=false: "+process.argv[2]);
    if(r.rows.length!==4) throw new Error("rows="+r.rows.length+" want 4: "+process.argv[2]);
    const ids=r.rows.map(x=>x.row).join(",");
    if(ids!=="0,1,2,3") throw new Error("ids="+ids+" want 0,1,2,3: "+process.argv[2]);
    console.log("milestone_table_ok "+process.argv[2]);' "$GOAL_DETECT" "$p"
done
```

## 실측 요약

| 축 | 수정 전 | 수정 후 |
|---|---|---|
| A | `checkout-index` 사본 CR 합계 **3307** (8파일) | **0** |
| B | `verify-ops.sh` 에 `spawnSync` **0건** — reproduce 는 파싱만 | `reproduce_ran 3` · `reproduce_unavailable 1`, rc 를 golden 에 동결 |
| B (읽기 전용) | producer 를 golden 앞에서 실행 가능 | 쓰기 축을 심으면 `OPS_INV_REPRODUCE_MUTATED_TREE` rc 1 |
| C | `unclassified 3` · `golden_only_in_tree 26` · protocol 산출물 미열거 | `unclassified 0` · `golden_only_in_tree 0` · `protocol_present 5` |
| C (부재 반사실) | `package-lock.json` 을 지워도 초록 | rc 1 + `golden 에만 ... ops/_meta/package-lock.json <sha>` |
| D | 신설 4 fixture 전건 rc 0 `verdict ok` (봉인 SHA `4bc4a99`) | 4/4 각각 다른 토큰으로 rc 1 |
| E1 | `grep -c '정상 상태다'` = 1 | 0 (rc 는 1 그대로) |
| E2 | `.claude/cache/**` 4종 `OUT_OF_SCOPE` | 4종 `tool` |
| E3 | `m0.sh reverify` rc 2 (usage) | rc 0 · `provenance_proven 0` |
| 표 | 두 PRD `headerFound=false rows=0` | 둘 다 `headerFound=true rows=4 ids=0,1,2,3` |

## 환경 주의

이 호스트에서 두 하네스를 돌리려면 접두 두 개가 필요하다. 둘 다 저장소 결함이 아니다.

- `PATH=/usr/bin:$PATH` — `~/.local/bin/env` 가 coreutils `env` 를 가린다. 그 파일은 PATH 를
  조작하는 셸 스크립트라 인자를 무시하고 exit 0 하므로, 하네스의 `env -u NODE_OPTIONS ... node`
  호출이 **아무것도 실행하지 않고** rc 0 을 낸다. 이미 `ops/findings/ops-20-statements.md`
  `F-OPS-20-E1` 로 기록돼 있다.
- `TMPDIR=C:/tmp` — git-bash `/tmp` 는 bash 와 node 가 서로 다른 경로로 보아 fixture 트리
  접두 대조(`FIXTURE_ESCAPE`)가 형 불일치로 실패한다.
