# Implementation Report: 운영 장애 알림 시스템 — 마일스톤 3 (알림 시스템 자기감시 화면)

**Plan**: `.claude/plans/ops-alert-system-m3.plan.md`
**Decision**: `ops-alert-system-m3`
**Branch**: `feat/monitoring-alert`
**M3_BASE**: server `5f9ecd0` · admin `110cfe1`

## Summary

M1·M2 가 만든 단일 발송 경로 위에 관측 계층 하나와 화면 하나를 얹었다. 실체는 화면이 아니라
**세 신호를 새로 만든 것**이다 — 런너 생존·실측 주기(`alert:tick`), 규칙별 점검 기록(`alert:ruleStat`),
발송 이력(`tn_alert_history`). 화면은 그 위의 얇은 렌더러이고, 판정은 전부 서버 순수 함수가 진다.

스누즈는 **시간 상한 24시간이 걸린 전달 차단**으로 구현했다 — 평가는 계속 돌고 목적지 전개부터
막으며, 그 구간의 경과가 `alert:snoozeLog` 에 남아 해제 시 요약 1건으로 나간다.

신규 규칙 0개 · 신규 스케줄러 0개 · 신규 프론트 의존성 0개. 마이그레이션은 `CREATE TABLE` 1건뿐이다.

## Assessment vs Reality

| Metric | Predicted (Plan) | Actual |
|---|---|---|
| Complexity | Medium | Medium — 예측대로. `run()` 재배선이 비용의 대부분이었다 |
| Files Changed | server 13 · admin 4 | **server 18 · admin 4** (수정 11 + 신설 7 / 수정 3 + 신설 1) |
| 신규 테이블 | 1 (추가 전용) | 1 |
| 신규 스케줄러 | 0 | 0 |
| 규칙 파일 diff | 0 (M6 불변) | 0 |

## Tasks Completed

| # | Task | Status | Notes |
|---|---|---|---|
| 1 | 관측 상태 신설 (`alert.entity.ts` · `alert-signal.service.ts` · 마이그레이션 · 리포지토리) | Complete | `alert.repository.spec.ts` 신설(아래 D1) |
| 2 | 판정 순수 함수 (`alert-monitor.ts`) | Complete | Codex R1 F1 흡수분 포함 |
| 3 | `run()` 배선 (§D2·D3·D4·D7) | Complete | Codex R1 F4 흡수로 `flushDigest` 반환이 상위집합 |
| 4 | 스누즈 저장·조회 (`alert-settings.service.ts`) | Complete | `alert-settings.service.spec.ts` 신설(아래 D1) |
| 5 | 관리자 API (`GET /monitor` · `POST /snooze`) | Complete | `POST /test` 가 `ctx.channelId` 전달 |
| 6 | 자기감시 화면 (`AlertMonitorPage.tsx`) | Complete | 설계 앵커 4개 준수(아래) |
| 7 | 회귀 · M6 불변 확인 | Complete | 전부 기대값 |

## Validation Results

| Level | Status | Notes |
|---|---|---|
| Static Analysis (server) | Pass (santa R1 수정 후) | `tsc --noEmit` 신규 오류 **0** — 잔존 **19건**(연속행 포함 21행)은 전부 M3_BASE 이전부터 있던 4개 spec 파일이다. **최초 봉인 시점에는 신규 3건이 있었다**(`alert-admin.controller.spec.ts` 24·81·125 `TS2554`): 컨트롤러 생성자가 `AlertMonitorService` 5번째 인자를 받게 됐는데 spec 3곳이 4인자였다. santa-loop R1 에서 닫았다 · eslint 0 |
| Static Analysis (admin) | Pass | `npm run type-check` 0 · 변경 파일 eslint 0 |
| Unit Tests | Pass | `apps/api/src/modules/alert` **256/256** — 기존 179 + M3 신규 **77**(santa-loop 회귀 4건 포함). (봉인 시점 리포트의 "249/249 · 신규 63" 은 실측과 어긋났다) |
| Build (server) | Pass | `nest build` exit 0 |
| Build (admin) | Pass | `npm run build:stg` 성공 |
| Integration | Pass | 로컬 수동 검증 V1~V35 **완료 (2026-08-14) — 35 PASS · 0 부분**. 실 DB(`insuniverse-dev` @ SSH 터널) · 실 Redis · 실 Google Chat 웹훅 2개 · admin dev 화면(Whale headless). santa 라운드 4 가 계획 대비 치환 5건을 지적해 전부 계획 문면대로 재측정했고, 부분이던 V27 도 `LOCK TABLES`+`KILL QUERY` 로 채널 조회만 격리해 닫았다. 기록: `ops-alert-system-m3-v-results.md` |
| Edge Cases | Pass | 아래 회귀 spec 표 |

### M6 불변 · 추가 전용 검증

| 검사 | 기대 | 실측 |
|---|---|---|
| 규칙 파일 diff (`rules/*`) | 0 | **0** |
| 마이그레이션 비주석 `ALTER`/`DROP` | 0 | **0** (plan §Validation 의 원본 명령 `grep -riE '\b(ALTER\|DROP)\b'` 는 **1** 을 낸다 — 되돌리기 주석의 `DROP TABLE` 이다. 실행문은 `CREATE TABLE` 1건뿐) |
| `drizzle/schema.ts` 삭제 줄 | 0 | **0** |
| `status()` 반환 필드 diff | 0 | **0** |
| `AdminLayout.tsx` diff | 0 | **0** |

### Design Grounding

**N/A (no design trigger)** — `impeccable-detect` 가 `design_signal=0`(`reason=no-signal`)을 냈다.
판정 대상 `AlertMonitorPage.tsx` 가 게이트 시점에 untracked 로도 존재하지 않아 detector 가 렌더
표면을 보지 못한 것이며, 문서화된 untracked greenfield trigger gap 이다. Phase 2.5.5c 캡처가
일어나지 않아 Phase 3.6·3.7 은 완전 no-op 이다.

대신 plan §Design Critique 가 이미 승격해 둔 `frontend-design-direction` Output Constraints 4개를
구현 제약으로 그대로 졌다:

| 앵커 | 구현 |
|---|---|
| heading depth ≤ 3 | `<h2>` 1개 + `<h3>` 6개. `<h4>` 이상 없음 |
| 강조색 화면당 1개 | `overall` 배너만 색을 쓴다(`BANNER` 상수). 나머지 표는 무채색 + 상태 텍스트 |
| raw markdown marker 금지 | 해당 없음 — 마크다운 렌더링 경로 없음 |
| 한 화면 항목 수 상한 | `Collapsed` 하나로 목록 5종(규칙·억제·스누즈억제·채널·이력)을 상위 3건 + `<details>` 접기 |

## Files Changed

### insuniverse-server

신설 7 · 수정 11 = **18**. 줄 수는 신설 파일이 총행, 수정 파일이 `git diff --numstat 5f9ecd01` 의
**삽입/삭제**다 — 봉인 시점 리포트는 `--stat` 의 합계 열을 `+삽입` 으로 잘못 라벨했다(santa R3).

| File | Action | Lines |
|---|---|---|
| `apps/api/src/modules/alert/alert-monitor.ts` | CREATED | 536 |
| `apps/api/src/modules/alert/alert-monitor.spec.ts` | CREATED | 509 |
| `apps/api/src/modules/alert/alert-monitor.service.ts` | CREATED | 239 |
| `apps/api/src/modules/alert/alert-monitor.service.spec.ts` | CREATED | 166 |
| `apps/api/src/modules/alert/alert.repository.spec.ts` | CREATED | 117 |
| `apps/api/src/modules/alert/alert-settings.service.spec.ts` | CREATED | 85 |
| `migrations/alert-history/01_create_tn_alert_history.sql` | CREATED | 32 |
| `drizzle/schema.ts` | UPDATED | +22 / -0 |
| `apps/api/src/modules/alert/alert.entity.ts` | UPDATED | +124. 상태 키 3 · 스누즈 키 2 · 타입 6 · **설정 2**(`alert_stale_grace` · `alert_history_retention_days`) |
| `apps/api/src/modules/alert/alert.repository.ts` | UPDATED | +106/-2. 이력 4메서드(`insertHistory`·`findRecentHistory`·`findRecentHistoryByChannel`·`pruneHistory`) · `alcUpdatedAt` select |
| `apps/api/src/modules/alert/alert.service.ts` | UPDATED | +565/-27. 틱·이력 훅·스누즈 게이트·집합 분리·`ruleStat`·보존기간 정리 |
| `apps/api/src/modules/alert/alert.service.spec.ts` | UPDATED | +436/-2. M3 회귀 **25건** 추가(`it()` 67 → 92) |
| `apps/api/src/modules/alert/alert-settings.service.ts` | UPDATED | +105/-2. 스누즈 read/write |
| `apps/api/src/modules/alert/alert-signal.service.ts` | UPDATED | +57/-0. `historyGap` · `snapshot.snoozeUntil` |
| `apps/api/src/modules/alert/alert-admin.controller.ts` | UPDATED | +59/-5. `GET /monitor` · `POST /snooze` · test ctx |
| `apps/api/src/modules/alert/alert-admin.controller.spec.ts` | UPDATED | +74/-0. 스누즈·monitor **5건** 추가(`it()` 3 → 8) |
| `apps/api/src/modules/alert/alert.module.ts` | UPDATED | +2. `AlertMonitorService` provider 등록 |
| `apps/api/src/modules/alert/alert.dto.ts` | UPDATED | +24. `SnoozeDto` |

### insuniverse-admin

| File | Action |
|---|---|
| `src/pages/Admin/Site/Alert/AlertMonitorPage.tsx` | CREATED (534줄) |
| `src/api/alertApi.ts` | UPDATED — `getMonitor` · `testSendStrict` · `snooze` · 타입 |
| `src/app/router/routes/domains/site.routes.tsx` | UPDATED — import 1 + 라우트 블록 1 |
| `src/pages/Admin/Site/Alert/AlertSettingPage.tsx` | UPDATED — 자기감시 링크 1줄 |

## Deviations from Plan

**D1 — spec 파일 2개 추가.** plan Files 목록에 없지만 Task 1·Task 4 의 Validate 줄이
`npx jest alert.repository` / `npx jest alert-settings` 를 요구하는데 두 spec 이 저장소에 없었다
(없으면 jest 가 "no tests found" 로 exit 1). 검증 가능하게 만들려면 신설이 필요하다.

**D2 — `flushDigest()` 는 `{ok, fail}` 을 유지하고 규칙 집합을 더한다.** plan Task 3 (11) 은
"`{ok, fail}` 대신 규칙별 성공 집합"이었으나 Implement-Codex R1 F4 가 실측으로 깼다 — 호출부가
`r.fail`·`r.ok` 로 `sendFailureCount`·`sent`·큐 비우기·`digestSentAt` 을 판정하므로 교체하면
물리 발송 회계가 깨지고, `ruleId` 없는 M1 잔여 큐 항목은 성공해도 빈 집합이라 큐가 안 비어
같은 다이제스트를 재발송한다. **상위집합** `{ok, fail, sentRuleIds}` 로 읽었다.

**D3 — 채널 귀속의 사실 소스를 회차 맵 하나로 통일.** plan Task 3 (9) 는 `pending`/`jobs` 에
`channelId` 를 싣게 했는데, 그 값의 출처인 `dest.channelId` 는 `destinationsFor()` 의 URL dedupe
결과라 **같은 URL 을 가진 채널 둘 중 먼저 온 쪽을 임의로 고른다** — R6-F5 가 없애려던 오귀속이
그대로 재현된다. 충돌을 `null` 로 판정하는 것은 회차 맵뿐이므로 발송 경로도 맵을 통해 귀속하게
했다. `pending` 은 `ruleId` 만 싣는다(규칙 귀속은 충돌 문제가 없다).

**D4 — `sentAt` 귀속을 접두 비교가 아니라 직접 전달로.** plan §D3 은 `jobs[i].keys` 접두 비교를
지시했으나, `pending`/`jobs` 가 이미 `ruleId` 를 들고 있으므로 파싱 없이 직접 귀속한다. "키를
split 하지 않는다"는 불변식을 더 강하게 만족한다(비교조차 하지 않는다). 억제 키에서 규칙을
얻어야 하는 복구 경로만 접두 비교(`ruleIdOfKey`)를 쓴다.

**D5 — `drizzle/schema.ts` 에 `bigint` import 를 별도 행으로 추가.** 기존 import 행을 고치면
plan Validation 의 "기존 정의 삭제 0줄" 검사가 깨진다(수정은 diff 상 삭제+추가다). 그 행은
introspect 산출물이기도 하다. `no-duplicate-imports` 규칙이 없어 eslint 0 이다.

**D6 — `AlertChannelRow.alcUpdatedAt` 을 선택 필드로.** 필수로 두면 기존 spec 픽스처가 전부
깨진다. DB 컬럼이 nullable 이고 `null`/부재가 똑같이 "한 번도 수정되지 않음"이라 의미가 같다.

**D7 — 보존기간 정리 경로를 넣었다. plan §D1 은 넣지 말라고 했다.** (santa-loop R1 에서 발견 —
최초 봉인 리포트가 이 이탈을 기록하지 않았다.)

plan §D1 은 명시적으로 반대 방향을 확정했다:

> **보존 상한을 지금 만들지 않는다.** 회차당 발송이 한 자릿수라 증가 속도가 미미하고, 상한을
> 붙이려면 삭제 경로가 생겨 **그것이 곧 증거 인멸 경로가 된다.** 필요해지면 인덱스가 이미
> 있으므로 기존 `delete-*` 스케줄러 계열에 1줄이면 된다.

구현은 그 반대를 골랐다 — `alert_history_retention_days`(`COMMON_SETTINGS`, 기본 90 · min 7 ·
max 3650 · 관리자 화면에서 재배포 없이 편집 가능) + `AlertRepository.pruneHistory()` 의
`DELETE FROM tn_alert_history … LIMIT 1000`, `run()` step 15 에서 매 회차 호출
(`alert.service.ts` 의 `pruneHistory` 호출부). 리스 안이고 실패는 swallow 다.

**맞바꾼 것**: 무한 증가를 막았고 보존기간을 M11(재배포 없이 조정)에 얹었다. 대신 plan 이
지목한 증거 인멸 경로가 실재하게 됐다 — 하한이 7일이라, 최근 이력 200건·채널 창 20건이
전부 7일 안에 들어오는 저트래픽 구간에서는 관리자가 채널 실패율의 근거를 스스로 지울 수
있다. 삭제 사실을 남기는 표지도 없다(`historyGap` 은 `INSERT` 실패·DB 폴백 전용이다).

**이 이탈은 되돌리지 않았다** — plan 의 대안("기존 `delete-*` 스케줄러에 1줄")도 같은 삭제
경로이고, 무한 증가를 그대로 두는 쪽이 더 나쁘다고 판단할 근거가 이 리포트에 없다. 판단을
바꿀 사람은 사용자다. 기록만 정정한다.

## Issues Encountered

**Implement-Codex R1 이 `needs-attention`(HIGH 5) 를 냈고, 그중 4건이 plan 최종 봉인 라운드(R9)에서
backlog 로 이월된 것과 동일 항목이었다.** 재확인된 4건 중 2건은 이번에 신규 작성하는 파일 안에서
수 줄로 닫히므로 흡수하고 backlog 를 마감했다(F1 `never`/전규칙OFF · F2 채널 창 하한). 나머지 2건은
M2 계약 변경이 필요해 backlog 유지다(F3 스냅샷 fencing · F5 `sentAt` 내구성). 신규 지적 1건(F4)은
위 D2 로 흡수했다. 상세: `.claude/notes/ops-alert-system-m3.md`.

**어드민 `npm run lint` 는 M3 이전부터 깨져 있다** — `public/_statics/adminLTE/.eslintrc.json` 이
설치되지 않은 `eslint-plugin-compat` 을 참조한다(그 파일은 `first commit` 소속). 변경한 `src/**`
4개 파일만 직접 lint 해 0건을 확인했다.

**보안 검토가 실행되지 않았다** — 이 세션은 Agent/Task tool 호출이 금지돼 있어
`subagent_type: "security-reviewer"` 를 띄울 수 없다. receipt 를 `security_skipped=true` 로 봉인했고
그 값은 **non-approving** 이라 `/mccp:pr` 가 blocking 으로 읽는다. 우회하지 않았다. 수동 확인한
보안 표면 5종은 notes 에 기록했다.

## Tests Written

`it()` 정의 수 기준. santa-loop R1 이 3건을 더했다(아래 표시).

| Test File | Tests | Coverage |
|---|---|---|
| `alert-monitor.spec.ts` | 34 | 판정 순서(dead > unknown > snoozed > stopped) · **미래 틱 fail-closed(santa R1)** · 실측 주기 양방향 · clamp · 규칙 health · 억제 목록 접두 매칭 · 채널 창 · 이력 구멍 |
| `alert.service.spec.ts` (추가분) | 25 | 틱(OFF·라벨미설정·리스미획득·간격링) · 스누즈(평가 유지·허위복구·streak 진행·해제 요약·부분실패·평가실패 보호·다이제스트 차단·폴백 차단) · `ruleStat` · 이력(구멍 표지·URL 부재·채널 귀속·충돌 미귀속·보존기간 정리) · **본문 URL 마스킹 3건(santa R1 2 · R2 1 — 스킴 대소문자·허용목록 축소·한글 산문 경계)** |
| `alert-monitor.service.spec.ts` | 5 | `GET /monitor` 조립 — 조회 실패가 초록 아님 · 원문 URL 부재 · 규칙/채널/이력 뷰 결합 |
| `alert-admin.controller.spec.ts` (추가분) | 5 | 스누즈 상한 400 · 알 수 없는 ruleId 400 · 절대시각 변환 · 해제(0) · 무효화 선행 |
| `alert.repository.spec.ts` | 4 | `insertHistory` 마스킹·컬럼 상한 · `findRecentHistory` 상한 · 채널별 조회 조건 |
| `alert-settings.service.spec.ts` | 4 | 만료·파손·예외가 전부 `null` · 전역/규칙별 키 분리 · 감사 필드 |

전체 alert 모듈 **256/256 통과** — 기존 179 + M3 신규 77.

## Next Steps

- [x] **로컬 수동 검증 V1~V35 — 완료 (2026-08-14). 35 PASS · 0 부분.**
      실측 기록과 재현 절차: `ops-alert-system-m3-v-results.md`.
      두 리뷰어가 "단위 테스트로 대체 불가"라 공통 지목한 **DB 차단·재시작 축이 전부
      닫혔다** — V7(조회 실패가 `available:false` + 화면 `측정 불가` 문구) · V11(이력·실패율이
      API 재시작 + Redis 전량 삭제를 넘김) · V22/V28(`gapAt` 표지, 전 목적지 실패·`fbState`
      부재 축 포함) · V26 a·b(스누즈 중 폴백 차단 / 만료 후 폴백 발송) ·
      **V27**(`LOCK TABLES tn_alert_channel WRITE` + `KILL QUERY` 로 그 SELECT 하나만
      끊어 격리 — GRANT 변경도 DDL 도 없이 제약 1 안에서 성립했다).
      이 실행이 **계측기 결함 3건째**를 드러냈다(`DB_DOWN_SKIPS` 에 `db-down-snoozed` 누락,
      server `2bfa2a0e`) — 제품은 옳게 동작하는데 하네스가 그 회차를 "무효"로 찍고 있었다.
      santa 라운드 4 의 Reviewer B 가 **계획 대비 입력 치환 5건**을 지적해 전부 계획 문면대로
      다시 쟀다(V7·V10·V13·V17·V28). 기각 2건(Redis 재시작 시 `gapAt` 소실 · 채널 창 1초
      경계)은 백로그이며 근거는 `.claude/state/santa-ledger.md` §Round 4
- [x] **마이그레이션 적용 완료 (2026-08-13, local `insuniverse-dev`).**
      `migrations/alert-history/01_create_tn_alert_history.sql` 실행 — 컬럼 10 · 인덱스 3
      (`idx_alert_history_created` · `idx_alert_history_channel`) · `alc_id` FK 없음 · 전 컬럼
      `utf8mb4_general_ci`(기존 alert 테이블과 동일). 기존 `tn_alert_channel`·`tn_alert_setting`
      무변경. 적용 전 `history.available` 이 false 로 떨어지는 것과 적용 후 빈 원장이
      `available:true, list:[]` 로 뜨는 것을 양쪽 다 실측했다. **운영·stg 적용은 배포 시점
      과제로 남는다.** 실측 기록: `ops-alert-system-m3-v-results.md`
      **santa R3 실측(2026-08-13)**: 실행 조건인 `127.0.0.1:13306` 은 로컬 MySQL 이 아니라
      stg RDS 로 가는 **SSH 터널**이고 현재 내려가 있다(13306·3306 닫힘 · Docker 데몬 미기동 ·
      Redis 6379 만 생존). 더불어 2.5 하네스 `scripts/alert-inject.ts` 에는 스누즈·monitor
      커맨드가 없어 M3 34건은 **하네스 확장이 선행**된다
- [ ] `security-reviewer` 게이트 — santa R2 의 Reviewer A 가 `mccp:security-reviewer` 로 전 표면을
      훑어 blocking 0 을 냈다(기록: notes). 그러나 implement receipt 는 `security_skipped=true`
      **그대로 둔다** — receipt 는 그 게이트가 그 시점에 돌지 않았다는 사실의 기록이고, 다른
      명령이 돌린 검토로 소급해 덮으면 receipt 가 거짓이 된다. 닫으려면 implement 게이트 재실행
- [ ] `/mccp:code-review` 또는 `/mccp:pr`
- [ ] **M3 완료는 운영 활성화 승인이 아니다** (§D11 · UI26) — 마일스톤 2.5 잔여조건 11건은 그대로
      열려 있고 M3 가 그중 무엇도 닫지 않았다
