# 마일스톤 4 리포트 — 벤더 비교 지표 + 발송 임계 산출

**Plan**: `.claude/plans/ops-alert-system-m4.plan.md`
**Notes**: `.claude/notes/ops-alert-system-m4.md` (Implement-Codex 리뷰 · 이월 근거)
**작성**: 2026-08-14

> **이 리포트를 "M4 완료 = 벤더 알림 동작 중"으로 읽으면 안 된다.** 규칙은 기본 OFF 로 배포되고,
> 켜는 것은 산출 수치가 나온 뒤의 **별도 결정**이며 토글 하나가 아니다(§활성화 5단계).

---

## 1. 벤더 4종 정정 — `codef` 는 감시되지 않는다

PRD 155행은 벤더를 5종(CODEF·COOCON·OCTOVER·HYPHEN·inhouse)으로 적었으나 실측은 다르다.

| 항목 | 실측값 (2026-08-14, stg RDS) |
|---|---|
| `tn_treat_provider` 등록 | **4행** — inhouse(사용) · octover(사용) · coocon(사용) · hyphen(`tpr_enabled=0`) |
| `codef` | 이 테이블에 **없다**. `odd_provider` 데이터에만 남아 있고 최신 행이 2026-06-08 인 은퇴 벤더 |
| 현재 감시 대상 | 사용 중인 **3종**(coocon · inhouse · octover) |

감시 대상은 코드가 아니라 `tn_treat_provider` 에서 파생한다(§D2). `codef` 가 다시 켜지면
테이블 한 행으로 감시에 들어온다 — **코드 변경 0**. 은퇴한 벤더를 코드로 되살리는 것보다
테이블을 사실 소스로 두는 편이 UI11 과 일관된다.

다만 **후보와 판정 대상은 다르다.** 자동 편입은 코드 비용이 0이라는 뜻이지 그 벤더를
**측정된 임계로** 판정한다는 뜻이 아니다. 그래서 판정 대상은 서명(`treat-vendor_derivation`)의
`vendors` 가 정하고, 현재 활성 벤더 집합이 그것과 다르면 그 회차는 **평가되지 않는다**(§D10 throw).

---

## 2. 기본값 3개는 산출물이 아니라 승계다

| 설정 키 | 기본값 | 출처 |
|---|---|---|
| `treat-vendor_window_ms` | 3,600,000 (60분) | `treat-monitor.service.ts:171-177` 의 60분 창 |
| `treat-vendor_min_sample` | 5 | 같은 파일 `:531` 의 `>= 5` |
| `treat-vendor_ratio_threshold` | 0.3 | 같은 파일 `:322` 의 `rate >= 70` 을 실패율로 뒤집은 값 |

**셋 다 "실측 산출"(UI4)을 만족하지 않는다.** "승계"라는 라벨은 값의 출처를 설명할 뿐
오탐·미탐 위험을 낮추지 않는다. 승계를 고른 이유는 두 가지다 — 지금 진료 화면이 이미 그
경계로 벤더를 `bad` 로 부르고 있어 알림이 화면과 어긋나지 않고, 근거 없는 새 수치를 넣는 것이
UI21 이 지목한 실패이기 때문이다. 이 사실은 코드 주석(`treat-vendor.rule.ts`)·설정 `label`·이
리포트 세 곳에 적혀 있다.

---

## 3. M6 실측 — 알림 타입 하나를 추가하는 데 든 파일

PRD Success Metrics M6("5번째 알림 타입을 실제로 추가해 변경 파일 수 계측")의 실측 기회가 M4 다.
**계측 대상은 §Files to Change 전체가 아니라 "알림 타입 하나를 추가하는 데 든 파일"** 이다 —
산출 스크립트와 그 spec 은 임계 산출 도구이지 타입 추가 비용이 아니고, 리포트·PRD 는 문서다.

| 파일 | 신규/기존 | 성격 | 변경량 |
|---|---|---|---|
| `apps/api/src/modules/alert/rules/treat-vendor.rule.ts` | 신규 | 타입 본체 | +432 |
| `apps/api/src/modules/alert/rules/treat-vendor.rule.spec.ts` | 신규 | 타입 검증 | +477 |
| `migrations/alert-vendor-rule/01_seed_treat_vendor_disabled.sql` | 신규 | 이 타입이 기본 OFF 여야 해서 필요한 **데이터** | +52 |
| `apps/api/src/modules/alert/alert.module.ts` | 기존 | 등록 | +2 |
| `apps/api/src/modules/alert/alert.repository.ts` | 기존 | 신호원이 새로워서 필요한 **쿼리** | +59 / -2 |
| `apps/api/src/modules/alert/alert.repository.spec.ts` | 기존 | 그 쿼리의 검증 | +67 |

**신규 3 + 기존 3 = 6** — 계획 §D7 의 예측치와 일치한다.

**등록 자체는 2줄이다**(`import` 1줄 + `ALERT_RULES` 배열 1줄). 나머지 기존 파일 2개는 등록
지점 밖의 중앙 파일이며, 그것을 세지 않으면 M6 을 과소계측하는 동시에 **그런 파일을 고쳐야
한다는 사실 자체를 가린다.** UI11 이 재려는 것이 정확히 그것이다.

성격은 다르다 — `alert.repository.ts` 와 그 spec 은 **신호원이 새로워서**, 시드 마이그레이션은
**이 규칙이 기본 OFF 여야 해서** 필요하다. 답을 하나의 숫자로 접으면 어느 쪽이 구조 부채인지
알 수 없다. 쿼리를 규칙 소유 파일로 옮기면 등록 비용은 줄지만 `AlertRepository` 라는 이 모듈의
데이터 접근 관례가 갈라지므로(UI10) 채택하지 않았고, 그 대가가 여기 2건으로 나타난다.

---

## 4. M4 가 닫지 않는 것

마일스톤 2.5 의 `APPROVED_WITH_CONDITIONS` **잔여조건 11건은 M4 로 하나도 닫히지 않는다.**
`security_skipped=true` 인 implement receipt 와 `/mccp:code-review`·`/mccp:pr` 미실행도 M3 에서
열린 채로 남아 있고 M4 가 그것을 닫지 않는다 — 이번 마일스톤의 implement receipt 역시
`security_skipped=true` 다(Agent tool 세션 정책, `.claude/notes/ops-alert-system-m4.md` §Security Reviewer).

**그 11건이 닫히기 전 운영 활성화는 승인된 바 없다.**

---

## 5. 규칙은 기본 OFF 로 배포된다 — 활성화 5단계

`isRuleEnabled()` 는 설정 행이 없으면 **켜짐**을 돌려준다(`alert-settings.service.ts:114-122`).
그래서 `ALERT_RULES` 에 한 줄을 더하는 것만으로 이 규칙은 배포 즉시 실운영 판정에 들어간다 —
그것도 §2 의 승계 기본값을 들고서다. 그것을 막기 위해 마이그레이션이
`alert_rule_enabled:treat-vendor = '0'` 행을 **미리 넣는다**(`migrations/alert-vendor-rule/01_...sql`).

### 배포·롤백 순서 (양방향)

| 방향 | 순서 | 그렇지 않으면 |
|---|---|---|
| 배포 | 시드 적용 → **DB 행을 직접 읽어** `'0'` 확인 → **그 다음에** 규칙을 등록한 빌드 기동 → API 로 `rules['treat-vendor'] === false` 재확인 | 시드 커밋 전에 뜬 인스턴스가 규칙을 켜진 것으로 읽고 승계 임계로 평가한다. 그 순간의 발송은 사후 API 확인으로 관측되지 않는다 |
| 롤백 | 규칙 등록을 뺀 빌드 배포 → **그 다음에야** 행을 정리한다. 기본은 **행을 남겨 두는 것** | 코드가 남은 채 행만 지우면 그 시점부터 켜진다. 되돌리기가 활성화가 된다 |

빌드 **전** 확인은 API 가 아니라 DB 여야 한다 — 그 시점 배포본에는 `treat-vendor` 가 등록돼
있지 않아 키가 응답에 **존재할 수 없고**, 통과가 불가능한 게이트는 우회를 강요한다.

> **롤백 정정(Implement-Codex R1 F5)** — 계획 Task 3b 는 롤백을 "행 `DELETE` 1건"으로 적었으나,
> 마이그레이션이 기존 값을 **보존**하므로 그 행이 먼저 있었다면 마이그레이션은 아무것도
> 소유하지 않는다. 소유하지 않은 값을 되돌리기가 파괴해서는 안 되고, 보존된 `'0'` 을 지우면
> 행 부재 = 켜짐 규약 때문에 **미래의 fail-open** 을 만든다. 그래서 기본은 행을 남기는 것이고,
> 지우려면 이 마이그레이션이 그 행을 새로 넣었음이 확인된 경우뿐이다. 마이그레이션 헤더
> 주석이 이 형태로 적혀 있다.

### 활성화 5단계 — 토글 하나가 아니다

"산출값이 존재한다"는 **조건**이지 **반영**이 아니다. 조건만 두면 명시적으로 미측정이라고
적어 둔 승계 기본값 0.3 을 그대로 둔 채 규칙만 켜는 경로가 열린다.

| # | 단계 | 확인 |
|---|---|---|
| 1 | 산출 스크립트를 스냅샷 벤더로 돌려 리포트를 낸다 | **풀 축과 활성 벤더 전부**가 `OK` 여서 서명이 출력됐다. 하나라도 부족하면 서명이 없고 여기서 멈춘다 |
| 2 | `treat-vendor_ratio_threshold` 를 **풀 축** 산출값으로 갱신 | — |
| 3 | `treat-vendor_overrides` 를 벤더별 산출값으로 갱신 | — |
| 4 | `treat-vendor_derivation` 에 리포트의 **서명**을 그대로 기록 | 2~3 에서 쓴 값과 서명의 `ratio_threshold`·`overrides` 가 같고, `window_ms`·`min_sample` 이 **지금 DB 의 값과도** 같다 |
| 5 | `alert_rule_enabled:treat-vendor` 를 `'1'` 로 — **마지막** | 켜기 직전 DB 를 직접 읽어 2~4 의 값이 리포트와 일치하는지 대조한다 |

**어느 단계에도 재배포는 필요 없다**(UI6). 규칙 ON 이 마지막인 이유는 중간 상태에서 켜져
있으면 그 회차가 미측정 임계로 돌기 때문이고, 5단계의 DB 대조가 없으면 2~3 을 건너뛴 것과
건너뛰지 않은 것이 구별되지 않는다. 4 를 빼먹으면 §D10 이 fail-closed 로 받아 매 회차 throw
하지만(반쪽 활성화가 조용히 성립하지 않는다), **2~3 누락은 그런 안전망이 없다** — 값이 이미
존재하기 때문이다. 5단계 대조가 그 자리를 메운다.

---

## 6. 산출 실행과 서명

### 실행 — 2026-08-14, 연결 DB(stg RDS via `127.0.0.1:13306` SSH 터널)

```bash
cd insuniverse-server
TS_NODE_TRANSPILE_ONLY=1 npx ts-node -r tsconfig-paths/register \
  scripts/alert-threshold-derive.ts --days 90 --vendors inhouse,octover,coocon
```

출력 전문:

```
[derive] 관측 183건 / 원본 183행

## 서명 — **출력하지 않는다**

풀 축이나 스냅샷 벤더 중 하나 이상이 `INSUFFICIENT_SAMPLE` 이다(§D5-4b 부분 미달 규칙).
넣을 값이 없는데 서명을 내면 그것이 곧 미측정 임계의 승인이 된다.

## 산출 조건

| 항목 | 값 | 출처 |
|---|---|---|
| 기간 | 최근 90일 | `--days` |
| 모집단(스냅샷) | coocon · inhouse · octover | `--vendors` |
| 창(window_ms) | 3600000 | **설정 부재 → 규칙 메타 기본값** |
| 표본 하한(min_sample) | 5 | **설정 부재 → 규칙 메타 기본값** |
| 목표 오발화율 | 1% → p99 | `--target-fpr` |
| 적격 버킷 하한 | 100 | `--min-buckets` (기본 max(30, ceil(100/fpr))) |

## 축별 판정

| 축 | 적격 버킷 | p50 | p90 | p95 | p99 | 판정 | 권고 임계 |
|---|---|---|---|---|---|---|---|
| coocon | 0 | — | — | — | — | `INSUFFICIENT_SAMPLE` | **없음** |
| inhouse | 0 | — | — | — | — | `INSUFFICIENT_SAMPLE` | **없음** |
| octover | 0 | — | — | — | — | `INSUFFICIENT_SAMPLE` | **없음** |
| **풀(집계)** | 2 | 0.2000 | 0.2857 | 0.2857 | 0.2857 | `INSUFFICIENT_SAMPLE` | **없음** |

## 진단 통계

| 축 | 저표본 버킷 수 | 그 안의 총 건수 |
|---|---|---|
| coocon | 19 | 25 |
| inhouse | 69 | 109 |
| octover | 18 | 22 |
| **풀(집계)** | 91 | 144 |

표본 하한 미달로 판정하지 못한 벤더: **3종** — §D4 가 의도한 침묵의 크기다.
```

### 읽는 법

**적격 버킷이 벤더 축에서 0개다.** 60분 창으로 90일을 자르면 버킷이 2,160개인데, 세 벤더
어느 쪽도 `total >= 5` 인 버킷을 **하나도** 만들지 못했다 — 가장 물량이 큰 inhouse 조차 109건이
69개 버킷에 흩어져 버킷당 평균 1.6건이다. 풀 축은 합산이라 적격 버킷이 2개 나왔지만 하한
100 에 한참 못 미친다. §실측 근거가 예측한 그대로이고, **이것이 실행 실패가 아니라 이
스크립트의 정상 출력**이다.

p99 = 0.2857 이라는 숫자가 나왔다는 사실이 오히려 이 판정의 존재 이유다 — 버킷 2개에서 뽑은
백분위는 정의되지만 의미가 없다. 충분성 판정이 없으면 저 0.2857 이 "실측 산출"을 자칭하며
설정에 들어간다. 그것이 PRD 가 지목한 ProviQ 실패의 반복이다(UI21).

**스냅샷 필터가 실제로 27건을 걸렀다.** 90일 창의 벤더별 종결 건수는 다음과 같다.

| 벤더 | 성공 | 실패 | 스냅샷 | 처리 |
|---|---|---|---|---|
| inhouse | 57 | 52 | 포함 | 벤더 축 + 풀 합산 |
| coocon | 15 | 10 | 포함 | 벤더 축 + 풀 합산 |
| octover | 13 | 9 | 포함 | 벤더 축 + 풀 합산 |
| hyphen | 16 | 5 | **제외**(`tpr_enabled=0`) | 버킷 집계 단계에서 버림 |
| codef | 5 | 1 | **제외**(테이블 미등재) | 버킷 집계 단계에서 버림 |

스냅샷 안 156건 + 밖 27건 = 183건. **밖 27건이 전체의 14.8%** 다 — 필터가 없었다면 풀 백분위가
런타임이 절대 판정하지 않을 벤더의 물량으로 계산됐다는 뜻이고, Plan-Codex R4 F1 이 지목한
오염이 이 데이터에서 실제로 성립함을 실측이 확인했다.

> **계획 §실측 근거의 건수와 다른 곳이 있다** — GROUND 는 coocon 29 · codef 12 로 적었고 여기는
> 25 · 6 이다. GROUND 는 `odd_provider` 가 찍힌 행을 셌고 이 스크립트는 **종결된 행만**
> 센다(성공 `ODD006` + 완료시각, 실패 상태 + 실패시각). 진행 중이거나 시각이 비어 있는 행은
> 어느 쪽도 아니므로 빠진다 — 분모에 넣으면 실패율이 처리 지연에 희석되기 때문이다.
> inhouse 109 · octover 22 · hyphen 21 은 양쪽이 같다.

### 서명 — **없음**

전 축이 `INSUFFICIENT_SAMPLE` 이므로 권고 적용값이 없고, 따라서 **서명도 출력되지 않았다.**
`treat-vendor_derivation` 은 기본값 `{}` 인 채로 남는다 — 그 상태에서 규칙을 켜면 §D10 이 매
회차 "산출된 것이 없다"로 throw 한다(fail-closed). 활성화를 반쪽으로 끝낼 수 없다는 것이
그 설계의 요점이다.

### 설정 부재가 드러났다

산출 조건 표의 `window_ms`·`min_sample` 출처가 **"설정 부재 → 규칙 메타 기본값"** 이다.
`tn_alert_setting` 에 `treat-vendor_*` 행이 아직 없기 때문이며(규칙이 한 번도 안 돌았으므로
정상), 이 표기 자체가 Implement-Codex R1 F4 흡수의 결과다 — `AlertSettingsService.read` 를
그대로 썼다면 **조회 실패와 부재가 구분되지 않아** 둘 다 조용히 기본값으로 떨어졌을 것이다.

**그래서 PRD Q3 는 열린 채로 둔다.** M4 는 Q3 를 닫지 않고 답을 넣을 자리(설정 키 · 산출
스크립트 · 조건 서명)를 만들었다. Q3 는 prod 데이터가 확보돼 산출 스크립트가
`INSUFFICIENT_SAMPLE` 이 아닌 서명을 낼 때 닫힌다.

---

## 7. 검증 결과

| 항목 | 명령 | 결과 |
|---|---|---|
| 타입 | `npx tsc --noEmit -p tsconfig.json` | 신규·변경 파일 **0건**. 잔여 오류는 전부 손대지 않은 기존 spec(`medication-standard` · `libs/hyphen` · `libs/octover`) |
| 린트 | `ESLINT_USE_FLAT_CONFIG=false npx eslint apps/api/src/modules/alert scripts/alert-threshold-derive.ts` | **0건** |
| 단위 | `npx jest apps/api/src/modules/alert scripts/alert-threshold-derive.spec.ts` | **17 suites / 336 tests 전부 통과** (M4 가 더한 80건 — 아래 내역) |
| 빌드 | `npm run build` | 통과 |
| 실 DB 1회 완주 | 위 §6 | **통과 — 183건 읽고 전 축 `INSUFFICIENT_SAMPLE`, 서명 미출력** |
| 시드 마이그레이션 | 실 DB 적용 (아래) | **통과** |
| 등록 빌드 기동 후 config 재확인 | `GET /admin/alert/config` | **통과** — 아래 §7-b (2026-08-18) |
| 임시 ON → poll → 복원 | 계획 §Validation 인라인 스크립트 | **통과** — 아래 §7-b (2026-08-18) |

### 시드 마이그레이션 실 DB 검증 (2026-08-14, `insuniverse-dev` @ 13306)

| Gate | 결과 |
|---|---|
| 적용 전 행 상태 | **행 없음** — 시드 없이 배포하면 `isRuleEnabled` 규약상 **켜진 채로** 뜬다 |
| 1회 적용 후 | `als_value='0'` |
| **2회 적용 후** | `als_value='0'` — 결과 동일(idempotent) |
| **값이 이미 `'1'` 인 DB 에 적용** | `'1'` 그대로 **보존**. 관리자 의도를 덮지 않는다 |
| 그 상태에서 사전 확인 게이트 | **배포 중단** — 값이 `'0'` 이 아니므로 §D9 2단계가 세운다 |
| 검증 후 상태 | `'0'` 으로 복원 |

`-dev` 접미 DB 가드를 통과한 뒤에만 썼다(M2.5 하네스와 같은 규약). 남아 있던 **규칙 등록
빌드 기동 후** 확인은 아래 §7-b 에서 닫혔다(§D9 3~4단계).

### 7-b. 등록 빌드 기동 후 런타임 1회 완주 (2026-08-18, `insuniverse-dev` @ 13306)

`§Acceptance` 2·3번이 요구한 두 증거다. 단위 test 가 원리적으로 덮지 못하는 축 — 2번은
코드가 아니라 **데이터**가 만들어야 하는 상태이고, 3번은 등록 1건이 **평가 경로까지
도달**했는지다.

환경: 서버 커밋 `63d6828e`(`feat/monitoring-alert`) 를 `npm run build` 한 `dist` 로 기동,
API `localhost:7200`(IPv6 전용 바인딩), 인증은 쿠키 `_aaT`, DB 는 SSH 터널 `13306`.
절차는 계획 §Validation 의 인라인 스크립트를 그대로 썼다 — `trap restore EXIT INT TERM`
으로 복원을 걸고 복원을 **재조회로 확인**한다.

| Gate | 기대 | 실측 |
|---|---|---|
| 기동 후 `GET /admin/alert/config` 의 `rules['treat-vendor']` | `false` | **`false`** — 시드가 배포에 걸렸다(§D9 4단계) |
| 같은 응답의 규칙 카탈로그 | `treat-vendor` 존재 | `treat-monitor` · `subscription-payment` · `schedule-heartbeat` · `api-error-rate` · `infra-health` · **`treat-vendor`** — 등록 1줄이 카탈로그에 나타난다 |
| 임시 ON 후 poll 1회의 서버 로그 | 규칙별 격리 | `규칙 평가 실패(treat-vendor) — 나머지 규칙은 계속: Error: 산출된 것이 없다 …` |
| `GET /admin/alert/monitor` 의 `treat-vendor.error` | "산출된 것이 없다" 포함 | **포함** — 전문이 `treat-vendor_derivation 가 비어 있어 이 회차를 판정하지 않는다(§D10 fail-closed)` 까지 |
| 같은 항목의 `health` | `error` | **`error`** |
| 같은 항목의 `checkedAt` | 전진하지 **않음** | **`null`** — throw 회차라 정상이다(§Validation). 여기서 `checkedAt` 을 근거로 쓰면 조용한 미탐이 된다 |
| 복원 | `false` 로 되돌아옴 | **`false`** — 재조회로 확인 |

**`treat-vendor` 는 이 회차에 한 건도 발송하지 않았다.** poll 이 실제로 내보낸 2건은 전부
`digest` 이고 내용은 `api-error-rate` 의 무트래픽 경고였다(`bySeverity: {digest: 2}`,
`criticalDestinations: 0`). 즉 관측된 발송은 M4 와 무관한 기존 규칙의 것이고, M4 규칙은
§D10 이 의도한 대로 **판정하지 않고 멈췄다**.

이것으로 §D10 fail-closed 가 문서상 설계가 아니라 **실제로 작동하는 경로**임이 확인됐다.
동시에 규칙별 격리(`AlertService` 가 한 규칙의 throw 를 나머지로 전파하지 않음)도 같은
로그 한 줄이 증거다.

여전히 **닫히지 않은 것**: 이 확인은 `treat-vendor` 가 판정을 **거부하는** 경로만 증명한다.
실제 벤더 판정(§D4 의 풀·지배·개별 3축)이 실데이터에서 어떻게 나오는지는 서명이 있어야
하고, 그 시점은 §D9 활성화 5단계 이후다.

M4 가 더한 테스트 **80건**의 내역(2026-08-18 실측 — 파일별로 jest 를 따로 돌려 세었다):

| 파일 | 건수 | 덮는 것 |
|---|---|---|
| `rules/treat-vendor.rule.spec.ts` | 46 | §D4 판정 픽스처(분자·분모·임계 각각 단언, 설명자 0·1·2종) · §D10 서명 대조(전부 throw) · §D3 오버라이드 경계 |
| `scripts/alert-threshold-derive.spec.ts` | 31 | 백분위·`ceil2`·클램프 경계 · 저표본 격리 · 골든 픽스처(변환 규칙 실수치 고정) · 스냅샷 필터 불변성 · 부분 미달 서명 억제 · 출력 형태 · 설정 범위 검사 |
| `alert.repository.spec.ts` (추가분) | 3 | 벤더 0종·1종·N종 반환 형태 정규화 |

이전 판이 적은 `신규 65건 + code-review 수정분 4건` 은 **어느 합도 총계와 맞지 않았다** —
내역표는 41+21+3 = 65 로 +4 가 어디에도 없었고, 그 65 도 당시 실측과 달랐다.
위 숫자는 세는 방법을 적어 다음 회차가 같은 값을 재현할 수 있게 한다(총 336 − 기존 256).

---

## 8. 계획과 다르게 한 것

| # | 계획 | 실제 | 왜 |
|---|---|---|---|
| 1 | Task 3b 롤백 = 행 `DELETE` 1건 | 롤백 기본은 **행 보존**, 삭제는 마이그레이션이 그 행을 새로 넣었음이 확인된 경우로 한정 | Implement-Codex R1 F5. 마이그레이션이 기존 값을 보존하므로 소유하지 않은 관리자 값을 되돌리기가 파괴할 수 있고, 보존된 `'0'` 삭제는 미래의 fail-open 이다 |
| 2 | Task 4 가 창·임계를 `AlertSettingsService` 에서 읽는다 | `AlertRepository.getSettingsByPrefix('treat-vendor_')` **1회**로 읽고, 조회 실패는 throw, 부재는 기본값이되 **무엇이 채워졌는지 리포트에 적는다** | Implement-Codex R1 F4. `AlertSettingsService.read` 는 조회 실패를 삼키고 메타 기본값을 돌려주는 런타임 fail-soft 라, 일시 실패가 **기본값으로 만든 서명**을 정상 출력처럼 낸다 |
| 3 | Task 4 Gate — "벤더가 전부 `OK` 여도 풀이 `INSUFFICIENT_SAMPLE` 일 수 있다" | 그 픽스처를 만들지 않고, **불가능함을 증명하는 픽스처**로 대체 | 산술적으로 불가능하다: 풀 버킷의 `total` 은 스냅샷 벤더 `total` 의 합이라 어떤 벤더가 적격인 버킷은 풀에서도 반드시 적격이다. 실재하는 부분 미달은 **반대 방향**(풀 OK · 저볼륨 벤더 미달)이며 그쪽을 spec 이 덮는다 |
| 4 | 규칙이 `AlertRepository` 신규 쿼리 1개만 쓴다 | 활성 벤더 목록은 기존 `TreatProviderService.list()` 로 읽는다 | 신규 쿼리를 하나 더 만들면 M6 계측이 늘고 관례가 갈린다. `TreatMonitorRule` 이 같은 서비스를 같은 방식으로 쓴다 |
| 5 | — | `ceil2` 를 `Math.ceil(x*100)/100` 이 아니라 스케일 후 유효자리 절단으로 구현 | `0.1 * 100 === 10.000000000000002` 라 그대로 올리면 0.11 이 나오고, 그 오차가 서명에 박혀 §D10 대조까지 흘러간다 |

---

## 9. 이월

Implement-Codex R1 이 낸 6건 중 **4건 이월**. 그중 3건(F0·F1·F2)은 Plan-Codex 봉인 라운드
F0~F2 와 **동일 건**이며 사용자 결정("R8 을 마지막으로 HIGH 이상만 반영")으로 이미 이월돼
`.claude/plans/codex-findings-backlog.md` 에 올라 있다.

| Finding | 요지 | 상태 |
|---|---|---|
| F0 | warn 다이제스트가 `channelHint` 를 버려 벤더 전용 채널 라우팅이 끊긴다 | 기존 이월(봉인 F2). 전 규칙 공용 경로 변경이라 M4 범위 밖 |
| F1 | `--target-fpr` 변환이 동률 분포에서 목표 발화율을 지키지 못한다 | 기존 이월(봉인 F0) |
| F2 | 고정 버킷이 런타임 슬라이딩 창을 재현하지 않고 서명에 폴링 주기가 없다 | 기존 이월(봉인 F1) |
| F3 | 판정 불가 잔여를 지배 벤더의 증거로 쓴다 | **신규 이월.** §D4 가 논거를 적어 반대로 결정한 자리라 뒤집으려면 plan 개정이 선행 조건이다 |

**F0 의 실질적 영향을 숨기지 않는다** — 계획 §Risks 의 "특정 벤더를 즉시 받고 싶으면 그 벤더
전용 채널을 `channelHint` 로 등록한다"는 완화책은 `vendor:<key>` warn 에 대해 **지금은
성립하지 않는다.** warn 은 전부 다이제스트 큐를 타는데 큐 항목이 `channelHint` 를 싣지 않기
때문이다. 규칙은 `channelHint` 를 그대로 채워 두므로 공용 경로가 고쳐지면 규칙 변경 없이
살아난다. 즉시 라우팅이 필요한 축은 `vendor-dominant:<key>`(critical)이며 그쪽은 정상 동작한다.

---

## 10. 남은 것

- [x] 산출 스크립트 1회 실행 (2026-08-14, §6) — 전 축 `INSUFFICIENT_SAMPLE`, 서명 없음
- [x] 마이그레이션 적용 + DB 직접 조회로 `'0'` 확인 (§7, 2026-08-14). 적용 전 행이 **없었다** —
      시드 없이 이 빌드를 올렸다면 규칙이 승계 임계로 켜진 채 떴다는 뜻이다
- [x] 규칙 등록 빌드 기동 후 `GET /admin/alert/config` 의 `rules['treat-vendor'] === false`
      (§7-b, 2026-08-18) — §D9 4단계
- [x] 임시 ON → `alert-inject poll 1 --sim-heartbeat` → 복원, `GET /admin/alert/monitor` 의
      `treat-vendor` `error` 가 "산출된 것이 없다"를 담는지 확인 (§7-b, 2026-08-18) —
      §D10 fail-closed 실동작 증거. `health='error'` · `checkedAt=null` · 복원 확인까지
- [x] 로컬 코드 리뷰 2회차 반영 (2026-08-18, `.claude/notes/ops-alert-system-m4.md`
      §Local Code Review 2회차) — CRITICAL·HIGH 0, MEDIUM 2 · LOW 2 전건 반영
- [ ] `/mccp:code-review`(PR 모드) · `/mccp:pr` — M3 에서 열린 채 남은 게이트.
      위 2회차는 Local Review Mode 라 `code-reviewer` receipt 를 쓰지 않는다

**§Acceptance 의 산출물 3건은 이것으로 전부 확보됐다.** 남은 것은 리뷰·PR 게이트뿐이다.
단, implement receipt 는 `security_skipped=true` · `codex_verdict='divergent'` 라
`/mccp:pr` 에 대해 non-approving 이고, 그 슬러그(`ops-alert-system-m4`)는 git 추적 중이라
같은 이름으로 재발급되지 않는다(`store.js` `assertNoTrackedOverwrite`). 재앵커가 필요하면
**새 슬러그**를 써야 한다.
