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

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

> Reason: L2 quorum not satisfied: 7 blocking finding(s): architect/HIGH, architect/FAIL, test/HIGH, test/FAIL — MCCP_REVIEW_SINGLE_PASS=deferred_to_prd_completion 로 진행한다. verdict는 divergent 그대로 봉인된다.

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | HIGH | `resolveInsightState` 의 판별 규칙이 이 계획이 스스로 «핵심» 이라 부른 구분(만료 vs 원래 진료 없음)의 한 사분면에서 확실히 틀린다. 규칙은 «진료 0건 + 분석 나이 > 30일 → expired» 인데, 원래부터 진료가 0건이었던 분석은 31일째부터 자동으로 `expired` 로 뒤집힌다. 즉 진료 기록이 애초에 없던 고객에게 화면이 «보존기간» 원인 어휘를 낸다 — 이 계획이 미러로 든 `ANS003BDiagnosis` 의 «none 과 0건을 같은 화면으로 접지 않는다» 규약을 서버 쪽에서 그대로 깬다. 판별에 쓸 수 있는 입력이 없어서가 아니다: 스윕은 `ttTreat*` 만 지우고 `tn_analysis_basic` 은 남으므로(원본 확인) 「원래 진료가 있었는가」를 가릴 재료가 조회 시점에 실재하는데 순수 함수의 정의역(`analysisAgeDays`·`treatmentCount`·`ddxStatus`)이 그것을 받지 않는다. `blindSpotLabels` 15종 어디에도 이 사각지대가 없고 `view-separates-no-treatments-from-expired` 는 30일 이내 표본만 볼 수 있어 이 오분류를 잡지 못한다. | plan.md:274 「`expired` = **진료가 0건이면서** 분석 생성일이 30일을 넘었다 … `no_treatments` = 30일 이내인데 진료가 0건이다」 · plan.md:274 「앞의 둘을 가르는 것이 핵심이다」 · insuniverse-server/apps/api/src/modules/treat/treat.repository.ts:445-467 (`deleteExpiredData` 는 `ttTreatBasic`·`ttTreatDetail`·`ttTreatPrescribe`·`ttTreatCarDetail`·`ttTreatMedicineSummary` 만 지운다 — `tn_analysis_basic` 은 대상이 아니다) · plan.md:239-255 `blindSpotLabels` 에 해당 항목 0건 |
| architect | MEDIUM | 안내 표의 소유 규칙이 자기모순이다. `toInsightView` 의 서명은 `notices` 를 응답과 **별개 인자**로 받는다(출처 `tn_analysis_basic.asbNotice`, 스윕 대상 아님). 그래서 `expired` 에서도 안내가 산다는 것이 계획의 논거인데, 같은 문단이 `unavailable` 에서만 안내를 함께 내린다고 하면서 이유를 「ddx 응답이 없으면 반사할 대상 자체가 없다」로 적는다. 반사 대상은 응답이 아니라 우리가 보낸 `notices` 이고 `unavailable` 에서도 그 입력은 그대로 있다 — 이유가 함수 자신의 정의역과 어긋난다. 게이트 `notices-follow-state-not-treatments` 는 이 어긋난 규칙을 그대로 동결해, 잘못된 쪽이 계약이 된다. | plan.md:274 「`toInsightView({ response, sentTreatments, notices, analysisAgeDays, ddxStatus })`」 · 같은 줄 「안내 표는 **진료 원본이 아니라 상태를 따른다**: `expired`·`no_treatments` 에서도 그대로 서고 `unavailable` 에서만 함께 내려간다(ddx 응답이 없으면 반사할 대상 자체가 없다)」 · 같은 줄 「보낸 `notices` 를 그대로 표 축으로 되돌리고」 · PRD:54 「호출자가 보낸 것의 반사 \| 지급제한 안내 표」 |
| architect | LOW | 필수 검사 이름 `subtab-badges-come-from-summary` 가 담지 못하는 예외가 계약 안에 이미 있다. 하위 탭 넷 중 `tip` 은 대응 요약 키가 없어(`subtabWithoutSummary: "tip"`) 배지가 요약에서 오지 않는데, 계획은 `ANS015Panels.tsx` 가 배지를 «상수가 아니라 프롭으로» 받도록 바꾸면서 `tip` 배지의 값 소유자를 어디에도 적지 않는다. 상수를 걷어내는 파일 목록에 `ANS015.fixture.ts` 가 있어 그 값이 어느 쪽에 남는지가 두 소유자 사이에 뜬다. | plan.md:142 「"subtabWithoutSummary": "tip"」 · plan.md:76 「`ANS015Panels.tsx` … 네 좌석과 배지·안내 표를 상수가 아니라 프롭으로 받는다」 · plan.md:172 required test 「subtab-badges-come-from-summary」 · PRD:89 「`영업 단계 팁` 배지 숫자의 근거 … 이 탭만 대응 키가 없다」 |
| security | MEDIUM | 게이트 #4·#8 은 요구 검사의 '실행됨' 을 `names.includes()` 로만 본다 — `it.skip`/`it.todo` 가 이름은 실재시키고 실패 목록에는 안 들어가므로, IDOR 방어의 유일한 실행 축(`insight-route-rejects-non-canonical-odd-id`)과 PII whitelist 실행 축(`view-drops-person-name-and-identifiers`)이 건너뛴 채로 그 게이트가 초록이 된다. 같은 하네스의 #5·#6·#9·#12 는 `missingOrSkipped()` 를 쓰는데 하필 이 둘만 R2 test F1 이 닫았다는 그 형태로 남아 있다. 사정거리는 Task 4·Task 5 의 부분 실행(`--only 7,8,9` · `--only 4,5,6`)이며, 전건 실행에서는 #12 가 뒤늦게 잡는다. | .claude/plans/ai-insight-report-m2.gates.mjs:1185-1187 (`if (!names.includes(n))`) · :851-854 (`if (!names.includes("view-drops-person-name-and-identifiers"))`) 대 :950 · :1279 의 `missingOrSkipped(...)`. 흡수 주석 :1809-1813 이 이 함정을 「계획서의 반증가능성 척추 전건이 그 형태였다」로 이미 이름 붙였다. |
| security | LOW | 게이트 #7 의 키 유출 검사는 site 서브모듈로만 한정돼 있어, 이 창이 **superproject 에 새로 커밋하는** fixture 4종(`.claude/plans/…request-fixture.json` · `…response-fixture.json` 등)에는 `X-API-Key`·`DDX_API_KEY` 검사가 한 건도 돌지 않는다. 이 파일들의 계보는 실제 ddx 프로브이며(#4 주석이 인정), 프로브 전사(요청 헤더 포함)를 `_provenance` 나 본문에 굳히면 키가 이력에 영구히 남는데 어떤 게이트도 반응하지 않는다. | gates.mjs:1086-1106 — 스캔 정의역은 `gitSite(...)` 와 `SITE_SCOPE` 뿐. 대조: gates.mjs:833 이 같은 fixture 4종에 대해 금지 식별자·`facility_name` 어휘는 돌리면서 키 문자열은 돌리지 않는다. (기존 `ai-insight-roundtrip.json` 에는 헤더 흔적 0건임을 실측 확인해 현재 실현된 유출은 없다 — 잔존 위험이다.) |
| test | HIGH | 실제 생산자(`analyze.repository.ts` 인사이트 입력 조회)의 산출 형상·컬럼 매핑을 반증하는 검사가 하나도 없다. `request-is-byte-stable` 은 손으로 기록한 fixture 입력에 매퍼를 재생할 뿐이고, 그 fixture 가 저장소가 실제로 내는 것과 같은지는 어떤 게이트도 묻지 않는다 — 내 렌즈의 전형적 결함(hand-built fixture ≠ real producer). | 계획서 Files to Change 는 `insuniverse-server/apps/api/src/modules/analyze/analyze.repository.ts` 를 UPDATE 로 올리고 Task 4 Action 이 「`tt_treat_basic` 에서 `oddId` 의 진료 원본을(약국 제외는 `getTreatment7DayGroups` 선례를 따르되…), `tn_analysis_basic` 에서 `asbNotice` 를 상병별로 접어 `occurrences`·최근 진료일·시술 기록 유무와 함께 낸다」고 규정하지만, Task 4 Validate 는 `--only 7,8,9`(키 참조 수·라우트 등록·표 쓰기 0건)로 전부 정적/부정 축이다. 게이트 하네스의 `REQUIRED_TESTS_SERVER`(.claude/plans/ai-insight-report-m2.gates.mjs:210-234)와 `SERVER_SPEC_PATTERN`(:252, `modules/(ddx/insight-(payload\|view)\|analyze/analyze-insight-route)\\.spec`)에 repository 스펙이 0건이다. 약국 제외 여부·`deductible_amt` 컬럼 선택·`asbNotice` 접기(「추적 중」 판정의 재료)가 전부 틀려도 #1~#17 전건이 초록으로 남는다. |
| test | LOW | `#2` 가 판정하는 「레퍼런스와 같다」는 구현자가 고른 동결 응답 1건에 대해서만 성립하며, `#17` 의 입력 앵커는 사후 교체만 막고 최초 선택의 자의성은 막지 못한다. | gates.mjs:333 BLIND_SPOTS — 「동결 응답 fixture 의 대표성 — `#2`·`#17` 은 동결 뷰모델 **1건**에 대해서만 … 판정한다」. `#2` 의 유도 축(`tableShape.rows`)이 레퍼런스 행수와 상등을 요구하므로 응답 fixture 는 사실상 레퍼런스 형상에 맞춰 저작될 수밖에 없다. 매 실행 공시되므로 은폐는 아니나, 「화면 구조가 레퍼런스와 같다」의 반증력이 그만큼 낮다. |
| invariant | HIGH | Task 1 의 「러너 산출을 읽지 못하면 빈 동결본을 쓰지 않고 --baseline 자체가 비0 으로 멈춘다」는 코드와 반대다. writeBaseline 은 판독 불가 여부를 검사하기 **전에** 두 동결본을 이미 디스크에 쓴다. 그 결과 `failing: []` · `suites: []` · `_unreadable: true` 인 빈 동결본이 남고, 어떤 게이트도 `_unreadable` 을 읽지 않는다 — `#13` 의 `judgeSuite` 는 `frozen.suites \|\| []` 로 「사라진 스위트 0건」 축을 공허하게 통과시키고(gates.mjs:1463), `judgeBaselineHead` 도 head 3종만 본다(gates.mjs:1850-1863). 게이트 기계 자신의 실패가 승인 축 하나로 퇴화한다. | .claude/plans/ai-insight-report-m2.gates.mjs:1958 (`fs.writeFileSync(R(P.SUITE_BASE), …)`) 가 :1969-1971 의 `if (val._unreadable) { … return 1; }` 보다 앞선다. 같은 파일 :1941 의 tsc 동결본도 무조건 쓰인다. 계획서 .claude/plans/ai-insight-report-m2.plan.md:262 「러너 산출을 읽지 못하면 빈 동결본을 쓰지 않고 `--baseline` 자체가 비0 으로 멈춘다」 |
| invariant | HIGH | `--rebaseline` 이 회귀 게이트(`#11`·`#13`)를 구현 수정 없이 초록으로 만드는 열린 길이다. 서브모듈 워킹트리에 미커밋 구현을 둔 채 `--rebaseline` 을 돌리면 `observedHeads()` 는 여전히 앵커 HEAD 를 관측하므로 `judgeBaselineHead` 가 통과하고, 그 시점의 신규 tsc 진단·신규 실패가 전부 «착수 시점부터 있던 것» 으로 동결된다. 유일한 흔적 `rebaselined_at` 은 실패가 아니라 `console.log` 공시다 — 차단이어야 할 자리가 경고로 내려앉았다. | .claude/plans/ai-insight-report-m2.gates.mjs:1864-1866 `if (base.rebaselined_at) { console.log(… 공시 …); }` — 어떤 분기도 `bad += 1` 을 하지 않는다. :1926-1928 `observedHeads()` 는 `rev-parse HEAD` 만 보고 워킹트리 dirty 여부를 보지 않는다. 계획서 NOT Building :105 「회귀 게이트를 구현 수정 없이 초록으로 만드는 길을 열지 않는다」 |
| invariant | LOW | `#10` 은 site 서브모듈에서만 `.css` 변경·미추적을 센다. Acceptance 와 NOT Building 은 「서브모듈에서 변경된 `.css` 0건」으로 서술해 server 트리를 포함하는 것처럼 읽히는데, 게이트의 정의역은 site 하나다 — 주장과 검사의 사정거리가 어긋난다(층위 6). | .claude/plans/ai-insight-report-m2.gates.mjs:1324-1335 는 `gitSite(...)` 만 쓴다. 계획서 :101 「이 창은 `.css` 파일을 한 건도 만들거나 고치지 않는다 … `#10` 이 변경된 `.css` 0건을 요구한다」 · Acceptance :360 |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | fail | 인용 원문 대조: ddx-verdict.service.ts:297-303·399-417(적재 직후 deleteAnalysis — 사실), own-analysis.guard.ts:31-32·38-43(비유한 oddId 통과 · 주문 부재 시 403 — 사실), treat.repository.ts `deleteExpiredData`(trbCreatedAt 30일 — 사실), ddx.client.ts:11 파일 수준 「재조회는 GET 만」 주석 실재(사실, Task 4 가 정정을 명시), ddx.client.ts:99 `cfg` 선전개 후 authHeaders 덮기(미러 주장 사실), analyze.controller.ts:45-47 클래스 수준 `JwtAuthGuard, OwnAnalysisGuard` + :239 라우트(사실), :276 `B2cAnalyzeController` 동거(사실), diseasePredictionApi.ts:308-318 「정회원 축 하나 · 소비자 둘」 주석과 isAdminViewPath 분기(미러 인용 정확), AdminViewMemberAnalysisPage.tsx 의 ANS015 0건(사실 — admin-view NOT Building 근거 성립). 공격한 축: 1) 상태 오라클의 정의역이 판별 대상을 실제로 덮는가 → 덮지 못하는 사분면 발견(위 HIGH). 2) 안내 표 소유가 응답인가 입력인가 → 서명과 이유가 어긋남. 3) angle 좌석/무좌석/요약 매핑의 전수성 → 7종·4키 전부 계약에 자리가 있어 반증 실패. 4) fixture 두 사본의 갈라짐 seam → `#17` 이 실제로 덮고 단독 clone 한계도 명시돼 있어 반증 실패. 5) seatEvidence 집계행 vs rejoinTuple 충돌 → NOT Building 과 `#1` 이 소유자를 명시해 반증 실패. 6) admin-view 축 이관이 조용한 누락인가 → NOT Building 이 소유자를 M4 로 적고 `#8` 이 부재를 강제해 반증 실패. |
| security | pass | 공격한 것: 1) 가드/파이프 발산 IDOR — `own-analysis.guard.ts:31-32,38-46` 원문을 읽어 `Number('1e3')=1000` vs `parseInt('1e3',10)=1` 경로가 실재함을 확인했고, 계획이 `String(oddId)===oddIdRaw` 정규형 요구와 `insight-route-rejects-non-canonical-odd-id` 로 닫는 것을 확인 — 다만 그 검사의 skip 사각지대가 finding 1 이다. 2) 키 유출 — `ddx.client.ts:92-108` 이 `e.config` 를 버리고 `authHeaders()` 를 `cfg.headers` **뒤에** 펴 덮어쓰기가 불가함을 원문으로 확인, 클라이언트 축 무결. 3) 절대경로 유출 재발 — `writeBaseline`/`suiteKey`(:1884-1888)가 스위트 경로를 `/src/` 기준 상대로 접고 tsc 설정오류는 `relativizeRoot` 를 타는 것을 확인, 홈 디렉터리 유출 경로를 못 만들었다. 4) admin-view fail-open 증폭 — `#8` 이 admin 컨트롤러 라우트 부재를 강제하며 `ROUTE_RE` 의 global lastIndex 상태도 호출 순서상 오염되지 않음을 추적, 실패. 5) 무적재 우회 — `getInsight` 밖 free-function 쓰기(`this.` 아님)는 정적 축 밖이나 BLIND_SPOTS 「적재 0건의 사정거리」가 공시하고 Acceptance 도 `this.<협력자>` 로 한정 서술해 거짓 주장이 아니므로 finding 으로 올리지 않았다. 6) fixture 실환자 유입 — 금지어 3종의 무력함을 계획이 스스로 인정하고 `facility_name` 어휘 가둠으로 대체한 것을 확인, 추가 반증 실패. |
| test | fail | 계획서 전문과 하네스 #0~#5·#6·#7·#12·#13·#14·#17 본문을 읽었다. 공격한 것: (1) Task↔Gate 대응 전수 — 18개 게이트가 모두 어느 Task 의 Gate 행에 실려 있고 미참조 게이트는 0건이었다. (2) #6(c) 의 배타성 주장이 항진 검사인가 — 이전 판의 상수 대 상수 검사는 이미 #15(f) 로 옮겨졌고 구현 배타성은 실행 축 `state-copy-names-its-cause` 가 진다. 반증 실패. (3) #14 반사실이 값싼 뒤집힘을 세는가 — `success!==true` 가 아니라 지정 이름의 사라짐/실패로만 판정하고, 서버·프런트 두 정의역을 따로 변이시켜 **둘 다** 요구한다. 반증 실패. (4) #3·#17 이 스펙의 인라인 기대값을 통과시키는가 — `extractItBody`+`stripComments` 로 그 `it` 본문 안의 fixture 참조를 요구한다. 반증 실패. (5) #12 가 `it.skip` 을 통과로 접는가 — `missingOrSkipped` 가 닫았다. 반증 실패. (6) #2 의 기준 파일(M1 동결본)이 흔들리는가 — 정직성 축이 레퍼런스에서 재유도해 대조하고 실측 음성 관찰이 붙어 있다. 반증 실패. 남은 것은 위 두 건이며, 첫 번째(저장소 질의의 무검사)는 BLIND_SPOTS 어디에도 공시돼 있지 않다. |
| invariant | fail | `#0` 앵커 조상성·digest 4종, `#2` 의 M1 동결본 정직성 축과 이탈 상등, `#3`·`#4`·`#5`·`#6`·`#12`·`#17` 의 미실행/건너뜀 판정(`missingOrSkipped`), `#13` 의 수집 실패 접힘, `#14` 의 양방향 반사실과 복원 확인, `#15` 의 계약 상등·`extractAxes` 동일성, `#16` 의 PRD 앵커, `#11` 의 tsc 판정 유무를 전부 열어 보려 했고 대부분 fail-closed 였다. 열린 자리는 세 곳: `--baseline` 이 판독 실패를 확인하기 전에 동결본을 쓰는 순서, 그 `_unreadable` 을 읽는 게이트의 부재, 그리고 `rebaselined_at` 이 실패가 아니라 로그인 점. 미커밋 워킹트리 + `--rebaseline` 경로를 끝까지 추적해 `#11`·`#13` 이 둘 다 초록으로 떨어지는 것을 확인했다. |

## 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": 658675,
  "halt_stage": null,
  "backlog_appended": 7,
  "backlog_skipped_nonblocking": 6,
  "granted": 4,
  "reviewed_plan_hash": "sha256:b9855b0ad16a899c88f3c0dc29162080a5c836f1672a3678ab9e7b842adbab07",
  "plan_path": ".claude/plans/ai-insight-report-m2.plan.md",
  "recorded_at": "2026-09-09T06:27:41.251Z"
}
```
