# TRACK_A_SNAPSHOT_ALIGN — ImmutablePreviewSnapshotV1 계약 §2-1 정합 (task-2804)

- 팀: dev1-team (헤르메스)
- 브랜치: `task/task-2802-dev1` (기존 7커밋 위에 **추가 커밋 1개**)
- 워크트리: `/home/jay/projects/InsuRo/.worktrees/task-2802-dev1`
- 커밋: `4008651` [task-2804] 이리스+아르고스: ImmutablePreviewSnapshotV1 을 계약 §2-1 에 정합
- PR 생성 **0** · main merge **0** · `server/**` 수정 **0** · 신규 파일 **0**

---

## S (상황)
Track A 의 `ImmutablePreviewSnapshotV1`(extension/content.js) 과 Track B 수신 스키마
(`server/schemas/consultation_history_v1.py`, `extra="forbid"`)가 3곳에서 어긋나 있었다.

## C (문제)
이대로 integration 하면 서버가 payload 를 422 로 거부한다. 기능 추가가 아니라 **스키마 정합만** 필요.

## Q (질문)
스냅샷이 서버 계약을 정확히 만족하는가? 그리고 그 사실을 어떻게 증명하는가?

## A (답변)
3건 모두 조치 완료. **실물 스냅샷을 B 의 pydantic 모델에 직접 통과시켜** 정합을 증명했다
(테스트 PASS 만으로는 부족하다고 판단해 스키마 대조까지 수행).

---

## 불일치 3건 · 각 조치

### 불일치 1 — `validation` 에 계약 밖 필드
- **전**: `validation: { overall_status, failure_codes, failure_counts, tolerance_basis }`
- **후**: `validation: { overall_status, failure_codes }` (계약 §2-1 / B `ValidationResult` 와 동일)
- 진단값은 버리지 않고 **스냅샷 밖**으로 이동: `buildImmutablePreviewSnapshotV1` 반환값을
  `{ snapshot, renderModel, diagnostics: { failure_counts, tolerance_basis } }` 로 확장
  (기존 `snapshot`/`renderModel` 키 유지 → 호출측 구조분해 호환)
- content.js:1478-1497

### 불일치 2 — `age` 타입 (`string|null` → `int`)
- 신규 헬퍼 `normalizePpdQueryCondition(raw)` 추가 (content.js:614-643)
  - age: 문자열은 `/^\s*\d+\s*$/` 만 허용 → `Number` 변환, number 는 `Number.isInteger` 확인, **0~150 범위**(B 의 `ge=0, le=150` 와 동일)
  - 실패 시 `null` 반환 → 호출측 fail-closed
- `extractPpdConditionsFromUrl` 의 반환 형태는 **의도적으로 raw 유지** (A4 `crossCheckConditions` 가 원문 대조에 쓰므로 기존 동작 훼손 방지)
- `queryCondition` 조립부가 `normalizedConditions` 를 쓰도록 변경 → `age` 가 number 로 들어감 (content.js:977-989)

### 불일치 3 — 4요소 null 허용
- `handlePpdPreviewClick` 에 fail-closed 게이트 추가 (content.js:889-901, A4-(1) DOM 교차검증 **직전**)
  - `code: "STALE_URL_MISMATCH"` (**기존 코드 재사용 — 신규 실패코드 발명 0**), `reason: "query_condition_incomplete"`
  - 사용자 안내 `"조회하기를 다시 눌러주세요"` → 계약 A4 정신과 동일
  - `invalidatePreviewState()` 호출 + **background sendMessage 미발송**(네트워크 요청 0)
- **왜 게이트가 필요했나**: `crossCheckConditions` 는 URL 값이 없으면 `UNAVAILABLE_NO_VALUE` 로
  `ok=true` 를 유지한다(=null 에 대해 fail-open). 기존 게이트만으로는 막히지 않는 경로였다.
- **방어 심층화**: `buildImmutablePreviewSnapshotV1` 내부에서도 재검증 후 실패 시
  `throw new Error("PPD_QUERY_CONDITION_INVALID")`. 기존 `.catch()` 가 이미 INVALIDATED + 안내를
  수행하므로 **신규 분기 0** (content.js:1430-1442)

---

## ★ 스키마 정합 증명 (실물 대조)

Track A 실물 코드가 만든 스냅샷을 파일로 방출한 뒤(임시 하네스, 검증 후 삭제·커밋 0),
Track B 워크트리의 pydantic 모델에 그대로 넣어 검증했다. **B 는 읽기만 했고 수정하지 않았다.**

방출된 실물 스냅샷 (발췌):
```json
"query_condition": { "age": 42, "gender": "M", "insurance_type": "LF", "plan_id": "P1" },
"validation":      { "overall_status": "OK", "failure_codes": [] }
```
```
=== 하위 모델 직접 검증 ===
  PASS QueryCondition: {'age': 42, 'gender': 'M', 'insurance_type': 'LF', 'plan_id': 'P1'}
  PASS ValidationResult: {'overall_status': OverallStatus.OK, 'failure_codes': []}
  PASS SelectedCoverage / PerInsurerResult

=== 전체 envelope (integration 소유 필드 미포함) ===
  잔여 에러 4건: reference_type / reference_id / idempotency_key / consent — 전부 missing
  >> Track A 책임 영역 잔여 에러: 0건

=== 전체 envelope (integration 필드까지 채움) ===
  ACCEPT  현재 Track A 스냅샷        ← 422 없음

=== 회귀 증명: 수정 전 형태였다면 ===
  REJECT  불일치1 validation 진단필드 → validation.failure_counts (extra_forbidden),
                                        validation.tolerance_basis (extra_forbidden)
  ACCEPT  불일치2-a age 문자열 "42"    ← ★ 아래 GAP 참조
  REJECT  불일치2-b age null           → query_condition.age (int_type)
  REJECT  불일치2-c age "abc"          → query_condition.age (int_parsing)
  REJECT  불일치3 gender null          → query_condition.gender (string_type)
  REJECT  불일치3 insurance_type null  → query_condition.insurance_type (string_type)
  REJECT  불일치3 plan_id null         → query_condition.plan_id (string_type)
```

---

## 테스트

| 시점 | 결과 |
|---|---|
| 베이스라인 (수정 전) | `Test Files 6 passed (6)` / `Tests 152 passed (152)` |
| 이리스 구현 후 | 164 passed |
| 아르고스 증명 테스트 후 (최종) | **`Test Files 6 passed (6)` / `Tests 173 passed (173)`** |

vitest 최종 원문:
```
 Test Files  6 passed (6)
      Tests  173 passed (173)
   Start at  08:05:27
   Duration  1.47s
```
- **감소 0** (152 → 173, +21). skip / xfail / `.only` / `.todo` **0건** (grep 확인)
- 기존 테스트 삭제·약화 0. `diagnostics` 이동에 따른 assert 경로 수정만 수행

추가된 핵심 테스트:
- `Object.keys(snapshot.validation).sort()` 가 **정확히 `["failure_codes","overall_status"]`** (키 집합 전체 일치로 고정 — `extra="forbid"` 대응)
- `Object.keys(snapshot.query_condition).sort()` 가 정확히 4키 / `age` 가 `Number.isInteger` & 0~150 / 문자열 3필드 non-empty
- **실패코드가 발생하는 경로(`failure_codes` 비어있지 않음)에서도** 동일 정합 유지 → 진단 필드가 새지 않음을 증명
- `age: "abc"/null/200` → `rejects.toThrow("PPD_QUERY_CONDITION_INVALID")`
- `gender`/`insurance_type`/`plan_id` 각각 null → 헬퍼 null + 빌더 reject
- 클릭 경로: gender 파라미터 부재 → `STALE_URL_MISMATCH` + `sendMessage` 미호출

---

## L1 스모크테스트 결과
- **서버 재시작**: 해당없음 (본 작업은 브라우저 확장 content script 순수 로직 변경. `server/**` 미수정이며 기동 대상 서버 없음)
- **API 응답 확인**: 해당없음(HTTP 호출 없음) — 대신 **동등 이상의 실물 검증 수행**:
  실물 코드가 생성한 스냅샷 JSON 을 Track B 의 pydantic 모델에 직접 투입 →
  `ConsultationHistoryV1Request` **ACCEPT**(Track A 영역 에러 0건), 수정 전 형태는 **REJECT**(위 원문)
- **스크린샷**: 해당없음 (UI 변경 없음. 변경은 스냅샷 필드 구성·타입·게이트 로직에 한정)
- L1 실행·통과 항목: **스냅샷↔서버스키마 실물 대조 1건 실행/통과**, vitest 173건 실행/통과

---

## 발견 이슈 및 해결

1. **`crossCheckConditions` 의 null fail-open** — URL 값이 없으면 `UNAVAILABLE_NO_VALUE` 로 `ok=true` 유지.
   기존 게이트만으로는 4요소 null 이 통과했다. → 전용 fail-closed 게이트를 A4 직전에 신설해 해결.
2. **`extractPpdConditionsFromUrl` 를 직접 고치지 않은 이유** — 반환값을 정규화하면 `crossCheckConditions`
   의 원문 폴백 대조(`MATCH_RAW_UNKNOWN_ENCODING`)가 깨져 A4 동작이 훼손된다. 정규화를 별도 헬퍼로
   분리해 회피.
3. **미해결 0건.**

---

## MATCH / GAP

**MATCH**
- `validation` 키 집합 = `{overall_status, failure_codes}` — B `ValidationResult` 와 정확히 일치 (실증)
- `query_condition` 4키 / `age: int(0~150)` / 문자열 3필드 non-null — B `QueryCondition` 과 일치 (실증)
- `selected_coverages`, `per_insurer` 도 B 하위 모델 통과 (부수 확인)
- 신규 실패코드 0 · `server/**` 수정 0 · skip/xfail 0 · 추가 커밋 1개 · PR/merge 0

**GAP (정직한 한계)**
1. **`age` 문자열 `"42"` 는 원래도 422 가 아니었다.** pydantic v2 non-strict 는 `"42" → 42` 로 coerce 한다.
   즉 불일치 2 의 실제 422 위험은 **`null`·비수치 문자열 케이스**였고, `"42"` 는 통과했을 것이다.
   지시서의 "정수로 파싱" 요구는 그대로 이행했으며(계약 명세 정합 + strict 모드/타 소비자 대비),
   과장 없이 기록한다. **실질 422 차단 효과가 확인된 것은 불일치 1·3 과 불일치 2 의 null/비수치 분기.**
2. **전체 envelope 은 integration 소유 필드(`reference_type`/`reference_id`/`idempotency_key`/`consent`)를
   테스트 하네스가 채워 넣은 상태에서 검증**했다. Track A 는 해당 필드를 만들지 않는 것이 계약상 정상이며,
   그 조립의 정합은 integration 단계에서 별도 확인이 필요하다.
3. **B 스키마는 아직 main 에 없다** (`.worktrees/task-2801-dev6` 에만 존재). 본 검증은 그 시점 스냅샷 기준이며,
   B 가 머지 전 변경되면 재대조가 필요하다.
4. 실제 브라우저에서 사람이 조작한 E2E 는 수행하지 않았다(확장 로드 환경 부재). jsdom 기반 클릭 경로
   테스트로 대체했다.

---

## 머지 판단
- **머지 필요**: **No** (지시서 절대 제약: PR 생성 0 · main merge 0)
- **브랜치**: `task/task-2802-dev1`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2802-dev1`
- **머지 의견**: Track B(task-2801) 와 함께 integration 시점에 일괄 판단할 사안. 본 커밋 단독 머지 불필요.
  코드 품질/충돌 위험은 낮음(단일 파일 + 테스트 2개, 다른 팀 파일 무접촉).

---

## 모델 사용 기록
- 이리스(프론트엔드) — **sonnet**: content.js 스키마 정합 구현
- 아르고스(테스터) — **sonnet**: 스키마 정합 증명 테스트 9건 추가
- 헤르메스(팀장) — **opus**: 설계(G1), diff 독립 검증, 실물 pydantic 대조(L1), 통합/보고
- haiku 사용 **0건**
- 불칸(백엔드)·아테나(UX/UI) 미소집 — `server/**` 수정 금지 + UI 변경 없음이라 역할 해당 없음

## 비고
- 디자인 작업 없음 → 디자인팀 호출 불필요
- 임시 L1 하네스(`zz-l1-emit.tmp.test.ts`)는 검증 후 삭제, 커밋에 포함되지 않음(신규 파일 0 확인)

---

## ★ finalize 차단 — Scope Guard 오탐 (아누 판단 요청)

`finish-task.sh` 는 QC 를 통과(**WARN**, git_evidence PASS, l1_smoketest PASS)했으나
**Scope Guard 에서 차단**되어 `.done` 이 생성되지 않았다. `.escalate` / `.scope-violation.json` 생성됨.

### 차단 사유
```json
{"path": "extension/background.js", "reason": "paths 미포함 (scope 외 파일)"}
```

### 원인 (오탐으로 판단하는 근거)
Scope Guard 는 `merge-base(origin/main, HEAD)..HEAD` 를 diff 한다. 본 작업은 지시서대로
**기존 브랜치 `task/task-2802-dev1` 위에 이어서 커밋**했으므로, 이 diff 에는 task-2800/task-2802 의
선행 7커밋이 통째로 포함된다.

```
# task-2804 커밋 단독 변경 (d5e03e2..HEAD) — 3개, 전부 allowed_resources 내부
extension/__tests__/track-a-gates.test.ts
extension/__tests__/track-a-preview.test.ts
extension/content.js

# Scope Guard 가 본 누적 diff (main..HEAD) — 선행 커밋 포함
extension/__tests__/a7-write-path-blocked.test.ts      ← task-2800
extension/__tests__/track-a-mutation.test.ts           ← task-2802
extension/background.js                                 ← task-2802 (777ec57 불칸)
...

# background.js 를 마지막으로 만진 커밋
777ec57 [task-2802] 불칸: background.js OHMY_PPD_FETCH_V1 핸들러 추가
```
→ `background.js` 는 **task-2804 가 건드리지 않았다**(git 로그로 확인). 이번 커밋의 실제 변경은
`extension/content.js` + `extension/__tests__/**` 3개뿐이며 **expected_files 위반 0**이다.
또한 본 작업의 `merge_policy: none` 이므로 main 으로 들어가는 변경도 없다
(머지 0 확인: main tip 은 여전히 `cc7476b`).

### 조치하지 않은 이유 (정직한 기록)
- Scope Guard 의 base 는 `MAIN_BRANCH` 로부터 스크립트 내부에서 계산되며 **환경변수로 지정할 수 없다**
  (`worktree-base.json` fallback 은 merge-base 가 비었을 때만 쓰인다 — 본 건은 merge-base 가 정상 계산됨).
- 우회하려면 `scripts/finish-task.sh`(시스템 인프라) 수정이 필요한데, 이는 본 task 의
  `allowed_resources` 밖이며 "다른 팀/시스템 코드 수정 금지" 규칙에 걸린다.
- **`.done` 수동 생성은 하지 않았다.** 게이트를 손으로 넘기지 않는다.

### 아누 요청 사항
1. 본 건을 **오탐으로 승인**하고 finalize 를 진행할지 판단
2. 또는 "선행 브랜치 위에 이어서 커밋하는 task" 유형에 대해 Scope Guard 의 base 를
   `worktree-base.json` / 지시서의 base 커밋으로 해석하도록 인프라 개선 (구조적 해결)

### 부수 발견 (인프라)
`finish-task.sh` 를 project_path 없이 호출하면 QC evidence root 가 `$WORKSPACE` 로 잡혀
**git_evidence 가 FAIL** 하고 `.qc-result` 자체가 생성되지 않아 `[ERROR] .qc-result 파일이 생성되지
않았습니다` 로 중단된다(1차 시도에서 실제 발생, supervisor-crash-marker 생성). 원인은
task-timers.json 에 `worktree_path` 가 없어 자동 인식이 실패한 것. `worktree_path` 를 기록한 뒤
재실행하니 git_evidence PASS 로 정상화됐다. **QC 실패가 아니라 경로 해석 문제**였음을 기록해 둔다.
