# 구현 노트 — drug-diag-api 마일스톤 3 (인사이트 자리)

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

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

## 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). 반환 `classification=disabled` · `blocking=0` · exit 0.
  `codex-policy seal` 이 `codex_disabled=true` 를 봉인했고 wrapper 가 spawn 직전에 그것을 읽어
  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. **`ANS015` 컴포넌트가 `null` 을 반환한다.** 본문 0줄이 UI3 의 번역이고, 등록된 컴포넌트가
   있어야 스위치를 켤 때 맵 조회가 `undefined` 를 내지 않는다.
2. **좌석을 다섯 상수에 동시에 넣는다.** 넷만 넣으면 `AttrPartialType` 이 선언 시점에 깨진다.
3. **숨김 가드를 6개 페이지에 복제한다.** 중앙 필터(`Partial`)는 `strict: true` 아래서 6곳의
   `(item: string, index: AnalysisType)` 명시 주석과 충돌한다. 대가는 중복이고 `#6` 이 계수로 받는다.
4. **`ANS015` 를 하위 탭 줄·`Info` 제외 목록에 지금 넣는다.** 실연동 때 넣으면 그때 화면이 흔들린다.

### 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` 로 실린다.**

그 판정이 실질적으로도 맞다 — 이 창의 렌더 산출물은 `ANS015.tsx` 하나이고 **본문이 0줄**이다.
헤딩도 강조 토큰도 목록도 없으므로 SKILL §Output Constraints 의 네 앵커가 걸릴 자리가 없다.
`MCCP_DESIGN_INTENT_REASON` 으로 탐지기를 우회해 critique 루프를 강제할 수 있었으나,
**없는 본문에 대해 라운드를 도는 것은 기록만 만들고 아무것도 검증하지 않는다.** 네 앵커는
실연동 창이 받는다(계획서 §Design Critique 의 앵커 표와 같은 판정).

디자인 정본은 이 창에서 별도로 세웠다 — `PRODUCT.md` · `DESIGN.md` · `.impeccable/design.json`.
사용자 지시(UI6)이고 게이트 `#13` 이 구조를 검사한다.

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

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

### D1 — `#11` 은 "이 창 경로 진단 0건" 이 될 수 없다. 귀속으로 바꿨다

계획서 `#11` 의 기대는 *"tsc 의 이 창 경로 진단 0건"* 이었다. **성립하지 않는다.**
이 창의 8개 파일을 HEAD 로 되돌린 뒤 잰 기준선에 그 경로의 진단이 **44건** 있다
(총 서명 78건 · 진단행 151건). 계획서가 그 사실을 모른 채 절대 0을 요구했고, 그대로
구현했으면 게이트가 영구히 붉거나 — 더 나쁘게 — 조건을 느슨하게 고쳐 아무것도 안 보게 됐다.

**대신 기준선 동결본에 없는 진단만 이 창에 귀속한다.** 동결본은
`.claude/plans/drug-diag-api-m3.tsc-baseline.json` 이고 측정 방법을 파일 안에 적었다.
`#11` 은 이제 총 진단행·서명 수·기준선 서명 수를 함께 내고, **이 창 경로의 44건을 통과시킨다는
사실을 사각지대로 출력에 적는다.** 이것이 계획서의 의도(이 창이 새 진단을 만들지 않는다)를
실제로 검사하는 유일한 형태다.

계획서 Acceptance 의 `tsc --noEmit 의 이 창 경로 진단이 0건` 은 **문면 그대로는 충족되지
않았다.** 더 강한 형태(기준선에 없는 진단 0건)로 충족됐고 그 사실을 여기 적는다.

### D2 — 제외 자리가 둘이 아니라 셋이었다. tsc 가 찾아 줬다

계획서 Task 7 은 하위 탭 줄과 상단 `Info` 두 자리만 이름했다. `tsc` 를 돌리자 세 번째가
나왔다 — `AnalysisDetailPage.tsx` 의 **페이지 수준 조회 effect** 가 `ANSCAR`·`ANS014` 만
제외해서, 좌석이 `getAnalysisDetail({ ansType: "ANS015" })` 분기로 떨어진다. 타입 오류는
그 호출의 `ansType` 유니온이 `ANS015` 를 모르기 때문에 났고, **스위치를 켜는 날 존재하지 않는
분석 종류로 조회가 나가는 실제 결함**이었다.

`ANS015` 를 자체 로딩 분기(`ANS003A`·`ANS003B`·`ANS007`·`ANS009` 와 같은 자리)에 넣어 닫았고,
`#8` 이 두 자리가 아니라 **세 자리**를 세며 그 사실을 출력에 적는다.

### D3 — `npx` 는 이 하네스에서 안 뜬다 (Windows, rc=127)

`spawnSync("npx", …, { shell: false })` 가 rc=127 이다 — Windows 에서 `npx.cmd` 를 못 찾는다.
`shell: true` 로 켜면 인용이 새 실패 모드가 되므로, 진입점을 `node` 로 직접 부른다 —
`node_modules/vitest/vitest.mjs` · `node_modules/typescript/bin/tsc`. `#10` 이 그 두 진입점의
실재를 먼저 확인하고 `--version` 양성 프로브를 낸다.

### D4 — `tsc` 는 진단이 있어도 exit **1** 이다. 종료코드 리터럴을 뺐다

계획서 `#11` 의 음성 주석은 *"tsc 는 진단이 있으면 exit 2 이므로 `0|1` 로 받지 않는다"* 로
썼다. **이 저장소에서는 1이다** — `npx tsc` 와 `node bin/tsc` 둘 다 rc=1 로 실측했다.
`0|2` 로 못박으면 검사가 판본 하나에 결박되고, `0|1` 로 받으면 진단이 있는데도 통과한다.

그래서 종료코드 리터럴을 아예 뺐다. 판정은 **출력**이 진다 — 0 이 아닌데 진단을 한 줄도
파싱하지 못했으면 그것이 설정 실패이고, 0 인데 진단을 파싱했으면 파서와 종료코드가 어긋난 것이다.

**그 판단의 결정적 증거가 뒤늦게 나왔다.** 같은 명령·같은 입력·같은 세션인데 호출 깊이에 따라
코드가 갈린다 — `node "$G" check11` 을 직접 부르면 `tsc rc=1`, `check-all` 이 자식으로 부르면
`tsc rc=2` 다(둘 다 진단 151건으로 동일). 어떤 리터럴을 골랐어도 절반은 틀렸을 것이다.
`0|2` 였으면 직접 호출이 붉고, `0|1` 이었으면 `check-all` 이 붉는다. 출력에서 유도하는 형태만
두 경우를 함께 통과시키며, 그러면서도 "0 이 아닌데 진단 0줄" 이라는 진짜 설정 실패를 잡는다.

### D5 — 진단 서명 정규화가 필요했다

원문 서명으로 대조하니 사전에 존재하던 오류 하나가 **이 창이 낸 것**으로 잡혔다. tsc 메시지가
타입 리터럴을 본문에 싣기 때문이다 — `Property '치아' is missing in type '{ 숨은: … }'` 가
좌석 추가 후 `'{ AI인사이트: string; 숨은: … }'` 로 바뀐다. 같은 오류이고 같은 줄이다.

작은따옴표로 감싼 타입 리터럴 블록을 접어서 비교한다. **대가는 명시한다** — 타입 *내용만*
달라진 새 오류는 기존 서명과 같아 보인다. 경로·오류코드·메시지 골격은 그대로 보므로 새 파일·
새 코드·새 문장은 계속 잡힌다. 하네스 안 `normSig` 주석에 같은 내용을 적었다.

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

- `.claude/plans/drug-diag-api-m3.tsc-baseline.json` — D1 의 동결본. 계획이 절대 0건을
  가정했으므로 이 파일의 필요를 몰랐다
- `.claude/notes/drug-diag-api-m3.md` — 이 파일. Phase 2.5 산출물이며 계획서 Task 0 보다 앞선다

### Security Reviewer

호출하지 않았다. 2.5.5 의 호출 조건은 **보안 민감 영역**(auth · crypto · secrets · 입력 검증 ·
SQL/명령 주입 · SSRF · 경로 탐색 · 권한 상승)이고, 이 창이 만지는 것은 프런트 상수 다섯 자리 ·
`null` 반환 컴포넌트 하나 · 페이지별 렌더 가드 여섯이다. 그중 어느 것도 그 목록에 없다.
서버·라우트·DB·네트워크를 한 줄도 건드리지 않는다.

**따라서 `security_skipped` 를 세우지 않는다.** 마일스톤 2 는 그것을 세웠는데(그 창은 실명 전송·
적재·삭제 연쇄를 다뤘고 Agent 도구가 금지돼 리뷰어를 못 불렀다), 이 창은 호출 조건 자체가 성립
하지 않는다 — 못 부른 것과 부를 자리가 없는 것은 다르다.

## /mccp:code-review — Local Review Mode (2026-09-03)

커밋되지 않은 변경 전건(서버 ddx 모듈 13파일 · analyze 연결 6 · 마이그레이션 1 · 사이트 11)을
대상으로 한 자문 리뷰다. PR 모드가 아니므로 receipt 를 쓰지 않는다.

검증: tsc 서버·사이트 진단 0건 · jest 81/81 · vitest 13/13 · eslint 신규 경고 0건
(기존 3건은 ANS003A · ANS003SubDetail · ANS003B 의 손대지 않은 effect).

| 라운드 | 지적 | 층위 | 판정 |
|---|---|---|---|
| R1 | 재개 경로가 1회 POST 마커를 건너뛴다 (HIGH) | 1 | 흡수 — `markSent` 를 조건부 전이(CAS)로 |
| R1 | `ddv_attempts` 증가가 read-then-write | 1 | 흡수 — `sql` 증가로 원자화, 질의 2→1 |
| R1 | 적재 whitelist 가 2단계에서 멈춘다 | 2 | 흡수 — `STORED_DRUG_KEYS` 로 약 원소까지 |
| R1 | 배치 전체 상한 부재 | 1 | 흡수 — `BATCH_DEADLINE_MS` |
| R1 | 배치 예외에서 오류 정체가 사라진다 | 1 | 흡수 — 종류·드라이버 코드만 남긴다 |
| R1 | 주석이 `RETENTION_DAYS` 에 잘못 붙었다 | 2 | 흡수 — 비바인딩 주석으로 분리 |
| R1 | 응답 봉투가 형제 라우트와 다르다 (LOW) | 2 | 흡수 — 뷰모델 직접 반환 |
| R1 | blacklist 2행이 whitelist 에 포섭된다 (LOW) | 2 | 부분 흡수 — 삭제 대신 주석을 의도 표명으로. `#7` 이 그 2자리를 센다 |
| R1 | 빌드 산출물 2건이 gitignore 밖 (LOW) | 8 | 흡수 — `.impeccable/` · `*.tsbuildinfo` |
| R1 | 진단 탭 빈 화면이 사유를 말하지 않는다 (LOW) | 2 | 흡수 — `NONE_NOTICE` |

### 흡수가 하네스를 함께 움직인 자리 3건

1. **m2 `#7` 프로브 p7** — `markSent` 호출 리터럴을 문자열로 replace 했다. 호출 형태가 바뀌면
   replace 가 조용히 no-op 이 되어 프로브가 재현해야 할 거부가 사라진다. 리터럴 적중을 단언하는
   `die` 를 붙였다.
2. **m2 `#7` 의 `catch\s*\{`** — 오류 정체를 남기려면 `catch (e) {` 여야 하는데 그 정규식이
   바인딩 있는 형태를 거부한다. 이 검사의 관심사는 항목별 `catch` 의 실재이지 바인딩 유무가
   아니므로 선택적 인자를 받게 넓혔다. `#8` 의 상한 상수 목록도 5자리로 넓히고 기대값을 유도로
   바꿨다(리터럴 `4` 제거 — INV4).
3. **m3 `#11` 의 `normSig`** — 이 창의 변경과 무관하게 붉었다. alien 8건이 전부 **공용체 원소
   순서만** 다른 같은 진단이었고(`"A" | "B"` ↔ `"B" | "A"`), 원인은 `tsconfig.node.tsbuildinfo`
   증분 캐시다. 캐시가 있으면 8건 중 2건, 지우면 8건 전건이 뒤집힌다(캐시 삭제 후 3회 반복해
   같은 결과). 순서는 타입의 의미가 아닌데 그것이 귀속의 근거가 되면 **로컬 캐시 상태가 게이트
   색을 정한다.** `normSig` 가 공용체 원소를 정렬하게 했다 — 78/78 서명이 일치하고 alien 0건.

### 재검증

`m2 check-all` 15/15 · `m2 --selfprobe` 15/15 · `m3 check-all` 13/13 · `m3 --selfprobe` 12/12.
service.spec 은 17→20건(재개 경합 2건 · 약 원소 whitelist 1건). `#7` 의 이름 있는 검사는
4→5건이며 신설분은 `resume-claim-no-post` 다.
