# `10-index-git` 산출물 읽는 법

**이 문서는 `10-index-git` 축의 산출물 10개만 서술한다.** 같은 디렉터리의 `codes/*` ·
`constants.yaml` · `schedules.yaml` · `vendor-behaviors.yaml` 등은 형제 PRD `11-index-code` ·
`12-index-runtime` 소유이며 각자 문서를 낸다. 이 디렉터리는 3개 PRD가 **공유**하고 배타 소유는
**파일 단위**다. 이 문서를 디렉터리 전체의 계약으로 읽지 마라.

## 1. 머리말

이 축은 **git 이력에서 읽어낸 사실**을 기계가 다시 읽을 수 있는 형태로 고정한다.
정책의 "왜"와 "언제"는 코드가 아니라 커밋 메시지·주석·삭제 이력·릴리스 계보에 있는데,
세션마다 원문을 다시 읽으면 같은 질문에 다른 수치가 나온다. 그래서 규약을
`docs/_meta/extraction-manifest.yaml`에 고정하고, 커밋되는 추출기와 그 산출물로 닫았다.

**전부 생성물이다. 손으로 고치지 않는다.** 값이 틀렸으면 추출기나 manifest를 고치고 다시 돌린다.
이 문서와 산출물이 어긋나면 **산출물이 정본**이고 이 문서를 고친다.

### 재생성

```
node docs/_meta/index/extract-commits.mjs
node docs/_meta/index/extract-comments.mjs
node docs/_meta/index/extract-releases.mjs
node docs/_meta/index/extract-dead-candidates.mjs
node docs/_meta/index/extract-cross-repo-days.mjs
node docs/_meta/index/extract-policy-candidates.mjs   ← 반드시 마지막
```

`extract-policy-candidates.mjs`는 앞선 산출물들을 **읽어서** 통합하므로 마지막이다.
전체 산출 순서의 정본은 `.claude/meta/05-final-spec.md` §5다.
`docs/_meta/index/index-pins.lock`이 있으면 위 명령에 **자동으로 먹는다** — 별도 인자가 없다.

### 입력 동결

| 항목 | 값 |
|---|---|
| manifest 규약 버전 | `version: 2` (`docs/_meta/extraction-manifest.yaml`) |
| `insuniverse-server` 동결 HEAD | `4d798f55f021d93c889725e50f18cbce20272fd1` |
| `insuniverse-site` 동결 HEAD | `767d1e441637b5cba759acbe1f928a47f17397eb` |
| `insuniverse-admin` 동결 HEAD | `fb001e1d26d342130cf9a8dc8fe5e8a64e1da9c8` |

세 값은 manifest `repos[].head`에서 옮긴 것이다. stg 쪽 기준점은 이 세 SHA이고 로컬
`refs/heads/stg`는 읽지 않는다 — 실측상 이미 이동했다 (`docs/_meta/findings/10-index-git.md` F-10-09).
**prod 쪽 tip은 manifest 동결 대상이 아니다** (F-10-12).

`docs/_meta/index/index-pins.lock`이 그것을 **재현 입력으로** 고정한다 (F-10-24). 이 파일이
있으면 추출기 3종(`releases` · `dead-candidates` · `policy-candidates`)은 live `origin/prod`를
보지 않고 여기 값으로 산출하므로, **prod가 움직여도 두 실행이 바이트 동일하다.**
핀 갱신은 의도적 행위이며 **전건 재생성을 동반한다** — 부분 갱신은 금지다. 파일이 없으면
`prod_tips_source: observed`로 떨어지고 그 사실이 산출물에 남는다(조용한 강등이 아니다).
`[classifier]` 절은 `legal_boilerplate` 규칙 버전을 담는다 — §3 참조.

### 전 산출물 공통 — 후보 목록이지 판정이 아니다

이 축이 내는 것은 전부 **관측 사실과 후보**다. 폐기 판정 · 배포 승격 판정 · 정책 확정은
`60-history-deprecated`와 `00-protocol` 소관이고, 게이트 on/off와 참조 카운트는 형제 PRD
`11-index-code` · `12-index-runtime` 소관이다. 이 파일들의 어떤 값도 단독으로 결론이 아니다.

## 2. 산출물 10개

건수는 **형식별 의미 단위**로 셌다. 물리 행 수가 곧 레코드 수인 것은 `.jsonl` 7개뿐이다.

| 파일 | 행 단위 | 건수 | 생성 추출기 | 이것이 아닌 것 |
|---|---|---|---|---|
| `commits.server.jsonl` | 커밋 1건 = 1행 | 2,587 | `extract-commits.mjs` | 열람용 히스토리 뷰 (`--full-history`라 단순화 안 함) |
| `commits.site.jsonl` | 커밋 1건 = 1행 | 2,446 | `extract-commits.mjs` | 같음 |
| `commits.admin.jsonl` | 커밋 1건 = 1행 | 1,157 | `extract-commits.mjs` | 같음 |
| `comments.server.jsonl` | 주석 블록 1건 = 1행 | 4,413 | `extract-comments.mjs` | 주석 **줄** 목록 (연속 주석은 1블록) |
| `comments.site.jsonl` | 주석 블록 1건 = 1행 | 3,514 | `extract-comments.mjs` | 같음 |
| `comments.admin.jsonl` | 주석 블록 1건 = 1행 | 4,761 | `extract-comments.mjs` | 같음 |
| `policy-candidates.git.jsonl` | 정책 후보 1건 = 1행 | 2,870 | `extract-policy-candidates.mjs` | 정책 **확정** 목록 |
| `releases.yaml` | 중첩 문서. 행 ≠ 레코드 | 버킷 엔트리 307 (`prod_only` 102 · `stg_only` 155 · `prod_merge` 45 · `release_like_unmatched` 5) | `extract-releases.mjs` | 배포 승격 판정 (F-10-11) |
| `dead-candidates.yaml` | 중첩 문서. 행 ≠ 레코드 | `deleted_paths` 833 · `revert_groups` 8 · `unresolved_revert_targets` 1 · `site_path_duplicates` 478 | `extract-dead-candidates.mjs` | 폐기 판정 |
| `cross-repo-days.csv` | 일자 1건 = 1행 | 데이터 151행 (+ 헤더 1행 = 물리 152행) | `extract-cross-repo-days.mjs` | **조인 키** (§5 · 사이드카 `docs/_meta/index/cross-repo-days.md`) |

저장소별 내역 — `releases.yaml` `prod_only`: server 51 · site 27 · admin 24 /
`stg_only`: server 64 · site 35 · admin 56 / `prod_merge`: server 24 · site 7 · admin 14.
`dead-candidates.yaml` `deleted_paths`: server 83 · site 695 · admin 55 /
`revert_groups`: server 4 · site 4 · admin 0 / `unresolved_revert_targets`: server 1 · site 0 · admin 0.

## 3. 파일별 필드 사전

### `commits.<repo>.jsonl`

| 필드 | 타입 | 뜻 | enum | 근거 앵커 |
|---|---|---|---|---|
| `sha` | string | 커밋 SHA (40자) | — | — |
| `date` | string | **author** date, KST 오프셋 포함 | — | manifest `commit_rules.date_field: author` |
| `era` | string | 커밋 시기 구간 | `A`(~2025-12-31) · `B`(2026-01-01~) | manifest `commit_rules.era` |
| `subject` | string | 커밋 제목 | — | — |
| `body` | string | trailer 제거 후 본문. **null 이 아니다** — 부재는 빈 문자열이다 | 빈 문자열 5,568 · 비빈 622 | manifest `body_present_rule` |
| `body_present` | bool | 본문이 규약상 존재하는가 | — | `docs/_meta/findings/10-index-git.md` F-10-03 |
| `is_merge` | bool | 머지 커밋인가 | — | manifest `commit_rules.include_merges: true` |
| `file_count` | int | 통계 패스에서 센 파일 수 | — | — |
| `files[]` | array | `{status, path, old_path, score}` | `status`: `A`·`M`·`D`·`R` | — |
| `excluded_only` | bool | 변경이 exclude 경로에만 걸렸는가 | — | F-10-01 |
| `large_commit` | bool | 파일 수가 임계 초과인가 | — | manifest `large_commit_threshold: 100` |
| `structural_event` | bool | 정책 추출 제외 · 구조 변경으로만 기록 | — | 같음 |

**머지 커밋은 `files: []` · `is_merge: true`다.** 통계 패스가 `--no-merges`이기 때문이지
파일을 안 건드려서가 아니다.

**`body`는 `null`이 아니고 `body_present`와 1:1도 아니다.** 6,190행 전부 string이며 부재는
빈 문자열이다 — `body === null`을 부재 신호로 쓰면 한 행도 안 걸린다. 그리고 `body`가
**비어 있지 않은데 `body_present: false`인 행이 33건** 있다(비빈 622 = `true` 589 + `false` 33).
trailer만 남아 규약상 본문 없음으로 판정된 것이고, 그 33건이 F-10-03이 말하는
`strip_trailers`의 "등"이 미정의라 귀속이 닫히지 않는 바로 그 수치다.
**`body_present`는 규약 판정이므로 `body !== ""`로 대체하지 마라.**

### `comments.<repo>.jsonl`

| 필드 | 타입 | 뜻 | enum | 근거 앵커 |
|---|---|---|---|---|
| `file` | string | 저장소 기준 상대 경로 | — | — |
| `lang` | string | 파서가 본 언어 | — | — |
| `line_start` · `line_end` | int | 블록 범위 (동결 HEAD 기준) | — | — |
| `block_type` | string | 주석 형태 | `line` 8,444 · `jsdoc` 3,133 · `block` 1,111 | manifest `comment_rules.block_types` |
| `text` | string | 주석 원문 | — | — |
| `hangul_chars` | int | 한글 문자 수 | — | manifest `min_hangul_chars: 4` |
| `class` | string | 5단계 분류 | `1_policy` 1,096 · `2_rationale` 274 · `3_impl` 10,927 · `4_todo` 108 · `5_dead` 283 | manifest `comment_rules.classification` |
| `tags[]` | array | **매치된 분류 후보 전부**(값은 class id) | `1_policy` 1,096 · `2_rationale` 344 · `4_todo` 115 · `5_dead` 338 | manifest `comment_rules.classification` |
| `classified_by` | string | 무엇이 분류했는가 | `keyword` 1,478 · `code` 283 · `fallback` 10,927 | F-10-07 |
| `commented_out` | bool | 주석 안이 코드 문법인가 | — | manifest `exclude_commented_out_code` |
| `policy_candidate` | bool | `1_policy`·`2_rationale`인가 | — | manifest `classification` — 1~2만 정책 후보 |
| `legal_boilerplate` | bool | 이 주석이 **법정 고지 표면**(약관·동의·처리방침)을 가리키는가 | `true` 26 (server 0 · site 26 · admin 0) · `false` 12,662 | `index-pins.lock` `[classifier]` · F-10-25 |

`classified_by: fallback` 10,927건은 **키워드가 안 걸려 기본값 `3_impl`로 떨어진 것**이다.
정책성 주석이 여기 섞여 있을 수 있다 (F-10-07 · F-10-08).

**`tags[]`는 키워드 목록이 아니라 매치된 class id 목록이고, `class`는 그중 우선순위 승자다.**
빈 배열 10,927건은 `classified_by: fallback`과 정확히 일치한다. 비어 있지 않은 1,761건에서
`class`는 **항상** `tags`에 들어 있고(예외 0건), 두 개 이상 매치된 121건은
`1_policy` > `2_rationale` > `4_todo` > `5_dead` 순으로 갈린다. 그래서 tag 합계는 class 합계보다
크다 — `5_dead` tag 338 중 55건은 다른 class로 확정됐다. **한 주석이 어떤 분류에 걸렸는지 보려면
`class`가 아니라 `tags`를 봐야 한다.**

**`legal_boilerplate`의 규칙은 manifest 밖 추출기 국소 상수다** (F-10-25). 판정식은
`A군 OR (B군 AND 문맥군)`이고, 매칭 전에 대상 텍스트에서 공백류와 `·`·`.`·`,`를 지운 뒤
부분문자열로 본다. 규칙 버전은 `index-pins.lock` `[classifier] legal_boilerplate=`에
`legal-boilerplate-v<version>:<sha256>` 으로 박혀 있다.

sha256 입력은 다음 5조각을 `\n\x1e\n`로 이어 붙인 UTF-8 문자열이다 — 순서대로
1) 정규 스펙 객체의 결정적 JSON(키 정렬 · 각 군은 정규화 후 정렬된 배열 · 공백 없음),
2) `normalizeForLegal` 소스, 3) `isLegalBoilerplate` 소스, 4) `materializeLegalBoilerplate`
소스, 5) 토큰 상수의 같은 방식 JSON. **구현 소스까지 전이적으로 넣는 이유**는 스펙이
정규화를 이름 문자열로만 담기 때문이다 — 정규식이나 매치 연산을 바꾸고 스펙을 그대로 두면
해시가 같은 채 bool이 달라진다. 대가는 **주석·공백만 고쳐도 핀이 바뀌는 과민성**이고,
이는 안전한 방향의 오류다. 현재 값은
`node docs/_meta/index/extract-comments.mjs --print-classifier-pin`으로 재계산할 수 있고,
행별 재계산 전건 대조는 `--verify-classifier <comments.*.jsonl>`이 한다.

### `policy-candidates.git.jsonl`

| 필드 | 타입 | 뜻 | enum | 근거 앵커 |
|---|---|---|---|---|
| `id` | string | **`source` 마다 파트 수가 다르다** — 아래 표 | — | — |
| `repo` | string | 저장소. **값은 전체 이름이다** | `insuniverse-server` 1,122 · `insuniverse-site` 1,189 · `insuniverse-admin` 559 | — |
| `source` | string | 어느 산출물에서 왔는가 | `comment` 1,370 · `commit` 557 · `deleted_path` 833 · `prod_only` 102 · `revert` 8 | — |
| `grade` | string | 증거 등급 | `A` 1,472 · `B` 582 · `C` 816 | `.claude/meta/05-final-spec.md` §2 |
| `cohort` | string\|null | 코호트 표시 | `null` 2,392 · `site_path_duplicates` 478 | F-10-17 · F-10-18 |
| `anchor` | object | **`source` 마다 모양이 다른 union** — 아래 표 | — | — |
| `anchor_commit` | object | `{sha, date, era}`. **전 행 non-null** — null 인 것은 안쪽 `era` 다 | `era`: `B` 1,570 · `A` 1,198 · **`null` 102** | **F-10-21** |
| `summary` | string | 후보 요지 | — | — |
| `summary_truncated` | bool | 절단됐는가 (전 행 `false`) | — | — |
| `prod_reached` | bool | prod에 도달했는가 | `true` 2,500 · `false` 370 | `releases.yaml` `reachability` |
| `status` | string\|null | §3 status | `null` 1,589 · `dead-code-candidate` 809 · `staged-only` 364 · `prod-hotfix-unreconciled` 102 · `deprecated-confirmed` 6 | **F-10-20** |
| `status_resolution` | string | 판정이 닫혔는가 | `git-axis-incomplete` 1,589 · `resolved` 1,281 | F-10-20 |
| `status_basis` | string | 무엇이 그 판정을 냈는가 | `reachability:reached-prod` 1,563 · `reachability:unreached` 364 · `deletion:absent-at-head` 809 · `deletion:renamed` 20 · `deletion:absent-and-prod-unreached` 4 · `release:prod-only-bucket` 102 · `revert:state-absent` 2 · `revert:state-present` 2 · `revert:state-unresolved` 4 | F-10-20 |
| `status_open[]` | array | 아직 열려 있는 status 후보. **원소는 `status` enum이 아니다** — 아래 | `active-prod` · `conditional-enabled` · `conditional-unknown` · `data-dependent-unknown` 각 1,589 · `dead-code-candidate` 26 · `deprecated-confirmed` 26 · `staged-only` 2 | F-10-20 |
| `requires_corroboration` | bool | **전 행 `true`** | — | 부모 PRD 제약 3 |
| `legal_boilerplate` | bool\|**null** | 법정 고지 표면 지시 여부. **`source: "comment"` 만 bool** | `null` 1,500 · `false` 1,352 · `true` 18 | `index-pins.lock` `[classifier]` · F-10-25 |

**`id` 와 `anchor` 는 둘 다 `source` 로 갈린다.** 같은 1,370행(`comment`)만 README 가 오래 적어 온
모양이고 나머지 **1,500행(52%)** 은 다르다. **둘 다 `source` 로 먼저 분기하라.**

`id` 는 `:` 로 이은 문자열이고 **파트 수가 `comment` 만 4개, 나머지는 3개**다. 4파트를 전제로
`split(":")[3]` 을 읽으면 1,500행에서 `undefined` 가 된다.

| `source` | 건수 | `id` |
|---|---|---|
| `comment` | 1,370 | `<repo>:comment:<파일경로>:<line_start>-<line_end>` (4파트) |
| `commit` | 557 | `<repo>:commit:<sha>` (3파트) |
| `prod_only` | 102 | `<repo>:prod_only:<sha>` (3파트) |
| `deleted_path` | 833 | `<repo>:deleted_path:<파일경로>` (3파트) |
| `revert` | 8 | `<repo>:revert:<patch_id>` (3파트) |

`anchor` 도 마찬가지로 `source` 마다 키가 통째로 다르다. `anchor.file` 을 무조건 읽는 파서는
같은 1,500행에서 `undefined` 를 얻는다.

| `source` | 건수 | `anchor` 키 |
|---|---|---|
| `comment` | 1,370 | `{file, line_start, line_end, class}` |
| `commit` | 557 | `{sha, subject}` |
| `prod_only` | 102 | `{sha, subject}` |
| `deleted_path` | 833 | `{path, last_delete, delete_count, renamed_to}` — `renamed_to`만 nullable(null 813 · 비null 20) |
| `revert` | 8 | `{patch_id, state, state_basis, targets}` |

`revert` 의 `anchor.state_basis` 는 최상위 `status_basis` 와 **다른 필드**다 — 전자는
revert 그룹의 복원 판정 근거이고 후자는 이 후보의 status 판정 근거다.

### `releases.yaml`

최상위: `generated_by` · `manifest_version` · `measured_at` · `release_note_pattern` ·
`release_like_pattern` · `prod_tips_source` · `frozen_heads` · `prod_tips` · `repos`.
`repos.<repo>` 아래에 `prod_only` · `stg_only` · `prod_merge` · `release_like_unmatched` 버킷과
`reachability`(`index_rows` · `unreached[]` · `unindexed_stg_only[]` · `unindexed_reason_counts`)가
온다. 판정식은 파일 머리 주석에 도메인 조건과 같은 줄로 적혀 있다.

**버킷 4개의 엔트리 모양이 넷 다 다르다.** 하나로 뭉뚱그린 파서는 어디선가 반드시 어긋난다.

| 버킷 | 건수 | 엔트리 |
|---|---|---|
| `prod_only` | 102 | `{sha, date, subject}` |
| `stg_only` | 155 | **맨 SHA 문자열**(객체가 아니다) |
| `release_like_unmatched` | 5 | `{sha, date, subject, parents[]}` |
| `prod_merge` | 45 | `{sha, date, subject, parents[], on_prod_first_parent, parents_on_prod_first_parent[], also_ancestor_of_head}` |

`stg_only`에서 `.sha`를 읽으면 `undefined`가 된다. 반대로 `prod_merge`를 `{sha,date,subject}`로만
읽으면 **F-10-11이 말하는 "부모 토폴로지 기록" 그 자체가 빠진다** — `on_prod_first_parent`와
`parents_on_prod_first_parent`가 제목 일치 말고 실제로 남은 유일한 증거이고, 적중분의 부모 위치가
저장소마다 갈린다는 것이 F-10-11의 관찰이다. `also_ancestor_of_head`는 동결 stg HEAD 조상 여부다.
`stg_only`는 `reachability.unreached`의 정의역이다 — `unreached := stg_only ∩ index`이므로
미도달 판정을 재현하려면 이 버킷이 필요하다.

`prod_tips_source` · `frozen_heads` · `prod_tips` 셋은 전부 **저장소명 → 문자열 맵**이다
(키는 `insuniverse-server` · `insuniverse-site` · `insuniverse-admin`. `prod_tips_source` 의
값은 3개 다 `"locked"`, 나머지 둘은 SHA). `prod_tips_source` enum 은 `observed` (실행 시점
관측) · `pinned` (`--prod-tip` 인자) · **`locked` (`index-pins.lock` — §1)** 3종이고,
`observed` 만 실행 중 이동 재확인 대상이다(나머지 둘은 live ref 를 보지 않는다). `reachability` 안에서 `unreached[]` 와
`unindexed_stg_only[]` 는 모양이 다르다 — 전자는 맨 SHA 문자열 배열, 후자는
`{sha, reason}` 객체 배열이다. `unindexed_reason_counts` 의 키는 `"empty"` 와
`"planning-only"` 2개뿐이고 값은 정수다 (server 0/0 · site 1/1 · admin 0/3).

`measured_at`은 **prod tip 3개의 author date 중 최댓값**이고 추출기 실행 시각이 아니다.

### `dead-candidates.yaml`

최상위: `generated_by` · `manifest_version` · `measured_at` · `prod_tips_source` ·
`frozen_heads` · `prod_tips` · `repos` · `site_path_duplicates`. 앞 6개는 `releases.yaml`과
같은 뜻·같은 모양이다. `generated_by`만 자기 추출기(`extract-dead-candidates.mjs`)를 가리키고
`measured_at` · `frozen_heads` · `prod_tips` · `prod_tips_source`는 두 파일이 같은 값이다 —
같은 실행에서 같은 입력으로 나왔기 때문이다. `release_note_pattern` ·
`release_like_pattern`은 여기 없다(릴리스 축 전용이다).

`repos.<repo>.deleted_paths[]` — `{path, last_delete{sha,date,subject}, delete_count,
renamed_to, renamed_to_at, same_commit_adds[], same_commit_adds_omitted,
basename_survivors[], basename_survivors_omitted, migration_cohort, prod_unreached}`.
`repos.<repo>.revert_groups[]` — patch-id 그룹 단위. 키 11개: `{patch_id, targets[], applies[],
reverts[], event_sequence[], final_event, ordering_authoritative, head_applicability{result,
per_target}, prod_unreached, state, state_basis}`. `state`는 `present`·`absent`·`unresolved`다.
`event_sequence[]`는 `{kind, sha, date, on_first_parent}`이고 `kind`는 `A`(적용)·`R`(되돌림)다.
`targets[]`는 `{sha, date, subject, era, body_present}`, `head_applicability.per_target[]`는
`{sha, forward_applies, reverse_applies, result}`다. `applies[]`·`reverts[]`는 맨 SHA 배열이다.
`repos.<repo>.unresolved_revert_targets[]` — 되돌림 대상을 규약으로 지목하지 못한 커밋
(`{sha, date, subject, raw, reason}`. 전 저장소 통틀어 1건, server). F-10-14 가 이것이다.
`repos.<repo>.counts` — 12키 요약(`absent_at_head` · `absent_at_head_not_renamed` ·
`deleted_paths_total` · `merge_only_deletions` · `migration_cohort_site` ·
`prod_unreached_commits` · `revert_candidates` · `revert_groups` · `revert_state_{absent,present,unresolved}` ·
`unresolved_revert_targets`).

**`deleted_paths[]`의 길이는 `counts.absent_at_head`이지 `counts.deleted_paths_total`이 아니다.**
833(83·695·55) 대 873(104·713·56)이다 — 배열에는 **동결 HEAD 에서 부재인 경로만** 들어 있고,
지워졌다 되살아난 40건은 집계에만 남는다. `deleted_paths_total`을 배열 길이로 기대하면 어긋난다.

최상위 `site_path_duplicates` — `{repo, counts, add_commits[], paths[]}`. **478건의 매핑은
`paths[]`에 있고**(`counts.total`도 478), `add_commits[]`는 그 사본이 들어온 커밋 2건이다.
`paths[]` 키 9개: `{duplicate_path, origin_path, origin_in_index, origin_at_head, origin_fate,
origin_delete_count, blob_identical_at_add, classification, deployment_visibility}`.
`add_commits[]` 키 5개: `{sha, date, subject, site_blobs_in_tree, site_blobs_identical_to_origin}`.
`counts` 키 10개(`total` · `add_commits` · `blob_identical_at_add` · `blob_differing_at_add` ·
`fate_alive` · `fate_deleted` · `fate_renamed` · `origin_at_head` · `origin_in_index` ·
`origin_readded_after_delete`).

`deleted_paths[]` 개별 필드 — `renamed_to` `string|null`(비null 20 · null 813) ·
`renamed_to_at` **`{sha, date}|null`**(비null 20 · null 813. `renamed_to`와 항상 짝이다) ·
`migration_cohort` `null` 355 · `"_site"` 478 · `prod_unreached` `bool`(false 829 · true 4) ·
`same_commit_adds[]`·`basename_survivors[]`는 문자열 배열이고 짝인 `*_omitted`는 정수다.
**`prod_unreached`는 `revert_groups[]`에도 같은 이름으로 있다**(false 6 · true 2) — 두 배열의
같은 이름 필드를 한 카운터로 합치면 833+8이 섞인다.

**`renamed_to`는 그 경로 일생 전체에서 관측된 R이지 `last_delete`의 성질이 아니다.**
같은 사건인지 보려면 `renamed_to_at.date`와 `last_delete.date`를 직접 대조하라.
절단은 배열이 아니라 `*_omitted` 정수로 낸다 — `"+3"`은 git이 허용하는 실제 경로명이라
배열 안에 섞을 수 없다.

### `cross-repo-days.csv`

| 컬럼 | CSV 표기 | 타입 | 뜻 |
|---|---|---|---|
| `date` | 따옴표 | string | KST 정규화 일자 |
| `dow` | **맨값** | **int 1~7** | ISO 요일. **1=월 … 7=일.** 요일 이름이 아니다 |
| `server_commits` · `site_commits` · `admin_commits` | 맨값 | int | 그날 저장소별 커밋 수 |
| `shared_rare_tokens` | 따옴표 | **string** | **토큰 자체를 `\|`로 이은 목록**이지 개수가 아니다 |

**`shared_rare_tokens`는 수가 아니다.** 값은 `"시스템\|워크플로우"` · `"공제보험"` ·
`"curl\|따옴표에\|메시지\|인용\|조립으로"` 같은 **토큰 문자열**이고, 151행 중 **124행이 빈
문자열 `""`** 이다. 열 이름이 `policy` 가 아니라 `shared_rare_tokens` 인 이유가 이것이다
(F-10-19). `parseInt` 로 읽으면 82%가 `NaN` 이 되는데, 그 82%가 §5 가 말하는
"동시일이어도 서로 다른 주제" 쪽이다. **개수가 필요하면 빈 문자열을 0으로 두고 `|` 로 쪼개
세라** — 비어 있지 않은 27행이 §5 의 17.9% 다.

타입이 열마다 따옴표 유무로 갈리므로 파서는 열 단위로 캐스팅해야 한다. `date` 와
`shared_rare_tokens` 만 따옴표를 두르고 나머지 4열은 맨값이다.

RFC4180에 주석 구문이 없어 이 파일 안에는 한계를 적을 수 없다. §5와 사이드카
`docs/_meta/index/cross-repo-days.md`가 그 자리다.

## 4. 읽을 때 틀리기 쉬운 것

**`status`가 `null`인 1,589행은 "§3 8개 값에 없다"가 아니라 "git 축이 판정하지 못했다"는 뜻이다.**
§3은 status를 8개 값으로 고정하는데 그 8개는 전부 판정 **결과**이고 "판정 못 했다"에 해당하는
값이 없다. 그래서 미판정을 `null`로 두고 정본을 `status_resolution`으로 옮겼다. `null`을
"해당 없음"이나 "정상"으로 읽으면 1,589건이 조용히 사라진다 (F-10-20).

**`status_open[]`의 어휘는 `status`의 어휘와 겹치되 같지 않다.** 원소 7종 중 `active-prod` ·
`conditional-enabled` · `conditional-unknown` · `data-dependent-unknown` **4종은 `status` 필드에
한 번도 나타나지 않는다** — 이 축이 그 값으로 확정한 행이 0이기 때문이다. 나머지 3종
(`dead-code-candidate` · `deprecated-confirmed` · `staged-only`)만 겹치고, `status`에만 있는
`prod-hotfix-unreconciled`는 반대로 `status_open`에 안 나온다. 7종 + 그것 = **§3이 고정한 8개 값**이고
두 필드는 같은 어휘에서 뽑는다. 배열 모양도 규칙적이다 — 4원소 1,563건은 **항상 같은 4종**,
6원소 24건은 거기에 `dead-code-candidate` · `deprecated-confirmed`를, 7원소 2건은 `staged-only`를
더한 것이다.

**`status_resolution`과 `status_open`이 미판정의 정본이다.** 두 필드는 전 행 non-null이고,
`status_resolution == "resolved"`인 것과 `status_open`이 빈 배열인 것은 정확히 일치한다(1,281행).
**미판정 건수는 `status`의 null을 세지 말고 `status_resolution`으로 세라.** 나머지 1,589행의
`status_open`은 이 축이 닫지 못해 형제 PRD로 넘어가는 후보들이다 — 4원소 1,563 · 6원소 24 ·
7원소 2로 갈린다.

**`requires_corroboration`은 전 행 `true`인 상수다.** 필터 조건으로 쓸 수 없다.
git 축 단독으로는 부모 PRD 제약 3(교차 확증)을 만족시킬 수 없다는 사실을 행마다 박아 둔 것이고,
값이 갈리는 날은 다른 축이 확증을 붙인 뒤다.

**`grade`는 `.claude/meta/05-final-spec.md` §2 증거 등급이고 규칙이 소스별로 갈린다.**
`source == "comment"`이면 era와 무관하게 `A`, `source == "prod_only"`도 `A`다 — **`anchor_commit`은
있고 그 안의 `era`만 `null`이다**(102건 전부). `anchor_commit` 자체는 2,870행 **전부 non-null**이므로
`prod_only` 후보에서도 `anchor_commit.sha`·`.date`를 쓸 수 있다. 나머지
`commit`·`deleted_path`·`revert`는 **era로 갈린다** — Era B면 `B`, Era A면 `C`다. 즉 `C` 816건은 "질이 낮다"가 아니라 **자유형식 시절(Era A) 커밋이라 제목에서
scope를 못 읽는다**는 뜻이다.

**`cohort: "site_path_duplicates"` 478행은 이동이 아니라 바이트 동일 사본이다.**
`_site/`는 `front/`로 이동한 소스 트리가 **아니다** — rename 검출 0건이고 `front/` 첫 접촉이
`_site/` 최초 등장보다 4개월 앞선다. 2025-01에 두 번 커밋됐다 지워진 사본이다 (F-10-17).
배포에 노출됐는지는 저장소 안 증거로 단정할 수 없다 (F-10-18). **이 축은 거르지 않았으므로
소비자가 필터해야 한다** — 안 거르면 같은 정책이 최대 2번 세어진다.

**`legal_boilerplate: true`는 표시이지 제외가 아니다.** 태그된 행은 후보에 그대로 남아 있고
인용 여부는 소비자가 정한다. `false`는 "약관이 아니다"가 아니라 **"이 토큰 목록에 안 걸렸다"**
이며, `null`(`policy-candidates.git.jsonl`의 `comment` 밖 1,500행)은 **"판정 대상이 아니다"**
이지 `false`가 아니다. 또한 이 태그는 `class`를 가로지른다 — 태그된 26건 중 9건이 `1_policy`
밖이다(`3_impl` 7 · `5_dead` 1 · `2_rationale` 1).

**`anchor_commit`은 blame 결과라서 최종 수정 커밋이지 도입 커밋이 아니다.**
`anchor_commit.date`를 "정책 도입일"로 인용하는 것은 **틀린 인용**이다. 오타 수정 한 번이면
날짜가 그 커밋으로 옮겨간다. 도입 시점이 필요하면 별도 질의가 필요하다 (F-10-21).

## 5. `cross-repo-days.csv` 한계 — 조인 힌트이지 조인 키가 아니다

3-repo 동시 커밋일 151일은 실재하는 상관이지만 **절반 가까이가 평일 효과**다.
공통 활동 구간(2024-11-30 ~ 2026-08-05) 기준 원시 배수는 3.75배인데, 평일만으로 좁히면
**2.09배**로 내려간다 — 원시 3.75배 중 **약 45%가 "셋 다 평일에 일한다"**이다.

남은 신호의 정밀도도 낮다. 같은 날이 같은 *주제*인지를 제목 희소 토큰 교차로 재면,
동시일 151일 중 세 저장소가 희소 토큰을 공유한 날은 **27일(17.9%)**이다.
대조군(순환 시프트 k=1·7·30)은 **0.0~0.7%**라 신호 자체는 실재하지만, 동시일을 집어도
**82%는 서로 다른 주제**다.

그러므로 이 CSV는 **어디를 먼저 볼지 정하는 힌트**이지, 3-repo 사건을 묶는 **조인 키가 아니다.**
날짜로 세 저장소 커밋을 조인해 "같은 변경"이라고 부르면 5건 중 4건이 오조인이다.
정본은 `docs/_meta/findings/10-index-git.md` F-10-19이고, 같은 경고가 CSV 옆
`docs/_meta/index/cross-repo-days.md`에도 있다.

## 6. 알려진 한계

살아 있는 제약이다. 닫혔다고 읽지 마라.

- **`prod_merge`는 제목 일치와 부모 토폴로지 기록이지 배포 승격 판정이 아니다** (F-10-11).
  저장소 안에 릴리스를 확증할 독립 증거가 없다 — 3저장소 모두 태그 0개이고 `refs/replace/*`도
  비어 있다. 판정은 `00-protocol` 소관이다.
- **`origin/prod` tip은 manifest 동결 대상이 아니다** (F-10-12). 이 축 입력의 절반이 고정되지
  않았다는 뜻이고, 계산 전 스냅샷 ↔ 쓰기 직전 재확인으로만 닫혀 있다.
  마일스톤 8이 `index-pins.lock`으로 **실행 사이의** 드리프트를 닫았다 (F-10-24) — 다만 그것은
  manifest 승격이 아니라 이 축의 재현 보조물이고, **핀이 원격 prod보다 뒤처지는 것은 여전히
  가능하다**(설계된 대가다. 핀 파일 `# 측정:` 날짜와 마일스톤 게이트가 그 창을 지킨다).
- **`release_like_pattern`은 manifest 밖 추출기 국소 상수다** (F-10-10). manifest
  `release_rules`에 없으므로 규약이 아니라 코드에 사는 값이다. **이 문서는 그 사실을 적을 뿐
  manifest 승격 여부를 판단하지 않는다** — 규약 판단은 `00-protocol` 소관이라 이 축이 닫지 않는다.
- **`is_merge && body_present` 30건이 `commit` 소스에서 빠졌다** (F-10-22). 머지 커밋 본문에
  정책 근거가 있어도 정책 후보로 올라오지 않는다.
- **법정 고지 보일러플레이트가 후보에 섞여 있다 — 이제 태그로 표시된다** (F-10-25).
  약관·개인정보 처리방침 같은 `legal` 성격의 표면을 가리키는 주석이 `1_policy`로 분류돼 정책
  후보에 들어가는 것은 그대로다. 마일스톤 8이 `legal_boilerplate` 필드로 **표시만** 했고
  **후보에서 빼지 않았다** — 인용 여부는 소비자(`20`~`60`) 판단이다. 판정 토큰 목록은
  manifest 밖 추출기 국소 상수이므로 **규약 승격 여부는 `00-protocol` 판단**이고 이 축이
  닫지 않는다(`release_like_pattern`과 같은 자세). 미탐의 대가는 "구별되지 않음"이지
  "사라짐"이 아니다.
- **`comment_rules`가 스캔 범위를 규정하지 않는다** (F-10-06). 무엇을 훑었는지가 규약이 아니라
  추출기 구현에 있다.
- **삭제와 이동이 한 커밋에 섞이면 `--diff-filter=D`만으로 구별되지 않는다** (F-10-16).
  `renamed_to` · `same_commit_adds` · `basename_survivors` 셋 중 어느 것도 이동을 증명하지 않고,
  셋이 다 비어도 폐기를 증명하지 않는다.

용어 신설 요청 이력은 `docs/_meta/term-requests/10-index-git.md`에 있다.
