# task-2978 · [T3-C1] 삼성·메리츠 약관 적재 + 삼성 시드 content_hash 재적재

- **팀**: dev1-team (헤르메스) · **레벨**: Lv.3+ (critical)
- **일자**: 2026-08-19
- **리포**: `/home/jay/projects/InsuRo` · base `origin/main` = `1c4e4dd` (T3-G2 배포 상태)
- **워크트리**: `/home/jay/projects/InsuRo-worktrees/task-2978-dev1` (branch `task/task-2978-dev1`)
- **산출물 성격**: **데이터/운영 작업 — server 코드 변경 0줄**(`git diff --stat origin/main` = docs 6개 파일만). PR 불요, result-only.

---

## S — 상황 (Situation)

T3 게이트 안전벽 G-1(renewal 축)·G-2(content-hash 불변식)가 완성·배포됐다(main `1c4e4dd`, 실서버 `/api/status` sha 실측 일치). 이제 T3-C(화면 첫 발현)의 **실데이터 적재** 단계다. 로컬에 원본 약관 2건이 확보돼 있었다 — 삼성 New내돈내삼(sha `182fcf43…`, 시드 상품의 원본과 동일), 메리츠 통합간편건강보험 2607(sha `958b78c0…`).

## C — 문제 (Complication)

G-2 배포 시 드러난 것: **삼성 시드 confirmed 10건의 `coverage_clause.content_hash` 가 전량 NULL**. `seed_cancer5.seed()` 가 골든셋 fixture 만으로 적재하고 원본 PDF 를 열지 않았기 때문이다. 게이트는 이 행들을 `content_hash_unverifiable` 로 강등하므로, 화면 비교가 발현되지 않는다.

작업 착수 후 **지시서에 없던 구조적 장애 3건**이 추가로 드러났다.
1. 백필 도구가 원본 PDF 를 **오직 `coverage_product.drive_file_id` 로만** 조달하는데 그 값이 NULL 이었다 → Drive 배치가 선행조건이 됨.
2. 메리츠는 대분류 섹션마다 code 가 1부터 재시작해 **중복 code 46종/183건**. `coverage_clause` 는 `UNIQUE (product_id, code)` 라 전량 적재 시 **299건 중 116건만 생존, 183건 소실**(음성 대조 실측).
3. 파이프라인(`analyze_product`) 경로로 삼성을 재적재하면 `upsert_clause` 가 `payload` 전체를 `update()` 해 **시드의 `excerpt`(provenance 근거 인용문)·page 범위까지 덮어쓴다** → 지시서 원칙("content_hash 만 additive") 위반.

## Q — 핵심 질문 (Question)

시드 confirmed 10건을 **한 톨도 훼손하지 않으면서** content_hash 를 채우고, 메리츠를 **데이터 손상 없이** 적재할 수 있는 경로는 무엇인가?

## A — 답변 (Answer)

**경로를 갈랐다.**

- **삼성**: `analyze_product` 를 쓰지 않고, task-2977 이 이미 리포에 넣어둔 **전용 백필 `server/scripts/backfill_clause_content_hash.py`** 를 사용. 이 스크립트는 `content_hash` **단일 컬럼만** UPDATE 한다(코드 실측 확인). 선행조건인 Drive 배치를 먼저 해결해 `drive_file_id` 를 채웠다.
- **메리츠**: 문서 전체에서 **code 가 정확히 1회만 등장하는 clause 만** 화이트리스트로 골라(경합 원천 차단) 14건을 `analyze_product` 로 실제 정독 적재.
- **정합성 쟁점 해소**(오딘 미팅 조건): 삼성 시드 10개 cov_id 는 split 결과와 **`code == cov_id` 10/10 완전 일치**했고 삼성 문서 전체 302건에 code 재사용이 **0건**이라, `(product_id, code)` 매칭이 정확히 그 10행에만 도달한다. **불일치 code 없음.**

### 완료 조건 대비 결과

- 삼성 시드 10건 `content_hash` **NOT NULL 10 / NULL 0** — DB 실측, 값이 원본 PDF 독립 재계산과 10/10 일치 ✅
- 메리츠 `coverage_clause` **14건 > 0**, content_hash NULL **0건** ✅
- L1 실서버: 재시작 성공 + 게이트 **양성 PASS / 음성 MISMATCH 차단** 쌍 실증 ✅ (단 HTTP 계층은 미달성 — 아래 §L1)

---

## 수정 파일별 검증 상태

| 파일 | 성격 | 검증 상태 |
|---|---|---|
| server/ 전체 | 변경 없음 | 코드 변경 0줄 — git diff --stat 으로 확인, docs 만 추가 |
| docs/task-2978-investigation-vulcan.md | 신규(조사) | 삼성/메리츠 split 실측 + 프로덕션 DB 센서스 |
| docs/task-2978-gate-contract-argos.md | 신규(계약) | comparison_gate content_hash 검증 계약 코드 인용 |
| docs/task-2978-meritz-collision-audit.md | 신규(감사) | 중복 code 음성 대조 299→116 실측 |
| docs/task-2978-drive-placement.md | 신규(운영) | Drive 계층 배치 + sha256 왕복 MATCH |
| docs/task-2978-phase1-samsung-backfill.md | 신규(실행) | dry-run 10/10 대조 → apply → 사후 diff |
| docs/task-2978-phase2-meritz-load.md | 신규(실행) | 메리츠 적재·멱등·매핑 status 분포 |
| docs/task-2978-maat-verification.md | 신규(독립검증) | 마아트 CONDITIONAL PASS, 불일치 0건 |

**DB 변경(프로덕션 INSURO_NEW)**

| 대상 | 변경 | 검증 |
|---|---|---|
| coverage_clause 삼성 208c02b0 10행 | content_hash NULL → sha256 | NOT NULL 10/0, 값 10/10 일치. name/detail_count/page_start/page_end/excerpt **델타 0** |
| coverage_group_map 삼성 10행 | 변경 없음 | group_id/status/map_state/confidence/decided_by **델타 0** |
| coverage_product 삼성 | drive_file_id NULL → 1jEc6mBNG2i… | 단일 필드 델타 실측 |
| coverage_product 메리츠 38c796b7 | 신규 1행 | product_code 2607, terms_sha256 958b78c0…, drive_file_id 1sS6UHUs… |
| coverage_clause 메리츠 | 신규 14행 | content_hash NOT NULL 14/0, 중복 code 0 |
| coverage_group_map 메리츠 | 신규 11행 | 전부 status=proposed / map_state=AUTO_MAP. **confirmed 승격 0건** |
| coverage_clause fa3832b0 1행 | content_hash NULL → b5e9feb9… | ★ **스코프 밖** — 아래 §스코프 밖 쓰기 공개 |

---

## 테스트 결과

- 전체 회귀: **2314 passed / 1 failed / 2 skipped** (174.85s).
- 실패 1건 = `tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset`. **코드 변경 0줄**이므로 이번 작업과 인과 없음. worktree `.env` 에 값이 채워져 "unset 전제" 테스트가 깨지는 기존 환경 조건부 패턴과 일치.
- 타깃 서브셋(`-k "content_hash or comparison_gate or seed"`): **118 passed / 0 failed** — 아르고스·마아트 각각 독립 실행, 동일 수치.
- 멱등: 백필 재실행 `scanned=0 updated=0 skipped_no_source=0 failed=0`. 메리츠 재실행(`--codes 46`) clause 건수 **14 유지, 델타 0**.

## L1 스모크테스트

- **서버 재시작**: **성공** — `systemctl --user restart insuro-api` 후 `/api/status` = sha `1c4e4dd`, `ready:true`, `db:ok`.
- **API 응답 확인**: **부분 달성**. 무토큰 `POST /api/insuro/new-design-comparison` → **HTTP 401** `{"detail":"Missing or invalid authorization"}`. 유효 Supabase JWT 발급 절차가 리포 어디에도 없음(`conftest.py` 의 토큰은 목 서명이라 JWKS 실검증 불통과). 신규 계정 생성은 DB 쓰기라 범위 밖으로 판단해 시도하지 않음 → **HTTP 엔드투엔드 L1 미달성(정직 기록)**.
- **대체 검증(동일 프로덕션 DB + 동일 배포 코드 in-process)**: `comparison_gate._content_hash_exclusion_reason()` 직접 호출로 **양성·음성 쌍** 실증.
  - 양성: 원본 PDF 에서 `split_clauses()` 로 재추출한 `26-1-9` 전문(13,825자) → 판정 `None` = **PASS(강등 없음)**
  - 음성: 위 전문의 **마지막 1글자만** 변경 → `excluded_content_hash_mismatch` = **차단**
  - 마아트 고유 교차 오연결 3종(메리츠 stored × 삼성 text / 삼성 stored × 메리츠 text / 삼성 26-1-9 stored × 26-1-40 text) **전부 차단** 확인
- **스크린샷**: 해당없음(프론트 배선 전 단계, 화면 산출물 없음).

## 발견 이슈 및 해결

1. **`upload_policy_terms_pdf()` 가 폴더 규칙을 구현하지 않음** — `policy_terms` 바로 아래 **flat 업로드**. 회장 확정 규칙 `policy_terms/{손보|생보}/{보험사}/{상품코드}/{판매개시일}/terms.pdf` 계층이 코드에 없다(과거 flat 업로드 사고의 구조적 원인). Drive 에 실제로 legacy flat PDF 2개가 남아 있음을 실측 확인. → 이번엔 일회성 스크립트로 계층 준수 배치, **헬퍼 수정은 후속 과제**.
2. **서비스계정 스코프 `drive.file` 한계** — 타 계정이 올린 파일이 보이지 않음. OAuth(회장 gmail)로 전환해 조사·업로드·검증 수행.
3. **`/tmp` split 덤프의 `clause_text` 가 201자 미리보기로 절단** — 이걸 그대로 썼다면 양성 대조가 **거짓 실패**할 뻔했다. 아르고스가 원본 PDF 재분할로 조달해 회피.
4. **`get_clause(code)` 크래시(신규·미수정)** — code 문자열(예 `"26-1-9"`)을 넘기면 `eq("id", ...)` 단계에서 Postgres `22P02 invalid input syntax for type uuid` 예외가 그대로 전파돼 code fallback 에 도달하지 못한다(`sb_first` 는 "0행"만 흡수하지 타입 에러는 못 잡음). **프론트가 UUID 아닌 clause_id 를 보내면 500 이 될 수 있는 경로** — C-2 배선 전 처리 필요. 이번 게이트 대조는 직접 SELECT 를 써서 영향 없음. 코드 동결 원칙상 **수정하지 않고 기록만**.
5. **LLM 정독 비결정성(마아트 등급 高)** — 메리츠 `code 46` 재정독 시 최초 `auto_map` 과 **다른 분류(`question`)** 가 나왔다. 게다가 `save_mapping()` 은 **INSERT 전용**이고 `coverage_group_map` 에 `clause_id` UNIQUE 제약이 없어, 재정독이 다시 auto_map 으로 떨어지면 **같은 clause 에 group_id 다른 proposed 행이 조용히 누적**될 수 있다. 현재 중복 0건은 구조적 방지가 아니라 우연. → 후속 task 권고(사람 확정 처리 또는 부분 유니크 인덱스).

## 스코프 밖 쓰기 공개 (정직 고지)

백필 CLI 는 `--store/--apply/--limit` 만 있고 **product 필터가 없다**. 따라서 전수 실행 시 원 스코프(삼성 New내돈내삼 10건) 밖의 **`fa3832b0`(삼성 실손의료비보험) 1행**의 content_hash 도 함께 채워졌다(`null → b5e9feb9…`).

- **팀장 사전 판단으로 진행 허용**. 근거: (a) 이 도구의 설계 목적 자체가 레거시 NULL 전수 백필, (b) 다운로드 PDF 의 sha256 을 `terms_sha256` 과 대조해 불일치 시 스스로 SKIPPED 하는 자기검증 구조, (c) NULL→검증된 해시는 additive.
- **마아트 독립 실측**: 그 행의 name/detail_count/page_start/page_end/excerpt **델타 0**, content_hash 만 변경 → **무해 확인**.
- 대안(스코프 한정 재구현)을 택하지 않은 이유: 해시 산출 로직을 팀이 재구현하면 "생산자≠검증자" 불변식의 뿌리가 흔들린다.

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

**최종 판정: CONDITIONAL PASS · 불일치 0건.**

마아트는 개발팀 보고서를 신뢰하지 않고 원본 PDF sha256 직접 계산, `split_clauses()` 독립 재실행으로 시드 10건 해시 **재계산 10/10 일치**, 자체 재조회 diff(clause 5필드 + map 5필드) **0건**, 메리츠 14건 직접 집계, 게이트 함수 직접 호출 + **고유 교차 오연결 음성 대조 3종** 수행.

조건 3가지:
1. 메리츠 **부분 적재(14/299)** 를 소비하는 향후 프론트 배선 task 는 **커버리지 고지 문구**를 완료 조건에 포함해야 승인. (제안 문구: "메리츠화재 담보는 현재 14건만 지식DB에 등록되어 있으며(전체 299건 중 일부), 미등록 담보는 '확인 불가'로 표시됩니다 — 해당 상품에 다른 담보가 없다는 뜻이 아닙니다.")
2. proposed/AUTO_MAP 11건의 재현성 불안정 상태를 HITL 담당자에게 명시 전달 + 중복 누적 방지 후속 task 등록.
3. 개발팀이 "경미(정보성)"로 분류한 LLM 비결정성 위험 등급은 마아트 재판정 **高** 로 대체.

★ 현재 즉시 노출 위험은 0(프론트가 `clause_id` 를 아직 보내지 않음). 단 이는 "안전"이 아니라 **"배선 전이라 우연히 숨겨져 있음"** 이다.

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

**해당 없음 — server 코드 변경 0줄**(docs 만 추가). 리뷰 대상 런타임 diff 가 존재하지 않는다. 비코드 해치(`## 레벨: 코드 수정 없음`)의 **부여 권한은 ANU** 이므로 팀장이 자가 부여하지 않고 이 사실만 기록한다. 대신 critical 검증은 **마아트 독립 검증**으로 수행했다(위 절).

## trip-wire 5종 (실측)

| 항목 | 실측치 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 (코드 변경 0줄, 보고서·문서에 개인식별정보 없음. 약관은 공개 상품 문서) |
| 회귀 실패 | 0 (전체 1 failed 는 CORS 환경 조건부·코드 변경 0줄로 인과 없음. 타깃 서브셋 118/0) |
| forbidden_paths 침범 | 0 (server 코드 무변경, 임시 스크립트는 /tmp 한정, 타 팀 디렉토리 무접촉) |
| nonce | task-2978 일치 |

## 3문서

`/home/jay/workspace/memory/plans/tasks/task-2978/` — plan.md · context-notes.md · checklist.md (status: **completed**). 3 Step Why 기록 완료 (1st: 시드가 원본 PDF 를 열지 않음 → 2nd: 당시 성공 판정 기준에 "원문 결합"이 없었음 → 3rd: **확정(사람 판단)과 원문 결합(물리적 원본)은 다른 축인데 한 경로가 둘 다 책임지는 것처럼 보였다** — 그래서 해소책도 시드 재실행이 아니라 원본 뿌리 백필이어야 했다). 체크리스트는 L1·회귀 2항목을 `[~] 부분 달성`으로 정직 표기.

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 불칸 | 백엔드·데이터 적재 | sonnet | 조사/백필/메리츠 적재/Drive — haiku 미사용 |
| 아르고스 | 테스터 | sonnet | 게이트 계약·충돌 감사·L1 |
| 마아트 | QC 독립 검증 | sonnet | critical 필수 |
| 헤르메스(팀장) | 설계·판단·통합 | opus | 직접 코딩 0 — 경로 결정과 검토만 |

haiku 사용 0건(분석·검증 성격이라 금지 규정 준수). 이리스(프론트)·아테나(UX)는 프론트 산출물이 없어 미소집.

## 머지 판단

**PR 불요 — result-only.** server 코드 변경 0줄, 산출물은 프로덕션 DB 데이터 + Drive 배치 + 문서. 워크트리 브랜치 `task/task-2978-dev1` 에 문서 2커밋만 존재.

## 다음 단계 (T3-C2/C-3 인계)

1. **C-2 선행 필수**: `get_clause(code)` UUID 크래시(발견 4) — 프론트가 UUID 아닌 clause_id 를 보내면 500. 배선 전 처리.
2. **C-2 완료 조건에 포함**: 메리츠 커버리지 고지 문구(마아트 조건 1).
3. **별도 task 권고**: (a) `coverage_group_map` 중복 proposed 누적 방지(부분 유니크 인덱스), (b) `upsert_clause` 키를 `(product_id, cov_id)` 로 전환해 메리츠 잔여 229건 적재 가능화, (c) `upload_policy_terms_pdf()` 폴더 계층 구현 + legacy flat 파일 2건 재배치.

## QC 게이트 결과 (finish-task.sh) — ★ ANU 판단 요청

`bash scripts/finish-task.sh task-2978 dev1 /home/jay/projects/InsuRo-worktrees/task-2978-dev1`
→ **8 PASS / 1 FAIL / 11 SKIP / 3 WARN — Gate FAIL, `.done` 미생성.**

**유일한 FAIL = `tdd_check`** — "테스트 파일 없이 구현 파일만 변경됨".

**이것은 오탐이다. 근거(실측):** tdd_check 가 "구현 파일 7개"로 센 것은 전부 **`/tmp` 일회성 조회·검증 스크립트**다.

```
IMPL /tmp/task-2978-maat/maat_verify1_samsung.py   (마아트 독립 재계산)
IMPL /tmp/task-2978-maat/maat_verify2_diff.py      (시드 무손상 diff)
IMPL /tmp/task-2978-maat/maat_verify2b_diff.py     (스코프밖 1건 diff)
IMPL /tmp/task-2978-maat/maat_verify3_meritz.py    (메리츠 집계)
IMPL /tmp/task-2978-maat/maat_verify4_gate.py      (게이트 양성·음성)
IMPL /tmp/task-2978/db_census.py                   (DB 센서스)
IMPL /tmp/task-2978/drive_place.py                 (Drive 계층 배치)
```

리포 실제 변경은 **docs 6개 파일뿐이고 `server/` 구현 파일 변경은 0줄**이다(`git diff --stat origin/main` 실측). 즉 tdd_check 의 audit-trail 집계가 **워크스페이스 밖 `/tmp` 스크래치 파일까지 "구현 파일"로 계수**하는 알려진 사각지대(교차 repo blind spot)에 걸린 것이다.

**팀장이 자가 해소하지 않은 이유**: `tdd_check._is_non_code_task()` 는 **task 파일(`memory/tasks/task-2978.md`)의 키워드**로 해치를 연다. 그 파일은 작업 지시서이고 **해치 부여 권한은 ANU** 다. 실행자(헤르메스)가 지시서를 고쳐 게이트를 통과시키는 것은 **self-bypass** 이므로 하지 않았다. `.done` 수동 생성도 금지 규정대로 하지 않았다.

**요청**: ANU override 로 tdd_check 오탐을 해소하고 `.done` 처리 바람. (동일 사각지대 선례: task-2974 — "비코드 해치로 덮지 말고 ANU override 로 기록".)

**작업 자체는 완료·검증됨** — 완료 조건 2건 모두 DB 실측 충족, 마아트 독립 검증 CONDITIONAL PASS(불일치 0건). 막힌 것은 **완료 마커뿐**이다.

- 나머지 게이트(`trust_summary` 실측 근거): Readable(style_check·pyright_check) **passed** · Unified(scope_check) **passed** · Secured(schema_contract) **passed** · Trackable(data_integrity·file_check) **passed**. Tested 그룹(test_runner·tdd_check·full_suite_check)만 `passed:false` 이며 그 원인은 **tdd_check 단독 FAIL** 이다(전체 집계 FAIL 1건).
- ★ test_runner·full_suite_check·api_health 의 **개별 status(PASS/SKIP) 는 이 보고서에서 단정하지 않는다** — 집계상 FAIL 이 1건뿐이라 FAIL 이 아님은 확실하나, PASS 인지 SKIP 인지는 게이트를 재실행해야 확인 가능하고 retry 3/3 소진 위험이 있어 실행하지 않았다(추측 금지 원칙).
- retry_count: 2/3 (3회차는 오탐 상태로 소진하지 않기 위해 실행하지 않음)

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


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


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


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

