# task-2976 — [T3-G1] axis_signature 갱신형(renewal) 축 편입 + signature 마이그레이션

- **팀**: dev2 (오딘)
- **레벨**: Lv.3 (critical)
- **대상 repo**: `/home/jay/projects/InsuRo` (base `d46bba8`)
- **worktree**: `/home/jay/projects/InsuRo/.worktrees/task-2976-dev2` (브랜치 `task/task-2976-dev2`)
- **PR**: [#230](https://github.com/Jeon-Jonghyuk/InsuRo/pull/230) — head `67740c3` · state `open` · **머지 HOLD**
- **근거**: `memory/meetings/2026-08-18-insuro-t3-batch-decision.md` (회장 승인 2026-08-18, A→G→C→B→D)

---

## S — 상황

`server/policy_grouping/axes.py::CoverageAxes.axis_signature()` 가 `renewal`(갱신형 여부)을 서명에서
제외하고 있었다. 이 서명은 ① `grouping_rule.axis_signature` **UNIQUE 학습룰 키** ②
`policy_extract/grouping_bridge.py::_axes_cross_verify()` **교차검증 키** 두 곳의 단일 판정 기준이다.

## C — 문제

갱신형 담보와 비갱신형 담보가 **동일 시그니처**를 가져 교차검증을 통과 → 보험료 구조가 전혀 다른
담보가 동일 담보로 공식 비교표에 오른다(**금소법 오비교 P0**). `LEARNED_RULE` 자동전이가 이미
열려 있어 라이브 노출 경로였다.

**실데이터 증명(합성 아님)**: 골든셋 `samsung_axes_golden.json` 14건 중 `26-1-23`(renewal=false)과
`27-1-25`(renewal=true)는 나머지 8축이 완전히 동일해 **v1 서명 문자열이 정확히 일치**했다
(fixture `notes` 에 "[갱신형] 접두 — 26-1-23과 동일 구조" 명시). 실측: 원본 14건 →
**v1 고유 서명 12개 → v2 고유 서명 13개**(+1 분리).

## Q — 질문

정상 경로를 깨지 않고, 기존 학습룰도 잃지 않으면서 갱신형↔비갱신형 오비교를 봉인할 수 있는가?

## A — 조치

1. **`renewal: bool` → `renewal_structure: Axis` 축 승격** — `RenewalStructure` enum 11값
   (`NON_RENEWABLE` / `RENEWABLE_1Y~20Y` / `RENEWABLE_UNSPECIFIED` / `REENROLLMENT` / `UNKNOWN`).
   로키 H1("10년갱신·20년갱신·재가입형이 bool True로 뭉개짐") 해소. 레거시 bool 은
   `__post_init__` 미러로 유지 → 하위호환.
2. **`axis_signature()` 에 `renewal` + `renewal_evidence` 편입 + `v2|` 프리픽스**.
   `renewal_evidence`(SOURCED/SELF_REPORTED)가 provenance 결속 — `renewal` 은 담보명 라벨
   "[갱신형]"만으로 자기신고 가능한 유일한 축이라 fail-closed 분리. `v2|` 는 v1 과 네임스페이스를
   물리 분리해 stale 학습룰의 우연한 오매칭을 원천 차단.
3. **signature 마이그레이션** — `grouping_rule.signature_version` 컬럼 + `axis_signature_v1()`
   (무결성 대조 전용) + backfill CLI(dry-run 기본, `--apply`, `--census-only`).

실측 결과: 갱신/비갱신 서명 분리 ✅ · 시드 confirmed 10건 재사용 **orphan 0** ✅ ·
전체 pytest **2287 passed** · 회귀 실패 **0** · 기존 봉인 회귀 **122 passed** · 신규 회귀 **41건**.

---

## 3 Step Why

- **1st Why (A)** — 왜 필요한가? `axis_signature()` 가 renewal 을 빠뜨려 갱신형/비갱신형이 같은 키를
  갖고, 이 키가 학습룰 UNIQUE 키이자 교차검증 키라서 오비교가 공식 비교표까지 흘러간다.
- **2nd Why (B)** — 왜 지금(C보다 먼저)인가? T3-C(화면 비교)는 결과를 사용자에게 노출한다. 게이트가
  뚫린 채 C를 켜면 오비교가 설계사·고객에게 그대로 보인다(금소법 설명의무 위반).
- **3rd Why (C)** — 왜 bool 편입이 아니라 축 승격인가? bool 을 그대로 서명에 넣으면 10년/20년/재가입형이
  전부 True 로 뭉개져 붕괴가 잔존하고(로키 H1), bool 은 마케팅 라벨만으로 자기신고가 가능하다.
  → enum 세분화 + provenance 결속 두 가지가 함께 필요.

A-B-C 일관성: ✅ (모두 "오비교를 화면 노출 전에 원천 차단"으로 수렴)

---

## 수정 파일별 검증 상태

| 파일 | 라인 | 변경 요약 | 검증 방법 | 상태 |
|---|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/policy_grouping/axes.py | 297 | RenewalStructure enum · renewal_structure 축 · __post_init__ 정합성 · renewal_evidence_state() · axis_signature v2 · axis_signature_v1() | 팀장 직접 실행: 갱신/비갱신 서명 분리 True, 10Y/20Y/재가입 3자 분리 True, v1 동일(구멍 재현) True | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/policy_grouping/knowledge.py | 992 | _axes_to_dict renewal_structure 직렬화 · GroupingRule.signature_version · learn_rule 3경로 · iter_grouping_rules/iter_group_maps/migrate_rule_signature | 시드 10건 backfill 후 find_rule hit=10 miss=0 실측 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/policy_grouping/reading.py | 384 | _parse_renewal_structure 파싱 헬퍼 + axes_from_ai_dict 배선 | pytest 41건 통과 · grep 반영 확인 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/policy_grouping/prompts.py | 221 | _AXES_JSON_SCHEMA renewal_structure · _RENEWAL_STRUCTURE_ENUM · 판정기준 블록 | 팀장 직접 실행 build_axis_reading_prompt(): placeholder 잔존 False, REENROLLMENT/RENEWABLE_10Y 렌더 True | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/policy_grouping/__init__.py | 145 | 신규 심볼 export | import 실행 성공 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/new_design_comparison/models.py | 331 | CoverageAxesIn.renewal_structure additive | L1 실서버 POST HTTP 200(422 아님) | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/new_design_comparison/engine.py | 685 | _axis_with_provenance provenance 보존 전달 | L1 실서버 POST HTTP 200 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/scripts/migrate_axis_signature_v2.py | 403 | v1→v2 backfill CLI · census_renewal_evidence · 운영 런북 · 알려진 한계 | 팀장 직접 실행 dry-run/apply: scanned=10 migrated=9 orphan=1 mismatch=0 · census self_reported=10 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/supabase/migrations/20260819090000_grouping_rule_signature_version.sql | 57 | signature_version 컬럼 + 인덱스 + 운영순서 주석 | ADD COLUMN IF NOT EXISTS · 기존 UNIQUE 무변경 코드리뷰 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/tests/test_renewal_axis_seal_task2976.py | 415 | 구멍재현/종단차단/대조군/H1/자기신고/레거시호환 18건 | pytest 실행 통과 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/tests/test_seed_signature_migration_task2976.py | 372 | 시드 재사용 orphan 0 · 골든셋 v1 충돌 실증 9건 | pytest 실행 통과 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server/tests/test_migrate_axis_signature_v2_task2976.py | 371 | census/read-only/분류 14건 | pytest 실행 통과 | verified |

---

## 테스트 결과 (팀장 독립 실행)

| 항목 | 수치 | 비고 |
|---|---|---|
| 전체 pytest (worktree HEAD `67740c3`) | **2287 passed, 1 failed, 2 skipped** (168s) | |
| 대조군 base `d46bba8` (clean /tmp worktree, `.env` 없음) | 2246 passed, 3 skipped, 0 failed | |
| 대조군 base `d46bba8` + **동일 `.env`** | **동일 1 failed** (`test_cors_fail_closed_when_ext_origin_unset`) | ★ 결정적 대조 |
| **회귀 실패** | **0** | 유일 실패는 base 에서도 재현되는 pre-existing 환경 이슈 |
| 기존 봉인 회귀 (t2970 + t2974 + comparison_gate) | **122 passed** | |
| 신규 회귀테스트 | **41 passed** | 3파일 |
| 기존 테스트 파일 수정 | **0개** | 기대값 완화 없음 |
| `comparison_gate.py` / `grouping_bridge.py` 변경 | **0줄** | 기존 봉인 로직 무변경 |

### 봉인 핵심 실측 (팀장 직접 실행)
```
갱신10Y: v2|...|renewal=RENEWABLE_10Y|renewal_evidence=SOURCED
비갱신 : v2|...|renewal=NON_RENEWABLE|renewal_evidence=SOURCED
★ 갱신 != 비갱신 : True
★ 10Y/20Y/재가입 3자 구분: True
★ 자기신고(SELF_REPORTED) vs 근거(SOURCED) 분리: True
v1 서명에 renewal 토큰 없음: True | v1에서 갱신==비갱신: True  ← 구멍 재현
```

### backfill 실측 (팀장 직접 실행)
```
seed confirmed = 10
DRY-RUN: {"scanned":10,"migrated":9,"orphan":1,"integrity_mismatch":0,"already_v2":0}
APPLY  : {"scanned":10,"migrated":9,"orphan":1,"integrity_mismatch":0,"already_v2":0}
★ backfill 후 v2 서명 재사용: hit=10 miss(orphan)=0
census(시드 적재 후): {"total":10,"legacy_renewal_only":0,"self_reported":10,"sourced":0,"unknown_structure":0}
```
(orphan 1건 = 팀장이 `source_clause_id=None` 으로 인위 주입한 재계산 불가 케이스 — 정상 분류 확인용)

---

## L1 스모크테스트

- **서버 재시작**: **성공**
  - HEAD: `cd /home/jay/projects/InsuRo/.worktrees/task-2976-dev2/server && python3 -m uvicorn main:app --host 127.0.0.1 --port 18976` → `/api/status` **HTTP 200**, `sha=c11d76d`
  - 대조군 base: `/tmp/insuro-base-2976/server` 포트 18977 → `/api/status` **HTTP 200**, `sha=d46bba8`
  - ★ 운영 서비스(`insuro-api`, user systemd)는 **재시작하지 않음** — 별도 포트만 사용
- **API 응답 확인**:
  - **L1-A** 정상 경로 무변경 payload → `POST /api/insuro/new-design-comparison` **HTTP 200**,
    `official_comparison`/`unknown_report`/`meta` 전부 존재, `meta.confirmed_only=true`
  - **L1-B** `renewal_structure`(provenance 포함) 추가 payload → **HTTP 200**(422 아님) —
    신규 필드가 실서버 스키마에 실제 배선됨을 종단 증명
  - **L1-C** `RENEWABLE_10Y` vs `NON_RENEWABLE` 2회 호출 → **두 응답 동일**
    (`unknown_report` 전량 `MAP_NOT_CONFIRMED`/`UNKNOWN`) → **오비교 차단이 HTTP 응답에서는 관측 불가**
  - 인증: task-2963 산출 세션의 `refresh_token` 으로 실제 유효 JWT 재발급하여 호출(스텁 아님)
  - 포트 정리 완료: `lsof -i:18976 -i:18977` 잔존 리스너 없음
- **스크린샷**: 해당없음 (백엔드 API 작업 — 프론트 변경 0)

### ★ L1-C 정직 고지
`CoverageRowIn`(기존측 담보)에 `clause_id`/`axes` 필드가 **존재하지 않아** 기존 담보가 구조적으로
`CONFIRMED` 에 도달할 수 없다(task-2968 에서 이미 알려진 사전 결함). 그래서 renewal 값이 무엇이든
게이트는 항상 `MAP_NOT_CONFIRMED`/`UNKNOWN` 으로 수렴하며, 이 PR 의 오비교 차단은 **현재 종단
HTTP 응답에는 드러나지 않는다**. 봉인 자체는 단위/통합 레벨(41건)과 pure function 실행으로 증명됨.
**T3-C 배선 시점에 발현**되며, 그때 게이트가 이미 닫혀 있어야 하므로 본 PR 의 선행 가치는 유효하다.

---

## ★★★ CI 상태 — Critical Escalation (회장 조치 필요)

**CI 실행 불가.** PR #230 의 모든 GitHub Actions 잡이 **0 스텝·2초 만에 실패**한다.

GitHub API annotation 원문:
> "The job was not started because recent account payments have failed or your spending limit needs to
> be increased. Please check the 'Billing & plans' section in your settings"

- **범위**: 레포 전역. **base `d46bba8`(main) 도 동일하게 실패**(잡 3초·0스텝) — 본 PR 이 만든 실패가 아님.
- **영향**: required status checks(`ci`/`guard`/`qc-check`/`merge-safety-check` 등) 전부 failure →
  **머지 자체가 GitHub 레벨에서 차단**됨.
- **완료조건 대비 판정**:
  - "구멍 재현 회귀테스트가 **원격에 실재**" → ✅ **충족**. `gh api contents` 로 원격 브랜치 실측:
    `test_renewal_axis_seal_task2976.py`(19,457B, sha `f524c01e`),
    `test_seed_signature_migration_task2976.py`(19,653B, sha `68e1ba56`),
    `test_migrate_axis_signature_v2_task2976.py`(15,801B, sha `e1888d1d`)
  - "**CI 에서 실행**" → ❌ **미충족**. 과금 차단으로 러너가 기동조차 못 함. **로컬 GREEN 만으로는
    t2970 교훈상 불인정**이므로, 과금 해소 후 CI 재실행 확인이 머지 전 필수 조건이다.

---

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

`pass: true` · risks 3 (HIGH 2, MEDIUM 1) → **1회 시정 라운드 수행**(bounded fix budget 준수).

### HIGH #1 — 배포 원자성(v1 학습룰 즉시 미조회) · Codex 제안 dual-read
**판정: dual-read 기각(REJECT), 운영 런북으로 대응.**
기각 사유: v2 miss 시 v1 폴백은 갱신형을 **비갱신형으로 학습된 v1 룰**에 매칭시켜 **이 PR 이 봉인하는
P0 를 다시 연다**. orphan → HITL 재질문은 fail-safe(오매핑 대신 재질문)이며 금소법상 올바른 방향.
→ backfill 스크립트 docstring + 마이그레이션 SQL 헤더에 **운영 순서**(① SQL 적용 → ② backfill
`--apply` → ③ 앱 배포) 및 기각 사유 명시.

### HIGH #2 — `coverage_group_map.axes` 미백필 → SOURCED/SELF_REPORTED 불일치
**판정: 유효한 지적. 부분 수용(관측 가능성 추가) + 한계 명시.**
팀장 실측으로 재현 확인:
```
저장 시드 axes → NON_RENEWABLE / SELF_REPORTED
저장(SELF_REPORTED) vs 신규정독(SOURCED) 교차검증 → False / axes_signature_mismatch
시드 10건 evidence: SELF_REPORTED 10 / SOURCED 0
```
완전 해결 불가 사유: 레거시 행에 provenance 를 채우려면 조항 원문 인용이 필요한데 코드로 만들면
**날조**(추측 금지 원칙 위반). → `census_renewal_evidence()` + `--census-only` 로 잔존량 상시 관측,
docstring 에 "confirmed 재사용이 끊기고 HITL 재질문으로 강등된다(오비교는 아님)" 명시.
**해소 경로 = 조항 재정독(G-2 후속)**.

### MEDIUM #3 — in-memory 중심 검증, 실 DB 통합 테스트 없음
**판정: 한계로 문서화.** 운영 DB 권한 밖. 배포 담당이 `--store supabase --census-only` → dry-run →
`--apply` 순으로 진행하도록 런북에 기록.

---

## 발견 이슈 및 해결

### 자체 해결 (3건)
1. **테스트 명칭 과잉주장** — 헤임달의 `TestGoldenSetRenewalHoleWasReal` 이 증명하지 않은 것을
   증명한 것처럼 읽혔다. → `TestGoldenSetRenewalSignatureSeparation` 으로 정직화 + v1 충돌을
   문자열 대조로 **직접 증명하는** 테스트 신설(커밋 `c11d76d`).
2. **팀장 측정 오류(자체 정정)** — 1차 측정에서 `seed_cancer5.load_golden_items()` 반환값(10건,
   CANCER5 필터)을 "골든셋 14건"으로 오독해 "골든셋에 renewal 충돌 없음(9/9)"이라는 **잘못된 결론**을
   내리고 헤임달에게 잘못된 정정을 지시했다. 헤임달의 재실측(12/13)이 이를 바로잡았고, 팀장이
   독립 재측정으로 확정했다. **교훈: 건수를 라벨링하기 전 `len()` 출력 확인 필수.** 3문서에 기록.
3. **Codex HIGH 2건** — 위 §Codex 사전 검증대로 1회 시정 라운드 완료(커밋 `67740c3`).

### 범위 외 미해결 (3건)
1. **L1-C 종단 미관측** — `CoverageRowIn` 에 `clause_id`/`axes` 부재(task-2968 기 알려진 결함).
   범위 외 사유: 기존측 스키마 확장은 T3-C 배선 작업이며 지시서가 "G-1 한정"을 명시.
2. **SELF_REPORTED 잔존** — 조항 재정독 없이는 provenance 부착 불가(날조 금지). **G-2 후속 필요.**
3. **`renewal == UNKNOWN` 시 공식 비교 제외 게이트 미신설** — 시드/기존 경로 회귀 위험이 있어
   G-1 범위 밖. 권고사항으로 기록.

---

## 팀원 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 토르 (Thor) | 백엔드 | sonnet | axes/knowledge/reading/prompts/engine/SQL/backfill 구현 · L1 실서버 검증 · Codex HIGH 시정 |
| 헤임달 (Heimdall) | 테스트/QA | sonnet | 구멍 재현 회귀테스트 41건 · 시드 마이그레이션 실측 · 과잉주장 정정 |
| 오딘 (Odin) | 팀장 | opus | 설계·API 계약 확정·통합 검증·Codex 판정·독립 실측 (직접 코딩 0) |

haiku 미사용(전건 sonnet 이상 — critical 작업).
미미르(UX/UI)·프레이야(프론트) 미소집: 프론트 변경 0줄.

---

## 머지 판단

- **머지 필요**: Yes — 단 **HOLD**
- **브랜치**: `task/task-2976-dev2` (원격 head `67740c3`)
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2976-dev2`
- **PR**: #230
- **머지 의견**:
  - 봉인 자체는 41건 회귀 + 팀장 독립 실측으로 증명, 회귀 실패 0, 기존 봉인 로직 무변경(Surgical).
  - ★ **머지 전 필수 2조건**: ① **GitHub Actions 과금 해소 후 CI 실제 GREEN 확인**
    (현재 러너 기동 불가 — 로컬 GREEN 불인정) ② ANU 독립검증.
  - ★ **배포 시 순서 엄수**: 마이그레이션 SQL 적용 → backfill `--apply` → 앱 배포.
    순서 역전 시 v1 학습룰이 일시 orphan(오매핑 아님, HITL 재질문 강등).
  - 이 PR 머지·배포가 **T3-C(화면 비교)의 안전 선행조건**이다.

## 다음 단계 (회장/ANU 판단 필요)

1. **[회장]** GitHub Actions 과금/spending limit 해소 — 현재 레포 전체 CI 정지 상태
2. **[ANU]** 독립검증 후 PR #230 머지 판단
3. **[후속]** G-2: 약관 원문 content-hash 불변식 + 레거시 axes 재정독(SELF_REPORTED 해소)
4. **[후속]** T3-C: 삼성 시드 화이트리스트 화면 비교 (G-1 GREEN 확인 후에만)

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

