# Implementation Report: 앱 푸시 딥링크를 almType 기반으로 정정

## Summary

`FcmService.sendPush` 가 `alm_id` 로 `deep_link` 를 파생하던 동작을 지우고, 호출부가 자기가 아는
목적지를 `SendPushInput.deepLink` 로 명시 전달하도록 바꿨다. `deepLink` 는 옵셔널이 아니라 필수
필드라 누락 호출부는 타입 검사가 잡는다. 알람타입 → 인앱 경로 매핑은 `alarm-deeplink.util.ts`
순수함수로 분리하고 단위테스트로 고정했다. 팩스(SND003) 목적지는 웹에 라우트가 없는
`/mypage/fax-history` 에서 `/invoices/list` 로 정정했다. wire 필드명은 앱이 읽는 계약이라
하나도 바꾸지 않았다.

## Assessment vs Reality

| Metric | Predicted (Plan) | Actual |
|---|---|---|
| Complexity | Small | Small |
| Confidence | — | 서버 축 높음 / 앱 소비 필드는 미판별 (Task 6 미수행) |
| Files Changed | 7 (서브모듈 6 + 부모 fixture 1) | 7 (동일) |

## Tasks Completed

| # | Task | Status | Notes |
|---|---|---|---|
| 0 | 서브모듈 base 를 `origin/stg` 로 고정 | 완료 | 진입 시 이미 `fix/push-deeplink-almtype` @ `340f6d3f` 였다 (선행 세션) |
| 0b | 타입 진단 기준선 golden 동결 | 완료 | 손대지 않은 트리에서 생성. `# base: 340f6d3f…` provenance 포함, 19행 |
| 1 | 딥링크 매핑 순수함수 신설 | 완료 | `resolveAlarmDeepLink(almType, almMessage)` |
| 2 | 매핑 단위테스트 작성 | 완료 | 7 케이스 (계획 5 + 상대경로 불변식 1 + enum 정본 대조 1) |
| 3 | `SendPushInput.deepLink` 필수화 + 파생 삭제 | 완료 | |
| 4 | alarm.service 명시 전달 | 완료 | 중첩 삼항 → util 호출 1줄, 팩스 경로 정정 |
| 5 | push.service(SND004) 명시 전달 | 완료 | `push.pshUrl \|\| '/alarm/detail/' + pshId` |
| 6 | 앱이 어느 필드를 읽는지 판별 실험 | **미수행 (차단)** | 실기기에서 푸시를 눌러 관찰해야 한다. 이 세션에서 수행 불가 — 아래 §미수행 참조 |

## Validation Results

| Level | Status | Notes |
|---|---|---|
| Static Analysis | 통과 | `tsc --noEmit` golden 대비 신규 0건 · `eslint` 0건 |
| Unit Tests | 통과 | 신규 6 tests. 인접 모듈 포함 22 tests 전부 통과 |
| Build | 통과 | `npm run build` (nest build) exit 0 |
| Integration | N/A | 서버 기동 통합 테스트 없음 |
| Edge Cases | 통과 | 미지 타입 fallback · 절대 URL 미생성 불변식 |

계획서 Validation 블록 전량 실행: `ALL GATES PASS` (exit 0).

```
gold_base=340f6d3f… poison=0 not_compiled=0 tsc_exit=1 total_ts=6 baseline_ts=6 new_ts=0
routes_checked=5 missing=0
dead_fax_path_files=0
push_deeplink_refs=1
callsites=2 no_deeplink=0
submodule branch=fix/push-deeplink-almtype merge_base=340f6d3f… origin_stg=340f6d3f…
dirty=0 changed=6 outside=0
```

### 반사실 검증 (흡수·게이트 각 건)

| 대상 | 반사실 | 결과 |
|---|---|---|
| 게이트 `#3` 강제력 | 두 호출부를 되돌리고 `tsc` | `new_ts=2`, `alarm.service.ts(106,38)` · `push.service.ts(123,44)` 로 파일명 무관 검출 |
| 게이트 `#3` 커버리지 대조 | `TARGETS` 에 없는 파일 추가 | `NOT COMPILED: ghost-nonexistent.ts` → `FAIL #3` |
| 게이트 `#5` | util 에 죽은 팩스 경로 재도입 | `dead_fax_path_files=1` → `FAIL #5` |

### Design Grounding

N/A — `impeccable-detect` `design_signal=false` (`silent_skip_reason=no-signal`). 렌더링 표면 변경 0건(서버 단독).
receipt 에 `impeccable_silent_skip=true` 로 기록했다.

## Files Changed

| File | Action | Lines |
|---|---|---|
| `.claude/plans/fixtures/tsc-baseline-origin-stg.txt` | CREATED | +20 |
| `insuniverse-server/apps/api/src/modules/alarm/alarm-deeplink.util.ts` | CREATED | +38 |
| `insuniverse-server/apps/api/src/modules/alarm/alarm-deeplink.util.spec.ts` | CREATED | +64 |
| `insuniverse-server/apps/api/src/modules/fcm/fcm.type.ts` | UPDATED | +5 |
| `insuniverse-server/apps/api/src/modules/fcm/fcm.service.ts` | UPDATED | +9 / -6 |
| `insuniverse-server/apps/api/src/modules/alarm/alarm.service.ts` | UPDATED | +5 / -12 |
| `insuniverse-server/apps/api/src/modules/push/push.service.ts` | UPDATED | +2 |

서브모듈 커밋 `abd50837` + `c593d9c3`(D4 코드리뷰 수용). **부모 저장소의 서브모듈 포인터는 올리지 않았다**(INV2 · Task 0) —
`git status` 의 ` M insuniverse-server` 는 스테이지하지 않는다.

## Deviations from Plan

### D1 — 계획서 Validation 블록의 셸 결함 4건을 수정했다 (층위 4·5)

`#3` 의 컴파일 커버리지 대조가 **어떤 입력으로도 통과할 수 없는** 상태였다.

- 원인: `printf … | grep -q` 에서 `grep -q` 가 첫 매치에 종료 → 앞단이 SIGPIPE(141) → `set -o pipefail`
  이 141 을 파이프라인 종료코드로 올림 → `if !` 가 뒤집어 **실재하는 파일을 `NOT COMPILED` 로 보고**.
- 실측: `printf '%s\n' "$FILES_OUT" | grep -qF fcm.type.ts` → `rc=141` (파일은 `--listFilesOnly` 출력에 실재, 3151행).
  입력이 작으면 SIGPIPE 가 안 나서 저작 시점에는 보이지 않는다.
- 수정: 파이프를 히어스트링으로 교체 — `#3`(커버리지) · `#7`(호출부 스윕) · `#9`(allowlist) · `#1`(jest 실패 판정) 4곳.
  `#1` 은 뒤집히면 **테스트 실패를 통과로 읽는** 자리라 함께 고쳤다.
- `#5` 는 다른 부류: `grep -rl` 이 0건에 exit 1 이라 `pipefail` 이 스크립트를 죽였다 —
  **수정이 성공했을 때만 실패하는** 게이트였다. `| grep -c . || true` 로 카운트를 받도록 교체.

### D2 — 단위테스트를 계획의 5 케이스에서 6 케이스로 늘렸다 (D4 에서 7 케이스가 됐다)

`반환값은 항상 인앱 상대경로다` 불변식 1건 추가. util 헤더 주석이 "절대 URL 을 만들지 않는다" 를
주장하는데 대응 검사가 없었다(층위 2).

### D3 — `## Codex Implementation Review` 를 계획서가 아니라 `.claude/notes/` 에 적었다

계획서 본문을 고치면 `plan_hash` 가 바뀌어 `mccp-plan-codex` receipt 가 stale 로 떨어진다(저장소 실측).

### D4 — 코드리뷰(`/mccp:code-review` 로컬) 지적 1건을 수용해 유틸 시그니처를 좁혔다 (층위 2)

지적: `almType: AlarmTypeValue | string` 에서 `| string` 이 union 을 `string` 으로 접어
**타입이 아무것도 강제하지 못한다**. `AlarmTypeValue` 는 어디서도 쓰이지 않는 dead export 였고,
`SND00x` 문자열이 `alarm.entity` 의 `AlarmType` 과 중복돼 헤더 주석의 "동일 문자열" 주장에
대응 검사가 없었다(층위 2). 실측 보강: `alimtalk.entity.ts:13~18` 이 같은 문자열을 다른 의미
(`SND003=충전` · `SND004=실손추가`)로 쓰므로 넓은 `string` 은 오배송 표면이다.

- `AlarmTypeValue` 삭제, 시그니처를 `almType: AlarmType` 으로 좁힘. 분기 비교도 enum 멤버로 교체.
- 실측 호출부 5곳(`fax` · `order-detail` · `subscribe`x2 · `loss-analysis`)이 이미 전부
  `AlarmType.*` 를 넘기고 있어 호출부 변경 0건.
- 주석 주장에 대응하는 검사 1건 추가 — enum 멤버 호출과 리터럴 캐스팅 호출의 반환이 같은지 대조.
  enum 값이 바뀌면 이 검사가 먼저 깨진다.
- 서브모듈 커밋 `c593d9c3`. 재검증: 계획서 Validation 전량 `ALL GATES PASS`(exit 0),
  `tsc` golden 대비 신규 0건(19건 baseline 과 **완전 동일**), `eslint` 0건, `jest` 7/7, `nest build` exit 0.

### D5 — `docs/_meta/verify/verify-policy-values.mjs` 의 ` M` 는 이 작업 산출물이 아니다 (분리)

같은 코드리뷰가 부모 저장소 워킹트리의 969행 churn 을 지적했다. 원인 확정: `.gitattributes:27` 이
`eol=lf` 를 선언했는데 **커밋된 blob 이 CRLF** 라(30개 추적 파일 전건) mtime 이 건드려지는 순간
영구 ` M` 이 된다. `git checkout HEAD --` 로도 CRLF 로 복원돼 되돌릴 수 없고, `git add` 하면
blob 이 LF 로 정규화돼 969행 진짜 diff 가 스테이지된다(실측: staged CR 0). 이번 마일스톤 범위 밖이라
스테이지하지 않고 `.claude/plans/codex-findings-backlog.md` 2026-08-20 MEDIUM 행 ·
`.claude/state/fix-task.md` 에 기록한 뒤 분리했다(전역 지시 2026-08-18).


## Issues Encountered

### I1 — `mccp-plan-codex` receipt 가 stale 이다 (조치 필요)

D1 이 계획서 본문을 고쳤으므로 `plan_hash` 가 `3cd8f4a1…` → `260fdeab…` 로 바뀌었다.
`mccp-implement-codex` receipt 는 재발급했고(미추적이라 제자리 가능), `mccp-plan-codex` 는
CLI write 가 intent 게이트로 막혀 있어 **`/mccp:plan` 재실행으로만** 재봉인된다.
`/mccp:pr` 는 이 stale 1건으로 HALT 한다.

### I2 — mccp `plan-conflict-detector` 가 상시 오탐 (범위 밖, 분리하고 진행)

`file-expansion` 판정이 계획서 정의 7파일과 실제 변경 7파일이 **동일한데도**
`7 unplanned` 를 냈다. 원인은 `parsePlanFilesToChange` 가 `File` 열의 마크다운 링크만 벗기고
백틱 코드스팬을 안 벗기는 것. 반사실로 확정: 같은 경로를 백틱으로 감싸 넘기면 `conflict:false`.
영향 반경 전수: 이 저장소 계획서 7/7 이 해당한다. B1(mccp 파일 미수정)이라 로컬 수정 불가 —
`.claude/plans/codex-findings-backlog.md` 2026-08-20 HIGH 행 · `.claude/state/fix-task.md` 에 기록하고
이 축을 게이트에서 분리했다. 실제 파일 확장 여부는 계획서 자체 게이트 `#9` 가 독립 검사해
`outside=0` 으로 통과했다.

## 미수행 — Task 6 (앱 소비 필드 판별)

실기기에서 `POST /alarm/test` 로 `deep_link` 와 `alm_id` 를 어긋나게 실은 판별 payload 를 보내고
background 탭이 어느 화면을 여는지 **사람이 눈으로 봐야** 하는 항목이다. 이 세션에서 수행 불가.
계획서에 결과를 지어내 적지 않았다(`판별 결과:` 행 부재 = Task 6 미완의 정직한 상태).

이 수정은 (a) 앱이 `deep_link` 를 소비하는 경우에만 팝업을 없앤다. (b) `alm_id`/`action_type` 으로
경로를 조립한다면 두 번째 선택지(`actionType: 'link'` + 절대 `redirectUri`)로 넘어가야 하고,
(c) 둘 다 아니면 앱 릴리스가 필요하다 — 계획 범위 밖.

## Tests Written

| Test File | Tests | Coverage |
|---|---|---|
| `insuniverse-server/apps/api/src/modules/alarm/alarm-deeplink.util.spec.ts` | 7 tests | SND001 · SND002 일반/실손 · SND003 · enum 밖 코드 · **enum 정본 대조** · 상대경로 불변식 |

## Next Steps

- [ ] `/mccp:plan .claude/plans/push-deeplink-almtype.plan.md` 재실행 — plan-codex receipt 재봉인 (I1)
- [ ] Task 6 판별 실험 — 실기기 필요 (사용자)
- [ ] 배포 전 필수: 실기기 background 푸시 2종(분석 SND002 · 이벤트 SND004) 확인
- [ ] `insuniverse-server` 저장소에 `fix/push-deeplink-almtype` → `stg` PR
