---
task_id: task-2973
team: dev2-team
level: 3
status: blocked_qc_tdd_check
merge_policy: HOLD
created: 2026-08-18
---

# task-2973 · 게이트 봉인 하드닝 3파일 신규 PR (a9b1e9c 이관)

## S — 상황

PR #226(게이트 봉인 본체)은 머지되어 `origin/main` = `dce9c987` 이 되었으나, 마아트·Codex 가 찾은
**구멍 3건을 막는 하드닝 커밋 `a9b1e9c`(3파일 +480/−25)가 main 에 미반영**된 채 PR 이 닫혔다(ANCESTOR_NO).
현 main 에는 구멍을 잡는 **회귀 테스트조차 부재**했다.

## C — 문제

T6(axes 배선)이 올라가는 순간 A/B 구멍이 라이브화된다. B 는 지금도 내부경로 도달 가능.
PR #226 이 이미 닫혀 재사용이 불가하므로 신규 PR 로 이관해야 한다.
동시에, `a9b1e9c` 는 merge-base `e0a7f64` 에서 갈라져 **그 이후 main 에 들어온 PR#226/#225/#224 를 모른다** —
브랜치를 통째로 가져오면 타 팀 작업이 되돌려진다(실측 −2419줄).

## Q — 핵심 질문

신규 코드 0줄로, 타 팀 작업을 훼손하지 않고, 3구멍이 실제로 막히는가?

## A — 답변 / 결과

3파일만 선별 이관하여 **신규 코드 0줄**로 PR #227 을 생성했고, A/B/C 3구멍이 전부 차단됨을
**동일 스크립트 before/after 실행**으로 실증했다. 전량 회귀 0 실패.

---

## 1. 3구멍 차단 실증표 (동일 스크립트, before/after)

`memory/reports/artifacts/task-2973/attack_repro.py` 를 현 main(baseline worktree)과 하드닝본에 **동일하게** 실행.

| 공격 | 현 main `dce9c987` (before) | 이관 후 (after) |
|---|---|---|
| A 위조 axes (6축 결손 + `axes_verified=True`) | **included** — 공식비교 채택 | `excluded` / `excluded_axes_malformed` |
| B `single_modality` 소문자 ↔ 비세분형 group | **included** — 표기변형만으로 봉인 무력화 | `excluded` / `excluded_modality_axes_group_mismatch` |
| C `map_state` 키 부재 entry | **KeyError: 'map_state' 예외 누출** | `excluded` / `excluded_malformed_entry` |
| B2 대조군 `SINGLE_MODALITY` 대문자 | `excluded_modality_axes_group_mismatch` | `excluded_modality_axes_group_mismatch` (**불변**) |

- B 구멍의 정확한 메커니즘: 봉인 로직 자체는 baseline 에도 존재하나 **정확 문자열 일치(`==`)만 검사**해
  대소문자 변형을 통과시켰다. 하드닝은 정규화 후 비교한다.
- 대조군 B2 가 before/after 동일 → **정상 경로 무변경** 확인.
- 마아트가 스크립트 151줄을 직접 읽고, 페이로드가 `a9b1e9c` 의 실제 테스트 3개 클래스와
  **verbatim 동일**함을 대조 확인 (유리하게 축소·조작된 정황 없음).

## 2. 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/policy_grouping/comparison_gate.py | +209 — 3구멍 차단(axes 스키마 검증·소문자 modality 정규화·map_state 부재 방어). blob fce1a14 → fce1a14 일치 | grep "REASON_EXCLUDED_AXES_MALFORMED" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/tests/test_grouping_gate_seal_task2970.py | +273 — A/B/C 하드닝 회귀 테스트 신규. blob 66dfbca → 66dfbca 일치 | grep "REASON_EXCLUDED_MALFORMED_ENTRY" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/tests/test_policy_grouping_comparison_gate.py | 23줄 — 게이트 계약 갱신 반영(배치 API 는 예외 대신 제외로 전환, evaluate_entry 단건 계약은 유지). blob 0a40ddb → 0a40ddb 일치 | grep "test_unknown_state_in_batch_is_excluded_without_raising" OK | verified |

- `git diff --stat dce9c987 HEAD` = **3 files changed, 480 insertions(+), 25 deletions(-)** — 지시 수치와 일치
- `git diff a9b1e9c HEAD -- <3파일>` = **0줄** → 신규 코드 0줄 실증 (마아트 반증 시도 실패)
- **3파일 외 변경 0** — 마아트 독립 확인(출력 빈 문자열)

## 3. 테스트 결과

| 항목 | 결과 |
|---|---|
| 게이트 2파일 | 91 passed |
| 전량 회귀 (하드닝본) | **2193 passed, 3 skipped, 0 failed** (176.05s) |
| 전량 회귀 (baseline 현 main) | 2171 passed, 3 skipped, 0 failed |
| pyright (comparison_gate.py) | 0 errors / 0 warnings |
| CI (PR #227) | **11/11 success** (`diagnostic` 포함 전부 완료) |

차이 22건 = 이번에 추가된 하드닝 회귀 테스트분.

### 회귀 실패 5건 → 환경오염으로 규명 (인과 확정)

최초 전량 실행에서 5 failed 가 나왔다. **같은 커밋에서 변수 하나(`.env`)만 바꿔** 인과를 확정했다:

| 조건 (동일 커밋 f01910e, 동일 2파일 scope 30 tests) | 결과 |
|---|---|
| worktree 루트 `.env` 존재 | 5 failed / 25 passed |
| `.env` 격리 | **30 passed** |

→ 코드 회귀 아님. worktree `.env` 환경오염(기존 알려진 함정과 동일 패턴).
실패 대상은 `test_standby_fallback.py` 4건 + `test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` 1건으로 3파일과 무관.

### 워크트리 `.env` 상태 (인수인계 필수)

회귀 판정을 위해 worktree 루트 `.env` 를 `.env.task2973-quarantine` 으로 **격리한 상태로 남겨두었다**.
- 이유: 이 파일이 있으면 무관한 테스트 5건이 실패해 회귀 판정을 오염시킨다(위 표).
- ANU 가 이 worktree 에서 재검증할 때 **그대로 두어야** 위 수치가 재현된다.
- 원복이 필요하면: `mv .env.task2973-quarantine .env`
- 추적 파일이 아니므로 PR 내용에는 영향 없다(PR = 3파일 그대로).

## 4. L1 스모크테스트

- **서버 재시작**: 성공 — 단 **프로덕션 `insuro-api` 는 건드리지 않음**(머지 HOLD 이므로 무단 배포 금지).
  worktree 코드를 별도 포트 127.0.0.1:8899 에 uvicorn 으로 기동.
- **API 응답 확인**:
  - `GET /api/status` → **200**, 응답 `sha = f01910e6843e4c0fb8c671e56bafa50c1c945317` = worktree HEAD 일치
    → **실행 중인 서버가 하드닝 코드임을 sha 로 실증**
  - `POST /api/insuro/new-design-comparison` 인증 없음 → **401** (라우트 생존)
  - `Authorization: Bearer fake` → **401 Invalid token** (JWT 검증 실제 동작)
  - 로드 증거: `comparison_gate.__file__` = worktree 경로, `REASON_EXCLUDED_AXES_MALFORMED` 심볼 존재
- **종단 HTTP E2E** (`l1_e2e_api.py`, 343줄 — TestClient, 인증 의존성만 오버라이드):
  - 베이스라인 정상 케이스 **200** — 하드닝이 정상 경로를 깨지 않음
  - 공격 A 종단 실행 → **200**(500 아님). 위조 entry 가 `official_comparison` 에서 빠지고
    `unknown_report` 로. `gate_reason_raw = excluded_axes_malformed`, 노출 enum = `NOT_COMPARABLE`
- **스크린샷**: 해당없음 (백엔드 전용 변경, UI 변경 0)
- 잔존 uvicorn 프로세스 0건, worktree 추적파일 무오염 확인

## 5. 발견 이슈 및 해결

### 이슈1 — `gh api -f content=@-` 가 빈 blob 을 조용히 업로드 (해결)
원격 결선 중 3파일 blob 이 전부 `e69de29b`(**빈 blob** sha)로 올라갔다. 로컬 sha 대조로 즉시 발견,
`jq` 로 JSON body 를 구성해 재업로드하여 3/3 일치시켰다.
**대조 없이 진행했으면 빈 파일 PR 이 될 뻔했다.** → 맥락노트에 함정으로 기록.

### 이슈2 — 회귀 5건 (해결) — 위 §3 참조. 환경오염으로 규명, 코드 회귀 아님.

### 이슈3 — `_unknown_rows()` 무방비 인덱싱 (범위 밖, 후속 필요)
헤임달 L1 fault-injection 결과: 게이트는 malformed entry 를 fail-closed 로 정상 차단하지만,
그 **한 단계 위** `engine.py::_unknown_rows()` 가 `entry["coverage_name"]` 을 `.get()` 없이 인덱싱해
손상 entry 가 거기까지 가면 **500** 이 난다.
- **현재 HTTP 경로로는 도달 불가능**(`_new_entries`/`_existing_entries` 가 항상 키를 채움) — 코드로 확인
- **회귀 아님**: 하드닝 전에도 같은 입력은 게이트에서 KeyError → 500 이었다. 결과 동일, 악화 없음
- `engine.py` 는 본 PR 범위(3파일) 밖이므로 **수정하지 않고 보고만** 함

### 이슈4 — engine.py 사유 매핑 누락 (범위 밖, 후속 필요)
`_GATE_REASON_TO_EXCLUDED_REASON` 에 신규 사유 2종(`excluded_axes_malformed`, `excluded_malformed_entry`)이 없다.
- 마아트가 두 사유로 `_unknown_rows()` 를 직접 실행 → `NOT_COMPARABLE` 폴백, **NO CRASH 실증**
- 다만 `excluded_axes_malformed` 는 지식DB axes 손상 시 **실제 도달 가능**하며, 이때 사용자 노출 사유가
  구체 사유 대신 일반 "구조적으로 비교 불가"로 뭉개진다 → **사유 추적성 손실**. 후속 과제 권고

## 5-1. Codex G1 게이트 (Lv.3+ 필수) — PASS, 단 HIGH 1건 실측 확인

`codex_gate_check.py` 결과: **pass=true, critical=false, risks=2**(high 1 / low 1).
외부 AI 호출 전 **sanitize 게이트 통과** — 3파일 PII 검출 **0건**.

### HIGH — `map_state` 타입 오염 시 예외 누출 (실측으로 사실 확인)

Codex 지적: "키 부재/알 수 없는 문자열"은 봉인되나 **비해시 타입(list/dict)** 은 `TypeError` 가 샌다.
팀장이 직접 양쪽에서 실행해 확인했다:

| `map_state` 값 | 현 main `dce9c987` (before) | 이관 후 (after) |
|---|---|---|
| `list` | TypeError: unhashable type: 'list' | TypeError (**잔존**) |
| `dict` | TypeError: unhashable type: 'dict' | TypeError (**잔존**) |
| `int` (42) | ValueError 누출 | `excluded_malformed_entry` |
| `None` | ValueError 누출 | `excluded_malformed_entry` |

**판정: 회귀 아님 — 순개선.** 현 main 은 4/4 전부 예외가 샜고, 하드닝 후 2/4 가 봉인됐다.
남은 2건(list/dict)은 **`a9b1e9c` 자체의 잔여 갭**이지 이번 이관이 만든 것이 아니다.

**수정하지 않은 이유**: 본 task 의 명시적 제약이 "**신규 코드 작성 금지 — `a9b1e9c` 내용 이관만**"이다.
여기서 타입 검증을 새로 넣으면 PR 이 더 이상 `a9b1e9c` 의 충실한 이관이 아니게 되어,
"이관본이 원본과 bit 단위 동일"이라는 이 작업의 핵심 검증 근거가 무너진다.
→ **은닉하지 않고 후속 과제로 명시**하며, 수정 여부는 ANU/회장 판단에 맡긴다(§10).

### LOW — 설계문서 "영향받는 파일 없음" 과 실제 3파일 불일치
task 파일 메타데이터 이슈. 실제 변경 범위는 보고서·PR 에 3파일로 정확히 명시되어 있어 실동작 영향 없음.

## 6. 마아트 독립 검증 (critical 필수)

| 항목 | 판정 |
|---|---|
| V1 이관 정확성 (blob sha·diff·3파일 외 0) | PASS |
| V2 "신규 코드 0줄" 반증 시도 | PASS (반증 실패 = 주장이 옳음) |
| V3 before/after 공격 재현 + 스크립트 조작 여부 감사 | PASS |
| V4 회귀 | 부분PASS — 0 실패는 확인, `.env` 인과는 마아트가 재현 못함(격리 상태였음) |
| V5 원격 대조 (tree sha·parent·PR 통계) | PASS |
| V6 engine.py 통합 (폴백 크래시 없음) | PASS |

**종합 판정: 조건부(Conditional)** — 머지 직전 `mergeable_state` 재확인 + 매핑 누락 후속 권고.

### 마아트 지적에 대한 팀장 확인
- **"30 passed 수치 부정확"** → 재확인 결과 **scope 차이**였다. 해당 2파일 scope 는
  `--collect-only` 로 **정확히 30 tests collected**. 마아트는 게이트 2파일(91)과 전량(2193)만 실행해
  그 scope 를 돌리지 않았다. 수치는 정확하다.
- **"`.env` 인과 미검증"** → 타당한 지적이나, 인과는 **팀장이 직접** 동일 커밋에서 `.env` 만
  격리/복원해 5 failed ↔ 30 passed 로 확정했다(§3 표). 마아트 시점엔 이미 격리 상태여서 재현 불가였다.
- **"`mergeable_state=unstable` 미언급"** → 지적이 맞다. 원인은 `diagnostic` 체크 지연이었고,
  추적한 결과 **최종 11/11 success** 로 해소되었다(§3·§7). `main` 은 branch protection 미설정.

## 6-1. ★ QC 게이트 결과 — tdd_check FAIL (교차repo 사각지대) · ANU 판단 필요

`finish-task.sh` (FINALIZE_ONLY=1) 실행 결과: **QC 종합 FAIL** → finish-task 는 `.done` 을 만들지 않고 exit 1.

> ★ **정정/주의**: 그럼에도 `memory/events/task-2973.done` 이 **존재한다**. 생성 시각 09:22:31 은
> 팀장이 CLAUDE.md 규칙대로 호출한 `task-timer.py end` 시각과 **정확히 일치**하며, 내용도
> `{task_id, team_id, end_time, duration_seconds}` 뿐이다(QC 결과 필드 없음).
> 즉 **`.done` 은 timer end 의 부산물이지 QC 통과의 산물이 아니다.** 이 `.done` 을 근거로
> "QC 통과"로 읽으면 안 된다. 팀장이 수동 생성한 것도 아니다.
> 하류 시스템이 `.done` 존재만으로 완료 처리할 위험이 있어 명시적으로 경고한다 — **ANU 판단 필요**.
집계: **9 PASS, 1 FAIL, 10 SKIP, 3 WARN** — 유일한 FAIL 은 `tdd_check` 다.

```
tdd_check: FAIL
  변경 파일 총 2개: 테스트 0개, 구현 2개
  테스트 파일 없이 구현 파일만 변경됨 → FAIL
    IMPL  memory/reports/artifacts/task-2973/attack_repro.py
    IMPL  memory/reports/artifacts/task-2973/l1_e2e_api.py
```

### 근본 원인 (사실 관계)

`tdd_check` 는 `memory/logs/audit-trail.jsonl` 을 **1순위**로 사용한다(`--check-files` 는
audit-trail 매칭이 있으면 무시됨 — 소스 확인). audit-trail 은 **워크스페이스 안의 Write/Edit** 만
기록하는데, 이번 실제 산출물은 **InsuRo 리포에서 `git checkout` (Bash)** 으로 이관되어
`bash_cmd` 로만 남았다. 그래서 audit-trail 에는 워크스페이스에 만든 **검증 스크립트 2개만** 남았고,
`_is_test_file()` 이 이름/경로 규칙(`test_*`, `/tests/`)에 안 맞아 둘 다 IMPL 로 분류됐다.

**판정의 사실성**: "테스트 파일 없이 구현 파일만 변경됨"은 **이 작업에 대해 사실이 아니다.**
실제 PR #227 은 테스트 파일 2개를 포함하며 그중 `test_grouping_gate_seal_task2970.py` 는 **+273줄 신규 회귀 테스트**다.
즉 검증기 로직이 아니라 **입력(감사추적)이 교차repo를 못 보는 것**이 원인이다.

### 실증 — 같은 검증기, 입력만 바꾸면 PASS

`--check-files` 로 **실제 산출물 3파일**을 넘겨도 audit-trail 이 우선이라 FAIL 이 유지된다.
그러나 같은 검증기의 경로 판정 함수에 3파일을 직접 넣으면 **PASS** 가 나온다:

```
tdd_check.verify('task-2973', check_files=<실제 3파일>) -> FAIL
    audit-trail 기반 검증 (task_id='task-2973')
    변경 파일 총 2개: 테스트 0개, 구현 2개      ← 워크스페이스 검증 스크립트만 보임

tdd_check._verify_by_check_files(<실제 3파일>)   -> PASS
    파일 총 3개: 테스트 2개, 구현 1개
      TEST  server/tests/test_grouping_gate_seal_task2970.py
      TEST  server/tests/test_policy_grouping_comparison_gate.py
```

→ **검증기 로직은 정상. 입력(감사추적)이 교차repo를 못 보는 것이 유일한 원인**임을 실증했다.
(참고: audit-trail 30379 행에 JSON 파싱 오류도 1건 있다 — 별건.)

### 팀장이 자체 해소하지 않은 이유 (self-bypass 금지)

기술적으로 통과시킬 방법은 있었으나 **전부 규칙 위반**이라 실행하지 않았다:
1. task 파일에 `코드 수정 없음`/`검증만` 문구 추가 → `_is_non_code_task()` 가 SKIP.
   → **해치 부여 권한은 ANU**. 봇 자가부여 = self-bypass. 게다가 코드를 바꿨으므로 **사실이 아님**.
2. 검증 스크립트를 `test_*.py` 로 개명 → 분류만 바꾸는 **게이트 우회**.
3. `--skip tdd_check` → 무단 우회.
4. 수동 `.done` 생성 → **절대 금지**.

### ANU 판단 요청

- 실질 게이트는 전부 통과했다: **Codex G1 PASS · sanitize PASS(PII 0) · G3 독립검증 PASS ·
  마아트 독립검증(조건부 PASS) · CI 11/11 success · 회귀 0 실패 · trip-wire 5종 0 위반**.
- 남은 것은 교차repo 감사 사각지대로 인한 `tdd_check` 오판 1건이다.
- **요청**: 이 건에 대한 해치 부여 또는 `tdd_check` 의 교차repo 대응(예: PROJECT_PATH 의 git diff 를
  감사 입력으로 인정) 판단. 그 전까지 `.done` 은 생성하지 않는다.

### 부수 확인
- `.qc-result` 파일이 생성되지 않아 `finish-task.sh` 가 exit 1 로 종료했고
  `task-2973.supervisor-crash-marker.json` 이 기록됐다. `.failed` 는 생성되지 않았다.
- 보고서에 `affected_files` 섹션이 없어 `browser_verify` 등 일부 검증기가 SKIP 되었다(아래 추가).

## affected_files

- /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/policy_grouping/comparison_gate.py
- /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/tests/test_grouping_gate_seal_task2970.py
- /home/jay/projects/InsuRo/.worktrees/task-2973-dev2/server/tests/test_policy_grouping_comparison_gate.py

## 7. 머지 판단

- **머지 필요**: Yes — 단 **머지 HOLD**(ANU 검증 후 판단). 팀장 자체 머지하지 않음
- **PR**: https://github.com/Jeon-Jonghyuk/InsuRo/pull/227 (`state=open`, files=3, +480/−25)
- **브랜치**: `task/task-2973-dev2` (원격 head `c199d963`)
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2973-dev2`
- **원격 무결성**: 원격 commit tree `32d2ba6` == 로컬 tree `32d2ba6` (bit 단위 일치),
  parent = `dce9c987`(현 origin/main) — 마아트 독립 확인
- **CI**: **11/11 success**. 검증 중 `diagnostic` 이 18분간 pip 설치 단계에서 지연되어 한때
  `mergeable_state=unstable` 이었으나(마아트 지적), 이후 정상 완료됨. `main` 은 branch protection 미설정
- **머지 의견**: 이관 정확성·3구멍 차단·회귀 0 실패가 모두 독립 검증됨. 머지 가능 상태로 판단하되,
  머지 시점에 `diagnostic` 완료 확인 필요

### ★ ANCESTOR 검증 (PR #226 사고 재발 방지 — 필수)

현재 상태(머지 전): 하드닝은 아직 main 조상이 **아님**(정상 — 머지 대기 중이므로).

```
git fetch origin main
git merge-base --is-ancestor c199d963477feb456d15e5fcfcb43cc74c548bca origin/main && echo ANCESTOR_YES || echo ANCESTOR_NO
```

**머지 직후 이 명령이 `ANCESTOR_YES` 를 낼 때까지 완료로 간주하지 말 것.**
추가로 심볼 실측 확인 권장:
```
git show origin/main:server/policy_grouping/comparison_gate.py | grep -c "REASON_EXCLUDED_AXES_MALFORMED"
```
→ 0 이면 반영 실패(PR #226 과 동일 사고).

## 8. 모델 사용 기록

| 역할 | 담당 | 모델 | 비고 |
|---|---|---|---|
| 팀장(설계/판단/결선) | 오딘 | Opus | 직접 코딩 없음 — 이관 지시·검증·PR 결선 |
| 백엔드 이관 | 토르 | sonnet | 기계적 이관 + 회귀/pyright |
| QA 공격재현·L1 | 헤임달 | sonnet | 스크립트 작성 판단 필요 → haiku 미사용 |
| 독립 반증 검증 | 마아트 | sonnet | critical 필수, 분석 작업 → haiku 금지 |

haiku 사용 0건.

## 9. 산출물

- PR #227 (3파일, +480/−25)
- `memory/reports/artifacts/task-2973/attack_repro.py` (151줄) — A/B/B2/C 재현
- `memory/reports/artifacts/task-2973/l1_e2e_api.py` (343줄) — 종단 HTTP E2E
- 3문서: `memory/plans/tasks/task-2973/{plan,context-notes,checklist}.md`

## 10. 후속 과제 (본 PR 범위 밖)

1. `engine.py::_GATE_REASON_TO_EXCLUDED_REASON` 에 신규 사유 2종 명시 매핑 (사유 추적성 복원)
2. `engine.py::_unknown_rows()` 의 `entry["coverage_name"]` 무방비 인덱싱 방어
3. **`map_state` 비해시 타입(list/dict) 봉인** — Codex HIGH, 현 main 대비 회귀는 아니나 잔여 갭 (§5-1)
4. 머지 후 ANCESTOR + 심볼 실측 확인 (§7)

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

