# Implementation Report: 운영 알림 관리 화면 리디자인 + 규칙별 테스트 발송 depth

- **Plan**: `.claude/plans/alert-console-redesign.plan.md` (제자리 유지 — 아카이브는 `/mccp:archive-complete` 몫)
- **Gate**: `.claude/plans/alert-console-redesign.gate.sh` — **18/18 통과** (`GATE_EXIT=0`, 2026-08-20)
- **Branch**: `feat/monitoring-alert` (worktree `.worktrees/monitoring-alert`)
- **Decision**: `alert-console-redesign` · `plan_hash sha256:053fcfe7…9cd2ab`

## Summary

관리자 콘솔의 운영 알림 화면 2종이 인라인 `React.CSSProperties` 로만 조판돼 있어 토큰·브레드크럼·
표 폭 규약이 전무했다. 같은 콘솔의 `/subscription/treat-monitor` 구조를 `al-` 접두로 1:1 승계해
전용 CSS 로 전면 이관하고, "테스트 발송" 을 `/site/alert/test` 로 한 depth 내려 **알림 종류 6종을
개별로** 발송할 수 있게 했다. 그 발송이 실제 채널 라우팅(`alc_types`)을 그대로 타도록 서버에
`AlertService.sendTest()` 를 신설하고 `ruleId` · `channelHint` 두 축을 추가했다.

부수 효과로 **G11 이 닫혔다** — 컨트롤러가 `this.alert.send()` 를 직접 부르느라 테스트 메시지에만
빠져 있던 `[dev]` 환경 라벨이 붙는다. 런타임으로 관측했다(아래 §Validation).

## Assessment vs Reality

| Metric | Predicted (Plan) | Actual |
|---|---|---|
| Complexity | Medium | Medium — 서버 계약 축(제외 사유 8종 · 브랜드 타입)이 예상보다 무거웠다 |
| Files Changed | 18 (CREATE 6 / UPDATE 12) | 18 (CREATE 6 / UPDATE 12) — 일치 |
| Gates | 18 | 18/18 pass |

## Tasks Completed

| # | Task | Status | Notes |
|---|---|---|---|
| 1 | 공용 스타일시트와 토큰 | 완료 | `--al-*` 9종이 `--tm-*` 와 이름·값 문자 단위 동일(게이트 `#11` `missing=[] extra=[] bad=[]`) |
| 2 | 브레드크럼 컴포넌트 | 완료 | `aria-label="운영 알림 이동경로"` — 상위 빈 `breadcrumb` 랜드마크와 구별 |
| 3 | 서버 — 규칙별 테스트 발송 | 완료 | `sendTest()` public · 계약 5종 + 전용 spec 1종 |
| 4 | 클라이언트 API 축 추가 | 완료 | `excluded: {channelId: number｜null, reason}[]` |
| 4b | 테스트 대상 열거를 서버가 준다 | 완료 | `GET /rules` 에 `testTargets` additive |
| 5 | 테스트 발송 화면 신설 | 완료 | 12행(규칙 6 · 벤더 차원 2종이 4행씩) |
| 6 | 설정 화면 이관 | 완료 | `maxWidth: 960` 제거 · `.al-form` 2열 그리드 |
| 7 | 자기감시 화면 이관 | 완료 | `BANNER` 는 `Record<AlertOverall, …>` 유지(D9) |
| 8 | 실측 확인 | 완료 | `.claude/notes/alert-console-redesign-render.md` — 1440·768 두 폭 |

## Validation Results

| Level | Status | Notes |
|---|---|---|
| Static Analysis | Pass | admin `tsc --noEmit` exit 0 (게이트 `#6`) · 서버 `tsc` 알림 모듈 error **0행** (게이트 `#18`) |
| Unit Tests | Pass | `jest apps/api/src/modules/alert` — **suites 17 / tests 336 / passed 336 / pending 0 / todo 0** |
| Build | Pass | `nest build` exit 0 · `vite build` exit 0 (3572 modules, 1m33s) |
| Integration | Pass | API 7200 + admin 5173 기동, 로그인 상태로 화면 3종 실제 렌더 |
| Edge Cases | Pass | 제외 사유 8종 계약 · 라벨 fail-closed · `unknown-hint` 사전 차단 · 미등록 `ruleId` 거부 |

**lint 은 게이트에 없다** — admin `npm run lint` 가 사전 파손 상태라 계획서 §Risks 가 제외했다.
서버 `tsc` 도 전체 0건이 아니라 알림 모듈 0건을 요구한다 — 비알림 사전 파손 **19행**
(`agency` · `medication-standard` · `hyphen` · `octover` spec 4파일)이 실재하며 게이트가 그
제외 구간을 출력에 적는다.

### 안전 계약 (게이트 `#13` — 필터 없이 실행 후 개별 단언의 `title` 로 판독)

| 계약 | total | passed |
|---|---|---|
| `sendTest 계약: channelId 교집합` | 1 | 1 |
| `sendTest 계약: 라벨 fail-closed` | 1 | 1 |
| `sendTest 계약: 제외 사유 코드 8종` | 1 | 1 |
| `sendTest 계약: 미등록 ruleId 거부` | 1 | 1 |
| `sendTest 계약: unknown-hint 사전 차단` | 1 | 1 |
| `sendTest 계약: 목적지 URL 정확 일치` (전용 spec) | 5 | 5 |
| `testTargets 계약: legacyGlobal 원문 미노출` | 2 | 2 |

게이트가 스스로 적는 사각지대: `sendtest-contract blind-spot=expected-values-unverified` —
기대값 **자체**가 옳은지는 이 검사가 못 본다.

### Design Grounding (v1.18.22)

| Field | Value |
|---|---|
| Verdict | `anchor_clean` |
| Mode | `enforce` |
| Rendered delta | yes — `.tsx` · `.css` 실재 (post-EXECUTE 재계산) |
| Advisories | 없음 |

Phase 2.5.5c 의 capture 는 상위 저장소 diff 만 봐서 서브모듈 내용이 안 잡힌다. Phase 3.7 에서
두 서브모듈의 `git diff` + untracked 를 합쳐 lint 했고, `hasRenderedDiff=true` · 위반 0건이었다.
receipt 에 `design_grounding_verdict=anchor_clean` 으로 restamp 했다.

### Design Finish (simplify + polish)

`polish` 만 돌렸다 — 계획서 §Design Routing Guide 가 "이 작업에서 실제로 쓸 것은 `layout` →
`polish` 두 개다. 나머지는 승계한 세계를 바꾸는 명령이라 UI2 와 충돌한다" 로 이미 판정했다.
`clarify` · `distill` 은 그 판정에 따라 **의도적으로 건너뛴다**(Phase 3.6 기본 절차와의 차이를
숨기지 않는다).

발견 1건, 적용 1건:

- **키보드 포커스 링 부재 (적용)**. `.al-btn` · `.al-input` · `.al-crumb a` · `<summary>` ·
  폼 입력에 `:focus-visible` 이 없었다. DESIGN.md 가 "새로 만드는 인터랙티브 요소에
  `:focus-visible` 아웃라인을 직접 준다" 를 Do 로, "Bootstrap 기본 포커스 링에 의존하지 않는다 —
  AdminLTE 가 `.btn:focus` 를 폭 0 으로 박아놨다" 를 Don't 로 명시한다. 같은 `/site/` 도메인의
  `TreatProviderPage.css` 71~74행 선례(`outline: 2px solid #475569; outline-offset: 2px`)를
  그대로 적용했다. 새 규약이 아니라 그 선례의 적용이다.

`detect.mjs` 를 범위 없이 돌리면 알림 디렉터리에 3건이 나오지만, 승계 정본
`TreatMonitorPage.css` 를 같은 방식으로 재면 같은 부류로 38건이다 — 승계한 세계의 성질이며
고치면 UI2 를 깬다. 고치지 않았고 그 대조를 렌더 노트에 숫자로 남겼다.

## Files Changed

### insuniverse-admin

| File | Action | Lines |
|---|---|---|
| `src/pages/Admin/Site/Alert/AlertPages.css` | CREATE | +445 |
| `src/pages/Admin/Site/Alert/AlertTestPage.tsx` | CREATE | +277 |
| `src/pages/Admin/Site/Alert/AlertBreadcrumb.tsx` | CREATE | +36 |
| `src/pages/Admin/Site/Alert/AlertMonitorPage.tsx` | UPDATE | +205 / -213 |
| `src/pages/Admin/Site/Alert/AlertSettingPage.tsx` | UPDATE | +196 / -257 |
| `src/api/alertApi.ts` | UPDATE | +71 / -13 |
| `src/app/router/routes/domains/site.routes.tsx` | UPDATE | +9 / -2 |

### insuniverse-server

| File | Action | Lines |
|---|---|---|
| `apps/api/src/modules/alert/alert.service.ts` | UPDATE | +236 / -0 |
| `apps/api/src/modules/alert/alert.service.sendtest-destinations.spec.ts` | CREATE | +175 |
| `apps/api/src/modules/alert/rules/vendor-targets.ts` | CREATE | +119 |
| `apps/api/src/modules/alert/alert.service.spec.ts` | UPDATE | +114 / -1 |
| `apps/api/src/modules/alert/alert-admin.controller.spec.ts` | UPDATE | +69 / -0 |
| `apps/api/src/modules/alert/alert.dto.ts` | UPDATE | +36 / -2 |
| `apps/api/src/modules/alert/alert-admin.controller.ts` | UPDATE | +34 / -28 |
| `apps/api/src/modules/alert/alert-url.brand-guard.ts` | CREATE | +34 |
| `apps/api/src/modules/alert/alert-url.ts` | UPDATE | +10 / -5 |
| `apps/api/src/modules/alert/rules/treat-monitor.rule.ts` | UPDATE | +8 / -12 |
| `apps/api/src/modules/alert/rules/treat-vendor.rule.ts` | UPDATE | +6 / -15 |

### 저장소 산출물

| File | Action | Why |
|---|---|---|
| `.claude/notes/alert-console-redesign-render.md` | CREATE | Task 8 렌더 실측 — 게이트 `#10` `#14` 의 입력 |
| `.claude/plans/alert-console-redesign.gate.sh` | UPDATE | 게이트 `#18` 종료코드 가드 수정 (아래 §Deviations) |
| `.claude/PRPs/reports/alert-console-redesign-report.md` | CREATE | 이 문서 |

## Deviations from Plan

**1. 게이트 `#18` 의 tsc 종료코드 가드가 뒤집혀 있었다 — 스크립트를 고쳤다.**

`gate.sh` 의 `#18` 이 `case "$STSC_EXIT" in 0|1) ;; *) 실행 실패 ;;` 로 돼 있었다. 그런데
TypeScript 의 `ExitStatus` 는 **0 성공 · 2 진단 있음 · 1 은 tsconfig 를 못 읽는 등 소스 도달 전
실패**다. 이 저장소는 비알림 사전 파손 19행이 있어 `tsc` 가 항상 **2** 를 내므로, 이 가드는
**어떤 구현도 통과시킬 수 없는 상태**였다(층위 5 — 게이트 실행 불가).

반사실 3방향으로 실측하고(2026-08-20 · tsc 5.5.4):

| 입력 | 종료코드 |
|---|---|
| `export const a: number = 1` | 0 |
| `export const b: number = "x"` | **2** |
| `-p nope.json` (TS5058) | **1** |

`0\|1\|2` 를 받도록 고치고, R15 F3 이 겨냥한 "tsc 가 아예 못 돈 것을 `alert-errors=0` 으로
읽는 것" 은 그 아래 가드를 `"$STSC_EXIT" != "0" && STSC_ALL == 0` 으로 넓혀 그대로 막았다.
수정 후 반사실도 돌렸다 — 컨트롤러에 `urlMasked: t.legacyGlobal.url` 을 넣으면
`alert-errors=1` · `TS2322` · exit 1 이고, 되돌리면 `alert-errors=0` · exit 0 이다.

계획서 본문(`## Validation` 의 `#18` 선언)은 종료코드를 언급하지 않는다 — 잘못된 전제는
스크립트 주석에만 있었다. 따라서 이 수정은 `plan_hash` 를 바꾸지 않고 receipt 체인도 건드리지
않는다.

**2. `AlertMonitorPage.tsx` 의 주석이 게이트 `#12` 를 스스로 떨어뜨렸다 — 주석을 고쳤다.**

D9 의 반례를 그대로 인용한 주석이 `al-banner-${` 리터럴을 담고 있어 `#12`(조합 0건)가 `tpl=1`
로 떨어졌다. 코드에는 조합이 없다. 반례 문자열 없이 같은 뜻을 적고, **왜 그 문자열을 안 적는지**
를 주석에 남겼다. 수정 후 `rec=2 tpl=0`.

**3. Phase 3.6 의 `clarify`/`distill` 미실행.** 위 §Design Finish 참조 — 계획서가 UI2 충돌로
이미 배제한 명령이다.

## Issues Encountered

- **Redis 6380 미기동으로 게이트 `#7` 이 먼저 멈춘다.** CI 규약(`.github/workflows/api.yml` 이
  `6380:6379` 서비스 컨테이너)이라 로컬에서 `redis-server --port 6380` 을 따로 띄웠다. 게이트가
  그 부재를 jest 연결 오류가 아니라 자기 메시지로 먼저 잡는다.
- **DB 터널(`127.0.0.1:13306`)이 없으면 Task 8 이 통째로 불가능하다.** 사용자가 올린 뒤 진행했다.
- **실 Google Chat 발송.** Task 8 (d) 는 실제 메시지를 낸다. 활성 채널 2종이 전부 실 Google Chat
  이고 `alc_types=null` 이라 규칙 1종 테스트가 메시지 2건을 낸다. 사용자에게 그 사실을 먼저
  숫자로 보이고 승인을 받은 뒤 `treat-vendor` 벤더 행 1건만 눌렀다.

## Tests Written

| Test File | Tests | Coverage |
|---|---|---|
| `alert.service.spec.ts` | +5 계약 | `channelId` 교집합 · 라벨 fail-closed · 제외 사유 8종 · 미등록 `ruleId` 거부 · `unknown-hint` 사전 차단 |
| `alert.service.sendtest-destinations.spec.ts` | 5 | 목적지 URL 정확 일치 (`toEqual` + `toHaveBeenCalledTimes`) — 전용 파일 |
| `alert-admin.controller.spec.ts` | +2 계약 | `testTargets` 의 `legacyGlobal` 원문 URL 미노출 (직렬화된 응답 단언) |
| `alert-url.brand-guard.ts` | (타입 수준) | `MaskedUrl` 브랜드 음성 테스트 — 판정 주체가 컴파일러 |

`treat-monitor.rule.spec.ts` · `treat-vendor.rule.spec.ts` 는 **한 줄도 안 고쳤다** — 게이트
`#16`(e) 가 `git hash-object` 정규화 blob id 동결값으로 확인한다
(`monitor=d7e3e070…` · `vendor=0fa57591…`).

## Next Steps

- [ ] `/mccp:pr` — 다만 implement receipt 가 `security_skipped=true` (Agent tool 금지 세션에서
      `security-reviewer` 미기동) 이고 `codex_verdict=divergent` 라 검증기가 non-approving 으로
      읽는다. PR 전에 그 두 축을 어떻게 처리할지 결정이 필요하다.
- [ ] 서브모듈 2종 커밋 → 상위 저장소 gitlink bump. INV2(main 포인터 동결)에 걸리므로 `main` 에
      밀지 않는다.
- [ ] backlog 1건 미해소 — `destinationsFor` 단일 trace 재구성 (MEDIUM,
      `.claude/plans/codex-findings-backlog.md`)
