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

**Plan**: `.claude/plans/ops-doc-reconcile-intake-m4.plan.md` · **Plan version**: `sha256:43fb93dd199caf1749f54138c8c50ec8c83308a49cb49ef1e8a4bc519a444c43`
**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 | The atomic-requirement prose table uses ' · ' both as the separator between requirements and inside a single requirement, so the rendered list does not match its own counts. Q26 lists four items but says 3. Q49 lists three items but says 2. The gate literal REQS is the canonical source and is internally consistent, so the plan's claim that the table is 'the same content as REQS' holds only if you read the separators the gate's way. The merged pairs also cut against D8's statement that conjunctions are always counted separately. | plan:173 'Q26 \| 3 \| 규칙의 현재 유효 · 폐기 여부 · 폐기 시점 · 폐기한 커밋'; plan:184 'Q49 \| 2 \| ...바로잡을 수 있는지 · 개발자 개입이 필요한지 · 개발자 개입일 때...'; gates.sh:124 '"Q26": ["규칙의 현재 유효 · 폐기 여부", "폐기 시점", "폐기한 커밋"]'; gates.sh:135 (Q49 2 elements) |
| test | MEDIUM | D7 says the external negative in gate #2 enforces the ordering "review.lock 입력 검사는 자식을 하나도 띄우기 전에 한다". No check would catch that ordering being wrong. The gate only asserts rc 1 and a first stderr token of OPS_INV_METRICS_INPUT, with a 300 s timeout. If the tool ran every child first and validated the lock afterwards, it would still exit 1 with the same token. The plan's own recorded negative expects the token to differ in that case, but nothing supports that: the gate never records whether any child was spawned, how long the run took, or whether the lock was checked before the first spawn. | gates.sh:239-246 (`XN_RC`, `XN_TOK` only; `timeout 300`). The recorded negative at gates.sh:211 is "입력 검사를 자식 실행 뒤로 미룬 사본 → 도구 밖 음성이 수 분 걸리고도 토큰이 달라야 한다". Plan D7 claims "검사 `#2` 의 도구 밖 음성이 이 순서를 문다". |
| test | LOW | Nothing checks that category values are valid. D8 allows only `reason`/`intent`/`exception`/`none`, but gate #4 skips any value that is not a counted category. A typo such as `Reason`, or any free string in `categories[].codex/fable`, is silently treated like `none`. Answer slots get strict shape checks (ANSWER_SLOT_MALFORMED); categories have no equivalent. The category counts could therefore undercount without any violation being reported. | gates.sh:536-537: `if (c.codex !== c.fable \|\| !(c.codex in cnt) \|\| !stated(c.id)) continue;`. Only the id set is checked (gates.sh:510 CATEGORIES_IDS). Plan §review.lock 형상 fixes the values as "`reason`/`intent`/`exception`/`none`". |
| test | LOW | Two frozen requirements in the atomic list still join two conditions with the same ` · ` separator the plan uses to split enumerations. The split-count lower bound in gate #1 cannot catch this, because it splits only on sentences and commas. It is borderline: both are close to either/or questions rather than lists. | gates.sh:124: `"Q26": ["규칙의 현재 유효 · 폐기 여부", ...]`. gates.sh:135: Q49 `"...바로잡을 수 있는지 · 개발자 개입이 필요한지"`. gates.sh:150-153: `parts` splits on `/\\.\\s+/` and `/,\\s+/` only. Plan D8 says the list counts every enumeration and conjunction separately. |
| invariant | LOW | The atomic-requirement table in the plan looks like it has a different count per question than the frozen literal it says it matches. Q26 lists 4 items separated by middle dots under count 3, and Q49 lists 3 items under count 2. The gate literal groups the extra items into single requirements, so the gate is consistent with itself. But a reviewer who reads the plan table would think a question has more requirements than the gate enforces. Fewer requirements make a 'full' answer easier, which is the less strict direction. | plan.md:173 'Q26 \| 3 \| 규칙의 현재 유효 · 폐기 여부 · 폐기 시점 · 폐기한 커밋' vs gates.sh:124 '"Q26": ["규칙의 현재 유효 · 폐기 여부", "폐기 시점", "폐기한 커밋"]'; plan.md:184 Q49 vs gates.sh:135 |
| invariant | LOW | Check #5 prints expected_unmeasured, but its exit decision does not require that value to be 2. The comment says it is fixed at 2. Today the value is still pinned in practice: OPS_SOURCES_ROOT is always exported, a missing bundle root halts the gate, a missing pseudonym map halts the gate, and OPS_DB_CSV_ROOT is unset. So the gap is not reachable now, but the claimed pin has no assertion behind it. | gates.sh:672 say("expected_unmeasured "...) ; gates.sh:674 ok = Object.values(o).every(...)... (no expectedUnmeasured term); comment gates.sh:583 '둘로 고정된다(`expected_unmeasured 2`)' |

## Refutation attempted

| Perspective | Verdict | What was attacked |
|---|---|---|
| architect | pass | 1. Checked the mirror citations against the code. census.mjs:905 is IS_ENTRY. check-statements.mjs:314 and :346 are the PII_PATTERNS and piiHit exports. verify-ops.sh:608 is opsSnapshot. reconcile.mjs:2150 is makeFixtureTree. All four say what the plan claims. 2. Tested whether importing piiHit from check-statements breaks the no-side-effects-on-import boundary. It does not: the module only runs main behind its IS_ENTRY guard (check-statements.mjs:2816). 3. Tested D4's claim that metrics.mjs is only an aggregator. The one piece it re-implements is the PII check, and the plan names that exception and imports the canonical function rather than copying it. 4. Tested D6's read of golden.txt for self-reference. The plan limits it to three key types, which leaves out counts.children_present (the key that metrics' own output moves). verify-ops is never edited, so the reproduce_ran lines stay stable when Task 7 regenerates golden after Task 4 publishes. 5. Checked that the gate's two reviewer 'ops' sets rebuild the 21-question union (gates.sh:37-38). 6. Checked the gate's REQS literal against the prose requirement table for the separator-ambiguous questions. Only the LOW inconsistency above turned up. |
| security | pass | I tried five attack paths. None produced a finding. 1) Host path leaking into committed files (the old cwd-leak shape). Gate #3 (gates.sh:268) counts any metrics.jsonl line containing the repo root or '/home/'. Gate #9 (gates.sh:868-905) runs the same check on everything under ops/intake/metrics/ (review.lock included), the findings M4 section, the lines the PRD adds and the notes. Task 7 also says bundle paths are written as a placeholder. I could not find a new committed field that escapes both scans. 2) PII hidden behind the hash exclusion (plan D11). A value is exempt only if it matches the anchored pattern ^(sha256:)?[0-9a-f]{64}$. Such a value cannot hold a phone number, national ID, email or Korean name. The name-token scan still runs over hashed values too, and the excluded count is printed (hash_excluded), so nothing is dropped silently. 3) Pseudonym tokens appearing in output. Gate #9 says it never prints the tokens or the matching lines, and its recorded negative run confirms the token was not printed. 4) Forged or partial review.lock answers. Each reviewer cell must have exactly the expected shape or the tool stops with OPS_INV_METRICS_INPUT. Cited statement ids must exist (Task 2 checks for absent ids). statements_sha256 must match or the gate halts. The denominator is re-derived by gate #1 from frozen literals. A hand-edited lock can still move the answer-rate number, but that is an honesty/review problem, not an escalation, because the lock is committed and reviewed. 5) Sending statement text to Codex and Fable (Task 2). The plan confirms zero pseudonym tokens in the prompt input before sending and ships only id/doc/kind/confidence/text. This data already passed the existing check-statements PII gate, so the plan adds no new data class to the outbound path. Other paths checked: the fixture tree is created with mkdtemp outside the repo and run from a relative path (gates.sh:409-421), so it cannot write into the real tree. Children run under the ops/ before/after hash snapshot. The umbrella verify-ops and the manifest are not touched, so no bypass env var or override is added. |
| test | pass | I read the whole plan and gates.sh: #1 (literal and split lower bound), #2 (selftest case names and the external negative), #3 (shape, --check, re-publish cmp), #4 (independent recount of all 16 rows, answer and category rule re-application, PII hash exclusion) and #5 (SPEC literal and verdict recomputation). Findings: - Wrongly approving: the answer rule is recomputed outside the tool, including the both-reviewers requirement, `conflict` must be a boolean, and only `stated` statements count. Gate #5 recomputes verdicts, so a row falsely marked pass is caught. - Validate lines: every task has one, and each maps to a gate that runs it. - Two strongest claims: the observed values are independent of the tool (#4 recomputes them from raw data and fresh tool runs) and the published file is reproducible (#3 regenerates it in a worktree and compares with cmp). Both hold. - Weak spots: the input-check-before-children ordering claim in D7 has no test that would catch it being false, and category enum values are not validated. - No existing test pins the old behaviour, because the plan changes no existing tool. I did not re-check the gates.sh pin sha, since that needs a command. |
| invariant | pass | I attacked six things and found no blocker. 1) Could an unknown input make a gate pass? Check #5 recomputes each row's verdict from the gate's own environment. A missing input row must be recorded as unmeasured with the exact basis, and a missing file makes every row ID_MISSING, which fails. 2) Could a stale review pass? Check #4 fails with STATEMENTS_SHA_STALE (gates.sh:505). 3) Could a changed environment turn rows into unmeasured and slip through? The sources root is always exported (gates.sh:43), a missing bundle halts (gates.sh:67), a missing pseudonym map halts (gates.sh:592), and the DB root is unset (gates.sh:16). 4) Anchoring: the plan pins gates.sh by sha256 before running it, and Task 8 forbids editing the gate during implementation. 5) Record-only rollback: the check #0 allow-list plus 'verify-ops.sh and manifest unchanged' in check #7. 6) Hash exclusion in the PII scan: the D11 regex covers only 64-character hex values, and the excluded count is reported. I did not re-run the gates. The two LOW findings above are wording and unused-assertion gaps, not fail-open paths. |

## 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": 148258,
  "halt_stage": "5.2e",
  "backlog_appended": null,
  "backlog_skipped_nonblocking": null,
  "granted": 4,
  "reviewed_plan_hash": "sha256:43fb93dd199caf1749f54138c8c50ec8c83308a49cb49ef1e8a4bc519a444c43",
  "plan_path": ".claude/plans/ops-doc-reconcile-intake-m4.plan.md",
  "recorded_at": "2026-09-22T03:37:44.988Z"
}
```
