# task-2831 보고서 — Integration Phase2 PR-D1 (확장 명시적 동의 저장 수직 슬라이스, flag OFF)

- 팀: dev1-team (헤르메스 팀장 / 불칸·이리스·아르고스)
- 레벨: Lv.4 · 프로젝트: InsuRo · merge_policy: **none (PR OPEN 유지)**
- worktree: `/home/jay/projects/InsuRo/.worktrees/task-2831-dev1` (branch `task/task-2831-dev1`, base=origin/main `bf44379` = PR-D0 포함)
- 단일 소스 계약: `memory/plans/insuro-composite-design/prD1-ingest-contract-260722.md` (팀장이 미팅 8항+설계 §3 기반 고정)

## Situation
Phase2 서버(PR-D0: V GET·CORS)가 origin/main 반영됨. 확장은 아직 Track A 로컬 미리보기까지만. 아키텍처 X(확장 캡처→동의→조립→전송, 확장=dumb carrier, 진실=서버)에서 **D1 수직 슬라이스** = 기존 preview 위에 명시적 동의 저장·조립·V ingest·보안불변식을 얹되 **flag OFF**(프로덕션 노출 0).

## Complication
- content.js는 preview 확정까지만(MV3 storage.session=TRUSTED_CONTEXTS라 조립·전송은 background 필수).
- taint-guard = "leak-proof 완전성" 주장 폐기 → 수명최소화+allowlist scrubber+sink-spy canary+single egress choke+URL allowlist, opaque 래퍼는 fail-loud tripwire로 강등(미팅 합의).
- 재시도=명시 버튼+같은 key+frozen bytes 동일 POST(서버 멱등), 상태조회 엔드포인트 신설 금지.
- JWT는 storage.local 유지·이동 금지, ohmy 토큰 무저장. extension/ 밖 변경 0.

## Question
Track A 회귀 0 + 보안 불변식(canary=0·consent-gate·byte보존·수명·single egress·tripwire)을 전부 지키며 flag OFF로 조립·저장 수직 슬라이스를 완결할 수 있는가?

## Answer (구현 결과)

### 생성/수정 파일 (전부 extension/ 하위 — 밖 변경 0)
| 파일 | 담당 | 내용 |
|---|---|---|
| `extension/background/ingest.js` (신규 328줄) | 불칸 | 순수 로직: `assembleRequestBody`(화이트리스트+stableStringify, JSON.stringify 전체금지, idempotency_key 1회 생성), `createOpaqueValue`(toJSON/toString/valueOf throw = tripwire), `isAllowedIngestUrl`(insuro.biz https·localhost:8000 + 고정 path만), `sendIngest`(**유일 egress fetch**, consent→pending→flag→URL→JWT 게이트, POST body=디코드 frozen bytes 그대로), `save/load/clearStoredBlob`(storage.session 단일 blob + read-time lazy expiry 30분), `handleIngestExpireAlarm`, base64/sha256 헬퍼. module.exports 25개(테스트 노출) |
| `extension/background.js` (+121) | 불칸 | `importScripts("./background/ingest.js")`(MV3 classic worker 전역 로드, vm-test 스킵 가드) + `INSURO_CONSENT_SAVE_V1`/`INSURO_CONSENT_RETRY_V1` 핸들러 + owner-change(SET_INSURO_JWT 내부·external 양쪽 전송차단 후 clear). 기존 OHMY_PPD_FETCH_V1/GET_STATUS/legacy block 원문 유지 |
| `extension/content.js` (+206) | 이리스 | preview `VALID`+snapshot 존재 시에만 2차 명시 버튼("인슈로에 동의하고 저장") 렌더, INVALIDATED 시 제거. `buildAnalysisMatrix`(renderModel.coverage_results에서 담보×보험사 격자 추출, apiBody 재파잉 불요·PII 표면 미증가), 클릭 시 `INSURO_CONSENT_SAVE_V1` 단일 메시지({snapshot,analysis_matrix,consent:true,reference}), 재시도 버튼→`INSURO_CONSENT_RETRY_V1`. content.js는 조립/전송/저장/base64/POST 코드 0 |
| `extension/__tests__/prd1-{ingest-core,background-dispatcher,single-egress,canary-sink-spy}.test.ts` (신규 66 tests) | 아르고스 | 계약 8항 전수 + non-vacuous 뮤테이션 |

### 핵심 불변식 검증 (직접 확인)
- **flag OFF = 프로덕션 fetch 0 (이중 방어)**: `INSURO_CONSULTATION_HISTORY_V1_ENABLED=false` const. background SAVE 핸들러가 flag≠true면 saveStoredBlob까지만 하고 `FLAG_DISABLED_STORED` 응답(sendIngest 미호출) + sendIngest 내부도 flag 게이트. 프로덕션 POST 0 확인.
- **consent 게이트**: 조립 전 `consent!==true → CONSENT_ABSENT` fail-closed(fetch 0). sender 검사 선행(mmlfcp origin).
- **single egress**: 서버 fetch는 `sendIngest` 함수 바디 유일(정적 brace-span 검사 + 2nd-fetch 주입 뮤테이션이 실제로 검출됨).
- **byte 보존**: assemble→b64→save→load→decode 바이트 완전 동일 + sha256 일치, 재시도=동일 bytes(재직렬화 0).
- **수명**: 2xx terminal ack 후 storage.session blob 제거, 비2xx 보존(재시도 윈도우), owner-change 전송차단 후 clear, alarms+read-time lazy expiry, 재시작 자동소멸, **JWT는 flag-ON 전 플로우 후에도 storage.local 유지(회귀 0)**.
- **tripwire**: opaque 래퍼 JSON.stringify/String()/템플릿리터럴/valueOf → throw.
- **canary sink-spy**: PII/JWT 마커 주입→조립→전송→재시도→만료 구동, ~13 sink(console/fetch/모든 storage/tabs/postMessage/dataLayer/structuredClone/sendResponse) 평문 마커 = 0 (예외=허용된 단일 ingest POST body + 단일 storage.session base64 blob). 뮤테이션(console.log 주입)이 실제로 마커 검출 = non-vacuous 실증.

## L1 스모크테스트 결과 (필수 기록)
- **서버 재시작: 해당없음** — 이 PR은 서버·웹앱 미변경(확장 코드만), 확장 flag OFF라 프로덕션 서버 호출 0. (서버 V ingest는 PR-D0에서 이미 반영, 이 PR은 클라 조립·저장까지만)
- **API 응답 확인 (실행됨·통과)**: `prd1-background-dispatcher.test.ts`가 **실제 `background.js`+`ingest.js` 메시지 핸들러를 vm.runInContext로 구동**(chrome mock + mock fetch, 테스트에서만 flag ON) → `INSURO_CONSENT_SAVE_V1` 처리 시 **정확히 1회 POST**(host=허용 endpoint, body=원본 frozen bytes 동일, Authorization 헤더에만 JWT), 2xx ack 후 storage.session clear, JWT는 storage.local 잔존을 관측. flag OFF(기본)에선 POST 0·`FLAG_DISABLED_STORED`. = 실코드 경로 실행 L1 등가.
- **스크린샷: 해당없음** — 확장 팝업/버튼 UI는 ohmymanager 실 FA 세션(로그인 토큰)이 있어야 렌더되며, flag OFF에서 서버 전송이 없어 브라우저 E2E 관측 대상이 없음. content.js 버튼 노출/제거·메시지 발신 로직은 vitest(track-a-preview + prd1)로 검증.
- **전체 스위트**: `npx vitest run extension/__tests__/` → **11 files / 273 tests 전부 PASS** (기존 207 회귀 0 + 신규 66). goal_assertion 통과.

## 테스트 결과
- `npx vitest run extension/__tests__/`: 273/273 PASS (2.11s). track-a-*·a7-write-path-blocked·matrix-detection·host-matching 회귀 0.
- TypeScript 진단: 우리 변경분 경고 전부 해소(아르고스 테스트 타입 캐스팅, 이리스 `_apiBody` 언더스코어). 잔여 `_lastMmlfcpTrace`(content.js:191)는 **origin/main 기존 코드**(우리 무관).

## 발견 이슈 및 해결
- **manifest ES module vs vm 테스트 충돌**(불칸 판단): 계약은 `import` 제안했으나 a7/mutation 테스트가 background.js를 vm.runInContext(플레인 스크립트)로 평가 → `import` 구문 SyntaxError. → **`importScripts` + `typeof importScripts==="function"` 가드**로 대체(실브라우저 전역 로드/테스트 스킵). manifest.json 변경 불요. 팀장 검토: 적절(회귀 0, 실브라우저 경로 유효).
- **RETRY consent 출처**: RETRY payload에 consent 없음 → "저장 blob 존재 = 최초 SAVE 시 consent=true 함의"로 해석, sendIngest에 consent 반영. 계약 정합.
- **jsdom 환경 gotcha**(아르고스): TextEncoder 출력이 cross-realm으로 `instanceof Uint8Array` 실패 → `Array.from` 비교. crypto.subtle.digest 실async → 폴링 대기. 테스트 환경 한정, 코드 버그 아님.

## 머지 판단
- **머지 필요: No (merge_policy=none)** — PR OPEN 유지. 자동머지 금지. 머지 판정 = 아누 Codex 반증리뷰 + 독립검증(Gemini OFF).
- 브랜치: `task/task-2831-dev1` · 워크트리: `/home/jay/projects/InsuRo/.worktrees/task-2831-dev1`
- 머지 의견: 보안 불변식 7종 전수 통과·non-vacuous 뮤테이션 실증·회귀 0·extension/ 밖 변경 0·flag OFF 이중방어로 프로덕션 노출 0. 코드 품질 양호. **near-match UI·계정전환 잠금 풍부화·재시도 UX·A7 뮤테이션 재범위화는 D2(별도, 이 PR 범위 밖)**.

## 모델 사용 기록
- 불칸/이리스/아르고스(+타입정리): 전부 **sonnet**. haiku 미사용. 팀장(Opus) = 설계·계약 고정·검토·통합만(직접 코딩 위임).

## 다음 (D2 — 별도 task)
near-match UI · 계정전환 잠금 · 재시도 풍부화 · A7 뮤테이션 재범위화("V-ingest 밖 write 재도입→실패", sender와 같은 PR) · 웹앱 V 재배선. flag ON은 전 파이프라인 E2E 후 회장 파일럿.

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

