# Notes: 운영 장애 알림 시스템 — 마일스톤 2.5

**Plan**: `.claude/plans/ops-alert-system-m2-5.plan.md`
**Gate**: mccp-implement-codex / decision `ops-alert-system-m2-5`

구현 단계 게이트 기록을 여기 둔다. plan 본문은 `mccp-plan-codex` receipt 가 해시로 봉인하고
있으므로 구현 리뷰를 plan 에 쓰면 그 receipt 가 stale 로 떨어진다.

## Codex Implementation Review

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.23.7/scripts/lib/codex-invoke.js adversarial-review` (fail-closed Bash wrapper, v0.2.2)
- 라운드 수: 1
- Codex verdict: `needs-attention` — "NO-SHIP: the harness is absent, and the proposed locking model cannot safely combine pooled `run()` writes, time advancement, cleanup, and DB-down operation."
- Codex thread: `019ff360-e356-70f0-83eb-53b2899f5606`
- 합치 결론: R1 은 plan §D3-6 의 **잠금 실현 방식**을 무너뜨렸다. plan 은 "모든 커맨드가 sentinel 행을
  `SELECT ... FOR UPDATE` 로 잠그고 자기 트랜잭션 끝까지 쥔다"고 정했는데, 그것을 문자 그대로
  구현하면 `AlertService.run()` 이 **다른 pool 커넥션**으로 쓰기 때문에 (a) 잠금 트랜잭션 안에서
  역행시킨 `digestSentAt` 이 다음 `run()` 에 보이지 않아 §D7 시간 전진이 **조용히 무효**가 되고,
  (b) REPEATABLE READ 스냅샷이 굳어 `cleanup` 이 낡은 값을 원본으로 오인해 복원하며,
  (c) 같은 행을 건드리면 lock wait 로 회차가 멈춘다. 잠금의 **목적**(하네스 커맨드 간 상호배제)은
  유지하되 **수단**을 세션 advisory mutex(`GET_LOCK`)로 바꾸고, 데이터 변경은 매번 **짧은 트랜잭션**
  으로 commit 한다. 이것이 이번 라운드의 유일한 설계 변경이며 plan §D3-6 에 대한 **Deviation** 이다.

### YAGNI Triage

| Finding | Severity | Verdict | Why |
|---|---|---|---|
| F1 대상 하네스가 diff 에 존재하지 않는다 | HIGH | **ACCEPT_NOW** | 사실이다. 다만 이 게이트는 Phase 3 EXECUTE **이전**에 도는 사전 게이트라 working tree 에 하네스가 없는 것이 정상이다. 지적의 실질은 "리뷰 대상이 제안이 아니라 빈 diff 였다"이며, Phase 3 가 `scripts/alert-inject.ts` + spec 을 만들어 그대로 해소한다. 설계 변경 없음 |
| F2 커맨드 전 구간 InnoDB 트랜잭션이 시간 전진과 양립 불가 | HIGH | **ACCEPT_NOW** | 맞고 blocking 이다. 위 합치 결론 참조. 잠금을 `GET_LOCK` 세션 mutex 로 교체하고 데이터 변경은 짧은 트랜잭션으로 분리 |
| F3 sentinel 이 하네스 커맨드는 배제하지만 실제 상태 기록자는 배제하지 못한다 | HIGH | **ACCEPT_NOW** | 맞다. `AlertService.run()` 은 `alert:lease` 를 보지 sentinel 을 보지 않고, `markSchedule()` 은 리스 없이 `alert:hb:*` 를 쓴다. 두 갈래로 흡수했다 — (i) 절차: 측정 구간에 스케줄러 진입점을 기동하지 않는다(plan Task 1b·5b 가 이미 15종 전부 미실행로 정했고, **코드 확인 결과 API 프로세스는 `AlertService.run()` 을 부르지 않는다** — `.run()` 호출자는 `analysis-status.schedule.ts` 하나뿐이다). (ii) 기계: `cleanup` 복원을 **compare-and-swap** 으로 바꿔 현재 값이 하네스가 쓴 값과 같을 때만 원본을 되돌린다. 대장에 `original` 과 `written` 을 함께 담는다 |
| F4 DB 차단 잠금 예외에 fail-closed 실현이 없다 | HIGH | **ACCEPT_NOW** | 맞다. plan §D3-6 의 예외는 절차적 문장뿐이었다. `--expect-db-down` 전용 모드로 못박고 세 겹으로 닫는다 — (i) DB 가 **살아 있으면 거부**한다(모드 오용 차단), (ii) 잠금 시도 자체를 건너뛴다, (iii) 회차 결과가 `db-down-*` 계열 `skipped` 가 아니면 그 측정을 **무효**로 처리하고 non-zero 로 종료한다. 일반 `--no-lock` 플래그는 노출하지 않는다 |

- Deferred to backlog: 0 → 백로그 추가 없음 (4건 전부 R1 에서 흡수)
- Open Questions: 없음 (§0 auto-CRITICAL 카탈로그 해당 없음 — 보안 경계·원자적 상태·스키마 파손 미해당. F2 가 원자성을 건드리나 미해결로 남기지 않고 R1 에서 닫았다)
- 재실행 판정: R2 미진입. 흡수가 4건 전부를 닫았고 `MCCP_GATE_ROUND_CAP` 기본 1

### 잠금 모델 확정 (F2·F4 흡수 결과)

| 축 | plan §D3-6 원문 | 확정 구현 | 왜 |
|---|---|---|---|
| 상호배제 수단 | sentinel 행 `SELECT FOR UPDATE`, 커맨드 끝까지 보유 | `GET_LOCK('alert-inject:ledger', n)` — 전용 mysql2 커넥션 1개에 세션 고정 | advisory mutex 라 MVCC 스냅샷을 만들지 않고 다른 커넥션의 행 쓰기를 막지 않는다. 하네스 커맨드 간 배제라는 **목적**은 그대로 |
| 대장 원자성 | 시딩 + 대장 UPSERT 한 트랜잭션 | 그대로 유지. 단 **짧은** 트랜잭션 | 크래시 원자성은 트랜잭션이 지고, 상호배제는 mutex 가 진다. 두 책임을 분리한다 |
| 시각 역행 가시성 | (명시 없음) | 회차 사이 조작을 매번 commit 후 `run()` 호출 | commit 전이면 다음 `run()` 이 못 본다 — F2 의 본체 |
| 복원 | 대장의 원래 값으로 되돌림 | `original` + `written` 저장, 현재 값 == `written` 일 때만 복원(CAS) | 외부 기록자가 그 사이 갱신했으면 덮지 않는다 — F3 |
| DB 차단 예외 | 절차적 상호배제 | `--expect-db-down` 모드. DB 생존 시 거부 + 결과 클래스 단언 | F4 |

`GET_LOCK` 선례는 저장소에 없다. 도입 근거는 위 표이며 프로덕션 코드는 건드리지 않는다
(하네스 프로세스 안에서만 쓴다).

### Security Reviewer

> security-reviewer unavailable, skipped (auto-fallback): 이 세션은 Agent(Task) tool 호출이 금지돼 있어 `subagent_type: "security-reviewer"` 를 부를 수 없다.

이 receipt 는 `security_skipped=true` 로 기록되며 downstream `/mccp:pr` 에서 non-approving 으로
취급된다. 우회하지 않는다.

해당 영역과 하네스가 취한 자세를 대신 명시한다 — 판정이 아니라 **미검토 표면의 목록**이다:

- **비밀값**: `.env` 는 `.gitignore:51` 이 제외한다. 하네스는 웹훅 URL 을 로그에 원문으로 찍지 않는다
  (`maskWebhookUrl` 과 같은 이유 — 구글챗 `?key=…&token=…` 은 경로·쿼리 자체가 자격증명이다).
- **SSRF 경계**: 하네스는 `AlertService.send` 를 **pass-through 로 감쌀 뿐** 검증을 우회하지 않는다.
  `isAllowedWebhookUrl` · `resolveAllowedAddresses` 가 그대로 진다.
- **SQL**: 전부 drizzle 파라미터 바인딩. 하네스가 받는 인자 중 SQL 에 닿는 것은 없다.
- **DB 쓰기**: `tn_payment` · `tn_order` INSERT 와 `tn_alert_setting` UPSERT 뿐이다. 스키마 변경 0,
  기존 행 UPDATE/DELETE 0(`cleanup` 이 지우는 것은 자기가 넣은 행뿐이다).

### Design Review

> impeccable silent-skip: detector 가 이번 diff 에서 design surface 를 찾지 못했다(`.tsx/.css/.html` 및 `.claude/cache/*` 산출 없음). 이 마일스톤은 검증 하네스 + 리포트이며 렌더 표면이 없다.

## Deviations from Plan (구현 시점)

plan 본문을 고치지 않고 여기 적는다. plan 은 `mccp-plan-codex` 가 봉인한 상태다.

1. **§D3-6 잠금 수단 변경** — 행 잠금 → `GET_LOCK` 세션 mutex + 짧은 트랜잭션. 근거는 위 Codex F2.
   §D3-6 이 보장하려던 것(회수 불가 행·키 0)은 그대로 지킨다.
2. **§D3-6 예외 구체화** — "절차적 상호배제" → `--expect-db-down` 모드로 기계화. 근거 F4.
3. **§D3 복원 방식 강화** — 무조건 복원 → compare-and-swap 복원. 근거 F3.
4. **`--sim-heartbeat` 신설**(`--advance` 가 함의) — 실행해 보고 발견한 결함이다. dev 는
   `alert:hb:*` 가 0개라 "존재하는 키만 갱신" 방식이 아무것도 갱신하지 않고, 288회차의 실시간
   12분이 흐르는 동안 최초 관측 유예가 만료되며 하네스가 A3 를 자작한다. 감시 목록 **전체**를
   가상 now 로 갱신하도록 바꿨다. 원본이 `null` 인 신규 키는 cleanup 이 삭제로 되돌린다.
5. **대상 DB 가드 신설**(`assertWritableDatabase`) — plan 에 없던 방어. dev(13306)와 stg 포트가
   인접하고 이 하네스는 실패 결제 행을 실제로 넣는다. 이름이 `-dev` 로 끝나거나
   `ALERT_INJECT_ALLOW_DB` 가 정확히 일치할 때만 쓰기 커맨드를 허용한다. `status` 는 진짜
   읽기 전용(`peekLedger`)으로 분리해 면제 근거를 사실로 만들었다.
6. **A6 주입 방법**(plan §D6 "프로세스 중지" → 연결 대상을 닫힌 포트로 전환) — 이 환경에서는
   프로세스를 멈출 수 없다. Redis 는 Windows 서비스이고 세션에 서비스 제어 권한이 없어
   **멈추면 되살릴 수 없다**(`sc start` → ACCESS_DENIED). MySQL 은 로컬에 설치돼 있지 않고
   AWS RDS 를 basti 로 터널링한 것이다. 앱 관점 실패 모드(`ECONNREFUSED`)는 동일하지만
   **부분 열화(응답 지연)는 재현되지 않는다** — 완전 단절만 측정했다.
7. **M5 측정 방식** — 형식이 틀린 URL 은 `assertUrl` 이 등록 단계에서 막아 발송 격리를 잴 수
   없다. 형식 검증은 통과하되 목적지가 존재하지 않는 URL 로 채널을 하나 더 등록해 쟀다.
8. **`test` 서브커맨드 신설** — Task 1 의 "채널 도달성"을 하네스 컨텍스트에서 재려면 필요한데
   §D2 커맨드 목록에 없었다.
9. **평가되지 않은 회차를 무효로 판정** — `run()` 이 `skipped` 를 돌려준 회차는 규칙을 한 번도
   돌리지 않았으므로 그 "발송 0건"은 무장애의 증거가 아니다. 고아 리스로 288회차가 통째로
   `lease-not-acquired` 로 건너뛰어지면서 9-a 가 깨끗하게 통과할 뻔한 것을 겪고 넣었다.
10. **`DEFAULT_SCHEDULE_EXPECT` 는 15종, `apps/api/src/schedules/` 파일은 16개** —
   `samsung-s2-report.schedule.ts` 가 dockerfile `SCHEDULER_TYPE` case 에 없어 감시 목록에서
   제외돼 있다(`alert.entity.ts:114` 이 근거를 이미 적어 뒀다). Task 5b 표는 **15종**으로 만들고
   16번째 파일이 왜 빠지는지 표 아래에 적는다.
