# Plan Review Panel — samsung-account-intake-hardening-m1

**Plan**: `.claude/plans/samsung-account-intake-hardening-m1.plan.md` · **Plan version**: `sha256:04f1b35570b1428935223aa8d276677a63e162f625ddf2125315779743e65a76`
**Verdict**: `divergent` via `multi-agent`
**Quorum**: 4/3 responses · 4 distinct roles (of 4 fielded) · passed=false
**Layers**: L1 converged · L2 divergent · L3 not fired

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

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| security | LOW | The intent check (DD7) only runs when the client sends source=paste, so the client decides whether it runs. This is not exploitable: without source, the same text goes down the xlsx path, which rejects it with E0819 before any judgement is made. A paste can therefore never reach confirm without the check. | plan DD3: "`source` 가 없으면 지금 코드 그대로(ZIP·CFB 가 아니면 E0819)"; Task 2 paste it 5; GROUND 5 samsung-intake-xlsx.ts#L137 |
| security | LOW | The plan correctly counts passwords before row edits and exclusions (applyRowEdits), so a user cannot turn the warning off by editing or excluding rows. Row edits cannot forge the count. | plan Task 3: "`applyRowEdits` 전의 `read.rows` 에서 비밀번호 있음 건수를 세어"; intent it 2 |
| test | LOW | The #5 counterfactual checks only the mustFail/mustPass subsets. So when the mismatch is always false, the endpoint its that expect true (endpoint 4 delete→true, endpoint 6 formatSuspected) also go red but are not reported, and the '붉음 2건' line under-reports how discriminating the check is. This does not open a hole: the extra red its fail in the safe direction. | gates.mjs:196-208 filters CF.mustFail/CF.mustPass only; plan L308 '의도 대조 it 둘만 붉고' |
| test | LOW | [out_of_scope] Front-end behaviour (withSource only sets source for PASTE_TYPE, onPaste keeping no raw text in state) has only a static contract check (#6) and one live pass, not a behavioural test. The plan states this openly (관측 13, NOT Building), and fixing it would need test infrastructure, which INV1 forbids. | plan L87, L102, L229 |
| invariant | HIGH | The paste intent check (UI6/DD7) can be turned off by the user's column mapping. The password count is taken from rows that were already read through the user's layout. If the password column is set to 'not used' in the column-mapping step, the count becomes 0, so a delete request built from a register list (which has passwords) no longer triggers format-suspected and E0822 never blocks it. This is the exact harm the check exists to stop (accounts suspended). A user who believes the request is a delete has a natural reason to drop the password column, because delete does not need it. The plan says 'screen manipulation cannot turn it off' and tests only excluded rows and edited values, not unmapping the column. | Plan DD7: "계수는 화면 수정 전 · 뺀 행 포함 판독 행의 비밀번호 있음 건수" and "뺀 행·고친 값으로 세면 화면 조작만으로 경고가 꺼진다". Task 3: rejudge reads `readIntakeRows(buffer, dto.filePassword, layout, dto.source)` and counts from `read.rows`, which depends on layout.fields.password. Task 3 intent it 2 covers only (a) exclude and (b) password edit. The Risks row "뺀 행·고친 값으로 못 끈다(`#4` intent it)" says nothing about unmapping. Neither #4 nor #5 fails when the layout sets password:null. |
| invariant | LOW | The rollback claim is asserted, not pinned. 'Revert only the front end and the server keeps running on the old contract' holds only for requests without source, and it is covered only by the source-absent it (E0819). No test pins that parse or preview responses without source are identical to the old ones: passwordIntentMismatch is added to every response, and the only check is the parse spec's toEqual. | Plan Risks row: "되돌림 경로 … 프런트만 되돌려도 서버는 옛 계약으로 돈다". Task 3 adds `passwordIntentMismatch: false` to the parse toEqual. No preview-summary equality check for xlsx input. |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | pass | 1. Checked whether withSource really is the single place that marks paste input. In api.ts:301-311, parse, preview and confirm are the only Axiosinstance.post calls in that folder. The FormData builders at IntakeUploadForm.tsx:75, IntakePreview.tsx:790 and IntakeConfirm.tsx:169 all go through those calls, so no caller skips the marker. 2. Checked whether the source flag reaches the reader through one path. readIntakeRows and readIntakeLayout both call openSheets (samsung-intake-xlsx.ts:82 and :104). Their only other callers are the controller (:93) and the service rejudge (:555). 3. Checked whether the password count happens before row edits. applyRowEdits runs after the read at service.ts:560, and the plan counts on read.rows before that line, so excluded or edited rows cannot turn the warning off. 4. Checked whether paste input would always be flagged as a suspicious format. At service.ts:406-416, headerUnexpected is suppressed by layoutConfirmed. sheetIntentMismatch needs the sheet name to map to an intent, and '붙여 넣은 표' should map to none, so the new flag joins formatSuspected cleanly. E0822 blocking uses the same value (:700). 5. Checked that the paste module is a pure function. It depends only on the IntakeSheet shape and has no I/O. The rule for the paste check lives in one function, and both the controller and the judge call it. No structural defect found. |
| security | pass | Hostile scenarios tried, none of which reached a harmful outcome: 1) Sending pasted text without source to skip the intent check: the xlsx path returns E0819 first (DD3, GROUND 5). 2) Sending a real xlsx with source=paste: it is read as garbage text, so there is no gain. 3) Sending source values other than paste: @IsIn plus a whitelist pipe rejects them (Task 3, endpoint it 3). 4) Using exclude or password edits to switch the warning off: the count is taken before edits (DD7). 5) Plaintext passwords leaking through request logs: the text travels as the multipart file part, which the body serializer does not log, and it is covered by the log it (GROUND 8, Task 3). The filename is a constant with no path or user data in it. 6) Leaks through exceptions: E0825 is a named error with a constant message, and unnamed errors are folded into E0819. 7) Denial of service from a large paste: a 1 MiB check runs before splitting (DD5), on top of the existing 10 MiB file cap. 8) A real .tsv picked in the file chooser being treated as a paste: the marker MIME type is one the OS never assigns (DD2). Even if that happened, the server still only reads it as text. 9) The raw paste showing on screen or being kept in state: DD6 keeps value empty and stores only counts, checked by #6. 10) Durable leakage: synthetic fixtures only, raw request files blocked by #7, and baseline and notes carry no absolute paths (T is a temp directory). One residual not re-checked here: the intent check itself can be bypassed by a legitimate operator who ticks the confirmation box, which is by design and carries the same weight as the xlsx sheet-name check. Lines 349-422 of the plan (Acceptance tail and notes) were not read. |
| test | pass | 1. Checked the it count. The plan promises 12 required its (paste 5 + intent 2 + endpoint 4 + log 1), and Task 3's item list matches that number. 2. Checked the #5 counterfactual against the gates.mjs counterfactual code. Only the subset is checked, so the extra red endpoint its do not make the gate fail. 3. Looked for the over-permissive direction. The intent check cannot be switched off by excluded rows or edited passwords (intent it 2). A confirm without acknowledgement is blocked with E0822 (endpoint 6). The xlsx path cannot turn the check on (intent it 1, undefined case). The tsv spoofing path is blocked by the PASTE_TYPE marker. If withSource fails, the request falls back to E0819, which fails closed. 4. The parse spec toEqual is expected to change, and the plan already lists this (관측 9 / Risks). The new field is added and the password-absence assertion is kept, so this is not an old test pinning the bug. 5. Checked that the Validate paths are real: gates.mjs exists, the spec paths are created by the plan, and #2 runs the whole fc/agency directory. I found no HIGH defect. |
| invariant | fail | Checked the paths where a source marker is present or missing. A paste file sent without source falls back to E0819, which blocks. A .tsv chosen with the file picker cannot pass as a paste, because the check relies on a custom MIME type. Checked where the password count is anchored: taken before edits, including excluded rows. I tried to turn the intent check off through excluded rows, edited values and column mapping, and the column mapping path gets through. Checked that formatSuspected feeds formatBlocked (E0822) through an OR. Checked #5: it tests only the single mismatch expression and does not guard the counting input. Checked the claimed rollback path. I read only the plan (lines 1-348). I did not open the server sources or the PRD, so the findings rest on the plan's own descriptions. |

## 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": "not fired"
  },
  "quorum": {
    "responded": 4,
    "required": 3,
    "roles": 4,
    "of": 4,
    "passed": false
  },
  "wall_clock_ms": 149109,
  "halt_stage": null,
  "backlog_appended": 2,
  "backlog_skipped_nonblocking": 5,
  "granted": 4,
  "reviewed_plan_hash": "sha256:04f1b35570b1428935223aa8d276677a63e162f625ddf2125315779743e65a76",
  "plan_path": ".claude/plans/samsung-account-intake-hardening-m1.plan.md",
  "recorded_at": "2026-09-22T05:07:02.101Z"
}
```
