# InsuRo 복합설계 Lv.4 프로그램 — 공통 계약 4종 + 병렬/통합/머지 계획 (DRAFT)

**상태**: **DRAFT — 회장 승인 전. dev dispatch 금지.**
**작성**: 아누 2026-07-18 KST · 지시: 회장(GPT 협의안) · 검토: Codex CC 예정
**전제**: A-0 라이브 성공(`transport_ok:true, http_status:200, company_count:10, url_construction:SOLVED, token_source:URL_QUERY, token_expired:false`) — 회장 단일 세션·단일 진입경로 한정.

---

# 0. 프로그램 구조

**Lv.4 parent** = 프로그램 등급. **하나의 거대 task 금지.** 경계 분리된 하위 track으로 구성.

| Track | 범위 | 디렉토리 | 실데이터 |
|---|---|---|---|
| **A** | 확장 production 이식 | `extension/**` | 로컬 조회·미리보기까지만, **서버 전송 OFF** |
| **B** | 서버·CRM 기반 | `server/**`, `supabase/**` | **fixture/mock만**, 실고객 write 금지 |
| **C** | 계산엔진·UI 프로토타입 | (별도 bounded task) | **fixture만**, 이번 통합의 blocker 아님 |
| **A↔B 통합** | 실제 전송 연결 | 별도 task | A·B 각각 독립검증 후에만 |

---

# 계약 1. 데이터 계약 (이미 동결 — 참조)
`memory/specs/ohmy_product_premium_data_contract_v1_260718.md` (V1 + AMENDMENT-1)
- 선택담보 = DOM `data-cd`(checked) / 가입금액 = `input#input_<cd>`
- 보험료 = `API premium × (화면금액 ÷ API 기준금액)`
- 화면 검증 = 선택담보 `em[premium]` 합 = 화면 상단 회사총액 (실측 10/10 일치)
- **검증 범위**: 관측 조건 한정. 일반화 금지.
- **fail-closed 방어 9종 유지.**

---

# 계약 2. 전송 envelope 계약 (신규)

## 2-1. **클라이언트 → 서버** payload (이것만)
```
{
  contract_version: "OHMY_PPD_V1",
  fa_account_id: <우리 InsuRo 로그인 FA 계정 ID>,   // authoritative (인증 세션으로 서버가 재확인)
  query_condition: { age, gender, insurance_type, plan_id },
  request_fingerprint: <string>,
  dom_selection_hash: <string>,
  api_captured_at: <ms>,        // ★ 동일조회 불변식 4요소 완비 (계약1 §5)
  dom_snapshotted_at: <ms>,
  selected_coverages: [ { coverage_cd, amount } ],
  per_insurer: [ { company_code, computed_sum, screen_sum, diff, validation_status } ],
  validation: { overall_status, failure_codes: [...] },
  consent: { granted_at },
  client_version: <확장 버전>
}
```

## 2-1-a. ★ `external_subject_key` 는 **서버 전용 생성값** (초안 모순 정정)
> Codex 지적: 초안은 2-1(클라이언트 payload)과 3-2/3-3(서버 HMAC)에서 **정면 충돌**했다. dev가 가장 오해하기 쉬운 지점.

- **클라이언트는 `external_subject_key` 를 생성·전송하지 않는다.** payload 에 넣으면 **서버가 거부**한다.
- 서버가 **외부 주체를 검증한 뒤 서버 비밀로 HMAC 을 파생**해 저장한다.
- **클라이언트가 보낸 키를 서버가 신뢰하는 구현은 금지**(신뢰경계 붕괴).
- 클라이언트가 세션전환 탐지를 위해 로컬에서 쓰는 값은 **메모리 내 비교용**이며 **전송 대상이 아니다.**

## 2-2. ★ 절대 포함 금지
- **ohmymanager JWT 원문·부분문자열**
- **`consultantid` 원문**, `ga_id`
- **API 전체 원본 응답**(coverage_premiums 원본·plan_coverages 등 대량 payload)
- 불필요한 고객 PII(주민번호·연락처·주소 등). **이름·생년월일은 CRM 고객 레코드 참조로 대체**(payload 직접 포함 최소화)
- 브라우저 URL 전체(토큰 포함 가능)

## 2-3. 전송 조건
- **명시적 사용자 클릭 + 동의**가 있을 때만.
- `validation.overall_status !== "OK"` 이면 **정상 상담결과로 저장 금지**(§계약4).
- 조회 즉시 자동 전송 **금지**. 오류 시 **조용한 재시도 금지**(중복 생성 방지).
- **멱등성**: `(fa_account_id, request_fingerprint, dom_selection_hash)` 조합으로 중복 저장 차단.

---

# 계약 3. 신원·tenant 계약 (신규) ★ CRM 안전 1순위

## 3-1. 권한의 authoritative 기준
**우리 InsuRo 로그인 FA 계정(`fa_account_id`) 하나뿐.**
CRM tenant·저장 권한·조회 권한은 **전부 이것으로 결정**한다.

## 3-2. ohmymanager 외부 주체 취급
- 토큰 payload의 `consultantid`는 **서명 검증 없이 누구나 위조 가능** → **인증된 신원이 아니다.**
- **금지**: 클라이언트가 decode한 `consultantid`로 **CRM tenant 결정·저장 권한 결정·타 설계사 데이터 접근 결정**.
- **허용(유일)**: **세션 주체가 바뀌었을 가능성 탐지** → 로컬 미리보기·pending payload **즉시 폐기**.
- 저장 시 외부 주체는 **`external_subject_key` = 서버 HMAC**(원문 미저장·서버 비밀 없이는 전수대입 곤란·동일 주체는 안정적 동일 키)으로만. **단순 salted hash 금지**(consultantid는 짧은 숫자라 전수대입 가능).
- **HMAC 키 생성은 외부 주체가 검증된 뒤에만.**

## 3-3. 매핑 규칙
- `fa_account_id` ↔ `external_subject_key` 는 **서버측 검증된 매핑**으로 연결.
- **매핑이 없거나 불일치 → 자동으로 새 사용자에게 붙이지 말고 저장 차단** (`TENANT_MAPPING_MISSING`).

## 3-4. 계정 전환 상태기계 (필수)
```
현재 owner A → 조회·미리보기 생성
  → owner 가 B 로 변경 감지
  → A의 응답·계산·미리보기·pending save 전부 즉시 폐기
  → B로 새 조회 전까지 저장 버튼 비활성화
  → 로그인 FA 계정과 B의 매핑 확인
  → 확인 후에만 새 상담 저장 가능
```
**캐시 금지**: 이전 API response · plan_id · selection hash · 고객 미리보기 · 저장 대기 payload · 이전 토큰.
전환 판단은 **토큰 원문 비교가 아니라 안정된 외부 subject 비교**로(같은 사용자도 토큰은 갱신됨).

## 3-5. ★ stale URL 혼합 차단 (회장 지적 파생 — 권한경계 침범)
> A설계사 조회 → resource timing에 A의 URL 잔존 → B로 전환 → 페이지 토큰은 B → **A의 조회조건 + B의 토큰**으로 재조회

**fetch 전 아래 전부 일치해야 진행. 하나라도 불일치 → fail-closed + "조회하기를 다시 눌러주세요"**:
- resource URL의 `age/gender/insurance_type/plan_id` == **현재 화면 조건**
- resource entry 생성시각 **> 현재 세션의 마지막 조회 시각**
- 현재 external owner == 조회 당시 external owner

---

# 계약 4. 실패 상태 공통 enum (신규) — A·B 동일 문자열 사용

## 4-1. 전송/인증 계층
`TOKEN_ABSENT` · `TOKEN_MALFORMED` · `URL_NOT_ALLOWED` · `SENDER_NOT_ALLOWED` · `TRANSPORT_ERROR` · `HTTP_401_UNAUTHORIZED` · `HTTP_403_FORBIDDEN`

## 4-2. 데이터 정합 계층
`API_MISSING` · `API_PREMIUM_MISSING_OR_INVALID` · `ZERO_PREMIUM_UNVERIFIED` · `NEGATIVE_INVALID` · `PARSE_FAIL` · `ZERO_BASELINE` · `DIFFERS_ACROSS_COMPANIES` · `DOM_DUPLICATE` · `API_DUPLICATE` · `SCREEN_CELL_MISSING` · `OUT_OF_TOLERANCE`

## 4-3. 세션/신원 계층
`STALE_URL_MISMATCH` · `SESSION_OWNER_CHANGED` · `TENANT_MAPPING_MISSING` · `CONSENT_MISSING` · **`EXTERNAL_SUBJECT_MISSING`**

> `EXTERNAL_SUBJECT_MISSING` (Codex 지적 — 누락 보완): 토큰은 존재하나 **외부 주체(consultantid 등)를 파생할 수 없는 경우**. `TOKEN_MALFORMED` 와 범위가 다르므로 별도 코드. **fail-closed**(세션전환 판정·tenant 매핑 불가 → 저장 차단).

## 4-4. 종합 판정 (저장 게이트)
| overall_status | 의미 | 저장 |
|---|---|---|
| `OK` | 전 항목 통과 | 정상 상담결과 저장 허용 |
| `REQUIRES_REVIEW` | 일부 담보/보험사만 실패, 나머지 유효 | **검토 필요 상태로만** 저장. 정상 결과로 표시 금지 |
| `BLOCKED` | 신원·세션·동의 실패 | **저장·전송 자체 차단** |

**원칙**: 어떤 실패도 **조용히 통과 금지**. 실패 코드는 반드시 payload에 열거.

---

# 병렬 안전·통합·merge 계획

## P-1. 착수 packet (병렬 invariant A 체크리스트)
| 항목 | Track A | Track B |
|---|---|---|
| expected_files | `extension/**` | `server/**`, `supabase/migrations/**` |
| 타 track 중첩 | **0** (디렉토리 분리 확인) | **0** |
| 상태기계 변경 | 확장 내부만 | 서버 내부만 |
| 설정/인프라 공통부 | **수정 금지**(`package.json`·`vitest.config.ts`·`tsconfig.json`) | **수정 금지**(동일) |
| 판정 | **병렬 가능** | **병렬 가능** |
| 예상 merge 순서 | (아래 P-3) | |

**공유 계약 문서**(`memory/specs/*.md`)는 **본 승인 시점에 동결 → 양 track 읽기 전용**. 수정 필요 시 **양 track 중단 후 계약 개정**.

## P-2. 각 track STOP 조건
- **A**: 계정전환/stale URL 차단이 구조적으로 불가 · 서버 전송 없이 미리보기 불가 · 진단브랜치 코드 필요 · manifest 권한 확대 필요
- **B**: 계약2 envelope으로 상담이력 표현 불가 · tenant 분리가 스키마상 불가 · 실고객 데이터 필요

## P-3. merge 순서 (**병렬 개발 O / 병렬 머지 X**)
```
1) 계약 PR (본 문서 확정본)  ← 최우선, 단독
2) A 또는 B 중 먼저 완료된 것 1개  ← current main 기준 stale base 확인 후
3) local main ff-only sync → 나머지 1개를 새 main 기준 재검증 후 merge
4) A↔B 통합 task (실제 전송 연결) ← 둘 다 독립검증 완료 후에만
5) 제한 파일럿 → 운영 활성화 (각각 회장 승인 게이트)
```
- **동시 merge 금지.** Merge Queue 미활성 → 순차 게이트를 **ANU가 직접 관리**.
- 각 PR에 병렬 invariant B 문구 필수: *"current main 기준 stale base 확인 완료. expected_files 밖 diff 0. 진행 중 병렬 task와 파일/자원 중첩 0. merge는 순차 진행 대상."*

## P-4. ★ 통합 전 실고객 write 차단 — **현재는 불가가 아님. 만드는 것이 A·B의 필수 작업**

> ★ 초안 오류 정정(Codex 발견 → ANU 실측 확인): 초안은 "이미 구조적으로 불가"라 썼으나 **거짓**이었다.
> **`origin/main` 에 활성 경로가 그대로 존재한다**:
> - `server/main.py:7225` `POST /api/insuro/composite-design/ingest` (DEPRECATED 주석은 있으나 **본체 보존·활성**)
> - `server/main.py:7353~` `POST /api/insuro/ohmy-capture` (신 경로, **활성**)
> - `extension/background.js:62` `fetch(\`${base}/api/insuro/ohmy-capture\`, {method:"POST"})` (**활성**)
> Track A가 main 에서 새 브랜치를 따면 **이 전송 코드가 그대로 딸려온다.**

**따라서 "실고객 write 불가"는 전제가 아니라 A·B의 수용기준(필수 산출물)이다:**
- **A 필수**: `extension/background.js` 의 `ohmy-capture` **POST 함수를 소스에서 삭제**(분기 플래그로 가리기 금지) + 레거시 캡처 버튼 **부착 경로 0**. (A-0 브랜치 `task-2788+3` 에서 검증된 방식 — **코드 복사 금지, 방식만 참조**.)
  → 수용기준: 빌드 산출물에서 `ohmy-capture` **실호출 도달경로 0**(정적+행동 테스트 이중 증명).
- **B 필수**: 두 ingest 엔드포인트를 **명시적으로 게이트**(feature flag OFF 또는 인증·tenant 미충족 시 fail-closed). **A↔B 통합 승인 전 실고객 write 수용 금지.**
  → 수용기준: 통합 전 상태에서 실고객 payload 가 **거부**됨을 테스트로 증명.
- **양 track 완료 후에만** "실고객 write 불가"를 **사실로 주장 가능**. 그 전까지는 **주장 금지.**

## P-5. 진단브랜치 격리 (강화)
- 대상: `task/task-2787-dev5` · `task/task-2787+2-dev5`(진단 +2~+7) · `task/task-2788-dev5`(A-0 스파이크). **로컬·remote 양쪽 존재 확인됨.**
- **금지**: merge · cherry-pick · rebase 이식 · **수동 복사 · 부분 포팅 · 코드 스니펫 붙여넣기**.
- **지식 전달은 오직 계약 문서(본 문서 + 데이터 계약 V1)를 통해서만.** 진단 브랜치 파일을 열어 코드를 옮기는 행위 자체를 금지한다("참조만"은 오해 소지 → 명시).
- A는 **최신 `origin/main` 에서 새 브랜치**. 착수 시 base SHA 기록.

## P-6. 회장 승인 게이트
① 본 계약 확정 ② A merge ③ B merge ④ A↔B 통합 ⑤ 파일럿 ⑥ 운영 활성화 — **각각 회장 승인**.

---

# 부록. P-4 / P-5 사실주장 검증 증거 (재확인 가능하도록 명령·출력 박제)

> Codex 지적: "확인됨" 단정만 있고 **근거가 문서에 없어 독립 재검증 불가**. → 아래에 **실행 명령과 원문 출력**을 박아 누구나 재현 가능하게 한다.

**검증 시각**: 2026-07-18 KST · **검증 주체**: ANU 직접 실행 · **작업 디렉토리**: `/home/jay/projects/InsuRo`

## P-4 근거 — `origin/main` 활성 전송 경로 (재현 명령)

> ★ Codex 재지적 반영: 이전 판본은 신 경로의 **주석 라인(7353)만** 박제해 "활성"을 증명하지 못했다.
> **판정 기준: `@app.<method>(...)` 데코레이터(=FastAPI 라우트 등록)가 소스에 존재하면 그 경로는 활성 라우트다.** method 는 post/get/put 등 무관 — 등록 자체가 활성의 근거.
> **분류 기준(별개)**: `post` = **write 계열** / `get` = **read 계열**. 게이트 우선순위가 다르므로 분리해 센다.

```bash
git show origin/main:server/main.py | grep -nE '^@app\.(post|get|put)\(.*(ohmy-capture|composite-design/ingest)'
```
```
7225:@app.post("/api/insuro/composite-design/ingest")
7403:@app.post("/api/insuro/ohmy-capture")
7612:@app.get("/api/insuro/ohmy-capture-history")
7667:@app.get("/api/insuro/ohmy-capture-history/{capture_id}")
7727:@app.post("/api/insuro/ohmy-capture/{capture_id}/link")
7858:@app.post("/api/insuro/ohmy-capture-history/{capture_id}/reassign")
```
**판정 결과 (위 기준 적용)** — 라우트 등록이 확인된 관련 엔드포인트 **총 6개**:

**write 계열 (`@app.post`) 4개 — Track B 게이트 1순위**
- `7225` `/api/insuro/composite-design/ingest` (구 경로. "호환성 위해 본체 보존" = **여전히 활성**)
- `7403` `/api/insuro/ohmy-capture` (**신 경로, 활성** — 이전 판본이 증명 못 했던 항목)
- `7727` `/api/insuro/ohmy-capture/{capture_id}/link`
- `7858` `/api/insuro/ohmy-capture-history/{capture_id}/reassign`

**read 계열 (`@app.get`) 2개 — tenant 격리 대상(write 게이트와 성격 다름)**
- `7612` `/api/insuro/ohmy-capture-history`
- `7667` `/api/insuro/ohmy-capture-history/{capture_id}`

→ **Track B 대상은 초안이 말한 2개가 아니라 위 6개.** write 4개는 **통합 전 실고객 write 차단**, read 2개는 **설계사 간 교차조회 차단**(계약3 tenant 분리)이 수용기준.

```bash
git show origin/main:extension/background.js | grep -nE "ohmy-capture|method: *\"POST\""
```
```
54:// 신 경로: OHMY_MATRIX_CAPTURED → /api/insuro/ohmy-capture (명시 클릭만)
56:// [신 task-2354 Phase 1] 매트릭스 캡처 → /api/insuro/ohmy-capture
62:    const r = await fetch(`${base}/api/insuro/ohmy-capture`, {
63:      method: "POST",
```
→ **확장 쪽 서버 POST 실호출이 main 에 존재**(62~63행 = 주석 아닌 실코드).

```bash
git show origin/main:extension/background.js | grep -nE "ohmy-capture|method: *\"POST\""
```
```
54:// 신 경로: OHMY_MATRIX_CAPTURED → /api/insuro/ohmy-capture (명시 클릭만)
56:// [신 task-2354 Phase 1] 매트릭스 캡처 → /api/insuro/ohmy-capture
62:    const r = await fetch(`${base}/api/insuro/ohmy-capture`, {
63:      method: "POST",
```
→ **확장 쪽 서버 POST 실호출이 main 에 존재.**

## P-5 근거 — 진단브랜치 실재 (재현 명령)
```bash
git branch -a | grep -E "2787|2788"
```
```
+ task/task-2787+2-dev5
+ task/task-2787-dev5
+ task/task-2788-dev5
  remotes/origin/task/task-2787+2-dev5
  remotes/origin/task/task-2787-dev5
  remotes/origin/task/task-2788-dev5
```
→ **로컬 3 + remote 3 실재.** 수동 포팅 유혹이 물리적으로 존재하므로 P-5 금지조항 필요.

## 검증 재현 지침
Track A·B 착수 봇은 **위 명령을 재실행해 동일 결과를 확인**한 뒤 작업을 시작한다. 결과가 다르면(예: 이미 제거됨) **STOP_REPORT** 후 계약 개정.

---

# 미해결·유보 (정직 기록)
- 다른 설계사·다른 진입경로에서 `token_source`가 `URL_QUERY`인지 **미검증** → 파일럿에서 확인
- 반올림 규칙 미확정(화면 셀 값 사용으로 회피)
- resource timing 취약성 → A에서 **DOM 기반 URL 구성을 1순위**, resource timing은 fallback
- `.done`/G4 게이트 결함 = 별도 Core 위생 백로그(본 프로그램 blocker 아님)
