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

**Plan**: `.claude/plans/ops-doc-reconcile-intake-m3.plan.md` · **Plan version**: `sha256:05d53efddc5c6ba85c2d8c8a277b849f41dcaabbbf5653d77815915a04fe580d`
**Verdict**: `unavailable` via `multi-agent`
**Quorum**: 4/3 responses · 4 distinct roles (of 4 fielded) · passed=true
**Layers**: L1 converged · L2 converged · L3 not fired (classification=exit-nonzero blocking=true exit=1)
**Halted at**: `5.2e`

> Reason: hybrid mode requested but L3 did not run (classification=exit-nonzero blocking=true exit=1) — "requested" is not "happened"; source stays multi-agent and the gate fails closed (DD2)

## Findings

| Perspective | Severity | Claim | Evidence |
|---|---|---|---|
| architect | LOW | D4 says the census tool reads only the child's rc and the first token of stderr. But rows 7 and 8 of the classification table (`not-sampled` for rows >= 1, `observe-empty` for rows 0) also depend on the stdout line `observe_rows N`. The boundary contract is stated incompletely. | Plan D4: "계수 도구는 자식의 rc 와 stderr **첫 토큰**만 읽는다". Classification table rows 7 and 8: "드라이런 rc 0 · 행 ≥ 1" and "드라이런 rc 0 · 행 0". |
| architect | LOW | The `--observe` mode only reaches `observe-empty` if it leaves out the empty-row invFail that `build()` applies. The plan says the mode takes "the same observe functions" and mirrors `build`, but it never says to drop that guard. If an implementer copies it, `observe-empty` can never happen and empty PDFs are counted as `observe-fail:OPS_INV_INTAKE_OBSERVE` instead. `observeZip` itself already fails on docx/pptx/hwpx files that have no body parts. | ops/intake/extract-intake.mjs:418 `if (!raw.length) invFail('OPS_INV_INTAKE_OBSERVE', ...)`; :267 observeZip invFail on no parts. Task 1 Mirror: "runCheck 의 build 호출 형태". |
| architect | LOW | The zipWrite citation is off by one line. The export is at line 4112 and the function definition is at line 3161. | ops/cells/extract-cells.mjs:4112 `export { zipWrite, ...`; plan cites `ops/cells/extract-cells.mjs:4111`. |
| security | LOW | Real-name filenames that the pseudonym tokens do not cover could still be opened in memory during the dry run. The plan says so openly and marks the item (미검증, unverified) instead of claiming coverage. | plan.md:204 Risks row, and plan.md:237 Acceptance item marked '(미검증)' |
| security | LOW | The PII exclusion list is built outside the repo from the `pii_names` tokens. No gate can prove the derivation script and tokens left no trace in scratch or the terminal; the only safeguard is a written norm. | plan.md:156 says to put the derivation script in scratch outside the repo, use it, then delete it, and never write names or tokens to files, the terminal or notes. Gate #3 only compares the list against the rule. |
| test | MEDIUM | The claim that PII files are never opened (D9) is only checked by a line the tool prints about itself. Gate #2 just greps for the text `case pii-listed-unopened ok`, so a selftest that prints that line without checking anything would still pass. The only negative case for it is written as `(구현 시 실측)`, meaning it has not been observed yet. | .claude/plans/ops-doc-reconcile-intake-m3.gates.sh:135-137 (only checks `cnt -x "case $c ok"`); :128 `음성: (구현 시 실측)`. Gate #3 checks that the pii-listed set matches, but not that the files were left unopened. Gate #6 only counts name tokens in the output. |
| test | LOW | Gate #1 shows the `--observe` mode uses the same code as publishing only by comparing row counts, for 4 sample files. A different or reimplemented observe path that happens to give the same number of lines would still pass. The claim 'same function' (D4) is only weakly tested. | .claude/plans/ops-doc-reconcile-intake-m3.gates.sh:99-109 compares only `observe_rows` against `wc -l ops/intake/$id.jsonl`. Plan D4 says gate `#1` enforces 'same function' through the row count. |
| test | LOW | The `observe-empty` and `lock-pii-excluded` reasons are not in the gate #5 injection set. They are covered only by the self-printed selftest (observe-empty) or by nothing (lock-pii-excluded: lock pii_excluded is 0 and there is no selftest case). `absent.lock-pii-excluded` is only printed, never checked against a non-zero value. | gates.sh:393-402 want5 list; gates.sh:135-136 case list has no lock-pii-excluded; plan classification table row 4 and D11. |
| invariant | LOW | D5 only stops the whole count when the child process emits `OPS_INV_INTAKE_TOOL`. Any other failure of the machinery itself is recorded per file as `observe-fail:<token>`, `observe-fail:UNKNOWN` or `observe-timeout`. That includes a crashed child, a Node-level import error, or a missing binary that some observer reports under a different token. So a broken observer could fill every row with the same false reason, and the publish run would still succeed. `--check` would only catch this if the second run behaved differently, and on the same host it would repeat the same false result. | Plan D5: "도구 핀 불일치(`OPS_INV_INTAKE_TOOL`) ... 는 계수 전체를 멈춘다. 멈추지 않는 것은 입력 파일 단위의 결함"; classification table row 9 `observe-fail:<OPS_INV_토큰>`(토큰 모양이 아니면 `UNKNOWN`). Gate #3 only partly offsets this: it independently checks the PDF outcome, but .claude/plans/ops-doc-reconcile-intake-m3.gates.sh:300-301 and Acceptance line 233 admit the non-PDF outcomes (356 rows) are checked only against the allowed-reason set (`observe_outcome_unverified`). |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | pass | 1. Opened the cited lines in extract-intake.mjs and confirmed they say what the plan claims: invFail at 37, intakeTargets at 325, which reads only lock parsed rows, sourcesRoot at 337, assertTools at 378, report at 428, runCheck at 471 and IS_ENTRY at 576. 2. Confirmed reconcile.mjs:557 buildTargets/load and extract-cells.mjs:75 MAX_ARCHIVE_BYTES. 3. Tested whether running the observers in a child process (D4) is really needed. It is: the observe functions end the process through invFail, so running them in the same process could not keep counting after a failure. 4. Tested whether the stop-on-tool-pin rule (D5) fits the child boundary. loadTools and assertTools emit OPS_INV_INTAKE_TOOL, so the census tool can tell a pin failure from a per-file failure by that token. 5. Checked whether the child-process boundary really carries no file names. Only counts and the first token come out, so the path-carrying invFail messages stay behind the boundary. 6. Checked the output location (D2 subdirectory, G11) against extract-intake's rule that the lock owns the top-level `.jsonl` files. The design keeps the census out of the observer's publish path. 7. Tested whether D13/D15 add a second owner for `counts`. They do not: sources.lock and the manifest are left untouched. Result: no HIGH or CRITICAL structural defect. Three LOW findings: the D4 contract is stated incompletely, the empty-row guard can be copied by mistake, and one line citation is off by one. |
| security | pass | I checked the plan's trust boundaries and places where data could leak, and found nothing HIGH or CRITICAL. 1. **Paths or names in committed files:** The census output and census.lock hold only sha256 hashes and counts (D8, UI4). Gate #6 checks their shape, and the findings table is generated from census.jsonl rather than hand-written (plan.md:168). The M3 plan puts no absolute path into a committed artifact. The only absolute path is a command-line argument to the `--observe` child process (D4). 2. **Opening a PII file anyway:** Files on the PII list are hashed only and are never passed to the observer or to `unzip` (D9). A zip is expanded only when its own hash is not on the list. The self-test case `pii-listed-unopened` uses a synthetic file that makes the test fail if it is opened (gate #2). 3. **Hostile zip input:** D3 checks for link entries, the declared unpacked total and a depth limit before extracting. Extraction goes to a 0700 `mkdtemp` directory outside the repo, the walk does not follow links, and `unzip` strips `../` and absolute entries by default. I could not get a traversal to land. 4. **Text leaking out of the child process:** Gate #1 checks `observe_extra_lines 0`, so only one count line comes out, and the parent reads just the return code and the first stderr token. 5. **Partial or forged census.lock:** Gate #3 checks that the PII list matches the rule and that the bundle pins are correct (`census_lock_bundles_ok`). Gate #6 checks the lock's shape. I found no fallback to a weaker field. 6. **Out of scope:** M2's db-probe masking belongs to M2, as the plan's NOT Building section says (plan.md:224), so I did not count it against this plan. I read the plan by targeted grep and did not open the extract-intake source. |
| test | pass | I read the plan and the Validation sections of gates.sh for #1, #2 and #5, plus the grep index of every check. I tested the two strongest claims. 1) Source defects don't halt the count: gate #5 injects real faults, checks 9 expected reason counts, and confirms the umbrella check still stops on partial input. The OPS_INV_INTAKE_OBSERVE token it expects on an empty pdf really exists at ops/intake/extract-intake.mjs:278. 2) The received-file count is checked by an independent reader (python zipfile in #3) against the full record multiset, and the plan records a negative run on an empty census (738 rows only in the oracle). I checked that every Validate line names a gate key that actually exists (observe_row_mismatch, selftest_case_missing, partial_mismatch). I looked for over-permissive paths and found only the weak spots above. No existing test encodes the behaviour being changed, since census.mjs is new and --observe is a new flag. |
| invariant | pass | Read the plan (Summary, Design Decisions D1-D16, classification table, Tasks, Validation, Acceptance) and gates.sh checks #3-#9. Attacked: (1) anchoring — the gate script is pinned by sha256 in the plan's Validation block and refuses to run on a mismatch; the plan hash covers that pin. (2) Missing input — an unset or absent OPS_INTAKE_BUNDLE_ROOT stops at gates.sh:45; the gates require oracle_total>=1, bundles_matched==declared>=1 and observe_spawned>=1, so an empty walk cannot pass as 0/0/0. (3) The new tolerance for missing sources — it is confined to census.mjs; gate #5 checks that verify-ops.sh --check on the partial tree still exits 1 with OPS_INV_SET_EQUALITY, so the umbrella gate was not loosened (UI3). Publish and --check refuse when the bundles do not match (D10), so a partial count cannot overwrite the committed file. (4) Report mode — gate #8 reads the `verdict` line rather than rc, because rc is 0 even on a golden mismatch. (5) PII scan — it fails when a scan target is missing (PII_SCAN_TARGET_ABSENT) or when there are zero tokens. (6) Tree mutation — gates #4 and #5 compare `git status` before and after. (7) Default direction for unknown values — an unknown ext, a non-token stderr, or a timeout all land in `failed`, never in read or excluded. The only residual gap is the LOW item above: machinery failures other than OPS_INV_INTAKE_TOOL get recorded as per-file failures instead of stopping the run. I found no route where a failure ends in approval. |

## 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": "unavailable",
  "source": "multi-agent",
  "layers": {
    "l1": "converged",
    "l2": "converged",
    "l3": "not fired (classification=exit-nonzero blocking=true exit=1)"
  },
  "quorum": {
    "responded": 4,
    "required": 3,
    "roles": 4,
    "of": 4,
    "passed": true
  },
  "wall_clock_ms": 120109,
  "halt_stage": "5.2e",
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:05d53efddc5c6ba85c2d8c8a277b849f41dcaabbbf5653d77815915a04fe580d",
  "plan_path": ".claude/plans/ops-doc-reconcile-intake-m3.plan.md",
  "recorded_at": "2026-09-17T08:23:37.629Z"
}
```
