# 운영 알림 admin 화면 — 사용법 · 발화 조건 · 미검증 축

**작성** 2026-08-21 · **브랜치** `feat/monitoring-alert`
**근거** 코드 통독 (`insuniverse-server/apps/api/src/modules/alert/**`, `insuniverse-admin/src/pages/Admin/Site/Alert/**`)
**선행 문서** `ops-alert-trigger-conditions.md` (발화 조건 상세) · `ops-alert-system-m4-report.md` (벤더 규칙) ·
`alert-console-redesign-report.md` / `-r2-` / `-r3-` (화면)

---

## 1. 진입과 권한

| 항목 | 값 |
|---|---|
| 경로 | `/site/alert` (설정) · `/site/alert/monitor` (자기감시) · `/site/alert/test` (테스트 발송) |
| 진입점 | 상단 내비 우측 **위성 접시 아이콘**(`fa-satellite-dish`), 알림 종 오른쪽 · 로그아웃 왼쪽 |
| 사이드바 | **없다.** `AdminLayout` 의 `HIDDEN_INTERNAL_PATHS` 에 `/site/alert` 가 들어 있다 |
| 권한 | `role === 'admin'` **이면서** `ALERT_ADMIN_IDS`(CSV env)에 `admId` 가 등재 **이면서** admin-view 경유 토큰이 아닐 것 |
| 판정 API | `GET /admin/alert/permission` → `200 {canManage: boolean}` (403 아님) |

- 허용목록이 비어 있으면 **admin 전원 거부**(fail-closed). `alert-admin.guard.ts` `canManageAlert`.
- 권한 조회가 실패하면 아이콘을 **그리지 않는다**(fail-closed). 즉 아이콘이 안 보이는 것은
  "권한 없음"과 "서버 응답 없음" 둘 다일 수 있다 — URL 직접 진입 시 `권한 없음` 화면이 뜬다.
- `ALERT_ADMIN_IDS` 를 바꾸면 **API 재기동**이 필요하다(`process.env` 직접 조회).

---

## 2. 화면 3종 — 각자 답하는 질문

| 탭 | 답하는 질문 | 되돌릴 수 없는 동작 |
|---|---|---|
| **설정** | 무엇을, 어떤 임계로, 어디로 보내는가 | 채널 삭제 · 알림 전체 OFF (둘 다 확인창) |
| **자기감시** | 알림이 안 오는 게 정상이라서인가, 시스템이 죽어서인가 | 없음 (스누즈는 24시간 상한) |
| **테스트 발송** | 이 종류의 경고가 실제로 어디로 가고, 그 목적지가 살아 있는가 | **실제 Google Chat 메시지가 나간다** |

탭이 화면 전환의 **유일한** 수단이다. 로딩·권한없음·응답없음 분기에도 탭은 항상 렌더된다.

---

## 3. 설정 탭

### 3-1. 상태 보드 / 배너

- 상단 배너 2종:
  - **발송 중지 중** — 활성 채널 0개 이거나 `alert_enabled=false`. 이 상태에서는 한 건도 안 나간다.
  - **발송 열화** — 직전 회차 발송 실패 > 0, 검증 탈락 목적지 > 0, 또는 실행 리스 값 손상.
- 보드 3칸: 활성 채널 수 · 마지막 실행 · 마지막 발송.

### 3-2. 채널

| 열 | 의미 |
|---|---|
| URL (마스킹) | 서버가 마스킹한 값. **원문은 조회 응답에 절대 안 나온다** |
| 대상 종류 | `alc_types`. **전부 체크 = 빈 배열 = "전체"** 로 저장된다 |
| 사용 | `alc_enabled`. 끄면 그 채널은 목적지에서 빠진다 |

- 대상 종류에서 **마지막 1개는 해제 불가**다 — 빈 배열이 "없음"이 아니라 "전체"로 읽히기 때문.
  채널을 끄려면 `사용` 체크를 해제한다.
- URL 검증 5조건(서버): **https · 자격증명 금지 · 443 포트 · 허용 접미사 · IP 리터럴 금지**.
  로컬 HTTP 싱크는 원천적으로 등록 불가.
- 채널 삭제는 확인창 + 위험색. 되돌릴 수 없다.

### 3-3. 공용 설정 (전 알림에 걸림)

값은 전부 **저장 단위 ↔ 표시 단위** 변환을 탄다 — `ms` 는 **초**로, `ratio` 는 **%** 로 보인다.
입력에서 벗어나면(blur) 즉시 저장되고 **다음 폴링부터 반영**된다. 재배포 불필요.

| 라벨 | 키 | 기본값 | 허용 범위 | 효과 |
|---|---|---|---|---|
| 알림 전체 사용 | `alert_enabled` | true | — | 끄면 **평가 자체를 안 한다**. "웹훅만 막고 평가는 돌리기"는 불가능 |
| 중복 억제창 | `alert_dedupe_ms` | 30분 | 1분~24시간 | 같은 (규칙·목적지·finding)을 이 시간 안에 재발송 안 함 |
| 폴링 주기 | `alert_poll_interval_ms` | 5분 | 1분~60분 | **아래 §8 참조 — 실제 평가 주기를 바꾸지 못한다** |
| 지속성 임계 | `alert_persistence_streak` | 1 | 1~10 | 연속 N회 잡혀야 발송. 1 = 억제 없음 |
| 실행 리스 수명 | `alert_lease_ttl_ms` | 15분 | 1분~60분 | 다중 인스턴스 상호배제 자물쇠 수명 |
| 주의 등급 요약 간격 | `alert_digest_interval_ms` | 24시간 | 1시간~7일 | 🟠 warn 을 묶어 보내는 간격 |
| DB 장애 대비 스냅샷 수명 | `alert_snapshot_ttl_ms` | 24시간 | 1시간~7일 | 만료 후 폴백 구간은 **무발송** |
| 스케줄러 기대 주기 | `alert_schedule_expect` | 15종 JSON | — | KEY/VALUE 편집기. 열린 집합(직접 입력 가능) |
| DB 장애 판정 지속성 임계 | `alert_fallback_streak` | 2 | 1~10 | DB 프로브 순단 1회를 장애로 부르지 않기 위한 전용 노브 |
| 생존 판정 유예 배수 | `alert_stale_grace` | 2.0 | 1~10 | 죽음 판정 임계 = 기대주기 × 이 값 |
| 발송 이력 보존일 | `alert_history_retention_days` | 90 | 7~3650 | 사후 조사 가능 기간 |

### 3-4. 알림 종류별 설정

소유 규칙으로 묶여 나온다. 그 종류를 꺼 두면 이 값들은 쓰이지 않는다.

| 규칙 | 설정 | 기본값 | 범위 |
|---|---|---|---|
| API 5xx 급증 | 판정 창 / 표본 하한 / 비율 임계 | 5분 / 100건 / 5% | 1~60분 / 1~100000 / 0.1~100% |
| 정기결제 실패 급증 | 판정 창 / 표본 하한 / 실패율 임계 | 120분 / 10건 / 20% | 5분~24시간 / 1~10000 / 1~100% |
| 스케줄러 하트비트 | 유예 배수 | 2.0 | 1~10 |
| 인프라 헬스 | 프로브 타임아웃 | 3초 | 0.5~30초 |
| 진료 모니터링 | (없음 — `TreatMonitorService` 판정을 그대로 전달) | — | — |
| **진료 벤더 비교** | 판정 창 / 표본 하한 / 실패율 임계 / 벤더별 오버라이드 / **산출 서명** | 60분 / 5건 / 30% / `{}` / `{}` | §6 |

### 3-5. JSON 설정 — KEY/VALUE 편집기

원문 textarea 를 쓰지 않는다. 세 갈래이며 **메타의 모양만 보고** 갈린다.

| 설정 | 모드 | 편집 |
|---|---|---|
| `alert_schedule_expect` | 열린 집합 · 스칼라 | 키 선택 상자 15종 + `직접 입력`, 값 1칸(초) |
| `treat-vendor_overrides` | 열린 집합 · 객체 | 키 = **런타임 활성 벤더**, 값 2칸(표본 하한 건 · 실패율 %) |
| `treat-vendor_derivation` | **고정 집합** | 읽기 전용 5칸 + **붙여넣기 가져오기** 하나 |

동작 주의:
- `＋`/`−` 는 `onMouseDown` 에서 기본 동작을 막아 blur 저장이 안 뜨게 한다. 저장 요청은 promise
  체인으로 직렬화된다 — 지운 키가 되살아나는 경합을 두 겹으로 막는다.
- 새 행은 **키를 고르는 순간** 저장된다. 빈 키·중복 키는 저장 전에 화면에서 막힌다.
- 저장된 원문이 파손이면 편집기를 **열지 않는다**(`저장된 값을 읽지 못했습니다`). 빈 값 `{}` 과
  파손을 구별한다.

### 3-6. 알림 종류 ON/OFF

체크박스 하나 = `alert_rule_enabled:<ruleId>`. **행이 없으면 켜짐**이다 — 그래서 `treat-vendor` 는
마이그레이션이 `'0'` 행을 미리 심어 둔다(§6).

---

## 4. 자기감시 탭

### 4-1. 종합 판정 배너 (색을 쓰는 유일한 자리)

판정 순서: `never → dead → unknown → snoozed → stopped → degraded → live`

| 값 | 라벨 | 뜻 |
|---|---|---|
| `live` | 정상 | 초록. **판정이 있을 때만** 칠한다 |
| `degraded` | 열화 | 돌고는 있으나 발송 실패·목적지 탈락 등 |
| `stopped` | 정지 | 채널 0개 또는 `alert_enabled=false` |
| `snoozed` | 잠시 끔 | 전역 스누즈 중 |
| `dead` | 런너 정지 — 죽음 | 틱이 멈췄다 |
| `never` | 미실행 | 한 번도 안 돌았다 |
| `unknown` | 판정 유보 | 틱 표본 3 미만 — 주기 학습 중 |

**`dead` 가 `stopped` 보다 먼저 판정된다** — 알림을 끈 상태에서 런너까지 죽으면 관측 가능한
차이가 0이기 때문. `dead` 임계는 **틱 간격 중앙값 × `alert_stale_grace`** 이고 설정값은 하한으로만 쓴다.

서버 응답이 없으면 **초록을 그리지 않고** `판정 불가 — 서버 응답 없음` 을 낸다.

### 4-2. 런너 보드

마지막 틱 / 마지막 실행 / 마지막 발송 / 실측 주기(관측 N회) / 죽음 판정 임계.

> **마지막 틱은 알림을 꺼도 계속 갱신된다.** 이 값이 멈추면 런너 자체가 죽은 것이다.

### 4-3. 알림 잠시 끄기 (스누즈)

- 전역 30분 / 2시간 / 24시간 / 다시 켜기. 규칙별은 2시간 / 다시 켜기.
- **평가는 계속 돌고 전달만 막는다.** 그 사이 경과는 `꺼 둔 사이에 막힌 경고` 목록에 쌓이고,
  다시 켤 때 **요약 1건**으로 나간다.
- 상한 24시간. 무기한 침묵 경로는 없다 — 끄려면 `alert_enabled` 를 쓴다.
- **2시간 초과 전역 스누즈만 확인창**이 붙는다(전부에 걸면 아무도 안 읽는다).

### 4-4. 규칙별 상태

`off` → `snoozed` → `error` → `never` → `stale` → `ok` 순으로 판정.

`checkedAt`(마지막 점검)은 `evaluate()` 가 **정상 종료한 회차에만** 움직인다 — 매번 throw 하는
규칙이 "정상·이상없음"으로 보이지 않는다.

### 4-5. 목록 4종

| 절 | 내용 |
|---|---|
| 중복이라 참은 경고 | 억제창 안에서 재발송을 참은 항목. `발송 실패 — 재시도 대기` 를 숨기지 않는다 |
| 꺼 둔 사이에 막힌 경고 | 스누즈 구간의 경과. 해소 여부와 요약 발송 도달 수 |
| 채널별 발송 실패율 | `sent:0` 은 `0%` 가 아니라 **미검증**. 각 행에서 바로 테스트 발송 가능 |
| 최근 발송 이력 | 종류 칸의 `여러 종류` 는 삭제가 아니라 **묶음**(다이제스트·그룹 발송) |

상위 3건만 펴고 나머지는 `+N건 더 보기`.

---

## 5. 테스트 발송 탭

> **실제로 메시지가 나간다.** 켜고 끈 상태와 억제를 타지 않으며, 목적지는 그 종류의 **실제 채널
> 라우팅(`alc_types`)** 을 그대로 따른다. 재는 것은 판정이 아니라 **목적지 도달성**이다.

### 5-1. 문면

```
🧪 테스트 발송 · 실제 장애 아님
————————————————
<그 규칙의 실제 발화 문면(sampleText)>
```

배너가 **첫 줄**이어야 알림 미리보기에 잡힌다. `보낼 문면` 은 발송 전에 `?자세히` 툴팁으로 확인 가능.

### 5-2. 행 구성

벤더 차원이 있는 규칙(`treat-monitor` · `treat-vendor`)은 **전역 1행 + 활성 벤더마다 1행**으로
펼쳐진다. 각 행이 자기 목적지로 발송한다.

- `unknown-hint` — 요청한 벤더 키가 그 규칙의 활성 벤더가 아니면 **발송 전에 차단**된다.
- `옛 전역 목적지` 는 **표시 전용 · 발송하지 않음**. 발송 여부가 발화한 경고의 종류 문자열에
  걸려 발화 없이 재현할 수 없기 때문 — 유일하게 남긴 미검증 축이다.
- `/rules` 조회가 실패하면 **목적지 판정 불가** 배너가 뜬다. 그 상태의 전역 행 성공을
  "모든 목적지에 도달"로 읽으면 안 된다.

### 5-3. 결과 판독 — 제외 사유 8종

| 코드 | 뜻 |
|---|---|
| `unknown-hint` | 요청한 벤더 키가 이 규칙의 활성 벤더가 아님 (발송 안 함) |
| `disabled` | 채널이 사용 중지 |
| `malformed-types` | `alc_types` 를 읽지 못함 — **설정 오류** |
| `hint-preempted` | 벤더 전용 채널이 잡혀 배제됨 (미구독 아님) |
| `not-subscribed` | 이 채널은 해당 종류를 구독하지 않음 |
| `empty-url` | 목적지 URL 이 빔 — **설정 오류** |
| `duplicate-url` | 같은 목적지의 앞선 채널이 이미 잡힘 — **설정 오류** |
| `url-rejected` | URL 허용 검증 탈락 — **설정 오류** |

`발송 대상이 없습니다 (호출은 성공했습니다)` 와 `테스트 발송 호출 실패` 는 다른 사실이다.

### 5-4. 전체 채널 도달성

종류를 가리지 않고 활성 채널 전체로 보낸다. 채널 신규 등록 직후 확인용.

---

## 6. 진료 벤더 비교 (`treat-vendor`) — 상세

### 6-1. 이 규칙이 답하는 질문

**"한 벤더가 죽은 것인가, 수집 풀 전체가 죽은 것인가."**

### 6-2. 판정 순서

```
1) 활성 벤더 목록 = tn_treat_provider 의 tpr_enabled=1 행 (tpr_key)   ← 코드가 아니라 테이블이 정한다
2) 산출 서명 대조 — 어긋나면 throw (평가 안 함, fail-closed)
3) 창 안(기본 60분) 벤더별 성공/실패를 한 쿼리·한 시점으로 집계
4) 풀 축: passesRatio(풀합계, 하한, 임계) 통과 →
     leave-one-out 전수로 설명자 계산
       설명자 정확히 1종 → key `vendor-dominant:<벤더>`  🔴 critical  channelHint=<벤더>
       설명자 0 또는 2종+ → key `vendor-pool`            🔴 critical  (힌트 없음)
     ※ 이 회차에는 개별 벤더 warn 을 내지 않는다 (같은 사건을 N+1건으로 안 보낸다)
5) 풀 축이 멀쩡하면 벤더 축: 벤더마다 자기 임계로 판정
       key `vendor:<벤더>`  🟠 warn  channelHint=<벤더>  → 다이제스트로 묶임
```

`passesRatio(sample, failed, minSample, threshold)` = `sample >= minSample && sample > 0 && failed/sample >= threshold`.
**`>=`** 다 — 임계 정확히 일치도 발화한다.

설명자 = "그 벤더를 빼면 잔여가 임계를 못 넘거나 **판정 자체가 불가능해지는**" 벤더.
`A 299/1000 · B 200/200` 에서 건수 최다는 A 지만 설명자는 B — 그래서 후보를 고르지 않고 전수 검사한다.

### 6-3. 억제 정책

`{ severitySplit: true, group: true }` — **지속성과 ratio 원칙은 켜지 않는다.**

- 지속성 미사용 근거: `AlertService.run()` 이 지속성을 심각도 분리보다 **먼저** 적용해서,
  공용 `alert_persistence_streak` 이 2 이상이면 첫 회차의 `vendor-pool` critical 이 통째로 걸러진다.
  단발 스파이크는 60분 창 자체가 흡수한다.
- `noise.ratio` 미사용 근거: `applyRatio` 는 규칙 단위 값 하나만 읽어 **벤더별 오버라이드를 실을 수 없다.**
  대신 `passesRatio()` 를 벤더별 값으로 직접 호출한다.
- 주의: 설정 키 접미 `_min_sample` · `_ratio_threshold` 는 `applyRatio` 가 규칙 id 로 조합해 읽는
  **기존 규약**이다. 나중에 `ratio: true` 로 바꾸면 같은 값으로 **이중 게이트**가 걸린다.

### 6-4. 산출 서명 (`treat-vendor_derivation`) — 무엇이고 왜 있는가

**미측정 임계로 규칙이 도는 것을 막는 fail-closed 증서**다. 5키 고정:

| 키 | 뜻 |
|---|---|
| `window_ms` | 산출이 전제한 관측 창 |
| `min_sample` | 산출이 전제한 벤더별 표본 하한 |
| `vendors` | 산출이 본 활성 벤더 목록 |
| `ratio_threshold` | 산출이 권고한 규칙 단위 실패율 |
| `overrides` | 산출이 권고한 벤더별 값 |

매 회차 `assertSignature()` 가 **저장된 서명 ↔ 그 회차가 실제로 쓸 값**을 대조한다.

- 비어 있으면 → `산출된 것이 없다 — … 이 회차를 판정하지 않는다(§D10 fail-closed)` throw.
- 어긋나면 → `산출 조건·적용값이 서명과 다르다 — window_ms: 서명 X ≠ 적용 Y · …` throw
  (**어긋난 항목 전부**를 한 번에 적는다).
- throw 는 `health='error'` · `checkedAt` 전진 안 함으로 자기감시에 남고, 억제 상태·streak 은 보존된다.
- **끄고 정상 반환하지 않는 이유**: 키가 사라지면 공용 경로가 그것을 "해소"로 읽어
  살아 있는 장애에 **복구 알림을 쏜다**.

**손편집 경로를 일부러 두지 않았다.** 화면은 읽기 5칸 + `가져오기` 하나다. 가져오기는 서명만
저장하지 않고 `ratio_threshold`·`overrides` 를 **한 번의 저장 요청에 함께** 실어 보낸다.
`vendors` 는 설정이 아니라 런타임 값이라 가져오기가 못 맞춘다 — 그 축이 어긋나면 **재산출**이 답이다.

### 6-5. 산출 스크립트

```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 \
  [--target-fpr 1] [--min-buckets N] [--out FILE]
```

읽기 전용. 창·표본 하한은 스크립트가 갖지 않고 `tn_alert_setting` 에서 읽는다(규칙과 같은 소스).
범위 밖 값이면 **되돌리지 않고 세운다** — 되돌리면 그 값으로 만든 서명이 정상 출력처럼 나간다.

판정 규칙:
- 기간을 `window_ms` 고정 버킷으로 자르고, `total >= min_sample` 인 버킷만으로 백분위(nearest-rank, 보간 없음).
- 적격 버킷 < `max(30, ceil(100/fpr))` 이면 `INSUFFICIENT_SAMPLE` — **수치를 권고하지 않는다**.
- **풀 축 + 스냅샷 벤더 전부**가 `OK` 여야 서명이 나온다(부분 미달은 활성화 불가).
- 스냅샷 밖 벤더 행은 버킷 집계 단계에서 버린다.

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

| # | 단계 | 확인 |
|---|---|---|
| 1 | 산출 스크립트 실행 | 전 축 `OK` 라 **서명이 출력**됐다. 하나라도 부족하면 여기서 멈춘다 |
| 2 | `treat-vendor_ratio_threshold` ← 풀 축 산출값 | — |
| 3 | `treat-vendor_overrides` ← 벤더별 산출값 | — |
| 4 | `treat-vendor_derivation` ← 서명 한 줄 그대로 | 2~3 값과 서명이 같고, `window_ms`·`min_sample` 이 **지금 DB 값과도** 같다 |
| 5 | `alert_rule_enabled:treat-vendor` = `'1'` — **마지막** | 켜기 직전 DB 를 직접 읽어 2~4 를 리포트와 대조 |

화면에서는 2~4 를 **`가져오기` 한 번**으로 처리할 수 있다(§3-5). 5단계만 `알림 종류` 체크박스다.
재배포는 **어느 단계에도 필요 없다.**

4를 빼먹으면 매 회차 throw 라 반쪽 활성화가 조용히 성립하지 않는다. 그러나 **2~3 누락은 그런
안전망이 없다** — 값이 이미 존재하기 때문이다. 5단계 DB 대조가 그 자리를 메운다.

### 6-7. 현재 상태 — **OFF, 그리고 켤 수 없다**

| 항목 | 실측 |
|---|---|
| `alert_rule_enabled:treat-vendor` | `'0'` (마이그레이션 `alert-vendor-rule/01_seed_treat_vendor_disabled.sql` 이 시드) |
| `treat-vendor_derivation` | `{}` — 서명 없음 |
| 산출 실행 (2026-08-14, stg RDS 90일) | 관측 183건, **전 축 `INSUFFICIENT_SAMPLE`**, 서명 미출력 |
| 벤더별 적격 버킷 | coocon 0 · inhouse 0 · octover 0 · 풀 2 (하한 100) |
| 원인 | 60분 창 × 90일 = 2160 버킷인데 최대 물량 inhouse 조차 109건이 69버킷에 흩어져 **버킷당 평균 1.6건** |

기본값 3개(60분 / 5건 / 30%)는 **산출물이 아니라 승계**다 — `treat-monitor.service.ts` 의
60분 창 · `>= 5` · `rate >= 70`(실패율로 뒤집으면 0.3). PRD Q3 는 **열린 채**이며 prod 데이터가
확보돼 산출이 `INSUFFICIENT_SAMPLE` 아닌 서명을 낼 때 닫힌다.

### 6-8. 벤더 모집단 정정

PRD 원문은 5종(CODEF/COOCON/OCTOVER/HYPHEN/inhouse)이라 적었으나 실측은 `tn_treat_provider` **4행**:

| 벤더 | `tpr_enabled` | 감시 |
|---|---|---|
| inhouse · octover · coocon | 1 | **대상** |
| hyphen | 0 | 제외 |
| codef | (테이블 미등재 — `odd_provider` 데이터에만 남은 은퇴 벤더, 최신 2026-06-08) | 제외 |

되살아나면 테이블 한 행으로 편입된다 — **코드 변경 0**. 단 **후보 편입 ≠ 측정된 임계로 판정**이다.
활성 벤더 집합이 서명 `vendors` 와 다르면 그 회차는 통째로 평가되지 않는다.

---

## 7. 권장 설정값

### 7-1. 지금 그대로 두는 것 (근거 있는 기본값)

| 설정 | 값 | 왜 |
|---|---|---|
| `alert_persistence_streak` | **1 유지** | PRD Q11 위임 결정. 2 로 올리면 **모든** critical 통보가 약 10분 늦어지는 확실한 비용 대비 노이즈 감소가 불확실. blip 에 가장 민감한 DB 프로브는 `alert_fallback_streak=2` 로 따로 막았다 |
| `alert_fallback_streak` | **2 유지** | 3초 타임아웃 1회짜리 프로브의 커넥션 순단 방어. `persistence_streak` 재사용 불가 — 기본 1이면 폴백 지속성 게이트가 통째로 no-op |
| `alert_dedupe_ms` | **30분 유지** | 기존 `treat-alert.service.ts` `DEDUPE_MS` 승계 |
| `alert_digest_interval_ms` | **24시간 유지** | 지표 M9(무장애 24시간 수신량 ≤ 요약 1건) |
| `alert_lease_ttl_ms` | **15분 유지** | 폴링 주기(5분)를 크게 상회해야 정상 실행을 탈취하지 않는다 |
| `alert_stale_grace` | **2.0 유지** | 오탐 방어를 이 값이, 미탐 방어를 실측 중앙값이 진다 |
| `alert_snapshot_ttl_ms` | **24시간 유지** | 만료 후 폴백 구간은 무발송(fail-closed) |
| `alert_history_retention_days` | **90 유지** | 하한 7보다 짧으면 주말 장애를 월요일에 조사 못 한다 |

### 7-2. 조건부로 손대는 것

| 설정 | 언제 | 방향 |
|---|---|---|
| `alert_persistence_streak` | 스파이크 노이즈가 **실제로 관측되면** | 2 로. 재배포 없이 화면에서 |
| `alert_schedule_expect` | `delete-analysis` · `delete-fc` 는 주석이 "확인필요"라 하루로 잡아 둔 값 | EventBridge 실제 설정과 대조 후 정정 |
| `treat-vendor_*` | **산출 서명이 나온 뒤에만** | §6-6 5단계 |
| `alert_poll_interval_ms` | — | **바꿔도 평가 주기가 안 바뀐다.** §8 참조 |

### 7-3. 절대 하지 말 것

- `treat-vendor_derivation` 손편집. 드리프트 검출기를 무력화한다. `가져오기`만 쓴다.
- 서명 없이 `alert_rule_enabled:treat-vendor='1'`. 매 회차 throw 만 쌓인다.
- 롤백 시 `alert_rule_enabled:treat-vendor` 행 삭제. **행 부재 = 켜짐**이라 미래의 fail-open 을 만든다.
  기본은 **행을 남기는 것**이고, 지우려면 그 행을 이 마이그레이션이 새로 넣었음이 확인된 경우뿐.

---

## 8. 의도대로 동작하지 않는 것 (확정)

> 전부 코드 대조로 확인된 항목. 출처는 `.claude/plans/codex-findings-backlog.md`.

| # | 증상 | 실체 | 심각도 |
|---|---|---|---|
| 1 | **`alert_poll_interval_ms` 가 평가 주기를 안 바꾼다** | 이 설정의 유일한 소비처는 `alert.service.ts` 의 미전달 억제 엔트리 **재시도 창** 하나다. 실제 기동은 EventBridge 고정 5분 — 1분으로 내려도, 60분으로 올려도 5분마다 돈다 | HIGH |
| 2 | **stg/prod 를 가르는 fail-closed 경계가 0개** | r2 에서 `ALERT_ENV_LABEL` 을 전 층 제거. 남은 경계는 `webhookHostAllow`(어느 호스트냐)와 `activeChannelCount===0`(보낼 곳이 있냐)뿐. **stg 설정에 운영 웹훅 URL 이 들어가면 그대로 나간다** | HIGH |
| 3 | **벤더 전용 채널 라우팅이 warn 에서 끊긴다** | `DigestEntry` 에 `channelHint` 필드가 없어 큐잉·flush 에서 힌트가 소실된다. `vendor:<key>` warn 은 전부 다이제스트를 타므로 **벤더 전용 채널만 등록된 상태면 목적지 0곳**. `vendor-dominant:<key>`(critical)는 정상 | HIGH |
| 4 | **`vendor-dominant` critical 이 벤더 채널 한 곳으로만 간다** | `destinationsFor()` 는 `channelHint` 가 잡히면 **그 채널만** 쓴다(합집합 아님). 풀 규모 사건인데 운영 채널이 못 본다. `vendor-pool` 분기는 힌트가 없어 일반 채널로 간다 | MEDIUM |
| 5 | **critical → warn 강등 시 잔여가 24시간 침묵** | 풀 축이 내려가 `vendor-dominant` 가 사라지고 `vendor:X` warn 만 남는 회차. 복구는 나가는데 대체 신호 warn 은 다이제스트 큐에 24시간 갇힌다 — 목적지에 남는 마지막 메시지가 복구 하나 | MEDIUM |
| 6 | **복구 발송이 한 번 실패하면 영구 소실** | `Promise.allSettled` 결과와 무관하게 억제 엔트리를 삭제한다. 웹훅 타임아웃 시 다음 회차 재시도 대상이 없고, 목적지에는 "사고 진행 중" 이 굳는다 | MEDIUM |
| 7 | **본문에 아이콘·태그가 두 번 붙는다** | 규칙 5종이 `text` 에 `🔴 [위험]` 을 넣는데 `formatFindingLine()` 이 `level` 로 같은 접두를 다시 만든다 → `🔴 [위험] 🔴 [위험] …`. M1 부터 실운영에 나간 형태 | LOW |
| 8 | **드리프트 throw 가 이미 큐잉된 다이제스트를 못 막는다** | 다이제스트 flush 가 `isUnevaluated` 를 안 본다. drift 이전 큐잉된 `treat-vendor` warn 이 drift 회차에 나가고 `sentAt` 이 전진할 수 있다 | MEDIUM |
| 9 | **툴팁 트리거 문구가 접근가능한 이름에 섞인다** | `알림 종류`·`renderSetting` 의 `<label>` 이 `AlertInfo` 트리거를 감싼다 | LOW |
| 10 | **`--al-sub` 대비 3.12~3.26:1 — WCAG 1.4.3 미달** | 마커·폼 라벨·표 헤더. 토큰 값을 바꾸면 계기판층과 갈리므로 두 층을 함께 보는 별도 라운드 필요 | MEDIUM |

---

## 9. 미검증 축 (검사가 아예 없거나, 도구가 못 보는 자리)

| # | 축 | 왜 못 봤나 |
|---|---|---|
| 1 | **벤더 판정 3축(풀·지배·개별)의 실데이터 거동** | 서명이 있어야 하고, 그 시점은 활성화 5단계 이후. 지금까지 확인된 것은 규칙이 **판정을 거부하는** 경로뿐 |
| 2 | **`--target-fpr` 이 실제 발화율을 보장하지 않는다** | 산출은 `p(100-fpr)` 을 쓰는데 런타임은 `>=`. 동률 많은 이산 분포에서 목표를 크게 넘긴다. 런타임과 같은 `>=` 로 역검증해야 닫힌다 |
| 3 | **고정 버킷이 런타임 슬라이딩 창을 재현하지 않는다** | 경계에 걸친 5건이 고정 버킷에서 2+3 으로 갈려 둘 다 미달인데 런타임 창에서는 5건 전부 판정 대상. 저볼륨일수록 지배적. 서명에 `alert_poll_interval_ms` 가 없어 §D10 이 주기 변경을 못 잡는다 |
| 4 | **`옛 전역 목적지`(legacy URL) 발송** | 발송 여부가 발화한 경고의 종류 문자열에 걸려 발화 없이 재현 불가. 화면은 표시만 한다 |
| 5 | **EventBridge 실제 스케줄러 주기** | `DEFAULT_SCHEDULE_EXPECT` 값은 각 파일 헤더 주석의 "동작주기"에서 왔고 **EventBridge 설정과 대조하지 못했다**(PRD Q8) |
| 6 | **테스트 발송 계약의 기대값 자체** | 게이트가 스스로 적는 사각지대 — `sendtest-contract blind-spot=expected-values-unverified` |
| 7 | **`security-reviewer` 게이트** | 전 마일스톤 implement receipt 가 `security_skipped=true`(Agent tool 금지 세션) |
| 8 | **admin lint** | `npm run lint` 가 사전 파손 상태라 게이트에서 제외 |
| 9 | **서버 `tsc` 전체** | 비알림 사전 파손 19행(`agency`·`medication-standard`·`hyphen`·`octover` spec). 게이트는 **알림 모듈 0건**만 요구 |
| 10 | **design-grounding 하네스** | 부모 저장소의 `git diff` 만 읽어 서브모듈 UI 변경을 못 본다 — 순수 리디자인 라운드에서도 `design_signal=false` |
| 11 | **마일스톤 2.5 잔여조건 11건** | `APPROVED_WITH_CONDITIONS`. **그 11건이 닫히기 전 운영 활성화는 승인된 바 없다** |

---

## 10. 실제 테스트가 필요한 것 (우선순위)

### 10-1. 지금 화면에서 바로 할 수 있는 것

| 확인 | 방법 | 기대 |
|---|---|---|
| 채널 도달성 | 테스트 발송 탭 → `활성 채널 전체로 테스트 발송` | 채널마다 `성공 (200)` |
| 규칙별 라우팅 | 테스트 발송 탭 → 규칙 행 각각 | `alc_types` 대로 목적지가 갈린다 |
| 벤더별 라우팅 | `진료 벤더 비교` 의 벤더 행(coocon·inhouse·octover) | 벤더 전용 채널이 있으면 그 채널**만**(`hint-preempted` 로 나머지 제외) |
| 권한 fail-closed | `ALERT_ADMIN_IDS` 에서 계정을 빼고 재기동 | 아이콘 미렌더 + `GET /permission` 이 **200 `{canManage:false}`** (403 아님) |
| 단위 왕복 | 초/% 입력 후 blur → 재조회 | 1800초 ↔ 1800000ms, 5% ↔ 0.05 |

> ⚠️ 채널 2개 모두 **실 Google Chat 웹훅**이고 `alc_types=null`(전체 구독)이다 —
> 규칙 1종 테스트가 메시지 **2건**을 낸다.

### 10-2. 하네스로 돌려야 하는 것

```bash
cd insuniverse-server
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts status          # 읽기 전용, 안전
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts scenario list   # 안전
npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts scenario vendor-fire
```

시나리오 12종: `payment-fire` · `payment-under` · `payment-suppress` · `payment-recover` ·
`multi-channel` · `heartbeat-fire` · `heartbeat-recover` · `api5xx-fire` · `vendor-fire` ·
`vendor-under` · `quiet` · `channel-test`.

전제:
- `DB_DATABASE` 가 **`-dev` 로 끝나야 한다** — 아니면 `assertWritableDatabase` 가 부팅 전에 막는다.
  터널이 stg 를 물고 있는지 먼저 의심할 것(dev 13306 과 stg 포트가 인접).
- Redis · API(`:7200`) 기동, 마이그레이션 2건(`alert-history` · `alert-vendor-rule`) 적용.
- 심은 행·바꾼 설정은 `tn_alert_setting` 의 `alert-inject:ledger` 행에 기록되고 `cleanup` 이 되돌린다.
- **실제 웹훅이 그대로 나간다.**

### 10-3. prod 데이터가 있어야 하는 것

1. **벤더 임계 산출 재실행** — prod 90일 데이터로. `INSUFFICIENT_SAMPLE` 이 안 나오는지가 관건.
   안 나오면 창을 키우거나(60분 → 6시간) 표본 하한을 내려야 하는데, **그러면 판정 성질이 바뀌므로
   서명·설정·리포트를 함께 다시 만들어야 한다.**
2. **§8 의 1~6번** — 전부 운영 트래픽이 있어야 재현된다.

---

## 11. 로컬 검증 환경

| | 주소 |
|---|---|
| API | `http://localhost:7200` — **`localhost` 또는 `[::1]`** (IPv6 전용 바인딩) |
| Admin | `http://localhost:5173` |
| DB / Redis | `127.0.0.1:13306` (SSH 터널 `insuniverse-dev`) / `127.0.0.1:6379` |

- 터널이 내려가 있으면 API 는 DB 연결에서 멈춘다 — 먼저 올려야 `:7200` 이 뜬다.
- 로컬 종합 판정은 보통 `dead` 다 — 로컬에 `analysis-status` 스케줄러가 없어 런너 틱이 멈춘 상태.
  **정상**이며, 회차를 돌리려면 하네스를 쓴다.
- 인증 쿠키는 `_aaT`.
