---
task_id: "2820"
team: dev6-team
level: 4
scope: task
result: success
merge: forbidden_pending_anu_chair
created_at: "2026-07-21T18:22:00+09:00"
---

# task-2820 — Integration Phase1 PR-A: 계약 V2 스키마 확장 (matrix 저장 + region)

## S (Situation)
InsuRo A↔B integration Phase 1 착수. 회장 결정(가, 260721): 웹 1·2·3사 재분석을 위해
`consultation_history_v1` 계약에 담보×보험사 격자(matrix)를 **같은 audited 레코드 안**에 저장.
본 task 는 **PR-A(스키마/계약/마이그레이션/테스트만)**. 접근제어(PR-B)·CRM검색(PR-C)은 별도.

## C (Complication)
실측 확정 갭: V1 은 보험사별 **합계**(`per_insurer.computed_sum` 등)만 저장하고,
재분석 엔진 `server/composite_calculator.py::calculate_composite_from_matrix` 가 요구하는
**격자**(`{insurers:[{name,code}], coverages:[{cd,name,amount,premiums:{보험사→보험료}}]}`)가 없음.
→ V1 만으론 웹 재분석 불가. 단, 계약 V1 은 frozen 원칙(§2-2 대량 payload 금지) — 확장은 하위호환·감사무결성을 깨면 안 됨.

## Q (Question)
계약 V1 frozen 을 보존하면서, 하나의 동의·멱등 레코드 안에 matrix 를 순수 additive 로 저장하려면?

## A (Answer) — 채택 설계: **V1 frozen 보존 + additive (V2 마커)**

### ★ 버전 처리 결정 (근거 명시 — 작업지시 요구사항)
**V1 additive(하위호환) 방식 채택.** contract_version 을 `Literal["OHMY_PPD_V1", "OHMY_PPD_V2"]` 로 확장하되,
cross-field validator 로 **V1 요청은 matrix 를 절대 실을 수 없게** 강제(V1 frozen 보존).
- 근거 1 (하위호환·감소 0): 기존 763 테스트는 전부 `OHMY_PPD_V1` + matrix 부재. validator 규칙상 종전과 100% 동일하게 통과 → **회귀 감소 0**.
- 근거 2 (frozen 무결성): `OHMY_PPD_V1` + `analysis_matrix` 조합은 422 로 거부 → V1 레코드는 계약 원형 그대로. matrix 는 오직 `OHMY_PPD_V2` 에서만 운반.
- 근거 3 (단일 레코드): matrix 는 같은 envelope 필드(`analysis_matrix`)로 저장 → **별도 테이블·별도 멱등키 없음**(Codex 제약: "한 번의 계약된 저장"이 갈라지지 않음). body_sha256 는 raw body(=matrix 포함)에서 계산되어 matrix 변경 시 자동 `IDEMPOTENCY_CONFLICT`.

### 계약문서+스키마/SQL 한 PR 동거 근거
계약 문서(artifact)와 스키마/SQL(runtime)이 한 PR 에 함께 가는 것은 **"계약 V2"라는 단일 응집 변경**이므로 의도적임(무관한 혼합 아님). 작업지시 §제약 명시 준수.

## 변경 파일 (server/** + docs/contracts/** + supabase/migrations/** 만)

### 생성 (2)
- `server/migrations/014_consultation_history_v2_matrix_and_region.sql` — analysis_matrix JSONB(nullable) 추가 + ok_only 뷰 갱신(analysis_matrix append) + customers.region TEXT(nullable). **append-only**(기존 013 미변경).
- `supabase/migrations/20260721120000_consultation_history_v2_matrix_and_region.sql` — 위와 **100% 동일**(미러). `diff` 출력 0.

### 수정 (4)
- `server/schemas/consultation_history_v1.py` — `CONTRACT_VERSION_V2` 상수, `MatrixInsurer`/`MatrixCoverage`(premiums 음수 거부 field_validator, 0 허용)/`AnalysisMatrix` 서브모델, `contract_version` Literal 확장, `analysis_matrix: AnalysisMatrix | None = None` 필드, `model_validator(mode="after")` cross-field 규칙. `extra="forbid"` 유지.
- `server/routes/consultation_history_v1.py` — `insert_payload` 에 `analysis_matrix` 키 추가(V2 만 값, V1 은 None). 멱등/unique scope/기존 필드 **무변경**.
- `docs/contracts/INSURO_LV4_PROGRAM_CONTRACTS_V1.md` — 말미 "계약 5. V2 Additive 확장" 섹션 append(결정·matrix 형태·region·금지필드 유지 명시).
- `server/tests/test_consultation_history_v1.py` — 신규 섹션 J 10개 테스트 append(기존 테스트 무수정).

## 마이그레이션 상세
- `analysis_matrix JSONB` **nullable** — V1 레코드는 NULL. 순수 additive.
- ok_only 뷰: `CREATE OR REPLACE VIEW` 는 기존 출력 컬럼 순서 유지 + 신규 컬럼은 **끝에만** 추가 가능 → analysis_matrix 를 deleted_at 뒤에 append.
- `customers.region TEXT` nullable(자유텍스트, 회장 결정 260721).
- 기존 013/20260719 마이그레이션 파일 **미변경**(append-only 규율). ALTER ... ADD COLUMN IF NOT EXISTS 로 013 적용 여부와 무관하게 멱등 안전.

## 검증 결과

### 테스트 (전부 통과)
- `pytest server/tests -q -p no:randomly` → **776 passed**(기존 763 + 신규 13, **감소 0**), 38 warnings. 독립 재실행 3회 동일(~102s). worktree root/서브디렉토리 양쪽에서 동일 통과.
- 신규 테스트(섹션 J, 13개): matrix round-trip 재분석 동일성 / V2 matrix 필수 / V1 matrix 금지 / V2 end-to-end 저장 / premiums 음수 거부·0 허용 / SQL 2파일 미러 동일 / ok_only 뷰 analysis_matrix 포함 / **(Gemini 반영)** 빈 premiums 거부 / 빈 insurers·coverages 거부 / premiums 키 insurers 정합 검증.

### Gemini PR 리뷰 대응 (PR #114)
- Gemini(gemini-code-assist) 리뷰 수신: **High 0건**, Medium 2건.
- **[ACCEPT] Medium#1** (schema L210): `MatrixCoverage.premiums` → `min_length=1`(빈 premiums 담보 거부, 0값 허용 유지).
- **[ACCEPT] Medium#2** (schema L235): `AnalysisMatrix.insurers/coverages` → `min_length=1`(빈 matrix 거부) + cross-field validator(각 coverage.premiums 키가 insurers code/name 집합에 존재하는지 검증).
- 근거: CONTRACT 스키마 데이터 정합성 강화, 순수 additive(파일럿 정상 payload 무영향), 회귀 감소 0. 반영 commit `1077055`. PR 코멘트로 수용 회신 게시.
- 미수정 High **0건** → G3 게이트 PASS 기준 충족. 단 머지는 설계상 금지(ANU/회장).

### SQL 미러 동일성
- `diff server/migrations/014_*.sql supabase/migrations/20260721120000_*.sql` → **출력 0 (MIRROR_IDENTICAL)**.

### py_compile
- schema/route/test 3파일 **에러 0**.

### 변경 범위 격리
- `git diff --stat`: docs/contracts(1) + server/routes(1) + server/schemas(1) + server/tests(1) + migrations 신규 2. **금지 경로(extension/src/main.py/customer_match/legacy_guard/.github) 변경 0**.

## L1 스모크테스트 결과 (필수 기록)
- **실행한 스모크 (수행함, PASS)**: 스키마→재분석 엔진 파이프라인을 실제 실행하여 계약 요구("저장형태로 재분석 시 원분석 동일")를 관찰 검증:
  - `AnalysisMatrix.model_validate(matrix).model_dump(mode="json")` → `calculate_composite_from_matrix()` 결과가 원본 matrix 재분석과 **완전 동일(True)**, data_count=5 (비자명 결과) → **PASS**.
  - `contract_version=OHMY_PPD_V2` + matrix 부재 → **거부(OK)**, cross-field validator 실동작 확인 → **PASS**.
- **테스트 실행 결과 (증거)**: `pytest server/tests -q -p no:randomly` → **776 passed** (독립 3회 동일) → **PASS**.
- **서버 재시작**: 스키마/계약/마이그레이션 변경이라 런타임 서버 재기동 불필요(ingest 라우터 default-DISABLED, 런타임 무영향). 대신 위 스키마 실행 검증으로 실동작 관찰.
- **API 응답 확인**: 신규 엔드포인트 없음(기존 라우터 additive). 위 스키마 레벨 실행 검증으로 대체 수행함.
- **스크린샷**: 백엔드 스키마 작업으로 UI 산출물 없음 → 실행 스모크(코드 실행 결과)로 증거 제출.
- 근거: pytest PASS 를 넘어 스키마→재분석 엔진 파이프라인을 실제 실행해 계약 요구를 관찰 검증함(표면 통과 아님).

## 발견 이슈 및 해결
- pyright reportMissingImports(schemas/composite_calculator/main/routes) + `__file__` str|None + Depends 미사용 경고: 전부 **pre-existing 패턴**(conftest sys.path 런타임 주입 방식·FastAPI Depends 주입·typeshed Optional). 런타임 773 passed 로 정상. 신규 도입 버그 아님 → 무수정.

## 머지 판단
- **머지 필요**: No (작업지시 §완료 "머지 금지 — ANU/회장 판정"). 본 task 는 PR 생성 + 독립검증 대상까지.
- **브랜치**: `task/task-2820-dev6`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2820-dev6`
- **base**: origin/main `f675bab` (gate G-1 검증 기준 커밋과 동일)
- **머지 의견**: 순수 additive·회귀 감소 0·미러 동일·frozen 보존. Gemini High 0 확인 후 ANU/회장 최종 판정 대기. PR-B(접근제어)·PR-C(CRM검색) 선행 의존 아님(스키마 기반).

## 모델 사용 기록
- 스바로그(백엔드): **sonnet** — 스키마/라우트/SQL/테스트 구현.
- 팀장(페룬, opus): 설계 결정(V2 additive)·구조 선행 파악·검토·L1 독립 검증·통합. 직접 코딩 0.

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

