# [Lv.4] Integration Phase2 PR-D2 — 확장 매칭 UI·FA확인·reference 배선·잠금·재시도·A7 재범위화

## allowed_resources
```yaml
allowed_resources:
  paths:
    - "extension/**"
  forbidden_paths:
    - "src/**"
    - "server/**"
    - ".github/**"
    - "docs/**"
  commands:
    - "npx vitest"
    - "git"
  merge_policy: "none"
  ttl_hours: 24
```
> ★ merge_policy=none — PR OPEN 유지(자동머지 금지). 머지=아누 Codex+독립검증.

## 배경·단일소스 (반드시 읽어라)
- 합의 미팅록: `/home/jay/workspace/memory/meetings/2026-07-22-insuro-extension-prD-design.md` (Codex 포함 CONSENSUS).
- 설계: `/home/jay/workspace/memory/plans/insuro-composite-design/integration-design-260721.md`.
- 리포 `/home/jay/projects/InsuRo`, base=현 origin/main(9d369fd). Phase2 서버(PR-C 후보검색·생성, PR-D0 GET·CORS)+확장 PR-D1(동의→assemble→V ingest flag OFF·taint-guard·INVALIDATED blob 폐기) 완료.
- **아키텍처 X**: 확장이 캡처→매칭→동의→조립→전송. 이 task = **PR-D1 위 매칭·FA확인 배선 + 잠금/재시도 UX + A7 재범위화**(D2b, 단일 확장 PR). D2a(redaction)는 드롭(서버가 body 로깅 안 함).

## 서버 계약 (이미 존재 — 확장이 소비)
- **후보검색**: `POST /api/insuro/crm/customer-candidates/search` — 입력 `{name, dob, gender}`(필수). 출력 = 후보 배열, 각 후보 **마스킹 형태** `{id, name_masked, dob_masked, gender, phone_masked, region}` + count. (raw PII 아님)
- **고객생성**: `POST /api/insuro/crm/customers` — 입력 `{name, dob, gender, phone(필수), region?}`. 출력 = 신규 `id`(=reference_id).
- 인증: 둘 다 JWT Bearer + 인카멤버십. **확장은 저장된 JWT(storage.local)를 `Authorization: Bearer` 로 붙인다**(기존 SET_INSURO_JWT 브리지 확보분). CORS는 PR-D0 처리.
- `reference_id`는 ingest 에서 서버가 **per-FA 소유 재검증**(이미 구현) — 확장이 고른 id 를 서버가 재확인.

## 작업 (합의 반영)
1. **후보검색 UI**(확장): 동의 저장 흐름 진입 시, 캡처된 이름+생년월일+성별로 `POST customer-candidates/search` 호출 → **마스킹 후보 리스트 표시**(name_masked·dob_masked·gender·phone_masked·region). FA 가 눈으로 확인·선택 → 그 후보 `id` = reference_id.
   - 검색 POST 도 **taint-guard·single egress** 대상(이름+생년월일+성별도 PII — canary sink-spy 에 포함). 검색 응답 마스킹값은 표시용.
2. **무매칭 → 신규 생성**: "일치 고객 없음, 신규 등록?" (유사후보 함께 표시) → FA 확인 → 생성 폼(이름/생년월일/성별 자동채움, **핸드폰 필수 입력**, 지역 선택) → `POST crm/customers` → 신규 id = reference_id.
3. **reference_id 배선**: 확정된 reference_id 를 `assembleRequestBody({snapshot, analysis_matrix, consent, reference})` 의 reference 로 전달(PR-D1 조립부 재사용). 조용한 자동붙임/자동생성 0 — 전면 FA 확인.
4. **INVALIDATED·계정전환 저장버튼 잠금 UI**: A4 INVALIDATED(stale URL/DOM/계정전환) 시 저장버튼 **잠금 + 사유 안내 + 재캡처 액션**. (PR-D1 이 pending blob 폐기는 이미 함 — 여기선 UI 잠금·안내.) 만료 시 투명 re-fetch 없음 → 명시적 재-preview·재-동의.
5. **재시도 UX**: 전송 실패 시 **명시 재시도 버튼** + 한국어 메시지. 조용한 자동 재시도 0. (재시도 = PR-D1 의 같은-bytes 재전송 재사용.)
6. **A7 뮤테이션 재범위화**: 기존 부재-강제 가드(`a7-write-path-blocked.test.ts`·`track-a-mutation.test.ts`)를 **"V-ingest(consent-gated) 밖의 서버 write 재도입 → 실패"** 로 재작성. "레거시 자동전송 부재" 는 유지. 즉 V ingest·crm 검색/생성은 허용, 그 외 서버 write 는 여전히 금지. **삭제 아니라 재범위화**.

## 제약
- extension/ 밖 변경 0. JWT storage 이동 금지(local 유지). 서버·웹앱 미변경(PR-E).
- flag(V ingest) 켜지 마라(테스트만 enable).
- 검색·생성 요청의 이름/생년월일/성별/핸드폰은 PII — **taint-guard·canary sink-spy·single egress·URL allowlist** 대상(PR-D1 인프라 재사용, 신규 egress 는 crm 엔드포인트만 allowlist 추가).
- 확장은 dumb carrier: 매칭 결정은 FA 확인, canonical 진실은 서버.

## 검증 (전부 통과)
- 후보검색 → 마스킹 후보 표시 → FA 선택 → reference_id 확정 → assembleRequestBody 정상. 무매칭 → 생성(핸드폰 없으면 막힘) → reference_id.
- **canary sink-spy 확장**: 검색/생성 요청의 이름·생년월일·성별·핸드폰·**JWT** 마커가 비허용 sink(console/비허용 fetch/storage.local/URL/telemetry/postMessage/dataLayer)에 = 0. crm 엔드포인트만 허용.
- INVALIDATED 시 저장버튼 잠금·재캡처 안내, pending blob 폐기 유지(PR-D1 회귀 0), 무동의 재시도 0.
- 재시도 = 같은 bytes(재직렬화 0), 조용한 자동 재시도 0.
- **A7 재범위화**: V-ingest·crm 검색/생성은 통과, 그 외 서버 write 재도입 뮤턴트 → 실패(killed). 레거시 자동전송 차단 유지.
- 기존 확장 전체(276) 회귀 0. `npx vitest run extension/__tests__/`.

## 완료
- extension/ 외 변경 0 (`git diff --stat`)
- **dev6 금지** — dev1. worktree finish(STRICT+정확base+worktree cwd) → PR(**OPEN 유지·머지 금지** merge_policy none) → ANU 독립검증.
- **ANU callback**(UTF-8 ≤3900 bytes, envelope). scope-guard 막힘 시 result.json+ANU callback.

## goal_assertions (auto-generated)
- `npx vitest run extension/__tests__/`
