---
task_id: task-3008
scope: task
team: dev4-team (비슈누)
level: Lv.2
status: PR #252 OPEN · 머지 대기(ANU) · 자가머지 안 함
pr: https://github.com/Jeon-Jonghyuk/InsuRo/pull/252
pr_head: 0bdfa786f632e86bb1f81e7894cdc0c7a0f60990
pr_base: edd7582f94ca684499209a817ec308ac8ae4977b
date: 2026-08-25
---

# task-3008 — 경보 배선 복구

## 결론 (한 줄)
**실제 텔레그램 수신 확인 = YES.** 서로 다른 3건이 `status=ok` 로 회장님 채팅방에 도달했다
(드리프트 `CE4FDEE2`, 헬스체크 사유A `8EB2EF9A`, 사유B `C63E5A76`). 로그의 "발송함"이 아니라
`schedule_history` 실행기록으로 판정했다.

## S — 상황
2026-08-25 조사에서 "실패가 조용해서 아무도 모른다" 유형 장애 4건이 드러났고, 그중 **알림 경로
자체가 배선되지 않은 것**이 이 태스크 범위였다. 트렌드 파이프라인 경고는 4개월째
(trend_data 마지막 유입 2026-04-30) 아무에게도 도달하지 않았다.

## C — 문제: 명세는 고장 3개였으나 실측은 6개였다

| # | 고장 지점 | 명세 | 실측 |
|---|---|---|---|
| ① | `COKACDIR_CHAT_ID`/`KEY` 미설정 → 발송 건너뜀 | 있음 | 재현됨 |
| ② | `run_trend_pipeline.sh` 가 없는 `--force-alert` 호출 | 있음 | 재현됨 |
| ③ | 드리프트 유닛 EnvironmentFile 미배선 | 있음 | 재현됨 |
| ④ | **`cokacdir --sendmsg` 플래그가 CLI 에 존재하지 않음** | **없음** | **신규 발견** |
| ⑤ | **`--at 10s` 를 cokacdir 가 거부** (`invalid --at value: 10s`) | **없음** | **신규 발견** |
| ⑥ | **동일 고장이 `daily_surge_detect.py` · `keyword_pool_refresh.py` 2곳 더** | **없음** | **신규 발견** |

★ ④⑤ 때문에 **명세대로 ①②③만 고쳤다면 알림은 여전히 0건 도달**이었다. 자격증명을 채워도
`--sendmsg` 는 `Unknown option`(exit 2), `--at 10s` 는 `invalid --at value` 로 각각 실패한다.

### 수정 전 재현 원문
```
# ① 
[WARNING] COKACDIR_CHAT_ID 또는 COKACDIR_KEY가 설정되지 않아 Telegram 알림을 건너뜁니다.
# ②
usage: daily_health_check.py [-h] [--dry-run]
daily_health_check.py: error: unrecognized arguments: --force-alert test
# ③
[drift-monitor] 알림 발송 건너뜀: CHAT_ID 또는 ANU_KEY_FILE 이 설정되지 않았습니다.
[drift-monitor] verdict=CRITICAL conditions=['DRIFT_UNDEPLOYED'] ... notified=False
# ⑤ (자격증명 배선 직후에도 여전히 실패)
[drift-monitor] 알림 발송 실패(exit=1): {"message":"invalid --at value: 10s","status":"error"}
```
`--at` 포맷 실측: `10s` ❌ · `30s` ❌ · `1m` ✅ · `2m` ✅ → **분 단위가 최소**.

## Q — 해법
발송 경로를 **단일 모듈 `server/scripts/alert_notify.py` (506행)** 로 통일해
"호출–정의 계약이 한 곳에서 일치"하게 만들었다. 정본 발송 형태는 저장소에 이미 있던
동작하는 관례(`ops/monitor/deploy_drift_check.py::notify()`)를 따랐다:
`cokacdir --cron <본문> --at 1m --chat <ID> --key-file <경로>`

- **자격증명**: 비-git 파일 `/home/jay/insuro-releases/shared/insuro-alerts.env` (0600).
  ★ **키 "값"이 아니라 키 파일 "경로"만** 담는다. 저장소에는 경로만 남는다.
  cron 은 로그인 셸을 상속하지 않으므로 `alert_notify` 가 **자체 로드**한다.
- **중복 억제**: 키 = `sha256(스크립트 + "|" + 사유)`, 하루 1회. 사유의 숫자·날짜는 `#` 로 접어
  매일 같은 실패가 1건이 되게 했다. **사유가 다르면 각각 발송**된다(새 실패 은폐 방지).
  발송 실패 시에는 억제 상태를 기록하지 않는다(실패를 "보냄"으로 적으면 재시도가 막힌다).
- **fail-closed**: 경보가 필요한데 발송 실패 시 **exit 3** + ERROR 로그. 조용한 성공 금지.
- **실행금지 가드**: 수신자가 사람이 아니라 **AI 에이전트**임이 실측으로 드러나(`--cron` 이
  본문을 프롬프트로 하는 에이전트를 스폰) 본문 맨 앞에 "[보고 전용 / 실행 금지]" 가드를 붙였다.
  3900 byte 예산 강제 시 **가드는 보존하고 본문만 자른다.**

## A — 검증: 명세 6개 항목 전부 실측

| # | 항목 | 결과 | 근거 |
|---|---|---|---|
| 1 | 수정 전 재현 | PASS | 위 재현 원문 3종 |
| 2 | **실제 텔레그램 수신** | **PASS** | `schedule_history` 3건 `status=ok` |
| 3 | 드리프트 CRITICAL 알림 도달 | PASS | `CE4FDEE2` status=ok, `notified=True` |
| 4 | 중복 억제 | PASS | 동일 사유 2회 → 1건만 등록, 다른 사유 → 각각 등록 |
| 5 | 민감정보 0 | PASS | 실제 발송 본문 1,573자 8종 패턴 스캔 0건 |
| 6 | 봉인(변이 실증) | PASS | 자격증명 제거 → exit 3 / 인자 정의 제거 → 4 FAIL |

### 2. 실제 수신 (판정 기준 = 로그 아닌 수신)
```
CE4FDEE2 (드리프트) status: ok | dur_ms: 69268
  → "🔴 배포 드리프트 경보 — verdict = CRITICAL ... A/B/C 커밋 표"
8EB2EF9A (사유A)   status: ok | dur_ms: 59563
  → "회장님, [보고 전용] 알림 하나 전달드립니다. 아누는 아무 조치도 취하지 않았습니다."
C63E5A76 (사유B)   status: ok | dur_ms: 69322
  → "## 📢 알림 전달 (보고 전용)"
```
★ 수신 에이전트가 **가드를 실제로 준수**했다("아무 조치도 취하지 않았습니다").

### 4. 중복 억제 실측
```
1회차 사유A → [INFO] 경보 발송 완료
2회차 사유A(숫자만 다름) → [INFO] 경보 중복 억제 (오늘 이미 발송됨)
3회차 사유B(다른 사유)   → [INFO] 경보 발송 완료
→ cron-list 등록 건수 = 2 (3 아님)
```

### 6. 봉인 변이 실증
```
자격증명 제거 → [ERROR] 경보 발송에 실패해 알림이 유실되었습니다 · 실측 exit=3 (기대 3)
--force-alert 인자 정의 삭제 → 4 failed, 77 passed
원복 → 81 passed, worktree clean
```

## 근본 원인 분석 (Why)

**1st Why — 왜 경고가 4개월간 도달하지 않았나?**
발송 함수가 자격증명 미설정 시 `log.warning` 만 남기고 `return` 했다. 호출자는 반환값을 보지
않았고 exit code 도 0이었다. 즉 **실패가 성공과 구별되지 않는 신호**를 냈다.

**2nd Why — 왜 아무도 그 사실을 몰랐나?**
알림 경로에 **자기 자신을 검증하는 수단이 없었다.** "경보가 도달했는가"를 묻는 주체가 없으니
경보 시스템의 고장은 정의상 조용하다. `--sendmsg` 라는 **존재하지 않는 플래그**가 코드에
남아 있었다는 사실 자체가, 이 경로가 **단 한 번도 실제로 실행된 적 없음**을 증명한다.

**3rd Why — 왜 그런 코드가 머지되었나?**
알림 코드가 "부수적 편의 기능"으로 취급되어 **실발송 검증 없이** 작성·리뷰되었다. 같은 패턴이
4개 파일(`daily_health_check` · `daily_surge_detect` · `keyword_pool_refresh` · 드리프트 유닛)에
복제되어 있었다 — 공용 발송 모듈이 없었기 때문에 각자 틀린 방식으로 각자 죽었다.

**대응**: 발송 경로를 단일 모듈로 통일하고, 발송 실패를 **exit 3 라는 시끄러운 신호**로 바꾸고,
자격증명 미설정 시 FAIL 하는 테스트를 변이로 실증해 봉인했다.

## 수정 파일별 검증 상태

| 파일 (절대경로) | 변경 | 검증 상태 |
|---|---|---|
| /home/jay/projects/InsuRo/server/scripts/alert_notify.py | 신규 506행 | verified |
| /home/jay/projects/InsuRo/server/scripts/daily_health_check.py | +195/-66 | verified |
| /home/jay/projects/InsuRo/server/scripts/run_trend_pipeline.sh | +39 | verified |
| /home/jay/projects/InsuRo/server/scripts/daily_surge_detect.py | +73/-13 | verified |
| /home/jay/projects/InsuRo/server/scripts/keyword_pool_refresh.py | +73/-13 | verified |
| /home/jay/projects/InsuRo/ops/monitor/deploy_drift_check.py | +5/-2 | verified |
| /home/jay/projects/InsuRo/ops/monitor/drift-monitor.conf | +4/-1 | verified |
| /home/jay/projects/InsuRo/server/tests/test_alert_notify.py | 신규 686행 | verified |

**저장소 밖(비-git, PR 에 포함되지 않음 · 이미 적용됨)**
- `/home/jay/insuro-releases/shared/insuro-alerts.env` — 신규 0600, 키 경로만
- `/home/jay/.config/systemd/user/insuro-drift-monitor.service` — `EnvironmentFile=` 배선
  (백업 `.bak-task3008`). ★ 저장소 템플릿 `ops/insuro-drift-monitor.service` 는 **허용 경로 밖이라
  수정하지 않았다** → 템플릿과 설치본이 이 1줄만큼 다르다(의도된 상태, ANU 판단 필요).

## L1 스모크테스트
- **서버 재시작**: 성공 — `systemctl --user daemon-reload` + `insuro-drift-monitor.service` 3회 기동
  (exit 1 = CRITICAL 정상 설계, exit code 의미 변경 없음)
- **API 응답 확인**: 해당없음 (HTTP API 무관 · 알림 배선 태스크)
- **실동작 확인**: pytest 아닌 **실제 CLI/systemd/텔레그램 왕복** — 수신 3건 `status=ok`
- **스크린샷**: 해당없음 (UI 변경 없음)

## 회귀
```
server/tests/test_alert_notify.py  →  81 passed
인접(alert/surge/keyword_pool/phase2/drift) → 177 passed, 2514 deselected
전체 server/tests/            → 2687 passed, 4 skipped (2:49)
```
회귀 실패 0건.

## trip-wire 5종 실측
- Critical7 = 0
- PII net-new = 0 (실제 발송 본문 스캔 0건, 저장소 키 값 grep 0파일)
- 회귀 실패 = 0
- forbidden_paths 침범 = 0 (`policy_analyzer`/`silson`/`main.py`/`src`/`supabase`/`extension`/`.github` diff 0건)
- nonce = task-3008 일치

## 범위 밖 — 보고만 (수정 안 함)
1. **트렌드 수집 0건의 원인** — 명세대로 손대지 않았다. 이제 이 경고는 **매일 오는 것이 정상**이다.
2. **`dispatch/anu_terminal_relay.py:27 DEFAULT_RELAY_AT = "30s"`** — ⑤와 같은 무효값이다.
   워크스페이스 코드라 이 태스크 범위 밖. **ANU 확인 필요**(릴레이 알림이 조용히 죽어 있을 수 있음).
3. **`exit=0` 인데 0건 수집인 조용한 성공**(트렌드 Step 1) — 별도 태스크.
4. **InsuWiki 백업 실패**(GCP 인증) — 범위 밖.

## ANU 판단 필요
- **머지 순서**: task-3005·task-3007 이 `server/tests/**` glob 을 공유한다(파일명은 겹치지 않음).
  파일 충돌은 없으나 머지는 순차로.
- **저장소 템플릿 vs 설치본 드리프트** 1줄 (위 참조).
- **범위 밖 ②** `DEFAULT_RELAY_AT="30s"` 후속 위임 여부.

## 미실행 / 한계
- `run_trend_pipeline.sh` **전체 실행 안 함** (DB·네이버 API 필요). `bash -n` + `notify_failure`
  격리 하네스 + 단위 테스트로만 검증했다. 실제 06:00 크론 1회전은 미검증이다.
- 이 PR 의 `ops/monitor/*` 수정분은 **머지 후 배포되어야** 설치된 드리프트 유닛에 반영된다.
  현재 라이브 동작은 EnvironmentFile 의 `NOTIFY_AT_DELAY=1m` 이 대신 보장하고 있다.

## PR
- **PR #252** https://github.com/Jeon-Jonghyuk/InsuRo/pull/252
- state=open · merged=false · changed_files=8 · +1485/-96
- head `0bdfa786` / base `edd7582f`
- blob 동일성 로컬 HEAD ↔ 원격 트리 **8/8 일치** (40-hex 선검사 통과 — 404==404 거짓 PASS 아님)
- ★ `FINALIZE_ONLY=1` 로 종결 — **봇 머지 미수행**. 머지 권한은 ANU.
- 게이트: QC 7 PASS / 13 SKIP / 3 WARN · scope-guard **PASS (8 files in scope)** · GIT-GATE PASS · MERGE-BASE PASS (edd7582f)
- 원격 반영 경로: 하네스가 CLI 직접 전송을 차단해 `gh api` git data API(blob→tree→commit→ref)를 사용했다.
- 타이머: `completed` · 33분 40초 (finish-task.sh 가 end 처리)

## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회

