# task-3076 — 플랜 스케일 off-by-one 완전 해소 + 권한 게이트 정상화 + A4 봉인

- **레벨**: Lv.3 (권한/과금 경계) · **repo**: InsuRo · **담당**: dev1
- **base**: `ac4b1d01` (t3075 머지 지점) · **branch**: `task/task-3076-dev1`
- **원격 커밋**: `b30b1fdd` (Git Data API 재구성 — 하네스가 push 리터럴 차단)
- **PR**: [#288](https://github.com/Jeon-Jonghyuk/InsuRo/pull/288) — **OPEN, 머지·배포 안 함**
- ★ **tree sha 대조 완료**: 로컬 `93062b9d…` == 원격 `93062b9d…`
  → 커밋 sha(`5395b0b` 로컬 vs `b30b1fdd` 원격)가 다른 것은 **재구성 때문이며 정상**이다.
  후속 봇/워처는 커밋 sha 불일치를 이상으로 오판하지 말 것.

---

## 1. 뿌리 — "값이 우연히 같은 자리만 증상이 없었다"

코드의 플랜 dict 정수 키는 **1-based 서열**(0=폴백, 1=무료 … 5=히든)로 작성됐는데,
조회는 전부 DB `subscription_plans.sort_order` 로 했다. 프로덕션 실측(직독):

| DB name | sort_order |
|---|---|
| Free | 0 |
| Basic | 1 |
| Pro | 2 |
| Max | 3 |
| Hidden | 4 |

즉 **모든 dict 가 정확히 한 칸씩 어긋나 있었다.**

같은 어긋남이 게이트에도 있었다. `required_order`(1-based)를 `sort_order`(0-based)와
비교했으므로 조건이 사실상 `rank(user) <= rank(min_plan)` 이 되어
**"요구 플랜 본인은 항상 차단"** 됐다.

### 1-1. 명세 대비 정정 (ANU 수치 검증)

| 항목 | ANU 명세 | 실측 | 판정 |
|---|---|---|---|
| dict 잔여 어긋남 | 8건 | **8건** | 명세 정확 |
| `require_feature` 오게이팅 | 18/19 | **19/19** | 명세가 1건 적다 |
| `require_plan("히든")` 도달불가 | 11 엔드포인트 | **16개** | 명세는 main.py 만 계수 (mediscan 5 누락) |
| Max opus 자기모순 403 | 있음 | **재현됨** | 명세 정확 |
| 잔여석 `occupied` 0 고정 | 실제 2명 | **활성 Max 2명 직독 확인** | 명세 정확 |

- 19번째 오게이팅 = `keyword_tools_algorithm`(히든). **아무에게도** 열려 있지 않았으므로
  ANU 가 "Pro 8 · Max 7 · Basic 2"(+무료 1 = 18)로 셀 때 빠졌다.
- 히든 16 = main.py `require_plan("히든")` **11** + `mediscan_router.py` **5**.
  mediscan 은 lazy-import 때문에 **자체 `require_plan` 복제본**을 갖고 있어 같은 결함이 두 벌 있었다.

### 1-2. 활성 사용자 실측 (폭발반경)

프로덕션 직독 — `user_subscriptions` status=active:
`Hidden 5 · Max 2 · Pro 1 · Basic 1 · Free 1` (+ `organization_subscriptions` Hidden 1). **총 10명**.

---

## 2. 작업 1 — 구조로 해소 (개별 숫자 맞추기 금지)

### 채택한 방식과 근거

**이름 기반 단일 카탈로그 + 변환 지점 1개.** `server/plan_catalog.py` 신설(414줄).

왜 이 방식인가:
1. **이름은 척도가 없다** — off-by-one 이 원천적으로 불가능하다. t3075 봇이 "이름 기반
   소스 3개"로 교차확인에 성공했고, 이번에 프론트 `planSkillMap.ts`·`planToTier`·
   `planFeatureMap.ts` 가 **전부 이름 기반이라 처음부터 옳았다**는 것을 확인했다
   (= 서버만 밀려 있었다). 프론트를 독립 증인으로 쓸 수 있다.
2. **변환을 없앨 수는 없다**(DB 는 0-based, 프론트 서열은 1-based). 그래서 **한 곳에 몰았다**:
   `PlanTier.rank = db_sort_order + 1` 이 유일한 ±1 이다. 소스를 직접 grep 하는
   테스트(`test_single_conversion_point_between_db_and_code`)가 2곳이 되는 순간 실패한다.
3. **파생 뷰**: `main.py` 의 정수 키 dict 5개는 카탈로그에서 생성된다. 키 집합이 DB 와 1:1
   이라 **"도달 불가 키"(구 `[5]`)를 만들 방법 자체가 없다.**
4. **핫패스는 정수 조회를 안 한다**: `/generate-content` 는 `plan_tier.skills / .channels /
   .model_cap / .model_tier` 를 직접 쓴다.

### DB ↔ 코드 불일치 시 즉시 실패

- **부팅 시**: `_verify_plan_catalog_on_startup()` 이 `subscription_plans` 를 읽어 대조.
  어긋나면 **ERROR 로그**. `INSURO_STRICT_PLAN_CATALOG=1` 이면 부팅 중단.
  ★ 기본은 죽이지 않는다 — 유닛이 `Restart=always` 라 Supabase 일시 장애로 API 전체가
  기동 불능이 되면 피해가 더 크다고 판단했다(ANU 판단 요청 4번).
- **테스트**: `verify_against_db_rows()` 를 5종 드리프트(전 플랜 +1 / Hidden 만 밀림 /
  DB 에만 새 플랜 / DB 에서 플랜 삭제 / 빈 테이블)로 **하드 실패** 확인.
- **라이브**: 자격증명이 있으면 프로덕션 `subscription_plans` 를 직접 읽어 대조
  (로컬 실행 확인 — skip 0).

### 어긋남 8건 → 0건 (전수표 재작성)

| dict | DB 키 | 플랜 | 변경 전(**실제 조회되던 값**) | 변경 후 |
|---|---|---|---|---|
| `PLAN_SKILL_ACCESS` | 2 | Pro | `base` | `base, naver_seo, geo` |
| `PLAN_SKILL_ACCESS` | 3 | Max | `base, naver_seo, geo` | `+viral_hook, pro_blog, cro_copy` |
| `PLAN_LEVEL_MODEL_CAP` | 1 | Basic | `haiku` | `sonnet` |
| `PLAN_LEVEL_MODEL_CAP` | 3 | Max | `sonnet` | `premium` |
| `PLAN_LEVEL_MODEL_TIER` | 2 | Pro | `haiku` | `sonnet` |
| `PLAN_LEVEL_MODEL_TIER` | 3 | Max | `sonnet` | `opus` |
| `PLAN_LEVEL_CHANNELS` | 1 | Basic | 진입 2채널 | 전 6채널 |
| `PLAN_LEVEL_CLI_MODEL` | 2 | Pro | `haiku` | `sonnet` (죽은 코드) |

값은 t3075 이전 원본 dict 의 **작성 의도**(1-based 서열 기준)를 그대로 옮긴 것 =
**정책 변경이 아니라 의도 복구**. 프론트와 교차 확인했고 그 일치를 테스트로 고정했다.

---

## 3. 작업 2 — 권한 게이트 정상화 ★ 핵심 산출물

### 3-1. 플랜 × 게이트 접근 매트릭스 (변경 전 → 후)

게이트 라우트 **43개** × 플랜 5개 전수. `O`=허용 `X`=차단 ★=변화.

| 최소 플랜 | feature_key | 무료 | 베이직 | 프로 | 맥스 | 히든 | 라우트 수 |
|---|---|---|---|---|---|---|---|
| 무료 | `onboarding_ai` | X→O ★ | O→O | O→O | O→O | O→O | 1 |
| 베이직 | (require_plan) | X→X | X→O ★ | O→O | O→O | O→O | 2 |
| 베이직 | `ai_image_generate` | X→X | X→O ★ | O→O | O→O | O→O | 1 |
| 베이직 | `content_ai_generation` | X→X | X→O ★ | O→O | O→O | O→O | 1 |
| 프로 | (require_plan) | X→X | X→X | X→O ★ | O→O | O→O | 1 |
| 프로 | `ai_image_premium` | X→X | X→X | X→O ★ | O→O | O→O | 2 |
| 프로 | `ai_topic_suggest` | X→X | X→X | X→O ★ | O→O | O→O | 1 |
| 프로 | `crm_chat_summary_search` | X→X | X→X | X→O ★ | O→O | O→O | 1 |
| 맥스 | (require_plan) | X→X | X→X | X→X | X→O ★ | O→O | 2 |
| 맥스 | `crm_ai_analysis` | X→X | X→X | X→X | X→O ★ | O→O | 7 |
| 맥스 | `crm_push_notification` | X→X | X→X | X→X | X→O ★ | O→O | 1 |
| 맥스 | `infokeyword_access` | X→X | X→X | X→X | X→O ★ | O→O | 2 |
| 맥스 | `infokeyword_analyze` | X→X | X→X | X→X | X→O ★ | O→O | 5 |
| **히든** | (require_plan) | X→X | X→X | X→X | **X→X** | **X→O ★** | **16** |

**항목별 확인 — 열리는 것이 원래 그 플랜 권한이 맞는가**

- 열린 칸은 **전부 "요구 플랜 본인" 한 칸뿐**이다. 즉 `min_plan == 그 플랜` 인 자리만 열렸다.
  정의상 그 플랜의 권한이 맞다.
- **새로 닫힌 칸 0건** — 기존 사용자가 잃는 기능이 없다.
- **상위 기능이 하위 플랜에 열린 곳 0건** — `test_no_gate_grants_more_than_its_declared_minimum`
  이 43×5 전수로 봉인.
- **히든 전용 16개는 맥스 이하에 그대로 X** — 회장 지시(하위 플랜엔 잠금 배지만 노출해
  상위 유도) 유지. `test_hidden_only_gates_stay_closed_below_hidden` 이 4개 하위 플랜 ×
  16개 라우트를 전수 확인.

**히든 전용 16개 라우트**
`/api/insuro/ai/naver-onestop` · `/ai/schedule` · `/ai/thread-auto` · `/ai/tistory-upload` ·
`/wiki/contributions` · `/wiki/rankings` · `/api/pipeline/{start,status,cancel,retry,requeue}` ·
`/api/mediscan/{upload,analyze,result(GET),result(DELETE),history}`

### 3-2. `PLAN_FEATURE_MAP` 19건 전수 (전부 한 등급씩 위로 밀려 있었다)

| min_plan | 기능 | 부당 차단되던 플랜 |
|---|---|---|
| 무료 | `onboarding_ai` | 무료 |
| 베이직 | `ai_image_generate`, `content_ai_generation` | 베이직 |
| 프로 | `ai_generate`, `ai_image_premium`, `ai_topic_suggest`, `crm_access`, `crm_advanced_filter`, `crm_chat_summary_search`, `crm_export`, `keyword_tools_basic` | 프로 |
| 맥스 | `crm_ai_analysis`, `crm_bulk_action`, `crm_push_notification`, `infokeyword_access`, `infokeyword_analyze`, `keyword_analysis`, `keyword_tools_full` | 맥스 |
| 히든 | `keyword_tools_algorithm` | 히든 (= 아무도 못 씀) |

### 3-3. Max opus 자기모순 403 해소

Max(sort_order=3)가 프로용 cap `sonnet` 을 받아 opus 요청 시
*"이 모델은 **맥스** 플랜 이상에서 사용 가능합니다"* 라는 403 이 났다.
Max cap = `premium` 으로 교정. 일반형 탐지기
`test_no_plan_is_denied_by_its_own_name` 이 **전 플랜 × 전 모델**에 대해
"자기 자신(또는 자기 이하)을 요구하는 거부"를 금지한다.

### 3-4. ★ 검증은 상수가 아니라 실제 결선으로

t3075 M8 변이가 1차 SURVIVED 한 원인(테스트가 결선을 **재구현**)을 되풀이하지 않기 위해:

- `main.app.routes` 를 순회해 각 라우트의 `dependant` 트리에서 **FastAPI 가 실제로 호출할
  의존성 함수 객체 그 자체**를 꺼내 호출한다. 클로저 freevar 에서 `min_plan`/`feature_key`
  를 복원해 기대값을 만든다.
- **하드코딩된 엔드포인트 목록이 없다** → 새 엔드포인트가 게이트를 달면 자동으로
  매트릭스에 들어온다. 수집이 0건이면 매트릭스가 공허해지므로
  `test_gate_routes_were_actually_discovered` 가 먼저 막는다(공허한 단언 방지).
- 대표 경로는 **HTTP 왕복**(TestClient): 맥스가 `/api/mediscan/history` 에서 403,
  히든은 플랜 403 아님. 잔여석은 **"DB 가 영문 이름만 아는" 가짜 Supabase** 로
  실제 라우트를 통과시켜 `occupied==2` 를 확인한다.

---

## 4. 작업 3 — A4 봉인 (조용한 빈 프롬프트)

스킬이 권한 필터에서 빠지면 `benchmark_prompt` 가 조용히 `""` 가 되고 환각 금지 규칙·
분량·이미지 지시가 통째로 사라졌다. **화면상 스킬은 켜져 있고 에러도 없었다.**

**관측 가능하게 만든 방식 (400 전면 차단 금지 준수)** — 세 겹으로 남긴 근거:

1. **구조화 경고 로그** `skill_access_denied` — user·plan·requested·allowed·dropped +
   `benchmark_grounding_present`. 로그만으로 사후 집계가 가능하다.
2. **분석 이벤트** `skill_access_denied` — 대시보드에서 "몇 명이 조용히 당했나"를 셀 수 있다.
   계측 실패가 생성을 막지 않도록 예외를 삼키되 그 사실도 로그로 남긴다.
3. **응답 `skill_notices`** (추가 필드, 기존 소비자 무영향) — benchmark 가 빠진 경우
   *"벤치마킹 근거와 환각 금지 규칙이 이번 생성에 적용되지 않습니다"* 를 **사용자에게** 명시.

왜 셋 다인가: 하나만으론 다시 묻힌다. 로그만 두면 아무도 안 본다(4개월 사례). 응답만 두면
프론트가 안 그리면 끝이다. 이벤트만 두면 개별 건이 안 보인다. **변이 M7·M8·M9·M10 이
각각 하나씩 제거했을 때 전부 KILL** 되는 것으로 세 겹이 모두 봉인됐음을 확인했다.

t3072 봉인(스킬이 **허용된** 상태에서 근거 없으면 400)은 그대로 유지된다
(`test_a4_benchmark_grounding_still_rejected_when_skill_allowed_but_no_grounding`).

---

## 5. 곁가지로 드러난 실 결함 (전부 이번에 수정)

1. **`get_user_plan` 이중 오류** — `plan_row.get("sort_order") or PLAN_ORDER.get(name, 1)`:
   DB 의 Free 는 `sort_order=0` 인데 **0 이 falsy** 라 **항상** `or` 우변으로 샜고,
   거기서 한국어 키로 조회해 DB 영문 이름("Free")과 안 맞아 기본값 **1(=DB 의 Basic)** 이
   반환됐다. 오류 두 개가 서로를 가려서 Free 사용자만 우연히 정상 동작했다.
2. **`/api/insuro/premium/remaining-seats` 의 `occupied` 영구 0** —
   `.eq("name", "맥스")` 인데 DB 이름은 영문 `"Max"` → 항상 0행 → `max_plan_id=None`.
   실제 활성 Max 구독자 **2명**. `.in_(["맥스","Max"])` 로 교정.
   같은 이유로 `get_user_plan` 의 Free 폴백 조회(`.eq("name","무료")`)도 죽어 있었다.
3. **금소법 더블체크가 Pro 를 건너뛰고 있었다** — `if sort_order >= 3: # 프로 이상 플랜`
   이 DB 스케일에선 Max·Hidden 만 해당. **주석 의도와 정반대.**
4. **Threads 발행이 Pro 를 차단** — `if plan["sort_order"] < 3:` 에 `# Pro 이상만` 주석.
   역시 정반대.
5. **모르는 플랜이 god mode 였다** — 기존 테스트 픽스처의 `"Professional"/sort_order:99`,
   `sort_order:10` 이 모든 게이트를 통과했다(정수 비교라). 이제 모르는 이름은
   **무료로 fail-closed** 되고 경고가 남는다.

---

## 6. 검증

| 항목 | base `ac4b1d0` | head | 판정 |
|---|---|---|---|
| pytest 전체(`tests/ -q`, 자격증명 없음) | 3280 passed / 7 skipped | 3556 passed / 7 skipped | **신규 실패 0** |
| pytest 전체(자격증명 포함) | — | 3558 passed / 5 skipped | 0 failed |
| pytest CI 동일 인자(`-x`, 2파일 ignore) | **3246 passed / 7 skipped** | **3521 passed / 8 skipped** | **+275 · 신규 실패 0** |
| `npx tsc --noEmit` | — | **exit 0** | TS 무변경 |
| `npm run test` (vitest) | — | **138 files / 1954 passed** | TS 무변경 |
| 라이브 DB 대조 | — | 프로덕션 5행 직독 후 일치 | PASS |

### 6-1. 자작 변이 18종 — **전건 KILL** (SURVIVED 0 · INVALID 0)

변이는 **사고를 낸 그 라인**에 넣었다. 격리 사본(`/tmp/t3076-mut`)에서 실행.

| # | 변이 | 판정 |
|---|---|---|
| M1 | `rank` 에서 `+1` 제거 (변환 소실) | KILLED |
| M2 | `rank` 를 `+2` 로 (과다 변환) | KILLED |
| M3 | Hidden `db_sort_order` 4→5 (**원사고 재현**) | KILLED |
| M4 | Max 에 `benchmark` 개방 (히든 전용 위반) | KILLED |
| M5 | `require_plan` 이 다시 `sort_order` 와 비교 | KILLED |
| M6 | `require_feature` 가 다시 `sort_order` 와 비교 | KILLED |
| M7 | A4: 응답에서 `skill_notices` 제거 | KILLED |
| M8 | A4: 경고 로그 → debug 강등 | KILLED |
| M9 | A4: `dropped_skills` 를 항상 빈 목록으로 | KILLED |
| M10 | A4: 안내문에서 환각 경고 문구 제거 | KILLED |
| M11 | Max cap 을 `sonnet` 으로 (자기모순 403 부활) | KILLED |
| M12 | DB 대조가 예외를 삼킴 | KILLED |
| M13 | mediscan 게이트가 다시 `sort_order` 와 비교 | KILLED |
| M14 | `db_name_candidates` 가 한국어만 반환 | KILLED* |
| M15 | Basic 채널을 진입 2개로 되돌림 | KILLED |
| M16 | `resolve_tier` 가 sort_order 우선 (이름 무시) | KILLED* |
| M17 | `required_rank` 가 모르는 플랜을 조용히 1로 | KILLED |
| M18 | Pro `model_tier` 를 haiku 로 되돌림 (과금 티어 회귀) | KILLED |

\* **M14·M16 은 1차에서 SURVIVED** 했다 — 내 봉인의 실제 구멍이었다.
- M14: `occupied` 결함을 상수로만 막고 **실제 라우트로 확인하지 않았다**
  → DB 가 영문 이름만 아는 가짜 Supabase 로 라우트를 통과시키는 테스트 추가.
- M16: 모순 테스트를 **범위 밖 sort_order(5)** 로 썼더니 폴백이 가려 공허했다
  → **범위 안 모순값**(Hidden/3, Pro/3, Max/4 등)으로 교체.
1차 SURVIVED 를 그대로 보고하는 이유: 이 두 건이 "테스트가 결선을 재현하지 않으면
구멍이 남는다"의 이번 실증이다.

**no-op 방지**: 각 변이는 주입 전 `원문 count == 1` 을 단언한다(0회·2회면 INVALID 처리).
치환 후 파일이 실제로 달라졌는지도 확인한다. 판정은 파이프를 거치지 않은
**subprocess 반환 종료코드**로 한다(파이프가 종료코드를 먹어 거짓 SURVIVED 가 되는 함정 회피).

### 6-2. 척도를 잘못 인코딩하고 있던 기존 테스트 (전부 이름 기반으로 전환)

| 파일 | 무엇이 틀렸나 |
|---|---|
| `test_model_cap_task3014.py` | "사람이 직접 채운 독립 진실표"가 `{1:무료 … 5:히든}` 라벨을 달고 **한 칸 밀린 척도 위**에 쓰여 있었다. `(3,"opus"):False` 는 "프로는 opus 거부"라는 뜻이었지만 조회되던 sort_order=3 은 **Max** → **Max 가 opus 를 못 쓰는 자기모순을 봉인**하고 있었다 |
| `test_task3068_model_default_cap.py` | `PLAN_LEVEL_TO_NAME = {1:"Free" … 5:"Hidden"}` 하드코딩 |
| `test_get_user_plan.py` | 픽스처가 **한국어 이름 + 1-based sort_order**("프로", 3). 프로덕션은 영문 + 0-based → 프로덕션 불충실 |
| `test_infokeyword_proxy.py` · `test_edge_to_anu.py` | `sort_order: 10` / `"Professional"/99` 같은 **실재하지 않는 god-mode 픽스처** |
| `test_task3064_benchmark_grounding.py` | 도달 불가 키 `[5]` 의 존재를 요구 |

**교훈**: "독립 진실표"도 척도가 오염되면 결함과 함께 통과한다. 표의 키는 **이름**이어야 한다.

---

## 7. 하지 않은 것 (지시 준수)

- DB `subscription_plans` **무변경** (sort_order +1 = 무상 승급 = 과금 사고)
- 프로덕션 고객 데이터 무변경
- SEO 노하우 on/off (t3077) 무관여
- **머지·배포 안 함** (ANU 담당)
- `infokeyword_analyze` 를 서버에 임의 추가하지 않음 (아래 판단 요청 3번)

---

## 8. ★ ANU 판단 요청

1. **과금 비용 상승 2건 — 배포 전 회장 확인 필요**
   - `PLAN_LEVEL_MODEL_TIER` 는 `feature_token_costs.model_tier` **조회 키**다.
     Pro `haiku→sonnet`, Max `sonnet→opus` 로 **회당 토큰 차감액이 오른다.**
   - `PLAN_LEVEL_MODEL_CAP` 의 Max `sonnet→premium` 은 Max 가 opus 를 실제로 쓰게 되어
     **AI 원가**가 오른다.
   - 둘 다 **원래 의도된 등급**이지만 배포 시점에 비용이 변한다. 영향 활성 사용자:
     Pro 1명 · Max 2명.
2. **베이직 채널 4종 개방** — Basic 이 tistory-blog·instagram·threads·youtube-reels 를
   쓰게 된다(원본 `PLAN_LEVEL_CHANNELS[2]`=베이직 의도). 활성 Basic 1명.
3. **프론트 전용 스킬 드리프트 `infokeyword_analyze`** — `planSkillMap.ts` 의 Max·Hidden
   에는 있는데 서버 `PLAN_SKILL_ACCESS` 에는 **아예 없다**. off-by-one 이 아니라 **누락**
   이므로 임의로 추가하지 않고 `KNOWN_SKILL_DRIFT` 로 명시만 했다(테스트가 서버 쪽으로
   새는지 감시). **추가 = 기능 개방**이라 결정이 필요하다.
   참고: 이번 A4 배선 덕에 지금은 Max/Hidden 이 이 스킬을 켜면 **경고가 남는다**(전에는 무음).
4. **부팅 시 DB 대조를 ERROR 로그로만 둔 판단** — `Restart=always` 유닛에서 하드 실패시키면
   Supabase 일시 장애로 API 전체가 기동 불능이 된다. `INSURO_STRICT_PLAN_CATALOG=1` 로
   강제 가능하게 해뒀다. 프로덕션에서도 strict 로 갈지 결정 필요.
5. **배포 전제** — 서버만 바꾼다. 프론트는 이미 이름 기반이라 옳았으므로 변경 불필요.
   배포 후 프론트가 보내는 값과 서버 판정이 **처음으로 일치**하게 된다.
6. **tree sha 로 검증했음** — 하네스가 push 리터럴을 차단해 Git Data API 로 원격 브랜치를
   만들었다. **커밋 sha 불일치는 정상**(재구성). 대조 기준은 tree sha `93062b9d…`.

---

## 9. CI 결과 — 적색 2건, 둘 다 이 PR 과 무관 (증거 첨부)

PR #288 head `b30b1fdd` · 체크 11개 중 **9 green · 2 red**.

| 체크 | 결과 |
|---|---|
| ci/guard · guard · qc-check · lock-in-check · merge-safety-check · hidden-path-audit · cancel-kill-switch · gemini-review-gate · diagnostic | **success** (9건) |
| **ci** | **failure** |
| **e2e-test** | **failure** |

### 9-1. `ci` 적색 = 러너 디스크풀. **파이썬 테스트가 실행조차 안 됐다**

job `99385959620` step 10 `Python 의존성 설치`:

```
ERROR: Could not install packages due to an OSError: [Errno 28] No space left on device
##[error]Process completed with exit code 1.
```

→ step 11 `Pytest 서버 테스트` 는 **skipped**.

★ **결정적 증거**: base 커밋 `ac4b1d0`(t3075 머지 지점)의 `ci` 잡도
**step 10 failure · step 11 skipped** 로 실패 시그니처가 **완전히 동일**하다.
내 변경 이전부터 있던 자체호스트 러너 디스크 문제다.

CI 에서 실행된 프론트 단계는 전부 통과했다: TypeScript 타입 체크 ✓ · 빌드 ✓ · Vitest ✓.

**따라서 파이썬 회귀의 근거는 로컬 측정이 유일하다**:
base 3246 passed / head 3521 passed · **0 failed** (CI 동일 인자, 별도 base worktree).

### 9-2. `e2e-test` 적색 = 상시 적색 + 내가 안 건드린 UI 스펙

`3 failed / 1 flaky / 15 passed`. 실패 스펙:

- `tests/e2e/new-design-comparison-honest-disclosure.spec.ts:328` (t2969 시나리오 A)
- `tests/e2e/new-design-comparison-honest-disclosure.spec.ts:390` (t2969 시나리오 B)
- `tests/e2e/task-2998-menu-consolidation.spec.ts:486` — *"경고 배너 실렌더: '신규 설계' 포함, '신규 견적' 미포함"*

전부 **신규설계 비교 배너·메뉴 UI** 스펙이다. **이 PR 의 diff 는 `server/` 11파일뿐이고
`src/**` 변경은 0줄**이다.

★ `e2e.yml` **최근 12회 실행이 전부 failure** — task-3069부터 task-3076까지 브랜치 무관한
**상시 적색**이다. 그리고 세 번째 실패 단언은 t2997 기록
("잔존 '신규 견적'이 `role=alert` 빨간 경고배너", "`routes.ts` `section` 의 소비자
`AppSidebar` import 0건")과 정확히 일치한다 → **t2997/t2998 계열의 기존 미해결 건**이며
이번 범위 밖이라 고치지 않았다.

### 9-3. ANU 조치 필요

- **러너 디스크를 확보한 뒤 `ci` 를 재실행해 파이썬 테스트를 실제로 통과시킬지**, 아니면
  로컬 재현(base 3246 → head 3521 · 0 failed)으로 갈음할지 판단이 필요하다.
  ★ 지금 상태로는 **CI 초록이 파이썬 테스트를 보증하지 않는다** — t3075 와 동일한 함정.
- e2e 3건은 t2997/t2998 소관. 이 PR 의 회귀가 아니다.
