# task-2814 — TRACKB_GEMINI_FIX: PR #110 Gemini 리뷰 5건 수정

- **팀**: dev6-team (팀장 페룬)
- **저장소**: `/home/jay/projects/InsuRo`
- **브랜치**: `task/task-2801-dev6` (PR #110)
- **워크트리**: `/home/jay/projects/InsuRo/.worktrees/task-2814-dev6`
- **커밋**: `4538a18` (정확히 1개) — `f885c48..4538a18` fast-forward push
- **작성일**: 2026-07-20

---

## S (Situation)

PR #110은 체크 11종 전부 SUCCESS 상태로, Gemini 코드리뷰 지적 5건만이 머지를 막고 있었다.
지적 내용은 ① `reference_id` 타입 불일치(HIGH, 500 크래시 경로), ② insert 예외를 전부 409로 뭉갬,
③ `granted_at` naive datetime, ④⑤ 뷰 `SELECT *` (2파일).

## C (Complication)

세 가지가 단순 수정을 막았다.

1. **①이 계약과 충돌할 가능성** — 계약 §2-1은 `reference_id: <string>`이다. UUID 강제가 계약 개정을
   선행해야 하는 사안이면 STOP_REPORT 대상이었다.
2. **②의 판정 근거 부재** — supabase 클라이언트가 실제로 어떤 예외에 어떤 에러코드를 담는지 모른 채
   분기하면 추측 코드가 된다.
3. **① 적용 시 기존 테스트 20건 붕괴** — 픽스처가 `"customer-001"` 같은 비-UUID를 쓰고 있었다.

## Q (Question)

계약을 위반하지 않으면서 5건을 모두 해소하고, 758 passed 기준선을 감소 0으로 유지할 수 있는가?

## A (Answer)

**5건 전부 수정 완료. pytest 758 → 763 passed (신규 5, 감소 0, 실패 0). 계약 충돌 없음 → STOP_REPORT 불필요.**

---

## 5건 각 조치 및 근거

### ① [HIGH] reference_id UUID 형식 검증 — 라우터 레벨 채택

**파일**: `server/routes/consultation_history_v1.py:34` (import), `:248-259` (검증)

```python
from uuid import UUID
...
    try:
        UUID(reference_id)
    except (ValueError, AttributeError, TypeError):
        raise _http_error(
            ConsultationHistoryErrorCode.REFERENCE_SHAPE_INVALID,
            f"reference_id must be a valid UUID string: {reference_id!r}",
        ) from None
```

**스키마 레벨 vs 라우터 레벨 — 라우터 레벨을 택한 근거 (결정적)**
Pydantic 필드를 `UUID` 타입으로 바꾸면 위반 시 `ValidationError`가 발생하고, 이는 기존 코드 경로상
**422 `VALIDATION_ERROR`** 로 떨어진다. 요구사항은 **400 `REFERENCE_SHAPE_INVALID`** 이므로 스키마 레벨로는
요구된 에러코드를 반환할 수 없다. 또한 기존 reference shape 검증 3건(`routes:228~246`)이 이미 라우터
레벨에 있어 같은 블록에 이어 붙이는 것이 구조적으로 일관된다. → **라우터 레벨 채택.**

**계약 정합성 판단 — 충돌 없음 (STOP_REPORT 불필요)**
- 계약 §2-1은 `reference_id: <string>`이지만, JSON에는 UUID 타입이 없으므로 UUID는 **문자열로 전송된다.**
  UUID 형식 문자열은 `<string>`의 부분집합이며 타입 계약을 위반하지 않는다.
- 계약 §4-3-a 표(라인 346)는 `REFERENCE_SHAPE_INVALID`를 "`reference_type`/`reference_id` **누락·두 종류
  동시·enum 위반**"으로 규정한다. 이는 "SHAPE_INVALID"라는 코드명이 포괄하는 **형식(shape) 위반의 예시
  열거**이며, 형식이 어긋난 `reference_id`는 이 코드의 의미 범위 안이다.
- **에러코드를 신설하지 않았다** — 기존 5종 enum 내의 코드를 그대로 사용하므로 §4-3-a enum 제약을 건드리지 않는다.
- 결정적으로, **수정 전 동작은 500 크래시**였고 500은 계약 enum에 아예 없는 응답이다. 즉 이번 수정은
  계약과 충돌하는 것이 아니라 **계약 밖 응답(500)을 계약 안 응답(400)으로 되돌리는 것**이다.
- → 계약 개정 선행 불필요. STOP_REPORT 조건 미충족.

### ② [MEDIUM] insert 예외 분기 — unique 위반만 409

**파일**: `server/routes/consultation_history_v1.py:361-386`

```python
        exc_str = str(exc).lower()
        is_unique_violation = getattr(exc, "code", None) == "23505" or (
            "duplicate key" in exc_str or "unique constraint" in exc_str
        )
        if is_unique_violation:
            raise _http_error(ConsultationHistoryErrorCode.IDEMPOTENCY_CONFLICT, ...) from exc
        raise _generic_error(500, "CONSULTATION_HISTORY_INSERT_FAILED",
            "failed to persist consultation history record") from exc
```

**에러코드 실사 결과 (추측 아님 — 설치된 패키지 소스 직접 확인)**
- 설치 버전: `supabase 2.28.0` / `postgrest 2.28.0`
- `postgrest.exceptions.APIError.__init__` 소스를 직접 읽어 확인: `self.code = error.get("code")` —
  PostgREST가 전달한 **PostgreSQL SQLSTATE 문자열**이 `.code`에 담긴다. unique violation SQLSTATE = **`23505`**.
- `.message`에는 `duplicate key value violates unique constraint ...` 가 담긴다.
- 클라이언트 버전 업그레이드로 예외 타입이 바뀔 가능성에 대비해 **SQLSTATE 우선 + 메시지 문자열 폴백**
  2중 판정으로 구성했다.

**500 경로에 새 enum을 추가하지 않은 근거**: 계약 §4-3-a는 서버 계층 에러코드를 5종으로 고정한다.
DB 장애용 코드를 enum에 추가하면 계약 위반이므로, 기존 `_generic_error()` 경로(enum 밖 generic)를 사용했다.

### ③ [MEDIUM] granted_at AwareDatetime

**파일**: `server/schemas/consultation_history_v1.py:23` (import), `:169-175`

```python
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
...
    granted_at: AwareDatetime
```

DB 컬럼이 `consent_granted_at TIMESTAMPTZ NOT NULL`이므로 naive datetime은 시간 왜곡을 유발한다.
`AwareDatetime`은 tzinfo 없는 값을 스키마 단계에서 422로 거부해 DB 도달을 차단한다.
사용처가 사라진 `from datetime import datetime` import는 grep으로 미사용 확인 후 제거했다.

### ④⑤ [MEDIUM] 뷰 SELECT * → 명시적 컬럼 나열 (2파일 동일)

**파일**: `server/migrations/013_consultation_history_v1.sql:121-154`,
`supabase/migrations/20260719120000_consultation_history_v1.sql:121-154`

`CREATE TABLE` 정의의 **22개 컬럼을 정의 순서 그대로** 나열했다
(`id, fa_account_id, operation_scope, idempotency_key, body_sha256, reference_type, reference_id,
contract_version, query_condition, selected_coverages, per_insurer, validation, consent_granted_at,
consent_version, consent_scope, consent_source, record_status, rollout_stage, rollout_batch_id,
created_at, updated_at, deleted_at`).

**두 파일 동일성 검증**:
```
$ diff server/migrations/013_consultation_history_v1.sql supabase/migrations/20260719120000_consultation_history_v1.sql
1c1
< -- 013_consultation_history_v1.sql
---
> -- 20260719120000_consultation_history_v1.sql
```
→ 헤더 파일명 주석 1줄 외 **완전 동일** (원래부터 이 1줄만 달랐음).

---

## 변경 파일 (5개 — allowed_resources 범위 내, 신규 파일 0건)

```
 server/migrations/013_consultation_history_v1.sql  |  29 +++-
 server/routes/consultation_history_v1.py           |  39 ++++-
 server/schemas/consultation_history_v1.py          |   9 +-
 server/tests/test_consultation_history_v1.py       | 187 ++++++++++++++++++---
 .../20260719120000_consultation_history_v1.sql     |  29 +++-
 5 files changed, 261 insertions(+), 32 deletions(-)
```

`server/main.py` · `server/utils/legacy_write_guard.py` · `conftest.py` **미변경** (`git status` 로 확인).

---

## 테스트 결과 — pytest 원문 (전/후)

**전 (기준선, f885c48)** — 팀장이 직접 실행:
```
758 passed, 38 warnings in 155.67s (0:02:35)
```

**후 (4538a18)** — 팀장이 팀원 보고와 별개로 직접 재실행:
```
763 passed, 38 warnings in 144.75s (0:02:24)
```

**758 → 763 (신규 5, 감소 0, 실패 0).** skip / xfail / assertion 완화 **0건**
(`git diff | grep '^+.*(skip|xfail)'` → 0건 확인).

### 신규 회귀 테스트 5종
- `test_non_uuid_reference_id_rejected_with_400_not_500` — ① 비-UUID → 400 `REFERENCE_SHAPE_INVALID`,
  **500이 아님** 명시 검증 + insert 0 + tenant 조회 0 (DB 접근 이전 차단 증명)
- `test_insert_unique_violation_returns_409_idempotency_conflict` — ②(a) `.code=="23505"` → 409
- `test_insert_generic_db_error_returns_500_not_409` — ②(b) ConnectionError → 500, **409 아님** 명시 검증
- `test_consent_granted_at_naive_datetime_rejected_422` — ③ naive → 422 + insert 0
- `test_consent_granted_at_aware_datetime_accepted` — ③ **대조군**: aware는 정상 200 저장

### 기존 테스트 픽스처 정정 (assertion 완화 아님)
① 적용 후 기존 20건이 실패했다. 원인은 픽스처가 `"customer-001"`, `"cust-A"` 등 **비-UUID**를
`reference_id`로 사용한 것으로, DB 컬럼이 `UUID NOT NULL`인 이상 **실제 DB에서는 애초에 통과할 수 없는
잘못된 픽스처**였다. 따라서 UUID로 정정하는 것이 올바른 수정이며 완화가 아니다.
의미 보존을 위해 이름 있는 상수(`_CUSTOMER_A_ID` 등, 구 문자열을 주석에 병기)로 치환했고,
"서로 다른 고객"은 서로 다른 UUID를 유지했다 —
`test_idempotency_conflict_when_reference_id_changes`(A≠B), `test_same_query_condition_different_customers_saved_separately`(A≠B)를
팀장이 직접 코드로 스팟 체크해 의미 보존을 확인했다.

---

## ★ L1 스모크테스트 결과

**pytest PASS와 별개로, 실제 uvicorn 기동 + 실제 TCP curl로 검증했다.**

- **서버 재시작: 성공**
  - 1단계(순정): 저장소 코드 그대로 `python3 -m uvicorn main:app --port 8731` 기동 →
    `Application startup complete.` / `Uvicorn running on http://127.0.0.1:8731`
    → 수정된 코드가 실제 ASGI 부팅에 성공함을 확인(pytest와 별개 경로).
- **API 응답 확인 (curl 실제 결과 원문)**
  1. **인증 계층 도달 확인** (순정 서버, 토큰 없음):
     ```
     POST /api/insuro/consultation-history/v1 → HTTP=401
     {"detail":"Missing or invalid authorization"}
     ```
  2. **① 비-UUID reference_id** (인증만 우회한 하네스, 실제 HTTP):
     ```
     HTTP=400
     {"detail":{"error_code":"REFERENCE_SHAPE_INVALID",
                "detail":"reference_id must be a valid UUID string: 'customer-001'"}}
     ```
     → **요구사항 그대로 400 REFERENCE_SHAPE_INVALID, 500 아님.**
  3. **③ naive granted_at** (실제 HTTP):
     ```
     HTTP=422
     {"detail":{"error_code":"VALIDATION_ERROR","detail":"[{\"type\": \"timezone_aware\",
      \"loc\": [\"consent\",\"granted_at\"], \"msg\": \"Input should have timezone info\", ...}]"}}
     ```
  4. **대조군** (정상 UUID + aware datetime):
     ```
     HTTP=500  {"detail":"Supabase 설정이 누락되었습니다"}
     ```
     → ①③ 게이트를 **통과해** DB 단계까지 도달했고 로컬 Supabase 미설정으로 실패한 것.
       즉 ①③ 검증이 **무차별 거부가 아님**을 증명한다.
- **스크린샷: 해당없음** (백엔드 API 작업 — 프론트 화면 변경 0건)
- **L1 한계 명시**: JWT는 Supabase JWKS 실서명이 필요해 로컬에서 유효 토큰을 만들 수 없다
  (보안 키 하드코딩 금지 원칙 준수). 따라서 ①③은 인증 계층만 우회한 하네스
  (`/tmp/l1_2814_harness.py` — **저장소 밖**, repo 신규파일 0건)로 검증했다.
  ②는 DB 예외 주입이 필요해 실서버 curl로는 재현 불가 → pytest 분기 테스트 2종으로 커버.
- **정리**: 하네스/서버 프로세스 종료 및 포트 8731/8732 미사용 확인 완료.

---

## 발견 이슈 및 해결

1. **원격 브랜치가 로컬보다 앞서 있었음** — 로컬 `task/task-2801-dev6`는 `8199bd7`(구버전)이고
   기존 worktree가 점유 중이었으나, 원격 tip은 지시서와 같은 `f885c48`이었다.
   → 원격 tip에서 **detached worktree를 신규 생성**해 작업. rebase/force-push 0.
   push 직전 원격 tip이 여전히 `f885c48`인지 재확인 후 fast-forward push 했다.
2. **① 적용 시 기존 테스트 20건 실패** — 상단 "픽스처 정정" 참조. 구현이 아닌 픽스처 문제로 확정 후 정정.
3. **pyright 경고** — `Import "main" could not be resolved`, `_feature_check is not accessed` 등은
   전부 **이번 변경과 무관한 기존 사항**(sys.path 기반 import 구조 + FastAPI `Depends` 관용구)이다.
   `git diff`로 해당 라인이 이번 커밋에서 변경되지 않았음을 확인했다. 미해결로 남기며, 범위 밖.

---

## 절대 제약 준수 확인

- **PR 생성 0** — PR #110 기존 것 사용. 신규 PR 생성하지 않음.
- **main merge 0** — 머지 수행하지 않음. PR #110은 `OPEN` / `MERGEABLE` 상태로 유지.
- **rebase / force-push 0** — `f885c48..4538a18` fast-forward push (push 출력 원문에 `f885c48..4538a18` 확인).
- **커밋 정확히 1개** — `git rev-list --count f885c48..HEAD` → `1`
- **push 명령** — `git push origin HEAD:task/task-2801-dev6` (지시서 그대로)
- **skip / xfail / assertion 완화 0건**
- **금지 파일 미수정** — `main.py`, `legacy_write_guard.py`, `conftest.py`
- **신규 파일 0건** (repo 기준. L1 하네스는 `/tmp` — git stage 0)
- **취소 마커 재확인** — 작업 시작 시 / commit·push 직전 2회 확인, 둘 다 없음.

---

## 머지 판단

- **머지 필요**: **No (팀장 머지 금지 — 지시서 절대 제약 `merge_policy: none`)**
- **브랜치**: `task/task-2801-dev6`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2814-dev6`
- **PR**: #110 — state `OPEN`, mergeable `MERGEABLE`, head `4538a18`(본 커밋 반영 확인)
- **머지 의견**: Gemini 지적 5건 전부 해소, 회귀 758→763 passed(감소 0), L1 실서버 검증 통과.
  머지 차단 요인은 해소된 것으로 판단하나, **머지 권한은 지시서상 이 작업 범위 밖**이므로
  아누(개발실장) 판단에 위임한다. Gemini 재리뷰 결과 확인 후 머지 권장.

---

## ★ 완료 차단 — finish-task.sh scope-guard FAIL (아누 판단 요청)

**`.done` 미생성. 수동 생성하지 않았다.** finish-task.sh가 scope-guard 단계에서 `exit 1` 했다.

### 관측 사실
```
[scope-guard] FAIL: 12건 위반
  VIOLATION: server/main.py: paths 미포함 (scope 외 파일)
  VIOLATION: server/utils/legacy_write_guard.py: paths 미포함
  VIOLATION: server/tests/conftest.py: paths 미포함
  ... (총 12건)
[SCOPE-GUARD] FAIL — 머지 차단 + .escalate 생성
```

### 원인 규명 (교차 확인 완료)
- `scope-diff.txt`는 **17개 파일**을 담고 있다 = `merge-base(origin/main, HEAD)..HEAD`
  (finish-task.sh:843) → **브랜치 전체**(task-2801 + task-2803 + task-2814)의 누적 변경.
- **내 커밋 `4538a18` 단독 변경은 정확히 5개 파일**이며 전부 allowed_resources 범위 안이다:
  ```
  $ git diff --name-only f885c48..HEAD
  server/migrations/013_consultation_history_v1.sql
  server/routes/consultation_history_v1.py
  server/schemas/consultation_history_v1.py
  server/tests/test_consultation_history_v1.py
  supabase/migrations/20260719120000_consultation_history_v1.sql
  ```
- 위반 지목된 12건은 **전부 이전 커밋(task-2801/2803) 유래**로, 파일별로 내 커밋 포함 여부를
  개별 확인했다(`server/main.py`·`legacy_write_guard.py`·`conftest.py`·계약문서 → 전부 "내 커밋에 없음").
- 즉 **구조적 미스매치**다: 지시서는 "기존 브랜치에 이어서 커밋 · rebase 금지"를 요구하는데,
  scope-guard는 브랜치-vs-main 누적 diff로 측정하므로 **이어받기 task의 저작 범위를 표현할 수 없다.**
  지시서의 금지 파일 3종(`main.py`/`legacy_write_guard.py`/`conftest.py`)은 내가 수정하지 않았고
  이전 task가 이미 수정해 PR #110에 포함된 상태다.

### 조치하지 않은 것 (의도적)
- `.done` 수동 생성 — **금지 규칙 준수, 하지 않음**
- task 파일 `allowed_resources.paths` 확장 — scope 계약을 통과시키려 사후 변조하는 것이므로 하지 않음
- `.allow-no-scope` 마커 생성 — scope **미정의** 상황용이며 본 건(scope 정의됨)에 쓰면 우회 조작
- scope-guard / finish-task.sh 수정 — 공유 하네스, 본 task 범위 밖

### 아누 판단 요청 사항
1. 이어받기(branch-continuation) task에서 scope-guard base를 **직전 커밋(`f885c48`)** 으로 잡는
   정식 경로가 필요하다. 현재 `SCOPE_BASE`는 `merge-base(origin/main, HEAD)` 고정이며
   `worktree-base.json` fallback은 merge-base 성공 시 무시된다(finish-task.sh:843-846).
2. 본 task의 산출물 자체는 완료 상태다 — 커밋/push/PR 반영, 763 passed, L1 통과.
   `.done` 발급 여부만 아누 판단이 필요하다.

### 부수 발견 — 별건 하네스 버그 2건 (본 task 범위 밖, 미수정)
- **(a) `teams/shared/verifiers/browser_verify.py:14`** — workspace 루트를 `..` 4단계로 계산해
  `/home/jay` 가 나온다(정답 `/home/jay/workspace`, `..` 3단계). 이로 인해
  `ModuleNotFoundError: No module named 'utils'` 가 발생해 QC가 `.qc-result` 생성 전에 죽는다.
  → 본 실행에서는 `PYTHONPATH=/home/jay/workspace` 로 **비침습 우회**했다(하네스 미수정).
- **(b) finish-task.sh QC evidence root** — `project_path` 인자를 생략하면
  `QC_EVIDENCE_ROOT`가 `$WORKSPACE`로 폴백해(finish-task.sh:734) 프로젝트 저장소 커밋을
  찾지 못하고 `git_evidence` FAIL → 3회 연속 시 ESCALATE 된다. 초기 2회 실패의 원인이었고,
  올바른 worktree 경로를 넘기자 `git_evidence PASS` 로 전환됨을 확인했다(내 조작 오류였음).

---

## 모델 사용 기록

- **팀장 페룬**: Opus — 설계 판단(①의 계약 정합성·라우터 vs 스키마 레벨, ②의 에러코드 실사),
  분배, diff 검토, 독립 pytest 재실행, L1 스모크테스트, 커밋/push
- **스바로그 (백엔드)**: **sonnet** — ①②③④⑤ 구현 (라우터/스키마/SQL 2파일)
- **벨레스 (테스터)**: **sonnet** — 픽스처 UUID 정정 + 회귀 테스트 5종 신규 작성
- **라다(프론트) / 모코시(UX)**: 미소집 — 본 작업은 백엔드/DB 전용으로 프론트·UX 변경분 0건.
  페르소나 고정 규칙에 따라 역할 밖 작업을 배정하지 않았다.
- **haiku 사용 0건** — 계약 해석·예외 분기 설계·테스트 의미 보존이 필요한 판단 작업이라 sonnet 이상으로 배정.

---

## MATCH / GAP

**MATCH**
- ① reference_id UUID 검증 → 400 `REFERENCE_SHAPE_INVALID` (라우터 레벨, 근거 명시) — 완료
- ① 계약 정합성 판단 + STOP_REPORT 여부 판정 — 완료 (충돌 없음)
- ② unique 위반만 409, 그 외 500 + 에러코드 실사 근거 — 완료
- ③ `AwareDatetime` 적용 — 완료
- ④⑤ 뷰 명시적 컬럼 나열, 2파일 동일 — 완료
- 758 passed 유지(감소 0, 신규 실패 0) — 완료 (763)
- ①의 회귀 테스트 / ②의 분기 테스트 / ③ naive 거부 / ④⑤ 두 파일 동일성 — 전부 완료
- 커밋 1개 · PR 생성 0 · merge 0 · rebase/force-push 0 — 완료

**GAP**
- ②의 **실서버 curl 재현 불가** — DB 예외 주입이 필요해 실 HTTP로는 재현할 수 없다.
  pytest 분기 테스트 2종(mock 주입)으로 커버했으며, 실운영 unique 충돌 시 동작은 미관측이다.
- **실 Supabase 연동 E2E 미수행** — 로컬에 Supabase 설정이 없어 실제 insert 경로(정상 저장,
  실제 UUID 컬럼 저장 성공)는 검증하지 못했다. ①의 근본 근거인 "비-UUID → DB 크래시"는
  DDL(`reference_id UUID NOT NULL`) 정적 확인에 기반하며 실 DB로 재현하지 않았다.
- **pyright 경고 미해결** — 기존 사항이며 이번 작업 범위 밖(위 "발견 이슈" 3번).
- **Gemini 재리뷰 미확인** — push 직후라 재리뷰 결과가 아직 없다. ANU 재실행 대상.
</content>

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

