## 요약

**회장이 같은 증상을 3회 겪은 진짜 원인이다.** Hidden 플랜인데도 벤치마킹 스킬을 한 번도 못 받았다 — 실사용자 10명 수령 **0/10**.

프로덕션 DB `subscription_plans.sort_order` 는 `0=Free 1=Basic 2=Pro 3=Max 4=Hidden` (최대 **4**) 인데, 코드는 `benchmark` 를 `PLAN_SKILL_ACCESS[5]` 에 넣어놨다. `main.py` 의

```python
allowed_skills = PLAN_SKILL_ACCESS.get(sort_order, ["base"])
```

가 DB 값으로 조회하므로 **`[5]` 는 아무도 도달할 수 없는 키**였다.

`benchmark` 가 `filtered_skills` 에서 빠지면 `benchmark_prompt` 가 **조용히 빈 문자열**이 되고, 그 안의 **환각 금지 규칙·분량 지시·이미지 프롬프트 규칙이 전부 사라진다.** 화면상 스킬은 켜져 있고 에러도 없다. t3068·t3070·t3074 는 전부 프롬프트 문구를 고쳤는데, **프롬프트 자체가 주입되지 않고 있었다.**

## 수정 — 회장 결정 "A" (코드를 DB 에 맞춘다)

`PLAN_SKILL_ACCESS[4]` 에 `"benchmark"` 추가. `[5]` 키는 방어적으로 보존한다.

**DB 는 건드리지 않았다.** `sort_order` +1 은 기존 사용자가 한 등급씩 무상 승급되는 **과금 사고**다.

`MAX_REACHABLE_PLAN_SORT_ORDER = 4` 신설 — 재발 탐지 테스트의 기준점.

## 실측 — base vs head, 동일 프로브

| 플랜 | sort_order | BASE `dcb70d4` benchmark_prompt | HEAD benchmark_prompt |
|---|---|---|---|
| Free | 0 | 0자 | 0자 |
| Basic | 1 | 0자 | 0자 |
| Pro | 2 | 0자 | 0자 |
| Max | 3 | 0자 | 0자 |
| **Hidden** | **4** | **0자 (환각 금지 없음)** | **2209자 (환각 금지 포함)** |

base 에서는 **다섯 플랜 전부 0자** — 증상이 그대로 재현된다. head 에서는 Hidden 만 살아나고 하위 플랜은 그대로 차단이다(Hidden 전용 정책 유지).

## 봉인 테스트 (신규 28건)

`server/tests/test_task3075_plan_scale_offbyone.py`

1. **`sort_order=4`(Hidden) 가 benchmark 를 받는다** — 상수 / 필터 결선 / **실제 엔드포인트** 3층으로 확인
2. **`sort_order<=3`(Max 이하) 은 받지 못한다** — 회장 지시(Hidden 전용) 유지
3. **도달 불가 키에 좌초된 권한 탐지** — 이번 결함의 *일반형*. `PLAN_SKILL_ACCESS`·`PLAN_LEVEL_MODEL_TIER`·`PLAN_LEVEL_MODEL_CAP`·`PLAN_LEVEL_CHANNELS`·`PLAN_LEVEL_CLI_MODEL` 5종 전부에 건다
4. **DB 실측 최대 `sort_order` == 코드 상한** — 라이브 조회(자격증명 없으면 skip). 로컬에서 자격증명 주입해 **실제 통과 확인**(skip 아님)

> ★ 실제 엔드포인트를 통과시키는 테스트를 넣은 이유: 상수만 읽는 테스트는 강제 라인 자체가 무력화돼도 잡지 못한다(t2970·t3014 반복 유형). 변이 **M8 이 이것으로만 죽었다.**

## 기존 테스트 정정 — 결함을 봉인하고 있던 단언들

- `test_task3064`: **`"benchmark 는 [5] 에만, 0~4 에는 없어야 한다"` 단언이 바로 이 결함을 지키고 있었다.** DB 기준(Hidden=4)으로 다시 못박았다.
- `test_task3072`: `HIDDEN_PLAN` 픽스처가 `sort_order=5`(도달 불가)였고, "권한 없는 플랜" 케이스는 `sort_order=4` 를 맥스로 간주했다 → **4 / 3** 으로 정정. 이 상태면 게이트가 실사용자에게 도달하는지 증명하지 못한다.
- `bench_harness` 프로덕션 픽스처 대조 기준 `[5]` → `[4]`.

## 검증

- **회귀**: base `dcb70d4` **3254 passed / 5 skipped / 0 failed** → head **3280 passed / 7 skipped / 0 failed**. 신규 실패 **0** (+26 통과, +2 skip 은 자격증명 없을 때 건너뛰는 라이브 DB 대조)
- **변이 8/8 KILLED** — 주입 전 `원문 in 소스` assert 로 no-op 아님 입증, 파이프 없이 `returncode` 직접 판정
- **DB 무변경**

| 변이 | 결과 |
|---|---|
| M1 `[4]` 에서 benchmark 제거(결함 원상복구) | KILLED |
| M2 benchmark 를 Max(3) 에 개방 | KILLED |
| M3 상한 상수를 5 로 되돌림 | KILLED |
| M4 도달 가능 구간에 구멍 | KILLED |
| M5 도달 불가 `[5]` 에만 신규 스킬 좌초 | KILLED |
| M6 환각 금지 가드 제거 | KILLED |
| M7 Free(0) 에 benchmark 개방 | KILLED |
| M8 스킬 필터 결선 무력화 | KILLED |

## 프론트 대칭 확인

`src/config/planSkillMap.ts` 는 플랜 **이름 문자열**로 인덱싱하므로 off-by-one 과 무관하다. benchmark 허용 집합은 서버·프론트 모두 `{Hidden}` 으로 **일치**한다(테스트로 봉인). 프론트는 건드리지 않았다.

---

## ⚠ 범위 밖 — 보고만 한다 (이 PR 에서 고치지 않음)

전수 확인 결과 **어긋난 dict 는 `PLAN_SKILL_ACCESS` 하나뿐이 아니다.** 코드의 플랜 dict 전체가 1-indexed(`0=Fallback, 1=무료 … 5=히든`)인데 DB 는 0-indexed다. "4와 5가 같은 값이라 상쇄"는 **최상위(Hidden)에서만** 참이고, **중간 등급에서 깨진다.**

의도 기준은 코드 주석 + **이름 기반 dict**(`PLAN_MODEL_ALLOWLIST`, `TIER_MIN_PLAN_NAME`, 프론트 `PLAN_SKILL_MAP`)로 **교차검증**했다.

| dict | Free(0) | Basic(1) | Pro(2) | Max(3) | Hidden(4) |
|---|---|---|---|---|---|
| `PLAN_SKILL_ACCESS` | OK | OK | **어긋남** | **어긋남** | 이 PR 로 해소 |
| `PLAN_LEVEL_MODEL_CAP` | OK | **어긋남** | OK | **어긋남** | OK |
| `PLAN_LEVEL_MODEL_TIER` | OK | OK | **어긋남** | **어긋남** | OK |
| `PLAN_LEVEL_CHANNELS` | OK | **어긋남** | OK | OK | OK |
| `PLAN_LEVEL_CLI_MODEL` (죽은 코드) | OK | OK | **어긋남** | OK | OK |

구체적 피해:

- **Basic(월 19,000원 유료)** 이 무료 채널셋만 받는다 — `tistory-blog / instagram / threads / youtube-reels` 4개 채널 차단. 모델 cap 도 `sonnet` 이어야 하는데 `haiku`.
- **Max(월 79,000원)** 가 `opus` 를 못 쓴다 — cap 이 `premium` 이어야 하는데 `sonnet`. `PLAN_MODEL_ALLOWLIST["맥스"]` 에는 opus 가 있고 `TIER_MIN_PLAN_NAME["premium"] == "맥스"` 인데도 그렇다.
- **Pro** 가 `naver_seo`·`geo` 스킬을 못 받는다(프론트는 준다고 표시). 과금 티어도 `sonnet` 이어야 하는데 `haiku`.

추가로 발견한 **잠복 결함 2건**:

1. `get_user_plan()` 은 `plan_id` 를 반환하지 않는데 `main.py:1812` 가 `plan_info.get("plan_id")` 로 읽는다 → **항상 None** → `plan_ai_models`(1889) · `plan_token_config`(1938) DB 보정 경로가 **둘 다 죽어 있다.** (`plan_ai_models` 에 `feature_key='*'` 행이 0건이라 현재 실피해는 없다.)
2. `main.py:718` 의 `plan_row.get("sort_order") or PLAN_ORDER.get(...)` 는 `sort_order=0`(Free)이 **falsy** 라 폴백을 탄다 → Free 사용자가 `sort_order=1` 로 취급된다. 게다가 Free 폴백 조회가 `.eq("name", "무료")` 인데 **DB 이름은 영문 `Free`** 라 조회가 항상 빈다. 현재는 `[0]`과 `[1]` 값이 같아 증상이 없지만, 둘을 다르게 만드는 순간 터진다.

이 항목들은 **등급별 권한·과금에 직접 영향**하므로 회장 결정 사항이라 판단해 손대지 않았다. 별건으로 올린다.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)
