# Plan Review Panel — ai-insight-report

**Plan**: `.claude/plans/ai-insight-report-m1.plan.md` · **Plan version**: `sha256:efac7a62fcee5d172323cb3f8475c614acd5e43399b588333fc90c11848ed91f`
**Verdict**: `divergent` via `multi-agent`
**Quorum**: 4/3 responses · 4 distinct roles (of 4 fielded) · passed=false
**Layers**: L1 converged · L2 divergent · L3 divergent
**Halted at**: `5.2e`

> Reason: L2 quorum not satisfied: 8 blocking finding(s): architect/HIGH, architect/FAIL, security/HIGH, security/FAIL

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | HIGH | PRD §Design Direction 이 레퍼런스 위에 덧씌운 세 개의 규범(강조 예산 · 정보 위계 3단계 상한 · 목록 상한)이 계획서 어디에도 없다 — User Intent 표에도, NOT Building 에도, 게이트에도 없다. 그중 «강조 예산 — 화면당 강조색 1개»는 «경고 성격의 발견 하나만 강조색을 갖고 나머지 셋은 무채 계열» 을 요구하는데, 계획서 Task 3 은 «레퍼런스 <style> 중 필요한 규칙만 그대로 옮긴다»(plan:92) 이고 게이트 #2 는 레퍼런스와의 구조 완전 상등을 요구한다. 같은 결정에 두 소유자가 있고 어느 게이트도 중재하지 않는다. 구현자는 PRD 를 따르면 #2/#5 를 깨고, 레퍼런스를 따르면 PRD §Design Direction 을 조용히 버린다 | PRD `.claude/prds/ai-insight-report.prd.md:135` «**강조 예산 — 화면당 강조색 1개.** 레퍼런스는 강조 표기 4종(…)을 쓴다. … 나머지 셋은 무채 계열의 형태·테두리로 구분한다» · :139 «한 하위 탭에서 발견 블록이 3개를 넘으면 상위 3개만 펼치고 나머지는 접는다 — 「보상 찾아줘」가 배지 11을 드는 자리다». 레퍼런스는 이 넷을 실제로 각기 다른 클래스로 쓴다(xai-report-reference.html:406 `is-hit` · :422 `flag` · :438 `sug` · :473 `fx` · :681 `tag`). 계획서에는 «강조 예산» · «목록 상한» · «정보 위계 3단계» 문자열이 0건이고, plan.md:185 는 오히려 «이 창의 디자인 방향은 확정 레퍼런스가 이미 정본» 이라고만 적는다 |
| architect | MEDIUM | 발견 단위 출처 표기를 버리는 결정(UI4)의 실제 정본은 «선행 PRD» 가 아니라 저장소 규범 문서 `DESIGN.md` 다. 계획서는 출처를 잘못 인용했고, 어떤 Task 도 그 규범을 철회하지 않는다. 게다가 그 규범 문구를 그대로 담고 있는 `ANS015.tsx` 는 이 창이 UPDATE 하는 파일인데, 해당 JSDoc 을 정리하는 지시가 Files to Change 의 Why 에도 Task 5 에도 없다 — M1 이 끝나면 저장소는 화면이 위반하는 규범을 소유자 없이 두 곳에 남긴다 | `DESIGN.md:279` «**The Honest Badge Rule.** 문장을 만든 경로를 배지로 밝힌다 … 이 시스템이 해서는 안 되는 한 가지다» · `DESIGN.md:291` «**Do** 문장 생성 경로(규칙/언어모델)를 배지로 정확히 표기한다». 같은 규칙이 `insuniverse-site/front/src/features/analysisDetailView/ui/ANS015/ANS015.tsx:18-22` 에 코드 주석으로 박혀 있다. 계획서 plan.md:70 은 «선행 PRD 가 … 못박았으나» 로만 적고 `DESIGN.md` 를 언급하지 않는다 |
| architect | MEDIUM | Task 4(고정 데이터 모듈)의 `Gate:` 가 그 Task 의 정확성을 증명하지 않는다. 본문은 «게이트 #2 가 문장과 표 본문까지 대조하므로 지어낸 값은 통과하지 못한다» 라고 증명 책임을 #2 에 지우는데, 정작 `Gate:` 와 `Validate:` 는 #9 다. #9 는 의존 diff 와 tsc 진단 서명만 본다 — fixture 의 문면을 한 글자도 보지 않는다 | plan.md:98-101 «… 게이트 `#2` 가 문장과 표 본문까지 대조하므로 …» / «- **Validate**: … `--only 9`» / «- **Gate**: `#9`». gates.mjs:490-512 `gate9()` 는 `front/package.json`·`package-lock.json` 의 numstat 과 tsc 진단 서명만 검사한다 |
| architect | LOW | 렌더 덤프의 소유자가 정해져 있지 않다. 계획서는 «`AI_INSIGHT_M1_DUMP` 가 있으면 렌더 산출 innerHTML 을 그 경로에 쓴다» 고만 적고 다섯 검사 중 누가 쓰는지를 고정하지 않는데, 게이트 #6 은 그 덤프에서 `aria-selected=true` 정확히 1개 · `tabindex=-1` 정확히 3개 같은 상태 의존 값을 단정한다. 탭을 전환하는 검사와 툴팁을 여는 검사가 같은 경로에 마지막으로 쓰면 #6 이 보는 상태가 바뀐다 — 검사 파일과 하네스가 같은 산출물의 두 소유자다 | plan.md:116 «`AI_INSIGHT_M1_DUMP` 환경변수가 있으면 렌더 산출 `innerHTML` 을 그 경로에 쓴다» (어느 `it` 인지 미지정, 5종 중 `subtab-switch-moves-panel-and-focus`·`tooltip-opens-one-at-a-time` 는 상태를 바꾼다). gates.mjs:404-410 이 그 덤프에 대해 개수 상등을 단정한다 |
| architect | LOW | Files to Change 의 PRD 행이 이미 처리된 작업을 미착수 작업으로 적고 있어 Task 8 의 범위 서술이 현재 트리와 어긋난다 | plan.md:58 «`.claude/prds/ai-insight-report.prd.md` \| UPDATE \| Delivery Milestones 1행을 `in-progress` 로 올리고 Plan 칸을 채운다» — 그러나 `.claude/prds/ai-insight-report.prd.md:80` 은 이미 `in-progress` 이고 Plan 칸이 이 계획서를 가리킨다 |
| security | HIGH | 플랜의 핵심 완화 주장 «스위치가 꺼져 있어 노출은 0» 이 거짓이다. `ANS015` 는 `AnalysisDetailPage.tsx:29` 에서 정적 import 되므로 `ANS015.fixture.ts` 의 가명화 진료 데이터는 `AI_INSIGHT_TAB_ENABLED=false` 여도 프런트 번들에 그대로 들어가 브라우저로 배포된다. 스위치는 런타임 렌더 분기일 뿐(`AnalysisDetailPage.tsx:525` `if (index === "AI인사이트" && !AI_INSIGHT_TAB_ENABLED) return null;`) 번들 포함 여부를 바꾸지 않는다. 즉 M1 머지 즉시 실환자 유래 상병코드·진료일(−11일 이동에 불과)·본인부담금이 모든 방문자가 받는 JS 자산에 실린다. 이를 잡는 게이트가 없다 — `#8` 은 상수 선언만 정규식으로 확인한다(gates.mjs:471). | plan:153 «스위치가 꺼져 있어 노출은 0 이고» vs `insuniverse-site/front/src/pages/analysis/list/detail/AnalysisDetailPage.tsx:29` 정적 import · `:525` 런타임 분기 · `.claude/plans/ai-insight-report-m1.gates.mjs:471` |
| security | MEDIUM | Task 2 의 7축 동결본이 `tableRows`(표 본문 행)와 `sentences`(point_say/point_why)를 포함하므로, 레퍼런스 안의 민감 진료 레코드가 `.claude/plans/ai-insight-report-m1.reference-structure.json` 이라는 **두 번째 영구 커밋 산출물**로 복제된다. 계획서 어디에도 마스킹·해시 대체 언급이 없고, 「가명 처리 범위가 충분한가」를 판정하는 게이트도 없다(`#10` 사각지대 목록도 이 축을 공시하지 않는다). | plan:86 «표 헤더 · 표 본문 행 … 문장(`p.point_say` · `p.point_why`)» + gates.mjs:173-181 `tableRows`/`sentences` 추출 · 원본 `xai-report-reference.html:390-394` (`2026-04-25 · △△병원 · N63 · 119,700원`) · gates.mjs:75-83 BLIND_SPOTS 에 해당 항목 없음 |
| security | LOW | `AI_INSIGHT_M1_DUMP` 는 검증 없는 임의 경로 싱크다. 테스트가 렌더 `innerHTML`(= 위 진료 데이터 전량)을 그 경로에 쓰는데 temp 디렉터리 제한이 없다. 하네스는 자기 호출에서만 `mkTemp()` 를 쓰므로(gates.mjs:300·392), CI 설정이나 개발자 셸에서 저장소 안 경로가 들어가면 민감 데이터 덤프가 워크트리에 떨어져 커밋 경로로 샌다. | plan:116 «`AI_INSIGHT_M1_DUMP` 환경변수가 있으면 렌더 산출 `innerHTML` 을 그 경로에 쓴다» — 경로 제약 서술 없음; gates.mjs:300 `path.join(mkTemp(), "ans015-dump.html")` |
| test | HIGH | UI10 / PRD §Constraints 5 의 접근성 성질 중 키보드 화살표 이동과 Esc·바깥 클릭 닫기는 어떤 검사로도 반증되지 않는다. Task 6 이 명시적으로 그것을 옮긴다고 주장하지만, 게이트 #6 은 정적 덤프의 속성만 세고 이름 집합(#7)에도 대응 검사가 없다. | plan.md:110 «`ArrowLeft`/`ArrowRight` 이동 … `Esc` 와 바깥 클릭으로 닫기» vs gates.mjs:404-423 (gate6 은 role/aria-selected/tabindex/aria-expanded/aria-controls 계수와 CSS 정규식만 본다). REQUIRED_TESTS(gates.mjs:436-442)의 다섯 이름에 화살표 이동·Esc·바깥 클릭을 겨냥한 이름이 없다. Acceptance(plan.md:169) 는 그럼에도 «접근성 계약 10항이 … 관측된다 — 게이트 #6» 으로 단언한다. |
| test | MEDIUM | 게이트 #2·#6 의 «차이 0건» 은 덤프 파일의 출처를 검사하지 않는다 — 덤프가 실제 ANS015 렌더 산출인지 확인하는 코드가 없어, 테스트가 레퍼런스 조각을 그대로 써 넣기만 해도 두 게이트가 초록이 된다. «추출기가 하나» 논증은 이 자리를 막지 못한다. | gates.mjs:300-313 — 존재 검사(`fs.existsSync(dump)`)와 `extractAxes` 대조뿐이고 덤프 생성 경로에 대한 어떤 대조도 없다. plan.md:116 은 덤프 작성 주체를 검사 자신으로 둔다(«`AI_INSIGHT_M1_DUMP` 환경변수가 있으면 렌더 산출 `innerHTML` 을 그 경로에 쓴다»). |
| test | MEDIUM | Task 5·6 의 Validate 명령이 그 Task 시점에 구조적으로 통과할 수 없다. 게이트 #2·#6 은 vitest 전체 통과와 덤프를 요구하는데, 덤프 지원과 `panel-renders-nothing` 제거는 뒤의 Task 7 이 한다. | plan.md:106 «--only 2» · plan.md:112 «--only 6» vs plan.md:116 (Task 7 이 덤프와 이름 교체를 한다). gates.mjs:302 는 vitest 미통과 시 즉시 fail 이고, 현행 ANS015.test.tsx:47-52 의 `panel-renders-nothing` 이 `toBeEmptyDOMElement()` 를 단언하므로 Task 5 직후 vitest 는 반드시 실패한다. |
| test | MEDIUM | Task 4(고정 데이터 모듈)의 Validate/Gate 가 그 Task 가 바꾼 것을 전혀 실행하지 않는다. `--only 9` 는 의존 diff 와 tsc 진단만 보며 fixture 문면이 레퍼런스와 같은지는 보지 않는다. Task 본문 스스로 «게이트 #2 가 대조한다» 고 쓰면서 Gate 칸에는 #2 가 없다. | plan.md:98-101 (Action 은 #2 를 근거로 들고 «- **Validate**: … --only 9» · «- **Gate**: `#9`»), gates.mjs:490-512 (gate9 = package.json/lock diff + tsc 서명 집합). |
| test | MEDIUM | «신규 CSS 격리 · 공용 스타일시트 무변경» 주장이 하드코딩된 표본에만 걸려 있어 과대허용 방향이 열려 있다 — 새 스타일시트를 하나 더 만들거나 `front/public/css` 에 파일을 추가하면 어느 게이트도 보지 않는다. | gates.mjs:49 (`CSS: ANS015/ANS015.css` 단일 경로)와 gates.mjs:327 (gate3 는 그 파일만 읽는다), gates.mjs:64-69 (PUBLIC_CSS 4개 리터럴) — 서브모듈 전체에 대한 «다른 CSS 변경/추가 0건» 대조가 없다. PRD §Success Metrics 2행(«기존 CSS 회귀 0행»)이 이 표본으로 측정된다. |
| test | MEDIUM | 게이트 #7 이 ANS015.test.tsx 한 파일만 돌려, `() => null` 이던 컴포넌트를 실제 패널로 바꾼 회귀가 나머지 스위트에 미치는 영향이 어떤 게이트에도 잡히지 않는다. | gates.mjs:568 — `run(node, [VITEST_BIN, "run", P.TEST_REL, …])` 로 대상이 `P.TEST_REL` 단일 파일이다. 저장소에는 다른 실행 검사 14개가 있다(예: insuniverse-site/front/src/features/analysisDetailView/ui/ANS003/ANS003B.test.tsx). Acceptance(plan.md:163-175)에 전체 스위트 통과 항목이 없다. |
| invariant | HIGH | 게이트 #9 의 tsc 축은 컴파일러가 정상 종료했는지를 보지 않는다 — tsc 가 진단이 아닌 이유(설정 오류 · 크래시 · OOM · 인자 거부)로 죽으면 진단 서명이 0건으로 관측되어 «신규 진단 0건» 으로 **통과**한다. 게이트 기계 자체의 실패가 HALT 가 아니라 승인으로 퇴화하는 자리다. | /home/madsc/work/insuniverse-temp/.worktrees/ai-insight-report/.claude/plans/ai-insight-report-m1.gates.mjs:500-511 — `const t = run(...)` 이후 `t.code` 를 어디에서도 보지 않고, 파싱은 `^(.+?)\\((\\d+),(\\d+)\\):\\s+(error TS\\d+:.*)$` 뿐이다. `error TS5083`·`TS6304` 류의 설정 단계 오류는 `file(line,col):` 접두가 없어 한 건도 잡히지 않고 `sigs` 가 비며 `fresh` 가 비어 `ok(id, "... 관측 0 ⊆ 동결 N")` 로 끝난다. 같은 절차로 만드는 Task 1 의 baseline(plan:80) 도 같은 침묵을 동결하므로 두 침묵이 서로를 정당화한다. |
| invariant | MEDIUM | 게이트 #2·#6 이 판정하는 «렌더 산출» 의 출처가 어디에도 고정돼 있지 않다. 덤프를 쓰는 주체가 피검사 대상인 테스트 파일이고, 게이트는 그 파일이 무엇을 썼는지 검사하지 않는다 — 레퍼런스에서 유도한 HTML 을 덤프에 쓰면 «차이 0건» 이 구현 없이 성립한다. | plan:116 «`AI_INSIGHT_M1_DUMP` 환경변수가 있으면 렌더 산출 `innerHTML` 을 그 경로에 쓴다» + gates.mjs:300-307 — 게이트는 `fs.existsSync(dump)` 와 그 내용의 추출만 본다. 게이트 #7(gates.mjs:436-455)은 테스트 **이름 5종**만 동결하고 본문은 보지 않으므로 덤프의 앵커링이 없다. |
| invariant | MEDIUM | 게이트 하네스 자신이 구현 중 편집 대상으로 선언돼 있는데, 그 내용을 붙드는 동결본이 없다. `ANCHOR`·`REQUIRED_TESTS`·`EMPHASIS_CLASSES`·`PUBLIC_CSS` 를 완화해도 통과하며 #10 은 마커/등록/Gate 참조의 **집합 상등**만 본다. | plan:49 «`.claude/plans/ai-insight-report-m1.gates.mjs` \| UPDATE \| … 구현 중 정정이 들어간다» + gates.mjs:523-552 — gate10 은 `# 검사 #N:` 번호 집합만 대조하고 검사 본문·상수는 대조하지 않는다. Mirror 로 든 drug-diag M5 는 이름 집합 동결본(`*.test-baseline.json`)을 별 파일로 갖는데(plan:38) 하네스 자신에 대응하는 동결본은 이 창에 없다. |
| invariant | LOW | 동결본 머리의 `anchor_commit`(superproject)은 계획서가 요구하지만 게이트 #1 이 대조하지 않는다 — 머리 필드가 무엇을 가리키든 통과한다. | plan:86 «머리에 `_purpose` · `_normalization` · `anchor_commit` · `site_anchor_commit` · `reference_sha256` · `_measured_at` 을 싣는다» 대 gates.mjs:274-279 — 대조하는 것은 `reference_sha256` 와 `site_anchor_commit` 둘뿐이다. |
| invariant | LOW | 각 Task 의 `Validate` 가 `--only N` 이라 착수 봉인 #0(레퍼런스 sha256 · 앵커 조상성)이 Task 단위 실행에서는 건너뛰어진다. 계획서는 «건너뜀 상태는 두지 않았다» 고 적었으나 부분 실행 경로에는 실질적 건너뜀이 있다. | plan:94 `--only 3,4,5` · plan:147 «건너뜀 상태는 두지 않았다» 대 gates.mjs:623 `if (!only \|\| only.has("0"))` — `--only 3,4,5` 는 gate0 을 아예 돌리지 않는다. |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | fail | 계획서가 인용한 자리를 전부 열어 대조했다. 실측 확인한 것: (1) `ai-insight-report-m1.gates.mjs` 는 실재해 `UPDATE` 표기가 맞다 · 등록 검사 11건과 계획서 `Gate:` 참조 집합(0~10)이 실제로 상등이라 게이트 #10 의 자기 검사는 성립한다. (2) 단일 추출기 주장 — `extractAxes` 는 파일 안에 하나뿐이고 gate1/gate2 가 같은 함수를 부른다(gates.mjs:157·281·307·309). 두 번째 추출기를 찾으려 했으나 없다. (3) 추출기 선택자를 레퍼런스 원문에 대조 — `ul.tab-list-dark > li > button`(:348-352) · `table.ev` · `button.tip_btn`(:363) · `p.point_say`/`point_why`(:371-373) · `h2.box_h`/`h3.sub_h`(:357·362) 전부 실재하고, 강조 5클래스도 `fx`·`tag`·`sug`·`flag`·`is-hit` 가 각각 실재해 축이 공허하지 않다. (4) NOT Building 의 «PDF 저장 버튼은 `.ai_box` 밖» 주장 — :322 가 `.title-area` 안, `.ai_box` 는 :346 시작이라 사실이다. `#panel-stub`(:849)도 `.ai_box` 밖이다. (5) Mirror 인용 3건 — `Signature.tsx:7` `import "./Signature.css"` · `ANS013.tsx:6·34·45` props+useMemo · `ANS015.test.tsx:47` `panel-renders-nothing` 전부 계획서 서술대로다. (6) 4개 패널이 레퍼런스에서 전부 DOM 에 있어(:355·461·595·728, `hide` 클래스) 구조 상등이 원리적으로 달성 가능한지 확인했다 — 가능하다. 깨진 곳은 위 다섯 건이며, 특히 PRD §Design Direction 의 세 규범이 계획서에 한 번도 나타나지 않는 것과 `DESIGN.md` 의 Honest Badge Rule 이 소유자 없이 남는 것이 구조 결함이다. |
| security | fail | 공격한 축: 1) 절대경로·머신명 누출 선례 재개방 — tsc-baseline `_produced_by` 선례(drug-diag-api-m5.tsc-baseline.json:3)에 절대경로가 없고 하네스는 `R()` 상대경로만 동결본에 쓰므로 무산. 2) `#0` 이 출력하는 복구 명령의 절대경로 — stdout 뿐이고 영구 산출물에 안 들어가 무산. 3) sha256/앵커 위조로 대조 기준 우회 — `#0`·`#1` 이 `reference_sha256`·`site_anchor_commit` 을 이중 대조하고 merge-base 조상 검사를 써서 landing 실패. 4) CSS 스코프 우회(`.ai_box2` 같은 접두 일치) — `scoped()` 가 `(?![\\w-])` 로 막아 무산. 5) 실제로 착지한 것: 가명 진료 데이터의 신뢰 경계 이동 — 번들 유출(HIGH) · 두 번째 영구 산출물 복제(MEDIUM) · 무제약 덤프 싱크(LOW). |
| test | fail | 게이트 하네스 전문을 읽고 11개 검사 각각이 계획서의 어떤 주장을 반증할 수 있는지 대조했다. 공격한 축: (1) 접근성 주장 10항 vs gate6 정적 단언 + REQUIRED_TESTS 이름 집합 — 화살표/Esc/바깥클릭에 falsifier 없음 확인. (2) «차이 0건» 의 덤프 출처 미검증. (3) Task 별 Validate 명령이 그 시점에 실행 가능한가 — Task 5·6 순서 모순 확인. (4) Task 4 Validate 가 fixture 를 안 본다. (5) CSS 격리·공용 무변경의 표본 범위(front/public/css 4건이 실제 전수인지 glob 으로 확인 — 그 디렉터리는 전수이나 신규 파일·추가 스타일시트는 미검사). (6) vitest 대상 파일 범위. 반증 실패한 것들: gate10 의 마커↔등록↔계획서 Gate 참조 상등은 계획서 Gate 행(#0~#10)이 실제로 전건을 부르므로 성립; tsconfig.node.json 이 src/**/* 를 include 하므로 gate9 tsc 가 신규 파일을 실제로 본다; 기존 `panel-renders-nothing` 이 결함을 계약으로 굳힌 자리는 계획이 Task 7·Risks 에서 명시적으로 뒤집고 #7 이 이름 집합으로 붙들고 있어 지적으로 성립하지 않았다. |
| invariant | fail | gates.mjs 11개 게이트 전건을 열어 «알 수 없는 입력이 어느 쪽으로 떨어지는가» 를 추적했다. 열려고 시도한 자리: (1) tsc 호출 실패 — 종료코드 미검사로 통과 경로 확인(HIGH), (2) scanCss 의 stray `}`·중첩 at-rule·깊이 2 — 전부 hard fail 로 닫혀 있어 반박 실패, (3) `base.diagnostics` 키 부재 — 빈 집합이라 오히려 엄격, 반박 실패, (4) 스위치 롤백 — gate8 이 선언 정규식 + 앵커 대비 0행 diff 두 겹이라 Acceptance 의 «워킹트리에서만 켠다» 를 실제로 강제함, 반박 실패, (5) 동결본 앵커링 — reference_sha256·site_anchor_commit 이 하네스 ANCHOR 와 묶여 있어 unanchored approval 경로 없음(단 anchor_commit 만 누락, LOW), (6) `--only` 우회 — unknown id 는 exit 1 로 닫힘, 다만 #0 건너뜀은 성립(LOW), (7) vitest 산출 파일 부재/파싱 실패 — 둘 다 ok=false 로 닫힘, (8) tsconfig.node.json 이 `src/**/*` 를 include 하는지 실제 확인 — 커버리지 자체는 성립. 남은 결함은 덤프 출처 미앵커링과 하네스 자기 동결본 부재. |

## Measurement

<!-- Written by plan-review/cli.js record on EVERY exit path, pass or halt.
     Machine-readable; do not hand-edit. A null field means the axis was
     not observed, never that it was zero. -->

```json
{
  "verdict": "divergent",
  "source": "multi-agent",
  "layers": {
    "l1": "converged",
    "l2": "divergent",
    "l3": "divergent"
  },
  "quorum": {
    "responded": 4,
    "required": 3,
    "roles": 4,
    "of": 4,
    "passed": false
  },
  "wall_clock_ms": 379548,
  "halt_stage": "5.2e",
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:efac7a62fcee5d172323cb3f8475c614acd5e43399b588333fc90c11848ed91f",
  "plan_path": ".claude/plans/ai-insight-report-m1.plan.md",
  "recorded_at": "2026-09-08T09:06:52.210Z"
}
```
