# Plan Review Panel — ai-insight-report-m7

**Plan**: `.claude/plans/ai-insight-report-m7.plan.md` · **Plan version**: `sha256:501ca6498caa2363c14a0794788b82683c244c0cd3dbd69ac5ca7f6cd085973c`
**Verdict**: `unknown` via `hybrid`
**Quorum**: 4/? responses · 4 distinct roles (of ? fielded) · passed=unknown
**Layers**: L1 converged · L2 ran (quorum not evaluated) · L3 not fired

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | HIGH | 좌석 키의 정본은 네 자리가 아니라 다섯 자리다 — 렌더 진입점 `seatPoints(live, "<seat>")` 호출 목록이 다섯 번째이고, 계획서의 어떤 Task·게이트도 그 자리를 갱신하거나 검사하지 않는다. 신규 좌석 3종이 `ANGLE_SEATS`·`SEAT_ORDER`·`SEAT_EVIDENCE`·`SEAT_COLUMNS` 네 자리에 전부 서고 게이트 #4·#15 가 초록인 채로 화면에는 한 건도 서지 않을 수 있다. | insuniverse-site/front/src/features/analysisDetailView/ui/ANS015/ANS015.tsx:172-174 — `pointsSales: seatPoints(live, "tip.sales"), pointsWarn: seatPoints(live, "cau.warn"), pointsDuty: [...seatPoints(live, "duty.admit5y"), ...seatPoints(live, "duty.exam1y")]` — 좌석마다 리터럴 호출이 손으로 유지된다. 계획서 Files to Change 는 `ANS015.tsx` 의 Why 를 「상단 `meta` 요약 줄」로만 적고(plan:51), Task 6 은 `SEAT_COLUMNS` 를 「좌석 키의 **네 번째 정본**」이라 부르며(plan:246, Acceptance plan:467) 이 호출 목록을 세지 않는다. |
| architect | HIGH | Task 8(시효 좌석 2종)이 「게이트 #7 이 없는 이유」로 제시한 대체 게이트 #14·#15 는 그 Task 가 실제로 하는 일을 못 본다. #14 의 `EXPECTED_OUTPUT_PATHS` 21개에는 좌석(`seats[]`) 경로가 한 건도 없고, #15 는 `SEAT_COLUMNS` 항목의 정적 실재만 본다 — 두 검사 모두 `cov.expired`·`cov.soon` 문장과 5열 표가 「숨은 보험 내용」 블록에 실제로 실렸는지와 무관하다. | plan:260 「이 Task 가 더하는 것은 블록이 아니라 **시효 좌석 2종의 문장과 5열 표**이고 … 산출 키(`#14`)와 좌석 열 구성(`#15`)이 그 자리를 본다」 vs plan:107-129 `EXPECTED_OUTPUT_PATHS`(salesTip/order/summary/coverage/generatedAt/evidenceView/caution 만) · plan:334-336 `#15 FAIL — SEAT_COLUMNS 누락:` 는 열 구성 존재만 단언한다. |
| architect | MEDIUM | `summary` 절의 형상이 Task 5 와 Task 9 사이에서 어긋난다 — Task 5 는 객체를 배열로 펴고 whitelist 로 `detailKo` 만 싣는데, Task 9 는 화면에서 `summary.병력분석.detail_ko`(객체 접근 · snake_case 원본 키)를 읽는다고 적는다. 매퍼가 그 형상을 내주지 않으므로 화면 칸이 조용히 빈다. | plan:236 「`summary[]` — `key`·`count`·`detailKo`·`noteKo` (응답은 객체이고 화면은 순서가 필요하므로 배열로 편다)」 vs plan:266 「본인부담(`summary.병력분석.detail_ko`)」. `EXPECTED_OUTPUT_PATHS` 도 배열 쪽만 담는다(plan:119-120 `summary.0.key`·`summary.0.detailKo`). |
| architect | MEDIUM | `tip.crm` 좌석은 서버 3자리와 `SEAT_COLUMNS` 에 서지만 그것을 그리는 경로가 계획서에 없다 — Task 7 은 「고객 관리 추천 품목」 블록을 `report.salesTip.sections` 순회로 그린다고 하고, 좌석 축과 절 축 중 어느 것이 그 블록의 정본인지 계획서가 정하지 않는다. 결과적으로 같은 내용에 두 소유자가 생기고 그중 하나(좌석)는 소비자 없이 계약만 늘린다. | plan:252 「`PanelTip` 을 `report.salesTip.sections` 순회로 바꾼다」 vs plan:212·246 — 같은 「고객 관리 추천 품목」을 `ANGLE_SEATS`→`tip.crm` 좌석으로도 세우고 `SEAT_COLUMNS` 열 구성까지 요구한다. `seatPoints` 호출은 ANS015.tsx:172-174 넷뿐이다. |
| test | HIGH | Task 9 (상단 meta 요약 줄, ANS015.tsx) 이 편집하는 파일을 보는 게이트가 하나도 없다 — Task 를 통째로 건너뛰어도 Validate 가 초록이다. | plan:268 `- **Validate**: node .claude/plans/ai-insight-report-m7.gates.mjs --only 14,15`. gates.mjs 에서 `ANS015.tsx` 는 `OWNED_FILES`(gates.mjs:184, #10 의 tsc 오류 필터 문자열)로만 등장하고 어떤 gate 도 그 파일을 읽지 않는다(`P` 에 없다 — gates.mjs:190-201). `#14`(gate14, gates.mjs:864)는 서버 매퍼 산출만, `#15`(gate15, gates.mjs:905-929)는 `ANS015.insight.ts` 의 SEAT_COLUMNS 키와 `ANS015.css` 선택자 실재만 본다. 즉 Task 5·Task 10 만 하고 Task 9 를 0줄로 두면 `#14`·`#15` 가 전건 통과한다. 계획서도 plan:266 에서 '게이트는 재료…와 `.meta`·`.sep` 절이 서는지를 본다' 로 대리 지표임을 자인하면서, CLAUDE.md §Acceptance 가 요구하는 `(미검증)` 행을 Acceptance(plan:457-483)에 두지 않았다. |
| test | HIGH | Task 3 이 더하는 evidence 열 3종(`expiresOn`·`daysLeft`·`visitDays`)에 외부 오라클이 전혀 없다 — 유일한 Validate 가 같은 Task 가 저작하는 spec 파일이라 자기 인증이다. | plan:222 Action, plan:224 `- **Validate**: cd insuniverse-server && npx jest modules/ddx/insight-view.spec.ts`. 이 세 이름은 저장소·계약·게이트 통틀어 plan:44 와 plan:222 두 줄에만 있다(`.claude` 전역 grep 결과 2건). `EXPECTED_OUTPUT_PATHS`(plan:107-129)에 대응 경로가 없고 gate14 는 `insight-report.ts` 의 `toInsightReport` 만 본다(gates.mjs:872) — `insight-view.ts` 의 되맞춤 산출은 어떤 게이트도 실행하지 않는다. `#13`(gate13, gates.mjs:842)은 `BASELINE_SERVER_SPEC_NAMES` 기존 7종만 보므로 신규 단언을 0건 써도 통과한다. |
| test | HIGH | Task 8 의 실제 산출물(「숨은 보험 내용」 5열 표 · 시효 좌석 2종 문장 · `emptyBlocks[].message` 렌더)을 재는 검사가 없다. 게이트 `#15` 는 `SEAT_COLUMNS` 에 **키가 있는지**만 보고 열 구성이 무엇인지는 보지 않는다. | plan:262 Validate `--only 8,14,15`. gate15 는 `m[1].includes('"' + key + '"')` 로 키 문자열 존재만 확인한다(gates.mjs:914-917) — `cov.expired: []` 로 두면 0열 표가 서면서 통과한다(게이트 주석 자신이 막겠다고 적은 바로 그 상태다, gates.mjs:899-900). gate8 은 `NEW_BLOCKS` 3종만 순회하고 「숨은 보험 내용」은 그 목록에 없다(plan:102-106). 저장소에는 `tableByHeaders`(ANS015.test.tsx:108-115, plan:406 이 스스로 인용) 로 헤더 구성을 단언하는 선례가 있는데 Task 11(plan:280)은 그 자리에 헤더 단언을 더하지 않는다. |
| test | MEDIUM | 계획서가 「신규 블록 4종 양·음 단언」을 주장하는데 검사는 3종만 강제한다 — 주장과 검사의 건수가 어긋난다. | plan:53 `\| ANS015.test.tsx \| UPDATE \| 신규 블록 4종 양·음 단언 \|` · plan:50 이 Panels 변경으로 4블록(설계 방향·점검한 유형·추천 품목·숨은 보험 내용)을 든다. 그러나 `NEW_BLOCKS` 는 3종(plan:102-106)이고 gate7/gate8 은 그 3종만 순회한다(gates.mjs:646, 688). gate 주석 자신도 `NEW_BLOCKS 4종`(gates.mjs:619, 666)으로 적혀 있어 주석·계획서·코드 셋이 불일치한다. Acceptance(plan:464)만 3종이다. |
| security | HIGH | 이 창의 유일한 '실행 축' 유출 오라클(view-drops-person-name-and-identifiers)은 좌석 1종(`수술 전 경고`, row 형상)만 오염시킨다. 계획은 좌석을 4종→7종으로 늘리면서(신규 `cov.expired`·`cov.soon`·`tip.crm`) 이 오라클의 적용 범위를 넓히라는 요구를 어느 Task·게이트에도 두지 않았다. 결과: ddx 응답의 `고객 관리 추천 품목` 발견 evidence 에 `person_name` 이 실려 오고 구현이 그 좌석 경로에서 원본을 통째로 싣는 코드를 쓰면, `#5`(리터럴 정적, 변수 우회 가능) · `#6`(`toInsightReport` 전용이라 view 산출을 아예 보지 않음) · `#9`/`#13`(스펙 이름만 고정) 전부 초록인 채 실명이 클라이언트 와이어에 도달한다. | insuniverse-server/apps/api/src/modules/ddx/insight-view.spec.ts:102-137 — 오염 대상이 `finding('수술 전 경고', …)` 하나뿐이고 단언은 그 한 view 의 `JSON.stringify` 다. gates.mjs gate6(.claude/plans/ai-insight-report-m7.gates.mjs:599-611)은 `toInsightReport` 만 실행한다. 계획서 Files to Change 는 `insight-view.spec.ts` 를 "신규 좌석 배치와 되맞춤" 으로만 적고(plan.md:47), Acceptance 의 `(미검증)` 목록(plan.md:477-483)에도 이 사각지대가 없다. |
| security | MEDIUM | `tip.crm` 을 `kind:'aggregate'` 로 세우면 그 좌석의 근거 표는 제공자가 지목한 evidence 가 아니라 **서버가 `sent` 에서 상병코드+기간으로 다시 고른 진료행**이 된다. 이것은 `records` 를 NOT Building 으로 내린 근거("좌석 evidence 가 화면이 그리는 행을 이미 준다", "어느 좌석에도 걸리지 않은 진료까지 나간다")와 같은 축의 노출인데, 계획은 이 좌석에 대해 그 판단을 다시 하지 않는다. 또한 집계행의 실질 필드(`item`·`reason_ko`·`badge_ko`)는 `InsightEvidenceRow` 가 담지 않아 조용히 버려지며 `#14`(report 매퍼 경로만 본다)·`#15`(SEAT_COLUMNS 키 존재만 본다) 어느 것도 이를 재지 않는다. | insuniverse-server/apps/api/src/modules/ddx/insight-view.ts:353-380 `expandAggregate(raw, sent, …)` 가 `sent` 를 상병코드·`first`~`last` 로 재선별해 행을 만든다 / plan.md:214 "추천 품목은 **집계 형상**… `kind:'aggregate'` 가 성립한다" / plan.md:61 `records` 제외 사유 |
| security | LOW | 게이트 `#5` 의 `# 기대:` 마커가 대상 파일을 두 종으로 적고 있는데 실제 코드는 세 종을 본다. 계획서 Acceptance 는 세 종을 주장한다 — 계약 상등 게이트 `#12` 는 주석을 보지 않으므로 이 문서-코드 어긋남은 어떤 검사도 잡지 않는다. | .claude/plans/ai-insight-report-m7.gates.mjs:555 "`FORBIDDEN_KEYS` 7종이 `insight-report.ts`·`insight-view.ts` 소스에" vs :567 `for (const f of [P.REPORT_TS, P.VIEW_TS, P.CONTRACT_TS])`; plan.md:462 "서버 소스 **3종**" |
| invariant | HIGH | 게이트 #12 는 `HARNESS_CONTRACT` 상수 덩어리만 계획서와 상등 대조하고, 실제로 판정을 내리는 gate0~gate15 **함수 본문 전체**는 어떤 검사·digest 에도 덮이지 않는다. 그런데 계획서는 Files to Change 에서 이 하네스 파일을 구현 중 `UPDATE` 대상으로 명시적으로 허가하고, #12 가 「하네스만 느슨하게 고치는 길을 막는다」고 주장한다. 그 주장은 상수 축에서만 참이다 — `fail(...)` 을 `ok(...)` 로 바꾸거나 조건을 뒤집는 loosening 은 계약을 한 자도 건드리지 않으므로 #12 가 초록이고 plan_hash 도 안 바뀐다. 이 창의 증거 기반 전체가 하네스의 자기보고인데 그 자기보고에 앵커가 없다. | plan.md:71 「게이트 `#12` 가 이 블록을 … 계약 상수와 **문자 그대로** 대조한다. 하네스만 느슨하게 고치는 길을 막는다.」 / plan.md:54 「`.claude/plans/ai-insight-report-m7.gates.mjs` \| UPDATE \| … Task 가 계약값을 조정하면 여기서 갱신된다」 / gates.mjs:807 `if (norm(planned) !== norm(HARNESS_CONTRACT))` — 대조 대상이 `HARNESS_CONTRACT` 한 객체뿐이고 gates.mjs:346-932 의 판정 코드는 비교 대상이 아니다 |
| invariant | MEDIUM | `#4`(SEAT_EVIDENCE·SEAT_ORDER)·`#15`(SEAT_COLUMNS·CSS 선택자)는 소스 원문을 주석 제거 없이 `includes`/정규식으로 본다. 이 저장소는 `//*` 주석이 관례이고 gates.mjs 자신이 `#5` 에서 그 위험을 실측으로 적어 뒀는데, 같은 위험이 좌석 축 게이트에는 적용되지 않았다. 주석 한 줄 `//* 'cov.soon' 는 나중에` 로 `SEAT_EVIDENCE 형상 미선언`·`SEAT_COLUMNS 누락` 이 초록이 된다 — 계획서가 「좌석 키의 네 번째 정본」이라 부르며 「0열 표가 조용히 선다」를 막는다고 한 바로 그 자리다. | gates.mjs:541 `if (!seatEv[1].includes("'" + key + "'"))` · gates.mjs:915 `if (!m[1].includes('"' + key + '"') && !m[1].includes("'" + key + "'"))` · gates.mjs:927 `new RegExp("\\\\" + sel + "(?![A-Za-z0-9_-])").test(css)` — 셋 다 `read(file)` 원문. 대비: gates.mjs:576 `read(f).replace(/^[ \\t]*\\/\\/.*$/gm, "")` 와 :569-575 의 근거 주석 |
| invariant | MEDIUM | `#7` 의 **양 방향**은 단언이 아니라 여전히 substring 존재 검사다 — `filled-report-shows-blocks` 본문에 라벨 문자열이 있고 `not.toContain` 에 감싸이지만 않으면 통과한다. 주석 한 줄(`// 설계 방향 은 아래에서 본다`)로 초록이다. 하네스 자신이 「파일 전문 substring 검사는 게이트가 아니다」를 적고 음 방향만 `not.toContain` 으로 묶었는데, 검사 머리(`# 기대:`)는 「4종의 제목을 각각 양·음 두 방향으로 **단언**한다」고 주장한다 — 주장 ≠ 검사(층위 2). Task 7·11 의 유일한 양 방향 게이트다. | gates.mjs:619-620 `# 기대: … NEW_BLOCKS 4종의 제목을 **각각** 양·음 두 방향으로 단언한다` vs gates.mjs:647 `if (!b2.includes(label)) { fail(...) }` 와 :655 (양 방향은 부정 여부만 추가로 본다). gates.mjs:630-633 이 같은 함정을 이미 실측으로 적어 뒀다 |
| invariant | MEDIUM | `SEAT_EVIDENCE` 는 계획서 자신이 「어떤 ddx evidence 원본 필드를 화면으로 올릴지 정하는 자리」라고 규정한 whitelist 인데, 신규 좌석 3종에 대해 **키 이름이 선언됐는가**만 검사하고 그 키 집합이 동결본 `evidence_shapes` 와 일치하는지는 어떤 게이트도 보지 않는다. Task 2 가 적은 형상(기본 8키 + `expires_on` / `+days_left`, 집계 9키)은 검사 없는 산문 주장이고, 실제로 어떤 필드를 실어도 blacklist 인 `#5`(금지 키 7종)만 통과하면 초록이다 — 노출 축을 넓히는 창에서 whitelist 계약이 blacklist 로 강등된다. | plan.md:214 Task 2 의 형상 서술 · plan.md:462 「금지 키 7종이 … 리터럴로 없다 — 게이트 `#5` · 세 번째 파일이 어떤 evidence 원본 필드를 화면으로 올릴지 정하는 자리다」 vs gates.mjs:540-542 (키 존재만) — `contract.json:218-263 evidence_shapes` 와의 상등 대조가 하네스 어디에도 없다 |
| invariant | LOW | `#15` 의 CSS 축은 선택자 **문자열의 실재**만 본다. 빈 규칙(`.dir{}`)이나 주석 안의 `.dir` 로 통과한다. 계획서는 Task 10 에 대해 「`#11` 은 순수 음성이라 한 줄도 안 넣어도 통과한다 — 이식이 실제로 됐는지는 `#15` 가 선택자 5종의 실재로 본다」로 `#15` 를 유일한 양성 검사로 세웠는데, 그 양성 검사가 「이식됐다」가 아니라 「이름이 어딘가 적혀 있다」를 잰다. | plan.md:274 「이식이 실제로 됐는지는 `#15` 가 선택자 5종의 실재로 본다」 vs gates.mjs:925-928 |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | fail | Mirror 인용을 원문 대조했다 — `insight-contract.ts:15` `ANGLE_SEATS`(계획서는 :17, 객체 내부라 실질 일치) · `:70` `SEAT_EVIDENCE` · `insight-view.ts:146` `SEAT_ORDER` · `ANS015.insight.ts:56` `SEAT_COLUMNS` · `:9` 「판정은 서버」 주석 · `insightApi.ts:126` `InsightReportUnobserved` 는 전부 계획서 주장대로였다. 「좌석 키가 세 자리/네 자리」라는 단일 정본 주장을 실제 호출처 grep 으로 쳤고 다섯 번째 자리(`ANS015.tsx:172-174` 리터럴 `seatPoints` 목록)를 찾았다 — 이것이 finding 1·2·4 의 뿌리다. Harness Contract 덩어리 수(15)와 `EXPECTED_OUTPUT_PATHS` 건수(21)는 세어서 Acceptance·착수 실측과 일치했다. `toCritTable` 이 신규 좌석에서 `null` 로 떨어지는 것은 설계상 정상이라 지적하지 않았다. `SPEC_SEMANTIC_SHIFTS` 의 `mock-labels-are-gone-for-good` 완화는 Task 7 과의 충돌을 정확히 짚고 있어 반박하지 못했다. |
| test | fail | gates.mjs 의 #3(반사실 소비)·#7(양음 방향)·#8(모든 출현)·#12(양방향 상등)·#13·#14·#15 본문을 읽고 각각 과대허용 방향을 뚫으려 했다. #3 은 synthReport 가 INTENTIONAL_SKIP 8종을 비우지 않아(gates.mjs:295-305) `-Y` 방향이 실제로 살아 있어 반박 실패. #12 는 sortDeep 정규화가 양방향이라 계획서 쪽 추가분도 안 버려서 반박 실패. #10 은 tsc 기계고장 분기가 있어 반박 실패. #7 은 it() 본문 절단 + not.toContain 방향 판정까지 해서 반박 실패. 뚫린 곳은 Task↔게이트 대응: ANS015.tsx(Task 9)를 읽는 게이트 0건, insight-view.ts 되맞춤 산출(Task 3)을 실행하는 게이트 0건, SEAT_COLUMNS 값(열 구성)을 보는 게이트 0건. contract.json 에서 visit_days·expires_on·days_left 가 실관측임은 확인했으므로 그 축은 지적하지 않았다. |
| security | fail | 공격한 것: (1) `#6` 미끼 축의 사각지대 주장 — contract.json 에 `analysisId`·`externalRef`·`subject`·`caveat` 4종이 실재하고 snake_case 3종만 부재함을 확인, 계획서 Acceptance 480 의 진술이 정확해 반증 실패. (2) `#5` 의 주석 제거 순서 주장 — gates.mjs:576 이 줄 주석을 먼저 지우는 순서가 맞고 과검출 방향임을 확인, 반증 실패. (3) `#3` 반사실이 `INTENTIONAL_SKIP` 8종에 대해 죽어 있는가 — synthReport(:264,283,303-305)가 그 절들을 비우지 않아 양방향이 살아 있음을 확인, 반증 실패. (4) `ANGLE_NO_SEAT` 잔류/`SEAT_ORDER` 이중 정본 누락 — `#4` 가 세 자리 전부와 잔류를 본다(gate4 실측 FAIL 로그 :307-313), 반증 실패. (5) admin-view fail-open 반전 가드 — 이 창이 가드를 건드리지 않고 `#9` 가 `modules/(ddx\|analyze)/` 를 돌린다, 잔여 위험은 이미 Acceptance 476·479 에 `(미검증)` 으로 공개됨. (6) 절대경로·머신명 유출 — contract.json 은 키 이름만 담고 `#2` 가 실환자 값 0건을 보므로 반증 실패. 남은 것은 위 3건이며, 1)은 「좌석을 늘리면서 유일한 실행 유출 오라클의 범위를 안 늘린다」는 이 계획이 직접 만드는 구멍이다. |
| invariant | fail | gates.mjs 967행 전건과 contract.json·plan 본문을 대조했다. 열어 보려 한 게이트: (1) `#3` 반사실 — `INTENTIONAL_SKIP` 8종을 빈 값으로 두면 `-Y` 방향이 죽는 경로를 찾았으나 synthReport:264,303-305 가 이미 `{synthetic:true}` 로 막아 두었다(닫힘). (2) `#12` 한쪽 방향 정규화 — sortDeep 이 양쪽 전체를 직렬화하므로 계획서 전용 키가 조용히 사라지지 않는다(닫힘). (3) 기계 고장 경로 — tsc 미설치(loadInsightReport:315 · gate10:742), manifest 부재(gate1:369), 동결본 부재(gate2·3·4·6·14), spec 파일 부재(gate7·13), git diff 실패(gate11), 최상위 예외(:962) 전부 FAIL 로 떨어진다(닫힘). (4) `#6` 미끼 생성 — `subject`·`externalRef`·`analysisId`·`caveat` 는 contract.json meta_keys/empty_block_keys 에 있어 실제로 미끼가 생긴다(닫힘, 나머지 3종의 사각지대는 Acceptance 에 `(미검증)` 로 이미 적혀 있다). (5) `#14` 의 `false` 값 오판 — `approved:false` 가 undefined/null 검사에 안 걸린다(닫힘). 열린 채로 남은 것은 위 5건 — 특히 하네스 본문 자체가 어떤 digest·상등 검사에도 안 덮이는데 구현 중 UPDATE 로 허가돼 있는 점이다. |

## 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": "unknown",
  "source": "hybrid",
  "layers": {
    "l1": "converged",
    "l2": "ran (quorum not evaluated)",
    "l3": "not fired"
  },
  "quorum": {
    "responded": 4,
    "required": null,
    "roles": 4,
    "of": null,
    "passed": null
  },
  "wall_clock_ms": 818242,
  "halt_stage": null,
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:501ca6498caa2363c14a0794788b82683c244c0cd3dbd69ac5ca7f6cd085973c",
  "plan_path": ".claude/plans/ai-insight-report-m7.plan.md",
  "recorded_at": "2026-09-11T05:50:08.022Z"
}
```

### Recording degradations

- decision.json absent or unreadable — verdict recorded as unknown
