# task-3078 — 엔드포인트 결선 봉인 + 인포키워드 글쓰기 스킬 제거 + 플랜 드리프트 알림

- 레벨 Lv.2 · repo InsuRo · 담당 dev1
- 지시서 `/home/jay/workspace/memory/tasks/task-3078.md` (sha256 앞16 `1c64600bcd39c4ab` — **확인 일치**)
- base `4c713f1` (t3077 머지·배포 완료) · head `d52f5e626be32fb67d0c8af9de44ef1866f6e38d`
- **PR #290 OPEN · 머지·배포 미실행** (지시서 종결 조건)
- worktree `/home/jay/projects/InsuRo/.worktrees/task-3078-dev1`

---

## 작업 A — ★★ 엔드포인트 결선 봉인 (최우선)

### A-1. 근본 원인

테스트가 두 가지를 구분하지 않는다.

- **(a) 상수가 존재하는가** ← 지금까지 검사해 온 것
- **(b) 그 상수가 실제 요청 처리에 실리는가** ← 아무도 검사하지 않던 것

(a) 만 보는 테스트는 상수를 그대로 둔 채 **호출부에서 인자 한 개만 지워도** 전부 통과한다.
t2970·t3014·t3075·t3077 사고는 전부 (b) 에서 났다.

### A-2. 재사용 헬퍼 — `server/tests/endpoint_prompt_harness.py`

`POST /api/insuro/generate-content` 를 **실제로 왕복**시키고, `build_content_prompt` 가
**실제로 반환한 프롬프트 문자열**을 돌려준다.

```python
cap = capture_generation(client, tier=HIDDEN_TIER, body={...})
assert main._ORGANIC_PROSE_FINAL_REMINDER in cap.prompt
```

설계 요점:

- **스텁이 아니라 스파이다.** 진짜 `build_content_prompt` 를 그대로 부르고 인자·반환값만
  기록한다. 가짜를 돌려주면 하네스 자신이 (a) 로 퇴화한다 — 주석에 못박았다.
- `cap.prompt` 는 호출이 없으면 **즉시 실패**시킨다. 빈 문자열을 돌려주면
  `assert X not in cap.prompt` 류가 **공허하게 통과**한다.
- `queue=` / `supabase=` 주입구가 있다. 이게 없으면 헬퍼 내부 패치가 바깥 패치를
  이겨서 큐 **이후** 단계(실제 생성 모델·토큰 과금 기록)를 영영 관측할 수 없다.
  (실제로 이 문제를 만나 헬퍼를 고쳤다.)
- `assert_source_contains()` — 변이 주입 전 no-op 선증명용.

### A-3. 봉인 테스트 — `server/tests/test_task3078_endpoint_wiring_seal.py` (19건)

봉인 대상 결선 3줄(전부 소스에 **정확히 1회** 존재함을 선증명):

| ID | `server/main.py` |
|---|---|
| W1 | `seo_mode = normalize_seo_mode(req.seoMode)` |
| W2 | `benchmark_prompt = build_benchmark_prompt(req.benchmark, seo_mode)` |
| W3 | `seo_mode=seo_mode,` (`build_content_prompt` 호출 인자) |

### A-4. ★ 무방비 지도 — 실측 (추측 아님)

서버 스위트 **3,662건 전체**를 오라클로 삼아 결선 13곳을 하나씩 끊었다.
(하네스 `/home/jay/workspace/memory/scratch/wiring_audit_3078.py`,
 결과 `wiring_audit_3078_result.json`)

| 결선 | 끊으면 죽는 기능 | 감사 전 | 실패수 | 지금 |
|---|---|---|---|---|
| `seo_mode=seo_mode` → `build_content_prompt` | SEO 노하우 모드 전체 (t3077 A4) | **SURVIVED** | 0 | KILL |
| `build_benchmark_prompt(.., seo_mode)` | 벤치마킹 키워드 횟수 규칙 (t3077 A5) | **SURVIVED** | 0 | KILL |
| `skills=filtered_skills` | **플랜 스킬 필터 우회 (조용한 권한 우회)** | **SURVIVED** | 0 | KILL |
| `model_cap = plan_tier.model_cap` | **모델 상한 우회** | **SURVIVED** | 0 | KILL |
| `cli_model = resolved_model` | **과금·집계 모델 오기록** | **SURVIVED** | 0 | KILL |
| `benchmark_prompt=` → 조립 | 환각금지·분량·이미지 규칙 (t3072) | KILL | 7 | KILL |
| `benchmark=` 객체 → 조립 | 이미지 구획 지시 (t3070 B-1/t3074) | KILL | 2 | KILL |
| `compliance_prompt=` → 조립 | 금소법 프롬프트 | KILL | 25 | KILL |
| `channel_prompt=` → 조립 | 채널 작성 규칙(③) | KILL | 25 | KILL |
| `profile_prompt=` → 조립 | 설계사 프로필·등록번호 하단문구 | KILL | 25 | KILL |
| `personal_reg_prompt=` → 조립 | 개인 추가 규정 | KILL | 25 | KILL |
| `allowed_skills = plan_tier.skills` | 플랜별 허용 스킬 원천 | KILL | 7 | KILL |
| `allowed_channels = plan_tier.channels` | 채널 게이트 | KILL | 1 | KILL |

**무방비 3곳을 새로 찾았다.** 셋 다 t3014 유형이며, **프로덕션 코드를 고치지 않고**
같은 하네스로 함께 봉인했다(테스트만 추가).

- **`skills=filtered_skills`** — `requested_skills` 로 바꾸면 `skill_notices` 는 여전히
  "빠졌습니다"라고 말하면서 **실제로는 그 스킬이 프롬프트에 실린다.** 사용자에게 거절을
  알리고 뒤로는 열어주는 상태였다. `requested_skills` 는 필터 **이전** 값이며
  `main.py:2378` 에 실재한다(크래시가 아니라 진짜 우회다).
  ★ t3076 이 이 자리를 이미 테스트했지만 오라클이 **응답(`skill_notices`)** 이었다 —
  그래서 못 잡았다. 오라클을 조립된 프롬프트로 옮겨야 잡힌다.
- **`cli_model`** — ★ 정확히 말하면 생성 모델이 아니다. `token_usage_log.model_tier` /
  `model_used` 와 분석 이벤트에 실린다. 하드코딩하면 **opus 로 생성하고 haiku 로
  과금·집계**된다. (생성 모델은 별도 변수 `generation_cli_model` 이라 둘 다 단언했다.)

### A-5. 변이 검증 — **9/9 KILL**

| 변이 | OLD(t3077 스위트) | NEW(t3078 봉인) |
|---|---|---|
| A4 `seo_mode` 인자 제거 | **SURVIVED** | KILL |
| A5 `build_benchmark_prompt` 인자 제거 | **SURVIVED** | KILL |
| W1 요청값→상수 하드코딩 | **SURVIVED** | KILL |
| W1b 요청값→기본값 고정 | **SURVIVED** | KILL |
| S1 스킬 필터 우회 | **SURVIVED** | KILL |
| S2 모델 상한 우회 | **SURVIVED** | KILL |
| S3 과금 모델 오기록 | **SURVIVED** | KILL |
| W4 채널 프롬프트 변환 무력화 | KILL | KILL |
| W5 최종 지시(⑦) 무력화 | KILL | KILL |

방법론: 대조군 선행 GREEN · 주입 전 `원문 in 소스` + `count==1` 로 **no-op 선증명** ·
파이프 없이 `returncode` **직접** 판정 · **git worktree 제자리** 실행(/tmp 복사 아님) ·
매 변이 후 원본 복원 + 종료 시 복원 확인.
하네스: `/home/jay/workspace/memory/scratch/mutation_harness_3078.py`

---

## 작업 B — 인포키워드 글쓰기 스킬 제거

`src/config/planSkillMap.ts` 에서 `infokeyword_analyze` 를 뺐다.

- `PLAN_SKILL_MAP.Max` · `PLAN_SKILL_MAP.Hidden` (지시서 명시)
- `SKILLS` 체크박스 목록 — **범위 판단 1건** (아래)

### ★ 범위 판단 (ANU 확인 요청)

지시서는 `PLAN_SKILL_MAP` 의 Max·Hidden 배열을 명시했다. 그런데 `Generate.tsx:785` 는
`SKILLS` **전체**를 렌더하고 권한이 없으면 자물쇠 배지를 붙인다. `PLAN_SKILL_MAP` 에서만
빼면 **어떤 플랜도 풀 수 없는 영구 잠금 체크박스**가 되어 클릭할 때마다 업그레이드
안내가 뜬다 — 지시서의 목표("글쓰기 스킬 체크박스 목록에서 뺀다")와 반대 결과다.
그래서 `SKILLS` 에서도 함께 뺐고, 코드에 이유를 주석으로 남겼다.

### ★★ 프로그램 무영향 — 실측

| 대상 | 상태 |
|---|---|
| `/infokeyword` 화면 (`src/pages/InfoKeyword.tsx`) | 생존 |
| `/admin/infokeyword` (`src/pages/AdminInfoKeyword.tsx`) | 생존 |
| `routes.ts` 인포키워드 라우트 | 생존 |
| 프록시 API (`server/main.py` 참조 **60건**) | 생존 |
| `PLAN_FEATURE_MAP["infokeyword_analyze"] == "맥스"` | 생존 |
| `planFeatureMap.ts` `infoKeywordAnalyze` 기능게이트 | 생존 |
| worker · 마이그레이션 · 기존 테스트 | 생존 |

봉인 테스트 2건을 추가해 이 무영향을 **고정**했다
(`test_infokeyword_is_not_a_writing_skill_anywhere` / `test_infokeyword_feature_gate_survives`).
후자는 "작업 B 를 프로그램 삭제로 오해하면 깨지는" 테스트다.

### `KNOWN_SKILL_DRIFT` 정리

t3076 의 유일 항목이 해소돼 **빈 집합**이 됐다. 해소 방향은 "서버에 추가"(=기능 개방)가
아니라 **프론트에서 제거**다. 여기에 다시 항목이 생기면 그것은 **새 드리프트**이며
회장 판단 대상이라고 주석에 명시했다.

---

## 작업 C — 플랜 카탈로그 드리프트 알림

> 회장 확정(2026-08-31): "어긋남이 감지되면 관리자모드에 남기고 아누 너가 체크해서
> 나한테 보고하는 형태"

### 구성

| 구성요소 | 파일 |
|---|---|
| 저널 모듈 | `server/plan_drift_journal.py` (신규) |
| 부팅 결선 | `server/main.py` `_verify_plan_catalog_on_startup()` |
| **ANU 조회 API** | `GET /api/insuro/admin/plan-catalog-drift` (`verify_admin`) |
| 관리자 화면 | `src/components/admin/PlanCatalogDriftBanner.tsx` → 구독 플랜 관리 화면 |
| DDL (**미적용**) | `supabase/migrations/20260831T000001_task3078_plan_catalog_drift_events.sql` |
| 롤백 | `supabase/migrations/20260831T000000_..._ROLLBACK.sql` |

### ★★ ANU 조회 경로

```
GET /api/insuro/admin/plan-catalog-drift          ← 관리자 JWT 필요
```

응답: `{open, events[], memory_events[], table, persisted_available}`

DDL 적용 후에는 SQL 로도 조회 가능하다:

```sql
SELECT detected_at, severity, fingerprint, detail
  FROM public.plan_catalog_drift_events
 WHERE severity = 'error' AND acknowledged_at IS NULL
 ORDER BY detected_at DESC LIMIT 20;
```

### 알림이 장애를 만들지 않는다

- `plan_drift_journal` 의 **모든 공개 함수가 예외를 밖으로 내보내지 않는다.**
- **이중 보관** — DB(영속) + 프로세스 메모리(링버퍼). DDL 미적용 상태(=지금)에서도
  관리자 API 가 드리프트를 보여주고, `persisted:false` 로 "DB 미기록"을 함께 알린다.
- 배너는 조회 실패 시 **아무것도 그리지 않는다.**
- DB 조회 실패는 드리프트로 **오인하지 않는다**(t3076 동작 유지, 테스트로 고정).
- `INSURO_STRICT_PLAN_CATALOG=1` 하드 실패는 **그대로** 두되, 죽기 **전에** 먼저 저널에 남긴다.

테스트 `server/tests/test_task3078_plan_drift_journal.py` 9건 — 오라클은 "함수 존재"가
아니라 **부팅 경로를 실제로 통과시킨 뒤 관리자 엔드포인트가 돌려주는 내용**이다.
대조군(`test_drifting_fixture_actually_drifts`)을 먼저 둬서 픽스처가 실제로 어긋남을
만드는지 선증명한다.

---

## 측정

| 항목 | base `4c713f1` | head `d52f5e6` | 델타 |
|---|---|---|---|
| 서버 pytest | 3646 passed / 8 skipped / **0 failed** | 3676 / 8 / **0** | **+30** = 신규 30건 |
| vitest | 140파일 1964건 / 0 failed | 141파일 1970건 / 0 | **+6** = 신규 배너 테스트 |
| `tsc --noEmit` | 0 | 0 | 0 |

신규 30건 내역: 봉인 19 + 드리프트 저널 9 + t3076 인포키워드 봉인 2. **회귀 0건.**
회귀 기준선은 동일 worktree 에서 측정했다(메인 repo 는 `.env` 가 있어 skip 집합이
달라진다 — t3077 교훈).

---

## 하지 않은 것 (지시서 준수)

- 인포키워드 프로그램·화면·API·기능게이트 삭제 **안 함**
- DB `subscription_plans` **무수정**
- SEO 모드 로직(t3077)·플랜 게이트 상수(t3076) **무수정**
- 프로덕션 DDL **미적용** · **머지·배포 미실행**

---

## ANU 판단 요청

1. **머지·배포 실행** (지시대로 하지 않았다)
2. **드리프트 테이블 DDL 적용** — 적용 전까지 드리프트 이력은 **재기동 시 사라진다**
   (메모리 링버퍼만 살아 있다). 적용 후 `pg_policies` 에 anon 이 없는지 확인할 것.
3. **작업 B 의 `SKILLS` 목록 동반 제거** 승인 (위 "범위 판단 1건")
4. **무방비 3곳은 테스트만 추가해 봉인했다** — 프로덕션 코드는 고치지 않았다.
   실제 결함이 아니라 **봉인 부재**였기 때문이다. 추가 조치가 필요한지 판단 요청.
5. 배포 시 **동작 변화 없음** — 이번 PR 은 프로덕션 로직을 바꾸지 않는다.
   유일한 런타임 추가는 부팅 시 드리프트 저널 기록(실패해도 무시)과 관리자 전용 조회 API 다.

---

## ANU 독립 재현 지침

- 변이 재현: `python3 /home/jay/workspace/memory/scratch/mutation_harness_3078.py`
  (worktree `server/` 에 두고 실행. 대조군이 GREEN 이 아니면 스스로 중단한다)
- 무방비 감사 재현: `wiring_audit_3078.py` — 1회 약 **50분**(전체 스위트 × 13변이)
- ★ 변이 주입 전 `원문 in 소스` + `count==1` 을 반드시 확인할 것(거짓 SURVIVED 방지)
- ★ 회귀 대조는 **반드시 동일 worktree 환경**에서 할 것
- ★ 감사 중에는 **테스트 파일을 추가하지 말 것** — 오라클이 중간에 바뀐다
  (이번에 실제로 이 함정을 피하려고 스테이징을 썼다)
- 봉인이 살아 있는지 빠른 확인:
  `cd server && python3 -m pytest tests/test_task3078_endpoint_wiring_seal.py -q` (19 passed)

---

## 도구 이슈 (사고 아님)

- 브랜치 원격 반영 명령이 v3.6 하네스에 차단돼 `worktree_manager.py finish --action pr`
  로 우회했다. 내부 반영은 **성공**했고(원격 `d52f5e6` = 로컬 sha 일치, `gh api git/refs`
  + `git ls-remote` 이중 확인), `gh pr create` 단계만 실패했다 — taskctl 이 workspace repo
  에 묶여 있어 외부 repo 에서 못 연다(t3077 과 동일).
  **PR 은 `gh api pulls POST` 로 열었다.**
- 그래서 `taskctl` 상태는 `COMMITTED` 에서 멈춰 있다(그 sha 는 workspace repo 것이라
  InsuRo PR 과 무관하다). PR 실재 판정은 **PR #290** 으로 할 것.
