# 구현 노트 — drug-diag-api 마일스톤 4 (소비자 확장 · 정회원 축)

**Plan**: `.claude/plans/drug-diag-api-m4.plan.md`
**Decision slug**: `drug-diag-api-m4`

> 이 파일이 구현 리뷰의 소재지다. 계획서 본문에 쓰면 `plan_hash` 가 움직여
> `mccp-plan-codex` receipt 가 stale 로 떨어지고 `/mccp:pr` 이 막힌다. 마일스톤 1·2·3 도 같다.

## Codex Implementation Review

> Codex skipped per `MCCP_CODEX_DISABLED=1` (env-level policy)

- 호출: `node C:/Users/madsc/.claude/plugins/cache/mccp/mccp/1.33.1/scripts/lib/codex-invoke.js adversarial-review`
  (fail-closed Bash wrapper). `codex-policy seal` 이 `codex_disabled=true` 를 봉인했고
  (`.git/worktrees/drug-diag-api/mccp/tmp/codex-policy.json`) wrapper 가 spawn 직전에 그것을 읽어
  `classification=disabled` 로 short-circuit 한다. 게이트는 이 변수를 해제하지 않는다 —
  영구 운영자 정책이다.
- 라운드 수: 0 — Codex 가 돌지 않았다.
- 합치 결론: **적대 리뷰 없음.** 구현 시점 결정에 대한 외부 모델 반박이 이번 창에 없다.
- YAGNI Triage: 해당 없음 — finding 0건.
- Deferred to backlog: 0
- Open Questions: 없음
- Codex session 참조: 없음 — 호출 자체가 없었다. receipt 의 `codex_verdict` 는 `skipped` 다.

### 교차 게이트 dedupe 를 적용하지 않은 이유

2.5.1 의 dedupe 는 계획 리뷰가 결정 집합에 대해 **수렴했을 때**만 성립한다. 이 창의
`mccp-plan-codex` receipt 는 `codex_verdict: skipped` 이며 dedupe 는 `converged` 이외의 값에
fail-close 한다. 그래서 2.5.2~2.5.4 를 정상 경로로 통과시켰고 결과가 위와 같다.

### 이 창의 구현 시점 결정 (Codex 가 반박했어야 할 목록)

계획이 미리 정하지 않은 자리이며 저자가 정했다. 외부 반박이 없으므로 목록으로 남긴다.

1. **하네스의 앞 창 호출은 `spawnSync(process.execPath, …)` 다.** `npx` 는 이 저장소의
   Windows 하네스에서 rc=127 이다(m3 D3 의 실측). 앞 창 게이트도 `.mjs` 이므로 node 로 직접 부른다.
2. **`#4` 의 앞 창 재실행은 검사 단위 자식 프로세스다.** `check-all` 을 부르면 이 창이 넘겨받지
   않은 축까지 돌아 비용이 중첩되고(앞 창 하네스는 분 단위다), 붉은 축 하나가 전체 rc 를 삼켜
   문면 대조가 불가능해진다.
3. **jest·vitest 산출은 `--outputFile`/리포터 파일로 받는다.** 파이프는 Nest 로거·vite 배너가
   섞여 JSON 파싱이 깨진다(저장소 선례).
4. **PDF 표의 열 라벨은 화면 문면을 그대로 쓰지 않는다.** 화면은 배지·툴팁으로 나누지만
   인쇄물은 열 제목 한 줄뿐이라 `세부 사유` · `허가 효능효과` 로 풀어 적는다(UI6 의 세 축이
   각각 다른 열이라는 성질은 같다).
5. **`tsc` 종료코드를 통과 조건으로 쓰지 않는다.** m3 D4 의 실측 — 같은 명령이 호출 깊이에 따라
   rc=1 과 rc=2 로 갈린다. 판정은 출력이 진다.

### Security Reviewer

> security-reviewer unavailable, skipped (auto-fallback): Agent(Task) tool forbidden in this
> session — security-reviewer subagent unreachable

이 창은 **호출 조건이 성립한다** — `admin-view` 인가 축에 판정 조회 라우트를 새로 연다.
마일스톤 3 과 다르다(그 창은 프런트 상수와 `null` 컴포넌트뿐이라 조건 자체가 없었다).
마일스톤 1·2 와 같은 상태이며 `security_skipped=true` 가 receipt 에 실린다 — 그 결과
이 receipt 는 `/mccp:pr` 에 대해 **non-approving** 이다. 숨기지 않고 그대로 둔다.

대신 이 창이 인가면에 대해 실행으로 확인하는 것을 적는다. 리뷰어의 대체가 아니라, 리뷰어가
없을 때 남는 기계적 증거다.

- `#7` — 회원 컨트롤러 클래스 가드 `JwtAuthGuard`·`OwnAnalysisGuard` 2/2 무변동 ·
  admin-view 컨트롤러 클래스 가드 `AdminViewTokenGuard` 1건 · 새 라우트가 메서드 수준에서
  가드를 벗기지 않음 · b2c·client admin-view 판정 라우트 0건
- `#8` — 같은 성질을 `Reflect.getMetadata` 로 실행해 확인하는 이름 있는 검사 4건
- `#9`·`#11` — 산출 뷰모델에 `person` 계열 키 0건(UI11 · 실명 비노출)

남는 사각지대: `AdminViewTokenGuard` 자체의 토큰 검증 강도는 이 창이 만들지 않은 코드이고
이 게이트들도 보지 않는다.

### Design Review

`impeccable-detect --mode implement` 가 `skill_available=true` · `design_signal=false` ·
`silent_skip=true` · `reason=no-signal` 을 냈다. silent-skip 을 그대로 받고 receipt 에
`impeccable_silent_skip=true` 로 싣는다.

탐지기가 못 본 자리를 적는다 — 이 창의 렌더 산출은 **PDF 절 하나**이고 `.ts` 파일이라
탐지기의 UI 확장자 목록(`.tsx/.css/…`)에 걸리지 않는다. 그래서 `design_signal=false` 는
"디자인 표면이 없다" 가 아니라 "탐지기 규칙이 이 표면을 모른다" 다.

그 표면에 대한 판정은 **계획 단계에서 이미 돌았다** — 계획서 §Design Critique 의 라운드 0 이
네 지적을 냈고 D13·D14·D15 와 `#9`·`#11` 로 흡수됐다(정보 위계 3단계 · 강조색 0 ·
raw markdown 마커 0건 · 분량 축약). 구현은 그 결정을 실행할 뿐이므로 critique 루프를 다시
돌리지 않는다.

## 계획서와 어긋난 자리 — 실측이 계획을 고친 여섯 건

전부 **구현 중 실행이 낸 것**이고 계획서 본문은 고치지 않았다(그 본문은 `mccp-plan-codex`
receipt 의 앵커다 — 고치면 체인이 stale 로 떨어진다). 게이트가 실측 쪽을 따른다.

### D1 — 빌더 시그니처가 `layout` 을 하나 더 받는다

계획서 Task 7 은 `buildDiagnosisPdfSection(view, { pageBreak })` 로 썼다. 실제 서명은
`(view, { pageBreak, layout })` 다.

이유는 인쇄물의 일관성이다. 이 문서의 다른 표는 전부 `layout: tableLayout` 을 달아 가로선
0.5·세로선 0 으로 그린다. 그 값을 안 넘기면 새 절만 pdfmake 기본값(사방 테두리)으로 그려져
같은 문서 안에 표가 두 종류가 된다. `layout` 은 **불투명 값**이고 빌더는 그것을 그대로 얹기만
한다 — `pdfmake` 를 여전히 import 하지 않으며 `#9` 가 그 사실을 계수로 문다.

### D2 — 색은 두 값이고 본문 셀의 `#FFFFFF` 를 안 쓴다

계획서 `#9` 의 기대는 *"신규 색 리터럴 0건(기존 두 값만)"* 이었다. 형제 `createTableBody` 는
셋을 쓴다 — 머리 칸의 `#F7F7FC`·`#424242` 와 본문 칸의 `#FFFFFF`. 본문 칸의 흰색은 기본값과
같아 지워도 렌더가 변하지 않으므로 빌더는 **두 값만** 쓴다.

**허용 집합을 리터럴로 적지 않는다.** `#9` 는 `DownloadHealthyPdf.tsx` 에서 색 리터럴을 읽어
그 집합 안에 있는지를 본다(실측: 기존 5종 · 빌더 2종). 자기 단언이 아니라 외부 기준 대조다.

### D3 — `#9` 의 `열 3/3` 에서 `bucket` 은 표의 열이 아니다

계획서 문면은 *"bucket·role·label 이 서로 다른 열 3/3"* 이다. 구현에서 `bucket` 은 표의
열이 아니라 **구획 라벨 한 줄**이다(D13 이 요구한 형태다 — 열로 만들면 모든 행에 같은 값이
반복된다). 그래서 `#9` 는 세 축을 이렇게 읽는다.

- `bucket` — `bucketLabel` 본문이 `bucket.bucket` 을 쓰고 `drugRow` 본문에는 `bucket` 이 0건
- `role` — 열 배열에 `세부 사유` 가 있고 `drugRow` 에 `drug.role` 이 정확히 1건
- `label` — 열 배열에 `허가 효능효과` 가 있고 `drugRow` 에 `drug.label` 이 정확히 1건

그리고 `세부 사유`·`허가 효능효과` 의 열 인덱스와 `drug.role`·`drug.label` 의 본문 위치가
각각 **서로 다름**을 함께 문다. 셋이 한 열로 접히면 이 셋 중 하나가 반드시 깨진다.

### D4 — `p4` 를 하네스 부재가 아니라 **전이 소거**로 바꿨다

처음에는 `M1_GATES` 를 없는 경로로 덮어 `#4` 를 즉시 죽였다. 싸지만 그 변이는 이 검사의 **첫
단언(존재 확인)만** 치고, 계획서 `#4` 의 음성이 겨냥한 자리 — *전이가 없으면 실패* — 를 한
번도 밟지 않는다. 프로브가 검사의 다른 부위를 친 채로 초록이 나면 그 초록은 근거가 아니다.

지금은 **`m2` 하네스의 임시 사본에서 `isAdminViewPath` 단언 한 줄을 지운다.** 그러면
`m2 check13` 이 rc=0 이 되고 `#4` 가 `이 창은 admin-view 를 여는 창이고 그 전이가 없다` 로
죽는다 — 계획서가 적은 그 문면 그대로다. 추적 원본은 안 건드리며(D4 · 앞 창 하네스 무수정)
러너가 마지막에 digest 를 다시 재 무변동을 확인한다. 대가는 `--selfprobe` 가 `#4` 를 두 번
완주하는 비용이다.

패치본이 임시 경로에서도 정상 동작함을 먼저 실측했다 — `check13` rc=0 · `check16` rc=0
(`import.meta.url` 이 옮겨가도 `require_` 가 절대 경로를 쓰므로 해석이 깨지지 않는다).

### D5 — `#12` 는 서버 축을 먼저 돈다

두 축의 순서는 계획서가 정하지 않았다. 서버를 먼저 두면 `p12`(서버 동결본에서 서명 하나 제거)
가 서버 tsc 한 번만 돌고 죽는다 — 사이트 tsc 까지 가면 프로브 하나가 컴파일을 세 번 돌린다.

### D6 — 프로브 치환은 전건 **적중 단언**을 붙였다

`mutate()` 헬퍼가 치환 전후를 비교해 안 바뀌면 죽는다. 대상 코드 형태가 바뀌면 `replace` 가
조용히 no-op 이 되고, 그러면 프로브는 아무것도 변이하지 않은 채 기준선을 다시 돌린다.

`p6` 은 그 함정을 실제로 밟을 뻔했다 — `const isAdminView = isAdminViewPath();` 는 형제 함수
셋에도 있어 문자열 치환이 **첫 형제**를 때린다. 그러면 이 프로브가 `#5` 의 인계 축을 붉히고
자기 축은 못 본다. 함수 머리에 앵커한 정규식으로 바꿨다.

### 계획서 Files to Change 에 없던 산출물 1건

- `.claude/notes/drug-diag-api-m4.md` — 이 파일. Phase 2.5 산출물이며 계획서 Task 0 보다 앞선다

## 로컬 코드 리뷰 (`/mccp:code-review` · Local Review Mode)

Codex 가 꺼져 있어 이 창의 적대 리뷰가 0라운드였다(위 §Codex Implementation Review). 그 공백을
미커밋 변경 전체에 대한 로컬 리뷰 1라운드로 메웠다. **PR 모드가 아니므로 `code-reviewer`
receipt 를 쓰지 않는다** — 이 절이 판정의 정본이다.

| 라운드 | A | B | 층위 | 흡수 | 기각 |
|---|---|---|---|---|---|
| R1 | local(main) | — (Codex disabled) | 1·2·6 | 6 | 0 |

### 흡수 6건

| # | 층위 | 자리 | 지적 | 흡수 |
|---|---|---|---|---|
| 1 | 1 | `analyze.repository.ts` `delete()` | 30일 스윕이 판정 행을 **상태 무관 전건 삭제**한다. `cleanup_pending` 에서 시도 상한을 소진한 행은 제공자 쪽에 원본이 남아 있고, 그것을 지울 유일한 열쇠가 `ddv_analysis_id` 다 — 행째로 지우면 `ddx-verdict.repository.ts` 운영 메모가 지시하는 수동 DELETE 가 불가능해지고 그 사실을 아는 기록이 어디에도 없다 | `DDX_SWEEPABLE_STATES` 신설. 종결 4상태만 행째로 지우고, 나머지는 `ddv_result` 만 `null` 로 비워 열쇠를 남긴다. 남는 열은 `odd_id`·`analysis_id`·상태·시각·자작 오류 문면뿐이라 PHI 가 아니다 |
| 2 | 6 | `ddx-verdict.service.ts` `STORED_DRUG_KEYS` | whitelist 기준이 **읽기 경로가 지금 읽는 집합**이라 계약 `ClassifiedDrug` required 6종 중 `confidence`·`source` 가 빠졌다. 적재 직후 제공자 원본을 지우므로(D10) 되찾을 곳이 없다. `source` 는 `ANS015.tsx` 가 실연동 필수로 못박은 값이다 — 규칙 유래인지 언어모델 유래인지를 가르는 유일한 근거 | 두 키 추가. 기준을 읽기 경로에서 **계약**으로 옮기고 그 이유를 주석에 박았다 |
| 3 | 1 | `ddx-verdict.schedule.ts` catch | 주석은 "`sql`·`sqlMessage` 를 안 싣는다"고 하는데 mysql2 의 `Error.message` 가 곧 `sqlMessage` 다 — `ER_DUP_ENTRY: Duplicate entry '<값>' for key` 처럼 바인딩 값이 실린다 | `kind`+`code` 로 바꾸고(`runBatch` 와 같은 형태), 문면은 `SAFE_ERROR_PREFIX` 로 시작하는 자작 오류일 때만 싣는다 |
| 4 | 2 | `DownloadHealthyPdf.tsx` | 판정 조회 catch 가 빈 블록이라 PDF 경로의 고장이 어디에도 안 나타난다 | `ANS003B` 와 같은 형태의 `console.warn` |
| 5 | 1 | `ANS003B.tsx` | `ignore` 플래그는 늦은 응답만 막고 기존 state 는 안 비운다 — 언마운트 없이 `oddId` 만 바뀌면 새 응답 전까지 앞 사람의 상병·약이 선다. `tabPickedByUser` 도 초기화되지 않아 다음 분석이 `ready` 여도 자동 전환이 막힌다 | effect 머리에서 판정 state 3종과 ref 를 되돌린다 — `oddId` 변경이 첫 마운트와 같아진다 |
| 6 | (위생) | `insuniverse-site/.gitignore` | `.claude/state/`(훅 작업 사본)가 미추적으로 남아 서브모듈을 dirty 로 만든다. 루트 `.gitignore` 가 아예 없었다 | 루트 `.gitignore` 신설, `.claude/state/` 한 줄. 추적 중인 `.claude/agents/`·`settings.local.json` 은 그대로 |

### 반사실 검증

1·2 는 검사를 붙였고 **수정을 되돌리면 뒤집히는 것**을 실행으로 확인했다. 프로브는 사본이 아니라
원본을 변이했으므로 전후 sha256 을 대조해 무변동을 확인했다(둘 다 일치).

| 검사 | 변이 | rc |
|---|---|---|
| `sweepable-states-exclude-provider-copies` | `DDX_SWEEPABLE_STATES` 에 `cleanup_pending` 추가 | 1 |
| `stored-drug-keeps-contract-required` | `STORED_DRUG_KEYS` 에서 `'source'` 제거 | 1 |

`sweepable-states-exclude-provider-copies` 는 위험 상태 목록을 **검사 안에서 독립 재열거**한다.
생산 코드의 목록을 되읽어 비교하면 그 목록이 틀렸을 때 검사도 함께 틀린다. 어휘 전건이 둘 중
하나로 분류되는지도 함께 물어, 새 상태가 조용히 어느 쪽도 아닌 채 남지 않게 한다.

**3·4·5·6 에는 새 검사가 없다.** 관측성·위생 변경이고 분기가 아니다. 이것이 이 라운드의
사각지대다 — 되돌려도 붉어지는 자리가 없다.

### 되돌린 흡수 1건 — `DdxConfigError` 클래스

3 을 처음에는 `ddx.client.ts` 에 `DdxConfigError`(`missing: string[]`)를 신설해 잡았다. 그 파일은
**마일스톤 1 `#9` 의 소스 digest 동결 3종**(매퍼·클라이언트·진입점) 중 하나라 `#4` 가
`소스 digest 불일치` 로 붉어졌다. 종결된 창의 동결값을 LOW 지적 하나 때문에 움직이지 않는다 —
클래스를 물리고 문면 접두 whitelist 로 같은 결과를 냈다. `ddx.client.ts` 는 바이트 무변동이다.

### 검증

| 검사 | 결과 |
|---|---|
| 서버 jest (`ddx` 축) | 81/81 (신규 2건 포함) |
| 사이트 vitest (변경 축) | 20/20 |
| 서버 `tsc --noEmit` | 진단 21줄 — 리뷰 **이전과 바이트 동일**. 변경 파일 0건 |
| 사이트 `tsc --noEmit` | 0건 |
| eslint | 서버 0건 · 사이트 경고 1건(`ANS003B.tsx` 74행 기존 effect, 기준선과 동일) |
| `m4.gates.mjs check-all` | 13/13 · exit 0 |
