#!/usr/bin/env bash
# verify.sh — extraction-manifest.yaml 이 동결한 값을 다시 만들어 대조한다.
#
# 왜 있는가:
#   manifest 는 "같은 입력으로 두 번 돌린 결과가 같은지 판정되게 하는 것" 하나를 위해 존재한다.
#   그런데 동결값을 사람이 눈으로 대조하면 그 판정은 실행되지 않는다. 이 스크립트가 저장소 SHA 3 ·
#   exclude 두 목록 · screen_codes · menu 하드코딩을 **다시 만들어** golden.txt 와 diff 한다.
#   재현성 판정은 프레임워크가 아니라 diff 의 빈 출력이다.
#
#   이 저장소에는 CI 가 없다. 그래서 doc-kit lint 전수 실행도 여기서 겸한다 — 우산 PRD 제약 #7 을
#   강제하는 유일한 실행 지점이다. lint 대상이 0건이면 통과가 아니라 실패다.
#
# 사용:
#   bash docs/_meta/verify/verify.sh                      # 리포트를 stdout 으로
#   bash docs/_meta/verify/verify.sh --check              # 상시 게이트
#   bash docs/_meta/verify/verify.sh --check --bootstrap  # lint 대상 0건 한시 허용.
#                                                         # lint glob 을 소유한 문서 PRD 가 전부
#                                                         # *Status: DRAFT 인 동안만 유효하며, 하나라도
#                                                         # 착수하면 거부된다(bootstrap_permitted).
#   bash docs/_meta/verify/verify.sh --update             # golden.txt 갱신 (전 검증 통과 시에만)
#   bash docs/_meta/verify/verify.sh --selftest           # 파서 불변식을 결함 fixture 로 양방향 검사
#   bash docs/_meta/verify/verify.sh --dump-exclude-inputs <dir>
#                                                         # 배제 분류 4종만 복사하고 종료 (값 미산출).
#                                                         # extract-disputed-counts.mjs 의 --exclude-digest
#                                                         # 물림을 이 스크립트 밖에서 검사하기 위한 것이다
#
# 종료 코드: 0 정상 / 1 검사 실패 / 2 사용법 오류
#
# 환경변수:
#   VERIFY_MANIFEST  파싱할 manifest 경로 (--selftest 가 fixture 를 물릴 때만 쓴다)
#   TMPDIR           작업용 임시 디렉터리. `mktemp -d` 의 규칙을 그대로 따른다 — 이 스크립트는
#                    모든 중간 산출물을 임시 디렉터리에 두므로 **쓰기 가능한 임시 디렉터리가
#                    전제**다. 없으면 시작 직후 mktemp 가 실패하고 아무 모드도 돌지 않는다.

set -euo pipefail
export LC_ALL=C

ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
cd "$ROOT"

MANIFEST="${VERIFY_MANIFEST:-$ROOT/docs/_meta/extraction-manifest.yaml}"
GOLDEN="$ROOT/docs/_meta/verify/golden.txt"
SCREEN_EXTRACTOR="$ROOT/docs/_meta/verify/extract-screen-codes.mjs"
MENU_EXTRACTOR="$ROOT/docs/_meta/verify/extract-menu-codes.mjs"
DISPUTED_EXTRACTOR="$ROOT/docs/_meta/verify/extract-disputed-counts.mjs"
LINTER="$ROOT/.claude/templates/doc-kit/lint-docs.mjs"
TEMPLATE_CONFIG="$ROOT/docs/_meta/template-config.yaml"
SPEC="$ROOT/.claude/meta/05-final-spec.md"
ONBOARDING="$ROOT/docs/_meta/onboarding-questions.yaml"
PRD_DIR="$ROOT/.claude/prds"
# PRD00 · PRD_UMBRELLA 는 prd_path() 정의 뒤(아래 함수 블록)에서 해석한다 —
# 완료 PRD 는 archived/ 로 옮겨지고 이 두 상수는 그 시점에 하드코딩된 경로를
# 가리키고 있었다(실측: `/mccp:archive-complete` 직후 `--check` 가 exit 2).
INDEX="$ROOT/docs/00-index.md"
OWNERSHIP="$ROOT/docs/_meta/ownership.md"
FINDINGS="$ROOT/docs/_meta/findings"

TMP=$(mktemp -d)
# golden 교체용 임시파일은 golden.txt **옆**에 만든다. mktemp 디렉터리는 별도 파일시스템일 수
# 있고(리눅스 /tmp · 컨테이너 · 마운트된 worktree), 그러면 mv 가 rename 이 아니라 복사+삭제로
# 떨어져 중단·디스크 부족 시 golden.txt 가 반쯤 쓰인 채 남는다. 같은 디렉터리면 rename 이라
# 원자적이다 — --update 는 구 golden 을 보관하지 않으므로 반쯤 쓰인 결과는 복구 불가다.
#
# 이름은 PID 가 아니라 mktemp 로 받는다. `$$` 는 예측 가능해서 미리 심링크를 깔아 두면 출력이
# 엉뚱한 경로로 나간다. mktemp 는 O_EXCL 0600 으로 만들어 그 경쟁을 없앤다. 실제 이름은
# --update 에서 정해지므로 여기서는 비워 두고 trap 만 걸어 둔다(모드 무관 잔재 정리).
GOLDEN_TMP=""
trap 'rm -rf "$TMP"; if [ -n "${GOLDEN_TMP:-}" ]; then rm -f "$GOLDEN_TMP"; fi' EXIT

# golden.txt 헤더 — 위치(1~3행)와 내용이 둘 다 고정이다. "# 로 시작하는 행을 양쪽에서 무시"하는
# 방식을 쓰지 않는 이유: 그러면 리포트 데이터를 # 뒤에 숨길 수 있다.
GOLDEN_HEADER_1='# golden.txt — verify.sh 리포트의 동결본. 재현성 판정의 기준선이다.'
GOLDEN_HEADER_2='# 갱신 시점: manifest 동결값(저장소 SHA · exclude 목록 · 추출 수치)이 정당하게 바뀌었을 때만.'
GOLDEN_HEADER_3='# 갱신 방법: bash docs/_meta/verify/verify.sh --update   (전 검증을 통과했을 때만 기록된다)'

die() { printf '%s\n' "$*" >&2; exit 1; }
usage() { printf '%s\n' "$*" >&2; exit 2; }

# 불변식 위반은 고유 토큰 1개를 stderr 에 1회 emit 하고 즉시 exit 1 한다(fail-fast).
# 토큰이 2개 동시에 뜨면 --selftest fixture 의 배타 단언이 무의미해진다.
inv_fail() {
  printf 'VERIFY_INV_%s %s\n' "$1" "$2" >&2
  exit 1
}

emit() { printf '%-38s %s\n' "$1" "$2"; }

# PRD 파일명 → 실경로. 완료된 PRD 는 `/mccp:archive-complete` 가 `.claude/prds/archived/`
# 로 옮기지만, ownership.md §승계 시 추가 · §승격 대상 목록 · findings/ 대응 규약은 전부
# **파일명**으로 근거를 건다 — 근거는 그 PRD 가 선언한 내용이지 파일이 놓인 자리가 아니다.
# `$PRD_DIR/<name>` 만 보던 다섯 자리가 아카이브 시점에 일제히 거짓 실패로 돌아섰다
# (12-index-runtime 아카이브 후 실측). 두 자리를 순서대로 보고 stdout 으로 경로를 낸다.
# 아카이브본이 우선하지 않는 것은 의도다 — 같은 이름이 양쪽에 있으면 살아 있는 쪽이 정본이다.
prd_path() {
  if   [ -f "$PRD_DIR/$1" ];          then printf '%s\n' "$PRD_DIR/$1"
  elif [ -f "$PRD_DIR/archived/$1" ]; then printf '%s\n' "$PRD_DIR/archived/$1"
  else return 1
  fi
}

# findings/ 형식 전환 유예 명단 (basename, 공백 구분). m7 이 §발견 기록 규약의 필수 6항목을
# `### ` 표제로 못박기 **전에** 쓰인 파일들이다. 세 파일 모두 머리에 "형식 정본은 00-protocol
# 마일스톤 7(pending)이며 그때 맞춘다" 는 전환 계약을 명시하고 있고, 본문 소유는 각 생성 PRD 다
# (ownership.md — `findings/` 는 디렉터리·기록 형식만 00-protocol 소유). 그래서 이 PRD 가
# 개작할 수 없고, 개작 없이 강제하면 게이트가 남의 소유 경로를 인질로 잡는다.
#
# 이 명단은 리터럴이지만 **양방향으로 검사된다** — 명단에 있는데 이미 6항목을 만족하면 실패하고
# (유예의 영구화 차단), 명단 밖은 전면 강제다. 세 PRD 가 각자 전환하며 여기서 지우면 명단이
# 비고 게이트는 자동으로 전면 강제가 된다. 이 저장소가 세 번 기각한 "검사 없는 규범" 과 다른
# 점이 그것이다.
FINDINGS_FORMAT_DEFERRED="10-index-git 11-index-code 12-index-runtime"

findings_deferred() {
  case " $FINDINGS_FORMAT_DEFERRED " in *" $1 "*) return 0 ;; *) return 1 ;; esac
}

# 위 §변수 블록에서 미룬 두 상수. prd_path() 가 있어야 archived/ 폴백이 걸리므로
# 여기서 해석한다. 부재는 여기서 죽이지 않는다 — 각 게이트가 자기 문맥의 오류
# 문구로 실패하는 편이 진단에 낫고, 그것이 이 스크립트가 다른 정본 파일에
# 쓰는 방식과 같다.
PRD00=$(prd_path insuniverse-docs-00-protocol.prd.md || printf '%s' "$PRD_DIR/insuniverse-docs-00-protocol.prd.md")
PRD_UMBRELLA=$(prd_path insuniverse-policy-docs.prd.md || printf '%s' "$PRD_DIR/insuniverse-policy-docs.prd.md")

# ─────────────────────────────────────────────────────────────────────────────
# manifest 파싱
# ─────────────────────────────────────────────────────────────────────────────

NORM="$TMP/manifest.norm"

normalize_manifest() {
  [ -f "$MANIFEST" ] || die "manifest 를 찾을 수 없다: $MANIFEST"
  # manifest 는 CRLF 다. Cygwin sed/awk 는 CR 을 조용히 벗기지만 MSYS2·Linux 는 벗기지 않아
  # 파싱값 끝에 \r 이 붙는다 — golden 내용과 sha256 이 머신마다 갈려 "신규 클론 복원" 지표와
  # 충돌한다. 읽는 지점에서 1회 정규화하고 이후는 사본만 본다.
  tr -d '\r' < "$MANIFEST" > "$NORM"
}

# slice_section <indent> <key> <출력 prefix> [연속행 허용 정규식]
#   범위: 시작 키 행 다음부터, 들여쓰기가 시작 키 이하인 첫 행 직전까지.
#
#   범위 안의 모든 비공백·비주석 행은 **항목이거나 잔여**다. 제3의 분류는 없다.
#   초안은 들여쓰기가 항목 깊이와 정확히 같은 행만 보고 나머지를 조용히 버렸는데, 그러면
#   `exclude_pathspec` 안에 `    unexpected: value` 를 넣어도 아무 토큰이 뜨지 않고 목록이
#   멀쩡해 보인다 — 불변식 5 가 잡아야 할 "비지 않은 잘못된 결과"가 정확히 그 구멍으로 샌다.
#   게다가 --update 는 이 파서만 통과하면 구 golden 을 읽지 않고 기준선을 축성한다.
#
#   항목보다 깊은 행은 **허용 형태를 명시한 목록에서만** 통과한다(중첩 목록의 `why:` 등).
#   형태를 못 대면 잔여다. 새 형태가 필요해지면 호출부에서 명시적으로 넓힌다 — 조용히
#   사라지는 것보다 시끄럽게 걸리는 쪽이 이 스크립트의 계약이다.
slice_section() {
  local ind="$1" key="$2" pfx="$3" cont="${4:-}"
  : > "$pfx.items"
  : > "$pfx.residual"
  : > "$pfx.status"
  awk -v IND="$ind" -v KEY="$key" -v PFX="$pfx" -v CONT="$cont" '
    function ind_of(s,   i) { i = 0; while (substr(s, i + 1, 1) == " ") i++; return i }
    BEGIN { started = 0; ended = 0; sawitem = 0; ITEM = IND + 2 }
    {
      if (!started) {
        if (ind_of($0) == IND && index($0, KEY ":") == IND + 1) started = 1
        next
      }
      if ($0 ~ /^[[:space:]]*$/) next
      d = ind_of($0)
      if (d <= IND) { ended = 1; exit }
      if (substr($0, d + 1, 1) == "#") next
      if (d == ITEM) {
        if (substr($0, ITEM + 1, 2) == "- ") { sawitem = 1; print $0 > (PFX ".items"); next }
        print $0 > (PFX ".residual")
        next
      }
      if (CONT != "" && sawitem && d > ITEM && $0 ~ CONT) next
      print $0 > (PFX ".residual")
    }
    END { printf "%d %d\n", started, ended > (PFX ".status") }
  ' "$NORM"
}

# parse_list <indent> <key> <shape-정규식> <값추출 sed> <출력파일> [연속행 허용 정규식]
#   불변식 5종을 순서대로 단언한다. 하나라도 깨지면 해당 토큰으로 즉시 exit 1.
parse_list() {
  local ind="$1" key="$2" shape="$3" pick="$4" out="$5" cont="${6:-}"
  local pfx="$TMP/slice.$key"
  local keyre n started ended bad

  # 1. 키 유일성 — 0회·2회 모두 실패
  keyre="^ {$ind}$key:"
  [ "$ind" -eq 0 ] && keyre="^$key:"
  n=$(grep -cE "$keyre" "$NORM" || true)
  [ "$n" -eq 1 ] || inv_fail KEY_UNIQUE "$key 키가 파일 전체에서 ${n}회 등장 (정확히 1회여야 한다)"

  slice_section "$ind" "$key" "$pfx" "$cont"
  read -r started ended < "$pfx.status"

  # 2. 경계 존재 — 종료 행이 없으면(EOF) 실패. 뒤에 무엇을 덧붙여도 조용히 섹션에 합류한다
  [ "$started" -eq 1 ] || inv_fail BOUNDARY "$key 섹션 시작을 찾지 못했다"
  [ "$ended" -eq 1 ] || inv_fail BOUNDARY "$key 섹션의 종료 행이 없다 (EOF 까지 열려 있음)"

  # 3. 항목 형태 — 후행 주석은 허용, 그 밖의 형태는 거부
  bad=$(grep -vcE "$shape" "$pfx.items" || true)
  [ "$bad" -eq 0 ] || inv_fail ITEM_SHAPE "$key 항목 ${bad}건이 기대 형태와 다르다: $(grep -vE "$shape" "$pfx.items" | head -3 | tr '\n' '|')"

  # 4. 비공집합
  n=$(wc -l < "$pfx.items")
  [ "$n" -ge 1 ] || inv_fail NONEMPTY "$key 목록이 비어 있다"

  # 5. 잔여 없음 — 리스트가 아닌 행이 범위 안에 남으면 인접 섹션이 잘못 귀속된 것이다
  n=$(wc -l < "$pfx.residual")
  [ "$n" -eq 0 ] || inv_fail NO_RESIDUAL "$key 범위 안에 리스트가 아닌 행 ${n}건: $(head -3 "$pfx.residual" | tr '\n' '|')"

  sed -E "$pick" "$pfx.items" > "$out"
}

TOP_SHAPE='^  - "[^"]*"[[:space:]]*(#.*)?$'
TOP_PICK='s/^  - "([^"]*)".*$/\1/'
NESTED_SHAPE='^    - pattern: "[^"]*"[[:space:]]*(#.*)?$'
NESTED_PICK='s/^    - pattern: "([^"]*)".*$/\1/'

# 최상위 두 목록은 평평하다 — 항목보다 깊은 행에 정당한 것이 하나도 없으므로 연속행을 허용하지
# 않는다(빈 문자열). 중첩 두 목록이 갖는 연속행은 스키마상 `      why: <값>` **하나뿐**이므로
# 키 이름까지 박아 둔다. `[a-z_]+` 로 넓히면 `      unexpected: value` 가 통과해 조용히 사라지고,
# 그것이 바로 이 변경이 닫으려던 fail-open 경로다 — 깊이만 옮겨 놓은 같은 구멍이 된다.
# 블록 스칼라나 새 필드가 필요해지면 이 정규식을 의도적으로 넓힌다. 그때까지는 시끄럽게 걸린다.
NESTED_CONT='^      why: .'

parse_manifest_lists() {
  parse_list 0 exclude_pathspec "$TOP_SHAPE"    "$TOP_PICK"    "$TMP/pathspec.raw"
  parse_list 0 exclude_glob     "$TOP_SHAPE"    "$TOP_PICK"    "$TMP/glob.raw"
  parse_list 2 pathspec_only    "$NESTED_SHAPE" "$NESTED_PICK" "$TMP/pathspec_only.raw" "$NESTED_CONT"
  parse_list 2 glob_only        "$NESTED_SHAPE" "$NESTED_PICK" "$TMP/glob_only.raw"     "$NESTED_CONT"
}

# repos — 불변식 5종의 대상은 아니지만(4개 목록 한정) 빈 값·형태 이상 통과는 막는다.
#
# 검사는 **소스 행**에서 한다. awk 가 뽑아 놓은 결과를 검사하면 잘림을 볼 수 없다 —
# `path: bad path/admin` 은 $2 가 `bad` 로 잘려 결과만 보면 형태가 멀쩡하다. 조용히 틀린
# 값이 만들어지는 바로 그 부류라, 잘리기 전 행에서 필드가 하나인지를 본다.
REPOS_NAME_SHAPE='^  - name: [A-Za-z0-9._-]+$'
REPOS_PATH_SHAPE='^    path: [A-Za-z0-9._/-]+$'
REPOS_HEAD_SHAPE='^    head: "[0-9a-f]{40}"[[:space:]]*(#.*)?$'

parse_repos() {
  # **먼저 섹션을 자른다.** 파일 전체에 awk 를 돌리면 `repos:` 밖 어디에 있든 name/path/head
  # 3행이 모이기만 하면 저장소로 잡힌다 — manifest 주석이나 다른 블록에 그런 3행이 들어오면
  # 조용히 틀린 저장소·SHA 로 값이 만들어진다(santa R11-B). 4개 목록이 이미 slice 를 거쳐
  # 검사받는 것과 같은 이유다.
  local n
  n=$(grep -cE '^repos:' "$NORM" || true)
  [ "$n" -eq 1 ] || die "manifest repos: 키가 파일 전체에서 ${n}회 등장 (정확히 1회여야 한다)"

  # `repos:` 다음 행부터, 들여쓰기 없는 다음 최상위 행 직전까지.
  awk '
    f && /^[^[:space:]#]/ { exit }
    /^repos:[[:space:]]*$/ { f = 1; next }
    f
  ' "$NORM" > "$TMP/repos.slice"
  [ -s "$TMP/repos.slice" ] || die "manifest repos: 섹션이 비어 있다"

  awk '
    /^  - name: / { name = $3 }
    /^    path: / { path = $2 }
    /^    head: / { h = $2; gsub(/"/, "", h); if (name != "" && path != "" && h != "") print name, path, h; name = ""; path = ""; }
  ' "$TMP/repos.slice" > "$TMP/repos.raw"
  [ -s "$TMP/repos.raw" ] || die "manifest repos: 를 파싱하지 못했다 — name/path/head 3필드가 온전한 항목이 0건"

  # path 는 git -C 인자로 흘러간다. 값 자체는 큰따옴표 문맥에서만 쓰여 재확장이 없지만,
  # 조용히 틀린 값이 만들어지는 것은 이 스크립트가 닫으려는 실패 부류 그 자체다.
  repos_shape_check '^  - name: '  "$REPOS_NAME_SHAPE" name
  repos_shape_check '^    path: '  "$REPOS_PATH_SHAPE" path
  repos_shape_check '^    head: '  "$REPOS_HEAD_SHAPE" head
}

# repos_shape_check <행 선택 정규식> <기대 형태> <필드명>
repos_shape_check() {
  local pickre="$1" shape="$2" field="$3" bad
  # 검사 대상은 파일 전체가 아니라 잘라 낸 repos 섹션이다 — 밖의 행을 검사하면 정당한 다른
  # 블록이 걸리고, 밖의 행을 값으로 쓰면 조용히 틀린 저장소가 만들어진다.
  bad=$(grep -E "$pickre" "$TMP/repos.slice" | grep -vE "$shape" | head -1 || true)
  [ -z "$bad" ] || die "manifest repos: $field 행 형태가 기대와 다르다 (필드가 하나여야 한다): $bad"
}

# ─────────────────────────────────────────────────────────────────────────────
# exclude 정합 판정 — wide / anchored / ext 3분류
#
# basename 접기는 쓰지 않는다. `*/_statics/*` 를 지우고 `public/_statics/*` 만 남긴 상태가
# 접기 후 같은 집합이 되어 OK 로 통과하는데, 그 순간 git 은 public/ 밖의 _statics 를 포함하기
# 시작하고 파일 스캔은 여전히 전부 prune 한다 — 잡아야 할 바로 그 발산이다.
# ─────────────────────────────────────────────────────────────────────────────

classify_pathspec() {
  : > "$TMP/wide"; : > "$TMP/anchored"; : > "$TMP/ext"
  local p
  while IFS= read -r p; do
    p=${p#:(exclude)}
    case "$p" in
      \*.*)
        [ "${p#*/}" = "$p" ] || die "분류 불가한 pathspec 항목: $p"
        printf '%s\n' "$p" >> "$TMP/ext" ;;
      */\*)
        local inner=${p%/\*}
        case "$inner" in
          */*)
            if [ "${inner%/*}" = "*" ]; then
              printf '%s\n' "${inner##*/}" >> "$TMP/wide"     # */X/*  → wide X
            else
              printf '%s\n' "$p" >> "$TMP/anchored"           # a/b/X/* → anchored
            fi ;;
          *) printf '%s\n' "$inner" >> "$TMP/wide" ;;          # X/*    → wide X
        esac ;;
      *) die "분류 불가한 pathspec 항목: $p" ;;
    esac
  done < "$TMP/pathspec.raw"
  sort -o "$TMP/wide" "$TMP/wide"
  sort -o "$TMP/anchored" "$TMP/anchored"
  sort -o "$TMP/ext" "$TMP/ext"
}

# 판정 3건. MISMATCH 상세는 stderr, 판정 요약은 stdout 리포트.
check_parity() {
  local verdict=OK diffout base

  # glob 목록에서 glob_only 선언분을 뺀 것이 "양쪽에 있어야 하는 디렉터리"다
  sort "$TMP/glob.raw" > "$TMP/glob.sorted"
  sort "$TMP/glob_only.raw" > "$TMP/glob_only.sorted"
  comm -23 "$TMP/glob.sorted" "$TMP/glob_only.sorted" | sed 's:/$::' | sort > "$TMP/glob_dirs"

  # 판정 1 — wide 이름 집합 == glob − glob_only
  diffout=$(diff "$TMP/wide" "$TMP/glob_dirs" || true)
  if [ -n "$diffout" ]; then
    verdict=MISMATCH
    printf 'exclude 정합 판정 1 실패 — pathspec wide 집합과 glob(− glob_only) 집합이 다르다\n' >&2
    printf '%s\n' "$diffout" | sed 's/^</  pathspec 에만: /; s/^>/  glob 에만    : /' >&2
  fi

  # 판정 2 — ext 집합 == pathspec_only 선언
  sort "$TMP/pathspec_only.raw" > "$TMP/pathspec_only.sorted"
  diffout=$(diff "$TMP/ext" "$TMP/pathspec_only.sorted" || true)
  if [ -n "$diffout" ]; then
    verdict=MISMATCH
    printf 'exclude 정합 판정 2 실패 — pathspec 확장자 필터와 pathspec_only 선언이 다르다\n' >&2
    printf '%s\n' "$diffout" | sed 's/^</  실제에만  : /; s/^>/  선언에만  : /' >&2
  fi

  # 판정 3 — anchored 의 basename 이 wide 에 있으면 redundant(통과), 없으면 두 층이 갈라진 상태
  while IFS= read -r a; do
    [ -n "$a" ] || continue
    base=${a%/\*}; base=${base##*/}
    if ! grep -qxF "$base" "$TMP/wide"; then
      verdict=MISMATCH
      printf 'exclude 정합 판정 3 실패 — anchored 규칙 %s 의 basename %s 가 wide 집합에 없다.\n' "$a" "$base" >&2
      printf '  좁은 규칙만 남아 git 층이 %s 를 %s 바깥에서 포함하기 시작한다 (파일 스캔은 계속 prune).\n' "$base" "${a%%/*}" >&2
    fi
  done < "$TMP/anchored"

  printf '%s\n' "$verdict" > "$TMP/parity"
}

# ─────────────────────────────────────────────────────────────────────────────
# 리포트 — `키<공백>값` 1행 단위. 시각·절대경로를 넣지 않는다(결정성 검사의 전제).
# ─────────────────────────────────────────────────────────────────────────────

# template-config 값 추출 2종. gen_report(골든 9행)와 run_template_config_gate(교차 대조)가
# 둘 다 쓴다 — 파서를 두 벌 두면 그 둘이 갈라진다.
#
# "흐름형 리스트는 한 줄"이라는 가정은 schema 가 여러 줄 연장을 FAIL 로 금지해 성립한다.
# 어긋나면 lint 가 이 게이트보다 먼저 실패하고, 그래도 새면 호출부의 [ -s ] 가 빈 추출을 잡는다.
# **주석·인용을 넓히지 않고 좁힌다** (santa m5 R2). 라운드 1 은 `]` 뒤 주석을 허용해 lint 쪽으로
# 넓혔는데 그 확장이 두 방향 모두에서 깨졌다:
#   A) `\(#.*\)\?` 가 공백을 요구하지 않았다. lint 의 stripComment 는 `#` 바로 앞이 공백일 때만
#      주석으로 치므로 `repos: [a, b]#c` 를 verify 는 `a, b` 로 읽고 lint 는 throw 한다.
#   B) `tc_scalar` 는 따옴표 상태를 모른 채 ` *#.*$` 를 잘라, `unknown_status_suffix: '#unknown'`
#      을 `'` 라는 쓰레기 값으로 만들었다. 비어 있지 않아 tc_guard 를 통과한다.
#
# 주석 판정이 lint 와 등가이려면 따옴표·대괄호 상태 기계가 필요하다. 셸로 세 번째 구현을 짜면
# 그것이 또 갈라진다 — 위 두 건이 그 증거다. 그래서 값 행에서 `#` 와 `'` 를 **배제**한다.
# 둘 다 없는 줄은 두 파서가 같게 읽는 것이 자명하다(tc_guard 가 거부한다). 설명은 키 위의
# 주석 줄에 쓴다 — 현 config 9개 키가 이미 그렇다.
# 콜론 뒤 공백은 개수를 묻지 않는다 (santa m5 R4 Reviewer B). lint 의 splitKey 는
# `^([A-Za-z0-9_.-]+):(?:\s(.*))?$` 로 공백 1개를 먹고 나머지를 trim 하므로 `repos:  [a, b]`
# 가 유효한데, 초안은 리터럴 공백 1개만 매치해 0건으로 떨어뜨리고 "블록형만 유효하다"는
# **틀린 진단**으로 죽였다. 추출은 관대하게(0개 이상), "1개 이상" 규칙은 tc_guard 가 건다 —
# 정규식을 두 군데서 조이면 그 둘이 또 갈라진다.
tc_list()   { tr -d '\r' < "$TEMPLATE_CONFIG" | sed -n "s/^$1:[[:space:]]*\[\(.*\)\][[:space:]]*\$/\1/p" | tr ',' '\n' | sed 's/^ *//; s/ *$//'; }
tc_scalar() { tr -d '\r' < "$TEMPLATE_CONFIG" | sed -n "s/^$1:[[:space:]]*//p" | sed 's/[[:space:]]*$//; s/^"//; s/"$//'; }

# tc_guard <키> <scalar|list> <포착한 값> — 빈 추출과 중복 등장을 emit **전에** 잡는다.
#
# santa 라운드 1 Reviewer B(gpt-5.4) 지적. golden 은 template_config.* 9행을 동결하는데
# emit 경로에는 "매칭 0건은 통과가 아니라 실패" 원칙이 빠져 있었다 — run_template_config_gate
# 는 repos·statuses·status_precedence 3종만 [ -s ] 로 막고, evidence_grades·auth_layers·
# unknown_status_suffix 는 lint 가 간접 검증하며, services 는 마일스톤 6 이 소비자를 붙였고,
# **project 는 소비자가 0이다.** 그래서 `project:` 를 지우고 --update 를 돌리면 빈 값이 정상
# 기준선으로 축성되고, 이후 --check 는 "빈 값이 그대로다"만 확인한다.
#
# 값의 *정당성*까지 기계가 판정하지는 못한다 — project 에는 댈 정본이 없다. 기계가 댈 수 있는
# 것은 존재와 유일성이고, 값이 바뀌는 것은 golden diff 가 잡는다. 그 둘의 합이 이 키에 걸 수
# 있는 전부다(actors 와 같은 처지이며, actors 는 그 사실이 config 주석에 이미 적혀 있다).
#
# **값을 포착한 뒤 검사한다.** `emit "x" "$(tc_scalar project)"` 안에서 die 하면 서브셸만
# 죽고 emit 은 빈 값으로 성공한다 — set -e 도 명령치환의 실패는 잡지 않는다. 막으려던 바로
# 그 fail-open 이 가드 안으로 옮겨 앉는 형태라, 호출부가 변수로 받아 이 함수에 넘긴다.
# **키 유일성은 값이 아니라 파일에서 센다** (santa m5 R1 Reviewer B). 초안은 scalar 만
# 추출 결과의 행 수로 중복을 잡았는데, list 는 원소마다 한 행이라 그 방법이 성립하지 않아
# 7개 list 키가 통째로 무방비였다. 그리고 두 파서가 중복을 **다르게** 읽는다 —
# `tc_list` 는 `sed …p` 라 매치하는 모든 행을 출력해 **병합**하고, lint 의 parseFlatYaml 은
# `out[key] = …` 평문 대입이라 **last-wins** 다. 둘 다 오류를 내지 않는다. 그래서
# `repos:` 를 두 줄로 쪼개 union 이 manifest 와 같으면 게이트가 초록으로 통과했고,
# lint 가 읽지 않는 키(`actors` — config 주석이 명시)라면 아무 층도 보지 못한다.
#
# 같은 파일이 형제 파서에는 이미 이 계약을 걸어 뒀다 — `parse_repos` 의 `repos:` 1회 검사
# (그리고 `--selftest` 의 repos_dup fixture). m5 가 config 파서를 쓰면서 가져오지 않았을 뿐이라
# 새로 설계할 것이 없다.
#
# **지원 형태를 명시적으로 좁힌다.** schema 는 스칼라 리스트를 흐름형(2번)과 블록형(3번)
# 둘 다 허용하고 목록이 길어지면 블록형을 권한다. 이 게이트는 흐름형만 읽는다 — 블록형까지
# 손으로 구현하면 같은 문법의 독립 구현이 둘이 되고, 그 둘이 어긋나는 것은 위 중복 키가
# 방금 보여 준 바로 그 실패다(둘 다 조용히 성공하며 다른 값을 낸다). 결손을 숨기는 대신
# **정확한 진단으로 거부**한다 — manifest 가 `menu_codes` DB 축을 `complete: false` +
# `db.gap` 으로 닫은 것과 같은 처리다. 블록형이 실제로 필요해지면 그때 lint 와의 등가를
# 증명하며 넓힌다.
tc_guard() {
  local key="$1" kind="$2" val="$3" n
  n=$(tr -d '\r' < "$TEMPLATE_CONFIG" | grep -cE "^$key:" || true)
  [ "$n" -eq 1 ] || die "template-config 의 $key 키가 파일 전체에서 ${n}회 등장한다 (정확히 1회여야 한다) — verify 는 병합하고 lint 는 last-wins 라 두 파서가 다른 값을 본다"
  # 값 행에 `#` 나 `'` 가 있으면 거부한다 (santa m5 R2 A·B). 위 tc_list 주석 참조.
  #
  # **자르지 않은 원문을 본다.** 초안은 주석을 먼저 떼어낸 뒤 남은 부분에서 `#` 를 찾았는데,
  # 그러면 따옴표 안의 `#` 가 주석으로 지워져 검사가 항상 참이 된다 — 자기 자신을 무력화하는
  # 형태였고 음성 대조가 잡았다.
  local raw val_raw shape
  raw=$(tr -d '\r' < "$TEMPLATE_CONFIG" | grep -E "^$key:" | head -1)

  # lint 의 splitKey 는 콜론 뒤에 **공백 1개 이상**이거나 줄이 거기서 끝나야 받는다
  # (`key:value` 는 "파싱할 수 없는 줄"로 throw). 추출기는 0개 이상을 받으므로 그 규칙을
  # 여기서 건다 — 안 걸면 lint 가 거부하는 줄을 게이트가 통과시킨다. (santa m5 R4)
  case "$raw" in
    "$key:") ;;                                   # 값 없음 — 아래 [ -n "$val" ] 가 잡는다
    "$key:"[[:space:]]*) ;;                       # 정상
    *) die "template-config 의 $key 행이 lint 의 splitKey 형태가 아니다 — 콜론 뒤에 공백이 필요하다. lint 는 \`$key:값\` 을 '파싱할 수 없는 줄'로 거부한다" ;;
  esac

  case "$raw" in
    *"#"*) die "template-config 의 $key 행에 # 가 있다 — 주석인지 값인지 구별하려면 lint 의 상태 스캐너가 필요하고 이 게이트에는 없다. 설명은 키 위의 주석 줄에 쓴다" ;;
    *"'"*) die "template-config 의 $key 행에 작은따옴표가 있다 — lint 의 unquote 는 풀고 이 게이트는 풀지 않아 두 파서가 다른 값을 본다. 따옴표를 빼거나 큰따옴표를 쓴다" ;;
  esac

  # 큰따옴표 축 (santa m5 R3 Reviewer B). 라운드 2 가 `#`·`'` 만 막고 `"` 를 남겼다.
  #   list  : lint 는 `parseScalarOrFlow(...).map(unquote)` 로 **원소마다** 푼다.
  #           `evidence_grades: ["S-test", A-comment]` → lint [S-test, A-comment]
  #                                                    → tc_list ["S-test", A-comment]
  #           이 4개 키(evidence_grades·actors·services·auth_layers)는 교차 대조가 없어
  #           따옴표 낀 값이 그대로 golden 에 굳는다.
  #   scalar: lint 의 unquote 는 **양끝이 짝일 때만** 푼다. tc_scalar 는 `^"` 와 `"$` 를
  #           따로 떼므로 `project: "insuniverse` 같은 미종결 값에서 갈린다
  #           (lint ["insuniverse] · verify [insuniverse]).
  # 원소 단위 unquote 를 셸로 옮기면 그것이 또 갈라진다 — 라운드 1·2 가 그 증거다. 좁힌다.
  val_raw=$(printf '%s' "$raw" | sed "s/^$key:[[:space:]]*//; s/[[:space:]]*\$//")
  # maplist 는 값이 **다음 줄들**에 있어 이 줄의 값 검사가 성립하지 않는다. 키 유일성과
  # splitKey 형태 검사는 위에서 이미 지났고, 값 자체는 lint 정본 파서(--emit-statuses)가 읽는다
  # — 셸이 맵 리스트를 손으로 파싱하면 그것이 tc_list 주석이 기록한 발산의 세 번째 사례가 된다.
  # blocklist 는 maplist 와 같은 처지다 — 값이 **다음 줄들**에 있어 이 줄의 값 검사가 성립하지
  # 않는다. 값 자체는 lint 정본 파서(--emit-list)가 읽는다.
  # 문면은 kind 별로 갈라 낸다 — 한 문장으로 합치면 statuses(맵 리스트) 위반에도 "블록형" 이라고만
  # 나와 무엇을 고쳐야 하는지가 사라진다 (code-review L7).
  if [ "$kind" = maplist ] || [ "$kind" = blocklist ]; then
    case "$kind" in
      maplist)   shape="맵 리스트(- key: 항목이 다음 줄들에 온다)" ;;
      *)         shape="블록형 리스트(- 원소가 다음 줄들에 온다)" ;;
    esac
    [ "$raw" = "$key:" ] \
      || die "template-config 의 $key 가 $shape 가 아니다 — 키 줄에 값이 붙어 있다 ($raw). 이 형태는 키 줄에 값을 두지 않는다"
  elif [ "$kind" = list ]; then
    case "$val_raw" in
      *'"'*) die "template-config 의 $key 리스트 원소에 큰따옴표가 있다 — lint 는 원소마다 unquote 하고 이 게이트는 하지 않아 두 파서가 다른 값을 본다. 따옴표 없이 쓴다" ;;
    esac
  else
    case "$val_raw" in
      *'"'*) printf '%s' "$val_raw" | grep -qE '^"[^"]*"$' \
        || die "template-config 의 $key 값의 큰따옴표가 값 전체를 감싸는 짝이 아니다 — lint 의 unquote 는 양끝이 짝일 때만 풀고 이 게이트는 앞뒤를 따로 떼어 두 파서가 다른 값을 본다. 값 전체를 감싸거나 따옴표를 뺀다" ;;
    esac
  fi

  if [ "$kind" = list ]; then
    # 끝을 앵커한다. 라운드 1 은 `\[.*\]` 라 `]` 뒤에 무엇이 오든 통과시켰고, tc_list 의
    # 관대한 주석 그룹과 합쳐져 lint 가 거부하는 줄을 게이트가 받아들였다. 이 패턴은
    # tc_list 의 sed 와 같은 형태여야 한다 — 갈라지면 "게이트는 통과인데 추출은 0건"이 된다.
    tr -d '\r' < "$TEMPLATE_CONFIG" | grep -qE "^$key:[[:space:]]*\[.*\][[:space:]]*$" \
      || die "template-config 의 $key 가 흐름형 한 줄이 아니다 — schema 3번 블록형은 계약상 유효하지만 이 게이트는 흐름형만 읽는다. 흐름형으로 쓰거나 tc_list 를 lint 와 등가로 넓혀라"
  fi
  [ -n "$val" ] || die "template-config 에서 $key 를 추출하지 못했다 (0건) — 빈 값을 golden 에 실을 수 없다"
}

# onboarding-questions 파서 3종. run_onboarding_gate 와 gen_report 가 둘 다 쓴다 —
# 파서를 두 벌 두면 그 둘이 갈라진다(위 tc_list 와 같은 이유).
#
# parse_repos 와 같은 계약이다: **섹션을 먼저 자르고** 잘린 범위 안에서만 읽는다. 파일 전체에
# awk 를 돌리면 주석이나 ia_verdict 문자열 안의 `- id:` 형태가 질문으로 잡힌다.
#
# 인자 K 는 호출부가 리터럴 2종(questions · coverage_exceptions)으로만 넘긴다. 파일 내용에서
# 오지 않으므로 -v 로 조립되는 정규식에 값이 흘러들 경로가 없다.
#
# questions: 는 파일의 **마지막 최상위 키**라 EOF 로 끝나는 것이 정상 경로다. manifest 목록이
# BOUNDARY 불변식(종료 행 존재)을 요구한 것과 다르다 — 그쪽은 목록 뒤에 반드시 다른 키가 오는
# 스키마였다. 여기서 EOF 를 결함으로 치면 정상 파일이 실패한다.
ONORM="$TMP/onboarding.norm"

# CR 을 벗긴 사본을 1회 만들고 이후는 **파일만** 읽는다 — normalize_manifest 와 같은 계약이며,
# parse_repos 가 `awk … "$NORM"` 으로 파일을 넘기는 것과 같은 형태다.
#
# 초안은 `tr -d '\r' < "$ONBOARDING" | awk` 파이프였다. CR 정규화는 같지만 **우변이 먼저 끝나는
# 형태에서 좌변이 SIGPIPE(141)로 죽고 pipefail 이 그것을 파이프라인 상태로 올린다.** 여기서는
# awk 의 `exit`(questions: 뒤에 다른 최상위 키가 오면 밟는다), 아래 승인 검사에서는 `grep -q`
# 가 그 우변이다. 승인 검사에서 그 뒤집힘은 **fail-open** 이다 — 가드가 조용히 안 터진다.
# 지금 파일이 25KB 라 파이프 버퍼(64KB) 안에 들어가 실현되지 않을 뿐이고, 이번 마일스톤이
# 질문 수 **상한을 폐지**해 그 전제를 스스로 없앴다. 파이프를 남길 이유가 없다.
oq_norm() { [ -s "$ONORM" ] || tr -d '\r' < "$ONBOARDING" > "$ONORM"; }

oq_slice() {   # <최상위 키>
  oq_norm
  awk -v K="$1" '
    f && /^[^[:space:]#]/ { exit }
    $0 ~ "^" K ":[[:space:]]*$" { f = 1; next }
    f
  ' "$ONORM"
}

# 출력 1행 = 1질문, 필드 구분자 `|`:
#   id|owner|service|screen_inventory|approved|src|q
# service·screen_inventory 는 없으면 `-`. 빈 문자열로 두면 필드 수가 흔들려 cut 이 조용히
# 다른 열을 읽는다.
#
# 값 추출에 **오프셋 산술을 쓰지 않는다.** 접두어를 정규식과 손계산 숫자로 두 번 적으면
# 둘이 어긋난다 — 초안이 `  - id: `(8자)를 10부터로 세어 Q01 을 01 로 자를 뻔했다. sub() 로
# 접두어를 한 번만 적는다.
#
# 키 중복과 `- id:` 앞의 필드를 거부한다. 마지막 값이 이기는 형태로 두면 한 질문에 src 를 두 번
# 쓰고 다른 질문에서 빼도 전체 행 수가 같아 통과한다 — 총수 대조로는 결속을 대신할 수 없다.
oq_records() {
  oq_slice questions | awk '
    function dup(k)     { printf "DUPKEY %s (id=%s)\n", k, id > "/dev/stderr"; FAIL = 1 }
    function orphan(k)  { printf "ORPHAN %s — `- id:` 앞에 필드가 왔다\n", k > "/dev/stderr"; FAIL = 1 }
    function val(pfx,   v) { v = $0; sub(pfx, "", v); return v }
    # 구분자를 값에 담은 채 내보내면 필드 수가 흔들린다. 호출부의 NF 검사가 잡기는 하지만
    # 그것은 게이트 쪽 검사라 gen_report 만 도는 경로에는 없다 — 계약을 파서에서 지킨다.
    function nopipe(k, v) { if (index(v, "|")) { printf "PIPE %s (id=%s) — 값에 레코드 구분자 | 가 있다\n", k, id > "/dev/stderr"; FAIL = 1 } }
    function flush() {
      if (id == "") return
      nopipe("id", id); nopipe("owner", owner); nopipe("service", svc); nopipe("screen_inventory", scr)
      nopipe("approved", approved); nopipe("src", src); nopipe("q", q)
      printf "%s|%s|%s|%s|%s|%s|%s\n", id, owner, (svc == "" ? "-" : svc),
             (scr == "" ? "-" : scr), approved, src, q
      id = ""; owner = ""; svc = ""; scr = ""; approved = ""; src = ""; q = ""
    }
    /^[[:space:]]*$/ || /^[[:space:]]*#/ { next }
    /^  - id: /               { flush(); id = val("^  - id: "); next }
    /^    owner: /            { if (id == "") orphan("owner");            else if (owner != "")    dup("owner");            owner    = val("^    owner: ");            next }
    /^    service: /          { if (id == "") orphan("service");          else if (svc != "")      dup("service");          svc      = val("^    service: ");          next }
    /^    src: /              { if (id == "") orphan("src");              else if (src != "")      dup("src");              src      = val("^    src: ");              next }
    /^    approved: /         { if (id == "") orphan("approved");         else if (approved != "") dup("approved");         approved = val("^    approved: ");         next }
    /^    screen_inventory: / { if (id == "") orphan("screen_inventory"); else if (scr != "")      dup("screen_inventory"); scr      = val("^    screen_inventory: "); next }
    /^    q: /                { if (id == "") orphan("q");                else if (q != "")        dup("q");                q        = val("^    q: ");                next }
    { printf "RESIDUAL %s\n", $0 > "/dev/stderr"; FAIL = 1 }
    END { flush(); if (FAIL) exit 1 }
  '
}

# coverage_exceptions — `- target: X` + `  why: "..."` 쌍. why 없는 target 은 항목이 아니다.
# 사유 없는 제외를 허용하면 "해당 태그 질문 전부 O"가 공집합에서 자동 성립하는 결함이
# 이름만 바꿔 되살아난다. 빈 섹션은 0건이며 정상이다.
#
# 출력은 `target|why` 2필드다. target 만 내보내면 **사유를 통째로 다른 말로 바꿔 써도 golden
# 이 한 행도 움직이지 않는다** — 제외를 정당화하는 유일한 근거가 동결 밖에 남는다(santa R1
# Reviewer B). 집합 연산이 필요한 호출부는 1열만 잘라 쓴다.
oq_exceptions() {
  oq_slice coverage_exceptions | awk '
    function val(pfx,   v) { v = $0; sub(pfx, "", v); return v }
    /^[[:space:]]*$/ || /^[[:space:]]*#/ { next }
    /^  - target: /   { if (t != "") { printf "MISSINGWHY %s\n", t > "/dev/stderr"; F = 1 } t = val("^  - target: "); next }
    /^    why: ".+"$/ { if (t == "") { print "ORPHANWHY" > "/dev/stderr"; F = 1; next } print t "|" val("^    why: "); t = ""; next }
    { printf "RESIDUAL %s\n", $0 > "/dev/stderr"; F = 1 }
    END { if (t != "") { printf "MISSINGWHY %s\n", t > "/dev/stderr"; F = 1 } if (F) exit 1 }
  '
}

# `ia_consumed` 판정의 **근거**가 적힌 자리는 세 곳인데 동결된 것은 `ia_verdict` 하나뿐이었다
# (santa 라운드 4 Reviewer B). 나머지 둘 — 이 yaml 의 주석과 PRD §미해결·의사결정 대기의
# `ia-doc@1` 판정 항목 — 은 같은 질문 ID 를 다시 열거하면서 아무도 읽지 않았다. 이 부류는
# 실제로 한 번 터졌다: 41건 시점 문장("화면 언급 3건 — Q29·Q39·Q41")이 55건 승인 뒤에도
# 그대로 남아 있었다(santa 라운드 1).
#
# 두 자리를 **각각** 낸다. 합집합으로 합치면 한쪽에서 사라진 토큰이 다른 쪽에 남아 값이
# 안 움직인다 — 지금 닫는 결함이 정확히 그 형태다.
#
# 대상은 아래 oq_rationale_block 이 정하는 **한 덩어리**다 — ID 행과 해시 행이 같은 텍스트를
# 가리켜야 한다. 다른 범위를 쓰면 "해시는 움직였는데 ID 는 그대로"의 해석 부담이 생기고,
# 그 어긋남 자체가 이 게이트가 닫아 온 부류다. 블록이 비면 호출부의 -s 가드가 실패시킨다.
oq_rationale_ids() {
  oq_rationale_block | awk '{
    while (match($0, /Q[0-9][0-9]/)) {
      print substr($0, RSTART, RLENGTH); $0 = substr($0, RSTART + RLENGTH)
    }
  }' | sort -u
}

# PRD 의 `ia-doc@1` 판정 항목 1행에서 같은 것을 뽑는다. 항목이 1행이 아니면 exit 1 —
# 0건이면 "근거에 질문 ID 가 없다"와 구별되지 않고, 그 침묵이 이 검사를 무력화한다.
# ID 집합만으로는 **같은 번호를 유지한 채 문장을 거짓으로 고치는 것**을 못 잡는다 — Q47 의
# 설명을 "화면 인벤토리를 묻는다"로 뒤집어도 토큰 집합이 그대로라 네 golden 행이 한 줄도
# 안 움직인다(santa 라운드 6 Reviewer B). 근거 산문 자체를 해시로 동결한다.
#
# ID 행을 없애고 해시만 두지 않는다. 해시는 "무언가 바뀌었다"까지만 말하는데, 실제로 터진
# 사고는 **어느 질문이 근거에서 빠졌는가**였다(41건 판정문). golden diff 가 그 답을 그대로
# 보여야 한다. 해시는 완결성을, ID 행은 가독성을 맡는다 — exclude_inputs 가 같은 짝이다.
#
# 대상은 `ia_consumed:` **바로 위에 붙어 있는 주석 블록**이다. 빈 행이 오면 버퍼를 비우므로
# 경계가 데이터로 정해지고 별도 계약이 되지 않는다. 0바이트면 호출부가 실패한다.
oq_rationale_block() {
  awk '/^#/            { buf = buf $0 "\n"; next }
       /^ia_consumed:/ { printf "%s", buf; found = 1; exit }
                       { buf = "" }
       END { if (!found) exit 1 }' "$ONORM"
}

prd_verdict_ids() {   # <정규화된 PRD 파일>
  awk '/ia-doc@1` 을 insuniverse 가 소비하는가/ {
         n++
         while (match($0, /Q[0-9][0-9]/)) {
           print substr($0, RSTART, RLENGTH); $0 = substr($0, RSTART + RLENGTH)
         }
       }
       END { if (n != 1) exit 1 }' "$1" | sort -u
}

# PRD 쪽 사본의 산문 전문. 위 oq_rationale_block 과 같은 이유다 — 여기 한 행이 판정 근거를
# 통째로 싣고 있어서, ID 를 유지한 채 논증을 뒤집으면 아무도 못 본다. 1행이 아니면 exit 1.
prd_verdict_line() {   # <정규화된 PRD 파일>
  awk '/ia-doc@1` 을 insuniverse 가 소비하는가/ { n++; print }
       END { if (n != 1) exit 1 }' "$1"
}

# ─────────────────────────────────────────────────────────────────────────────
# 뷰어 축 근거 앵커 2종의 digest 산출
#
# `gen_report` 의 emit · `oq_ia_gate` 규칙 (f)·(g) · `selftest_ia_gate` 픽스처가 **전부 이
# 두 함수를 부른다.** 셋이 각자 해시하면 CR 처리나 행 선택이 한 자리에서만 달라져도 golden
# 이 checkout 마다 흔들리고, 그 드리프트의 원인이 어느 자리인지 아무도 말하지 못한다.
#
# **순수 함수가 아니다 — 추출 결과를 파일로 남긴다.** 규칙 (e) 는 digest 가 아니라 그 추출
# 행 자체를 읽어 위임 기록 형태를 본다. 그 경로를 함수가 정하면 호출부는 자기가 쓰지 않은
# 파일을 읽게 되고, 두 호출 사이에 같은 경로를 쓰는 코드가 하나만 끼어도 (e) 가 엉뚱한
# 대상을 판정하고 조용히 통과한다. 그래서 **위임 행 쪽은 출력 경로를 호출부가 인자로 준다**
# — 읽는 쪽이 곧 이름 짓는 쪽이다. 절 쪽은 digest 만 소비되므로 임시 파일이 함수 안에 머문다.
#
# 입력은 CR 을 벗긴 뒤 해시한다. `core.autocrlf=true` 이고 두 원본에 EOL 속성이 없어
# checkout 필터가 CR 을 넣는다 — 정규화 없이 얼리면 LF 에서 만든 golden 이 CRLF checkout
# 의 --check 에서 드리프트로 떨어진다.
#
# **해시하기 전에 추출 결과를 검증한다.** `grep | tr | sha256sum` 로 짜면 0매치에서 빈 입력의
# 정상 digest(e3b0c442…)가 나오고, gen_report 는 `|| die` 문맥 안이라 errexit 가 꺼져 그
# 실패가 뒤따르는 emit 에 묻힌다. 2행 이상은 이어붙어 한 digest 가 된다 — 둘 다 조용하다.
# 실패 시 stdout 을 비우는 것은 expect_fn 계약이자 호출부 `[ -s ]` 가드의 전제다.
#
# 기대 digest 를 마지막 인자로 주면 **대조만 하고 아무것도 찍지 않는다** —
# 선언 ↔ 실측 대조는 값을 흘리지 않아야 호출부가 출력으로 판정하지 않는다.

# `70-viewer` PRD 의 `ia-doc@1` 위임 행. 이 PRD 가 편집할 수 없는 자리라 뷰어 축의 두 번째
# 앵커다 — 그 행이 위임에서 수용으로 바뀌면 digest 가 움직이고 재판정이 강제된다.
# `<추출행 경로>` 에 CR 을 벗긴 그 행을 남긴다 — 규칙 (e) 가 읽는 자리다.
ia_delegation_sha() {   # <70-viewer PRD 경로> <추출행 경로> [기대 digest]
  local f="$1" out="$2" want="${3:-}" n got
  [ -f "$f" ] \
    || { printf 'ia-doc@1 위임 행 원본이 없다: %s\n' "$f" >&2; return 1; }
  grep -F '`ia-doc@1` 지원' "$f" | tr -d '\r' > "$out" || true
  n=$(wc -l < "$out")
  [ "$n" -eq 1 ] \
    || { printf '70-viewer PRD 의 ia-doc@1 위임 행이 %s행이다 (정확히 1행이어야 한다)\n' "$n" >&2; return 1; }
  got=$(sha256sum < "$out" | cut -d' ' -f1)
  if [ -n "$want" ]; then
    [ "$got" = "$want" ] \
      || { printf 'ia-doc@1 위임 행 실측 %s != 선언 %s — 행이 바뀌었거나 선언이 낡았다\n' "$got" "$want" >&2; return 1; }
    return 0
  fi
  printf '%s\n' "$got"
}

# `ia_viewer_src` 가 가리키는 절. 절 표제(`### <id>. …`)부터 다음 `### ` 직전까지를 슬라이스
# 한다. 비공백이 아니면 거부 — 빈 절의 digest 를 기준선으로 얼리면 근거가 사라진 상태가
# 정상으로 축성된다.
#
# 표제 대조는 **정규식이 아니라 리터럴 접두**다(`index($0, pfx) == 1`). id 를 정규식에 끼워
# 넣으면 `1.2` 의 `.` 가 임의 문자로 풀려 `1x2` 절을 근거로 집는다.
#
# 표제가 2개 이상이면 거부한다 — 위임 행 쪽이 "정확히 1행"을 요구하는 것과 같은 이유이고,
# 그 비대칭이 여기 있으면 뒤에 같은 id 의 절을 하나 더 놓아 첫 슬라이스만 남기는 경로가
# 열린다(앞 절은 그대로 두고 뒤에 진짜 내용을 옮겨 적을 수 있다).
ia_viewer_section_sha() {   # <파일> <절 id> [기대 digest]
  local f="$1" id="$2" want="${3:-}" got rc=0
  [ -f "$f" ] \
    || { printf 'ia_viewer_src 의 파일이 없다: %s\n' "$f" >&2; return 1; }
  awk -v pfx="### $id." '
       index($0, pfx) == 1 { n++; s = 1; print; next }
       s && index($0, "### ") == 1 { s = 0 }
       s
       END { if (n > 1) exit 3 }' "$f" | tr -d '\r' > "$TMP/ia.section" || rc=$?
  [ "$rc" -eq 0 ] \
    || { printf '%s 의 절 %s 표제가 2개 이상이다 (정확히 1개여야 한다)\n' "$f" "$id" >&2; return 1; }
  [ -s "$TMP/ia.section" ] \
    || { printf '%s 에 절 %s 이 없거나 비어 있다\n' "$f" "$id" >&2; return 1; }
  got=$(sha256sum < "$TMP/ia.section" | cut -d' ' -f1)
  if [ -n "$want" ]; then
    [ "$got" = "$want" ] \
      || { printf '뷰어 축 근거 절 실측 %s != 선언 %s — 절이 바뀌었거나 선언이 낡았다\n' "$got" "$want" >&2; return 1; }
    return 0
  fi
  printf '%s\n' "$got"
}

# 하류 문서 PRD 가 `onboarding-questions.yaml` 을 **이름으로** 지목하는가. 우산 PRD 는
# "5개 PRD 전부가 자기 Success Metrics 에서 이 파일을 이름으로 참조하므로 태그 질문이
# 늘면 종료 조건이 자동으로 따라온다"를 필수 수정 0건의 근거로 삼는데, 게이트는
# `.claude/prds/` 의 **파일명**만 세고 내용을 읽지 않았다(santa 라운드 4 Reviewer B).
# 참조를 지워도 --check 가 초록이었고, 그러면 그 PRD 의 종료 조건은 이 파일과 끊긴다.
#
# **`## Success Metrics` 절 안만 본다.** 파일 전체를 grep 하면 서론이나 개정 이력의 언급이
# 종료 조건 참조를 대신해 준다 — 20-foundation-access 는 §Users 에, 50-domain 은 §반드시
# 지킬 것에 별개 언급이 있어서, 지표 행을 지워도 초록이었다(santa 라운드 5 Reviewer B).
# 규범은 "종료 조건이 이 파일에 걸려 있다"이지 "어딘가 이름이 나온다"가 아니다.
#
# 절 표제는 정확히 1회여야 한다. 0회면 슬라이스가 빈 채로 나와 "참조 없음"과 구별되지
# 않고, 2회면 어느 절을 본 것인지 말할 수 없다 — 둘 다 조용한 통과가 아니라 조용한
# **탈락**으로 떨어지므로 호출부 메시지가 두 사유를 함께 적는다.
#
# 참조가 **없는** PRD 는 출력에서 빠진다. 파일 자체가 없는 경우도 마찬가지로 빠지며,
# 그쪽은 호출부의 5종 대조가 먼저 붉어진다. 함수는 항상 0 을 반환한다 — 마지막 PRD 가
# 참조를 갖지 않았다는 이유로 함수 반환값이 오염되면 호출부의 집합 대조에 닿지 못한다.
oq_downstream_refs() {   # <owner id 목록 파일> <레코드 파일>
  local o f n other foreign svc_owner
  # 서비스 축으로 판정되는 owner 는 지표 행에 태그 리터럴 대신 "해당 서비스 태그 질문"이라고
  # 쓴다 — 그 PRD 의 종료 판정 단위가 태그가 아니라 서비스이기 때문이다. **데이터에서
  # 유도한다**: service 필드를 가진 질문의 owner 가 그것이다(검사 4 가 세우는 사실).
  # 리터럴로 적으면 그 자체가 검사 없는 규범이 된다.
  svc_owner=$(awk -F'|' '$3 != "-" { print $2 }' "$2" | sort -u)
  [ "$(printf '%s' "$svc_owner" | grep -c . || true)" -le 1 ] \
    || { printf 'service 필드를 가진 owner 가 2종 이상이다: %s\n' "$(printf '%s' "$svc_owner" | tr '\n' ' ')" >&2; return 1; }
  while read -r o; do
    [ -n "$o" ] || continue
    # 활성과 `archived/` 양쪽을 본다 — `prd_path` 가 이 저장소의 정본 해석기다. 못 찾으면
    # 그 owner 가 참조 집합에서 조용히 빠지는 것이 아니라 자기 사유로 실패한다.
    f=$(prd_path "insuniverse-docs-$o.prd.md") \
      || { printf '문서 PRD 파일이 없다: insuniverse-docs-%s.prd.md (.claude/prds/ 와 archived/ 양쪽)\n' "$o" >&2; return 1; }
    # 구조 이상은 **자기 사유로** 실패한다. 조용히 건너뛰면 호출부의 집합 대조가
    # "참조하지 않는다"로 뭉개 보고하고, 진짜 원인(파일 없음 · 표제 0회/2회)이 메시지에서
    # 사라진다(santa 라운드 7 Reviewer B).
    [ -f "$f" ] || { printf '문서 PRD 파일이 없다: %s\n' "$f" >&2; return 1; }
    n=$(grep -c '^## Success Metrics[[:space:]]*$' "$f" || true)
    [ "$n" -eq 1 ] \
      || { printf '%s 의 `## Success Metrics` 표제가 %s회다 (정확히 1회여야 한다)\n' "$o" "$n" >&2; return 1; }
    # 절 안에서도 **지표 표의 행**만 본다. 절만 좁히면 같은 절의 산문 한 문장이나 코드펜스가
    # 지표 행을 대신해 준다(santa 라운드 6 Reviewer B). 규범은 "지표 표의 한 행이 이 파일을
    # 지목한다"이지 "그 절 어딘가에 이름이 나온다"가 아니다 — 종료 조건은 행이 싣는다.
    #
    # 범위는 절의 **첫 표 하나**다. 절 뒤쪽에 표를 하나 더 붙여 거기서 이름을 대는 우회를
    # 막는다(라운드 7 Reviewer B). 펜스는 ``` 와 ~~~ 둘 다 걷는다 — 한쪽만 걷으면 다른
    # 표기로 위장한 예시가 표 행으로 읽힌다.
    #
    # ponytail: 이 검사는 **"지표 표에 이 파일을 대는 행이 있다"까지만** 증명한다. 그 행이
    # 실제로 의존을 선언하는지(예: "onboarding-questions.yaml 는 폐기됨"이라고 적힌 행)는
    # 구조로 판정할 수 없다 — 문장의 뜻은 사람이 읽는다. 거기까지 기계화하려면 지표 행에
    # 기계 판독 키를 하나 달아야 하고, 그것은 하류 5개 PRD 의 표 형식을 바꾸는 일이다.
    # 표 안에서도 **데이터 행**만 낸다. 헤더와 구분자를 포함하면 표 **앞에** 행을 하나 끼워
    # 넣어 거기서 이름을 대는 우회가 남는다(santa 라운드 8 Reviewer B) — 그 행은 표의 지표가
    # 아니라 표 위의 장식이다. 구분자 행이 2번째가 아니거나 데이터 행이 0건이면 표가 아니므로
    # 자기 사유로 실패한다.
    awk '/^## Success Metrics[[:space:]]*$/ { f = 1; next }
         f && /^## /                        { exit }
         f && /^(```|~~~)/                  { fence = !fence; next }
         fence                              { next }
         f && /^\|/ {
           rows++
           # 구분자는 칸마다 하이픈이 하나 이상이어야 한다. `[[:space:]:|-]+` 로 두면
           # `|   |   |   |` 처럼 하이픈 0개인 행도 구분자로 통과한다(santa 라운드 9 Reviewer B).
           if (rows == 2 && $0 !~ /^\|([[:space:]]*:?-+:?[[:space:]]*\|)+[[:space:]]*$/) { bad = 1; exit }
           if (rows > 2) print
           seen = 1; next
         }
         f && seen                          { exit }
         END { if (bad || rows < 3) exit 1 }' "$f" > "$TMP/oq.sm" \
      || { printf '%s 의 §Success Metrics 가 표(헤더 + 구분자 + 데이터 행) 형태가 아니다\n' "$o" >&2; return 1; }
    grep 'onboarding-questions\.yaml' "$TMP/oq.sm" > "$TMP/oq.smrow" || true
    # 참조가 아예 없으면 출력에서 빠지고 호출부의 집합 대조가 그것을 보고한다.
    [ -s "$TMP/oq.smrow" ] || continue
    # 그 행이 **자기** 태그를 거는가. 파일 이름만 보면 20-foundation-access 의 지표 행을
    # `50-domain` 태그를 가리키게 바꿔도 통과한다 — 그 PRD 는 자기 질문에 종료 조건을
    # 걸지 않은 것인데 게이트는 초록이다(santa 라운드 9 Reviewer B).
    #
    # 판정은 "다른 문서 PRD 의 태그를 가리키지 않는다"로 둔다. 자기 태그 **명시**를
    # 요구하지 않는 이유: 50-domain 의 지표 행은 "해당 서비스 태그 질문"이라고 적어
    # 태그 리터럴이 없고, 그 표현이 옳다(그 PRD 는 서비스 축으로 판정된다). 하류 PRD 의
    # 표 문구를 이 게이트 때문에 바꾸는 것은 소유 경계를 넘는 일이다.
    #
    # 태그 매칭은 **경계**로 한다. 부분일치로 두면 `50-domain-x` 나 경로·URL 안의 우연한
    # 일치가 위반으로 잡힌다(santa 라운드 10 Reviewer B) — fail-closed 지만 틀린 거부다.
    foreign=0
    while read -r other; do
      [ -n "$other" ] || continue
      [ "$other" = "$o" ] && continue
      if grep -qE "(^|[^0-9a-z-])$other([^0-9a-z-]|$)" "$TMP/oq.smrow"; then foreign=1; fi
    done < "$1"
    [ "$foreign" -eq 0 ] \
      || { printf '%s 의 §Success Metrics 온보딩 행이 다른 문서 PRD 의 태그를 가리킨다\n' "$o" >&2; return 1; }
    # 자기 태그를 **적는가**. "다른 태그가 없다"만 보면 `onboarding-questions.yaml 질문 전부 O`
    # 같은 무태그 일반 문구도 통과하고, 그러면 그 지표는 어느 질문 집합에 걸려 있는지를
    # 말하지 않는다(santa 라운드 10 Reviewer B). 라운드 9 가 천장으로 적었던 자리인데
    # 서비스 축 owner 를 데이터에서 유도할 수 있어 나머지 넷에는 요구할 수 있다.
    if [ "$o" != "$svc_owner" ]; then
      grep -qE "(^|[^0-9a-z-])$o([^0-9a-z-]|$)" "$TMP/oq.smrow" \
        || { printf '%s 의 §Success Metrics 온보딩 행이 자기 태그를 적지 않는다\n' "$o" >&2; return 1; }
    fi
    printf '%s\n' "$o"
  done < "$1"
  return 0
}

json_field() {  # <json 파일> <필드명>
  node -e '
    const fs = require("fs");
    const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
    const v = j[process.argv[2]];
    if (v === undefined || v === null) { console.error("필드 없음: " + process.argv[2]); process.exit(1); }
    process.stdout.write(String(v));
  ' "$1" "$2"
}

gen_report() {
  local out="$1"
  local name path head actual

  normalize_manifest
  parse_manifest_lists
  parse_repos
  classify_pathspec
  check_parity

  : > "$out"

  # 저장소 SHA — manifest 값과 실제 HEAD 를 둘 다 싣는다. manifest 만 고치면 manifest_head 행이,
  # 서브모듈만 움직이면 actual_head 행이 diff 에 뜬다. 별도 단언문 없이 두 방향이 다 잡힌다.
  # 서브모듈 미초기화는 MISSING 을 싣고 실패로 친다 — 빈 문자열로 조용히 통과시키지 않는다.
  : > "$TMP/repos.missing"
  sort -k1,1 "$TMP/repos.raw" | while read -r name path head; do
    emit "repo.$name.manifest_head" "$head"
    if [ -d "$ROOT/$path/.git" ] || [ -f "$ROOT/$path/.git" ]; then
      actual=$(git -C "$ROOT/$path" rev-parse HEAD 2>/dev/null || true)
      [ -n "$actual" ] || actual=MISSING
    else
      actual=MISSING
    fi
    [ "$actual" = MISSING ] && printf '%s %s\n' "$name" "$path" >> "$TMP/repos.missing"
    emit "repo.$name.actual_head" "$actual"
  done >> "$out"

  # 집합은 개수가 아니라 전수로 싣는다. 개수만 실으면 항목 하나가 다른 항목으로 바뀐
  # 교체 드리프트가 통과한다.
  emit "exclude.pathspec_count"     "$(wc -l < "$TMP/pathspec.raw")"          >> "$out"
  emit "exclude.glob_count"         "$(wc -l < "$TMP/glob.raw")"              >> "$out"
  emit "exclude.glob_dirs"          "$(sed 's:$:/:' "$TMP/glob_dirs" | tr '\n' ' ' | sed 's/ $//')" >> "$out"
  emit "exclude.glob_only"          "$(tr '\n' ' ' < "$TMP/glob_only.sorted" | sed 's/ $//')"       >> "$out"
  emit "exclude.pathspec_wide"      "$(sed 's:$:/:' "$TMP/wide" | tr '\n' ' ' | sed 's/ $//')"      >> "$out"
  emit "exclude.pathspec_anchored"  "$(tr '\n' ' ' < "$TMP/anchored" | sed 's/ $//')"               >> "$out"
  emit "exclude.pathspec_ext"       "$(tr '\n' ' ' < "$TMP/ext" | sed 's/ $//')"                    >> "$out"
  emit "exclude.parity"             "$(cat "$TMP/parity")"                                          >> "$out"

  node "$SCREEN_EXTRACTOR" --json > "$TMP/screen.json"
  emit "screen_codes.route_count"   "$(json_field "$TMP/screen.json" route_count)" >> "$out"
  emit "screen_codes.set_sha256"    "$(json_field "$TMP/screen.json" set_sha256)"  >> "$out"

  node "$MENU_EXTRACTOR" --json > "$TMP/menu.json"
  emit "menu_hardcoded.code_count"  "$(json_field "$TMP/menu.json" code_count)"    >> "$out"
  emit "menu_hardcoded.set_sha256"  "$(json_field "$TMP/menu.json" set_sha256)"    >> "$out"

  # ── 분쟁 수치 5종 (마일스톤 4) ──
  #
  # check_parity **뒤**에서만 부른다. 정합 MISMATCH 상태의 분류 파일로 값을 내지 않는다.
  #
  # 추출기는 manifest 를 파싱하지 않는다 — 파서를 두 벌 두면 그 둘이 갈라진다. 대신 여기서
  # 만든 wide/anchored/ext/repos.raw 를 넘긴다. 그리고 **넘긴 것이 추출기가 읽은 것과 같은지**를
  # digest 로 대조한다. 계산 주체가 둘이라야 대조가 의미를 갖는다 — 여기서 sha256sum 으로,
  # 추출기에서 읽은 바이트로 각각 낸다. 한 줄만 달라도 추출기가 exit 1 한다.
  local exclude_digest
  exclude_digest=$(cat "$TMP/wide" "$TMP/anchored" "$TMP/ext" "$TMP/repos.raw" | sha256sum | cut -d' ' -f1)

  node "$DISPUTED_EXTRACTOR" \
    --repos-file "$TMP/repos.raw" --wide-file "$TMP/wide" \
    --anchored-file "$TMP/anchored" --ext-file "$TMP/ext" \
    --exclude-digest "$exclude_digest" \
    --json > "$TMP/disputed.json"

  # json_field 는 최상위 필드만 읽는다. 추출기가 평평한 키로 내므로 그대로 뽑는다.
  emit "commits.insuniverse-admin.total"       "$(json_field "$TMP/disputed.json" commits_insuniverse_admin_total)"       >> "$out"
  emit "commits.insuniverse-admin.with_body"   "$(json_field "$TMP/disputed.json" commits_insuniverse_admin_with_body)"   >> "$out"
  emit "commits.insuniverse-server.total"      "$(json_field "$TMP/disputed.json" commits_insuniverse_server_total)"      >> "$out"
  emit "commits.insuniverse-server.with_body"  "$(json_field "$TMP/disputed.json" commits_insuniverse_server_with_body)"  >> "$out"
  emit "commits.insuniverse-site.total"        "$(json_field "$TMP/disputed.json" commits_insuniverse_site_total)"        >> "$out"
  emit "commits.insuniverse-site.with_body"    "$(json_field "$TMP/disputed.json" commits_insuniverse_site_with_body)"    >> "$out"
  emit "spec_files.insuniverse-admin.count"    "$(json_field "$TMP/disputed.json" spec_files_insuniverse_admin_count)"    >> "$out"
  emit "spec_files.insuniverse-server.count"   "$(json_field "$TMP/disputed.json" spec_files_insuniverse_server_count)"   >> "$out"
  emit "spec_files.insuniverse-site.count"     "$(json_field "$TMP/disputed.json" spec_files_insuniverse_site_count)"     >> "$out"
  emit "spec_files.set_sha256"                 "$(json_field "$TMP/disputed.json" spec_files_set_sha256)"                 >> "$out"
  emit "roles.insuniverse-server.count"        "$(json_field "$TMP/disputed.json" roles_insuniverse_server_count)"        >> "$out"
  emit "roles.insuniverse-server.excluded_count" "$(json_field "$TMP/disputed.json" roles_insuniverse_server_excluded_count)" >> "$out"
  emit "roles.set_sha256"                      "$(json_field "$TMP/disputed.json" roles_set_sha256)"                      >> "$out"
  emit "comments.hangul_block_count"           "$(json_field "$TMP/disputed.json" comments_hangul_block_count)"           >> "$out"
  emit "comments.hangul_line_count"            "$(json_field "$TMP/disputed.json" comments_hangul_line_count)"            >> "$out"
  emit "comments.set_sha256"                   "$(json_field "$TMP/disputed.json" comments_set_sha256)"                   >> "$out"
  # 오늘의 층화가 고르는 40. 값이 아니라 값을 만든 규칙의 상태다 — dead_code_rule 을 고치면
  # 층화가 움직여 수치보다 이 행이 먼저 붉어진다. 동결 객체 + 규칙만의 함수이며 fixture 를
  # 읽지 않는다(fixture 를 골든 행에 넣으면 워킹트리 편집이 SHA 변경 없이 골든을 움직인다).
  emit "sample.stratum_sha256"                 "$(json_field "$TMP/disputed.json" sample_stratum_sha256)"                 >> "$out"

  # ── template-config 동결 (마일스톤 5) ──
  #
  # template-config 는 8개 worktree 가 공유하는 형식 계약이다. run_template_config_gate 는
  # "지금 통과한다"까지만 보므로 어떤 값으로 통과했는지는 golden 이 잡는다.
  #
  # 요약 다이제스트 1행이 아니라 **파싱된 값을 전수로** 싣는다. 통짜 해시는 주석 오타와
  # 어휘/순서 변경을 구별하지 못하고 diff 가 "달라졌다"까지만 말한다 — 위 exclude 목록을
  # 개수 대신 전수로 싣는 것과 같은 판단이다. 순서가 의미를 갖는 두 키(statuses ·
  # status_precedence)는 정렬하지 않고 파일 순서 그대로 싣는다.
  #
  # 존재 검사를 먼저 두는 이유: 없는 파일에서 추출하면 빈 값이 정상값처럼 실린다 —
  # 위 서브모듈 미초기화에 MISSING 을 싣는 것과 같은 부류의 fail-open 이다.
  #
  # 구분자는 공백이 아니라 쉼표다. auth_layers 원소에 공백이 있어(`계정 종류`) 공백으로
  # 이으면 [인증, 계정 종류] 와 [인증, 계정, 종류] 가 같은 행이 되고, 원소를 쪼개거나 합친
  # 드리프트가 golden 을 그대로 통과한다. 값에 쉼표는 들어올 수 없다 — YAML 흐름형의
  # 구분자라 원소가 되지 못한다.
  #
  # 9행 전부가 tc_guard 를 지난다. 3종만 게이트가 대조하고 나머지는 lint·golden 에 기대던
  # 구조라, 추출 자체가 비는 경우는 어느 층도 막지 못했다(santa R1 Reviewer B).
  local k v
  [ -f "$TEMPLATE_CONFIG" ] || die "template-config 가 없다: $TEMPLATE_CONFIG"
  v=$(tc_scalar project);               tc_guard project scalar "$v"
  emit "template_config.project"               "$v" >> "$out"
  v=$(tc_scalar unknown_status_suffix); tc_guard unknown_status_suffix scalar "$v"
  emit "template_config.unknown_status_suffix" "$v" >> "$out"
  # exit_kinds · surface_exit_kinds 는 **둘 다** 싣는다 — 두 이탈 어휘가 갈라지는 것을
  # golden diff 가 보여야 하는데 한쪽만 실으면 다른 축의 변경이 조용히 지나간다.
  v=$(tc_list repos); tc_guard repos list "$v"
  emit "template_config.repos" "$(printf '%s\n' "$v" | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"

  # ── statuses — 맵 리스트라 tc_list(흐름형 한 줄)로 읽지 못한다 ──
  #
  # **셸에 두 번째 YAML 파서를 만들지 않는다.** tc_list 머리 주석이 라운드 1~4 에 걸쳐 주석·
  # 따옴표·공백에서 lint 와 갈라진 사례 4건을 기록하고 "셸로 세 번째 구현을 짜면 그것이 또
  # 갈라진다" 로 닫는다. 그래서 lint 정본 파서의 CLI 출력(`id|label|placement` 한 줄씩)을 읽는다.
  #
  # `statuses` 행의 값은 **승격 전과 같은 id 목록**이다 — 움직이는 것은 신설 2행뿐이고,
  # 그 최소 diff 자체가 승격이 값을 바꾸지 않았다는 증거다.
  local emitted
  emitted=$(node "$LINTER" --emit-statuses "$TEMPLATE_CONFIG") \
    || die "lint --emit-statuses 가 실패했다 — config.statuses 의 형태가 계약을 벗어났다 (template-config.schema.md)"
  [ -n "$emitted" ] || die "lint --emit-statuses 가 0건을 냈다 — 빈 값을 golden 에 실을 수 없다"
  tc_guard statuses maplist "$emitted"
  emit "template_config.statuses" \
    "$(printf '%s\n' "$emitted" | cut -d'|' -f1 | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"
  emit "template_config.status_labels" \
    "$(printf '%s\n' "$emitted" | cut -d'|' -f2 | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"
  emit "template_config.status_placements" \
    "$(printf '%s\n' "$emitted" | cut -d'|' -f3 | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"

  # 나머지 흐름형 리스트 키. **순서는 승격 전과 같다** — statuses 행이 자리를 옮기면 값이
  # 그대로여도 golden diff 가 부풀어, 승격이 값을 바꾸지 않았다는 최소 diff 증거가 사라진다.
  for k in evidence_grades actors services auth_layers status_precedence exit_kinds surface_exit_kinds; do
    v=$(tc_list "$k"); tc_guard "$k" list "$v"
    emit "template_config.$k" "$(printf '%s\n' "$v" | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"
  done

  # ── deferred_ref_targets (13-relation-contract M5) ──
  #
  # `supersedes` 유예의 **정본 키**다. golden 에 싣지 않으면 그 값이 계약 밖에 남아 목록이
  # 늘어나거나 원소가 바뀌어도 어느 층도 보지 못한다.
  #
  # **블록형이고 원소에 `#` 가 있다.** `tc_list` 는 흐름형 한 줄만 읽고 `tc_guard` 는 값 행의
  # `#` 를 거부하므로 셸로는 이 값을 읽을 수 없다 — statuses 와 같은 처방으로 lint 정본 파서의
  # CLI 출력(`--emit-list`)을 읽는다. 셸에 두 번째 리스트 파서를 만들면 `tc_list` 머리 주석이
  # 기록한 발산의 세 번째 사례가 된다.
  local deferred
  deferred=$(node "$LINTER" --emit-list deferred_ref_targets "$TEMPLATE_CONFIG") \
    || die "lint --emit-list deferred_ref_targets 가 실패했다 — config.deferred_ref_targets 의 형태가 계약을 벗어났다 (template-config.schema.md)"
  [ -n "$deferred" ] || die "lint --emit-list deferred_ref_targets 가 0건을 냈다 — 빈 값을 golden 에 실을 수 없다"
  tc_guard deferred_ref_targets blocklist "$deferred"
  emit "template_config.deferred_ref_targets" \
    "$(printf '%s\n' "$deferred" | tr '\n' ',' | sed 's/,$//; s/,/, /g')" >> "$out"

  # ── onboarding-questions 동결 (마일스톤 6) ──
  #
  # 이 파일은 하류 5개 PRD 전부의 종료 조건이다. run_onboarding_gate 는 "지금 형태가
  # 성립한다"까지만 보므로 어떤 질문으로 성립했는지는 golden 이 잡는다.
  #
  # ids 와 set_sha256 을 둘 다 싣는다. 층이 다르다 — ids 는 추가·삭제·개명을 읽을 수 있는
  # diff 로 보여주고, set_sha256 은 문구·owner·출처·승인 상태가 바뀐 것을 잡는다. 하나만
  # 실으면 각각 반대쪽이 조용히 통과한다.
  #
  # src 를 해시 입력에 넣는다. 빼면 전 질문의 출처를 무의미한 값으로 바꿔도 ids·hash 가
  # 그대로다 — 게이트의 src↔owner 대조는 PRD 이름까지만 보므로 절이 통째로 바뀌는 것을
  # 못 본다. 출처가 바뀌면 기준선이 움직이는 것이 맞다.
  #
  # 존재 검사를 먼저 두는 이유는 위 template-config 블록과 같다 — 없는 파일에서 추출하면
  # 빈 값이 정상값처럼 실린다.
  #
  # 빈 목록과 빈 추출을 **다른 값으로** 싣는다. 위 tc_guard 와 같은 부류다 — 값이 없는 것을
  # 빈 문자열로 두면 (1) `emit` 의 %-38s 가 후행 공백만 남은 행을 만들어 공백을 터는 편집기
  # 하나가 --check 를 붉히고, (2) 키가 통째로 사라진 것과 정상적으로 비어 있는 것이 golden
  # 에서 같은 모양이 된다. 목록은 `-`(비어 있음이 정상), 스칼라는 die(비면 파손).
  #
  # approved_at 도 싣는다. 승인 **시각**은 지표 #5 의 증거인데 어느 행에도 안 실려 있어
  # 값만 바꿔도 golden 이 한 행도 움직이지 않았다(santa 라운드 2 Reviewer B). unapproved_count
  # 는 "몇 건이 미승인인가"를 말하고 이 행은 "언제 승인됐는가"를 말한다 — 층이 다르다.
  #
  # ia_verdict 와 그 근거 집합도 싣는다. 판정문은 **정확한 건수와 질문 ID 를 인용하는 규범
  # 산문**인데 게이트도 golden 도 그것을 읽지 않아, 문장을 거짓으로 고쳐도 --check 가
  # 초록이었다(santa 라운드 3 Reviewer B). 실제로 이 판정문은 41건 시점에서 한 번 굳어
  # 승인된 55건을 안 덮은 전력이 있다(라운드 1).
  #
  # screen_mention_ids 는 **판정이 아니라 드리프트 탐지기**다. `q` 에 "화면"이 들어간
  # 질문의 ID 를 기계적으로 뽑는다 — 판정("목록형인가")은 사람이 하고, 이 행은 그 판정이
  # 근거로 삼은 모집단이 움직였다는 사실만 드러낸다. ponytail: 부분문자열 휴리스틱이라
  # "화면" 없이 인벤토리를 묻는 표현은 못 잡는다. 그것까지 기계화하려면 질문에 태그를
  # 하나 더 달아야 하고, 그 태그는 screen_inventory 가 이미 하고 있다(검사 5).
  #
  # ia_rationale_ids · prd_ia_verdict_ids 는 **같은 판정을 적어 둔 다른 두 자리**를 동결한다.
  # ia_verdict 한 행만 얼려 두면 나머지 두 사본은 자유롭게 어긋난다(santa 라운드 4 Reviewer B).
  # downstream_refs 는 우산 PRD 가 "하류 5개 PRD 전부가 이 파일을 이름으로 참조한다"를 필수
  # 수정 0건의 근거로 쓰는데 그 참조를 아무도 안 읽던 자리다.
  local exc ia at verdict scr_ids rat_ids prd_ids down_refs rat_sha prd_sha cmt_sha
  [ -f "$ONBOARDING" ] || die "onboarding-questions 가 없다: $ONBOARDING"
  [ -f "$PRD00" ] || die "PRD 를 찾을 수 없다: $PRD00"
  tr -d '\r' < "$PRD00" > "$TMP/prd00.gen"
  oq_records > "$TMP/oq.gen"           || die 'onboarding-questions 파싱 실패 (위 stderr 참조)'
  oq_exceptions | sort > "$TMP/oq.gen.exc" || die 'coverage_exceptions 파싱 실패 (위 stderr 참조)'
  exc=$(tr '\n' ' ' < "$TMP/oq.gen.exc" | sed 's/ $//')
  ia=$(sed -n 's/^ia_consumed: *//p' "$ONORM")
  [ -n "$ia" ] || die 'onboarding-questions 에서 ia_consumed 를 추출하지 못했다 (0건)'
  at=$(sed -n 's/^approved_at: *//p' "$ONORM")
  [ -n "$at" ] || die 'onboarding-questions 에서 approved_at 을 추출하지 못했다 (0건)'
  verdict=$(sed -n 's/^ia_verdict: *//p' "$ONORM")
  [ -n "$verdict" ] || die 'onboarding-questions 에서 ia_verdict 를 추출하지 못했다 (0건)'
  # 뷰어 축 2종 + 근거 앵커 2종. 두 sha 는 **oq_ia_gate 규칙 (f)·(g) 와 같은 순수 헬퍼**를
  # 부른다 — gen_report·게이트·selftest 가 각자 해시하면 CR 처리나 행 선택이 한 자리에서만
  # 달라져도 golden 이 checkout 마다 흔들리고 원인이 어느 자리인지 아무도 말하지 못한다.
  # 헬퍼가 실패하면 die 로 끊는다. 0건을 `-` 로 메워 기준선에 앉히면 근거가 사라진 상태가
  # 정상으로 동결된다 — 이 파일의 다른 추출들이 0건을 실패로 두는 것과 같은 이유다.
  ia_on=$(sed -n 's/^ia_basis_onboarding: *//p' "$ONORM")
  [ -n "$ia_on" ] || die 'onboarding-questions 에서 ia_basis_onboarding 을 추출하지 못했다 (0건)'
  ia_vw=$(sed -n 's/^ia_basis_viewer: *//p' "$ONORM")
  [ -n "$ia_vw" ] || die 'onboarding-questions 에서 ia_basis_viewer 를 추출하지 못했다 (0건)'
  ia_src=$(sed -n 's/^ia_viewer_src: *//p' "$ONORM")
  [ -n "$ia_src" ] || die 'onboarding-questions 에서 ia_viewer_src 를 추출하지 못했다 (0건)'
  ia_srcp=$(printf '%s' "$ia_src" | tr -d '"')
  ia_secsha=$(ia_viewer_section_sha "$ROOT/${ia_srcp%%#*}" "${ia_srcp##*#}") \
    || die 'ia_viewer_src 가 가리키는 절의 digest 를 내지 못했다 (위 stderr 참조)'
  ia_delsha=$(ia_delegation_sha "$ROOT/.claude/prds/insuniverse-docs-70-viewer.prd.md" "$TMP/ia.delline") \
    || die '70-viewer PRD 의 ia-doc@1 위임 행 digest 를 내지 못했다 (위 stderr 참조)'
  scr_ids=$(awk -F'|' 'index($7, "화면") { print $1 }' "$TMP/oq.gen" | sort | tr '\n' ' ' | sed 's/ $//')
  oq_rationale_ids > "$TMP/oq.gen.rat" || die 'onboarding-questions 주석에서 질문 ID 를 뽑지 못했다'
  [ -s "$TMP/oq.gen.rat" ] || die 'onboarding-questions 주석의 판정 근거에 질문 ID 가 0건이다'
  rat_ids=$(tr '\n' ' ' < "$TMP/oq.gen.rat" | sed 's/ $//')
  prd_verdict_ids "$TMP/prd00.gen" > "$TMP/oq.gen.prdrat" \
    || die 'PRD 의 ia-doc@1 판정 항목이 정확히 1행이 아니다'
  [ -s "$TMP/oq.gen.prdrat" ] || die 'PRD 의 ia-doc@1 판정 항목에 질문 ID 가 0건이다'
  prd_ids=$(tr '\n' ' ' < "$TMP/oq.gen.prdrat" | sed 's/ $//')
  oq_rationale_block > "$TMP/oq.gen.ratblk" \
    || die 'onboarding-questions 에서 ia_consumed 위의 근거 주석 블록을 찾지 못했다'
  [ -s "$TMP/oq.gen.ratblk" ] || die 'ia_consumed 위의 근거 주석 블록이 비어 있다'
  rat_sha=$(sha256sum < "$TMP/oq.gen.ratblk" | cut -d' ' -f1)
  # 이 파일의 **모든** 주석. 판정 근거 블록만 얼려 두면 파일 머리의 파서 계약 블록("형태가
  # 파서 계약이다" · "키 중복·미지 키는 파싱 실패다" · 하류 참조 규범)이 동결 밖에 남아,
  # 그것을 거짓으로 고쳐도 --check 가 초록이다(santa 라운드 8 Reviewer B).
  #
  # 판정 근거 해시를 이것으로 대체하지 않는다 — 이 행은 "이 파일의 규범 산문이 바뀌었다"만
  # 말하고, ia 근거는 PRD 에 사본이 있어 **어느 쪽이 움직였는지**를 따로 말해야 한다.
  # 들여쓴 주석도 담는다. `^#` 만 잡으면 questions: 아래의 그룹 주석(`  # ── 20-… ──`)
  # 41행이 동결 밖에 남는다(santa 라운드 9 Reviewer B) — 그 주석들이 배분 근거를 적는다.
  grep '^[[:space:]]*#' "$ONORM" > "$TMP/oq.gen.cmts" || true
  [ -s "$TMP/oq.gen.cmts" ] || die 'onboarding-questions 에 주석이 0행이다 — 규약 산문이 사라졌다'
  cmt_sha=$(sha256sum < "$TMP/oq.gen.cmts" | cut -d' ' -f1)
  prd_verdict_line "$TMP/prd00.gen" > "$TMP/oq.gen.prdline" \
    || die 'PRD 의 ia-doc@1 판정 항목이 정확히 1행이 아니다'
  [ -s "$TMP/oq.gen.prdline" ] || die 'PRD 의 ia-doc@1 판정 항목이 비어 있다'
  prd_sha=$(sha256sum < "$TMP/oq.gen.prdline" | cut -d' ' -f1)
  # 문서 PRD 재열거의 모집단은 **활성 ∪ archived** 다. `/mccp:archive-complete` 가 완료 PRD 를
  # `.claude/prds/archived/` 로 옮기므로 활성만 세면 그 PRD 가 아래 owner 양방향 대조에서
  # '알 수 없는 owner' 로 떨어진다(20-foundation-access 마일스톤 7 실측). 십의 자리 규칙
  # (`[2-6]0`)은 문서 PRD 의 **의미 정의**라 그대로 둔다 — 배제가 리터럴이 아니라 규칙에서 나온다.
  { ls "$ROOT/.claude/prds"; ls "$ROOT/.claude/prds/archived" 2>/dev/null; } \
    | sed -nE 's/^insuniverse-docs-([2-6]0-[a-z-]+)\.prd\.md$/\1/p' \
    | sort -u > "$TMP/oq.gen.owners"
  [ -s "$TMP/oq.gen.owners" ] || die '.claude/prds/ 에서 문서 PRD 를 0종 추출했다'
  oq_downstream_refs "$TMP/oq.gen.owners" "$TMP/oq.gen" > "$TMP/oq.gen.refs" \
    || die '하류 문서 PRD 의 §Success Metrics 구조 확인 실패 (위 stderr 참조)'
  down_refs=$(sort "$TMP/oq.gen.refs" | tr '\n' ' ' | sed 's/ $//')
  emit "onboarding.question_count"      "$(wc -l < "$TMP/oq.gen")"                                          >> "$out"
  emit "onboarding.ids"                 "$(cut -d'|' -f1 "$TMP/oq.gen" | sort | tr '\n' ' ' | sed 's/ $//')" >> "$out"
  emit "onboarding.owner_counts"        "$(cut -d'|' -f2 "$TMP/oq.gen" | sort | uniq -c \
                                            | awk '{printf "%s=%s, ", $2, $1}' | sed 's/, $//')"            >> "$out"
  emit "onboarding.service_counts"      "$(awk -F'|' '$3 != "-" { print $3 }' "$TMP/oq.gen" | sort | uniq -c \
                                            | awk '{printf "%s=%s, ", $2, $1}' | sed 's/, $//')"            >> "$out"
  emit "onboarding.coverage_exceptions" "${exc:--}"                                                         >> "$out"
  emit "onboarding.unapproved_count"    "$(awk -F'|' '$5 == "false"' "$TMP/oq.gen" | wc -l)"                >> "$out"
  emit "onboarding.approved_at"         "$at"                                                               >> "$out"
  emit "onboarding.ia_consumed"         "$ia"                                                               >> "$out"
  emit "onboarding.ia_verdict"          "$verdict"                                                          >> "$out"
  emit "onboarding.ia_basis_onboarding" "$ia_on"                                                            >> "$out"
  emit "onboarding.ia_basis_viewer"     "$ia_vw"                                                            >> "$out"
  emit "onboarding.ia_viewer_src"       "$ia_src"                                                           >> "$out"
  emit "onboarding.ia_viewer_sha256"    "$ia_secsha"                                                        >> "$out"
  emit "onboarding.ia_viewer_delegation_sha256" "$ia_delsha"                                                >> "$out"
  emit "onboarding.screen_mention_ids"  "${scr_ids:--}"                                                     >> "$out"
  emit "onboarding.ia_rationale_ids"    "$rat_ids"                                                          >> "$out"
  emit "onboarding.ia_rationale_sha256" "$rat_sha"                                                          >> "$out"
  emit "onboarding.comments_sha256"     "$cmt_sha"                                                          >> "$out"
  emit "onboarding.prd_ia_verdict_ids"  "$prd_ids"                                                          >> "$out"
  emit "onboarding.prd_ia_verdict_sha256" "$prd_sha"                                                        >> "$out"
  emit "onboarding.downstream_refs"     "${down_refs:--}"                                                   >> "$out"
  emit "onboarding.set_sha256"          "$(sort "$TMP/oq.gen" | sha256sum | cut -d' ' -f1)"                 >> "$out"

  # ── 재현 조건 ──
  #
  # 위 16행은 *값*이고 아래 5행은 값을 만든 *조건*이다. 조건이 조용히 바뀌면 값은 결정적이면서
  # 틀리다. env.git_version 은 trailer 정의를 git 에 위임한 대가이고(버전 간 파서 발산이
  # 조용해지지 않게), exclude_inputs.* 는 배제 분류가 값을 지배하는데 그 분류 자체가 golden
  # 밖이던 구멍이다. 분류가 달라지면 수치가 아니라 **입력 행이 먼저** 붉어져 어느 층이
  # 움직였는지가 diff 에 보인다. 개별 행이라야 어느 목록인지가 드러난다.
  emit "env.git_version"                "$(json_field "$TMP/disputed.json" git_version)"          >> "$out"
  emit "exclude_inputs.anchored_sha256" "$(sha256sum < "$TMP/anchored"  | cut -d' ' -f1)"         >> "$out"
  emit "exclude_inputs.ext_sha256"      "$(sha256sum < "$TMP/ext"       | cut -d' ' -f1)"         >> "$out"
  emit "exclude_inputs.repos_sha256"    "$(sha256sum < "$TMP/repos.raw" | cut -d' ' -f1)"         >> "$out"
  emit "exclude_inputs.wide_sha256"     "$(sha256sum < "$TMP/wide"      | cut -d' ' -f1)"         >> "$out"

  # ── 00-index · ownership · findings ──
  #
  # 개수·열거·해시 3층을 함께 둔다. 해시는 "무언가 바뀌었다" 만 말하고, 실제 사고는 "어느
  # 경로가 빠졌는가" 였다(santa 라운드 6). 각 추출의 **0건은 실패**다 — 비면 `-` 로 메우지
  # 않는다. `-` 를 기준선으로 축성하면 사라진 값이 정상으로 동결된다.
  #
  # bootstrap_permitted 의 입력(하류 PRD status)은 여기 넣지 않는다. 하류가 착수하면 정당하게
  # 바뀌는 값이라 동결하면 그 착수가 --update 를 요구하는 드리프트로 보고된다.
  tree_paths "$INDEX" '^## 문서 트리$' '^## 상태 범례$' | sort > "$TMP/gr.tree"
  [ -s "$TMP/gr.tree" ] || { printf '00-index.md §문서 트리에서 경로를 추출하지 못했다 (0건)\n' >&2; return 1; }
  md_section "$INDEX" '^## 문서 트리$' '^## 상태 범례$' \
    | sed -nE 's|^  ([0-9]{2}-[a-z-]+)/.*$|\1|p' > "$TMP/gr.dirs"
  [ -s "$TMP/gr.dirs" ] || { printf '00-index.md 트리에서 절 디렉터리를 추출하지 못했다 (0건)\n' >&2; return 1; }
  # 상태 범례는 **표 파서로** 읽는다. 코드만 뽑는 sed 를 따로 두면 게이트와 golden 이 같은 표를
  # 다른 파서로 읽게 되고(하우스 룰 2), 뜻 열이 golden 밖에 남는다 — 뜻을 뒤집어도 아무 값이
  # 안 움직이던 구멍이 그것이다(santa m7 R2 Reviewer B).
  md_table "$INDEX" '^## 상태 범례$' '^## 폐기 목록$' 2 > "$TMP/gr.statusrows"
  [ -s "$TMP/gr.statusrows" ] || { printf '00-index.md §상태 범례 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  awk -F'|' '{ print $1 }' "$TMP/gr.statusrows" > "$TMP/gr.statuses"
  [ -s "$TMP/gr.statuses" ] || { printf '00-index.md §상태 범례에서 status 코드를 추출하지 못했다 (0건)\n' >&2; return 1; }
  emit "index.entry_count"   "$(wc -l < "$TMP/gr.tree")"                                          >> "$out"
  emit "index.doc_count"     "$(grep -c -v -x -F -- "${INDEX#"$ROOT"/}" "$TMP/gr.tree" || true)"   >> "$out"
  emit "index.tree_sha256"   "$(sha256sum < "$TMP/gr.tree" | cut -d' ' -f1)"                      >> "$out"
  emit "index.section_order" "$(tr '\n' ' ' < "$TMP/gr.dirs" | sed 's/ $//')"                     >> "$out"
  emit "index.status_legend" "$(tr '\n' ' ' < "$TMP/gr.statuses" | sed 's/ $//')"                 >> "$out"
  # 코드 열거만으로는 뜻 열이 자유롭다. 열거는 "무엇이 빠졌는가" 를, 해시는 완결성을 맡는다 —
  # exclude_inputs · inherited_additions 가 같은 짝이다.
  emit "index.status_rows_sha256" "$(sha256sum < "$TMP/gr.statusrows" | cut -d' ' -f1)"           >> "$out"

  # ── fixtask (관계 축 계약 M8) ──
  #
  # 이 창의 **동결 기제**다. `fixtask.cohort_m8` 은 착수 시점 행 집합을 골든에 싣고, 기준선
  # 커밋의 golden.txt 가 그 시점의 값을 보존한다 — 게이트는 워킹트리가 아니라 그 커밋에서
  # 읽는다. 가변 원장을 분모로 쓰면 행을 지울 때 분모·분자가 함께 줄어 "미판정 0" 이 유지되고
  # 알려진 결함 하나가 조용히 사라진다(M8 관측 5).
  #
  # 매핑·계보는 **파일 digest** 로 싣는다. 내용을 펼쳐 실으면 골든이 그 파일의 사본이 되고
  # 두 자리가 따로 표류한다. 0건은 실패다 — 부재를 `-` 로 메우지 않는다.
  node "$FIXTASK_TOOL" digest-set "$BACKLOG_LEDGER" > "$TMP/gr.fxcohort" \
    || { printf 'fixtask digest-set 실패\n' >&2; return 1; }
  [ -s "$TMP/gr.fxcohort" ] || { printf 'fixtask 코호트를 0건 유도했다\n' >&2; return 1; }
  [ -f "$FIXTASK_MAP" ]     || { printf 'fixtask 대체 매핑이 없다: %s\n' "$FIXTASK_MAP" >&2; return 1; }
  [ -f "$FIXTASK_LINEAGE" ] || { printf 'fixtask 계보가 없다: %s\n' "$FIXTASK_LINEAGE" >&2; return 1; }
  node "$FIXTASK_TOOL" read-map "$FIXTASK_MAP"                   > "$TMP/gr.fxmap" || return 1
  node "$FIXTASK_TOOL" read-map "$FIXTASK_LINEAGE" --kind lineage > "$TMP/gr.fxln" || return 1
  tree_rows_raw "$INDEX" '^## 문서 트리$' '^## 상태 범례$' > "$TMP/gr.fxtree"
  [ -s "$TMP/gr.fxtree" ] || { printf 'fixtask 트리 raw 행을 0건 유도했다\n' >&2; return 1; }
  emit "fixtask.cohort_count"        "$(wc -l < "$TMP/gr.fxcohort")"                              >> "$out"
  emit "fixtask.cohort_m8"           "$(tr '\n' ' ' < "$TMP/gr.fxcohort" | sed 's/ $//')"         >> "$out"
  emit "fixtask.replacement_map"     "$(sed 's/\t/=/' "$TMP/gr.fxmap" | tr '\n' ' ' | sed 's/ $//')" >> "$out"
  emit "fixtask.lineage"             "$(sed 's/\t/=/' "$TMP/gr.fxln"  | tr '\n' ' ' | sed 's/ $//')" >> "$out"
  emit "fixtask.tree_rows_sha256"    "$(sha256sum < "$TMP/gr.fxtree" | cut -d' ' -f1)"            >> "$out"
  emit "fixtask.subsection_body"     "$(subsection_body_count "$SPEC" '^## 4[.] 확정 docs 구조$' '### 읽기 축 2 — 흐름 진입')" >> "$out"
  # 파일별 발견 표제 수. `findings.files` 는 **파일 목록**만 동결하므로 파일은 그대로 두고
  # 항목만 지우면 아무 값도 안 움직였다 — 기록을 빼도 마일스톤이 complete 가 되던 자리다
  # (backlog `…-m6.plan.md` R1 F4 행). 여기서부터는 항목 삭제가 `--check` 를 붉힌다.
  : > "$TMP/gr.fxfind"
  for ffile in "$FINDINGS"/*.md; do
    [ -f "$ffile" ] || continue
    [ "$(basename "$ffile")" != README.md ] || continue
    printf '%s=%s\n' "$(basename "$ffile" .md)" "$(findings_heading_count "$ffile")" >> "$TMP/gr.fxfind"
  done
  [ -s "$TMP/gr.fxfind" ] || { printf 'findings 파일별 표제 수를 0건 유도했다\n' >&2; return 1; }
  emit "fixtask.findings_headings"   "$(sort "$TMP/gr.fxfind" | tr '\n' ' ' | sed 's/ $//')"      >> "$out"

  ow_rows "$OWNERSHIP" '^## PRD별 배타 소유 경로$' '^## 승계 시 추가$' > "$TMP/gr.own"
  [ -s "$TMP/gr.own" ] || { printf 'ownership.md §PRD별 배타 소유 경로에서 행을 추출하지 못했다 (0건)\n' >&2; return 1; }
  ow_pairs "$TMP/gr.own" | awk -F'|' '{ print $2 }' | sort -u > "$TMP/gr.paths"
  [ -s "$TMP/gr.paths" ] || { printf 'ownership.md 에서 배타 소유 경로를 추출하지 못했다 (0건)\n' >&2; return 1; }
  md_table "$OWNERSHIP" '^## 승계 시 추가$' '^## 공유 자산' 5 > "$TMP/gr.add"
  [ -s "$TMP/gr.add" ] || { printf 'ownership.md §승계 시 추가에서 선언 행을 추출하지 못했다 (0건)\n' >&2; return 1; }
  md_table "$OWNERSHIP" '^## 공유 자산 충돌 방지 프로토콜$' '^## 스크래치 승격 규약$' 3 > "$TMP/gr.sa"
  [ -s "$TMP/gr.sa" ] || { printf 'ownership.md §공유 자산 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  md_table "$OWNERSHIP" '^### 승격 대상 목록$' '^\*\*승격 실행은' 4 > "$TMP/gr.scratch"
  [ -s "$TMP/gr.scratch" ] || { printf 'ownership.md §승격 대상 목록을 추출하지 못했다 (0건)\n' >&2; return 1; }
  emit "ownership.prd_count"           "$(wc -l < "$TMP/gr.own")"                                          >> "$out"
  emit "ownership.paths_sha256"        "$(sha256sum < "$TMP/gr.paths" | cut -d' ' -f1)"                    >> "$out"
  emit "ownership.inherited_additions" "$(awk -F'|' '{ print $2 }' "$TMP/gr.add" | sort \
                                          | tr '\n' ' ' | sed 's/ $//')"                                   >> "$out"
  # 경로 열만 동결하면 나머지 4열이 golden 밖에 남는다. `범위 한정` 은 검사 2b 가 PRD00
  # §배타 소유 경로의 `←` 한정어에 묶지만 **그 PRD 도 이 worktree 소유**라 두 파일을 함께
  # 고치면 전 검사가 초록인 채 정본의 권한 범위가 바뀐다(`사유` 열은 아무 데도 안 묶인다).
  # 바로 아래 shared_assets 가 같은 결함을 sha256 으로 닫았다 — 같은 처리를 여기에도 건다.
  emit "ownership.inherited_additions_sha256" "$(sha256sum < "$TMP/gr.add" | cut -d' ' -f1)"               >> "$out"
  emit "ownership.shared_assets"       "$(awk -F'|' '{ print $1 }' "$TMP/gr.sa" | tr '\n' ' ' | sed 's/ $//')" >> "$out"
  # 자산명만 동결하면 소유자를 바꾸거나 접근 규칙을 지워도 golden 이 조용하다.
  emit "ownership.shared_assets_sha256" "$(sha256sum < "$TMP/gr.sa" | cut -d' ' -f1)"                      >> "$out"
  emit "ownership.scratch_sources"     "$(awk -F'|' '{ printf "%s%s=%s, ", $1, $2, $4 }' "$TMP/gr.scratch" \
                                          | sed 's/, $//')"                                                >> "$out"
  # `내용` 열(3열)이 위 열거에 없다. 게이트도 인수 PRD 실재만 보므로 승격 대상을 통째로 다른
  # 것으로 바꿔 써도 아무 값이 안 움직였다(santa m7 R2 Reviewer B). 누락이 곧 재조사라는
  # 것이 이 목록의 존재 이유이고, 무엇을 넘겼는지가 바뀌면 그 재조사가 다시 생긴다.
  # shared_assets 가 같은 결함을 sha256 으로 닫았다 — 같은 처리를 건다.
  emit "ownership.scratch_sha256"      "$(sha256sum < "$TMP/gr.scratch" | cut -d' ' -f1)"                  >> "$out"

  # `ls` 를 쓰지 않는다 — 하위 디렉터리가 파일과 구별 없이 한 줄로 섞여 들어온다.
  [ -d "$FINDINGS" ] || { printf 'findings 디렉터리가 없다: %s\n' "$FINDINGS" >&2; return 1; }
  find "$FINDINGS" -maxdepth 1 -type f -printf '%f\n' | sort > "$TMP/gr.findings"
  [ -s "$TMP/gr.findings" ] || { printf 'findings/ 가 비어 있다\n' >&2; return 1; }
  emit "findings.files"                "$(tr '\n' ' ' < "$TMP/gr.findings" | sed 's/ $//')"                >> "$out"

  # 발견 파일의 **스키마**도 동결한다. 항목 목록과 상태 enum 은 PRD 문장에서 유도되는데(검사 5)
  # 그 PRD 도 이 worktree 소유다 — PRD 와 README 를 함께 고치면 필수 항목을 하나 줄여도 전
  # 검사가 초록이고 `findings.files` 는 파일이 안 늘었으니 움직이지 않는다. 유도의 신뢰 근원이
  # 감시 대상 안에 있을 때 열거를 동결하는 것이 이 파일의 처방이다(lint.glob_exceptions 와 같다).
  md_section "$PRD00" '^### 발견 기록 규약' '^### 스크래치 승격 규약' > "$TMP/gr.find.prd"
  [ -s "$TMP/gr.find.prd" ] || { printf 'PRD §발견 기록 규약 절을 추출하지 못했다 (0건)\n' >&2; return 1; }
  prd_finding_items "$TMP/gr.find.prd" > "$TMP/gr.find.items" || return 1
  [ -s "$TMP/gr.find.items" ] || { printf 'PRD §발견 기록 규약에서 필수 항목을 0건 유도했다\n' >&2; return 1; }
  prd_status_enum "$TMP/gr.find.prd" > "$TMP/gr.find.enum" || return 1
  [ -s "$TMP/gr.find.enum" ] || { printf 'PRD §발견 기록 규약에서 상태 enum 을 0건 유도했다\n' >&2; return 1; }
  emit "findings.required_items"       "$(paste -sd, "$TMP/gr.find.items")"                             >> "$out"
  emit "findings.status_enum"          "$(paste -sd, "$TMP/gr.find.enum")"                              >> "$out"
  # 유도는 **버리는 부분**이 있다 — 항목 토큰의 괄호 안 의미 제약(`근거 앵커(파일:행 …)`)은
  # 항목명만 남기며 사라지고, 그 절의 다른 문장(판단 금지 · 파싱 계약)도 어느 열거에도 안 실린다.
  # `재현 절차(반드시 응답 본문 포함)` 같은 제약을 넣거나 빼도 위 두 행이 안 움직인다(santa m7
  # R5 Reviewer B). 열거는 "무엇이 빠졌는가" 를, 해시는 완결성을 맡는다 — 이 파일이 이미
  # inherited_additions · shared_assets · scratch 에 쓴 짝이다.
  emit "findings.rules_sha256"         "$(sha256sum < "$TMP/gr.find.prd" | cut -d' ' -f1)"              >> "$out"

  # lint glob 예외는 PRD 산문에서 유도된다(lint_glob_exceptions). 동결하지 않으면
  # §lint 전수 게이트에 `… 는 glob 에서 제외한다` 한 문장을 더하는 것만으로 그 경로가
  # run_lint_coverage_gate 감시와 bootstrap_permitted 감시 집합에서 함께 빠지고, 두 게이트가
  # 그대로 초록이며 golden 도 움직이지 않는다 — 커버리지 소실이 자기 승인되는 경로다.
  # 하류 status 와 달리 예외 선언은 정당한 변동이 없으므로 동결이 드리프트 오보를 내지 않는다.
  lint_glob_exceptions > "$TMP/gr.lgexc" \
    || { printf 'PRD §lint 전수 게이트에서 glob 예외 선언을 찾지 못했다\n' >&2; return 1; }
  [ -s "$TMP/gr.lgexc" ] \
    || { printf 'PRD §lint 전수 게이트의 glob 예외 선언이 비어 있다\n' >&2; return 1; }
  emit "lint.glob_exceptions"          "$(tr '\n' ' ' < "$TMP/gr.lgexc" | sed 's/ $//')"                   >> "$out"

  if [ -s "$TMP/repos.missing" ]; then
    printf '서브모듈 미초기화 — actual_head 를 읽을 수 없다:\n' >&2
    sed 's/^/  /' "$TMP/repos.missing" >&2
    printf '  git submodule update --init 로 복원한다.\n' >&2
    return 1
  fi
  [ "$(cat "$TMP/parity")" = OK ] || return 1
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# 마크다운 절 슬라이스 + 표 정규화 — 00-index · ownership · findings 게이트의 공용 부품.
#
# 전부 파일을 직접 읽어 파일로 낸다. 가드 좌변에 파이프를 두지 않는다는 이 스크립트의 계약
# (§onboarding 게이트 주석)이 여기에도 적용되므로, 호출부는 결과를 파일에 받은 뒤 대조한다.
# ─────────────────────────────────────────────────────────────────────────────

# md_section <파일> <시작 정규식> <끝 정규식>
#   CRLF 정규화 후 절 경계로 자른다. 끝 정규식은 시작 **다음 행부터** 찾는다.
#
#   경계를 단언한다 — 시작 표제 정확히 1회 + 시작 뒤에 끝 표제 실재. sed 의 범위는 그 둘을
#   조용히 어긴다: 끝을 못 찾으면 EOF 까지 먹고, 시작이 2회면 범위 두 벌을 이어 붙인다.
#   `00-index.md` 의 `## 폐기 목록` 을 다른 제목으로 바꿔도 §상태 범례 추출이 **글자 하나
#   안 바뀌는** 것이 그 형태다(santa m7 R1 Reviewer B — 실측). 같은 계약이 manifest 목록에는
#   이미 걸려 있다(`parse_list` 의 BOUNDARY 불변식) — 새로 설계할 것이 없다.
#
#   **끝 정규식을 빈 문자열로 주면 EOF 까지**다. 파일의 마지막 절(`00-index.md` §폐기 목록)은
#   뒤에 표제가 올 수 없어 위 계약이 정상 파일을 실패시킨다. 침묵하는 EOF 삼킴과 다른 것은
#   호출부가 그것을 **명시**한다는 점이다 (`oq_slice` 가 `questions:` 를 그렇게 다룬다).
#
#   위반 시 **아무것도 내지 않고** stderr 에 사유를 적는다. die 를 쓰지 않는 이유는 이 함수가
#   파이프 좌변에 놓이기 때문이다 — 서브셸만 죽고 우변은 빈 입력으로 성공한다(하우스 룰 5).
#   호출부는 전부 0건 가드를 갖고 있어 그 가드로 시끄럽게 떨어진다.
md_section() {
  local f="$1" s="$2" e="$3" ns start
  tr -d '\r' < "$f" > "$TMP/ms.norm"
  ns=$(grep -c "$s" "$TMP/ms.norm" || true)
  [ "$ns" -eq 1 ] \
    || { printf 'md_section: %s 의 시작 표제 /%s/ 가 %s회다 (정확히 1회여야 한다)\n' "$f" "$s" "$ns" >&2; return 1; }
  if [ -z "$e" ]; then
    start=$(grep -n "$s" "$TMP/ms.norm" | cut -d: -f1)
    tail -n "+$start" "$TMP/ms.norm"
    return 0
  fi
  start=$(grep -n "$s" "$TMP/ms.norm" | cut -d: -f1)
  tail -n "+$((start + 1))" "$TMP/ms.norm" > "$TMP/ms.after"
  grep -q "$e" "$TMP/ms.after" \
    || { printf 'md_section: %s 의 끝 표제 /%s/ 가 시작 뒤에 없다 — 절이 EOF 까지 열려 있다\n' "$f" "$e" >&2; return 1; }
  sed -n "/$s/,/$e/p" "$TMP/ms.norm"
}

# tree_paths <파일> <시작 정규식> <끝 정규식>
#   절 안의 코드펜스 1개를 트리로 읽고 **ROOT 상대 전 경로**를 낸다.
#
#   basename 만 뽑으면 트리 동등성을 증명하지 못한다 — `01-authn.md` 를 다른 디렉터리 밑으로
#   옮겨도 값이 그대로다. 들여쓰기가 계층이므로 디렉터리 문맥을 유지하며 복원한다. 또 정본
#   코드펜스는 한 행에 파일 토큰을 2~4개 담는 블록이 있어(10-server · 20-site · 30-admin)
#   행당 1회 추출은 12건을 조용히 빠뜨린다. 행 안의 토큰을 **전부** 방출한다.
#
#   **들여쓰기 가정이 깨지면 실패한다.** 초안은 문맥을 복원하지 못한 토큰을 `pre[d]` 미정의인
#   채로 이어 붙여 **접두어 없는 basename 을 조용히 냈다**. 두 파일에 같은 교란을 넣으면 양쪽이
#   똑같이 basename 으로 떨어져 검사 1 의 전 경로 대조가 그대로 통과한다 — IF1 이 닫은 구멍이
#   다시 열리는 것이고, 실측에서 3칸 들여쓰기 하나로 재현됐다(santa m7 R3). 2의 배수가 아닌
#   들여쓰기와 상위 문맥 없는 토큰은 거부한다. 실패 시 **아무것도 내지 않는다**(ow_rows 와 같은
#   계약) — 호출부의 0건 가드가 시끄럽게 떨어진다.
tree_paths() {
  md_section "$1" "$2" "$3" | sed -n '/^```$/,/^```$/p' | awk '
    function ind_of(s,   i) { i = 0; while (substr(s, i + 1, 1) == " ") i++; return i }
    /^```/ { next }
    /^[[:space:]]*$/ { next }
    {
      d = ind_of($0)
      if (d % 2 != 0) {
        printf "tree_paths: 들여쓰기가 %d칸이다 (2의 배수여야 계층이 복원된다): %s\n", d, $0 > "/dev/stderr"
        F = 1; next
      }
      line = substr($0, d + 1)
      if (line ~ /^[A-Za-z0-9._-]+\/([[:space:]]|$)/) {
        sub(/[[:space:]].*$/, "", line)
        if (d > 0 && !(d in pre)) {
          printf "tree_paths: 디렉터리 %s (들여쓰기 %d) 의 상위 문맥이 없다\n", line, d > "/dev/stderr"
          F = 1; next
        }
        # **얕아지면 더 깊은 문맥을 버린다.** 남겨 두면 `site/` 뒤에 4칸으로 들여쓴 파일이
        # 앞 가지의 `server/auth/` 를 접두어로 달고 나온다 — 부재가 아니라 **잔류**라 위의
        # `!(d in pre)` 가드에 걸리지 않고, 틀린 경로가 조용히 나온다(santa m7 R5 Reviewer B).
        for (i in stack) if (i + 0 > d)     delete stack[i]
        for (i in pre)   if (i + 0 > d + 2) delete pre[i]
        stack[d] = line
        p = ""
        for (i = 0; i <= d; i += 2) if (i in stack) p = p stack[i]
        pre[d + 2] = p
        next
      }
      n = split(line, t, /[[:space:]]+/)
      for (i = 1; i <= n; i++) if (t[i] ~ /\.md$/) {
        if (!(d in pre)) {
          printf "tree_paths: %s (들여쓰기 %d) 의 디렉터리 문맥을 복원하지 못했다 — 접두어 없는 basename 은 트리 동등성을 증명하지 못한다\n", t[i], d > "/dev/stderr"
          F = 1; continue
        }
        rows++; buf[rows] = pre[d] t[i]
      }
    }
    END {
      if (F) exit 1
      for (i = 1; i <= rows; i++) print buf[i]
    }
  '
}

# ow_rows <파일> <시작 정규식> <끝 정규식>
#   PRD 소유 표를 `<PRD 파일명>|<선행조건>|<정렬된 경로>|<종료조건>` 로 정규화한다.
#
#   파일명 집합만 대조하면 두 PRD 사이에서 경로·선행조건·종료조건을 **맞바꿔도** 통과한다.
#   행 단위로 묶어 결속 축을 본다. 경로만 행 안에서 정렬하고 **행 순서는 보존**한다 —
#   종료조건의 `동일` 축약이 바로 위 행을 가리키므로 행을 정렬하면 축약이 뜻을 잃는다.
#   백틱만 벗기고 `**` 는 남긴다(`docs/01-foundation/**` 와 `docs/01-foundation/` 은 다르다).
ow_rows() {
  md_section "$1" "$2" "$3" | awk '
    # 코드펜스 안의 표는 예시이지 데이터가 아니다. ``` 와 ~~~ 둘 다 걷는다 — 한쪽만 걷으면
    # 다른 표기로 위장한 예시가 행으로 읽힌다(oq_downstream_refs 와 같은 처리).
    /^(```|~~~)/ { fence = !fence; next }
    fence { next }
    {
      if ($0 !~ /^\|/) next
      # 열 수는 **정확히** 4열(파이프 6조각)이어야 한다. `< 6` 으로 두면 열이 하나 늘어난
      # 표에서 앞 4열만 읽고 조용히 통과한다 — md_table 이 `!= want + 2` 로 거부하는 것과
      # 같은 부류이며, 두 파서가 같은 파일을 다르게 받으면 검사 1 의 행 대조가 무의미해진다.
      #
      # GFM 의 `\|` 는 셀 안의 리터럴 파이프이지 구분자가 아니다. 안 걷으면 정당한 마크다운이
      # 폭 위반으로 떨어지고, 그 행은 **조용히 사라진다**(santa m7 R2 Reviewer B).
      line = $0
      gsub(/\\\|/, "\001", line)
      n = split(line, c, "|")
      if (n != 6) { bad++; width[n - 2] = 1; next }
      prd = c[2]; pre = c[3]; paths = c[4]; end = c[5]
      gsub(/\001/, "|", prd); gsub(/\001/, "|", pre); gsub(/\001/, "|", paths); gsub(/\001/, "|", end)
      if (prd !~ /\.prd\.md/) next
      gsub(/`/, "", prd); gsub(/`/, "", pre); gsub(/`/, "", paths); gsub(/`/, "", end)
      m = split(paths, p, ",")
      for (i = 1; i <= m; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", p[i])
      for (i = 1; i < m; i++) for (j = i + 1; j <= m; j++) if (p[j] < p[i]) { t = p[i]; p[i] = p[j]; p[j] = t }
      ps = ""
      for (i = 1; i <= m; i++) ps = ps (i > 1 ? " " : "") p[i]
      gsub(/[[:space:]]+/, " ", prd); gsub(/^ | $/, "", prd)
      gsub(/[[:space:]]+/, " ", pre); gsub(/^ | $/, "", pre)
      gsub(/[[:space:]]+/, " ", end); gsub(/^ | $/, "", end)
      rows++
      buf[rows] = prd "|" pre "|" ps "|" end
    }
    # 폭이 다른 행은 **1건이라도 전체를 실패시킨다.** 초안은 그 행만 버리고 나머지를 냈는데,
    # 유효한 행이 하나라도 있으면 진단조차 뜨지 않아 행 하나가 조용히 사라졌다(santa m7 R2
    # Reviewer B). `parse_list` 의 NO_RESIDUAL 불변식과 같은 계약이다 — 범위 안의 행은
    # 항목이거나 잔여이며 잔여는 실패다.
    #
    # 실패 시 **아무것도 내지 않는다.** 행을 흘려보낸 뒤 exit 1 하면 앞부분이 이미 나가 있어
    # 호출부의 `[ -s ]` 가 통과한다. 그래서 버퍼에 모았다가 END 에서만 낸다.
    END {
      if (bad) {
        ws = ""
        for (k in width) ws = ws (ws == "" ? "" : ",") k
        printf "ow_rows: 표 행 %d건이 4열이 아니다 (실제 %s열) — 폭이 다른 행은 버리지 않고 전체를 실패시킨다\n", bad, ws > "/dev/stderr"
        exit 1
      }
      for (i = 1; i <= rows; i++) print buf[i]
    }
  '
}

# ow_pairs <정규화된 행 파일> — `<owner>|<path>` 쌍으로 펼친다.
ow_pairs() {
  awk -F'|' '{ n = split($3, a, " "); for (i = 1; i <= n; i++) print $1 "|" a[i] }' "$1" | sort -u
}

# md_table <파일> <시작 정규식> <끝 정규식> <열 수>
#   구분선·머리행을 뺀 본문 행을 `|` 로 이어 낸다. 백틱과 `**` 를 벗기고 공백을 축약한다.
#
#   머리행은 **위치로** 버린다(구분선을 뺀 첫 행). 머리 문구를 정규식으로 잡으면 같은 낱말로
#   시작하는 본문 행까지 사라지고, 그 소실은 조용하다. 절 안에 같은 열 수의 표가 두 개 있으면
#   두 번째 표의 머리행은 본문으로 들어오므로, 이 함수는 표가 절당 1개인 자리에서만 쓴다.
md_table() {
  md_section "$1" "$2" "$3" | awk -v want="$4" '
    # ow_rows 와 같은 이유로 코드펜스를 걷는다.
    /^(```|~~~)/ { fence = !fence; next }
    fence { next }
    {
      if ($0 !~ /^\|/) next
      if ($0 ~ /^\|[[:space:]]*:?-{2,}/) next
      line = $0
      gsub(/\\\|/, "\001", line)   # GFM 이스케이프 파이프 — ow_rows 와 같은 처리
      n = split(line, c, "|")
      if (n != want + 2) { bad++; width[n - 2] = 1; next }
      seen++
      if (seen == 1) next
      out = ""
      for (i = 2; i <= want + 1; i++) {
        v = c[i]
        gsub(/\001/, "|", v)
        gsub(/`|\*\*/, "", v)
        gsub(/[[:space:]]+/, " ", v)
        gsub(/^ | $/, "", v)
        out = out (i > 2 ? "|" : "") v
      }
      rows++
      buf[rows] = out
    }
    # 위 ow_rows 의 END 와 같은 계약 — 폭이 다른 행이 1건이라도 있으면 전체 실패, 출력 0바이트.
    END {
      if (bad) {
        ws = ""
        for (k in width) ws = ws (ws == "" ? "" : ",") k
        printf "md_table: 표 행 %d건이 %d열이 아니다 (실제 %s열) — 폭이 다른 행은 버리지 않고 전체를 실패시킨다\n", bad, want, ws > "/dev/stderr"
        exit 1
      }
      for (i = 1; i <= rows; i++) print buf[i]
    }
  '
}

# lint_glob_covers <디렉터리 경로>
#   LINT_GLOBS 중 하나가 이 디렉터리를 덮는지. case 의 패턴은 비인용이라 glob 로 물린다.
lint_glob_covers() {
  local dir="$1" g gd
  for g in "${LINT_GLOBS[@]}"; do
    gd="${g%/*}/"
    case "$dir" in
      $gd) return 0 ;;
    esac
  done
  return 1
}

# doc_prd_paths — 문서 PRD 가 소유한 `docs/<번호절>/**` 경로를 `<PRD>|<경로>` 로 낸다.
#
#   분류 라벨이 아니라 **우산이 고정한 경로 모양**에서 유도한다. 라벨에서 유도하면 같은
#   마일스톤이 쓰는 가변 표가 감시 대상을 정하게 되어, 행을 재분류하는 것만으로 감시가
#   흔들린다. 경로 모양은 run_ownership_gate 검사 1 이 우산에 행 단위로 묶는다.
doc_prd_paths() {
  ow_rows "$OWNERSHIP" '^## PRD별 배타 소유 경로$' '^## 승계 시 추가$' > "$TMP/dp.rows"
  [ -s "$TMP/dp.rows" ] || return 1
  ow_pairs "$TMP/dp.rows" | awk -F'|' '$2 ~ /^docs\/[0-9][0-9]-[a-z-]+\/\*\*$/'
  return 0
}

# lint_glob_exceptions — PRD §lint 전수 게이트가 **선언한** glob 예외를 읽는다.
#   코드 리터럴로 적으면 그 리터럴이 검사 없는 규범이 된다.
lint_glob_exceptions() {
  md_section "$PRD00" '^### lint 전수 게이트' '^### Out of scope' > "$TMP/lg.sec"
  grep -F 'glob 에서 제외한다' "$TMP/lg.sec" > "$TMP/lg.excline" || true
  [ -s "$TMP/lg.excline" ] || return 1
  grep -oE 'docs/[0-9a-z-]+/\*\*' "$TMP/lg.excline" | sort -u
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# lint 전수 게이트 — 매칭 0건은 통과가 아니라 실패다.
#
# 이 저장소에 CI 가 없어 verify 가 유일한 강제 지점이다. 경고-후-통과는 필수 게이트를 선택으로
# 바꾼다 — 디렉터리 이름을 하나 잘못 만들어 전 문서가 glob 밖으로 나가도 --check 가 초록으로
# 남고, 무인 실행에서는 stderr 경고를 읽는 사람이 없다.
# ─────────────────────────────────────────────────────────────────────────────

# lint 대상 glob. 인용해 두어 **패턴 그대로** 배열에 남는다 — 비인용이면 대입 시점에 확장돼
# 파일이 생기는 순간 패턴이 사라진다. run_lint_gate 와 bootstrap_permitted·run_lint_coverage_gate
# 가 이 한 목록을 읽는다. 두 벌을 두면 한쪽만 고쳐도 아무것도 실패하지 않는다.
LINT_GLOBS=('docs/0*/*.md' 'docs/10-server/*.md' 'docs/20-site/*.md' 'docs/30-admin/*.md')

run_lint_gate() {
  local bootstrap="$1" g
  local files=()
  shopt -s nullglob
  for g in "${LINT_GLOBS[@]}"; do files+=($g); done   # 의도적 비인용 — 여기서 glob 를 확장한다
  shopt -u nullglob

  if [ "${#files[@]}" -eq 0 ]; then
    if [ "$bootstrap" = 1 ]; then
      printf 'lint.files 0 — 검사 대상 없음. --bootstrap 으로 한시 통과.\n' >&2
      printf '  --bootstrap 은 docs/ 에 타입 문서가 0건인 동안만 유효하다. lint glob 을 소유한 문서 PRD 가\n' >&2
      printf '  전부 *Status: DRAFT 인 동안만 허용되며 하나라도 착수하면 bootstrap_permitted 가 거부한다.\n' >&2
      return 0
    fi
    printf 'lint.files 0 — 검사 대상 없음. 게이트가 비어 있다.\n' >&2
    printf '  docs/0*/ · docs/10-server/ · docs/20-site/ · docs/30-admin/ 아래 .md 가 1건도 없다.\n' >&2
    printf '  문서가 아직 없는 마일스톤 3~6 에서는 --bootstrap 을 붙여 한시 통과시킨다.\n' >&2
    return 1
  fi

  # 파일이 있으면 --bootstrap 은 효력이 없다 — 정상 게이트를 돈다
  # `--root` 는 참조를 해석하는 모든 모드에서 필수다. 저장소 루트를 준다 — 실 문서는
  # `../_meta/…` 로 위를 참조하므로 docs/ 결박은 정상 참조를 거부한다.
  node "$LINTER" --root "$ROOT" --strict-refs "${files[@]}"
}

# ─────────────────────────────────────────────────────────────────────────────
# template-config 게이트 — 지표 #6("템플릿 적용 가능")의 자동 판정.
#
# doc-kit 예시의 `config:` 는 문서 기준 상대경로로 키트 fixture 를 가리킨다(계약이며 키트는
# 동결됐다). 제자리에서 돌리면 검사되는 것이 fixture 이지 docs/ 산출물이 아니다. 그래서
# **실 배치 깊이**로 스테이징한다 — 실 문서는 docs/<절>/x.md 에 놓여 config: ../_meta/…
# 로 쓸 것이므로 그 경로 형태를 그대로 태운다. 파일명도 실명이라 감춰지는 것이 없다.
#
# --bootstrap 을 보지 않는다. config 는 마일스톤 5 이후 항상 존재하므로 면제할 상태가 없다.
#
# 순서 의존: gen_report 가 만든 $TMP/repos.raw 를 읽는다. validate_candidate 가 gen_report
# 뒤에 이 함수를 부르므로 성립한다. 성립하지 않으면 조용히 통과하지 않고 실패한다.
# ─────────────────────────────────────────────────────────────────────────────

run_template_config_gate() {
  local dir="$TMP/tc" ex="$ROOT/.claude/templates/doc-kit/examples" f
  [ -f "$TEMPLATE_CONFIG" ] || { printf 'template-config 가 없다: %s\n' "$TEMPLATE_CONFIG" >&2; return 1; }
  [ -f "$TMP/repos.raw" ]   || { printf 'repos.raw 가 없다 — gen_report 뒤에 호출해야 한다\n' >&2; return 1; }

  mkdir -p "$dir/docs/_meta" "$dir/docs/20-site"
  cp "$TEMPLATE_CONFIG" "$dir/docs/_meta/template-config.yaml" || return 1
  # `policy-body-only` 를 함께 스테이징한다. 그 예시는 `## 부록` 이 **없는** 정상 policy-doc 이며,
  # 목록이 리터럴 3종이면 그 네 번째 예시는 **실 배포 config 로 검사되는 유일한 게이트 밖**에 남는다.
  for f in policy policy-body-only ia flow; do
    sed 's|^config: template-config\.insuniverse\.yaml$|config: ../_meta/template-config.yaml|' \
      "$ex/$f-example.md" > "$dir/docs/20-site/$f-example.md" || return 1
    # 치환 실패를 조용히 넘기지 않는다 — 키트가 config: 행 형태를 바꾸면 sed 가 무동작이 되고
    # 게이트는 "config 가 틀렸다"로 보고하게 된다. 스테이징 고장과 값 결함을 구별한다.
    grep -qx 'config: ../_meta/template-config.yaml' "$dir/docs/20-site/$f-example.md" \
      || { printf '스테이징 실패 — %s-example.md 의 config: 행을 치환하지 못했다\n' "$f" >&2; return 1; }
  done

  # 스테이징 루트는 `$dir/docs` 다 — 예시가 `../_meta/…` 와 `../99-deprecated/…` 로 형제 절을
  # 가리키므로 그 둘이 모두 루트 안에 들어야 한다. 저장소 루트로 고정하면 이 트리가 통째로
  # 거부되고, 문서 자기 트리로 좁히면 설계상 정상인 상향 참조가 거부된다.
  node "$LINTER" --root "$dir/docs" --strict-refs "$dir/docs/20-site/policy-example.md" \
    "$dir/docs/20-site/policy-body-only-example.md" "$dir/docs/20-site/ia-example.md" || return 1
  # flow-example 은 --strict-refs 로 돌리지 않는다 — refs 하나(../30-admin/ia-authority.md)가
  # 의도적 미해결이고 strict 에서 위반으로 승격되도록 키트가 설계했다(lint-docs.mjs:790).
  # strict 로 올려도 그 flow 의 순서 단언이 늘지 않는다(srcUnresolved 조기 반환, :664-669) —
  # 게이트를 깨기만 한다. auth_layers 는 3건 전부에서, status_precedence 는 해결되는 2건에서
  # 검사된다.
  node "$LINTER" --root "$dir/docs" "$dir/docs/20-site/flow-example.md" || return 1

  # ── 키트 자체 검증 + 관계 축 판정 도구 ──
  # `--selftest` 는 마일스톤 3 전까지 **어떤 게이트에서도 돌지 않았다**(실측). 지표 "결함 fixture 가
  # 지정 규칙으로 FAIL 한다" 의 판정 수단이 게이트 밖에 있으면 그 지표는 사람이 기억할 때만 참이다.
  node "$LINTER" --selftest || return 1

  # `--probe=relation` 은 지표 "관계 계산 가능성" 의 판정 도구다. **동결 경로를 인자로 명시한다** —
  # 생략하면 probe 가 동결값을 어디서 읽었는지(또는 읽기는 했는지)를 게이트가 말할 수 없다.
  # 스테이징된 트리에서 돌리는 이유는 실 배치 깊이가 상대경로 해석의 다른 경로를 밟기 때문이다.
  cp "$ex/probe-expected.relation.yaml" "$dir/docs/20-site/probe-expected.relation.yaml" || return 1
  node "$LINTER" --probe=relation --root "$dir/docs" \
    --expected "$dir/docs/20-site/probe-expected.relation.yaml" \
    "$dir/docs/20-site/flow-example.md" || return 1

  # `--probe=history` — 시간 축 계약의 판정 도구다. 문서는 스테이징 트리에서, 인덱스·manifest·
  # 저장소 작업본은 **실 저장소**에서 온다. 그 셋은 스테이징 트리에 없고 문서에서 위로 걸어
  # 올라가 찾는 방식은 여기서 실패하므로 경로가 전부 명시 인자다.
  #
  # 네 인자 중 하나라도 빠지면 probe 가 usage 오류로 거부한다. 특히 `--repos-dir` 가 없으면
  # 경계 역전(인덱스 최신 행이 기준 HEAD 의 조상인가)과 0건 사유 (iii) 판정이 아예 실행되지
  # 않는다 — 선택 인자였다면 이 배선이 그 축을 건너뛴 채 초록으로 남았을 자리다.
  cp "$ex/probe-expected.history.yaml" "$dir/docs/20-site/probe-expected.history.yaml" || return 1
  node "$LINTER" --probe=history --root "$dir/docs" \
    --index-dir "$ROOT/docs/_meta/index" \
    --manifest "$ROOT/docs/_meta/extraction-manifest.yaml" \
    --repos-dir "$ROOT" \
    --expected "$dir/docs/20-site/probe-expected.history.yaml" \
    "$dir/docs/20-site/policy-example.md" || return 1

  # ── 정본 교차 대조 3종 ──
  # lint 는 문서가 config 와 맞는지만 본다. config 가 *정본*과 맞는지는 아무도 보지 않는다.
  # 이 PRD 가 존재하는 이유가 그 부류의 발산이므로 댈 수 있는 것은 여기서 댄다.
  #
  # diff 실패가 파이프 뒤 sed(항상 exit 0)에 먹히지 않는 것은 파일 상단의 `set -o pipefail`
  # 에 달려 있다. 그것을 끄면 이 대조 3종이 조용히 통과하기 시작한다.

  # 1. config.repos == manifest repos: — 갈라지면 anchor-repo 검사가 조용히 헐거워진다
  tc_list repos | sort > "$TMP/tc.repos"
  [ -s "$TMP/tc.repos" ] || { printf 'config.repos 를 추출하지 못했다\n' >&2; return 1; }
  cut -d' ' -f1 "$TMP/repos.raw" | sort > "$TMP/mf.repos"
  diff "$TMP/tc.repos" "$TMP/mf.repos" \
    | sed 's/^</  config 에만  : /; s/^>/  manifest 에만: /' >&2 \
    || { printf 'config.repos 가 manifest repos: 와 다르다\n' >&2; return 1; }

  # 2. config.statuses == 05-final-spec.md §3 표 — **id·라벨·배치 3축을 순서 포함**으로.
  #    정본이 순서를 갖는다. id 만 대조하던 구판은 배치·라벨이 통째로 갈려도 초록이었다.
  #    config 쪽은 **셸이 파싱하지 않는다** — lint 정본 파서의 --emit-statuses 를 읽는다.
  node "$LINTER" --emit-statuses "$TEMPLATE_CONFIG" > "$TMP/tc.statuses3" \
    || { printf 'lint --emit-statuses 가 실패했다 — config.statuses 형태가 계약을 벗어났다\n' >&2; return 1; }
  [ -s "$TMP/tc.statuses3" ] || { printf 'config.statuses 를 추출하지 못했다 (0건)\n' >&2; return 1; }
  md_table "$SPEC" '^## 3\. 활성성' '^## 4\.' 5 \
    | awk -F'|' '{ print $1 "|" $2 "|" $3 }' > "$TMP/spec.statuses3"
  # 매칭 0건은 통과가 아니라 실패다 — spec 절 제목이나 열 수가 바뀌면 md_table 이 0바이트를
  # 내고, 빈 것끼리 맞으면 게이트가 초록으로 남는다.
  [ -s "$TMP/spec.statuses3" ] || { printf '05-final-spec.md §3 status 표를 추출하지 못했다 (0건 — 5열이 아닐 수 있다)\n' >&2; return 1; }
  diff "$TMP/tc.statuses3" "$TMP/spec.statuses3" >&2 \
    || { printf 'config.statuses 가 05-final-spec.md §3 와 다르다 (id·라벨·배치 3축 · 순서 포함 대조)\n' >&2; return 1; }
  cut -d'|' -f1 "$TMP/tc.statuses3" > "$TMP/tc.statuses"

  # 3. status_precedence 집합 == statuses 집합 — schema 가 "권한다"고만 적고 lint 는 잡지
  #    않는다고 명시한 구멍이다. statuses 에만 있는 값을 쓴 구성 요소는 순서 계산에서 빠진다.
  sort "$TMP/tc.statuses" > "$TMP/tc.statuses.sorted"
  tc_list status_precedence | sort > "$TMP/tc.precedence.sorted"
  [ -s "$TMP/tc.precedence.sorted" ] || { printf 'config.status_precedence 를 추출하지 못했다\n' >&2; return 1; }
  diff "$TMP/tc.statuses.sorted" "$TMP/tc.precedence.sorted" \
    | sed 's/^</  statuses 에만        : /; s/^>/  status_precedence 에만: /' >&2 \
    || { printf 'status_precedence 집합이 statuses 집합과 다르다\n' >&2; return 1; }

  # 4. evidence_grades ↔ 05-final-spec.md §2 등급 표 — **양방향**.
  #
  #    config 주석이 "§2 표의 행을 1:1 로 옮긴 것" 이라 적을 뿐 아무도 확인하지 않았다(실측).
  #    그래서 `O-ops` 만 넣고 §2 에 등급 O 행을 만들지 않아도 어느 층도 울지 않았다 —
  #    근거 없는 등급이 lint 를 통과하는 자리이며, 반대로 등급표에만 넣으면
  #    `evidence: [O-ops]` 를 쓴 문서가 evidence-domain 에서 거부된다. 두 방향을 함께 문다.
  tc_list evidence_grades > "$TMP/tc.evidence"
  [ -s "$TMP/tc.evidence" ] || { printf 'config.evidence_grades 를 추출하지 못했다\n' >&2; return 1; }
  md_table "$SPEC" '^## 2\. 확정 소스 등급' '^### 확정된 개념 분리' 4 \
    | cut -d'|' -f1 | sort -u > "$TMP/spec.grades"
  [ -s "$TMP/spec.grades" ] || { printf '05-final-spec.md §2 등급 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  sed 's/-.*$//' "$TMP/tc.evidence" | sort -u > "$TMP/tc.prefixes"
  comm -23 "$TMP/tc.prefixes" "$TMP/spec.grades" > "$TMP/grade.orphan"
  [ ! -s "$TMP/grade.orphan" ] || {
    sed 's/^/  §2 등급표에 없는 접두: /' "$TMP/grade.orphan" >&2
    printf 'config.evidence_grades 의 접두가 05-final-spec.md §2 등급 열에 없다\n' >&2; return 1; }
  if grep -qx 'O' "$TMP/spec.grades"; then
    grep -qx 'O-ops' "$TMP/tc.evidence" \
      || { printf '05-final-spec.md §2 에 등급 O 행이 있는데 config.evidence_grades 에 O-ops 가 없다 (O-2·O-4 는 쌍이다)\n' >&2; return 1; }
  fi
  if grep -qx 'O-ops' "$TMP/tc.evidence"; then
    grep -qx 'O' "$TMP/spec.grades" \
      || { printf 'config.evidence_grades 에 O-ops 가 있는데 05-final-spec.md §2 에 등급 O 행이 없다 (O-2·O-4 는 쌍이다)\n' >&2; return 1; }
  fi
}

# ─────────────────────────────────────────────────────────────────────────────
# onboarding-questions 게이트 — 지표 #5("온보딩 질문 승인")와 하류 5개 PRD 종료 조건의
# 측정 단위가 이 파일이다. 게이트가 보는 것은 질문의 내용이 아니라 **측정 가능성**이다.
#
# 승인 판정 시점의 정본은 PRD Delivery Milestones 6행 status 다. complete 이전에는 미승인을
# 허용하고(PRD 가 "초안 제출 후 다른 마일스톤을 막지 않는다"로 명시한 대기 기간), complete 인
# 순간 승인 완료를 요구한다. 별도 --final 모드를 만들지 않는 이유는 판정 기준을 게이트 안에
# 복제하면 정본과 갈라지기 때문이다 — run_template_config_gate 가 05-final-spec.md §3 을
# 읽어 대조하는 것과 같은 형태다.
#
# 가드는 `cmd && { …; return 1; }` 형태를 쓴다. set -e 는 && 좌변 실패를 면제하므로 이것이
# "매치되면 거부"로 정확히 동작한다 — || 로 바꾸면 의미가 뒤집힌다. 다만 좌변이 실패하면
# $? 가 남으므로 함수 끝에 return 0 을 명시한다.
#
# **그 가드의 좌변에 파이프를 두지 않는다.** `producer | grep -q` 는 매치가 나면 grep 이 먼저
# 끝나 producer 가 SIGPIPE(141)를 받고, pipefail 이 그 141 을 파이프라인 상태로 올린다 — 즉
# **매치했는데 가드가 안 터진다.** 거부해야 할 때 조용히 통과하는 형태이므로 이 함수 안의
# 스칼라 조회는 전부 $ONORM(파일)을 직접 읽는다. 같은 이유로 검사 1의 위반 목록도 파이프
# `| head -3` 이 아니라 파일에 받은 뒤 자른다(초과분이 있으면 awk 가 SIGPIPE 로 죽는다).
#
# --bootstrap 을 보지 않는다. 이 파일은 마일스톤 6 이후 항상 존재한다.
# ─────────────────────────────────────────────────────────────────────────────

# ia 판정 게이트 — `ia_consumed` 를 두 축의 OR 로 분해한 계약 7규칙.
#
# 구 검사 5 를 옮긴 자리이고 규칙 (b) 가 그 이빨이다 — 온보딩 축 선언과 screen_inventory
# 실체의 대조는 한 글자도 약해지지 않았다. 나머지 여섯은 M2 가 판정 입력을 하나에서 둘로
# 늘리면서 필요해진 것들이다.
#
# **golden 을 읽지 않는다.** 상시 게이트가 golden 값을 대조하면 신규 키가 아직 없는 첫
# --update 가 자기 자신에 막혀 부트스트랩이 봉쇄된다(do_update 는 validate_candidate 를
# 먼저 돌린다). 의미 축의 방어선은 (f)·(g) 의 **실측 ↔ yaml 선언** 대조다 — yaml 은 입력
# 이라 --update 가 건드리지 못하고, 침묵시키려면 사람이 선언 리터럴을 고쳐 최종 diff 에
# 남겨야 한다. golden 동결만으로는 --update 한 번이 새 값을 조용히 기준선으로 앉힌다.
#
# 실패는 사유를 stderr 에 적고 return 1, stdout 은 비운다 — expect_fn 계약이자 호출부
# `[ -s ]` 가드의 전제다.
oq_ia_gate() {   # <정규화된 yaml> <레코드 파일> <root>
  local y="$1" rec="$2" root="$3"
  local k n v dec on vw or_ act src sf sid ssha dsha v70 dl

  # (a) ia 판정 6키가 각 1회 · 값이 계약 형태
  for k in ia_consumed ia_basis_onboarding ia_basis_viewer ia_viewer_src \
           ia_viewer_sha256 ia_viewer_delegation_sha256; do
    n=$(grep -c "^$k: " "$y" || true)
    [ "$n" -eq 1 ] \
      || { printf '%s 가 %s회 등장한다 (정확히 1회여야 한다)\n' "$k" "$n" >&2; return 1; }
  done
  dec=$(sed -n 's/^ia_consumed: *\(true\|false\)[[:space:]]*$/\1/p' "$y")
  on=$(sed -n 's/^ia_basis_onboarding: *\(true\|false\)[[:space:]]*$/\1/p' "$y")
  vw=$(sed -n 's/^ia_basis_viewer: *\(true\|false\)[[:space:]]*$/\1/p' "$y")
  [ -n "$dec" ] || { printf 'ia_consumed 선언이 없거나 true/false 가 아니다\n' >&2; return 1; }
  [ -n "$on" ]  || { printf 'ia_basis_onboarding 선언이 없거나 true/false 가 아니다\n' >&2; return 1; }
  [ -n "$vw" ]  || { printf 'ia_basis_viewer 선언이 없거나 true/false 가 아니다\n' >&2; return 1; }
  # 선언 digest 2종은 64자리 소문자 hex 여야 한다 — 축약·상징 표기가 들어오면 (f)·(g) 의
  # 대조가 무의미해진다. 두 앵커를 같은 루프로 본다.
  for k in ia_viewer_sha256 ia_viewer_delegation_sha256; do
    v=$(sed -n "s/^$k: *//p" "$y" | tr -d '"')
    printf '%s' "$v" | grep -Eq '^[0-9a-f]{64}$' \
      || { printf '%s 이 64자리 소문자 hex 가 아니다 (값 %s)\n' "$k" "$v" >&2; return 1; }
  done

  # (b) 온보딩 축 선언 == screen_inventory 실체. **구 검사 5 그대로다.**
  # 파이프 대신 파일로 받는다 — `cut | grep -q` 는 grep 이 먼저 끝나면 cut 이 SIGPIPE(141)를
  # 받고 pipefail 이 그것을 집어 boolean 이 뒤집힐 수 있다.
  cut -d'|' -f4 "$rec" > "$TMP/ia.scr"
  if grep -qx true "$TMP/ia.scr"; then act=true; else act=false; fi
  [ "$on" = "$act" ] \
    || { printf 'ia_basis_onboarding 선언(%s)과 screen_inventory 질문 존재(%s)가 다르다\n' "$on" "$act" >&2; return 1; }

  # (c) ia_consumed == (온보딩 || 뷰어) — 양방향. 한쪽만 보면 축을 켜 놓고 선언을 안 바꾼
  # 상태와 선언만 켜 놓고 축이 둘 다 꺼진 상태 중 하나가 조용히 통과한다.
  if [ "$on" = true ] || [ "$vw" = true ]; then or_=true; else or_=false; fi
  [ "$dec" = "$or_" ] \
    || { printf 'ia_consumed 선언(%s)이 두 축의 OR(%s)과 다르다 (onboarding=%s viewer=%s)\n' \
                "$dec" "$or_" "$on" "$vw" >&2; return 1; }

  # (d)+(g) 뷰어 축 근거 절 — 실재·비공백(d)과 실측 digest == 선언(g). (d) 만으로는 절을
  # **비공백인 채 의미만 뒤집는** 경로가 열려 있고, 그 경우 --update 가 golden 의
  # ia_viewer_sha256 을 새 값으로 앉힌다. 두 검사는 ia_viewer_section_sha 안에 함께 산다.
  src=$(sed -n 's/^ia_viewer_src: *//p' "$y" | tr -d '"')
  case "$src" in
    *.md#*) ;;
    *) printf 'ia_viewer_src 가 "<경로>.md#<절 id>" 형태가 아니다 (값 %s)\n' "$src" >&2; return 1 ;;
  esac
  sf=${src%%#*}; sid=${src##*#}
  # 가리킴 자체를 묶는다. (g) 가 보증하는 것은 **가리키는 대상**의 불변성이지 가리킴의
  # 불변성이 아니다 — 경로가 자유로우면 이 PRD 가 소유한 파일로 재조준하고 digest 2개만
  # 다시 선언하는 것으로 (d)·(g) 가 전부 초록이 된다. 그러면 뷰어 축의 근거가 자기 단언으로
  # 무너진다. 위임 앵커가 경로를 하드코딩해 "이 PRD 가 편집할 수 없는 자리"를 얻은 것과
  # 같은 성질을 여기서도 요구한다. `..` 는 `.claude/meta/` 접두를 통과하면서 밖으로 나가는
  # 유일한 경로라 따로 막는다.
  case "$sf" in
    .claude/meta/*.md) ;;
    *) printf 'ia_viewer_src 가 .claude/meta/<파일>.md 밖을 가리킨다 (값 %s)\n' "$sf" >&2; return 1 ;;
  esac
  case "$sf" in
    *..*) printf 'ia_viewer_src 에 상위 경로 참조가 있다 (값 %s)\n' "$sf" >&2; return 1 ;;
  esac
  ssha=$(sed -n 's/^ia_viewer_sha256: *//p' "$y" | tr -d '"')
  ia_viewer_section_sha "$root/$sf" "$sid" "$ssha" || return 1

  # (e) `70-viewer` PRD 의 ia-doc@1 행이 정확히 1행 실재하고 **위임 기록 형태**를 유지하는가.
  # digest 대조보다 앞이다 — 행이 수용으로 바뀌면 (f) 도 붉어지지만 (f) 는 "무언가 바뀌었다"
  # 까지만 말하고 (e) 가 "무엇이 잘못됐는가"를 말한다. 토큰은 구조 바닥일 뿐 뜻을 판정하지
  # 못하므로 (e) 를 의미 축의 방어선으로 읽으면 안 된다 — 그 자리는 (f) 다.
  # 추출행 경로는 **여기가 정한다** — 읽는 쪽이 이름 짓는 쪽이어야 (e) 가 자기가 부르지
  # 않은 산출물을 판정하는 일이 없다.
  v70="$root/.claude/prds/insuniverse-docs-70-viewer.prd.md"
  dl="$TMP/ia.delline"
  ia_delegation_sha "$v70" "$dl" > /dev/null || return 1
  grep -qF '소비 문서 0건' "$dl" && grep -qF 'M6' "$dl" \
    || { printf '70-viewer 의 ia-doc@1 행이 더는 위임 기록 형태가 아니다 — 뷰어 축을 재판정하라\n' >&2; return 1; }

  # (f) 위임 행 실측 재계산값 == yaml 선언. 이 PRD 가 편집할 수 없는 앵커라 자기 단언이 아니다.
  dsha=$(sed -n 's/^ia_viewer_delegation_sha256: *//p' "$y" | tr -d '"')
  ia_delegation_sha "$v70" "$dl" "$dsha" || return 1

  return 0
}

run_onboarding_gate() {
  local n bad m6 want got nat niv nver
  [ -f "$ONBOARDING" ] || { printf 'onboarding-questions 가 없다: %s\n' "$ONBOARDING" >&2; return 1; }
  [ -f "$PRD00" ] || { printf 'PRD 를 찾을 수 없다: %s\n' "$PRD00" >&2; return 1; }
  oq_norm
  # 검사 5·6 이 둘 다 PRD 를 읽는다. 한 번만 정규화한다.
  tr -d '\r' < "$PRD00" > "$TMP/prd00.norm"

  # 0. questions: 는 파일의 **마지막 최상위 키**여야 한다.
  #
  # oq_slice 는 다음 최상위 키에서 멈춘다. 그러니 목록 중간에 최상위 키가 하나 끼면 거기서
  # 목록이 잘리고 **잘린 목록은 정상적으로 짧은 목록과 구별되지 않는다** — 뒤에 남은 질문도,
  # 잔여(RESIDUAL)도 아무도 보지 않는다. golden 의 question_count 가 --check 에서는 잡지만
  # --update 는 그 잘린 상태를 그대로 기준선으로 축성한다(santa 라운드 2 Reviewer B).
  #
  # 파서 주석이 "questions: 는 마지막 키라 EOF 로 끝나는 것이 정상"이라고 **전제**만 해 두고
  # 검사를 두지 않았던 자리다. manifest 목록이 BOUNDARY(종료 행 존재)를 요구한 것의 거울상
  # 이며, `questions:` 가 2회 등장하는 경우도 같은 검사가 잡는다 — 두 번째가 최상위 키다.
  awk '/^questions:[[:space:]]*$/ { f = 1; next } f && /^[^[:space:]#]/ { print; exit }' "$ONORM" > "$TMP/oq.tail"
  [ -s "$TMP/oq.tail" ] \
    && { printf 'questions: 뒤에 최상위 키가 있다 — 그 지점에서 질문 목록이 잘린다: %s\n' "$(head -1 "$TMP/oq.tail")" >&2; return 1; }

  # 0b. version — 이 파서가 이해하는 스키마 판.
  #     파일이 판을 선언하는데 아무도 읽지 않으면 v2 로 올려도 v1 파서가 조용히 계속 돌고,
  #     그때 나오는 값은 결정적이면서 틀리다(santa 라운드 7 Reviewer B). golden 행이 아니라
  #     **리터럴 고정**으로 둔다 — 동결은 "바뀌었다"를 말하지만 이 자리는 "1 이 아니면
  #     이 파서를 쓰면 안 된다"이고, 판을 올릴 때 파서와 함께 이 상수를 고치는 것이 맞다.
  nver=$(grep -c '^version: ' "$ONORM" || true)
  [ "$nver" -eq 1 ] \
    || { printf 'version 이 %s회 등장한다 (정확히 1회여야 한다)\n' "$nver" >&2; return 1; }
  grep -qx 'version: 1' "$ONORM" \
    || { printf 'version 이 1 이 아니다 — oq_* 파서는 v1 스키마만 이해한다\n' >&2; return 1; }

  oq_records > "$TMP/oq.rec" || { printf 'onboarding-questions 파싱 실패 — 위 RESIDUAL/DUPKEY/ORPHAN 행 참조\n' >&2; return 1; }
  [ -s "$TMP/oq.rec" ] || { printf 'onboarding-questions 에서 질문을 0건 추출했다\n' >&2; return 1; }
  oq_exceptions | sort > "$TMP/oq.exc" || { printf 'coverage_exceptions 파싱 실패 — 사유(why) 없는 target 이 있다\n' >&2; return 1; }
  # 집합 연산은 target 1열만 쓴다 — oq_exceptions 는 golden 용으로 `target|why` 를 낸다.
  cut -d'|' -f1 "$TMP/oq.exc" > "$TMP/oq.exc.t"
  n=$(wc -l < "$TMP/oq.rec")

  # 1. 레코드 형태 — 필드 7개가 전부 채워졌는가. 빠진 필드는 빈 문자열로 나와 golden 에
  #    정상값처럼 실린다(fail-open). src↔owner 일치도 여기서 본다 — 출처가 다른 PRD 를
  #    가리키면 태그와 근거가 갈라지고, 그때 지표 #2 는 엉뚱한 문서를 채점한다.
  awk -F'|' '
    NF != 7                          { print "필드 수 " NF ": " $0; next }
    $1 !~ /^Q[0-9][0-9]$/            { print "id 형태: " $0; next }
    $2 !~ /^[0-9][0-9]-[a-z-]+$/     { print "owner 형태: " $0; next }
    $3 !~ /^(-|server|site|admin)$/  { print "service 값: " $0; next }
    $4 !~ /^(-|true)$/               { print "screen_inventory 값: " $0; next }
    $5 !~ /^(true|false)$/           { print "approved 값: " $0; next }
    $6 !~ /^".+"$/                   { print "src 형태(따옴표): " $0; next }
    $7 !~ /^".+"$/                   { print "q 형태(따옴표): " $0; next }
    # src 의 계약은 `"insuniverse-docs-<owner>.prd.md §<절>"` 넷이다 — 따옴표 · 파일명 ·
    # § · **절 이름**. 접두어만 보면 앞의 셋만 검사하고 넷째가 빠진다: `"…prd.md §"` 로
    # 잘라도 통과하며, 그러면 출처가 문서까지만 가리키고 절을 가리키지 못한다. 지표 #2 는
    # "이 질문을 자기 문서만 보고 답할 수 있는가"이고 그 판정의 시작점이 이 절 이름이다.
    # (santa 라운드 7 Reviewer A)
    { want = "\"insuniverse-docs-" $2 ".prd.md §"
      if (substr($6, 1, length(want)) != want) { print "src 가 owner 와 다른 PRD 를 가리킨다: " $1 " " $6; next }
      # 남는 것은 <절> + 닫는 따옴표다. 1바이트뿐이면 절 이름이 비었다.
      if (length($6) <= length(want) + 1) print "src 에 § 뒤 절 이름이 없다: " $1 " " $6 }
  ' "$TMP/oq.rec" > "$TMP/oq.bad"
  bad=$(head -3 "$TMP/oq.bad")
  [ -z "$bad" ] || { printf 'onboarding 레코드 형태 위반 %s건 (앞 3건):\n%s\n' "$(wc -l < "$TMP/oq.bad")" "$bad" >&2; return 1; }
  [ "$(cut -d'|' -f1 "$TMP/oq.rec" | sort -u | wc -l)" = "$n" ] \
    || { printf 'id 가 중복된다\n' >&2; return 1; }

  # 2. 규모 — 하한 20. **상한은 두지 않는다.**
  #
  # 초안은 20~30 이었고 2026-08-07 사용자 검토에서 두 번 넘겼다(운영 흐름 13건 → 45 로
  # 상향, 관리자 예외·운영 처리 14건 → 다시 넘김). 두 번 다 정당한 확장이었다 — 질문
  # 집합의 크기는 이 제품의 운영 표면이 정하는 것이지 책상에서 정할 수 있는 값이 아니다.
  # 그때마다 상수를 올리면 게이트는 아무도 믿지 않는 숫자를 지키게 되고, 반사적으로
  # bump 되는 검사는 검사가 아니다.
  #
  # 상한을 없애도 잃는 것이 없다. 의도치 않은 증가는 golden 의 onboarding.question_count
  # 와 onboarding.ids 가 이미 잡는다 — 1건만 늘어도 --check 가 붉어지고 명시적 --update
  # 를 강제한다. 상한은 그 위에 얹힌 중복이었다.
  #
  # 하한은 다르다. 질문이 20건 밑으로 떨어진 파일은 확장이 아니라 파손이며(하류 5개 PRD
  # 의 종료 조건이 통째로 헐거워진다), 그것은 결정된 불변식이다. 남긴다.
  [ "$n" -ge 20 ] \
    || { printf '질문 수 %s 가 하한 20 미만이다 — 하류 5개 PRD 의 종료 조건이 헐거워진다\n' "$n" >&2; return 1; }

  # 3. owner ∪ 예외 == 하류 문서 PRD 5종. **양방향 대조다.**
  #    ⊆ 만 보면 태그가 하나도 안 붙은 PRD 가 "해당 태그 질문 전부 O"를 질문 0건으로
  #    자동 성립시킨다 — run_lint_gate 가 0건을 실패로 치는 것과 같은 fail-open 이다.
  #    정본은 .claude/prds/ 의 파일명이다(우산 PRD §PRD 분할 맵과 1:1).
  { ls "$ROOT/.claude/prds"; ls "$ROOT/.claude/prds/archived" 2>/dev/null; } \
    | sed -nE 's/^insuniverse-docs-([2-6]0-[a-z-]+)\.prd\.md$/\1/p' \
    | sort -u > "$TMP/oq.owners.want"
  # 계수를 리터럴로 물지 않는다 — 그 리터럴이 형제 축의 정당한 아카이브 이동에 깨져
  # `origin/main` 자체를 붉혔다. 규약이 바뀌었는지는 **0종**으로 판정한다.
  [ -s "$TMP/oq.owners.want" ] \
    || { printf '문서 PRD 를 0종 추출했다 — .claude/prds/ 파일명 규약이 바뀌었다\n' >&2; return 1; }
  grep -vx '50-domain/.*' "$TMP/oq.exc.t" > "$TMP/oq.exc.prd" || true
  cut -d'|' -f2 "$TMP/oq.rec" | sort -u > "$TMP/oq.owners.got"
  # 예외와 실제 태그가 겹치면 "제외했는데 질문이 있다"는 모순이다
  comm -12 "$TMP/oq.owners.got" "$TMP/oq.exc.prd" > "$TMP/oq.exc.clash"
  [ -s "$TMP/oq.exc.clash" ] \
    && { printf 'coverage_exceptions 에 있는 PRD 에 질문이 붙어 있다: %s\n' "$(tr '\n' ' ' < "$TMP/oq.exc.clash")" >&2; return 1; }
  sort -u "$TMP/oq.owners.got" "$TMP/oq.exc.prd" > "$TMP/oq.owners.union"
  diff "$TMP/oq.owners.want" "$TMP/oq.owners.union" \
    | sed 's/^</  질문도 예외도 없는 PRD: /; s/^>/  알 수 없는 owner\/예외 : /' >&2 \
    || { printf 'owner ∪ 예외 가 문서 PRD 5종과 다르다\n' >&2; return 1; }
  # 3b. 그 5종이 이 파일을 **이름으로** 지목하는가.
  #     우산 PRD §하류 PRD 통지가 "필수 수정 0건"의 근거로 그 참조를 든다 — 질문이 늘면
  #     종료 조건이 자동으로 따라오는 것은 참조가 살아 있을 때뿐이다. 지금까지 게이트는
  #     `.claude/prds/` 의 파일명만 셌고 내용을 안 읽어, 참조를 지워도 초록이었다
  #     (santa 라운드 4 Reviewer B). 3 과 같은 주제라 같은 검사에 둔다.
  oq_downstream_refs "$TMP/oq.owners.want" "$TMP/oq.rec" > "$TMP/oq.refs.raw" \
    || { printf '하류 문서 PRD 의 §Success Metrics 구조 확인 실패 (위 stderr 참조)\n' >&2; return 1; }
  sort "$TMP/oq.refs.raw" > "$TMP/oq.refs.got"
  comm -23 "$TMP/oq.owners.want" "$TMP/oq.refs.got" > "$TMP/oq.refs.missing"
  [ -s "$TMP/oq.refs.missing" ] \
    && { printf '§Success Metrics 에서 onboarding-questions.yaml 을 이름으로 참조하지 않는 문서 PRD 가 있다: %s\n  (`## Success Metrics` 표제가 없거나 2회 등장하는 경우도 여기로 떨어진다)\n' \
                "$(tr '\n' ' ' < "$TMP/oq.refs.missing")" >&2; return 1; }

  # 4. 50-domain 은 서비스 축으로 판정된다(그 PRD 지표: "그 서비스 각론 + 총론만으로").
  #    서비스 하나가 0건이면 그 서비스 종료 판정이 vacuous 하다. 3종 전수 또는 명시 예외.
  #    정본은 template-config.services — config 와 갈라지면 여기서 먼저 붉어진다.
  tc_list services | sort > "$TMP/oq.svc.want"
  [ -s "$TMP/oq.svc.want" ] || { printf 'template-config.services 를 추출하지 못했다\n' >&2; return 1; }
  { awk -F'|' '$2 == "50-domain" { print $3 }' "$TMP/oq.rec"
    sed -n 's|^50-domain/||p' "$TMP/oq.exc.t"; } | sort -u > "$TMP/oq.svc.union"
  diff "$TMP/oq.svc.want" "$TMP/oq.svc.union" \
    | sed 's/^</  질문도 예외도 없는 서비스: /; s/^>/  알 수 없는 service   : /' >&2 \
    || { printf '50-domain 의 service ∪ 예외 가 template-config.services 와 다르다\n' >&2; return 1; }
  awk -F'|' '$2 != "50-domain" && $3 != "-"' "$TMP/oq.rec" > "$TMP/oq.svc.stray"
  [ -s "$TMP/oq.svc.stray" ] \
    && { printf '50-domain 이 아닌 질문에 service 가 붙어 있다\n' >&2; return 1; }

  # 5. ia 판정. **축 계약 7규칙은 oq_ia_gate 가 진다** — `ia_consumed` 가 두 축(온보딩 ·
  #    뷰어)의 OR 이고, 구 검사 5 의 이빨(선언 == screen_inventory 실체)은 그 규칙 (b) 로
  #    한 글자도 약해지지 않은 채 옮겨 갔다.
  #
  #    여기 남는 둘은 **축 계약 밖**의 검사라 추출 경계를 여기서 끊는다. ia_verdict 는
  #    판정문 자체이지 축이 아니고, 5b 의 근거 피복은 산문 두 자리가 화면 언급 질문을
  #    전수 덮는가를 본다 — 둘 다 판정 입력이 하나에서 둘로 늘어도 형태가 그대로다.
  #
  # 등장 횟수부터 센다. approved_at 이 같은 이유로 1회 검사를 갖는다(santa 라운드 2) —
  # ia_verdict 는 그 검사가 없어 두 번 적으면 gen_report 의 sed 가 여러 행을 잡아 golden
  # 값이 개행을 품은 채 축성된다. ia_consumed 쪽 1회 검사는 oq_ia_gate 규칙 (a) 에 있다.
  niv=$(grep -c '^ia_verdict: ' "$ONORM" || true)
  [ "$niv" -eq 1 ] \
    || { printf 'ia_verdict 가 %s회 등장한다 (정확히 1회여야 한다)\n' "$niv" >&2; return 1; }
  # 판정에는 근거 문장이 딸려야 한다. 비면 golden 의 onboarding.ia_verdict 가 빈 값을
  # 정상 기준선으로 싣는다 — tc_guard 가 template-config 에서 닫은 것과 같은 fail-open 이다.
  grep -qE '^ia_verdict: ".+"$' "$ONORM" \
    || { printf 'ia_verdict 가 없거나 따옴표로 감싼 비어 있지 않은 문자열이 아니다\n' >&2; return 1; }
  # 5b. 판정 근거가 적힌 나머지 두 자리 — 이 파일의 주석과 PRD 의 `ia-doc@1` 판정 항목 —
  #     이 화면 언급 질문을 **전수** 덮는가.
  #
  #     golden 의 ia_rationale_ids·prd_ia_verdict_ids 는 "근거 문장이 바뀌었다"를 잡는다.
  #     그런데 질문이 늘어 화면 언급이 새로 생긴 경우는 --update 가 세 행(screen_mention_ids
  #     포함)을 함께 갱신해 조용히 통과시킨다 — 41건 시점 판정문("화면 언급 3건")이 승인된
  #     55건을 안 덮은 사고가 정확히 그 경로였다(santa 라운드 1). 동결만으로는 못 막는다.
  #
  #     방향은 ⊆ 하나다. 근거가 화면 언급 아닌 질문(Q42·Q07 처럼 "가장 가까운 것")을 함께
  #     논하는 것은 정상이므로 역방향은 요구하지 않는다.
  awk -F'|' 'index($7, "화면") { print $1 }' "$TMP/oq.rec" | sort -u > "$TMP/oq.scrids"
  oq_rationale_ids > "$TMP/oq.rat" \
    || { printf 'onboarding-questions 주석에서 질문 ID 를 뽑지 못했다\n' >&2; return 1; }
  prd_verdict_ids "$TMP/prd00.norm" > "$TMP/oq.prdrat" \
    || { printf 'PRD 의 ia-doc@1 판정 항목이 정확히 1행이 아니다\n' >&2; return 1; }
  comm -23 "$TMP/oq.scrids" "$TMP/oq.rat" > "$TMP/oq.rat.missing"
  [ -s "$TMP/oq.rat.missing" ] \
    && { printf '화면을 언급하는 질문인데 onboarding-questions 주석의 판정 근거가 거론하지 않는다: %s\n' \
                "$(tr '\n' ' ' < "$TMP/oq.rat.missing")" >&2; return 1; }
  comm -23 "$TMP/oq.scrids" "$TMP/oq.prdrat" > "$TMP/oq.prdrat.missing"
  [ -s "$TMP/oq.prdrat.missing" ] \
    && { printf '화면을 언급하는 질문인데 PRD 의 ia-doc@1 판정이 거론하지 않는다: %s\n' \
                "$(tr '\n' ' ' < "$TMP/oq.prdrat.missing")" >&2; return 1; }
  # 5c. 축 계약 7규칙 — (a) 6키 형태 · (b) 온보딩 축 == screen_inventory 실체 · (c) OR 양방향 ·
  #     (d)+(g) 근거 절 실재·비공백과 digest 선언 대조 · (e) 위임 행 1건·위임 기록 형태 ·
  #     (f) 위임 행 digest 선언 대조. 실패 사유는 oq_ia_gate 가 stderr 에 적는다.
  oq_ia_gate "$ONORM" "$TMP/oq.rec" "$ROOT" || return 1

  # 6. 승인 상태 — 내부 정합은 항상, 완료 요구는 PRD 6행이 complete 일 때만.
  #
  # approved_at 은 **시각 하나뿐이라 무엇을 승인했는지 말하지 못한다.** 그래서 approved_sha256
  # 을 함께 요구한다 — 승인이 채워져 있는 동안 그 값은 현재 질문 집합의 해시와 같아야 한다.
  # 없으면 complete 이후 문구·owner·src 를 고치고 전건 approved: true 를 유지한 채 --update
  # 한 번으로 승인이 그대로 승계된다(santa R1 Reviewer B). golden 의 set_sha256 은 "값이
  # 바뀌었다"까지만 말하고 재승인 여부는 말하지 않는다 — 파일 주석이 규범으로만 적어 두고
  # 자동 경로에 강제가 없던 자리가 정확히 여기다.
  #
  # 해시 입력은 golden 의 onboarding.set_sha256 과 **같은 계산**이다(정렬한 레코드 전문).
  # 파일 전체가 아니라 레코드라 approved_sha256 행 자체가 입력에 섞이는 순환이 없다.
  #
  # approved_at 의 **형태**도 본다. 파일은 이 값을 "ISO 시각, 미승인이면 null" 로 선언하는데
  # 게이트가 null 이냐 아니냐만 보면 아무 문자열이나 승인 증거가 된다 — 선언과 검사가 갈리는
  # 자리다(santa 라운드 2 Reviewer B). 등장 횟수도 센다: 두 번 적히면 `null` 과 시각이
  # 동시에 성립해 아래 분기가 어느 쪽이든 갈 수 있다(manifest 의 `repos:` 1회 검사와 같다).
  nat=$(grep -c '^approved_at: ' "$ONORM" || true)
  [ "$nat" -eq 1 ] \
    || { printf 'approved_at 이 %s회 등장한다 (정확히 1회여야 한다)\n' "$nat" >&2; return 1; }
  grep -qE '^approved_at: (null|"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z")$' "$ONORM" \
    || { printf 'approved_at 이 null 도 ISO-8601 UTC 시각도 아니다\n' >&2; return 1; }

  awk -F'|' '$5 != "true"' "$TMP/oq.rec" > "$TMP/oq.unapproved"
  if ! grep -qx 'approved_at: null' "$ONORM"; then
    [ -s "$TMP/oq.unapproved" ] \
      && { printf 'approved_at 이 채워졌는데 미승인 질문이 있다 (반쪽 승인)\n' >&2; return 1; }
    want=$(sort "$TMP/oq.rec" | sha256sum | cut -d' ' -f1)
    got=$(sed -n 's/^approved_sha256: *//p' "$ONORM")
    [ "$got" = "$want" ] \
      || { printf 'approved_sha256 가 현재 질문 집합과 다르다 — 승인 이후 질문이 바뀌었다. 재승인하고 이 값을 갱신하거나 approved_at 을 null 로 되돌린다\n  기록: %s\n  실제: %s\n' "${got:-없음}" "$want" >&2; return 1; }
  fi
  m6=$(awk -F'|' '$2 ~ /^ *6 *$/ { gsub(/^ +| +$/, "", $5); print $5; exit }' "$TMP/prd00.norm")
  [ -n "$m6" ] || { printf 'PRD Delivery Milestones 6행 status 를 읽지 못했다 (0건)\n' >&2; return 1; }
  if [ "$m6" = complete ]; then
    grep -qx 'approved_at: null' "$ONORM" \
      && { printf 'PRD 마일스톤 6 이 complete 인데 approved_at 이 null 이다\n' >&2; return 1; }
    [ -s "$TMP/oq.unapproved" ] \
      && { printf 'PRD 마일스톤 6 이 complete 인데 미승인 질문이 %s건 있다 (지표 #5: 미승인 항목 0)\n' "$(wc -l < "$TMP/oq.unapproved")" >&2; return 1; }
  fi

  # && 가드의 좌변 실패가 $? 에 남으므로 명시적으로 성공을 반환한다.
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# 00-index 게이트 — 인덱스는 전사(轉寫)다. 정본은 05-final-spec.md §4(트리)·§3(status).
#
# 게이트가 증명하는 것은 **사본이 정본과 같은지**이지 정본이 옳은지가 아니다. 파일 단위 트리는
# 하류와 합의된 적이 없고(문서 PRD 5종 전부 DRAFT), 그 잠정성과 3단계 개정 경로는 인덱스 머리와
# PRD §Open Questions 에 적혀 있다.
#
# 추출 0건은 통과가 아니라 실패다 — 절 제목이 바뀌면 추출이 비고, 빈 것끼리 맞으면 초록이 된다.
# ─────────────────────────────────────────────────────────────────────────────

run_index_gate() {
  local self n_entry n_doc n_self_idx n_self_spec n_dirs n_uniq

  [ -f "$INDEX" ] || { printf '00-index 가 없다: %s\n' "$INDEX" >&2; return 1; }
  [ -f "$SPEC" ]  || { printf '05-final-spec 이 없다: %s\n' "$SPEC" >&2; return 1; }

  # 1. 트리 경로 집합 == 05-final-spec.md §4 (전 경로 대조 — basename 아님)
  tree_paths "$INDEX" '^## 문서 트리$' '^## 상태 범례$'    | sort > "$TMP/idx.tree"
  tree_paths "$SPEC"  '^## 4\. 확정 docs 구조$' '^## 5\.'  | sort > "$TMP/spec.tree"
  [ -s "$TMP/idx.tree" ]  || { printf '00-index.md §문서 트리에서 경로를 추출하지 못했다 (0건)\n' >&2; return 1; }
  [ -s "$TMP/spec.tree" ] || { printf '05-final-spec.md §4 트리에서 경로를 추출하지 못했다 (0건)\n' >&2; return 1; }
  diff "$TMP/idx.tree" "$TMP/spec.tree" \
    | sed 's/^</  00-index 에만     : /; s/^>/  05-final-spec 에만: /' >&2 \
    || { printf '00-index.md 트리가 05-final-spec.md §4 와 다르다\n' >&2; return 1; }

  # 1b. 자기 참조 — 경로를 리터럴로 적지 않고 $INDEX 에서 유도한다. 리터럴은 $INDEX 와
  #     어긋나도 아무도 모르고, "존재함" 만 보는 검사는 중복 등장과 계수 불일치를 통과시킨다.
  self="${INDEX#"$ROOT"/}"
  n_self_idx=$(grep -c -x -F -- "$self" "$TMP/idx.tree" || true)
  n_self_spec=$(grep -c -x -F -- "$self" "$TMP/spec.tree" || true)
  [ "$n_self_idx" -eq 1 ] \
    || { printf '자기 참조 %s 가 00-index 트리에 %s회 등장한다 (정확히 1회)\n' "$self" "$n_self_idx" >&2; return 1; }
  [ "$n_self_spec" -eq 1 ] \
    || { printf '자기 참조 %s 가 05-final-spec §4 트리에 %s회 등장한다 (정확히 1회)\n' "$self" "$n_self_spec" >&2; return 1; }
  n_entry=$(wc -l < "$TMP/idx.tree")
  n_doc=$(grep -c -v -x -F -- "$self" "$TMP/idx.tree" || true)
  [ $((n_entry - n_doc)) -eq 1 ] \
    || { printf 'entry_count(%s) - doc_count(%s) 가 1 이 아니다 — 차이를 만드는 것은 자기 참조 1건뿐이어야 한다\n' \
         "$n_entry" "$n_doc" >&2; return 1; }

  # 2. 읽는 순서 == 절 번호 오름차순. 번호가 곧 읽는 순서라는 원칙(05 §4)이 사본에서 깨지지 않게.
  md_section "$INDEX" '^## 문서 트리$' '^## 상태 범례$' \
    | sed -nE 's|^  ([0-9]{2}-[a-z-]+)/.*$|\1|p' > "$TMP/idx.dirs"
  [ -s "$TMP/idx.dirs" ] || { printf '00-index.md 트리에서 절 디렉터리를 추출하지 못했다 (0건)\n' >&2; return 1; }
  sed 's/-.*$//' "$TMP/idx.dirs" > "$TMP/idx.dirnums"
  n_dirs=$(wc -l < "$TMP/idx.dirnums")
  n_uniq=$(sort -u "$TMP/idx.dirnums" | wc -l)
  [ "$n_dirs" -eq "$n_uniq" ] \
    || { printf '00-index.md 트리에 같은 번호의 절이 2회 이상 등장한다 (%s행 중 고유 %s)\n' "$n_dirs" "$n_uniq" >&2; return 1; }
  sort -n "$TMP/idx.dirnums" > "$TMP/idx.dirnums.sorted"
  diff "$TMP/idx.dirnums" "$TMP/idx.dirnums.sorted" >&2 \
    || { printf '00-index.md 트리의 절 등장 순서가 번호 오름차순이 아니다\n' >&2; return 1; }

  # 3. status 범례 == 05-final-spec.md §3 — **순서 포함, 뜻 열까지**.
  #
  #    status 코드만 대조하면 뜻을 통째로 뒤집어도 초록이다 — `conditional-unknown` 의 뜻을
  #    "부록 격리" 에서 "본문 수록" 으로 바꿔도 코드 8개는 그대로다(santa m7 R2 Reviewer B).
  #    이 파일은 정본의 **사본**이고 뜻 열이 그 사본의 내용이다. 공유 자산 표를 자산명이
  #    아니라 전 행으로 대조하기로 한 것(구현 리뷰 IF4)과 같은 처리다.
  #
  #    두 표는 **모양이 다르다**: 정본은 5열(status·라벨·배치·판정·문서 처리), 사본은 2열
  #    (status·뜻)로 **뒤 두 열**을 ` — ` 로 이어 적는다. 그 결합 규칙을 여기서 되돌려 대조한다.
  #    사본 쪽을 5열로 바꾸는 편이 단순하지만 그것은 인덱스 표 형식을 게이트 편의로 바꾸는
  #    것이라, 사람이 읽는 문서 쪽을 건드리지 않고 규칙을 기계에 적는다.
  #
  #    **13-relation-contract M4 가 정본에 `라벨`·`배치` 2열을 넣었다.** 열 수와 awk 필드 번호를
  #    함께 옮긴다 — 한쪽만 고치면 md_table 이 폭 불일치로 0바이트를 내고 아래 0건 가드가
  #    붉어진다(조용히 통과하는 경로는 없다). 사본은 `라벨`·`배치` 를 전사하지 않는다.
  md_table "$INDEX" '^## 상태 범례$' '^## 폐기 목록$' 2 > "$TMP/idx.statusrows"
  md_table "$SPEC" '^## 3\. 활성성' '^## 4\.' 5 \
    | awk -F'|' '{ print $1 "|" $4 " — " $5 }' > "$TMP/spec.statusrows"
  [ -s "$TMP/idx.statusrows" ]  || { printf '00-index.md §상태 범례 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  [ -s "$TMP/spec.statusrows" ] || { printf '05-final-spec.md §3 status 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  diff "$TMP/idx.statusrows" "$TMP/spec.statusrows" \
    | sed 's/^</  00-index 에만     : /; s/^>/  05-final-spec 에만: /' >&2 \
    || { printf '00-index.md 상태 범례가 05-final-spec.md §3 와 다르다 (순서·뜻 포함 전 행 대조)\n' >&2; return 1; }

  # 4. 폐기 목록 == 트리의 `99-deprecated/` 항목, **정확 집합**.
  #
  #    리터럴 2건의 **존재**만 보면 살아 있는 문서를 가리키는 항목을 하나 더 끼워 넣어도
  #    통과한다(santa m7 R1 Reviewer B — 실측). 이 파일이 정본인 이상 그것은 살아 있는 문서를
  #    폐기로 표시하는 것이고, `index.*` 5행 어디에도 남지 않는다.
  #
  #    기대값은 리터럴이 아니라 **검사 1 이 정본에 묶어 둔 트리**에서 유도한다. 범위는 §폐기
  #    목록 절 안이다 — 파일 전체에서 링크를 걷으면 서론에 정당한 링크가 하나 생기는 순간
  #    거짓 실패가 된다. 이 절은 파일의 마지막이라 끝 표제를 빈 문자열로 명시한다.
  md_section "$INDEX" '^## 폐기 목록$' '' > "$TMP/idx.dep.sec"
  [ -s "$TMP/idx.dep.sec" ] || { printf '00-index.md §폐기 목록 절을 추출하지 못했다 (0건)\n' >&2; return 1; }
  grep -oE '\]\([^)]+\)' "$TMP/idx.dep.sec" > "$TMP/idx.links.raw" || true
  sed -E 's/^\]\(//; s/\)$//' "$TMP/idx.links.raw" | sort -u > "$TMP/idx.links"
  sed -n 's|^docs/\(99-deprecated/.*\)$|\1|p' "$TMP/idx.tree" | sort -u > "$TMP/idx.dep"
  [ -s "$TMP/idx.dep" ] \
    || { printf '00-index.md 트리에 99-deprecated/ 문서가 0건이다 — 폐기 목록의 기대값을 유도할 수 없다\n' >&2; return 1; }
  diff "$TMP/idx.dep" "$TMP/idx.links" \
    | sed 's/^</  트리에만 (링크 없음): /; s/^>/  링크에만 (트리 없음): /' >&2 \
    || { printf '00-index.md §폐기 목록의 링크 집합이 트리의 99-deprecated/ 항목과 다르다\n' >&2; return 1; }

  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# ownership 게이트 — 이 파일은 전사가 아니라 **정본 승계**다.
#
# 승계를 전사처럼 대조하면 승계된 정본을 폐지된 소스에 영구히 묶게 된다. 그래서 동등성이 아니라
# 무손실 승계를 본다 — 우산 9행 전수 보존(행 단위 결속) + 초과분은 선언·근거·범위 한정과 함께.
#
# 허용 목록을 코드에 리터럴로 박지 않는다. 리터럴 예외는 개수 증가만 막고 *어느 문서가 정본인지*
# 를 해결하지 못해, 우산을 읽는 worktree 에는 무소유이고 ownership.md 를 읽는 worktree 에는
# 00-protocol 소유인 상태가 영구히 초록으로 남는다.
# ─────────────────────────────────────────────────────────────────────────────

# overlap_kind <a> <b> — 두 경로의 포함 관계를 분류한다: same / a_parent / b_parent / none.
#
#   `겹친다/안 겹친다` 로 뭉개지 않는 이유: 상하위 겹침의 허용 여부가 **상위 쪽 범위 한정**에
#   달려 있어서, 어느 쪽이 상위인지 모르면 판정할 수 없다(아래 검사 2c 참조).
#
#   결과를 stdout 이 아니라 `$OVERLAP_KIND` 에 둔다. `$(overlap_kind …)` 로 부르면 호출마다
#   서브셸이 생기고, 검사 2c-2 는 추가 행 수의 제곱만큼 부른다 — 행이 2에서 17로 늘면서 그
#   비용이 그대로 --check 대기시간이 된다(Windows 는 fork 가 특히 비싸다).
OVERLAP_KIND=none
overlap_kind() {
  local a="${1%\*\*}" b="${2%\*\*}"
  a="${a%/}"; b="${b%/}"
  if [ "$a" = "$b" ]; then OVERLAP_KIND=same; return 0; fi
  case "$b" in "$a"/*) OVERLAP_KIND=a_parent; return 0 ;; esac
  case "$a" in "$b"/*) OVERLAP_KIND=b_parent; return 0 ;; esac
  OVERLAP_KIND=none
}

# prd_finding_items <PRD §발견 기록 규약 절 파일>
#   `기록 필수 항목:` 문장에서 항목명을 **순서대로** 낸다.
#
#   README 표를 정본처럼 받으면 README 가 자기 스키마를 스스로 재정의한다 — 초안은 README 항목이
#   PRD 절 **어딘가에 있기만** 하면 통과시켜서, README 순서를 뒤집으면 발견 파일도 뒤집힌 순서를
#   따라야 하는데 게이트는 초록이었다(santa m7 R3 Reviewer B). 순서의 정본은 이 문장이다.
prd_finding_items() {
  grep -F '기록 필수 항목:' "$1" > "$TMP/fi.line" || true
  [ "$(wc -l < "$TMP/fi.line")" -eq 1 ] \
    || { printf 'PRD §발견 기록 규약의 "기록 필수 항목:" 문장이 %s개다 (정확히 1개여야 한다)\n' \
         "$(wc -l < "$TMP/fi.line")" >&2; return 1; }
  # 항목은 ` · ` 로 나뉜다. 괄호 이후(`근거 앵커(파일:행 …)` · `상태(enum)` 뒤의 잔여 문장)를
  # 잘라 항목명만 남기고 강조·백틱을 벗긴다.
  # 구분자 좌우 공백은 관용한다. ` · ` 를 정확히 요구하면 사람이 문서를 자연스럽게 고치는 것
  # (`·` 를 붙여 쓰기)이 곧 거짓 실패가 된다(santa m7 R4 Reviewer B). **줄바꿈은 관용하지
  # 않는다** — 문장이 접히면 뒤 항목이 사라지고, 그것은 README 와의 exact diff 가 어느 항목이
  # 빠졌는지 이름으로 지목하며 시끄럽게 떨어진다. 그 계약은 PRD 문장 옆에 적어 두었다.
  sed 's/^.*기록 필수 항목:[[:space:]]*//' "$TMP/fi.line" \
    | awk -F'[[:space:]]*·[[:space:]]*' '{ for (i = 1; i <= NF; i++) print $i }' \
    | sed 's/(.*$//; s/\*\*//g; s/`//g; s/^[[:space:]]*//; s/[[:space:]]*$//' \
    | grep -v '^$'
}

# prd_status_enum <PRD §발견 기록 규약 절 파일> — `상태(...)` 괄호에서 enum 을 낸다.
#   코드에 리터럴로 적으면 그 리터럴이 검사 없는 규범이 된다(lint_glob_exceptions 와 같은 이유).
prd_status_enum() {
  grep -oE '상태\([^)]+\)' "$1" > "$TMP/fs.raw" || true
  # **정확히 1개**여야 한다. `head -1` 로 첫 것을 채택하면 절에 `상태(초안 / 확정)` 같은 다른
  # 뜻의 괄호가 하나 생기는 것만으로 enum 이 조용히 바뀐다 — `*Status:` 유일성과 같은 계약이다.
  [ "$(wc -l < "$TMP/fs.raw")" -eq 1 ] \
    || { printf 'PRD §발견 기록 규약의 "상태(...)" 가 %s개다 (정확히 1개여야 한다)\n' \
         "$(wc -l < "$TMP/fs.raw")" >&2; return 1; }
  sed 's/^상태(//; s/)$//' "$TMP/fs.raw" \
    | awk -F'[[:space:]]*/[[:space:]]*' '{ for (i = 1; i <= NF; i++) print $i }' \
    | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^$' > "$TMP/fs.enum"
  # 소비부(findings_structure_ok)가 쉼표로 잇는다. 값에 쉼표가 있으면 한 값이 둘로 쪼개져
  # 접두 대조가 **더 느슨해진다** — 규약 밖 값이 통과하는 방향이라 거부한다.
  grep -q ',' "$TMP/fs.enum" \
    && { printf 'PRD §발견 기록 규약의 상태 enum 값에 쉼표가 있다 — 이 스크립트가 쉼표로 잇는다\n' >&2; return 1; }
  cat "$TMP/fs.enum"
}

run_ownership_gate() {
  local owner path scope reason evidence ev_prd ev_file qual o2 p2 s2 _r2 _e2 st item n_items f sec prd
  local ffile fbase

  [ -f "$OWNERSHIP" ]    || { printf 'ownership.md 가 없다: %s\n' "$OWNERSHIP" >&2; return 1; }
  [ -f "$PRD_UMBRELLA" ] || { printf '우산 PRD 가 없다: %s\n' "$PRD_UMBRELLA" >&2; return 1; }
  [ -f "$PRD00" ]        || { printf 'PRD 를 찾을 수 없다: %s\n' "$PRD00" >&2; return 1; }

  # 1. PRD 9행 == 우산 §PRD 분할 맵, **행 단위 결속**. 파일명 집합만 보면 두 PRD 사이에서
  #    경로·선행조건·종료조건을 맞바꿔도 통과한다.
  ow_rows "$PRD_UMBRELLA" '^## PRD 분할 맵$' '^### 공유 자산' > "$TMP/ow.umb"
  ow_rows "$OWNERSHIP" '^## PRD별 배타 소유 경로$' '^## 승계 시 추가$' > "$TMP/ow.own"
  [ -s "$TMP/ow.umb" ] || { printf '우산 PRD §PRD 분할 맵에서 행을 추출하지 못했다 (0건)\n' >&2; return 1; }
  [ -s "$TMP/ow.own" ] || { printf 'ownership.md §PRD별 배타 소유 경로에서 행을 추출하지 못했다 (0건)\n' >&2; return 1; }
  diff "$TMP/ow.umb" "$TMP/ow.own" \
    | sed 's/^</  우산 에만     : /; s/^>/  ownership 에만: /' >&2 \
    || { printf 'ownership.md 승계 표가 우산 §PRD 분할 맵과 행 단위로 다르다 (선행조건·경로·종료조건 결속 포함)\n' >&2; return 1; }

  # 2. 초과분 == `## 승계 시 추가` 선언. 검사 1 이 행 단위이므로 여기서는 초과분만 본다.
  md_table "$OWNERSHIP" '^## 승계 시 추가$' '^## 공유 자산' 5 > "$TMP/ow.add"
  [ -s "$TMP/ow.add" ] || { printf 'ownership.md §승계 시 추가에서 선언 행을 추출하지 못했다 (0건)\n' >&2; return 1; }
  ow_pairs "$TMP/ow.umb" > "$TMP/ow.pairs.umb"
  ow_pairs "$TMP/ow.own" > "$TMP/ow.pairs.tbl"
  awk -F'|' '{ print $1 "|" $2 }' "$TMP/ow.add" | sort -u > "$TMP/ow.pairs.add"
  sort -u "$TMP/ow.pairs.tbl" "$TMP/ow.pairs.add" > "$TMP/ow.pairs.all"
  comm -23 "$TMP/ow.pairs.all" "$TMP/ow.pairs.umb" > "$TMP/ow.pairs.excess"
  diff "$TMP/ow.pairs.excess" "$TMP/ow.pairs.add" \
    | sed 's/^</  선언되지 않은 초과: /; s/^>/  선언만 있고 표에 없음: /' >&2 \
    || { printf 'ownership.md 의 초과 소유가 §승계 시 추가 선언과 일치하지 않는다\n' >&2; return 1; }

  # 2b. 선언의 **근거를 기계 검증**한다. 확인하지 않으면 ownership.md 가 자기 예외를 자기가
  #     승인하는 자리가 되어, 잘못된 배타 소유가 정본으로 승계된다.
  while IFS='|' read -r owner path scope reason evidence; do
    [ -n "$owner" ] || { printf '§승계 시 추가 행에 소유자가 비어 있다: %s\n' "$path" >&2; return 1; }
    # sed 로 뽑지 않는다 — `.*(...)` 는 탐욕적이라 `insuniverse-docs-00-protocol.prd.md` 에서
    # `l.prd.md` 만 남긴다. grep -o 는 왼쪽부터 최장 일치라 파일명 전체를 낸다. 결과는 파일로
    # 받는다(파이프 뒤 head 는 grep 을 SIGPIPE 로 죽인다).
    printf '%s\n' "$evidence" | grep -oE '[a-z0-9][a-z0-9.-]*\.prd\.md' > "$TMP/ow.ev.prd" || true
    ev_prd=$(head -1 "$TMP/ow.ev.prd")
    [ -n "$ev_prd" ] \
      || { printf '§승계 시 추가 (%s) 의 근거에서 PRD 파일명을 읽지 못했다: %s\n' "$path" "$evidence" >&2; return 1; }
    [ "$ev_prd" = "$owner" ] \
      || { printf '§승계 시 추가 (%s) 의 소유자(%s)와 근거 문서(%s)가 다르다\n' "$path" "$owner" "$ev_prd" >&2; return 1; }
    ev_file=$(prd_path "$ev_prd") \
      || { printf '§승계 시 추가 (%s) 의 근거 PRD 파일이 없다: %s (archived/ 포함해 찾았다)\n' "$path" "$ev_prd" >&2; return 1; }
    md_section "$ev_file" '^## 배타 소유 경로$' '^## ' > "$TMP/ow.ev"
    [ -s "$TMP/ow.ev" ] \
      || { printf '%s 에서 §배타 소유 경로 절을 추출하지 못했다 (0건)\n' "$ev_prd" >&2; return 1; }
    # **접두어가 아니라 토큰 일치**다. `index($0, p) == 1` 은 선언 경로가 실재 경로의 접두어이기만
    # 하면 통과한다 — `docs/_meta/findings/50-domain` (확장자 없음)이 실재하는
    # `docs/_meta/findings/50-domain.md` 를 근거로 삼아 통과했다(santa m7 R2 Reviewer B).
    # 없는 경로에 소유를 세우는 것이라 근거 검증 자체가 무의미해진다. 첫 필드는 2d 의
    # 코드펜스 추출(`^([^[:space:]`]+)`)이 쓰는 것과 같은 단위다.
    awk -v p="$path" '$1 == p { print; found = 1 } END { exit !found }' \
      "$TMP/ow.ev" > "$TMP/ow.ev.hit" \
      || { printf '§승계 시 추가 (%s) 의 경로가 근거 %s §배타 소유 경로에 없다 (첫 토큰 정확 일치)\n' "$path" "$ev_prd" >&2; return 1; }
    # 범위 한정. 근거 행의 `←` 한정어를 잃고 경로만 옮기면, 이 파일이 정본이 되는 순간
    # 하류 PRD 가 자기 findings 파일을 쓸 권한이 정본상 사라진다.
    sed -nE 's/^[^ ]+[[:space:]]+←[[:space:]]*(.*)$/\1/p' "$TMP/ow.ev.hit" > "$TMP/ow.ev.qual"
    if [ -s "$TMP/ow.ev.qual" ]; then
      qual=$(head -1 "$TMP/ow.ev.qual" | sed 's/[[:space:]]\{1,\}/ /g; s/^ //; s/ $//')
      [ "$scope" = "$qual" ] \
        || { printf '§승계 시 추가 (%s) 의 범위 한정이 근거와 다르다\n  선언: %s\n  근거: %s\n' \
             "$path" "$scope" "$qual" >&2; return 1; }
    else
      [ "$scope" = "전체" ] \
        || { printf '§승계 시 추가 (%s) 의 근거에 한정어가 없는데 범위 한정이 "전체" 가 아니다: %s\n' \
             "$path" "$scope" >&2; return 1; }
    fi
    # 2c. 겹침 거부. 자기 PRD 에 경로를 적고 그 PRD 를 근거로 드는 자기 승인이 **두 개의 배타
    #     소유자**를 만드는 것을 막는다. 근거 검증만으로는 이 경로가 열려 있다.
    #
    #     우산 9행은 범위 한정 열이 없다 — 그 표의 소유는 전부 `전체` 다. 그래서 어떤 겹침도
    #     거부한다.
    while IFS='|' read -r o2 p2; do
      [ "$o2" != "$owner" ] || continue
      overlap_kind "$path" "$p2"
      if [ "$OVERLAP_KIND" != none ]; then
        printf '§승계 시 추가 (%s / %s) 가 우산 표의 배타 소유 경로와 겹친다: %s / %s\n' \
          "$owner" "$path" "$o2" "$p2" >&2
        return 1
      fi
    done < "$TMP/ow.pairs.umb"
    # 2c-2. **추가 행끼리도** 본다. 초안은 우산 행하고만 대조해서, 서로 다른 두 PRD 가 같은
    #       경로를 각자 자기 PRD 를 근거로 선언하면 둘 다 초록이었다(santa m7 R2 Reviewer B) —
    #       자기 승인이 두 배타 소유자를 만드는 것을 막겠다던 검사가 정확히 그 조합만 비워 뒀다.
    #
    #       **상하위 겹침은 상위의 범위 한정을 읽는다.** PRD00 은 `docs/_meta/findings/` 를
    #       *"디렉터리·기록 형식만. 각 파일 본문은 생성 PRD 소유"* 로 한정했고, 그래서 하류의
    #       `docs/_meta/findings/<id>.md` 선언은 규약이 **예정한** 승계다. 한정이 붙은 상위는
    #       정의상 하위에 대해 배타적이지 않다 — 무조건 거부하면 정상 상태를 막는 거짓 실패가
    #       된다. 한정 문구를 기계가 해석하지는 못하지만 `전체` 인지 아닌지는 판정할 수 있고,
    #       그 이분이 곧 "하위를 남에게 넘겼는가" 다. 같은 경로는 한정과 무관하게 두 배타
    #       소유자이므로 언제나 거부한다.
    while IFS='|' read -r o2 p2 s2 _r2 _e2; do
      [ "$o2" != "$owner" ] || continue
      overlap_kind "$path" "$p2"
      case "$OVERLAP_KIND" in
        same)
          printf '§승계 시 추가 (%s / %s) 가 다른 소유자의 추가 선언과 같은 경로다: %s\n' \
            "$owner" "$path" "$o2" >&2
          return 1 ;;
        a_parent)
          if [ "$scope" = "전체" ]; then
            printf '§승계 시 추가 (%s / %s) 가 범위 한정 없이 다른 소유자의 추가 경로를 포함한다: %s / %s\n' \
              "$owner" "$path" "$o2" "$p2" >&2
            return 1
          fi ;;
        b_parent)
          if [ "$s2" = "전체" ]; then
            printf '§승계 시 추가 (%s / %s) 가 범위 한정 없는 다른 소유자의 상위 경로 안에 있다: %s / %s\n' \
              "$owner" "$path" "$o2" "$p2" >&2
            return 1
          fi ;;
      esac
    done < "$TMP/ow.add"
  done < "$TMP/ow.add"

  # 2d. 이 파일의 소유 PRD 가 선언한 배타 소유 경로가 **하나도 빠지지 않았는지**.
  #     검사 2 는 초과만 본다. 선언을 지우는 쪽은 초과가 줄어 조용히 통과하는데, 이 파일이
  #     정본이므로 여기서 빠진 경로는 정본상 무소유가 된다 — 승계가 아니라 소실이다.
  #
  #     하류 8개 PRD 로 일반화하지 않는다. 10·11·12 는 우산이 축약 표기(`comments.*.jsonl`)를
  #     쓰고 자기 PRD 는 전 경로를 쓰며, 문서 PRD 5종은 우산에 없는 `docs/_meta/findings/<id>.md`
  #     ·`term-requests/<id>.md` 를 각자 선언한다 — 그 정규화는 하류가 착수하며 자기 선언을 낼
  #     때의 축이다. 지금 넣으면 DRAFT 표기 차이가 곧바로 거짓 실패가 된다.
  #     소유 경로 목록은 이 절의 **첫 코드펜스**다. 절 전체의 펜스를 긁으면 하위 `###` 설명
  #     블록이 소유 선언으로 읽힌다 — `### .gitattributes — EOL 정책` 이 실은 git 속성 규칙인
  #     `docs/_meta/index/*.yaml` · `docs/_meta/verify/*.mjs` 두 줄을 펜스에 담고 있어, 그 둘이
  #     "ownership.md 에서 누락된 소유 경로" 로 잡혔다(12-index-runtime 회부 5 병합 후 실측).
  #     설명 블록은 얼마든 늘어날 수 있으므로 첫 펜스로 못박는 쪽이 규약이다.
  md_section "$PRD00" '^## 배타 소유 경로$' '^## ' | awk '/^```$/ { n++; next } n == 1' \
    | sed -nE 's/^([^[:space:]`]+).*$/\1/p' | sort -u > "$TMP/ow.p00"
  [ -s "$TMP/ow.p00" ] || { printf 'PRD §배타 소유 경로에서 경로를 추출하지 못했다 (0건)\n' >&2; return 1; }
  awk -F'|' -v o="$(basename "$PRD00")" '$1 == o { print $2 }' "$TMP/ow.pairs.all" | sort -u > "$TMP/ow.own00"
  comm -23 "$TMP/ow.p00" "$TMP/ow.own00" > "$TMP/ow.lost"
  if [ -s "$TMP/ow.lost" ]; then
    printf 'PRD §배타 소유 경로가 ownership.md 에서 누락됐다 — 승계가 아니라 소실이다:\n' >&2
    sed 's/^/  /' "$TMP/ow.lost" >&2
    return 1
  fi

  # 2e. 하류 PRD 가 선언한 `findings/<id>.md` · `term-requests/<id>.md` 도 이 파일이 알아야 한다.
  #     2d 는 PRD00 하나만 본다 — 그러면 8개 하류 PRD 가 각자 선언한 15경로가 소유권 정본 밖에
  #     남고, 이 파일이 *"배타 소유 경로가 겹치는 행은 없다 — 동시 작업의 유일한 보증"* 이라고
  #     적은 채 그 보증이 비어 있다. 두 worktree 가 같은 `term-requests/x.md` 를 각자 자기
  #     것이라고 선언해도 여기서 아무것도 터지지 않았다(santa m7 R3 Reviewer B — 실측).
  #
  #     `docs/_meta/index/**` 계열로는 넓히지 않는다. 우산은 축약 표기(`comments.*.jsonl`)를,
  #     하류 PRD 는 전 경로를 쓴다 — 그 정규화는 하류가 착수하며 자기 선언을 낼 때의 축이고 지금
  #     넣으면 표기 차이가 곧바로 거짓 실패가 된다(2d 주석과 같은 이유). findings·term-requests
  #     는 양쪽 다 전 경로라 그 문제가 없어 지금 묶을 수 있다.
  : > "$TMP/ow.dsdecl"
  for f in "$PRD_DIR"/insuniverse-docs-*.prd.md; do
    [ -f "$f" ] || continue
    prd=$(basename "$f")
    md_section "$f" '^## 배타 소유 경로$' '^## ' > "$TMP/ow.ds.sec" \
      || { printf '%s 에서 §배타 소유 경로 절을 추출하지 못했다 (위 stderr 참조)\n' "$prd" >&2; return 1; }
    [ -s "$TMP/ow.ds.sec" ] \
      || { printf '%s §배타 소유 경로 절이 비었다 (0건)\n' "$prd" >&2; return 1; }
    sed -n '/^```$/,/^```$/p' "$TMP/ow.ds.sec" | sed -nE 's/^([^[:space:]`]+).*$/\1/p' > "$TMP/ow.ds.paths"
    [ -s "$TMP/ow.ds.paths" ] \
      || { printf '%s §배타 소유 경로에서 경로를 0건 추출했다 — 추출 0건은 통과가 아니다\n' "$prd" >&2; return 1; }
    grep -E '^docs/_meta/(findings|term-requests)/[A-Za-z0-9._-]+\.md$' "$TMP/ow.ds.paths" \
      | sed "s|^|$prd\||" >> "$TMP/ow.dsdecl" || true
  done
  sort -u "$TMP/ow.dsdecl" > "$TMP/ow.dsdecl.u"
  [ -s "$TMP/ow.dsdecl.u" ] \
    || { printf '하류 PRD 에서 findings·term-requests 선언을 0건 유도했다 — 감시가 사라진 것이지 통과가 아니다\n' >&2; return 1; }
  comm -23 "$TMP/ow.dsdecl.u" "$TMP/ow.pairs.all" > "$TMP/ow.dslost"
  if [ -s "$TMP/ow.dslost" ]; then
    printf 'PRD 가 선언한 findings·term-requests 경로가 ownership.md 에 없다 — 정본이 소유를 모른다:\n' >&2
    sed 's/^/  /' "$TMP/ow.dslost" >&2
    return 1
  fi

  # 3. 공유 자산 == 우산 §공유 자산 충돌 방지 프로토콜, **전 행**. 자산명만 대조하면 소유자를
  #    바꾸거나 "읽기 전용" 규칙을 지워도 통과한다 — 이 파일이 정본이 되는 순간 잘못된 쓰기
  #    권한이 실린다.
  md_table "$PRD_UMBRELLA" '^### 공유 자산 충돌 방지 프로토콜$' '^## Success Metrics$' 3 > "$TMP/ow.sa.umb"
  md_table "$OWNERSHIP" '^## 공유 자산 충돌 방지 프로토콜$' '^## 스크래치 승격 규약$' 3 > "$TMP/ow.sa.own"
  [ -s "$TMP/ow.sa.umb" ] || { printf '우산 §공유 자산 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  [ -s "$TMP/ow.sa.own" ] || { printf 'ownership.md §공유 자산 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  diff "$TMP/ow.sa.umb" "$TMP/ow.sa.own" \
    | sed 's/^</  우산 에만     : /; s/^>/  ownership 에만: /' >&2 \
    || { printf 'ownership.md 공유 자산 표가 우산과 다르다 (자산·소유자·접근 규칙 전 행 대조)\n' >&2; return 1; }

  # 4. 스크래치 승격 목록. `.temp.md` 는 .gitignore 대상이라 **파일 존재를 요구하지 않는다** —
  #    부재는 실패가 아니다. 검사하는 것은 목록이 비지 않았는지와 인수 PRD 가 실재하는지다.
  md_table "$OWNERSHIP" '^### 승격 대상 목록$' '^\*\*승격 실행은' 4 > "$TMP/ow.scratch"
  [ -s "$TMP/ow.scratch" ] || { printf 'ownership.md §승격 대상 목록이 비어 있다\n' >&2; return 1; }
  while IFS='|' read -r f sec item prd; do
    prd_path "$prd" >/dev/null \
      || { printf '승격 대상 (%s %s) 의 인수 PRD 가 실재하지 않는다: %s (archived/ 포함해 찾았다)\n' "$f" "$sec" "$prd" >&2; return 1; }
  done < "$TMP/ow.scratch"

  # 5. findings/ 개설.
  [ -d "$FINDINGS" ]                  || { printf 'findings 디렉터리가 없다: %s\n' "$FINDINGS" >&2; return 1; }
  [ -f "$FINDINGS/README.md" ]        || { printf 'findings/README.md 가 없다\n' >&2; return 1; }
  [ -f "$FINDINGS/00-protocol.md" ]   || { printf 'findings/00-protocol.md 가 없다\n' >&2; return 1; }
  md_table "$FINDINGS/README.md" '^## 기록 형식$' '^## 적지 않는 것$' 3 > "$TMP/ow.find.rows"
  [ -s "$TMP/ow.find.rows" ] \
    || { printf 'findings/README.md §기록 형식 표를 추출하지 못했다 (0건)\n' >&2; return 1; }
  awk -F'|' '{ print $2 }' "$TMP/ow.find.rows" > "$TMP/ow.find.items"
  # README 가 규약을 벗어나 항목을 발명하지 못하게 PRD §발견 기록 규약에 묶는다.
  md_section "$PRD00" '^### 발견 기록 규약' '^### 스크래치 승격 규약' > "$TMP/ow.find.prd"
  [ -s "$TMP/ow.find.prd" ] || { printf 'PRD §발견 기록 규약 절을 추출하지 못했다 (0건)\n' >&2; return 1; }
  # 항목명 **포함 여부**가 아니라 **순서까지** 대조한다. 포함 검사는 README 순서를 정본으로
  # 받아들여, README 와 발견 파일이 함께 뒤집혀도 초록이었다(santa m7 R3 Reviewer B).
  # 개수도 여기서 유도된다 — `6` 을 코드에 적으면 그 리터럴이 검사 없는 규범이 된다.
  prd_finding_items "$TMP/ow.find.prd" > "$TMP/ow.find.prditems" || return 1
  [ -s "$TMP/ow.find.prditems" ] \
    || { printf 'PRD §발견 기록 규약에서 필수 항목을 0건 유도했다\n' >&2; return 1; }
  diff "$TMP/ow.find.prditems" "$TMP/ow.find.items" \
    | sed 's/^</  PRD 에만    : /; s/^>/  README 에만: /' >&2 \
    || { printf 'findings/README.md 의 필수 항목이 PRD §발견 기록 규약과 다르다 (순서 포함)\n' >&2; return 1; }
  n_items=$(wc -l < "$TMP/ow.find.items")

  # 값 규약의 정본도 PRD 다. 초안은 표제 아래가 **비지 않았는지**만 봐서 `발견 일시: yesterday`
  # 와 `상태: done` 이 통과했다(santa m7 R3 Reviewer B — 실측). 어느 항목이 날짜이고 어느
  # 항목이 상태인지는 README 의 `담는 것` 열에서 유도한다 — 항목명을 코드에 박지 않는다.
  prd_status_enum "$TMP/ow.find.prd" > "$TMP/ow.find.enum" || return 1
  [ -s "$TMP/ow.find.enum" ] \
    || { printf 'PRD §발견 기록 규약에서 상태 enum 을 0건 유도했다\n' >&2; return 1; }
  awk -F'|' '$3 ~ /YYYY-MM-DD/ { print $2 }' "$TMP/ow.find.rows" > "$TMP/ow.find.dateitem"
  [ "$(wc -l < "$TMP/ow.find.dateitem")" -eq 1 ] \
    || { printf 'findings/README.md 에서 YYYY-MM-DD 를 담는 항목이 %s개다 (정확히 1개여야 한다)\n' \
         "$(wc -l < "$TMP/ow.find.dateitem")" >&2; return 1; }
  awk -F'|' -v e="$(head -1 "$TMP/ow.find.enum")" 'index($3, e) { print $2 }' \
    "$TMP/ow.find.rows" > "$TMP/ow.find.statusitem"
  [ "$(wc -l < "$TMP/ow.find.statusitem")" -eq 1 ] \
    || { printf 'findings/README.md 에서 상태 enum 을 담는 항목이 %s개다 (정확히 1개여야 한다)\n' \
         "$(wc -l < "$TMP/ow.find.statusitem")" >&2; return 1; }

  # 발견 파일 **전수**를 본다. 초안은 `00-protocol.md` 하나만 검사했는데, README 규약은
  # findings 파일 전부에 걸리고 디렉터리·기록 형식은 이 PRD 소유다 — 하류가 만든
  # `50-domain.md` 가 무검사로 통과하면 규약이 한 파일에만 적용되는 셈이다(santa m7 R2
  # Reviewer B). 파일명 규약(`<prd-id>.md`, README §파일명 규약)도 같이 본다. 목록은
  # `findings.files` 로 golden 에 이미 동결돼 있어 파일이 늘고 주는 것은 --update 로 드러난다.
  # 순회 전에 **디렉터리 모양**을 먼저 못 박는다. `*.md` 순회도 golden 의 `findings.files`
  # (`-maxdepth 1 -type f`) 도 하위 디렉터리를 보지 않아, `findings/legacy/50-domain.md` 는
  # 파일명 규약·6항목 검사·동결 어디에도 걸리지 않고 통과했다(santa m7 R3 — 실측). 깊이 1 의
  # `.md` 정규 파일만 허용한다. 이 디렉터리의 **형식**이 이 PRD 의 배타 소유물이다.
  {
    find "$FINDINGS" -mindepth 2
    find "$FINDINGS" -mindepth 1 -maxdepth 1 -not -type f
    find "$FINDINGS" -mindepth 1 -maxdepth 1 -type f -not -name '*.md'
  } | sort -u > "$TMP/ow.find.stray"
  if [ -s "$TMP/ow.find.stray" ]; then
    printf 'findings/ 에 규약 밖 엔트리가 있다 — 깊이 1 의 .md 정규 파일만 허용한다:\n' >&2
    sed "s|^$ROOT/|  |" "$TMP/ow.find.stray" >&2
    return 1
  fi

  : > "$TMP/ow.find.list"
  for ffile in "$FINDINGS"/*.md; do
    [ -f "$ffile" ] || continue
    [ "$(basename "$ffile")" != README.md ] || continue
    printf '%s\n' "$ffile" >> "$TMP/ow.find.list"
  done
  [ -s "$TMP/ow.find.list" ] \
    || { printf 'findings/ 에 발견 파일이 0건이다 (README.md 제외) — 개설의 증거가 없다\n' >&2; return 1; }
  while read -r ffile; do
    fbase=$(basename "$ffile" .md)
    prd_path "insuniverse-docs-$fbase.prd.md" >/dev/null \
      || { printf 'findings/%s.md 가 파일명 규약을 어긴다 — 대응 PRD 가 없다: insuniverse-docs-%s.prd.md (archived/ 포함해 찾았다)\n' "$fbase" "$fbase" >&2; return 1; }
    tr -d '\r' < "$ffile" > "$TMP/ow.find.doc"
    if findings_deferred "$fbase"; then
      # 전환 유예. m7 이 형식 정본을 세우기 전에 쓰인 파일이며, 각자 머리에 "마일스톤 7 이
      # 도착하면 그 형식으로 맞춘다" 는 전환 계약을 달고 있다. 본문 소유는 생성 PRD 라
      # (ownership.md — `findings/` 는 디렉터리·기록 형식만 이 PRD 소유) 여기서 개작하지 않는다.
      #
      # 유예가 **검사 없는 규범**이 되지 않도록 반대 방향을 건다: 이미 6항목 형식을 만족하는데
      # 명단에 남아 있으면 실패한다. 전환을 끝낸 PRD 는 게이트가 명단에서 지우라고 말하고,
      # 명단이 비면 아래 전면 강제로 자동 전환된다. 명단 파일의 실재는 위 prd_path 검사가 이미
      # 봤고, findings/ 순회 자체가 실재하는 파일만 돈다.
      if findings_structure_ok "$TMP/ow.find.items" "$TMP/ow.find.doc" \
           "$(paste -sd, "$TMP/ow.find.enum")" \
           "$(cat "$TMP/ow.find.dateitem")" "$(cat "$TMP/ow.find.statusitem")" 2>/dev/null; then
        printf 'findings/%s.md 는 이미 필수 %s항목 형식을 만족한다 — 전환 유예 명단에서 지워라\n' \
          "$fbase" "$n_items" >&2
        printf '  verify.sh FINDINGS_FORMAT_DEFERRED 에서 "%s" 제거\n' "$fbase" >&2
        return 1
      fi
      printf '  --  findings/%s.md 형식 전환 유예 (m7 이전 기록. 전환은 %s PRD 소관)\n' \
        "$fbase" "$fbase"
      continue
    fi
    findings_structure_ok "$TMP/ow.find.items" "$TMP/ow.find.doc" \
      "$(paste -sd, "$TMP/ow.find.enum")" \
      "$(cat "$TMP/ow.find.dateitem")" "$(cat "$TMP/ow.find.statusitem")" \
      || { printf 'findings/%s.md 의 발견별 필수 %s항목 검사 실패 (위 stderr 참조)\n' "$fbase" "$n_items" >&2; return 1; }
  done < "$TMP/ow.find.list"
  # enum 은 PRD 에서 유도했다. README 쪽에도 같은 값이 실려 있는지 확인하되, **상태 행의 셀
  # 안에서** 본다. 파일 전체를 뒤지면 상태 행에 값 하나만 남기고 나머지를 다른 산문에 적어도
  # 통과한다 — 상태 행 식별이 첫 enum 값 하나로 되기 때문에 그 조합이 열려 있었다(santa m7 R6
  # Reviewer B). 날짜 형식은 위 행 식별(`$3 ~ /YYYY-MM-DD/` 가 정확히 1행)이 이미 강제한다.
  awk -F'|' -v e="$(head -1 "$TMP/ow.find.enum")" 'index($3, e) { print $3 }' \
    "$TMP/ow.find.rows" > "$TMP/ow.find.statuscell"
  while read -r st; do
    grep -qF -- "$st" "$TMP/ow.find.statuscell" \
      || { printf 'findings/README.md 상태 행에 enum "%s" 가 없다 — 파일 어딘가가 아니라 그 행이 규약이다\n' "$st" >&2; return 1; }
  done < "$TMP/ow.find.enum"

  return 0
}

# findings_structure_ok <필수 항목 목록 파일> <정규화된 발견 파일>
#   항목 존재는 **발견마다** 본다. 파일 어디든 비지 않은 표제가 하나 있으면 통과하는 형태로
#   두면 발견이 2건째부터 항목 누락이 조용히 지나간다 — README 규약은 *"발견 하나가 `## `
#   표제 하나이며 그 아래 필수 6항목을 이 순서 그대로"* 다. 순서와 개수도 같이 본다.
#
#   항목이 **비지 않았는지**만 보면 값 규약이 전혀 강제되지 않는다 — `발견 일시: yesterday` ·
#   `상태: done` 이 통과했다(santa m7 R3 Reviewer B — 실측). 날짜 항목과 상태 항목의 첫 비공백
#   행을 각각 `YYYY-MM-DD` 접두와 PRD 유도 enum 에 묶는다. 어느 항목이 그것인지도, enum 값도
#   인자로 받는다 — 이 함수에 리터럴을 두지 않는다.
findings_structure_ok() {   # <항목 목록> <정규화된 발견 파일> <enum(쉼표구분)> <날짜 항목명> <상태 항목명>
  awk -v enums="$3" -v dateitem="$4" -v statusitem="$5" '
    BEGIN { ne = split(enums, E, ",") }
    function flush(   i, v, j, ok) {
      if (fname == "") return
      if (k != want) {
        printf "발견 \"%s\" 의 `### ` 항목이 %d개다 (%d개여야 한다)\n", fname, k, want > "/dev/stderr"; F = 1; return
      }
      for (i = 1; i <= want; i++)
        if (body[i] == 0) { printf "발견 \"%s\" 의 \"%s\" 항목이 비어 있다\n", fname, item[i] > "/dev/stderr"; F = 1 }
      for (i = 1; i <= want; i++) {
        if (body[i] == 0) continue
        # **접두가 아니라 정확 일치.** 접두로 두면 `2026-08-05 observed-on-stg` 와
        # `사용자 통보 but already fixed` 가 통과한다(santa m7 R4 Reviewer B — 실측).
        # README 는 날짜를 `YYYY-MM-DD`, 상태를 *"셋 중 하나"* 로 선언했고 이 둘은 열거 필드다 —
        # 설명이 붙을 자리는 관찰 사실·심각도이지 enum 칸이 아니다.
        # 열거 필드는 **행도 하나**다. 첫 비공백 행만 보면 그 아래에 설명을 한 줄 더 붙이는
        # 우회가 남는다(santa m7 R5 Reviewer B).
        if (item[i] == dateitem || item[i] == statusitem) {
          if (body[i] != 1) {
            printf "발견 \"%s\" 의 \"%s\" 는 열거 필드다 — 비공백 행이 %d개다 (1개여야 한다)\n", \
              fname, item[i], body[i] > "/dev/stderr"; F = 1
          }
        }
        if (item[i] == dateitem) {
          v = first[i]; gsub(/`/, "", v); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
          if (v !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) {
            printf "발견 \"%s\" 의 \"%s\" 가 YYYY-MM-DD 하나가 아니다: %s\n", \
              fname, item[i], first[i] > "/dev/stderr"; F = 1
          }
        }
        if (item[i] == statusitem) {
          v = first[i]; gsub(/`/, "", v); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); ok = 0
          for (j = 1; j <= ne; j++) if (v == E[j]) ok = 1
          if (!ok) {
            printf "발견 \"%s\" 의 \"%s\" 가 규약 enum(%s) 중 하나가 아니다: %s\n", \
              fname, item[i], enums, first[i] > "/dev/stderr"; F = 1
          }
        }
      }
    }
    NR == FNR { item[FNR] = $0; want = FNR; next }
    # 코드펜스 안의 `## `·`### ` 는 표제가 아니라 **예시**다. 걷지 않으면 발견 하나가
    # 마크다운 조각을 인용하는 것만으로 게이트가 벽돌이 된다(santa m7 R1 Reviewer B — 실측).
    # 다만 **본문으로는 센다** — 재현 절차가 코드 블록 하나뿐인 발견을 "비어 있음" 으로
    # 떨어뜨리면 그 자체가 새 거짓 실패다.
    # **울타리 줄 자체는 내용이 아니다.** 초안은 `~~~` 한 쌍만으로 body 가 2가 되어 빈 코드
    # 블록이 "비어 있지 않은 항목" 으로 통과했다(santa m7 R6 Reviewer B). 라운드 1 이 지키려던
    # 것은 *"본문이 코드 블록뿐인 항목을 비었다고 떨어뜨리지 않는다"* 이고, 그것은 울타리 **안**의
    # 비공백 행을 세는 것으로 그대로 지켜진다.
    /^(```|~~~)/ { infence = !infence; next }
    infence      { if (cur > 0 && $0 !~ /^[[:space:]]*$/) { body[cur]++; if (first[cur] == "") first[cur] = $0 } next }
    /^## /  { flush(); fname = substr($0, 4); k = 0; cur = 0; nfound++; delete body; delete first; next }
    /^### / {
      hdr = substr($0, 5)
      if (fname == "") { printf "`## ` 발견 표제 밖에 `### %s` 가 있다\n", hdr > "/dev/stderr"; F = 1; next }
      k++; cur = k; body[k] = 0; first[k] = ""
      if (k > want) { printf "발견 \"%s\" 의 항목이 %d개를 넘는다: %s\n", fname, want, hdr > "/dev/stderr"; F = 1; next }
      if (hdr != item[k]) {
        printf "발견 \"%s\" 의 %d번째 항목이 \"%s\" 다 (\"%s\" 여야 한다)\n", fname, k, hdr, item[k] > "/dev/stderr"; F = 1
      }
      next
    }
    /^#/ { next }
    cur > 0 && $0 !~ /^[[:space:]]*$/ { body[cur]++; if (first[cur] == "") first[cur] = $0 }
    END {
      flush()
      if (nfound == 0) { printf "`## ` 발견이 0건이다\n" > "/dev/stderr"; F = 1 }
      if (F) exit 1
    }
  ' "$1" "$2"
}

# ─────────────────────────────────────────────────────────────────────────────
# fixtask 게이트 — 관계 축 계약 M8 이 상시 층으로 올린 검사 5종.
#
# 왜 상시 층인가: 같은 축(투영 검사가 부분문자열만 본다 · 트리 대조가 설명 열을 안 본다 ·
# 소절 배치를 파일 전역 계수로 판정한다)이 M6 `#1`·`#2`·`#6` 과 M7 `#2`·`#8` 에서 세 번
# 재제기됐다. 그 검사들은 전부 **동결된 계획서 Validation 블록** 안이라 고칠 수 없고
# (`plan_hash` 가 receipt 에 결박돼 있다), 계획서별 일회용 검사가 매번 같은 사각을
# 재생산하는 것이 원인이었다. 여기로 올리면 재생산이 멈춘다.
#
# 배선 규약(§findings_structure_ok 와 같다): 순수 함수로 뽑고 `--check` 와 `--selftest`
# 양쪽에서 부른다. `--selftest` 전용으로 두면 사람들이 돌리는 `--check` 가 그 불변식을
# 안 보게 된다.
#
# 상시검사 #5 · #6 · #7 · #11 · #12 — 이 주석 표식이 `#2` 의 "명명된 검사 실재" 판정 입력이다.
# ─────────────────────────────────────────────────────────────────────────────

FIXTASK_TOOL="$ROOT/docs/_meta/verify/fixtask-projection.mjs"
FIXTASK_ENUM="$ROOT/docs/_meta/verify/fixtask-enumerate.mjs"
FIXTASK_MAP="$ROOT/docs/_meta/verify/fixtask-replacement-map.yaml"
FIXTASK_LINEAGE="$ROOT/docs/_meta/verify/fixtask-lineage.yaml"
BACKLOG_LEDGER="$ROOT/.claude/plans/codex-findings-backlog.md"
FIXTASK_FIX="$ROOT/docs/_meta/verify/fixtures/fixtask"

# cr_count <파일> — CR 바이트 수. **gawk 로 재지 않는다.**
#   이 플랫폼 gawk 5.4.0 은 레코드 분리 단계에서 CR 을 소비해 `$0` 에 넣지 않는다. 정규식이든
#   `index()` 든 같은 이유로 못 본다(M8 §GROUND 관측 1 실측). grep·sed·tr 은 정상이다.
#   **전역 `BINMODE=r` 로 고치지 않는다** — 이 스크립트의 다른 awk 파서들은 gawk 가 CR 을
#   벗겨 주기 때문에 CRLF 입력에서 지금 옳게 동작한다. 전역 적용은 그것들을 한꺼번에 깬다.
cr_count() {
  local total plain
  total=$(wc -c < "$1")
  plain=$(tr -d '\r' < "$1" | wc -c)
  printf '%s\n' "$((total - plain))"
}

# eol_paths_agree <파일> — 선택 경로(바이트 계수)와 독립 두 경로가 합치하는지.
#   출력: `byte=<n> binmode=<n> grep=<n> gawk_default=<m>`
#   불변식은 **앞 셋의 일치** 하나다. gawk 기본값은 진단으로만 보고한다 — 요구하면 gawk 가
#   고쳐지는 순간 이 저장소가 red 가 된다(진단 관측을 버전 스큐 실패로 바꾸는 일이다).
eol_paths_agree() {
  local f="$1" byte bin gr gawk_default
  byte=$(cr_count "$f")
  bin=$(awk -v BINMODE=r '/\r$/' "$f" | wc -l)
  gr=$(grep -c $'\r' "$f" || true)
  gawk_default=$(awk '/\r$/' "$f" | wc -l)
  if [ "$bin" -ne "$byte" ]; then
    printf 'eol_paths_agree: %s — binmode %s != byte %s\n' "$f" "$bin" "$byte" >&2
    return 1
  fi
  if [ "$gr" -ne "$byte" ]; then
    printf 'eol_paths_agree: %s — grep %s != byte %s\n' "$f" "$gr" "$byte" >&2
    return 1
  fi
  printf 'byte=%s binmode=%s grep=%s gawk_default=%s\n' "$byte" "$bin" "$gr" "$gawk_default"
}

# tree_rows_raw <파일> <절 시작 정규식> <절 끝 정규식|""> — 절의 **첫 코드펜스** 원문 행.
#   `tree_paths` 는 `.md` 토큰만 뽑아 경로 집합만 증명한다. 그 사각이 M6 `#1` 이다 —
#   경로가 같고 설명 열만 다른 두 트리를 통과시켰다. 여기서는 행 전문을 그대로 낸다.
tree_rows_raw() {
  local f="$1" s="$2" e="$3"
  tr -d '\r' < "$f" > "$TMP/trr.norm"
  awk -v s="$s" -v e="$e" '
    BEGIN { st = 0; fence = 0; done = 0 }
    {
      l = $0
      if (st == 0) { if (l ~ s) st = 1; next }
      if (e != "" && l ~ e) exit
      if (l ~ /^(```|~~~)/) {
        if (!fence && !done) { fence = 1; next }
        if (fence) { fence = 0; done = 1; next }
        next
      }
      if (fence) print l
    }
  ' "$TMP/trr.norm"
}

# subsection_body_count <파일> <절 시작 정규식> <대상 h3 리터럴>
#   출력: 본문 비공백 행 수, 또는 절 슬라이스 안에서 h3 를 못 찾으면 `MISSING`.
#
#   **본문 범위를 정의한다**(M8 관측 13): 대상 h3 **다음 행**부터 **다음 depth<=3 헤딩 직전**
#   또는 **EOF** 까지이며 **경계 헤딩은 제외**한다. 이 정의가 없으면 헤딩 뒤 비공백 줄을 세는
#   구현이 다음 헤딩을 본문으로 오인해 빈 소절도 통과시킨다 — "본문 비어 있지 않음" 이 검사
#   없는 규범이 된다. 절 끝은 다음 `^## ` 또는 EOF 다(EOF 마지막 소절이 정상 통과해야 한다).
#   울타리 줄 자체는 내용이 아니다(§findings_structure_ok 와 같은 규약).
subsection_body_count() {
  local f="$1" s="$2" h3="$3"
  tr -d '\r' < "$f" > "$TMP/sub.norm"
  awk -v s="$s" -v h3="$h3" '
    BEGIN { st = 0; n = 0; fence = 0; hit = 0 }
    {
      l = $0
      if (l ~ /^(```|~~~)/) { fence = 1 - fence; next }
      if (fence) { if (st == 2 && l !~ /^[ \t]*$/) n++; next }
      if (st == 0) { if (l ~ s) st = 1; next }
      if (st == 1) {
        if (l ~ /^#{1,2} /) { st = 3; next }
        if (l == h3) { st = 2; hit = 1; next }
        next
      }
      if (st == 2) {
        if (l ~ /^#{1,3} [^#]/) { st = 3; next }
        if (l !~ /^[ \t]*$/) n++
        next
      }
    }
    END { if (!hit) print "MISSING"; else print n }
  ' "$TMP/sub.norm"
}

# findings_heading_count <파일> — 코드펜스 밖 `## ` 발견 표제 수.
#   `findings_structure_ok` 의 END 블록이 같은 불변식을 이미 지고 있다(HEAD 실측: 표제 0개
#   파일에서 exit 1). 여기 따로 두는 이유는 그 판정이 6항목 구조 검사 **안에** 묻혀 있어
#   독립적으로 시험되지 않기 때문이다 — 구조 검사를 손보다 END 블록을 잃어도 아무것도
#   떨어지지 않는다. 이 함수가 그 축을 이름 붙은 검사로 분리한다.
findings_heading_count() {
  tr -d '\r' < "$1" > "$TMP/fh.norm"
  awk '
    /^(```|~~~)/ { fence = 1 - fence; next }
    !fence && /^## / { n++ }
    END { print n + 0 }
  ' "$TMP/fh.norm"
}

run_fixtask_gate() {
  local dir proj slug plan n=0 rows want got cnt eol f

  # 상시검사 #5 — 투영 대조. **기본 모드**로 부른다.
  #   `--require-present` 를 상시 경로에 달면 `.claude/state/` 가 gitignore 대상이라
  #   매칭 행을 가진 clean checkout 이 **필연 red** 가 된다(M8 관측 8). 기본 모드는
  #   backlog 에서 재생성한 뒤 디스크 사본이 있을 때만 exact-diff 하고 사전 부재를 오류로
  #   세지 않는다. 상시 검사의 이빨은 추적되는 fixture 와 golden 이 진다.
  [ -f "$FIXTASK_TOOL" ] || { printf 'fixtask 생성기가 없다: %s\n' "$FIXTASK_TOOL" >&2; return 1; }
  [ -f "$BACKLOG_LEDGER" ] || { printf 'backlog 원장이 없다: %s\n' "$BACKLOG_LEDGER" >&2; return 1; }
  dir="${VERIFY_FIXTASK_DIR:-$ROOT/.claude/state/fix-task.d}"
  if [ -d "$dir" ]; then
    for proj in "$dir"/*.md; do
      [ -f "$proj" ] || continue
      slug=$(basename "$proj" .md)
      plan="$ROOT/.claude/plans/$slug.plan.md"
      node "$FIXTASK_TOOL" verify "$BACKLOG_LEDGER" ".claude/plans/$slug.plan.md" "$proj" > /dev/null || return 1
      n=$((n + 1))
    done
  fi
  printf '  ok  fixtask 투영 대조 %d건 (기본 모드 · 사전 부재는 오류가 아니다)\n' "$n" >&2

  # 상시검사 #6 — findings 항목 존재.
  for f in "$FINDINGS"/*.md; do
    [ -f "$f" ] || continue
    [ "$(basename "$f")" != README.md ] || continue
    cnt=$(findings_heading_count "$f")
    [ "$cnt" -ge 1 ] \
      || { printf 'findings/%s 에 `## ` 발견 표제가 0개다 — 기록을 통째로 빼도 통과하면 안 된다\n' \
           "$(basename "$f")" >&2; return 1; }
  done
  printf '  ok  fixtask findings 항목 존재 (전 파일 표제 >=1)\n' >&2

  # 상시검사 #7 — EOL 관측 가능성. CRLF fixture 에서 선택 경로가 바이트 계수와 일치하고 0 이 아니다.
  [ -f "$FIXTASK_FIX/crlf.md" ] || { printf 'CRLF fixture 가 없다: %s\n' "$FIXTASK_FIX/crlf.md" >&2; return 1; }
  eol=$(eol_paths_agree "$FIXTASK_FIX/crlf.md") || return 1
  cnt=$(cr_count "$FIXTASK_FIX/crlf.md")
  [ "$cnt" -gt 0 ] \
    || { printf 'CRLF fixture 의 CR 이 0 이다 — checkout 정규화가 LF 로 바꿨다 (.gitattributes -text 확인)\n' >&2; return 1; }
  printf '  ok  fixtask EOL 관측 가능성 (%s)\n' "$eol" >&2

  # 상시검사 #11 — 트리 행 **raw 대조**. 설명 열까지 본다 (M6 `#1` 대체).
  tree_rows_raw "$INDEX" '^## 문서 트리$' '^## 상태 범례$' > "$TMP/ft.idx"
  tree_rows_raw "$SPEC"  '^## 4[.] 확정 docs 구조$' '^## 5[.]' > "$TMP/ft.spec"
  [ -s "$TMP/ft.idx" ] || { printf '00-index.md §문서 트리에서 raw 행을 0건 뽑았다\n' >&2; return 1; }
  [ -s "$TMP/ft.spec" ] || { printf '05-final-spec.md §4 에서 raw 행을 0건 뽑았다\n' >&2; return 1; }
  if ! diff -u "$TMP/ft.spec" "$TMP/ft.idx" > "$TMP/ft.diff"; then
    printf '트리 행 raw 대조 실패 — 경로가 같아도 설명 열이 다르면 여기서 떨어진다:\n' >&2
    head -40 "$TMP/ft.diff" >&2
    return 1
  fi
  rows=$(wc -l < "$TMP/ft.idx")
  printf '  ok  fixtask 트리 행 raw 대조 %s행 (설명 열 포함)\n' "$rows" >&2

  # 상시검사 #12 — **정본** §4 슬라이스 안의 소절 배치 + 정의된 본문 범위 (M7 `#2` 대체).
  #   정본은 `.claude/meta/05-final-spec.md` 의 h3 이고 `docs/00-index.md` 의 `## 흐름으로 읽기`
  #   는 **사본**이다(M8 관측 9). 파일 전역 `grep -c` 는 쓰지 않는다.
  cnt=$(subsection_body_count "$SPEC" '^## 4[.] 확정 docs 구조$' '### 읽기 축 2 — 흐름 진입')
  [ "$cnt" != MISSING ] \
    || { printf '05-final-spec.md §4 슬라이스 안에 `### 읽기 축 2 — 흐름 진입` 이 없다\n' >&2; return 1; }
  [ "$cnt" -gt 0 ] \
    || { printf '05-final-spec.md `### 읽기 축 2 — 흐름 진입` 의 본문이 0행이다 (범위: 다음 행 ~ 다음 depth<=3 헤딩 직전 또는 EOF)\n' >&2; return 1; }
  printf '  ok  fixtask 소절 배치 (정본 §4 슬라이스 내부 · 본문 %s행)\n' "$cnt" >&2

  return 0
}

# selftest_fixtask — 상시검사 5종을 결함 fixture 로 **양방향** 검사한다.
#   정상이 통과하고 결함이 떨어지는 것을 둘 다 본다. 한쪽만 보면 비교기가 죽어도 모른다.
selftest_fixtask() {
  local d="$FIXTASK_FIX" got

  printf '\n── fixtask 상시검사 ──\n'

  # 투영 — 정상 1 · 변이 3 · 부재(--require-present) 1
  expect_fn "fixtask 투영 정상" 0 "" -- \
    node "$FIXTASK_TOOL" verify --require-present "$d/backlog.ok.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/projection.expected.md"
  for mut in drop severity merged; do
    expect_fn "fixtask 투영 변이 $mut" 1 "기대(재생성)" -- \
      node "$FIXTASK_TOOL" verify --require-present "$d/backlog.ok.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/projection.$mut.md"
  done
  expect_fn "fixtask 투영 부재 (--require-present)" 1 "투영이 없다" -- \
    node "$FIXTASK_TOOL" verify --require-present "$d/backlog.ok.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/projection.absent.md"
  expect_fn "fixtask 투영 부재 (기본 모드)" 0 "" -- \
    node "$FIXTASK_TOOL" verify "$d/backlog.ok.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/projection.absent.md"

  # 동결 매핑 파서 — 엄격 문법 양방향
  expect_fn "fixtask 매핑 정상" 0 "" -- node "$FIXTASK_TOOL" read-map "$d/map.ok.yaml"
  expect_fn "fixtask 매핑 중복 키" 1 "중복 키" -- node "$FIXTASK_TOOL" read-map "$d/map.dupkey.yaml"
  expect_fn "fixtask 매핑 따옴표 없는 값" 1 "문법 위반" -- node "$FIXTASK_TOOL" read-map "$d/map.unquoted.yaml"

  # findings 항목 존재 — 빈 파일은 0, 실파일은 >=1
  got=$(findings_heading_count "$d/findings.empty.md")
  if [ "$got" -eq 0 ]; then
    printf '  ok  fixtask findings 빈 파일 표제 0개\n'
  else
    st_fail "fixtask findings 빈 파일" "표제가 ${got}개다 (0이어야 한다 — 코드펜스 안 표제를 셌을 수 있다)"
  fi
  got=$(findings_heading_count "$FINDINGS/00-protocol.md")
  if [ "$got" -ge 1 ]; then
    printf '  ok  fixtask findings 실파일 표제 %s개\n' "$got"
  else
    st_fail "fixtask findings 실파일" "표제가 0개다"
  fi

  # EOL — CRLF fixture 는 합치하고 0 이 아니다. LF 로 정규화된 사본은 0 이다.
  got=$(cr_count "$d/crlf.md")
  if [ "$got" -gt 0 ]; then
    printf '  ok  fixtask CRLF fixture CR=%s\n' "$got"
  else
    st_fail "fixtask CRLF fixture" "CR 이 0 이다 — checkout 정규화 (.gitattributes -text 확인)"
  fi
  tr -d '\r' < "$d/crlf.md" > "$TMP/st.lf.md"
  got=$(cr_count "$TMP/st.lf.md")
  if [ "$got" -eq 0 ]; then
    printf '  ok  fixtask LF 사본 CR=0 (측정이 상수를 내지 않는다)\n'
  else
    st_fail "fixtask LF 사본" "CR 이 ${got} 이다"
  fi
  expect_fn "fixtask EOL 3경로 합치" 0 "" -- eol_paths_agree "$d/crlf.md"

  # 트리 행 raw — 경로는 같고 설명 열만 다른 fixture 가 떨어진다
  tree_rows_raw "$d/tree-desc-drift.md" '^## T정본$' '^## T사본$' > "$TMP/st.tree.a"
  tree_rows_raw "$d/tree-desc-drift.md" '^## T사본$' '^## T끝$'  > "$TMP/st.tree.b"
  if [ ! -s "$TMP/st.tree.a" ] || [ ! -s "$TMP/st.tree.b" ]; then
    st_fail "fixtask 트리 drift fixture" "한쪽에서 raw 행을 0건 뽑았다"
  elif diff -q "$TMP/st.tree.a" "$TMP/st.tree.b" > /dev/null; then
    st_fail "fixtask 트리 설명 열 드리프트" "설명 열이 다른데 raw 비교기가 같다고 했다"
  else
    sed -nE 's/^[[:space:]]*([A-Za-z0-9._/-]+\.md).*$/\1/p' "$TMP/st.tree.a" | sort > "$TMP/st.tree.pa"
    sed -nE 's/^[[:space:]]*([A-Za-z0-9._/-]+\.md).*$/\1/p' "$TMP/st.tree.b" | sort > "$TMP/st.tree.pb"
    if diff -q "$TMP/st.tree.pa" "$TMP/st.tree.pb" > /dev/null; then
      printf '  ok  fixtask 트리 설명 열 드리프트 (경로 집합은 동일 — `.md` 토큰 비교기는 통과했다)\n'
    else
      st_fail "fixtask 트리 drift fixture" "경로 집합까지 달라 이 fixture 가 M6 #1 사각을 재현하지 못한다"
    fi
  fi

  # 소절 배치 — 오배치·빈 본문은 떨어지고 EOF 마지막 소절은 통과한다
  got=$(subsection_body_count "$d/spec-subsection-misplaced.md" '^## 4[.] 확정 docs 구조$' '### 읽기 축 2 — 흐름 진입')
  if [ "$got" = MISSING ]; then
    printf '  ok  fixtask 소절 오배치 (§4 슬라이스 밖)\n'
  else
    st_fail "fixtask 소절 오배치" "본문 ${got}행으로 통과했다 — 헤딩이 §4 밖인데 파일 전역을 봤다"
  fi
  got=$(subsection_body_count "$d/spec-subsection-empty.md" '^## 4[.] 확정 docs 구조$' '### 읽기 축 2 — 흐름 진입')
  if [ "$got" = "0" ]; then
    printf '  ok  fixtask 소절 빈 본문 (본문 0행)\n'
  else
    st_fail "fixtask 소절 빈 본문" "본문 ${got}행 — 다음 헤딩을 본문으로 셌다"
  fi
  got=$(subsection_body_count "$d/spec-subsection-eof.md" '^## 4[.] 확정 docs 구조$' '### 읽기 축 2 — 흐름 진입')
  if [ "$got" != MISSING ] && [ "$got" -gt 0 ]; then
    printf '  ok  fixtask 소절 EOF 마지막 (본문 %s행)\n' "$got"
  else
    st_fail "fixtask 소절 EOF 마지막" "본문 ${got} — 경계 처리를 EOF 쪽으로만 조였다"
  fi

  # 독립 열거 — 정상 통과 · common-mode 필드 훼손은 필드별 대조에서만 떨어진다
  expect_fn "fixtask 독립 열거 정상" 0 "" -- \
    node "$FIXTASK_ENUM" compare "$d/backlog.ok.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/projection.expected.md"
  expect_fn "fixtask 독립 열거 common-mode" 1 "severity 불일치" -- \
    node "$FIXTASK_ENUM" compare "$d/common-mode/backlog.md" '.claude/plans/oracle-fixture-m8.plan.md' "$d/common-mode/projection.md"
}

# ─────────────────────────────────────────────────────────────────────────────
# lint 커버리지 게이트 — 문서 PRD 가 소유한 절이 lint glob 밖으로 나가지 않았는지.
#
# **--bootstrap 과 무관하게 항상 돈다.** 감시 집합을 lint glob 에서 유도하는 것만으로는
# 커버리지 소실이 자기 승인된다 — `docs/30-admin/*.md` 를 지워도 50-domain 은 나머지 두 경로로
# 감시에 남아 아무것도 실패하지 않고, admin 문서만 조용히 lint 밖으로 나간다. 그래서 반대
# 방향으로도 묶는다: 문서 PRD 경로에서 **PRD 가 선언한 예외**를 뺀 전부가 glob 에 덮여야 한다.
# ─────────────────────────────────────────────────────────────────────────────

run_lint_coverage_gate() {
  local prd path dir n

  doc_prd_paths > "$TMP/cov.paths" \
    || { printf 'ownership.md 에서 문서 PRD 소유 경로를 유도하지 못했다\n' >&2; return 1; }
  [ -s "$TMP/cov.paths" ] \
    || { printf 'ownership.md 에서 docs/<번호절>/** 모양의 문서 PRD 경로를 0건 유도했다 — 감시가 사라진 것이지 통과가 아니다\n' >&2; return 1; }
  lint_glob_exceptions > "$TMP/cov.exceptions" \
    || { printf 'PRD §lint 전수 게이트에서 glob 예외 선언을 찾지 못했다\n' >&2; return 1; }
  [ -s "$TMP/cov.exceptions" ] \
    || { printf 'PRD §lint 전수 게이트의 glob 예외 선언이 비어 있다\n' >&2; return 1; }

  : > "$TMP/cov.uncovered"
  while IFS='|' read -r prd path; do
    if grep -qxF -- "$path" "$TMP/cov.exceptions"; then continue; fi
    dir="${path%\*\*}"
    if ! lint_glob_covers "$dir"; then printf '%s  %s\n' "$prd" "$path" >> "$TMP/cov.uncovered"; fi
  done < "$TMP/cov.paths"

  if [ -s "$TMP/cov.uncovered" ]; then
    printf 'lint 커버리지 소실 — 문서 PRD 가 소유한 절이 lint glob 에 덮이지 않는다:\n' >&2
    sed 's/^/  /' "$TMP/cov.uncovered" >&2
    printf '  예외로 허용되는 것은 PRD §lint 전수 게이트가 선언한 경로뿐이다:\n' >&2
    sed 's/^/    /' "$TMP/cov.exceptions" >&2
    return 1
  fi

  n=$(wc -l < "$TMP/cov.paths")
  printf '  ok  lint 커버리지 (문서 PRD 경로 %s건 = glob 피복 + 선언 예외)\n' "$n" >&2
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# bootstrap_permitted — `--bootstrap` 의 **만료 조건**.
#
# 인자 파서에는 어떤 단계 조건도 없었다. 서술만 "첫 문서 PRD 가 산출물을 낼 때까지" 로 고치면
# 그 문장이 곧 무기한 면제의 근거가 된다 — 하류가 착수하고도 문서를 0건 만들거나 잘못된
# 디렉터리에 만들면 같은 명령이 계속 초록이고, 이 저장소에 CI 가 없어 잡을 다른 지점이 없다.
# 만료 조건 없는 면제는 게이트가 아니다.
#
# 감시 집합은 lint glob 과 **교집합**을 갖는 문서 PRD 행에서 유도한다. 번호 디렉터리 모양만
# 보면 60-history-deprecated 가 들어오는데 그 소유 경로는 우산 제약 #7 예외로 glob 밖이라,
# 60 이 먼저 착수하는 순간 --check 와 --update 가 둘 다 영구 실패한다 — 우산이 허용한 정상
# 작업 순서가 검증 경로를 벽돌로 만든다.
#
# 읽지 못한 상태를 "전부 DRAFT" 로 뭉개지 않는다. 파일명이 바뀌는 순간 면제가 영구화된다.
# ─────────────────────────────────────────────────────────────────────────────

bootstrap_permitted() {
  local prd path dir st n n_st

  [ -f "$OWNERSHIP" ] || { printf 'ownership.md 가 없어 --bootstrap 만료 조건을 판정할 수 없다: %s\n' "$OWNERSHIP" >&2; return 1; }
  doc_prd_paths > "$TMP/bs.paths" \
    || { printf 'ownership.md 에서 문서 PRD 소유 경로를 유도하지 못했다\n' >&2; return 1; }
  lint_glob_exceptions > "$TMP/bs.exceptions" \
    || { printf 'PRD §lint 전수 게이트에서 glob 예외 선언을 찾지 못했다\n' >&2; return 1; }

  : > "$TMP/bs.watched"
  while IFS='|' read -r prd path; do
    if grep -qxF -- "$path" "$TMP/bs.exceptions"; then continue; fi
    dir="${path%\*\*}"
    if lint_glob_covers "$dir"; then printf '%s\n' "$prd" >> "$TMP/bs.watched"; fi
  done < "$TMP/bs.paths"

  sort -u "$TMP/bs.watched" > "$TMP/bs.watched.u"
  n=$(wc -l < "$TMP/bs.watched.u")
  # 4 를 코드에 적지 않는다 — "lint glob 과 교집합을 갖는 문서 PRD 행 수" 로 유도하고,
  # 교집합이 비면 감시가 사라진 것이지 통과가 아니다.
  [ "$n" -gt 0 ] \
    || { printf '%s\n' '--bootstrap 감시 집합이 비었다 — lint glob 과 교집합을 갖는 문서 PRD 행이 0건이다' >&2; return 1; }

  : > "$TMP/bs.nondraft"
  while read -r prd; do
    bs_prd=$(prd_path "$prd") \
      || { printf '감시 대상 PRD 파일이 없다: %s (archived/ 포함해 찾았다) — 읽지 못한 상태를 DRAFT 로 뭉개지 않는다\n' "$prd" >&2; return 1; }
    # `*Status:` 행은 **정확히 1개**여야 한다. 초안은 전부 모은 뒤 `head -1` 로 첫 줄을
    # 채택했는데, `*Status: DRAFT` 아래 `*Status: complete` 를 한 줄 더 넣으면 DRAFT 가
    # 굳어 --bootstrap 이 계속 허용됐다(santa m7 R1 Reviewer B — 실측). 0개를 거부하는
    # 이유("읽지 못한 상태를 DRAFT 로 뭉개지 않는다")가 2개 이상에도 그대로 선다 — 어느
    # 것이 상태인지 말할 수 없는 것은 못 읽은 것과 같다. 이 스크립트가 `repos:`·
    # template-config 키·`## Success Metrics` 표제에 이미 거는 것과 같은 유일성 계약이다.
    # 유일성을 세운 뒤에는 `head -1` 이 필요 없다 — 파이프도 함께 사라진다.
    tr -d '\r' < "$bs_prd" | grep -E '^\*Status:' > "$TMP/bs.status" || true
    n_st=$(wc -l < "$TMP/bs.status")
    [ "$n_st" -eq 1 ] \
      || { printf '%s 에 *Status: 행이 %s개다 (정확히 1개여야 한다) — 읽지 못한 상태도 모호한 상태도 DRAFT 로 뭉개지 않는다\n' "$prd" "$n_st" >&2; return 1; }
    st=$(sed -nE 's/^\*Status:[[:space:]]*([A-Za-z-]+).*/\1/p' "$TMP/bs.status")
    [ -n "$st" ] \
      || { printf '%s 의 *Status: 행에서 상태값을 읽지 못했다\n' "$prd" >&2; return 1; }
    case "$st" in
      DRAFT|Draft|draft) ;;
      *) printf '%s (%s)\n' "$prd" "$st" >> "$TMP/bs.nondraft" ;;
    esac
  done < "$TMP/bs.watched.u"

  if [ -s "$TMP/bs.nondraft" ]; then
    printf '%s\n' '--bootstrap 은 lint 대상을 소유한 문서 PRD 가 전부 DRAFT 인 동안만 유효하다.' >&2
    printf '  이미 착수한 PRD:\n' >&2
    sed 's/^/    /' "$TMP/bs.nondraft" >&2
    printf '  이제부터는 --bootstrap 없이 돌린다 — lint 대상이 0건이면 그것이 결함이다.\n' >&2
    return 1
  fi

  printf '  ok  --bootstrap 허용 (감시 문서 PRD %s종 전부 DRAFT)\n' "$n" >&2
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# validate_candidate — golden 상태와 **무관한** 전 검증.
#
# 초안은 --update 가 "--check 전 검증"을 돌리게 했는데, --check 안에 run1 vs golden 대조가
# 들어 있어 golden 이 없거나 정당하게 낡았을 때 최초 생성도 갱신도 불가능해진다. golden 에
# 의존하는 부분만 떼어내 모드별로 붙인다.
# ─────────────────────────────────────────────────────────────────────────────

validate_candidate() {
  local bootstrap="$1"

  gen_report "$TMP/run1" || die '리포트 생성 실패 (1회차) — exclude 정합 MISMATCH 는 위 stderr 참조'
  gen_report "$TMP/run2" || die '리포트 생성 실패 (2회차)'

  if ! diff -u "$TMP/run1" "$TMP/run2" > "$TMP/determinism.diff"; then
    printf '결정성 실패 — 같은 입력으로 2회 실행한 리포트가 다르다:\n' >&2
    cat "$TMP/determinism.diff" >&2
    return 1
  fi
  printf '  ok  결정성 (2회 실행 diff 빈 출력)\n' >&2

  node "$SCREEN_EXTRACTOR"   --selftest > /dev/null || { printf 'extract-screen-codes.mjs --selftest 실패\n'   >&2; return 1; }
  node "$MENU_EXTRACTOR"     --selftest > /dev/null || { printf 'extract-menu-codes.mjs --selftest 실패\n'     >&2; return 1; }
  node "$DISPUTED_EXTRACTOR" --selftest > /dev/null || { printf 'extract-disputed-counts.mjs --selftest 실패\n' >&2; return 1; }
  printf '  ok  추출기 selftest 3종\n' >&2

  # md 파서 불변식도 여기서 돈다. 추출기 셋은 이미 이 자리에서 도는데 셸 쪽 파서만 `--selftest`
  # 전용으로 두면, 정작 사람들이 돌리는 `--check` 가 파서 불변식을 안 보게 된다(santa m7 R6).
  # manifest fixture 쪽은 스크립트를 재호출해 여기 넣지 않는다 — 그쪽은 `--selftest` 전용이다.
  SELFTEST_FAILED=0
  selftest_md_parsers > /dev/null || { printf 'md 파서 selftest 실행 실패\n' >&2; return 1; }
  [ "$SELFTEST_FAILED" -eq 0 ] \
    || { printf 'md 파서 selftest %d건 실패 (위 stderr 참조)\n' "$SELFTEST_FAILED" >&2; return 1; }
  printf '  ok  md 파서 selftest (tree_paths · md_section · md_table · prd_* · findings · overlap)\n' >&2

  # ia 축 계약 불변식도 같은 자리에서 돈다. `--selftest` 전용으로 두면 사람들이 돌리는
  # `--check` 가 그 불변식을 안 보게 된다 — md 파서 쪽과 같은 이유다(santa m7 R6).
  # 재귀는 없다: selftest_ia_gate 는 oq_ia_gate 만 몰고 validate_candidate 를 다시 부르지
  # 않는다. do_update 가 validate_candidate 를 먼저 돌리는 구조라 이 구분이 중요하다.
  SELFTEST_FAILED=0
  selftest_ia_gate > /dev/null || { printf 'ia 게이트 selftest 실행 실패\n' >&2; return 1; }
  [ "$SELFTEST_FAILED" -eq 0 ] \
    || { printf 'ia 게이트 selftest %d건 실패 (위 stderr 참조)\n' "$SELFTEST_FAILED" >&2; return 1; }
  printf '  ok  ia 게이트 selftest (축 OR 양방향 · 온보딩 축 대조 · 앵커 2종 digest 선언 대조 · 가리킴 결속 15종)\n' >&2

  run_lint_gate "$bootstrap" || return 1
  printf '  ok  lint 전수 게이트\n' >&2

  run_template_config_gate || return 1
  printf '  ok  template-config 게이트 (예시 3종 lint + 정본 교차 대조 3종)\n' >&2

  # --check 와 --update 가 같은 함수를 타므로 배선은 이 한 곳이면 끝난다 — 그래서 PRD 6행이
  # complete 가 되는 순간 --update 경로도 같이 막힌다(미승인 상태를 기준선으로 축성 불가).
  run_onboarding_gate || return 1
  printf '  ok  onboarding 게이트 (경계 + 형태·src결속 + 규모 + owner 5종·하류참조 + service 3종 + ia 판정·근거 2자리 + 승인 상태)\n' >&2

  run_index_gate || return 1
  printf '  ok  00-index 게이트 (트리↔spec §4 전 경로 + 자기참조 1회 + 절 순서 + status 범례↔spec §3 + 폐기 링크)\n' >&2

  run_ownership_gate || return 1
  # 메시지에서 **수를 뺀다**. 이전 문면은 `PRD 9행 결속` 을 리터럴로 찍었는데 M1 이 10번째 행을
  # 넣어 golden `ownership.prd_count` 는 10 이 됐고 메시지만 낡았다 — 계수를 골든에서 읽어 찍는
  # 것보다 메시지가 수를 자칭하지 않게 하는 쪽이 같은 결함의 재발을 막는다(INV4).
  printf '  ok  ownership 게이트 (PRD 전 행 결속 + 초과분↔선언·근거·범위한정·겹침 + 무손실 승계 양방향 + 공유자산 전 행 + 스크래치 목록 + findings 개설·항목 순서·값 규약)\n' >&2

  # --bootstrap 과 무관하게 항상 돈다. 감시 집합을 lint glob 에서 유도하는 것만으로는
  # glob 삭제가 자기 승인되므로 반대 방향(문서 PRD 경로 ⊆ glob 피복 + 선언 예외)도 묶는다.
  run_lint_coverage_gate || return 1

  # fixtask 게이트 — M8 이 상시 층으로 올린 검사 5종. `--check` 와 `--update` 가 같은 함수를
  # 타므로 배선은 이 한 곳이면 끝난다.
  run_fixtask_gate || return 1

  # fixtask 결함 fixture 도 여기서 돈다. `--selftest` 전용으로 두면 사람들이 돌리는
  # `--check` 가 그 불변식을 안 보게 된다 — md 파서·ia 게이트 쪽과 같은 이유다(santa m7 R6).
  SELFTEST_FAILED=0
  selftest_fixtask > /dev/null || { printf 'fixtask selftest 실행 실패\n' >&2; return 1; }
  [ "$SELFTEST_FAILED" -eq 0 ] \
    || { printf 'fixtask selftest %d건 실패 (위 stderr 참조)\n' "$SELFTEST_FAILED" >&2; return 1; }
  # 내역에 건수를 적지 않는다. 위 ownership 메시지와 같은 이유로 — 케이스가 늘면 리터럴만
  # 낡아 거짓을 찍는다(INV4). 실측: 투영 케이스는 6인데 이 행이 5를 주장하고 있었다.
  printf '  ok  fixtask selftest (투영 · 매핑 · findings · EOL · 트리 · 소절 · 독립열거)\n' >&2

  return 0
}

do_check() {
  local bootstrap="$1"
  validate_candidate "$bootstrap" || exit 1

  [ -f "$GOLDEN" ] || die "golden.txt 가 없다: $GOLDEN — bash docs/_meta/verify/verify.sh --update 로 생성한다"

  # 헤더는 위치(1~3행)와 내용이 둘 다 고정이다
  {
    printf '%s\n' "$GOLDEN_HEADER_1"
    printf '%s\n' "$GOLDEN_HEADER_2"
    printf '%s\n' "$GOLDEN_HEADER_3"
  } > "$TMP/header.want"
  head -3 "$GOLDEN" > "$TMP/header.got"
  if ! diff -u "$TMP/header.want" "$TMP/header.got" > "$TMP/header.diff"; then
    printf 'golden.txt 헤더 불일치 — 1~3행은 고정 문자열이다:\n' >&2
    cat "$TMP/header.diff" >&2
    exit 1
  fi

  tail -n +4 "$GOLDEN" > "$TMP/golden.payload"
  if ! diff -u "$TMP/golden.payload" "$TMP/run1" > "$TMP/drift.diff"; then
    printf 'golden 드리프트 — 재현한 리포트가 동결본과 다르다:\n' >&2
    cat "$TMP/drift.diff" >&2
    printf '\n값 변경이 정당하면 bash docs/_meta/verify/verify.sh --update 로 기준선을 갱신한다.\n' >&2
    exit 1
  fi

  printf '  ok  golden 대조 (헤더 + 페이로드)\n' >&2
  printf 'check PASS\n'
}

# ─────────────────────────────────────────────────────────────────────────────
# --dump-exclude-inputs <dir> — 분류 4종을 지정 디렉터리로 복사하고 끝낸다. 값을 내지 않는다.
#
# 왜 있는가: `--exclude-digest` 물림을 verify.sh **밖에서** 검사하려면 분류 파일이 필요한데,
# $TMP 는 verify.sh 내부 경로이고 trap 으로 지워져 "그 안을 고쳐 보라"는 절차는 실행할 수 없다.
# 값을 산출하지 않으므로 리포트·golden 경로에 영향이 없다.
# ─────────────────────────────────────────────────────────────────────────────

do_dump() {
  local dir="$1"
  [ -n "$dir" ] || usage "--dump-exclude-inputs 에 디렉터리 인자가 필요하다."
  mkdir -p "$dir" || die "디렉터리를 만들 수 없다: $dir"

  normalize_manifest
  parse_manifest_lists
  parse_repos
  classify_pathspec
  check_parity
  [ "$(cat "$TMP/parity")" = OK ] || die 'exclude 정합 MISMATCH — 분류를 덤프하지 않는다 (위 stderr 참조)'

  cp "$TMP/wide" "$TMP/anchored" "$TMP/ext" "$TMP/repos.raw" "$dir/"
  printf 'dump OK — %s (wide anchored ext repos.raw)\n' "$dir"
}

do_update() {
  local bootstrap="$1"
  validate_candidate "$bootstrap" || exit 1
  GOLDEN_TMP=$(mktemp "$GOLDEN.new.XXXXXX")
  # 구 golden 은 읽지 않는다 — 읽으면 최초 생성과 정당한 갱신이 둘 다 막힌다.
  {
    printf '%s\n' "$GOLDEN_HEADER_1"
    printf '%s\n' "$GOLDEN_HEADER_2"
    printf '%s\n' "$GOLDEN_HEADER_3"
    cat "$TMP/run1"
  } > "$GOLDEN_TMP"
  mv "$GOLDEN_TMP" "$GOLDEN"
  GOLDEN_TMP=""
  printf 'update OK — %s (%s행)\n' "$GOLDEN" "$(wc -l < "$GOLDEN")"
}

# ─────────────────────────────────────────────────────────────────────────────
# --selftest — 파서 불변식 5종을 결함 fixture 로 양방향 검사.
#
# fixture 는 (a) exit 1, (b) 기대 토큰 존재, (c) **나머지 4개 토큰 부재** 를 전부 단언한다.
# "무언가 실패했다"만 보면 fixture 가 엉뚱한 이유로 통과해 미탐이 숨는다.
# ─────────────────────────────────────────────────────────────────────────────

ALL_TOKENS="KEY_UNIQUE BOUNDARY ITEM_SHAPE NONEMPTY NO_RESIDUAL"
SELFTEST_FAILED=0

st_fail() { printf 'FAIL %s: %s\n' "$1" "$2" >&2; SELFTEST_FAILED=$((SELFTEST_FAILED + 1)); }

# expect_die <라벨> <fixture 경로> <stderr 부분 문자열>
#   불변식 토큰이 없는 실패(repos 파싱 등)를 검사한다. 종료 코드만 보면 "무언가 실패했다"만
#   확인되므로 사유 문자열까지 대조한다.
expect_die() {
  local label="$1" fixture="$2" want="$3" rc=0 err="$TMP/st.err"
  VERIFY_MANIFEST="$fixture" bash "$0" > /dev/null 2> "$err" || rc=$?
  if [ "$rc" -eq 0 ]; then
    st_fail "$label" "결함 fixture 인데 exit 0"
    return
  fi
  if ! grep -q "$want" "$err"; then
    st_fail "$label" "기대 사유 \"$want\" 없음 — stderr: $(head -2 "$err" | tr '\n' '|')"
    return
  fi
  printf '  ok  %s\n' "$label"
}

# expect_token <라벨> <fixture 경로> <기대 토큰|"">
expect_token() {
  local label="$1" fixture="$2" want="$3" rc=0 err="$TMP/st.err" tok
  VERIFY_MANIFEST="$fixture" bash "$0" > /dev/null 2> "$err" || rc=$?

  if [ -z "$want" ]; then
    if [ "$rc" -ne 0 ]; then
      st_fail "$label" "정상 manifest 인데 exit $rc — stderr: $(head -2 "$err" | tr '\n' '|')"
      return
    fi
    for tok in $ALL_TOKENS; do
      grep -q "VERIFY_INV_$tok" "$err" && { st_fail "$label" "정상 manifest 인데 토큰 VERIFY_INV_$tok 이 떴다"; return; }
    done
    printf '  ok  %s\n' "$label"
    return
  fi

  if [ "$rc" -eq 0 ]; then
    st_fail "$label" "결함 fixture 인데 exit 0 — VERIFY_INV_$want 가 걸려야 한다"
    return
  fi
  if ! grep -q "VERIFY_INV_$want" "$err"; then
    st_fail "$label" "기대 토큰 VERIFY_INV_$want 없음 — stderr: $(head -2 "$err" | tr '\n' '|')"
    return
  fi
  for tok in $ALL_TOKENS; do
    [ "$tok" = "$want" ] && continue
    if grep -q "VERIFY_INV_$tok" "$err"; then
      st_fail "$label" "기대 토큰 외 VERIFY_INV_$tok 도 떴다 — fixture 가 엉뚱한 이유로 통과할 수 있다"
      return
    fi
  done
  printf '  ok  %s (VERIFY_INV_%s)\n' "$label" "$want"
}

# expect_fn <라벨> <기대 exit> <기대 stderr 부분 문자열|""> -- <명령…>
#   md 파서·게이트 헬퍼용. manifest fixture 처럼 스크립트를 통째로 다시 부르지 않고 함수를
#   그 자리에서 돌린다 — 재호출 1회가 수십 초라(Windows) 이 규모의 사례를 그 방식으로 둘 수 없다.
#   stdout 은 버리지 않고 파일에 받는다: **실패했는데 출력이 남아 있는 것**이 이 파일에서 가장
#   비싼 결함(호출부의 `[ -s ]` 가드가 통과한다)이라 그것까지 본다.
expect_fn() {
  local label="$1" want_rc="$2" want_err="$3"; shift 3
  [ "${1:-}" = -- ] && shift
  local rc=0 err="$TMP/st.fnerr" out="$TMP/st.fnout"
  "$@" > "$out" 2> "$err" || rc=$?
  if [ "$rc" -ne "$want_rc" ]; then
    st_fail "$label" "exit $rc (기대 $want_rc) — stderr: $(head -2 "$err" | tr '\n' '|')"
    return
  fi
  if [ -n "$want_err" ] && ! grep -qF -- "$want_err" "$err"; then
    st_fail "$label" "기대 사유 \"$want_err\" 없음 — stderr: $(head -2 "$err" | tr '\n' '|')"
    return
  fi
  if [ "$want_rc" -ne 0 ] && [ -s "$out" ]; then
    st_fail "$label" "실패인데 stdout 이 비지 않았다 — 호출부의 0건 가드가 통과한다"
    return
  fi
  printf '  ok  %s\n' "$label"
}

# ─────────────────────────────────────────────────────────────────────────────
# md 파서 selftest — 마일스톤 7 의 santa 라운드 3~5 가 **세 라운드 연속** 결함을 낸 자리다.
#
# 반증은 그때마다 일회용 스크립트로 했고 그 스크립트는 저장소에 없다. 여기 fixture 로 옮겨
# 회귀를 막는다. 사례는 실제로 깨졌던 것만 둔다 — 가정이 아니라 실측 이력이다.
# ─────────────────────────────────────────────────────────────────────────────


# ─────────────────────────────────────────────────────────────────────────────
# ia 게이트 selftest — 음성 14 + 정상 1.
#
# **사례는 전부 `oq_ia_gate` 자신을 몬다.** digest 헬퍼만 몰면 규칙 (f)·(g) 호출이 통째로
# 빠진 구현이 전건 초록이 된다 — 검사가 자기 대상을 못 보는 형태다(층위 7).
#
# 픽스처는 실물을 복사하지 않는다. 복사하면 실물 §1-2 나 70-viewer 가 정당하게 바뀔 때마다
# selftest 가 함께 깨지고, 여기서 보려는 것은 절의 내용이 아니라 **게이트의 반응**이다.
# ─────────────────────────────────────────────────────────────────────────────

# 픽스처 root 1벌 — 근거 절 파일 + 70-viewer PRD 사본을 실제 경로 구조로 깐다.
iafx_root() {   # <root 디렉터리> <위임 행 전문> <근거 절 본문>
  mkdir -p "$1/.claude/prds" "$1/.claude/meta"
  printf '### 1-2. fixture\n\n%s\n\n### 1-3. next\n' "$3" > "$1/.claude/meta/12.md"
  printf '%s\n' "$2" > "$1/.claude/prds/insuniverse-docs-70-viewer.prd.md"
}

iafx_yaml() {   # <파일> <consumed> <onboarding> <viewer> <src> <secsha> <delsha>
  printf 'ia_consumed: %s\nia_basis_onboarding: %s\nia_basis_viewer: %s\nia_viewer_src: "%s"\nia_viewer_sha256: "%s"\nia_viewer_delegation_sha256: "%s"\n' \
    "$2" "$3" "$4" "$5" "$6" "$7" > "$1"
}

selftest_ia_gate() {
  local d="$TMP/iafx" r rd rs rdd sec del src
  local DELLINE='- **`ia-doc@1` 지원** — 소비 문서 0건 (우산 제약 #1 단서). 판정은 `00-protocol` M6 소관.'
  # 토큰(`소비 문서 0건`·`M6`)은 그대로 두고 **뜻만 수용으로 뒤집은** 행. 규칙 (e) 는 통과
  # 하고 (f) 만 잡는다 — 토큰 검사가 의미 축을 못 지킨다는 것이 이 픽스처의 주장이다.
  local DELREWR='- **`ia-doc@1` 지원** — 소비 문서 0건 이었으나 이제 수용한다. M6 위임을 철회한다.'
  local SECBODY='소비자 2종 표 자리 — 온보딩 축과 뷰어 축.'
  local SECREWR='소비자는 하나뿐이고 뷰어 축은 근거가 없다.'
  local STALE=0000000000000000000000000000000000000000000000000000000000000000

  rm -rf "$d"; mkdir -p "$d"
  r="$d/root";   iafx_root "$r"  "$DELLINE" "$SECBODY"
  rd="$d/delrw"; iafx_root "$rd" "$DELREWR" "$SECBODY"
  rs="$d/secrw"; iafx_root "$rs" "$DELLINE" "$SECREWR"
  # 같은 id 의 절을 뒤에 하나 더 놓는다 — 앞 절은 그대로 두고 진짜 내용을 옮겨 적는 경로다.
  rdd="$d/secdup"; iafx_root "$rdd" "$DELLINE" "$SECBODY"
  printf '\n### 1-2. fixture (중복)\n\n%s\n' "$SECREWR" >> "$rdd/.claude/meta/12.md"
  src='.claude/meta/12.md#1-2'

  # 레코드 픽스처 — screen_inventory(4번 필드)가 전건 `-` 다. 현 실데이터와 같은 조합이라
  # 온보딩 축의 실체는 false 이고, 그것이 규칙 (b) 의 기준이 된다.
  printf 'Q01|20-x|-|-|true|s|q1\nQ02|30-y|-|-|true|s|q2\n' > "$d/rec"

  sec=$(ia_viewer_section_sha "$r/.claude/meta/12.md" 1-2) \
    || { st_fail "ia-gate/fixture" "근거 절 digest 산출 실패"; return; }
  del=$(ia_delegation_sha "$r/.claude/prds/insuniverse-docs-70-viewer.prd.md" "$d/delline") \
    || { st_fail "ia-gate/fixture" "위임 행 digest 산출 실패"; return; }

  # 1. 선언 true 인데 두 축 다 false — OR 계약의 한쪽 방향
  iafx_yaml "$d/y1" true false false "$src" "$sec" "$del"
  expect_fn "ia-gate/or-false-declared-true" 1 "두 축의 OR" -- oq_ia_gate "$d/y1" "$d/rec" "$r"

  # 2. 뷰어 축만 true 인데 선언 false — 반대 방향. 한쪽만 보면 이쪽이 조용히 통과한다
  iafx_yaml "$d/y2" false false true "$src" "$sec" "$del"
  expect_fn "ia-gate/or-true-declared-false" 1 "두 축의 OR" -- oq_ia_gate "$d/y2" "$d/rec" "$r"

  # 3. 온보딩 축 선언이 screen_inventory 실체와 갈린다 — 구 검사 5 의 이빨
  iafx_yaml "$d/y3" true true true "$src" "$sec" "$del"
  expect_fn "ia-gate/onboarding-axis-diverges" 1 "screen_inventory 질문 존재" -- \
    oq_ia_gate "$d/y3" "$d/rec" "$r"

  # 4. ia_viewer_src 의 파일이 없다
  iafx_yaml "$d/y4" true false true '.claude/meta/nope.md#1-2' "$sec" "$del"
  expect_fn "ia-gate/viewer-src-missing" 1 "ia_viewer_src 의 파일이 없다" -- \
    oq_ia_gate "$d/y4" "$d/rec" "$r"

  # 5. 절 id 가 그 파일에 없다 — 빈 슬라이스를 정상으로 얼리면 근거가 사라진 채 동결된다
  iafx_yaml "$d/y5" true false true '.claude/meta/12.md#9-9' "$sec" "$del"
  expect_fn "ia-gate/viewer-section-empty" 1 "없거나 비어 있다" -- \
    oq_ia_gate "$d/y5" "$d/rec" "$r"

  # 6. 위임 행이 **토큰을 유지한 채** 수용 의미로 바뀌었다 — (e) 는 통과하고 (f) 만 잡는다
  iafx_yaml "$d/y6" true false true "$src" "$sec" "$del"
  expect_fn "ia-gate/viewer-delegation-rewritten" 1 "위임 행 실측" -- \
    oq_ia_gate "$d/y6" "$d/rec" "$rd"

  # 7. 위임 행은 그대로인데 **선언만 낡았다** — (f) 의 대조가 양방향임을 보인다.
  #    선언만 고쳐 침묵시키는 경로가 있는지 검사 자신이 답하는 자리다
  iafx_yaml "$d/y7" true false true "$src" "$sec" "$STALE"
  expect_fn "ia-gate/viewer-delegation-declared-stale" 1 "위임 행 실측" -- \
    oq_ia_gate "$d/y7" "$d/rec" "$r"

  # 8. 긍정 근거 절이 **비공백인 채 의미만 뒤집혔다** — 실재·비공백만 보던 (d) 의 사각
  iafx_yaml "$d/y8" true false true "$src" "$sec" "$del"
  expect_fn "ia-gate/viewer-section-rewritten" 1 "근거 절 실측" -- \
    oq_ia_gate "$d/y8" "$d/rec" "$rs"

  # 9. 절은 그대로인데 선언만 낡았다 — 8 과 대칭. 위임 행 쪽 6·7 과 같은 짝이다
  iafx_yaml "$d/y9" true false true "$src" "$STALE" "$del"
  expect_fn "ia-gate/viewer-section-declared-stale" 1 "근거 절 실측" -- \
    oq_ia_gate "$d/y9" "$d/rec" "$r"

  # 10. 절 표제가 2개다 — 앞 절만 슬라이스되고 뒤에 옮겨 적은 내용이 근거 밖에 남는다
  iafx_yaml "$d/y10" true false true "$src" "$sec" "$del"
  expect_fn "ia-gate/viewer-section-dup-heading" 1 "표제가 2개 이상이다" -- \
    oq_ia_gate "$d/y10" "$d/rec" "$rdd"

  # 11. ia_viewer_src 가 `.claude/meta/` 밖을 가리킨다 — 가리킴 자체를 묶지 않으면 이 PRD 가
  #     소유한 파일로 재조준하고 digest 2개만 다시 선언하는 것으로 (d)·(g) 가 전부 초록이 된다
  iafx_yaml "$d/y11" true false true 'docs/_meta/self.md#1-2' "$sec" "$del"
  expect_fn "ia-gate/viewer-src-outside-meta" 1 "밖을 가리킨다" -- \
    oq_ia_gate "$d/y11" "$d/rec" "$r"

  # 12. 접두는 통과하면서 `..` 로 밖으로 나간다 — 11 의 검사만으로는 열려 있는 경로
  iafx_yaml "$d/y12" true false true '.claude/meta/../prds/x.md#1-2' "$sec" "$del"
  expect_fn "ia-gate/viewer-src-traversal" 1 "상위 경로 참조" -- \
    oq_ia_gate "$d/y12" "$d/rec" "$r"

  # 13. 판정 6키 중 하나가 2회 — 규칙 (a). 두 번 적으면 sed 가 여러 행을 잡아 비교가 무너진다
  iafx_yaml "$d/y13" true false true "$src" "$sec" "$del"
  printf 'ia_basis_viewer: true\n' >> "$d/y13"
  expect_fn "ia-gate/key-declared-twice" 1 "2회 등장한다" -- \
    oq_ia_gate "$d/y13" "$d/rec" "$r"

  # 14. 선언 digest 가 hex 가 아니다 — 규칙 (a). 축약·상징 표기가 들어오면 (f)·(g) 가 무의미해진다
  iafx_yaml "$d/y14" true false true "$src" "DEADBEEF" "$del"
  expect_fn "ia-gate/declared-digest-not-hex" 1 "64자리 소문자 hex" -- \
    oq_ia_gate "$d/y14" "$d/rec" "$r"

  # 15. 정상 — 현 실데이터와 같은 조합(onboarding=false · viewer=true · consumed=true)
  iafx_yaml "$d/y15" true false true "$src" "$sec" "$del"
  expect_fn "ia-gate/accept-viewer-axis" 0 "" -- oq_ia_gate "$d/y15" "$d/rec" "$r"
}

selftest_md_parsers() {
  local d="$TMP/mdfx"
  mkdir -p "$d"

  # ── tree_paths ──
  # 정상: 들여쓰기 0/2/4 + 한 행 다중 토큰
  printf '## T\n\n```\ndocs/\n  a/\n    01-x.md  02-y.md\n  b/\n    03-z.md\n```\n\n## E\n' > "$d/tree.ok.md"
  expect_fn "tree_paths 정상 (한 행 다중 토큰)" 0 "" -- tree_paths "$d/tree.ok.md" '^## T$' '^## E$'
  if ! diff -q <(tree_paths "$d/tree.ok.md" '^## T$' '^## E$') \
       <(printf 'docs/a/01-x.md\ndocs/a/02-y.md\ndocs/b/03-z.md\n') > /dev/null; then
    st_fail "tree_paths 정상 출력" "전 경로가 기대와 다르다"
  else
    printf '  ok  tree_paths 정상 출력 (전 경로 3건)\n'
  fi
  # 홀수 들여쓰기 — 계층이 복원되지 않는다
  printf '## T\n\n```\ndocs/\n  a/\n   01-x.md\n```\n\n## E\n' > "$d/tree.odd.md"
  expect_fn "tree_paths 홀수 들여쓰기" 1 "2의 배수여야" -- tree_paths "$d/tree.odd.md" '^## T$' '^## E$'
  # 상위 문맥 없음 — 접두어 없는 basename 이 나가면 트리 동등성이 증명되지 않는다
  printf '## T\n\n```\n    01-x.md\n```\n\n## E\n' > "$d/tree.nopre.md"
  expect_fn "tree_paths 상위 문맥 없음" 1 "디렉터리 문맥을 복원하지 못했다" -- \
    tree_paths "$d/tree.nopre.md" '^## T$' '^## E$'
  # dedent 후 깊은 문맥 잔류 — 부재가 아니라 **잔류**라 위 가드에 안 걸린다 (santa m7 R5)
  printf '## T\n\n```\nserver/\n  auth/\n    01-login.md\nsite/\n    20-home.md\n```\n\n## E\n' > "$d/tree.stale.md"
  expect_fn "tree_paths dedent 후 깊은 문맥 잔류" 1 "디렉터리 문맥을 복원하지 못했다" -- \
    tree_paths "$d/tree.stale.md" '^## T$' '^## E$'

  # ── md_section 경계 ──
  printf '## A\n1\n## A\n2\n## B\n' > "$d/sec.dup.md"
  expect_fn "md_section 시작 표제 2회" 1 "정확히 1회" -- md_section "$d/sec.dup.md" '^## A$' '^## B$'
  printf '## A\n1\n' > "$d/sec.noend.md"
  expect_fn "md_section 끝 표제 없음" 1 "EOF 까지 열려 있다" -- md_section "$d/sec.noend.md" '^## A$' '^## B$'

  # ── ow_rows / md_table 폭 ──
  printf '## A\n\n| a | b | c |\n|---|---|---|\n| 1 | 2 |\n\n## B\n' > "$d/tbl.width.md"
  expect_fn "md_table 폭 불일치는 전체 실패" 1 "폭이 다른 행은 버리지 않고" -- \
    md_table "$d/tbl.width.md" '^## A$' '^## B$' 3

  # ── prd_finding_items / prd_status_enum ──
  printf '기록 필수 항목: 가 · 나(설명) · **다(A / B)**. 끝.\n' > "$d/prd.ok.md"
  if ! diff -q <(prd_finding_items "$d/prd.ok.md") <(printf '가\n나\n다\n') > /dev/null; then
    st_fail "prd_finding_items 정상" "항목 유도가 기대와 다르다"
  else
    printf '  ok  prd_finding_items 정상 (괄호·강조 제거, 순서 보존)\n'
  fi
  # 구분자 좌우 공백 관용 — 뜻이 같은 서식이 거짓 실패가 되면 안 된다 (santa m7 R4)
  printf '기록 필수 항목: 가·나·**상태(A/B)**. 끝.\n' > "$d/prd.tight.md"
  if ! diff -q <(prd_finding_items "$d/prd.tight.md") <(printf '가\n나\n상태\n') > /dev/null; then
    st_fail "prd_finding_items 구분자 공백 없음" "붙여 쓴 가운뎃점이 유도를 깨뜨렸다"
  else
    printf '  ok  prd_finding_items 구분자 공백 관용\n'
  fi
  # stderr 를 버리지 않는다. 이 사례는 성공을 기대하는 자리이고, 실패했을 때 **왜** 실패했는지가
  # 사라지면 selftest 가 "다르다" 만 말하고 원인을 감춘다(santa m7 R6).
  if ! diff -q <(prd_status_enum "$d/prd.tight.md") <(printf 'A\nB\n') > /dev/null; then
    st_fail "prd_status_enum 구분자 공백 없음" "붙여 쓴 빗금이 enum 을 1개로 만들었다"
  else
    printf '  ok  prd_status_enum 구분자 공백 관용\n'
  fi
  # 기대 사유에 **개수까지** 넣는다. `정확히 1개` 만 보면 0벌 fixture 로 망가져도 같은 조건을
  # 만족해 selftest 가 계속 ok 를 찍는다 — 검증 코드가 wrong-reason 실패를 통과로 오인하는
  # 것이 이 파일에서 가장 비싼 결함이다(santa m7 R6 Reviewer B).
  printf '기록 필수 항목: 가 · 상태(A / B).\n덧붙임 상태(초안 / 확정) 는 다른 뜻이다.\n' > "$d/prd.dup.md"
  expect_fn "prd_status_enum 상태(...) 2벌" 1 '"상태(...)" 가 2개다' -- prd_status_enum "$d/prd.dup.md"
  printf '기록 필수 항목: 가 · 나.\n기록 필수 항목: 다.\n' > "$d/prd.dupline.md"
  expect_fn "prd_finding_items 문장 2벌" 1 '문장이 2개다' -- prd_finding_items "$d/prd.dupline.md"
  printf '가 · 나.\n' > "$d/prd.noline.md"
  expect_fn "prd_finding_items 문장 0벌" 1 '문장이 0개다' -- prd_finding_items "$d/prd.noline.md"
  printf '기록 필수 항목: 가 · 나.\n' > "$d/prd.noenum.md"
  expect_fn "prd_status_enum 상태(...) 0벌" 1 '"상태(...)" 가 0개다' -- prd_status_enum "$d/prd.noenum.md"

  # ── findings_structure_ok ──
  printf '발견 일시\n관찰 사실\n상태\n' > "$d/items.txt"
  local E='미판정,사용자 통보'
  printf '## F\n\n### 발견 일시\n\n2026-08-05\n\n### 관찰 사실\n\n무언가.\n\n### 상태\n\n`미판정`\n' > "$d/fnd.ok.md"
  expect_fn "findings 정상" 0 "" -- findings_structure_ok "$d/items.txt" "$d/fnd.ok.md" "$E" '발견 일시' '상태'
  printf '## F\n\n### 관찰 사실\n\n무언가.\n\n### 발견 일시\n\n2026-08-05\n\n### 상태\n\n`미판정`\n' > "$d/fnd.order.md"
  expect_fn "findings 항목 순서 교체" 1 "1번째 항목이" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.order.md" "$E" '발견 일시' '상태'
  printf '## F\n\n### 발견 일시\n\n2026-08-05\n\n### 관찰 사실\n\n무언가.\n' > "$d/fnd.short.md"
  expect_fn "findings 항목 누락" 1 "항목이 2개다" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.short.md" "$E" '발견 일시' '상태'
  printf '## F\n\n### 발견 일시\n\nyesterday\n\n### 관찰 사실\n\n무언가.\n\n### 상태\n\n`미판정`\n' > "$d/fnd.date.md"
  expect_fn "findings 날짜 형식" 1 "YYYY-MM-DD 하나가 아니다" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.date.md" "$E" '발견 일시' '상태'
  printf '## F\n\n### 발견 일시\n\n2026-08-05\n\n### 관찰 사실\n\n무언가.\n\n### 상태\n\ndone\n' > "$d/fnd.enum.md"
  expect_fn "findings 상태 enum" 1 "중 하나가 아니다" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.enum.md" "$E" '발견 일시' '상태'
  # 열거 필드에 설명 한 줄 더 — 첫 행만 보면 통과하던 우회 (santa m7 R5)
  printf '## F\n\n### 발견 일시\n\n2026-08-05\nobserved-on-stg\n\n### 관찰 사실\n\n무언가.\n\n### 상태\n\n`미판정`\n' > "$d/fnd.extra.md"
  expect_fn "findings 열거 필드에 둘째 행" 1 "비공백 행이 2개다" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.extra.md" "$E" '발견 일시' '상태'
  # 코드펜스 안의 `### ` 는 표제가 아니라 예시다. 다만 **울타리 안 내용**은 본문으로 센다 (santa m7 R1)
  printf '## F\n\n### 발견 일시\n\n2026-08-05\n\n### 관찰 사실\n\n```\n### 인용된 표제\n```\n\n### 상태\n\n`미판정`\n' > "$d/fnd.fence.md"
  expect_fn "findings 코드펜스 안 표제는 예시" 0 "" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.fence.md" "$E" '발견 일시' '상태'
  # 빈 코드펜스는 내용이 아니다 — 울타리 줄을 세면 `~~~` 한 쌍만으로 통과한다 (santa m7 R6)
  printf '## F\n\n### 발견 일시\n\n2026-08-05\n\n### 관찰 사실\n\n~~~\n~~~\n\n### 상태\n\n`미판정`\n' > "$d/fnd.emptyfence.md"
  expect_fn "findings 빈 코드펜스는 빈 항목" 1 "항목이 비어 있다" -- \
    findings_structure_ok "$d/items.txt" "$d/fnd.emptyfence.md" "$E" '발견 일시' '상태'

  # ── overlap_kind ──
  local a b want got
  while read -r a b want; do
    overlap_kind "$a" "$b"; got="$OVERLAP_KIND"
    if [ "$got" != "$want" ]; then
      st_fail "overlap_kind $a / $b" "$got (기대 $want)"
    fi
  done <<'EOF'
docs/x/ docs/x/** same
docs/x/ docs/x/y.md a_parent
docs/x/y.md docs/x/ b_parent
docs/x/ docs/xy/ none
EOF
  printf '  ok  overlap_kind 4종 (same · a_parent · b_parent · none)\n'
}

selftest() {
  local base="$TMP/fx.base"
  tr -d '\r' < "$ROOT/docs/_meta/extraction-manifest.yaml" > "$base"

  # 정상
  expect_token "정상 manifest 통과" "$base" ""

  # 1. 키 유일성 — exclude_glob: 키를 1회 더 등장시킨다
  awk '{ print } /^exclude_glob:/ { print "exclude_glob:"; print "  - \"dup/\"" }' "$base" > "$TMP/fx.dup"
  expect_token "키 2회 등장" "$TMP/fx.dup" KEY_UNIQUE

  # 2. 경계 존재 — exclude_glob: 블록을 파일 끝으로 옮겨 종료 행을 없앤다
  # 뒤따르는 0열 주석까지 같이 옮기면 그 주석이 종료 행 노릇을 해 BOUNDARY 가 안 걸린다.
  # 키 행과 항목 행만 옮겨 EOF 로 열린 섹션을 만든다.
  awk '
    /^exclude_glob:/ { grab = 1; block = $0 "\n"; next }
    grab && /^  - / { block = block $0 "\n"; next }
    grab { grab = 0 }
    { print }
    END { printf "%s", block }
  ' "$base" > "$TMP/fx.boundary"
  expect_token "종료 행 없음(EOF)" "$TMP/fx.boundary" BOUNDARY

  # 3. 항목 형태 — 항목 1건을 `  - {a: 1}` 로 변조
  sed 's|^  - ":(exclude)dist/\*"$|  - {a: 1}|' "$base" > "$TMP/fx.shape"
  expect_token "항목 형태 변조" "$TMP/fx.shape" ITEM_SHAPE

  # 4. 비공집합 — exclude_glob 목록을 비운다
  awk '
    /^exclude_glob:/ { print; drop = 1; next }
    drop && /^  - / { next }
    drop && /^[^ ]/ { drop = 0 }
    { print }
  ' "$base" > "$TMP/fx.empty"
  expect_token "목록 비움" "$TMP/fx.empty" NONEMPTY

  # 5. 잔여 없음 — 형제 키를 exclude_pathspec 항목 **사이**에 넣어 범위 안에 남게 한다
  sed 's|^  - ":(exclude)dist/\*"$|  extra_key: 1\n  - ":(exclude)dist/*"|' "$base" > "$TMP/fx.residual"
  expect_token "섹션 안 형제 키" "$TMP/fx.residual" NO_RESIDUAL

  # 5b. 잔여 없음 — 항목보다 **깊게** 들여쓴 행. 평평한 최상위 목록에는 정당한 연속행이 없다.
  # 깊이만 다르고 나머지는 5번과 같은 결함인데, 초안 파서는 이것만 조용히 버렸다.
  sed 's|^  - ":(exclude)dist/\*"$|    unexpected: value\n  - ":(exclude)dist/*"|' "$base" > "$TMP/fx.deep"
  expect_token "항목보다 깊은 잡음 (평평한 목록)" "$TMP/fx.deep" NO_RESIDUAL

  # 5c. 중첩 목록의 연속행 허용이 과하게 넓지 않은지 — `      why:` 는 통과하지만
  # 그보다 깊은 행은 잔여여야 한다. 허용 정규식이 느슨해지면 여기서 걸린다.
  sed 's|^    - pattern: "\*\.svg"$|        junk: 1\n    - pattern: "*.svg"|' "$base" > "$TMP/fx.deepnested"
  expect_token "허용 깊이 밖 연속행 (중첩 목록)" "$TMP/fx.deepnested" NO_RESIDUAL

  # 5d. 같은 깊이의 **다른 키** — 허용은 깊이가 아니라 키 이름으로 좁혀져 있어야 한다.
  # `[a-z_]+` 처럼 넓히면 이 행이 통과해 조용히 사라진다. 깊이만 옮긴 같은 fail-open 이다.
  sed 's|^      why: "위와 동일 — 확장자 필터"$|      unexpected: value|' "$base" > "$TMP/fx.samedepth"
  expect_token "같은 깊이 미허용 키 (중첩 목록)" "$TMP/fx.samedepth" NO_RESIDUAL

  # 6. repos 섹션 밖의 name/path/head 3행은 저장소가 아니다.
  #    자르기 전에는 파일 어디에 있든 3행이 모이면 저장소로 잡혀, 틀린 경로·SHA 로 값이 나왔다.
  {
    cat "$base"
    printf '\nfake_block:\n  - name: phantom-repo\n    path: phantom\n    head: "%040d"\n' 0
  } > "$TMP/fx.repos_outside"
  # 파이프로 두지 않는다 — grep -q 가 매치로 먼저 끝나면 좌변 bash 가 SIGPIPE(141)로 죽고
  # pipefail 이 그 141 을 파이프라인 상태로 올려 조건이 false 가 된다. 즉 **fixture 가 결함을
  # 재현한 바로 그 경우에 selftest 가 ok 를 찍는다** — 검사가 자기 대상을 못 보는 형태다.
  VERIFY_MANIFEST="$TMP/fx.repos_outside" bash "$0" > "$TMP/fx.outside.out" 2>/dev/null || true
  if grep -q 'phantom-repo' "$TMP/fx.outside.out"; then
    st_fail "repos 섹션 밖 3행" "섹션 밖 항목이 저장소로 잡혔다"
  else
    printf '  ok  repos 섹션 밖 3행은 저장소가 아니다\n'
  fi

  # 7. repos: 키 2회 등장 — 어느 쪽을 읽었는지가 조용히 갈리지 않게 막는다
  awk '{ print } /^repos:/ && !done { print "repos:"; done = 1 }' "$base" > "$TMP/fx.repos_dup"
  expect_die "repos: 키 2회 등장" "$TMP/fx.repos_dup" "repos: 키가 파일 전체에서"

  selftest_ia_gate
  selftest_md_parsers
  selftest_fixtask

  if [ "$SELFTEST_FAILED" -ne 0 ]; then
    printf 'selftest FAILED — %d건\n' "$SELFTEST_FAILED"
    exit 1
  fi
  printf 'selftest PASS\n'
}

# ─────────────────────────────────────────────────────────────────────────────
# 인자 파싱
# ─────────────────────────────────────────────────────────────────────────────

MODE=report
BOOTSTRAP=0
DUMP_DIR=""

while [ "$#" -gt 0 ]; do
  case "$1" in
    --check)     MODE=check ;;
    --update)    MODE=update ;;
    --selftest)  MODE=selftest ;;
    --bootstrap) BOOTSTRAP=1 ;;
    --dump-exclude-inputs)
      MODE=dump
      shift
      [ "$#" -gt 0 ] || usage "--dump-exclude-inputs 에 디렉터리 인자가 필요하다."
      DUMP_DIR="$1" ;;
    *)           usage "알 수 없는 옵션: $1
usage: bash docs/_meta/verify/verify.sh [--check [--bootstrap] | --update | --selftest | --dump-exclude-inputs <dir>]" ;;
  esac
  shift
done

# --bootstrap 은 --check · --update 와만 함께 쓴다.
#
# `--update --bootstrap` 을 금지하자는 안이 있었다(lint 우회 상태의 기준선 축성 차단). 그런데
# 마일스톤 3~6 은 docs/ 에 타입 문서가 0건이라 그 금지는 golden.txt 생성 경로 자체를 없앤다.
# 실질 위험도 성립하지 않는다 — lint 결과는 golden 밖이라 "빈 lint 값이 동결"되는 일이 없고,
# 이후 맨몸 --check 는 여전히 0건에서 실패한다. 금지 대신 **맨몸 --update 를 0건에서 실패**시켜
# 우회가 항상 명령줄에 드러나게 한다(§lint 전수 게이트의 가시성 원칙).
if [ "$MODE" != check ] && [ "$MODE" != update ] && [ "$BOOTSTRAP" -eq 1 ]; then
  usage "--bootstrap 은 --check 또는 --update 와만 함께 쓴다."
fi

# --bootstrap 만료 조건. --check 와 --update 가 같은 자리를 타므로 호출은 여기 한 곳이면 끝난다.
# 거부는 usage(exit 2)다 — 값이 틀린 것이 아니라 그 플래그를 쓸 수 있는 기간이 끝난 것이다.
if [ "$BOOTSTRAP" -eq 1 ]; then
  bootstrap_permitted || usage "--bootstrap 을 쓸 수 없다 (위 stderr 참조)."
fi

case "$MODE" in
  report)   gen_report "$TMP/report" || exit 1; cat "$TMP/report" ;;
  check)    do_check "$BOOTSTRAP" ;;
  update)   do_update "$BOOTSTRAP" ;;
  selftest) selftest ;;
  dump)     do_dump "$DUMP_DIR" ;;
esac
