# task-2884 보고서 — 생보 3대질병: 멱등 append(UPDATE) + 계단식 2시나리오

- 팀: dev1-team (헤르메스)
- 레벨: Lv.4 · merge_policy: none (머지=ANU)
- 브랜치: `task/task-2884-dev1` (base=origin/main d8e46ae, #159 life_matrix 계약 존재)
- worktree: `/home/jay/workspace/projects/insuro/.worktrees/task-2884-dev1`
- 커밋: `3147c7f`(불칸+이리스), `784d544`(아르고스)

## Situation
손보 3사 복합설계 저장(1회) 후 생보 3대질병(life_matrix)을 이어서 캡처·저장할 때, 같은 상담을 두 번째로 저장하면 body가 달라져(life_matrix 추가) `IDEMPOTENCY_CONFLICT`로 거부됐다. 또한 웹앱은 생보-손보를 담보별 "차액 테이블"로만 보여줘, 회장이 원하는 "손보 3사 복합 대비 생보 대체 시 총액이 얼마나 달라지나"를 계단식으로 보여주지 못했다.

## Complication
- 멱등성 규약상 같은 idempotency_key + 다른 body = CONFLICT(저장 0). 생보 append가 이 규칙에 막힘.
- body_sha256은 서버가 받은 **raw body 원문 바이트**의 해시(재직렬화 금지 §2-3-b). "life_matrix만 제거한 것"을 서버가 바이트 동일하게 재현하려면 확장의 직렬화(`stableStringify`, 정렬 키·compact)를 정확히 미러링해야 함.

## Question
같은 idempotency_key에 **life_matrix만 추가**된 경우를 CONFLICT가 아니라 기존 레코드 UPDATE로 안전하게 전환하고, 웹앱에 손보3사 baseline 대비 2시나리오 계단식을 손보 스타일로 표시하려면?

## Answer (구현)

### A. 서버 — 멱등 append (`server/routes/consultation_history_v1.py`) — 불칸
- 신규 헬퍼 `_stable_stringify(value)` 추가: 확장 `background/ingest.js`의 JS `stableStringify`(재귀 키정렬·compact·공백0·`ensure_ascii=False`)를 바이트 동일하게 미러링.
- 멱등성 SELECT에 `life_matrix` 컬럼 추가.
- `existing_row` 있고 body_sha256이 다를 때, **CONFLICT 던지기 전에 append 판정**(3조건 AND):
  1. 신규 body에 life_matrix 존재(`raw_data.get("life_matrix") is not None` & `parsed.life_matrix is not None`)
  2. 기존 레코드 life_matrix가 NULL(`existing_row.get("life_matrix") is None`)
  3. `sha256(_stable_stringify({life_matrix 제거한 raw_data})) == existing_row["body_sha256"]`
- 3조건 성립 → `UPDATE {life_matrix, body_sha256}` (id 스코프) 후 응답 `{record_id, status, life_appended: True}`. **insert 0**.
  - 가드: 오직 `life_matrix`+`body_sha256`만 갱신. analysis_matrix/query_condition/기타 손보 필드 절대 미변경(NULL→채움만).
  - `updated_at`은 013 migration의 `BEFORE UPDATE` 트리거가 자동 갱신(수동 미포함).
- 불성립 → 기존 `IDEMPOTENCY_CONFLICT` 유지. replay(동일 body_sha256) 경로 불변.
- consent/tenant/RLS/fingerprint 로직 불변(append는 tenant check ⑦ 도달 전 return, 기존 fa_account_id 스코프 레코드).

### B. 웹앱 — 계단식 2시나리오 (`src/lib/lifeThreeCritical.ts`, `LifeThreeCriticalDiff.tsx`) — 이리스
- 신규 `computeLifeStaircase(lifeMatrix, composite, coverageNameMap)` 추가(기존 `computeLifeThreeCriticalDiff` + 헬퍼 재사용, 유지).
  - baseline = 손보 복합 총액(`triple_best.total ?? dual_best.total ?? single_best.total`, 숫자 아니면 coverages premium 합 폴백).
  - **(1) cancer_only**: 손보 암 premium을 생보 최저 암 premium으로 대체 → newTotal, `diffVsBaseline = sonboCancer − lifeMin`(exact). 생보사명 표기.
  - **(2) all_three**: 3담보 모두 가진 생보사 중 3담보 합 최저 단일 생보사 선정 → 손보 3담보 배정을 그 생보사 3담보 합으로 대체 → newTotal, baseline 대비 차이(exact). 생보사명 표기.
  - 둘 다 미성립/lifeMatrix 없음 → null(placeholder graceful).
- 컴포넌트를 계단식으로 재작성: baseline → (1) → (2), 각 단계 **직각 화살표**(곡선 금지)에 총액차이(exact, 월 및 N년, months=paymentYears*12 or 240) + 큰 폰트(text-2xl/3xl). 생보사명 노출. **"절감" 단어 미사용(차액/차이만)**, 반올림 없음(`toLocaleString`).
  - data-testid: `life-staircase-section`, `life-staircase-baseline`, `life-staircase-step-cancer_only`, `life-staircase-step-all_three`, `life-staircase-diff-cancer_only`, `life-staircase-diff-all_three`.

### C. 확장 재저장 흐름 (`extension/content.js`) — 이리스 (조사 결과 no-op)
- **조사 결과**: 생보 캡처(`captureLifeMatrixToSession`, storage.session 별도 키)는 손보 `_previewSnapshot`(모듈 변수)을 **덮어쓰지 않음**. 저장 성공(terminal ack) 후 저장 버튼은 `isConsentSaveTerminal`만 반영, `disabled`/`removeConsentControls` 미호출 → **재클릭 가능**. 두 요구사항 모두 현재 코드에서 이미 성립 → **content.js 변경 불필요(무의미 변경 금지)**.
- **★ ANU 에스컬레이션 (범위 밖 gap)**: `assembleRequestBody`(`extension/background/ingest.js`, **allowed_resources 밖·수정 금지**)가 매 저장 `crypto.randomUUID()`로 **랜덤 idempotency_key** 생성. 따라서 현재 프로덕션에서 재저장은 **새 key**로 나가 서버 append(동일 key 전제)가 트리거되지 않고 **새 레코드**가 될 가능성이 높음. content.js는 key를 통제할 수 없음.
  - **후속 필요(ingest.js, 불칸 영역)**: (a) 재저장 시 직전 pending/성공 blob의 idempotency_key 재사용, 또는 (b) 손보 저장 key를 세션 보존 후 생보 재저장 시 동일 key 재조립. → ANU가 별도 후속 태스크로 위임 권고.
  - 본 태스크의 서버 append 로직(A)은 그 후속이 붙는 즉시 동작하도록 완비됨.

## 테스트 결과
- **서버**: 신규 `server/tests/test_consultation_history_v1_life_append.py` **10 passed**(append 성공·손보변경 CONFLICT·replay·이미 life 있을 때 conflict·insert 회귀·`_stable_stringify` 단위+서버-미러 바이트 동일). 관련 서버 스위트(append+life+v1+delete+rls) **132 passed**.
  - 전체 `server/tests/`는 `test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` **1건 실패** — base(d8e46ae) clean worktree에서도 동일 실패 재현(env 의존, CORS/origin 코드 우리 diff에 0건 grep) → **사전 존재 실패, 회귀 아님**.
- **웹앱**: `npx vitest run` **73 files / 1085 passed**(회귀 0). `npx tsc --noEmit` **에러 0**.

## L1 스모크테스트 결과
- **서버 재시작**: 해당없음(라이브 DB/JWT/allowlist 필요한 파일럿 환경 미연결). 대신 아래 실동작 교차검증 수행.
- **API 응답 확인(핵심 실행 검증, PASS)**: 확장의 **실제 JS `stableStringify`**(background/ingest.js 원문)로 손보 body(B_sonbo)와 손보+life body(B_full)를 node로 실제 방출 → 서버의 **실제 `_stable_stringify`**(import)로 `json.loads(B_full)`에서 life_matrix 제거 후 재직렬화 → **B_sonbo와 sha256 바이트 동일**(`b3d5254…`) 확인. 정수·소수 혼합(12345.0/12345.5/0.0) 포함. 즉 프로덕션 라운드트립에서 append 매칭 성립을 실제 실행으로 확증.
  - 주의(검증된 엣지): JS `JSON.stringify`는 정수를 `.0` 없이 방출(`12345`)하므로 서버 `json.loads`가 int로 파싱→재방출도 `.0` 없음. 이론상 유일 발산(`12345.0` 표기)은 확장이 절대 방출하지 않아 프로덕션에서 발생 불가.
- **스크린샷**: 미통과(라이브 브라우저 렌더는 저장 레코드+composite 데이터 파이프라인/auth 필요 — 파일럿 미연결). 대신 컴포넌트 실제 렌더 검증은 vitest RTL(jsdom)로 수행: baseline·2시나리오·exact diff·생보사명·"절감" 부재를 real DOM에서 assert(7 tests PASS).

## 발견 이슈 및 해결
1. `_stable_stringify` float 표기 발산 우려 → L1에서 확장 실제 JS와 서버 라운드트립 바이트 동일 확증(위). 프로덕션 발생 불가로 판정.
2. CORS 테스트 실패 → base 대조로 사전 존재 확정(회귀 아님).
3. idempotency_key 랜덤 생성(ingest.js, 범위 밖) → C에서 ANU 에스컬레이션 문서화.

## 머지 판단
- **머지 필요**: Yes (ANU 소유 — merge_policy=none)
- **브랜치**: `task/task-2884-dev1` (pushed, origin)
- **머지 의견**: 변경 전부 allowed_resources 내부(server routes/tests, src lib/component/tests). manifest·migrations·골든 픽스처 불변. 서버 132 + 웹앱 1085 GREEN, tsc clean. CORS 1건은 사전 존재. append 프로덕션 정합성 L1 실증. **단, 프로덕션 end-to-end append 트리거는 ingest.js key 재사용 후속 필요(ANU 위임 권고)** — 서버/웹앱 코드는 완비.

## 모델 사용 기록
- 불칸(서버 A) / 이리스(웹앱 B, 확장 C 조사) / 아르고스(서버 테스트): 전부 **sonnet**. haiku 미사용.

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

