# task-2993 — 미배포 상시 탐지 (머지됐으나 프로덕션에 없는 커밋 감시)

- **팀**: dev5-team (마르둑) · **레벨**: Lv.2 · **검증 레벨**: normal
- **저장소**: /home/jay/projects/InsuRo (외부 repo)
- **워크트리**: /home/jay/projects/InsuRo/.worktrees/task-2993-dev5
- **브랜치**: task/task-2993-dev5 (origin/main `d098708` 기준, 3 commits)
- **머지**: ANU 담당. 본 태스크는 **PR 생성까지**가 범위 — 직접 머지하지 않음.

**S**: 머지와 프로덕션 반영 사이의 갭을 아무도 감시하지 않아 16시간 미배포 사고가 발생했다.
**C**: 자동 배포는 범위 밖이며, 감시가 오히려 비통제 배포를 유발해선 안 된다.
**Q**: 머지됐으나 프로덕션에 반영되지 않은 상태를 어떻게 오탐 없이 상시 탐지하는가?
**A**: A(origin/main)/B(current release)/C(/api/status) 3자 비교 + 2단계 판정 + 중복 억제 감시를 신규 구축했다.

## S — 상황

"코드가 고쳐졌다"와 "프로덕션이 고쳐졌다"가 다른데 그 차이를 감시하는 장치가 없었다.
디스크는 새 커밋인데 구동 프로세스는 옛 커밋인 상태(B!=C)가 `Restart=always` 와 겹치면
크래시가 곧 비통제 배포 트리거가 된다. 실제로 금소법 직결 결함(실손계산기 과대추정)이
코드상 고쳐진 뒤에도 프로덕션에 반영되지 않은 채 남아 있었다.

## C — 복잡성

- **배포 마커 부재**: `scripts/deploy/insuro-deploy.sh` 에 `.deploying` 마커가 **존재하지 않음**(grep 0건 실측).
  해당 경로는 forbidden_paths 라 직접 수정 불가 → 제안만 가능.
- **검증 자체가 사고를 유발할 수 있음**: 태스크 원문이 제안한 "라이브 `current` symlink 전환" 검증법은
  `Restart=always` 하에서 크래시 시 승인 없는 배포를 일으킨다 — 막으려는 사고 그 자체.
- **알림 경로가 AI 에이전트를 구동함**: `cokacdir --cron` 의 인자는 단순 메시지가 아니라 **다른 AI 에게 주는 지시(PROMPT)** 다.

## Q — 질문

머지·배포·구동의 3지점 불일치를, 배포 중 오탐 없이, 알림 스팸 없이, 그리고
**감시가 스스로 배포를 유발하지 않으면서** 상시 탐지할 수 있는가?

## A — 답변 (구현)

### 1. 3자 비교
- **A** = `origin/main` 최신 sha (배포되어야 할 것)
- **B** = `current` symlink 가 가리키는 release sha (디스크가 준비한 것)
- **C** = `/api/status` 의 sha (실제로 돌고 있는 것)

`A != B` → `DRIFT_UNDEPLOYED` · `B != C` → `DRIFT_NOT_RESTARTED`(★ 사고 유형, **별도 탐지**).
둘은 동시 성립 가능하며 각각 `conditions` 에 개별 기록된다.
sha 비교는 **40자 hex 정규화 후에만** 수행하고, 정규화 불가 시 `UNKNOWN` — 접두 매칭으로 "같다"고 추정하지 않는다.

### 2. 2단계 판정
불일치 **최초 관측 시각**(파일 mtime·프로세스 시작시각이 아님)을 상태파일에 기록하고 경과를 계산한다.
경과 <= `GRACE_SEC`(기본 1800) → `WARNING` · 초과 → `CRITICAL`.
불일치 조합이 바뀌면 새 사건으로 보고 `first_seen` 을 리셋한다.

### 3. 배포 중 보류
1. `$RELEASES_ROOT/.deploying` 마커 지원 — **stale 방어** 포함(`DEPLOY_MARKER_MAX_AGE_SEC` 초과 시 무시).
   마커가 남은 채 죽으면 감시가 영구 무력화되므로 필수.
2. `$RELEASES_ROOT/releases/.tmp-*` — **기존 배포 스크립트가 수정 없이 이미 만드는 신호**를 채택.

### 4. 알림
`CRITICAL` 일 때만 발송. 지문 `(verdict, A, B, C)` 가 같으면 재발송하지 않고, OK 복귀 시 1회 알린 뒤 지문을 비운다.
**발송이 실제로 성공했을 때만 지문을 갱신**한다. `CHAT_ID`/`ANU_KEY_FILE` 미설정 시 발송을 건너뛰고 로그만 남긴다(크래시 없음).

### 5. 주기 실행
user 스코프 systemd timer, `OnUnitActiveSec=10min`, `Persistent=true`. **유닛 파일 산출만 하고 설치하지 않았다.**

## 수정 파일별 검증 상태

| 파일 | 변경 | grep 키워드 | 건수 | 상태 |
|---|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/ops/monitor/drift_rules.py | 신규 판정 순수함수 | DRIFT_NOT_RESTARTED | 5 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/ops/monitor/deploy_drift_check.py | 신규 수집기·진입점 | NOTIFY_MAX_BYTES | 2 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/ops/monitor/drift-monitor.conf | 신규 설정 | GRACE_SEC | 2 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/ops/insuro-drift-monitor.service | 신규 유닛 템플릿 | ExecStart | 6 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/ops/insuro-drift-monitor.timer | 신규 10분 타이머 | OnUnitActiveSec | 1 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/tests/test_drift_rules.py | 신규 pytest 44건 | SUPPRESSED_DEPLOYING | 3 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2993-dev5/docs/deploy/drift-monitor.md | 신규 문서·제안서 | 감시의 사각지대 | 2 | verified |

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 본 태스크는 **감시 전용**이며 재기동이 **금지 행위**다. 재시작 대신 라이브 무중단을 실측으로 증명했다(아래 표).
- **API 응답 확인**: `curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:8001/api/status` → **HTTP 200**
- **실제 실행**: 감시 스크립트를 실환경에 **읽기 전용으로 1회 실행**(팀장 직접):
  ```
  python3 ops/monitor/deploy_drift_check.py --dry-run --json --state-file /tmp/t2993-marduk-L1.json
  → verdict=WARNING, conditions=[DRIFT_UNDEPLOYED], exit=0
    a_sha=d09870893cbfdaa7bde17fe588ce6c196159928d
    b_sha=deeaf846fc313abb7b6068a824a78eba768efeba
    c_sha=deeaf846fc313abb7b6068a824a78eba768efeba
    should_notify=false, deploy_marker.marker_active=false, collection_errors={a:null,b:null,c:null}
  ```
  ★ 실환경에 **실제 A!=B 드리프트가 살아있어** 인위적 조작 없이 탐지가 실증됐다.
- **스크린샷**: 해당없음 (백엔드/ops 작업, UI 없음)

## 검증 6종 실측 (닌기르수)

라이브 변경 없이 격리 샌드박스(`/tmp/t2993-sandbox/`, 스텁 `/api/status` 포트 **18001**, 더미 git repo)에서 수행.

| 항목 | 결과 | 근거 |
|---|---|---|
| V1 정상 상태 오탐 0 | PASS | A=B=C → verdict=OK, conditions=[], should_notify=false |
| V2 B!=C 탐지 (사고 유형) | PASS | conditions=[DRIFT_NOT_RESTARTED] |
| V3 A!=B 탐지 | PASS | 샌드박스 + **라이브 읽기전용** 양쪽. 라이브 값이 기준값과 정확히 일치 |
| V4 유예 2단계 전이 | PASS | 경계값 정확히 1800→WARNING, 1801→CRITICAL |
| V5 배포 마커 보류 | PASS | (a) .deploying (b) releases/.tmp-* (c) stale 마커 무시 3경로 전부 |
| V6 중복 알림 억제 | PASS | 스텁 cokacdir 호출 로그: 1회차만 기록, 2회차 추가 없음. 지문 변경 시 재알림 |

## 서비스 무중단 증명

| 항목 | 작업 시작 전 | 작업 종료 후 | 동일 |
|---|---|---|---|
| MainPID | 212596 | 212596 | 예 |
| NRestarts | 1 | 1 | 예 |
| ActiveEnterTimestamp | Thu 2026-08-20 22:10:40 KST | Thu 2026-08-20 22:10:40 KST | 예 |
| /api/status started_at | 2026-08-20T13:10:42.720412+00:00 | 2026-08-20T13:10:42.720412+00:00 | 예 |
| /api/status sha | deeaf846fc313abb7b6068a824a78eba768efeba | deeaf846fc313abb7b6068a824a78eba768efeba | 예 |
| current symlink | releases/deeaf846fc313abb7b6068a824a78eba768efeba | releases/deeaf846fc313abb7b6068a824a78eba768efeba | 예 |
| releases 디렉토리 | deeaf846... 1개 | deeaf846... 1개 | 예 |

운영 상태파일 `/home/jay/insuro-releases/.monitor/` 는 **생성조차 되지 않았다**(오염 0).
`/tmp` 잔존물 0건. **라이브를 애초에 건드리지 않았으므로 원상복구할 대상이 없다.**

## 리뷰에서 잡은 결함 (팀장 → 엔키, 전부 해결됨)

| 등급 | 결함 | 조치 |
|---|---|---|
| P0 | 알림 문구가 `systemctl restart ~ 필요` 같은 **명령형**이었다. `cokacdir --cron` 인자는 AI 에이전트의 PROMPT 이므로 수신 에이전트가 이를 지시로 읽고 **실제로 재기동·배포할 수 있었다** — 본 태스크가 막으려던 사고 그 자체 | 메시지 최상단에 `[보고 전용 / 실행 금지]` 가드 삽입 + 해소방법을 서술형으로 전환 + 테스트로 고정. 해결 완료 |
| P1 | cron PROMPT 3900 bytes 초과 시 **알림이 조용히 사라짐** | `NOTIFY_MAX_BYTES=3900` 예산 가드. core(verdict·A/B/C) 보존 후 tail 절삭 + 생략 표시. 해결 완료 |
| P2 | 죽은 코드 `_DRIFT_CONDITION_NAMES` | 삭제. grep 0건 확인. 해결 완료 |
| P3 | `int - None` 타입 경고(오탐이나 향후 크래시 여지) | 명시적 좁히기. 동작 불변, 기존 테스트 전건 통과. 해결 완료 |
| P4 | systemd 유닛 `ExecStart` 가 **임시 워크트리 경로**를 가리켜, 워크트리 삭제 시 타이머가 **조용히 죽음** | 안정 경로로 교체 + 템플릿 관례 명시 + 문서에 설치 전 경로 검증 절차 추가. 해결 완료 |

엔키가 자체 발견해 수정한 결함 2건도 있다: (1) 발송 실패·미설정 상태에서도 알림 지문이 소비돼
설정을 채워도 영영 재알림이 안 가던 결함 → **발송 성공 시에만 지문 갱신**, (2) `--json --dry-run` 병용 시
미리보기가 stdout 에 섞여 JSON 이 깨지던 결함 → `notify_preview` 필드로 이동.

## 검증 방법에 대한 판단 (태스크 원문과 다르게 한 부분 — 명시)

태스크 원문 검증 2번은 "라이브 release 를 하나 더 만들고 `current` 만 바꾼 뒤 재기동하지 않는다"였다.
**이 방법을 쓰지 않았다.** `insuro-api.service` 는 `Restart=always` 이므로 라이브 `current` 를 바꿔둔 상태에서
프로세스가 크래시하면 **승인 없는 프로덕션 배포**가 발생한다 — 본 태스크가 막으려는 사고 유형이다.
태스크의 안전 규칙("배포·재기동·symlink 전환 절대 금지", "라이브 무중단")이 검증 레시피보다 우선한다고 판단해,
라이브와 동일 구조의 격리 샌드박스로 **동등한 증거**를 만들었다. A!=B 는 실환경에 이미 드리프트가
살아있어 읽기 전용으로 실증했다. **이 판단은 ANU 재검토 대상으로 남긴다.**

## 프론트(Cloudflare Pages) 조사 결과 — 조사만, 구현 없음

**태스크의 전제("확인 방법 미확정")와 달리, 확인 가능한 경로가 존재한다.**

- `wrangler pages deployment list --project-name=insuro` 의 **Source 컬럼이 실제 git commit hash**(7자 축약형)다.
  근거: 2026-08-20 08:12 wrangler 로그에 `Detected commit hash: 74679f2e2b5fd8a71081a00f3ff4e5d3ce2d4cb0` 와
  목록의 `74679f2` 가 일치. **단, 조사 세션에 토큰이 없어 직접 재현은 실패**했다(토큰은 `.env.keys` 에 존재, 값 미확인).
- `/version.json`·`/build-info.json` → SPA fallback(200 이지만 `text/html`) = **실질 부재**.
- `index.html` 의 `/assets/index-XXXX.js` 는 **Rollup 콘텐츠 해시이지 git sha 가 아니다** — sha 대용 불가.
- `cf-ray`/`etag` → 배포 식별에 사용 불가.

**미검증 사항(추론이며 실증 아님)**: "목록 최상단 = 현재 라이브"라는 전제. 로그에 롤백 이력 grep 0건이라
롤백 시 목록 순서 동작을 확인하지 못했다. 후속 태스크에서 반드시 검증해야 한다.
**주의**: 과거 배포가 `commitDirty: true`(워킹트리 dirty 상태 배포)였다 — sha 가 배포 내용과 100% 일치한다는 보장이 아니다.

## 배포 마커 제안 (ANU 승인 필요 — 직접 수정하지 않음)

`scripts/deploy/insuro-deploy.sh` 는 forbidden_paths 라 수정하지 않았다. 제안 내용:
배포 시작 시 `$RELEASES_ROOT/.deploying` 생성, `trap ... EXIT` 로 제거.
현재는 대체 신호(`releases/.tmp-*`)로 동작하므로 **이 제안 없이도 감시는 기능한다.**

## 알려진 한계 (정직하게 남김)

1. **감시의 감시가 없다.** 이 감시는 "조용하면 정상" 구조라 **감시가 죽어도 조용하다.**
   현재 확인 수단은 상태파일 `last_check_ts` 나이 확인과 `systemctl --user list-timers` 뿐이며,
   heartbeat 는 **미구현**이다. 문서 `## 감시의 사각지대` 절에 기록했다.
2. **불일치 내용이 바뀌면 `first_seen` 이 리셋**되어, 새 드리프트는 grace(1800초)를 다시 거친 뒤
   CRITICAL 이 된다. 알림 폭주 방지를 위한 의도된 디바운스이나, **새 사고 인지가 최대 30분 지연**될 수 있다.
3. **유닛 미설치.** timer/service 는 산출물로만 존재하며 설치되지 않았다. 머지 후 설치해야 실제 감시가 시작된다.
4. 프론트(CF Pages)는 **감시에 연결되지 않았다** — 조사 결과만 위에 기록했다.

## 3 Step Why

- **1st Why**: 왜 미배포가 16시간 방치됐나 → 머지와 프로덕션 반영 사이를 **사람 기억에 의존**했고 감시 장치가 없었다.
- **2nd Why**: 왜 두 값 비교가 아니라 3자 비교인가 → A/B 비교만으로는 **디스크는 새 것인데 프로세스는 옛 것(B!=C)** 을 놓친다.
  이 상태가 `Restart=always` 와 겹치면 크래시가 곧 배포가 된다. 실제 사고가 이 유형이었다.
- **3rd Why**: 이 설계가 실패할 수 있는 시나리오는 → (a) 감시 자체가 죽으면 조용하다(한계 1),
  (b) 배포 마커가 stale 로 남으면 영구 보류될 수 있다 → `DEPLOY_MARKER_MAX_AGE_SEC` 로 방어했다,
  (c) 알림 프롬프트가 수신 AI 를 움직여 비통제 배포를 유발할 수 있었다 → P0 로 잡아 가드를 넣었다.

## trip-wire 5종 (실측치)

| 항목 | 실측 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 (CHAT_ID/키 하드코딩 0건 — grep 실측, 설정 기본값은 빈 문자열) |
| 회귀 실패 | 0 (pytest 44 passed, 팀장 직접 재실행) |
| forbidden_paths 침범 | 0 (`git diff --name-only origin/main...HEAD -- scripts/ server/ src/ supabase/ .github/` → 빈 값) |
| nonce=task_id 일치 | task-2993 |

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 엔키 | 백엔드 구현 + P0~P4 수정 | sonnet | 코딩 작업 |
| 나부 | CF Pages 조사 | sonnet | 조사/분석이라 haiku 금지 규칙 적용 |
| 닌기르수 | 검증 6종 실측 | sonnet | 검증 작업 |
| 마르둑(팀장) | 설계·리뷰·통합·L1 | opus | 직접 코딩 0건. 결함 P0~P4 리뷰 검출 |

haiku 미사용.

## 완료 조건 대조

| 조건 | 결과 |
|---|---|
| 3자 비교 동작 | 충족 (V1~V3 + L1 실환경) |
| B!=C 탐지 실증 | 충족 (V2, 샌드박스) |
| 2단계 판정 | 충족 (V4, 경계값 1800/1801) |
| 중복 알림 없음 | 충족 (V6, 스텁 호출 로그) |
| 오탐 0 | 충족 (V1, V5 stale 방어) |
| 검증 후 원상복구 | 충족 — **라이브 미변경이라 복구 대상 없음** |
| 서비스 무중단 | 충족 (MainPID/NRestarts/started_at 전부 동일) |

## 후속 필요 (ANU 판단)

1. **PR 머지 + 유닛 설치** — 설치해야 실제 감시가 시작된다. 현재는 코드만 존재한다.
2. **실환경 A!=B 드리프트 해소** — `origin/main` `d0987089` 가 미배포 상태다(본 태스크는 감시만 하므로 배포하지 않았다).
3. **배포 마커 추가 승인** — `scripts/deploy/insuro-deploy.sh` 수정 건.
4. **CF Pages 감시 연결** — 조사 완료. "최신=라이브" 전제 검증이 선결 과제다.
5. **heartbeat(감시의 감시)** — 현재 최대 사각지대.
6. **검증 방법 대체 판단 재검토** — 위 "검증 방법에 대한 판단" 절.

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


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

