# task-2977 · [T3-G2] content-hash 불변식 — 약관 원문↔담보 결합 검증 (comparison_gate)

- **팀**: dev2-team (오딘) · **레벨**: Lv.3+ (critical)
- **repo**: `/home/jay/projects/InsuRo` · base `origin/main` = `6de797b`
- **worktree**: `/home/jay/projects/InsuRo/.worktrees/task-2977-dev2` · 브랜치 `task/task-2977-dev2`
- **HEAD**: `72b1d80ec31aa5b40971084399ed3f2dbc46556c` (19 커밋, 17파일 +1589/-17)
- **PR**: https://github.com/Jeon-Jonghyuk/InsuRo/pull/231 — ⚠️ **머지 HOLD**(ANU 독립검증 후)

---

**S**: InsuRo 신규설계 비교 게이트는 t2970(3구멍)·t2974(6갭)·G-1(renewal)으로 map_state·axes 형식을 봉인해 왔고, 현재 전체 pytest 2287건이 통과하는 상태였다.

**C**: 그러나 레드팀(로키) H2 + QC(마아트)가 **결합(binding) 검증 채널이 통째로 비어 있음**을 지적했다. 게이트는 `axes_verified` 불리언 **자기신고**만 보고, 화면에 표시될 지급조건(clause_text)이 실제 그 담보 조항의 것인지 전혀 검증하지 않는다. 틀린 clause를 붙여도 "그 clause 자신의 axes"로 교차검증이 **자기정합 통과**한다. base 실측 재현 결과 뇌졸중 조항을 암진단비에 결합해도 `official_rows=1`로 **공식 비교를 그대로 통과**했다(금소법 설명의무 P0).

**Q**: 생산자가 만든 산출을 검증자가 신뢰하지 않는 구조로, 원문↔담보 결합을 재현 가능하게 검증할 수 있는가?

**A**: 원본 약관 원문의 sha256(`content_hash`)을 불변식으로 도입하고, **게이트가 requested 원문에서 직접 재계산**해 DB stored 값과 독립 대조하는 fail-closed 단계를 신설했다. 결과 — 전체 pytest **2314 passed / 0 failed / 3 skipped**, 신규 봉인 회귀 **26건**(clean worktree red→green 실증), 원격 CI **4/4 GREEN**(2237 passed, base 대비 **+26 = 봉인 테스트 수와 정확히 일치**), L1 실서버·실프로세스 3종 PASS(정상 경로 무변경). 마아트 독립검증이 발견한 **HIGH 우회 1건**을 시정 후 팀장이 원본 재현으로 재검증 완료.

---

## S — 상황 (Situation)

T3 배치(미팅 단일소스 `memory/meetings/2026-08-18-insuro-t3-batch-decision.md`, 순서 A→G→C→B→D)에서 G-1(renewal 축)이 PR #230으로 머지·배포되어 main이 `6de797b`에 있었다. G-2는 T3-C(화면 비교)의 **마지막 안전 선행**이다.

기존 봉인은 entry의 **형식·플래그** 무결성만 검증한다. 결합의 진위는 어느 게이트도 묻지 않았다.

## C — 문제 (Complication)

`axes_verified`는 불리언 **자기신고**이고, 교차검증에 쓰이는 axes 자체가 **그 붙은 clause에서 추출된 것**이다. 신고자와 검증자가 같으므로 검증이 성립하지 않는다.

**base(`6de797b`) 실프로세스 실측 재현** — 담보 X(암진단비)에 담보 Y(뇌졸중) 조항을 결합:

| 시나리오 | base `6de797b` | 판정 |
|---|---|---|
| 오결합(뇌졸중 조항 → 암진단비) | `official_rows=1` | **오비교가 공식 비교를 그대로 통과** |

## Q — 핵심 질문 (Question)

"생산자 ≠ 검증자" 불변식을 코드 구조로 강제해, 자기정합 순환을 끊을 수 있는가?

## A — 답변 (Answer)

### 설계 불변식

- **해시 뿌리** = linker가 자기 추출·정규화한 텍스트가 **아니라**, `clause_splitter`가 원본 PDF `page_start~page_end`에서 뽑은 **원문**(정규화 최소 v1: NFC → CRLF/CR→LF → per-line rstrip → 전체 strip). 결정적 함수라 재현 가능.
- **생산** = 적재 경로(`pipeline`/`upsert_clause`)가 content_hash를 생산·저장.
- **검증** = 게이트는 제출된 해시를 **신뢰하지 않고** requested `clause_text`에서 **직접 재계산**해 stored와 대조.
- **원문 부재 = 검증 불가 = 강등**(통과 아님). 제출 해시는 재계산의 대체 소스가 될 수 없다.

### 제외 사유 2종 분리 (뭉개기 금지)

| 사유 | 의미 | 성격 |
|---|---|---|
| `excluded_content_hash_mismatch` | stored ≠ 재계산 (개정 약관·오결합) | **오비교 차단** — 데이터 손상 신호 |
| `excluded_content_hash_unverifiable` | 레거시(NULL)·원문 부재로 대조 불가 | **fail-safe 강등**(UNKNOWN/HITL) |

하나로 뭉개면 backfill이 끝날 때까지 진짜 오결합이 레거시 노이즈에 묻힌다(기존 `_counts` 주석·group_id 비-str 처리 선례와 동일 원칙).

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|------|-----------|-----------|------|
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/content_hash.py | 신규 — 정규화 v1 + compute_content_hash(64자 hex) | grep content_hash 6건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/comparison_gate.py | 사유 2종 신설 + 대조 단계(axes 검증군 뒤) + 약한경로 제거 | grep content_hash 39건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/clause_splitter.py | ClauseRecord.content_hash 필드 + __post_init__ 자동계산 | grep content_hash 11건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/knowledge.py | upsert_clause 3개소 keyword-only additive + get_clause(구체클래스 전용) | grep content_hash 11건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/pipeline.py | 적재 시 content_hash 전달 | grep content_hash 3건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_grouping/__init__.py | content_hash/REASON 상수 export | grep content_hash 다수 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/policy_extract/grouping_bridge.py | GroupingLink additive 2필드 + 덕타이핑 get_clause 조회 | grep content_hash 10건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/new_design_comparison/engine.py | entry 배선 + 게이트 사유 명시 매핑 2건 | grep content_hash 8건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/new_design_comparison/models.py | NewDesignCoverageIn.clause_text additive | grep content_hash 1건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/scripts/backfill_clause_content_hash.py | 신규 — dry-run 기본/--apply/SKIPPED_NO_SOURCE | grep content_hash 36건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/supabase/migrations/20260819110000_coverage_clause_content_hash.sql | 신규 — additive·nullable·idempotent CHECK | grep content_hash 15건 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_content_hash_seal_task2977.py | 신규 봉인 회귀 26건 (a/b/c + fail-closed + 약한경로) | 26 passed 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_policy_grouping_comparison_gate.py | 픽스처에 content_hash/clause_text 보강 | 43 passed 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_grouping_gate_seal_task2970.py | 픽스처 보강(t2970 봉인 유지) | 48 passed 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_gate_type_seal_task2974.py | 픽스처 보강(t2974 봉인 유지) | 31 passed 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_new_design_comparison.py | 종단 픽스처에 clause_text/content_hash 시드 | 43 passed 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2977-dev2/server/tests/test_policy_grouping_store_extras.py | Protocol 비오염 가드 테스트 1건 추가 | 15 passed 실측 | verified |

planned 항목 **0건**.

---

## 테스트 결과

### 로컬 (worktree, 팀장 직접 실행)

```
2314 passed, 3 skipped, 40 warnings in 188.20s
```
- 신규 봉인 `test_content_hash_seal_task2977.py` → **26 passed, skipped 0**
- 기존 봉인 무회귀: t2970+t2974 **79 passed** · G-1 renewal **19 passed** · 5키 잠금 `test_counts_tallies_all_map_states_with_zero_defaults` **1 passed**(깨지지 않아 미수정)
- skipped 3건은 base에도 있는 환경 조건부(`.env`/`INSURO_POLICY_FIXTURE_DIR` 미설정) — 이번 작업이 만든 skip 0건

### ★ 원격 CI (로컬 GREEN 불인정 → 실재 확인)

PR #231 head `72b1d80` — **4/4 GREEN**: CI(pytest) · CI(guard) · E2E Tests · Diagnostic Pytest

| | base `6de797b` | HEAD `72b1d80` | delta |
|---|---|---|---|
| CI 서버 pytest | 2211 passed / 45 skipped | **2237 passed** / 46 skipped | **+26 passed** |

**+26 = 신규 봉인 테스트 26건과 정확히 일치** → 봉인 테스트가 원격에서 실제로 수집·실행됐다는 산술 증거(과거 "커밋됐지만 CI에서 미실행" 사고 회피 확인).

### ★ red→green 실증 (clean worktree)

봉인 코드가 없으면 실제로 깨지는지 확인하기 위해, 수정 **직전** 커밋 `1857169`(테스트는 있고 fix는 없음)를 `/tmp` 별도 worktree에 detach 체크아웃해 실행:

```
FAILED ...TestWeakPathSealed_MaatFinding::test_weak_path_clause_text_key_absent_with_forged_requested_hash_is_unverifiable_not_mismatch
FAILED ...test_weak_path_clause_text_none_with_forged_requested_hash_is_unverifiable
FAILED ...test_weak_path_clause_text_empty_string_with_forged_requested_hash_is_unverifiable
FAILED ...test_weak_path_clause_text_whitespace_only_with_forged_requested_hash_is_unverifiable
4 failed, 22 passed
```
→ fix 적용 후 **26 passed**. 구멍을 실제로 잡는 회귀임이 실증됨.

---

## L1 스모크테스트

- **서버 재시작**: 성공 — worktree 코드로 별도 포트(8899) 실서버 기동. 프로덕션(8001) 무영향(기동 전후 HTTP 200 유지).
- **API 응답 확인**:
  - `GET /api/status` → **HTTP 200**, `sha_short=72b1d80`, `ready=true`, `db=ok` (배포 대상 코드가 실제로 서빙됨을 sha로 확인)
  - `POST /api/insuro/new-design-comparison` (무인증) → **HTTP 401** (500 아님 — import/부팅 시 런타임 결함 없음)
- **실프로세스 실엔진 3종** (pytest 하네스 없이 `compare_new_design()` 직접 호출, `/tmp/l1_task2977_gate_live.py`):

| 시나리오 | base `6de797b` | HEAD `72b1d80` |
|---|---|---|
| S1 정상(원문 일치) | official_rows=1 | **official_rows=1** (정상 경로 무변경) |
| S2 오결합(뇌졸중 조항→암진단비) | official_rows=**1** ← 오비교 통과 | **0** (`content_hash_mismatch`) |
| S3 레거시(clause 부재) | official_rows=1 | **0** (`content_hash_unverifiable`) |

★ **base에서 틀린 조항이 그대로 공식 비교를 통과하는 것이 실측 재현**되었고 HEAD에서 차단된다. 동시에 S1이 양쪽 모두 1행으로 **정상 경로가 죽지 않았음**이 대조군으로 증명된다.

- **스크린샷**: 해당없음 (서버 게이트 전용 작업 — 프론트 변경 0건)

---

## 발견 이슈 및 해결

### 자체 해결 (4건)

1. **[HIGH] 게이트 "약한 경로"로 생산자≠검증자 불변식이 무력화** — 마아트 독립검증이 재현. `clause_text` 없이 `requested_content_hash == stored`만 제출하면 원문 검증 없이 `included=True` 통과. **해결됨** — 원문 부재 시 무조건 UNVERIFIABLE로 강등하도록 fallback 완전 삭제, 회귀 6건 추가, 팀장이 마아트 원본 코드로 재현 재검증.
2. **[HIGH] 종단 배선 부재로 공식 비교 전멸** — 게이트만 넣으면 `engine`이 `content_hash`/`clause_text`를 안 실어 CONFIRMED 담보가 전부 강등(26건 실패). **해결됨** — `GroupingLink` additive 2필드 + `NewDesignCoverageIn.clause_text` + `store.get_clause()` + engine passthrough로 계약 완결(게이트 약화 없이).
3. **[MEDIUM] KnowledgeStore Protocol 오염** — `get_clause`를 Protocol에 추가해 task-2975의 "Protocol 비오염" 가드 테스트가 깨짐. **해결됨** — 잠금 테스트를 고치지 않고, 선례 3건과 동일하게 **구체 클래스 전용**으로 이동 + 덕타이핑 접근 + "Protocol에 없음" 가드 테스트 추가(방어 강화).
4. **[MEDIUM] 게이트 사유 노출 추적성 누락** — 신규 사유 2건이 `_GATE_REASON_TO_EXCLUDED_REASON`에 미매핑되어 t2974 traceability 봉인이 깨짐. **해결됨** — MISMATCH→`NOT_COMPARABLE`, UNVERIFIABLE→`MAP_NOT_CONFIRMED` 명시 매핑(`gate_reason_raw`로 행 단위 granularity 보존).

### 범위 외 / 미해결 (2건)

1. **[INFO] 원격 CI skipped가 base 45 → HEAD 46으로 +1** — 범위 외 사유: 우리 신규 테스트 파일에는 skip/xfail 마커가 **0건**(grep 확인)이라 우리 테스트는 skip될 수 없고, 진행 시퀀스상 추가 skip 위치(~1268/1552)가 신규 테스트 삽입 지점(+20 shift 구간) **밖**이다. 수집 총계는 base 2256 → HEAD 2283으로 **정확히 +27**(신규 테스트 수)이라 미수집은 없다. CI가 `-q`로 돌아 원격 로그에 SKIPPED 사유가 남지 않아 **어느 기존 테스트인지 특정 불가** — ANU 재검증 시 `-rs` 옵션으로 확인 권고.
2. **[INFO] 기존 증권 분석(existing) 측은 여전히 UNVERIFIABLE** — 범위 외 사유: `_existing_entries`에는 clause_id 자체가 없어 stored 조회가 불가능하다. 이는 신규 회귀가 아니라 t2968 이후의 기존 현실(기존담보 100% UNKNOWN)과 동일하며, 값을 지어내지 않고 정직하게 강등한다. 해소는 T3-C 화면 비교 배선의 몫.

---

## 마아트 독립 검증 (critical 레벨)

- **1차 판정: NEEDS WORK** — 우회 11종 중 **1건 뚫림**(위 HIGH 이슈 1). "정상 포맷의 위조값"을 기존 봉인 테스트 20건이 한 번도 넣어보지 않았음을 지적.
- **교훈(context-notes.md에 기록)**: fail-closed 테스트를 malformed 입력(None/int/list/dict/set)으로만 구성하면 이 계열 구멍은 구조적으로 못 잡는다. **위조는 형식이 올바른 법이다.**
- 그 외 항목: 기대값 완화 **0건**(assert 삭제 0줄 — diff 검증), 마이그레이션 additive·기존 UNIQUE 무변경, backfill dry-run 기본 동작, 5키 잠금 유지 전부 PASS.
- **시정 후 팀장 재검증**: 마아트 원본 재현 코드로 독립 실행 → d-1 우회 `UNVERIFIABLE` 봉인 · 정상 경로 통과 유지 · 위조 탐지 유지 **3/3 PASS**.

## Codex 사전 검증 (Lv.3+ G1)

`codex_gate_check.py` 결과 `"pass": true` — 단, `"source": "maat_fallback"`, `"fallback_reason": "codex-companion 실행 실패"`. **실제 Codex 리뷰가 아니라 마아트 폴백(파일 존재 확인)**이므로 게이트 강도는 약하다. 정직하게 기재한다.

## trip-wire 5종 (실측)

| trip-wire | 실측치 | 근거 |
|---|---|---|
| Critical7 | 0 | 전체 pytest 0 failed · CI 4/4 GREEN |
| PII net-new | 0 | 추가 라인 대상 주민번호/연락처/API키/JWT 패턴 스캔 0건 |
| 회귀 실패 | 0 | 2314 passed / 0 failed · 기존 봉인 t2970/t2974/G-1 전건 통과 |
| forbidden_paths 침범 | 0 | 수정 전량이 worktree 내부. 메인 리포/타 worktree/타 팀 디렉토리 미접촉 |
| nonce | task-2977 | 발사 task_id와 일치 |

## 3문서

`/home/jay/workspace/memory/plans/tasks/task-2977/` — plan.md · context-notes.md · checklist.md (status: completed). 3 Step Why(A: 결합 검증 채널 부재 → B: 생산자가 만들지 않은 외부 고정점 → C: 고정점 **사용 방식**까지 분리해야 순환이 끊김) 기록 완료. 마아트 HIGH 시정 경위도 §9로 추가.

## 모델 사용 기록

| 팀원 | 모델 | 담당 |
|---|---|---|
| 토르(백엔드) ×5 | sonnet | 생산 경로 · 게이트 · 종단 배선 · Protocol 시정 · 약한경로 제거 |
| 헤임달(테스트/QA) ×3 | sonnet | 봉인 회귀 26건 · 기존 픽스처 보강 · 약한경로 회귀 |
| 마아트(횡단 QC) | sonnet | 독립 검증(HIGH 등급 지적 1건 발견) |
| 프레이야(프론트)·미미르(UX/UI) | 미활성 | 서버 게이트 전용 작업 — 프론트/UX 변경 0건 |

haiku 미사용(전부 판단·설계 밀도가 필요한 작업). 팀장(Opus)은 직접 코딩하지 않고 설계·분배·검토·통합·독립 재검증만 수행.

## 머지 판단

- **머지 필요**: Yes — 단 ⚠️ **머지 HOLD**(ANU 독립검증 후). task 지시 사항.
- **브랜치**: `task/task-2977-dev2` · **PR**: #231 · **HEAD**: `72b1d80`
- **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2977-dev2`
- **머지 의견**: 원격 CI 4/4 GREEN + 봉인 테스트 수와 원격 passed 증가분이 정확히 일치(+26) + clean worktree red→green 실증 + L1 base 대조군으로 구멍 재현·차단 확인. 마아트 HIGH 등급 지적 1건은 시정·재검증 완료. **정상 경로 무변경**이 base 대조군으로 증명됨.
- ⚠️ **배포 순서 (역전 금지 — G-1 교훈)**: ① 마이그레이션 SQL → ② content_hash backfill → ③ 앱
- ⚠️ 이 PR 머지·배포가 **T3-C(화면 비교)의 마지막 안전 선행**. G-2 GREEN 후에만 C 착수.

---

## G3 독립 검증

`python3 scripts/g3_independent_verifier.py --task-id task-2977` → **PASS** (fail_reasons 0건)

| 항목 | 결과 |
|---|---|
| report_parse / planned_check / file_existence / grep_verification | PASS |
| team_scope / pytest_execution / cross_verify / gemini_review | SKIP |
| micro_commit | WARN — **교차 repo 사각지대 오탐**. 검증기가 워크스페이스 git 기준으로 집계해 "커밋 0개"로 보지만, 실제 worktree 커밋은 **19건**(`git log origin/main..HEAD`). 비코드 해치로 덮지 않고 그대로 노출한다. |
| three_step_why | WARN → **해결됨** — context-notes.md 헤딩을 `1st Why`/`2nd Why`/`3rd Why` 리터럴로 정정 |

## CI_WATCH_HANDOFF

`/home/jay/workspace/memory/events/task-2977.ci-watch-handoff.json` 생성.
terminal_state = **`CI_GREEN_MERGE_HOLD`** — CI 4/4 GREEN 이라 watch 대상이 이미 terminal 상태이며,
MERGE_READY 조건 중 CI/테스트는 충족했으나 task 지시에 의해 머지는 ANU 독립검증 전까지 HOLD.

## result.json

`/home/jay/workspace/memory/events/task-2977.result.json` — trip-wire 5종 실측치 + `callback_schedule_created: false` + `report_path` 기록.
relay_hints 없음 → deterministic closeout.

★ trip-wire 수치는 **자기증명이므로 통과 근거가 아니다**(QC-RULES §8-B). ANU 가 clean worktree 재실행으로 대조해야 하며, 불일치 시 재실행 수치를 채택한다.

## Gemini 리뷰 (참고)

Gemini PR 리뷰는 **sunset**되어 이 리포에서 발화하지 않는다(`G4_GATE_ENABLED=0`). PR #231의 실제 Gemini High = **0건**.

★ 본 보고서의 `[HIGH]` 표기는 전부 **마아트(횡단 QC) 독립검증** 등급이며 Gemini 지적이 아니다. 혼동 방지를 위해 명시한다. 리뷰 권위는 Codex + ANU 독립검증에 있으며, Codex는 이번 실행에서 companion 장애로 마아트 폴백되었다(위 §Codex 사전 검증 참고).

## 세션 통계
- 총 도구 호출: 0회

