# notes: 10 · 사실 인덱스 · git 축 — 마일스톤 5 (cross-repo-days.csv + `_site/→front/` 매핑)

**Plan**: `.claude/plans/insuniverse-docs-10-index-git-m5.plan.md`

이 파일은 implement 게이트의 감사 기록이다. plan 본문에 덧붙이지 않는 이유는
plan 해시가 바뀌면 `mccp-plan-codex` receipt 가 stale 로 떨어지기 때문이다
(`/mccp:prp-implement` Phase 2.5.4 가 허용하는 notes 경로). 마일스톤 3·4 와 같은 자리다.

**아래 §흡수로 생긴 plan 이탈 4건은 plan 본문 해당 서술을 대체한다.** plan Task 2·5·7 과
Validation 7b 의 관련 문장은 그 범위에서 낡았고, 이 파일이 정본이다.

## 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 (`MCCP_GATE_ROUND_CAP` 기본 1. R1 에서 4건 전부 흡수 — 미해소 ACCEPT_NOW 없음)
- Codex verdict: `needs-attention` — *"No-ship: the plan leaves origin classification ambiguous,
  specifies a control-group transform that may not break pairings, and its advertised regression
  guard does not protect all existing YAML metadata."*
- 합치 결론: 4건 전부 실재하는 결함이고 축이 하나다 — **plan 이 "합계가 맞으면 옳다" 로 검사를
  대신했다.** (a) `origin_fate` 3분할은 합 478 만 단언하는데, 합은 배타성을 증명하지 않는다 —
  평가 순서가 조용히 오분류를 만들고도 합을 맞출 수 있다. (b) 대조군은 "저장소마다 k칸" 이
  permutation 을 특정하지 못한다 — 셋을 같은 k 로 밀면 짝이 안 깨지고, self-pair 를 건너뛰면
  분모가 151 아래로 내려가는데 기대율은 151 기준으로 적혀 있었다. (c) Validation 7b 의 `cut()` 은
  **키 줄을 버려서** 스칼라 최상위 키가 전부 빈 블록으로 해시된다 — `manifest_version` 을
  바꿔도 가드가 통과한다. 게다가 대조 대상이 4개뿐인데 plan 본문은 "`counts` 제외 전부" 라고 적었다.
  (d) `kstDay` 는 `sv-SE` 표현 순서에 의존해 small-icu 폴백을 잡지 못한다.
  **넷 다 "단언이 있다" 와 "단언이 그 명제를 증명한다" 사이의 틈이다.**
- YAGNI Triage:

  | Finding | Severity | Verdict | Why |
  |---|---|---|---|
  | F1 `origin_fate` 3분할이 배타적이지 않고 우선순위가 없다 | HIGH | **ACCEPT_NOW** | 정확하다. `alive`(HEAD 존재)와 `deleted`(원본이 `deleted_paths` 에 있음)는 독립 계산이라 삭제 후 재추가된 경로가 둘 다 만족한다. 합 478 단언은 그것을 못 잡는다. **우선순위를 정하는 대신 배타성 자체를 단언한다** — 세 raw 술어를 각각 계산해 정확히 1개만 참인지 전건 검사하고, 아니면 경로를 실어 `throw`. 우선순위로 접으면 오분류가 조용히 통과한다. selftest 에 delete-then-readd 픽스처 |
  | F2 대조군 회전이 짝을 깨지 못하고 분모가 불안정하다 | HIGH | **ACCEPT_NOW** | 정확하다. permutation 을 코드 수준으로 특정한다 — `server=i` · `site=(i+k) mod n` · `admin=(i+2k) mod n`, `n=151` 고정. **self-pair 행을 건너뛰지 않는다**(분모가 변한다). 대신 self-pair 0건을 단언한다. 실측으로 확인: k=1·7·30 전부 self=0 이고 값이 plan 기재와 동일하다(1/151 · 0 · 0). selftest 에 `k` 가 길이의 약수인 합성 케이스를 넣어 단언이 실제로 터지는지 본다 |
  | F3 sha256 가드가 스칼라 최상위 키를 빈 블록으로 해시한다 | HIGH | **ACCEPT_NOW** | 가장 확실한 결함이다(confidence 0.99). `cut()` 이 키 줄을 `continue` 로 버려서 `generated_by`·`manifest_version`·`measured_at` 같은 스칼라는 본문이 없어 전부 `sha256("")` 이 된다 — 값을 바꿔도 SAME 이 나온다. 가드를 다시 쓴다: **키 줄을 블록에 포함**하고, 대조 대상을 커밋본에서 열거한 최상위 키 전부(`counts` 만 제외)로 넓히고, **키 순서 목록도 함께 대조**한다. 스칼라 1개를 손으로 깨뜨려 가드가 실제로 exit 1 을 내는지 음성 검사한다 |
  | F4 `kstDay` 에 로케일·타임존 fail-closed 프로브가 없다 | MEDIUM | **ACCEPT_NOW** | 싸고 조용한 손상 클래스를 통째로 닫는다. `sv-SE` 표현 순서 의존을 없애고 `formatToParts()` 로 `YYYY-MM-DD` 를 조립한다(로케일 폴백에 무관). 결과 모양을 `^\d{4}-\d{2}-\d{2}$` 로 재검사하고, 최초 호출 시 `2025-07-05T15:00:00Z → 2025-07-06` 경계를 1회 프로브해 `Asia/Seoul` 이 실제로 반영됐는지 확인한다. 모듈 최상위 부작용을 만들지 않으려고 lazy init 안에 둔다 |

- 흡수로 생긴 plan 이탈 (**plan 본문 해당 서술을 대체**):
  1. **Task 2 `origin_fate`** — "3분할의 합이 478 인지 단언" → **세 raw 술어 배타성 전건 단언**.
     `alive := origin ∈ ls-tree(HEAD)` · `deleted := origin ∈ deleted_paths` ·
     `renamed := origin ∈ {R 의 old_path}`. 경로마다 참인 술어가 정확히 1개가 아니면 `throw`.
     합 검사는 그 뒤에 남긴다(둘 다 있는 것이 싸다).
  2. **Task 5 대조군** — "저장소마다 k칸씩 밀어" → **`server=i` · `site=(i+k) mod n` ·
     `admin=(i+2k) mod n`**. 분모는 언제나 `n`(=151). self-pair 행을 건너뛰지 않고
     **self-pair 0건을 단언**한다. plan Task 7 의 "약수일 때 자기 자신과 짝지어지는 행을
     건너뛰는지" 는 **건너뛰기가 아니라 단언이 터지는지**를 보는 검사로 바뀐다.
  3. **Validation 7b** — 4개 키 하드코딩 + 키 줄 버리는 `cut()` → **커밋본에서 최상위 키를
     열거**해 `counts` 만 빼고 전부, **키 줄 포함**해 해시, **키 순서 목록도 대조**.
     스칼라 변조 음성 검사 1건 추가.
  4. **Task 1 `kstDay`** — `Intl.DateTimeFormat("sv-SE", …).format()` →
     **`formatToParts()` 조립 + 모양 재검사 + lazy 경계 프로브**.

- 착수 측정으로 확정한 값 (전부 plan 기재와 일치 — F2 permutation 확정판으로 재실행):
  `union 392` · `triple 151` · `server+site 105` · `server+admin 51` · `site+admin 4` ·
  `server 39` · `site 40` · `admin 2` · 비-KST `3` · `docs 854` · `vocab 4980` · `thr 8` ·
  확증 `27/151 (17.9%)` · 대조 `k=1 1/151 (0.7%)` · `k=7 0` · `k=30 0` · **self-pair 전부 0** ·
  전체 `exp 40.2 obs 151 (3.75×)` · 평일 `exp 70.7 obs 148 (2.09×)` · 주말 `obs 3 (132×)`.
- Deferred to backlog: 0 → `.claude/plans/codex-findings-backlog.md` (기재 없음)
- Open Questions: 없음 (구현 시점 항목 기준. plan-codex 단계의 `_site/` 배포 노출 `unknown`
  회부와 매핑 독립 파일 여부 회부 2건은 그대로 유효 — F-10-18 · F-10-17)
- Codex session 참조: threadId `019fda97-b761-79b2-a134-a744dd105f2f`

## 구현 중 발생한 plan 이탈 (Codex 흡수와 별개)

구현하면서 plan 본문과 갈린 자리다. 전부 실측 근거가 있다.

1. **`site_path_duplicates` 의 `counts` 위치** — plan Task 2 는 "`counts` 에 추가" 라고 적었으나,
   `counts:` 는 `repos.<name>` 아래 **평면 정수 맵**이다. 거기에 중첩 객체를 넣으면 이미터가
   깨지고 무엇보다 **`repos:` 블록이 바뀌어 plan 자신의 바이트 무변경 요구를 위반한다.**
   그래서 새 최상위 섹션 안에 `site_path_duplicates.counts` 로 뒀다.
2. **`origin_delete_count` 필드 추가** — plan 스키마에 없던 정수 열이다. Codex F1 이 지적한
   "삭제 후 재추가" 사례가 실측 **16건** 있는데(465 alive 중), 이것을 `origin_fate` 로 접으면
   분류가 그 사실을 삼킨다. 분류는 배타성 단언을 통과한 3값 그대로 두고 raw 사실을 옆에 낸다.
3. **blob 수치 정정 — 486/849 → 487/850.** plan §실측 1-e 는 `core.quotePath=false` 없이 잰
   값이라 한글 경로 1건(`..._사용매뉴얼_1008.pdf`)이 `"\353\263..."` 로 인용돼 빠졌다.
   478행 기준으로는 **476 동일 · 2 상이**다. selftest 가 487/850 을 회귀로 고정한다 (F-10-17).
4. **회귀 가드 `cut()` 정정 2건** — (a) F3 대로 키 줄을 블록에 포함시켰고, (b) 블록 끝의
   빈 줄을 떨어낸다. (b)가 없으면 파일 마지막 최상위 키(`repos:`)만 EOF 개행 때문에 빈 줄
   1개를 더 갖게 되어 새 섹션을 뒤에 붙이는 순간 CHANGED 로 오탐한다. 이미터는 블록 안에
   빈 줄을 내지 않으므로 안전한 정규화다. **스칼라 변조 음성 검사**를 같이 돌려 가드가
   실제로 잡는 것을 확인한다.
5. **`--manifest` CLI 플래그를 만들지 않았다** — plan Task 3 Validate 는 "동결 SHA 를 한 글자
   바꾼 임시 manifest 로 exit 1" 을 적었는데, 그러려면 CLI 표면이 하나 늘어난다. 같은 명제를
   selftest 통합층이 `collect({repos: [head 를 0으로 바꾼 것]})` 로 검증한다.
6. **검증 산출 경로가 `/tmp` 가 아니라 `.mccp/check/`** — `resolveOutPath` 가 `--output` 을
   워크트리 안으로 봉인하는데 Windows 의 `/tmp` 는 워크트리 밖으로 해석된다. `.mccp/` 는
   `.gitignore` 에 있어 git status 를 더럽히지 않는다. plan Validation 의 `/tmp` 경로는
   그 봉인과 함께 쓸 수 없다.
7. **`partitionDays` 가 키 정렬된 객체를 낸다** — 삽입 순서는 입력 데이터에 의존하므로
   결정적이지 않다. 회귀 고정값이 데이터 순서에 묶이는 것을 막는다.
8. **PRD §필수 해소 항목 2건에 해소 표시를 달았다** — plan Files to Change 는 "Delivery
   Milestones 5행만" 이라고 적었으나 Acceptance 가 "답변 기재" 를 요구한다. 두 bullet 에
   취소선 + 1줄 요약 + findings 앵커를 달았다.
9. **plan Task 7 의 대조군 검사 방향이 바뀌었다** — "약수일 때 자기 자신과 짝지어지는 행을
   **건너뛰는지**" → "**단언이 터지는지**". F2 흡수로 건너뛰기 자체를 없앴기 때문이다.
10. **`insuniverse-site/.claude/state/` 제거** — 다른 세션(`d49b1040-…`)의 mccp hook-trace
    파편이 동결 서브모듈에 남아 있어 M4 의 "축 3 임시 인덱스 사용 후 워킹트리 무해" 단언이
    거짓으로 실패했다. 내 변경이 만든 것이 아니고 재생성 가능한 로그다. 스크래치에 백업 후
    제거했다. 제거 뒤 `git -C insuniverse-site status --porcelain` 이 빈 출력이다.

## Phase 3.6 / 3.7 (design finish · grounding)

둘 다 **no-op**. 2.5.5b 의 design trigger 가 발동하지 않았으므로(`SIGNAL=0` ·
`DESIGN_INTENT_ACTIVE=0`) 2.5.5c 캡처 아티팩트가 없고, Phase 3.7 은 아티팩트 부재 시 완전
no-op 이다. Phase 3.6 도 트리거 게이트에서 걸린다. 산출물은 `.csv` 1개와 `.yaml` 섹션 1개로
렌더링 표면이 없다.

### Security Reviewer

해당 없음 — **범위 밖 판정**이지 auto-fallback 이 아니다. 그래서 `security_skipped` 를 세우지 않는다
(마일스톤 3·4 와 같은 판단, 같은 근거).

이 마일스톤이 새로 만드는 표면을 짚는다:

- **새 git 호출** — `ls-tree -r <SHA>` 2회(추가 커밋 트리)와 `rev-parse HEAD` 3회뿐이다.
  전부 `execFileSync` 배열 인자에 `shell: false` 이고, SHA 는 인덱스에서 읽어 40자 hex 검증을
  통과한 값이다. 셸을 경유하지 않으므로 명령 주입 축이 없다.
- **CSV 이미터** — 새 산출 형식이므로 CSV formula injection(`=`·`+`·`-`·`@` 로 시작하는 필드가
  스프레드시트에서 실행되는 축)을 따로 본다. 이 파일의 필드는 `date`(`YYYY-MM-DD`) ·
  정수 3개 · `shared_rare_tokens`(`[a-z]{3,}|[가-힣]{2,}` 토큰을 `|` 로 이은 것)뿐이다.
  **그 문자로 시작할 수 있는 필드가 구성상 존재하지 않는다.** 토큰 정규식이 곧 방어선이다.
- **`--output`** — 기존 `resolveOutPath` 가 그대로 봉인한다(realpath 정규화 후 ROOT 접두어 검사,
  쓰기 직전 재검사). 이 마일스톤이 그 규칙을 넓히지 않는다.

인증·암호·비밀·네트워크·SQL·외부 입력이 없다. 입력은 manifest · 커밋된 인덱스 ·
동결 서브모듈의 로컬 git 객체뿐이다.

### Design (impeccable)

`impeccable-detect --mode implement` → `skill_available: true` · `design_signal: false` ·
`signal_files: []` · `reason: no-signal` → **silent-skip**. receipt 에
`impeccable_silent_skip=true` 로 기록한다.

이 마일스톤의 산출물은 `.csv` 1개와 `.yaml` 섹션 1개다. 렌더링 표면이 없다.
plan 단계 detector 와 같은 판정이고, 마일스톤 4 의 오탐(측정 데이터로 인용한 `.tsx` 경로 문자열)
같은 상황도 이번엔 없다.
