# Plan Review Panel — ops-doc-reconcile-intake-m5

**Plan**: `.claude/plans/ops-doc-reconcile-intake-m5.plan.md` · **Plan version**: `sha256:8c4ae95b48ce206fdabf787ee67ace110d697b0d496dc0f17fb1ca5945e4a943`
**Verdict**: `divergent` via `hybrid`
**Quorum**: 4/3 responses · 4 distinct roles (of 4 fielded) · passed=true
**Layers**: L1 converged · L2 converged · L3 divergent
**Halted at**: `5.2e`

> Reason: L1+L2 converged but L3 (Codex) returned divergent

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | LOW | [out_of_scope] The D2 pool rule ties the intake2/ prefix to M5, so later milestones must pick a different prefix. That extension point relies on a convention, not a structural guarantee. The plan already says this (R1 architect LOW). | plan D2: "`intake2/` 는 M5 전용 접두다 — 뒤 마일스톤은 다른 접두를 쓴다" |
| security | LOW | The host-path pattern in check #9 only matches `/home/`, `/tmp/` and Windows `C:\\Users`. It does not catch other absolute roots such as `/root/`, `/mnt/`, `/Users/` or `/var/`. On this Linux host the bundle and staging directories sit under `/home`, so the path-leak case the plan cares about is covered. I found no current input that would reach a leak through this gap, so it is a residual limit rather than a defect. | .claude/plans/ops-doc-reconcile-intake-m5.gates.sh:923 `HOST = /\\/home\\/\|\\/tmp\\/\|[A-Za-z]:[\\\\/]Users/` |
| security | LOW | Check #7 binds `run_nonce` to the answer files, but the author writes that nonce. So nothing proves that the pre-send PII check ran, or that the two external model runs really happened. The plan already lists both as unverified in Acceptance. What it commits (prompt.md, codex.json, fable.json) is covered by the #9 PII, pseudonym-token and host-path scan. That leaves only the transient input actually sent to the models unverified, and the plan says so openly. | plan:324-325 Acceptance (미검증); gates.sh:938-940 scans r2codex/r2fable/r2prompt |
| test | LOW | Check #3 cannot catch a pool file that was wrongly left out of candidates.jsonl, for example one that scores higher but is missing. Its order and allocation recomputation only uses the rows the tool itself published. Only the tool's own --candidates-check re-derivation, which shares the same code, would see the omission. | gates.sh:396-435 builds byQ and picked only from `rows` read from CAND. The plan's NOT Building section says a second observer for the whole pool is excluded under INV1. This was already disclosed and rejected in R1 as a layer-8 blind spot. |
| invariant | LOW | The registration gate does not bind the human-review sha to an independent record. `sensitive_reviewed_sha256 === sha256` is written by the author, so the gate proves the two values are consistent with each other. It does not prove a person actually reviewed the file. The plan already lists this as unverified, so no gate is presenting itself as proof it cannot give. | Lines 540-541 of .claude/plans/ops-doc-reconcile-intake-m5.gates.sh check `s.sensitive_reviewed_sha256 === s.sha256` and require `sensitive_note.length >= 10`. Plan D8 and Risks line 286 mark the image-name check as (미검증), i.e. unverified. |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | pass | Checked the plan's citations against census.mjs. Line 403 does map lock parsed to read/lock-parsed. Lines 149-167 hold LIVE_TEMPS and the process.on('exit') sweep, which does run when invFail skips finally. Line 575 is build()'s finally that cleans up the unpack directory. D6 and D7's cleanup design depends on these lines, and they say what the plan claims. Tried to break D2's pool re-derivation: after registration, the not-sampled rows become read. The pool adds back lock-parsed intake2/ rows, so --candidates-check re-derives the same set, and self-test case candidates-pool-intake2 covers this. Looked for a boundary leak in D4's scoring child. Only integers leave the process, and it reuses the runObserve path rather than a copy. Checked D6 for duplicated rules. Staging and candidate selection reuse build() instead of adding a new tool, so there is no second copy of the unpack, nested-zip or PII rules. Checked whether the D14 exemption list matches Files to Change: the four exempted files match the ops/ paths outside ops/intake/**. Found no structural defect I could back with evidence. |
| security | pass | These are the attacks I tried, and none reached a consequence. 1. Unconfirmed file text leaking out of the scoring child. D4 limits the child's output to integer lines, and the #2 probe checks that Hangul and phone-number text do not appear. 2. The staging directory. D6 fixes its location, rejects the target if its real path is inside the repo, if it is a link, or if it already exists, sets 0700/0600 permissions, hands cleanup to the existing LIVE_TEMPS exit hook when a halt skips the finally block, and check #4 fails if the directory is still there. I could not get staged files to land in a tracked path. 3. Trusting the `intake2/` path prefix to decide what goes back into the candidate pool. A pool row only exists after a person has registered it in the lock, and check #4 matches it by id and sha to the reviewed-hash field. A forged prefix would need a lock write, which already requires human review. 4. PII in the committed artifacts: review2 prompt.md and answers, candidates, new lock rows, mask rules, new observation files, new statements, the findings M5 section, the PRD diff and notes. Check #9 runs the canonical `piiHit` check and the pseudonym-map token check over every one of them (gates.sh:936-957). Its handling of 64-char hex hashes is disclosed. 5. A pseudonym prefix creating a keyword match. D4 replaces pseudonyms with boundary characters before scoring. 6. Absolute paths leaking into notes, the earlier `cwd` precedent. Task 13 requires placeholders and check #9 greps for host paths. 7. Bypass through rejected candidates. They are not kept anywhere, and check #4 fails if any are left in the sample tree (`intake2_extra`). The only residual limits are the two LOW items above. |
| test | pass | I read the whole plan and the Validation sections of gates.sh for checks #3, #4 and #7. What I attacked: (1) Whether #7 detects an over-permissive result, meaning the full/pass verdict is recorded without being earned. #7 re-scores with the M4 rule outside the tool (gates.sh:741-766), compares the verdict and basis (line 779), requires the target string (line 780), and ties review.lock to the prompt file, the nonce and both answer files (lines 800-831). The forged-nonce limitation is disclosed as unverified in the Acceptance section. (2) Whether #3 enforces the per-question and total caps and pool membership. It does: rank ≤ capQ at line 405, pool membership comes from the BASE census at line 399, it recomputes the allocation itself at lines 426-435, and it enforces 1 ≤ picked ≤ CAP_TOTAL at line 495. The recount compares against a raw score run separately. (3) Whether every Task's Validate line names a check that exercises what the Task changed. Tasks 1-14 map to #0-#10, and the selftest case names are checked in #2. (4) Whether any existing test pins the old behaviour. The M4 selftest expectations for answer-rule and verdict-rules are explicitly rewritten in Task 4, and the M3/M4 frozen gates going red is disclosed in D13. The only gap I found is the one already disclosed and listed above. |
| invariant | pass | I tried to get each gate to pass on missing or degraded input. Missing bundle root: stops at the first line with BUNDLE_ROOT_UNSET (gates.sh:76). Absent or unreadable candidates.jsonl in #4: the catch falls through to picked 0, so every new row counts as reg_not_picked and REGISTERED_RANGE fails (gates.sh:526, 545, 583). Selftest outputs captured with `\|\| true`: the census and metrics runs still need a named `case X ok` for every case plus the final ok line, and the observer now also has to exit 0 (gates.sh:257-282). Stage directory left behind, including a symlink: caught at gates.sh:579. #4 PII-list and disk-hash bypass: both are checked per row, and a lstat failure counts as a failure. Missed target check: qar_target_ok is enforced (gates.sh:840). Drift between the reviewed and executed gate file: the plan pins the gates.sh sha256 before running it (plan:274-276). Rollback: D13 and the 4-file waiver keep the starting 157 statements and 17 lock rows unchanged, and #0 checks both. Accounting: CAP_TOTAL is enforced both on allocation and on registration. A missing reviewer stops the run instead of dropping to a single reviewer (D10). Reviewer forgery and whether the external review actually ran are openly marked unverified rather than claimed. |

## 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": "hybrid",
  "layers": {
    "l1": "converged",
    "l2": "converged",
    "l3": "divergent"
  },
  "quorum": {
    "responded": 4,
    "required": 3,
    "roles": 4,
    "of": 4,
    "passed": true
  },
  "wall_clock_ms": 121122,
  "halt_stage": "5.2e",
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:8c4ae95b48ce206fdabf787ee67ace110d697b0d496dc0f17fb1ca5945e4a943",
  "plan_path": ".claude/plans/ops-doc-reconcile-intake-m5.plan.md",
  "recorded_at": "2026-09-22T07:42:22.413Z"
}
```
