# task-2821 보고서 — Integration Phase1 PR-B: 파일럿 allowlist 게이트 (flag OFF 유지)

- 팀: dev6 (페룬)
- 레벨: Lv.4 / 한정승인(팀장 게이트) / normal 검증
- 브랜치: `task/task-2821-dev6`
- 워크트리: `/home/jay/projects/InsuRo-worktrees/task-2821-dev6`
- base: `origin/main da35fce` (계약 V2 = PR-A #114 반영됨)
- PR: https://github.com/Jeon-Jonghyuk/InsuRo/pull/115
- 커밋: `3598343`

## S (Situation)
V ingest 엔드포인트 `POST /api/insuro/consultation-history/v1` 는 PR-A(#114)로 계약 V2 기반이 완성됐으나,
인증이 `_require_feature_enabled` → `_require_fa_account_id()` 순으로 **모든 인카멤버**를 통과시킨다.
feature-flag `INSURO_CONSULTATION_HISTORY_V1_ENABLED` 는 단일 글로벌 bool 이라, allowlist 없이 flag 를 켜면
전 인카 사용자에게 엔드포인트가 노출된다.

## C (Complication)
Codex 지적: flag ON 전에 **파일럿 계정(회장) 한정 allowlist** 를 먼저 심어야 한다.
단 이 PR 에서 flag 는 절대 켜지 않는다(프로덕션 노출 0). 또한 계약 에러 enum 5종·멱등·per-FA 소유권·스키마는
불변이어야 하고, allowlist 는 회장 실제 ID 하드코딩 없이 fail-closed 여야 한다.

## Q (Question)
flag OFF 를 유지하면서, body 역직렬화 이전에 비허용 계정을 차단하는 allowlist 게이트를
계약 불변·회귀 0·하드코딩 0 으로 어떻게 추가하는가?

## A (Answer) — 구현
### 1. 라우트 (`server/routes/consultation_history_v1.py`, +42/-1)
- 신규 **라우트 dependency** `_require_pilot_allowlisted` (글로벌 미들웨어 아님).
- 게이트 순서: `_require_feature_enabled`(FEATURE_DISABLED 우선) → **allowlist** → body.
  - 핸들러 시그니처의 `fa_account_id` 를 `Depends(_require_pilot_allowlisted)` 로 교체.
- `_load_pilot_allowlist()`: env `INSURO_CONSULTATION_HISTORY_ALLOWLIST`(콤마구분) 파싱 → frozenset.
  **미설정/빈값 = 빈 frozenset = fail-closed(전원 deny)**. 회장 실제 ID 하드코딩 없음(env 전용).
- 거부 = 기존 `ConsultationHistoryErrorCode.FEATURE_DISABLED`(403) 재사용. **신규 enum 0**.
- `_fa_account_id_dependency = _require_fa_account_id()` 모듈 싱글턴 공유 → `_verify_incar_member` double-verify 방지.

### 2. `.env.example` (+3)
`INSURO_CONSULTATION_HISTORY_ALLOWLIST=`(값 비움) + fail-closed·회전 주석 1줄.

### 3. 테스트
- `test_consultation_history_v1.py` `ch_env` fixture 에 `INSURO_CONSULTATION_HISTORY_ALLOWLIST=FAKE_FA` 추가 → **기존 회귀 방지**(fail-closed 로 인해 안 하면 기존 테스트 전부 403 붕괴).
- 신규 `test_consultation_history_allowlist.py` (13종): 비허용 차단·회장 통과·env 미설정/빈값 fail-closed·pre-deserialize(spy 0)·뮤테이션 kill·flag OFF 우선순위·파싱 유닛.

## 검증 결과 (팀장 독립 재실행)
- `py_compile routes/consultation_history_v1.py` → OK
- 타깃 테스트(allowlist+consultation+legacy): **102 passed**
- **전체 회귀: `tests/` 789 passed, 0 failed** (기존 776 + 신규 13 = 789, 감소 0)
- 스키마 파일 `git diff` 없음 → **계약 5종 enum 불변** 확인
- feature-flag 코드/기본값(`"false"`) 미변경, ON 전환 없음(테스트 내 monkeypatch 임시 ON, 자동 원복)

### 뮤테이션·pre-deserialize 증명 (직접 점검)
- 뮤테이션: `_load_pilot_allowlist` 무력화 시 비허용 계정 200 통과 → allowlist 체크가 실제 차단 원인임 증명(killed).
- pre-deserialize: 비허용 계정 요청 시 `parse_and_validate_body` spy 0회 호출 + 403 = deserialize 0.

## L1 스모크테스트 결과 (실 ASGI 스택, TestClient 실HTTP)
- 서버 재시작: 해당없음(엔드포인트 flag OFF 유지가 제약 — 실 uvicorn 대신 실 ASGI 앱 직접 기동)
- API 응답 확인:
  - flag OFF (기본) → **403 FEATURE_DISABLED** (프로덕션 노출 0)
  - flag ON + allowlist 미설정 → **403 FEATURE_DISABLED** (fail-closed)
  - flag ON + allowlisted(FAKE_FA) → **400 REFERENCE_SHAPE_INVALID** (body 검증 도달 = 게이트 통과, FEATURE_DISABLED 아님)
- 스크린샷: 해당없음(백엔드 API)

## 머지 판단
- **머지 필요: No (이 봇이 머지하지 않음)** — task 지시 "머지 금지 · ANU/회장 판정".
- 브랜치: `task/task-2821-dev6`
- 워크트리: `/home/jay/projects/InsuRo-worktrees/task-2821-dev6`
- 머지 의견: 회귀 0·계약 불변·flag OFF·fail-closed 확인. Gemini High 0 + ANU 독립검증 후 회장 판정 대기.
  후속 순서: PR-C(후보검색 5항목+region·명시적 생성 phone 필수) → Phase 2 → 전 파이프라인 E2E → **그 후 flag ON**(파일럿) → G-3.

## 운영 노트
- allowlist 는 **env 기반**. 회장 fa_account_id 회전 시 **env 갱신**으로 대응. rotation/audit 운영은 이 task 범위 밖.

## 모델 사용 기록
- 스바로그(백엔드): **sonnet** — 라우트 dependency + 테스트 구현(코딩).
- 팀장(페룬, opus): 설계·정밀 지시·독립 검증(pytest 재실행·diff 점검·L1 스모크)만 수행. 직접 코딩 없음.

## 발견 이슈 및 해결
- (이슈) fail-closed allowlist 추가 시 기존 `ch_env` fixture 사용 테스트 전부 403 붕괴 위험.
  (해결) fixture 에 allowlist env 를 FAKE_FA 로 추가 → 회귀 0 확보. flag 를 켜는 지점이 fixture 1곳뿐임을 grep 으로 사전 확인.

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

