# Okstra Plan-Body Verification Contract

## When to Use

- ONLY when the run's task-type is `implementation-planning` — no other task-type runs this round
- As a Phase 6 sub-step, AFTER the Report writer worker's draft is reviewed — see [okstra-lead-contract](./okstra-lead-contract.md) "Phase 6 sub-step: Plan-body verification". Do NOT read this file during Phase 5.5 finding convergence; it is not part of that loop.
- Companion to [convergence](./convergence.md): finding convergence reconciles worker **findings** (`F-*`); this contract verifies the **consolidated plan body** (`P-*`) authored by the Report writer worker. The two queues are disjoint — see "MUTUAL EXCLUSION" below.

This contract defines a **second, independent** verification round that fires only for `task-type = implementation-planning`. The round verifies the *consolidated plan* that the report-writer worker has authored, not the worker findings that were already reconciled earlier.

## Lifecycle position (BLOCKING)

Plan-body verification runs **after** finding convergence and **after** the report-writer draft is written. Sequence inside a single implementation-planning run:

```
Phase 4   workers produce independent analyses (Findings F-001…)
  → Phase 5.5   FINDING convergence ([convergence](./convergence.md), sections "Convergence Algorithm" through "Convergence State Artifact")
  → Phase 6   report-writer authors report-writer-narrative Markdown (consolidated Option Candidates / Stepwise Execution Order / Dependency / Validation Checklist / Rollback)
  → okstra plan-items prepare + prompt + validate-prepared creates and projects the deterministic P-* queue
  → PLAN-BODY VERIFICATION ROUND ← this contract
  → final render/validation
  → User Approval gate (the frontmatter `approved:` flip is honoured by run-prep only when this round's Gate result is `passed` or `passed-with-dissent`)
  → implementation phase (separate run)
```

Plan-body verification MUST NOT replace, precede, or be conflated with the Phase 5.5 finding convergence above. They are two distinct rounds with different inputs (findings vs. consolidated plan body), different ID schemes (`F-*` vs. `P-*`), and different state files.

## MUTUAL EXCLUSION (BLOCKING)

The finding queue (Phase 5.5, [convergence](./convergence.md)) and the plan-item queue (this contract) are **disjoint**:

- A finding-convergence reverify prompt MUST NOT contain any `P-*` item.
- A plan-body verification prompt MUST NOT contain any `F-*` finding.
- The two rounds write to **different state files**: `runs/<task-type>/state/convergence-<task-type>-<seq>.json` (findings, see [convergence](./convergence.md) §"Convergence State Artifact") vs. `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (plan items, see §"`plan-body-verification.json` schema").
- Aggregation logic (verdict counting, classification) MUST NOT carry votes from one queue into the other.

Mixing the two queues — for example, parsing a Phase 6 draft's Stepwise Execution Order step as if it were an `F-*` finding — is a contract violation. Future Claude reading this contract: if you find yourself tempted to "just reuse the finding queue for plan items, they're similar enough", stop. They are not similar enough; the verdict semantics differ (see §"Plan-body verdict semantics" below).

## Configuration

Plan-body verification is configured under `convergence.planBodyVerification` in `task-manifest.json`:

| Setting | Default | Description |
|---------|---------|-------------|
| `enabled` | `true` | If `false`, the round is skipped and the approval gate is not blocked by this round (legacy behaviour). |
| `maxRounds` | `1` | Upper bound. Plan-body verification is consistency / completeness checking, not fact checking — additional rounds rarely help. Range 1–3. |
| `selfFixMaxRounds` | `1` | One report-writer rewrite at most. The initial verification is round 1; targeted re-verification is round 2 after that rewrite. |
| `gating` | `true` | If `true` (default), `majority-disagree` blocks approval. If `false`, the round is advisory-only and never blocks approval. Prepare emits `true` because the plan does not exist yet. After the report-writer draft, `okstra plan-items prepare` (and `seed`) flip it to `false` when `designPreparation.mode` is `no-design-inputs` and the Stage Map has exactly one row. That path keeps extraction and one verification round and does not run the self-fix loop or a sweep batch. Two-or-more stages, a PREP item, or non-empty `designPreparation.items` keep `gating=true`. `--no-plan-verification` is the separate manual opt-out (`enabled=false`). **Enforced:** `okstra_ctl.plan_items.advisory_plan_body_gating`, `validators/validate-run.py` `_validate_advisory_plan_body_gating`. |

Default values are emitted into the manifest by `scripts/okstra_ctl/render.py` (`_build_convergence_block`). The ctx knob `OKSTRA_PLAN_VERIFICATION=false` flips `planBodyVerification.enabled` to false. `gating=false` is not that opt-out: extraction and one round still run.

The shared Majority definition and the auto-disable rule (fewer than 2 analyser workers → advisory `gating=false` path) are owned by [convergence](./convergence.md) §"Convergence Algorithm" / §"Configuration" and apply here unchanged.

## Plan-item extraction (Round 0 equivalent)

From the report-writer's draft of `## 5.4 Implementation Plan Deliverables`, the lead creates the verification queue only through this sequence (see also `templates/reports/final-report-v2.template.md` §5.5.9):

```text
okstra plan-items prepare --narrative <report-writer-narrative.md> --run-manifest <run-manifest>
→ place `okstra plan-items prompt --run-manifest <run-manifest>` output verbatim in every verifier prompt
→ okstra plan-items validate-prepared --narrative <report-writer-narrative.md> --run-manifest <run-manifest>
```

The persisted `items[]` are the sole queue. The lead MUST NOT freely summarise,
select, omit, reorder, or renumber items. A verifier prompt may use the compact `subject`
as its heading, but it MUST include the lossless `payload` for the item's evidence and
judgement. The final `validate` command confirms that the persisted queue exactly matches
the current draft before verdict aggregation.

The deterministic extractor assigns one contract-specific direction prefix, followed by the shared execution prefixes.

### Legacy candidate-comparison branch

| ID | Source | Payload |
|---|---|---|
| `P-Opt-<N>` | `4.5.1 Option Candidates` | one Option (its File Structure list + interfaces + blast radius); verify its trade-off claims and consistency with the recommended option |

### Selected-direction branch

| ID | Source | Payload |
|---|---|---|
| `P-Dir-1` | `implementationPlanning.directionRealization` | exactly one selected-direction realization; compare it with `selectedDirectionRef` and the byte-verified snapshot |

`P-Dir-1` verifies the core mechanism, architecture boundaries, planning invariants, and any hidden direction change. An AGREE verdict means `directionRealization` preserves those properties from the snapshot named by `selectedDirectionRef`; it does not re-score candidates or recommend another direction. A required direction change is a `direction-invalidated` result, not a planner rewrite.

### Shared execution items

| Prefix | Source sub-section | One row per |
|--------|--------------------|-------------|
| `P-Step-<N>` | `4.5.4 Stepwise Execution Order` | one step (path + command + success signal) |
| `P-Dep-<N>` | `4.5.5 Dependency / Migration Risk` | one dependency row |
| `P-Val-<N>` | `4.5.6 Validation Checklist` | one checklist item |
| `P-Rb-<N>` | `4.5.7 Rollback Strategy` | one rollback path |
| `P-Req-<N>` | `4.5.8 Requirement Coverage` | one requirement coverage row |
| `P-Prep-S<stage>-<kind>` | Stage `designSurfaceCoverage` + `5.5.10 Implementation Design Preparation` | exactly one detector-produced `(stage, kind)` |
| `P-Var-<N>` | `5.5.11 Variation-Point Analysis` | one variation point (its `behavior` + `extractionDecision`), or a lone `P-Var-0` when the plan declares no variation point |

For legacy candidate-comparison plans, `4.5.2 Trade-off Matrix` and `4.5.3 Recommended Option` are NOT extracted as standalone plan items — the trade-off matrix is evaluated implicitly through each option's `P-Opt-*` verification, and the recommended option is one of those `P-Opt-*` rows. Selected-direction plans contain neither section and use only `P-Dir-1` for direction preservation.

Each plan item inherits the `[TICKETID: ...]` tag of its source section (per the standard ticket-tagging contract).

For every detector-produced `(stage, kind)`, extract exactly one plan item named `P-Prep-S<stage>-<kind>`. The V1 detector in `scripts/okstra_ctl/design_surfaces.py` owns the set: extraction consumes its output and never reruns a free-form requirements or keyword analysis. The worker receives the stage trigger evidence and its single `designSurfaceCoverage` row, plus only the referenced `designPreparation` PREP items needed to judge that row. `okstra_ctl.plan_items.expected_plan_item_ids()` and `validators/validate-run.py` enforce the exact ID set, while `_validate_detector_coverage` enforces exactly-once coverage/evidence.

When extracting each item, lead also captures a **`subject`** — a plain one-line label (≤12 words) describing *what that item is* in the reader's terms, e.g. `P-Opt-1` → "Option A: split upload v2 into a new module", `P-Step-1.1` → "Stage 1 Step 2: regression-check with `npm run test:v2`". This is a label-capture, not new analysis. The `subject` is what §5.5.9 renders as the per-item heading so the reader knows *what* each AGREE/DISAGREE is about without cross-referencing §4.5; a bare `P-*` ID with no subject is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_item_subject_substance` fails a subject that is a placeholder — under 3 chars, equal to the item id, or shaped like a bare `P-*` id.

## Fact and judgement (BLOCKING)

A single-vote block has a reason behind it: two spelled-out references
contradicting each other is a fact one verifier can settle by looking, and a
fact must not be outvoted. What was missing is that the fact was never checked —
writing "this path does not exist" was enough to block.

So a DISAGREE on a single-vote kind declares what sort of claim it is:

- **`claimKind: fact`** — okstra can reproduce it. Carry a `reproduction` probe:
  `path-exists` / `path-absent`, `literal-present` / `literal-absent`, or
  `citations-differ`. **okstra runs it and writes `reproductionResult`; do not
  write that field yourself.** A verifier that reports its own result puts the
  gate on a self-report, and the same claim then settles differently depending
  on who raised it.
- **`claimKind: judgement`** — no mechanical check exists. It takes a quorum,
  exactly like `b` / `c` / `e`.

A claim that cannot be written as one of those three probes is a `judgement`.
That is a classification, not a demotion: giving a single-vote block to something
no machine can confirm is what was wrong in the first place.

| Declared | Reproduced | Effect |
|---|---|---|
| `fact` | `reproduced` | blocks on one vote |
| `fact` | `not-reproduced` | quorum, and the claim is recorded as unfounded |
| `fact` | `not-runnable` | quorum — okstra could not judge it, which refutes nothing |
| `judgement` | — | quorum |
| nothing declared | — | blocks on one vote, as before |

The last row is deliberate. Verdicts recorded before this field existed are not
re-judged in hindsight, so adoption only ever relaxes: a claim earns the quorum
route by declaring itself, never loses a block by staying silent.

**Enforced:** `validators/validate-run.py` `_single_vote_block_survives`, with
the probes in `scripts/okstra_ctl/claim_reproduction.py`.

## What the gate asks (BLOCKING)

The gate does not ask whether the plan is free of defects. Under adversarial
reading a plan of any size yields findings every round, so a bar of zero is not
reachable and a run that aims at it does not end — one task spent five runs and
its last two went entirely into the plan's account of itself.

It asks two things instead, and both must hold:

1. **The stage about to start is executable as written.** Its steps, commands,
   exit contract, rollback target and validation signals are settled — §"Gate
   scope" decides which items that covers.
2. **What was set aside is written down.** Every defect the gate stopped
   blocking on appears in `planBodyVerification.setAside` with its reason —
   `observed` (a frozen stage), `deferred` (a stage not yet reached), or
   `record` (the plan's account of itself).

The second condition is what makes the first safe to relax. Knowing a defect is
the acceptance condition, not removing it; without the register a deferred
defect and one nobody raised read the same in the report.

`okstra plan-items complete-round` writes the register from the same computation
that produced the gate value. Do not hand-write it — a hand-written register
drifts from the verdicts it claims to summarise, and nothing else reads it, so
the drift stays invisible.

**Enforced:** `validators/validate-run.py` `_validate_set_aside_register` fails
a scored round whose declared register does not match the one recomputed from
the verdicts.

## Two blocks — execution and record (BLOCKING)

A plan carries two kinds of content, and they answer to different gates.

| Block | Items | A defect there |
|---|---|---|
| `execution` | steps, options, dependencies, validations, rollbacks, design prep, variation points, the selected direction | blocks the start |
| `record` | requirement coverage rows, and the approval dispositions and decision references they carry | **does not block.** It is recorded and becomes the next run's input |

The record is the plan's account of itself: which brief line each stage answers,
what the user decided, which clarification a deviation rests on. It has to be
accurate and it is still verified — but a wrong sentence in it does not make the
next stage unsafe to start, and treating it as though it did is what kept a plan
whose executable content had already settled from ever being approved.

**A requirement that nothing builds is not a record defect.** A coverage row with
`status: gap` or a `blocked C-NNN` blocks through `gateBlockedBy: coverage-gap`,
which reads the rows directly and is untouched by this split. What stops blocking
is a verdict about the row's *accuracy* — a citation that resolves to the wrong
stage, a disposition recorded without its confirmation.

**Enforced:** `scripts/okstra_ctl/plan_items.py` `_item_block` assigns the block
at extraction, `okstra plan-items seed` carries it onto the row, and
`validators/validate-run.py` `_plan_item_gate_class` downgrades a `record`
blocker to `has-dissent`. `_independent_coverage_blockers` is the separate
channel that keeps a genuine gap blocking.

## Gate scope — the stage about to start (BLOCKING)

The plan covers every stage; implementation runs one at a time. An item blocks
approval only when it has standing over the stage that is about to start:

- **`in-scope`** — one of the item's stages is `ready` or `active`, or the item
  belongs to no stage at all. It blocks.
- **`observed`** — the item's stages are all `done`. It does not block. A frozen
  stage's defect cannot be fixed by planning at all: the Stage Ledger forbids
  editing its commands, so an item that blocks on one blocks forever.
- **`deferred`** — the item's stages are all still `blocked`. It does not block;
  the stage it judges has not been reached.

An item with no `stageScope` is `in-scope` when it is plan-wide (`P-Opt-*`,
`P-Var-*`, `P-Dep-*`, `P-Dir-1`) or when no stage is `done` yet. Once a stage is
`done`, an unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` is `deferred`: omitting
`stageRefs` must not re-score the whole checklist on every re-plan. Scoping a
plan-wide item out would stop an unrequested-work verdict from blocking a start.

**Nothing is dropped.** An out-of-scope blocker becomes `has-dissent`, so the
gate reads `passed-with-dissent` rather than `passed` and the reader can see that
something is outstanding. `gate.items[]` carries the bucket for each item; a
scoped-out defect and a real consensus would otherwise look identical in the
record.

**Enforced:** `validators/validate-run.py` `_stage_scope_bucket` and
`_plan_item_gate_class`, which `_recompute_plan_body_gate` and
`_gate_summary_item` both call — the scoping cannot apply to the gate value and
not to the summary the round protocol records.

## Plan-body verdict semantics

The verdict tokens `AGREE` / `DISAGREE` / `SUPPLEMENT` are reused, but their meaning is plan-specific:

- **AGREE**: the item is executable as written *and* internally consistent with other items in the plan.
- **DISAGREE(<kind>)**: the item is broken. `<kind>` MUST be one of:
  - `a` — a concrete referenced file path / symbol **contradicts** a different concrete path / symbol for the same artifact in another step or option's File Structure list (a genuine mismatch between two spelled-out references). An abbreviated / ellipsis (`…`) / under-specified path is NOT kind `a` — it contradicts nothing, it is merely imprecise notation; classify it as `b`. On a `P-Var-*` item this kind never blocks on one vote — a variation-point defect is a design judgement and takes a majority exactly like `b` / `e` (see the `P-Var-*` paragraph below).
  - `b` — a command **or a referenced path** is not executable or is ambiguous — including an abbreviated, ellipsis, or under-specified path that does not resolve as written. A command that fails **only** because the planning-time worktree lacks build/test dependencies is NOT kind `b` — see §"Planning-time environment gap" below.
  - `c` — validation signal is not observable
  - `d` — rollback violates commit / dependency order (advisory — a rollback is human-run, so this never blocks the gate; likewise any `P-Rb-*` rollback item is advisory regardless of kind)
  - `e` — item contradicts the trade-off matrix
  - `f` — requirement coverage row cites no concrete option / stage / step, cites a non-existent option / stage / step, or marks a requirement `covered` while the cited plan item does not satisfy the row's stated requirement. A row that cites an existing option / stage / step is concrete for this purpose even if that option's File Structure paths are abbreviated — path imprecision inside the cited option is kind `b` on that option's own item, not `f` on the coverage row.
- **SUPPLEMENT**: the item is sound but is missing a dependency / edge case / precondition.
- **fixability** (DISAGREE-only, required): each `DISAGREE(<kind>)` judges whether the defect can be fixed using only the code you have now + this plan draft + the brief.
  - `planner-fixable` — resolved by correcting the plan itself without external information (abbreviated paths, prose commands, placeholders, requirement-coverage remapping, citing a non-existent stage, etc.).
  - `needs-user-input` — the fix requires an open user clarification (infrastructure / contract decision) or external information.
  One-line criterion: "Can this defect be fixed using only the code I have now + the plan + the brief?" — if so, `planner-fixable`. **Enforced:** `validators/validate-run.py` `_validate_disagree_has_fixability` fails, on a run where the verification round ran (`roundCount >= 1`), any `DISAGREE` verdict whose `fixability` is missing or not one of the allowed values (`planner-fixable` / `needs-user-input`) — so that `_validate_self_fix_before_clarification` cannot let a mislabelled planner-fixable defect be promoted without a self-fix.

`P-Prep-S<stage>-<kind>` applies the same verdict tokens and adds these disposition checks:

- `inline-contract`: AGREE only when the contract contains the kind-specific fields needed to implement the stage; a label or generic prose is insufficient.
- `prep-item`: AGREE only when every referenced PREP item has a concrete `aiProposal`, correct `stageRefs`/`kind`, and state-specific safeguards required by `schemas/final-report-v2.0.schema.json`.
- `not-applicable`: AGREE only when the rationale is consistent with the stage action; otherwise DISAGREE with `fixability` (`planner-fixable` when the plan can supply the missing contract, `needs-user-input` only for genuinely external facts).
- A declared `blocked` item is not itself a plan-body failure. Missing or duplicate coverage, an empty proposal, a mismatched reference, or an unjustified disposition is a failure and receives `DISAGREE(<kind>)` with `fixability`.

`P-Var-<N>` applies the same verdict tokens to the variation-point analysis and is **majority-gated**: exactly like the `b` / `c` / `e` kinds, only a `majority-disagree` blocks the gate, and a single `DISAGREE` does not block on its own. Whether a behavior has two implementations, and whether the plan extracted the right interface for it, is a judgement about the design — it lacks the concrete certainty of kind `a`, where the verifier can point at two spelled-out references that contradict each other. So a P-Var defect is raised under a majority-gated kind (`b` when the extraction decision or its seam is not implementable as written, `b` likewise when a declared "no variation point" is contradicted by evidence the plan itself carries, `e` when it contradicts the recommended option) and never as kind `a`, which would single-vote-block on a judgement call. **Enforced:** `validators/validate-run.py` `_is_variation_point_item` excludes `P-Var-*` from both kind-`a` gating paths (`_classify_plan_item_gate`, `_is_correctness_critical`), so a mis-tagged `DISAGREE(a)` on one still needs a majority to block.

DISAGREE on a `P-Var-*` item means one of:

- **the declared "no variation point" is false** — `P-Var-0` claims `hasMultipleImplementations: false`, but the same behavior already has two or more implementations in a sibling task's stage or in the brief's own material, so the declaration is contradicted by evidence the plan itself carries;
- **the extraction decision violates OCP** — the plan branches on resource identity (one `if` / `switch` arm per implementation) instead of extracting the interface the second implementation plugs into, so adding the next implementation means editing the same call site again;
- **the declared test seam is not actually injectable** — `extractionDecision.coveredBy` or the recommended option's `testSeams[].injectedAs` names no construction or wiring point a test can replace, so the seam exists on paper but nothing can be substituted at it.

The hexagonal rule that an extracted point must declare `interfaceKind: "port"` is already machine-checked by `validators/validate-run.py` `_validate_variation_point_analysis` (it fires only for a project whose `architecture.style` is `hexagonal`). Do not re-run that mechanical check as a verdict; spend the judgement on placement and semantics instead — a point extracted as a port whose domain rule leaked into the adapter passes the validator and is still wrong.

`P-Dir-1` carries the same YAGNI judgement as the legacy option item, but its comparison source is the selected-direction snapshot rather than a trade-off matrix. A new abstraction, configuration knob, widened interface, file, or stage with no original-requirement link is a hidden direction change and receives `DISAGREE(e)` on `P-Dir-1`.

`P-Opt-<N>` carries the **YAGNI judgement** and is majority-gated for the same reason as `P-Var-*`: whether an abstraction serves the stated requirement or only a forecast is a judgement about the design, not a contradiction between two spelled-out references. Raise it as `DISAGREE(e)` — an option that carries an abstraction, parameter, or configuration knob no Requirement Coverage row demands contradicts the trade-off matrix that scored it, because the complexity the matrix priced is not the complexity the option actually buys. DISAGREE on a `P-Opt-*` item under this rule means one of:

- **an abstraction nobody asked for** — a helper module, strategy / factory, indirection layer, or interface whose only justification in the plan is a caller no requirement names. A second implementation already on the table is `P-Var-*` territory and is the opposite defect: do not raise both on the same behavior;
- **a configuration knob with one value** — a flag, option object, or env-var switch that every planned call site passes identically;
- **a widened signature** — a step that adds an optional parameter no planned call site supplies.

The scope-provenance gate cannot reach these. It resolves the source of a *requirement row* and the citation of a *stage*, so unrequested work smuggled inside a legitimately-sourced stage passes it clean — the gate's own stated limit (`prompts/profiles/implementation-planning.md` "Scope provenance" → "The reach of this gate — do not over-trust it"). This verdict is that gate's missing half and the only plan-side judgement that can block on it, so a `P-Opt` item's AGREE asserts the option is free of unrequested work — not merely that it is executable. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` — kind `e` sits in neither `_SINGLE_VOTE_BLOCKING_KINDS` nor `_ADVISORY_ONLY_KINDS`, so a `majority-disagree` on a `P-Opt-*` item blocks approval exactly like any other majority-gated defect, and one lone dissent does not.

The semantic checks above are the plan-body enforcement layer for the schema-valid structures; `validators/validate-run.py` enforces detector coverage and references, while this worker verdict decides whether the content is implementable.

Worker non-result handling (`timeout`, `error`, no result file, wrapper `cli-failure`) is identical to finding convergence: do NOT aggregate as DISAGREE, record `contract-violation`, and apply the round-level abort rule below.

## Planning-time environment gap

`implementation-planning` runs in the task-key worktree, and that worktree carries **no installed
build/test dependencies** — `scripts/okstra_ctl/worktree.py` `DEFAULT_WORKTREE_SYNC_DIRS` mirrors
only okstra-owned directories, and installing dependencies here would violate the profile's
"no build/migration/deploy execution" boundary. So a plan's `npm test` / `yarn workspace … build` /
`pytest` command **cannot succeed at plan time by construction**, no matter how correctly the plan
wrote it.

This is a property of the phase, not a defect of the plan. Judge accordingly:

| What the verifier observes | Verdict |
|---|---|
| The command is not declared anywhere (no such script in `package.json` / `Makefile` / task runner) | `DISAGREE(b)` — the plan cites a command that does not exist |
| The command is declared, but running it here fails on missing dependencies (`exit 127`, `command not found`, missing module/binary) | `UNVERIFIABLE` — not `DISAGREE(b)` |
| The command is declared and its arguments contradict the plan's own file structure | `DISAGREE(a)` or `DISAGREE(b)` per the usual rules |
| A referenced **path** does not exist in the tree | `DISAGREE(b)` unchanged — paths are checkable here; dependencies are not |

**What this phase can confirm about a command is that it is defined**, that its arguments name real
paths, and that its stated success signal is observable in principle. Execution success is the
`implementation` run's evidence to produce, not this round's.

Recording `UNVERIFIABLE` is the honest outcome — it is persisted as `verification-error`, excluded
from both numerator and denominator, and never converted to `DISAGREE`. Voting `DISAGREE(b)` on a
dependency-missing command instead manufactures a defect the planner cannot fix by editing the plan,
which is what sends the item into a self-fix round that can only paper over it.

## Mode constraint

Plan-body verification only supports **lightweight mode** (defined in [convergence](./convergence.md) §"Verification Mode"). `full-reanalysis` is not meaningful here because the "original source materials" for a plan item are the worker's own analysis plus the lead-mediated synthesis — there is no independent ground truth to re-read. The manifest's top-level `verificationMode` is ignored for this round; lightweight is always used.

Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement text embedded in the `Requirement Coverage` row with the cited Option / Stage / Step in the draft plan. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the row's stated requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.

## Adversarial plan-body posture

When `config.adversarial == true` (the default for `implementation-planning`; see [convergence](./convergence.md) §"Configuration"), the plan-body round runs with an **adversarial posture**. The classification rules and gate arithmetic in §"Round protocol" are UNCHANGED — `majority-disagree` blocks approval, and that class now includes a blocking-kind minority dissent so a 2-AGREE / 1-DISAGREE on `b` / `c` / `e` is not passed silently. Advisory `dissent-isolated` (`DISAGREE(d)`, `P-Rb-*`) still does not block. Adversarial mode changes only *how each verifier evaluates an item*:

- The burden of proof sits on the plan: an item earns `AGREE` only if the verifier actively tried to break it and could not.
- The verifier MUST open the file paths / symbols / commands the item cites and confirm they exist and are **defined** as written. This is the one allowed widening of the lightweight "judge from internal consistency and stated commands / paths" rule — confirming the existence of cited paths is not "re-analyzing the original requirements". The widening stops at *definition*: a build/test command's **execution success** is out of scope here, because the planning worktree has no dependencies installed (§"Planning-time environment gap"). Confirm the script is declared; do not treat its failure to run as evidence against the plan.
- If a cited path / command / validation signal cannot be confirmed, the verifier responds `DISAGREE(<kind>)` with the applicable breakage kind (a–f); uncertainty resolves toward DISAGREE, not AGREE.
- **Single-vote-blocking kinds.** A reproduced `DISAGREE(a)` (cited path/symbol mismatch) on any item other than a `P-Var-*` one, or a reproduced `DISAGREE(f)` on a `P-Req-*` item, blocks on that one vote even if the rest AGREE. **Enforced:** `validators/validate-run.py` `_single_vote_block_survives`. Kinds `b` / `c` / `e` do not auto-block on one unreproduced vote, but a blocking-kind minority with ≥2 participating votes is still `majority-disagree` and goes to the user — the majority does not silently pass it. **Rollback ordering (`d`) never blocks.** Because `a` is reserved for a concrete contradiction between two spelled-out references, an abbreviated path is raised as `b`, never `a`. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate`.

Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in [convergence](./convergence.md) §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden). A reproduced fact (`a`, or `f` on P-Req) still blocks on one confirmed vote. A blocking-kind minority (`b`/`c`/`e`) with ≥2 participating votes goes to the user rather than passing as `has-dissent`. Rollback ordering (`d`) is advisory and never blocks. A lone surviving `DISAGREE` whose peer returned a non-result does NOT block — a worker failure must not make the gate stricter than a healthy roster would.

## Round protocol (single round at default `maxRounds=1`)

Before each verifier call, write one task-instructions file under the current
run's `state/` directory and run `okstra agent-prompt materialize` with
`--audience reverification-worker`,
`--assignment-ref reverify/<workerId>`, the exact `--worker-id`, and
`--dispatch-kind reverify-r<N>`. For a v2 run, select the verifier's canonical
source `RoleExecution` row from the run manifest's static role state. Use its
`participantRef`, and set `sourceRoleExecutionRef` to the selected source
`RoleExecution` row's `roleExecutionRef`, not that row's
`sourceRoleExecutionRef` field. Pass `--source-role-execution-ref
<sourceRoleExecutionRef>`. Do not derive that reference from `workerId`,
provider, model, or execution-label text. A legacy v1 run omits this flag. Run
`okstra agent-prompt verify` against the
returned `metadataPath` before dispatch and use the returned `promptPath`
without modification. Native-session calls use only `hostModelValue`; before
the host primitive, run `okstra agent-prompt record-dispatch` with the project
root, run manifest, metadata path, and `--enforcement-mode
host-native-spec-link-gate`, then run `okstra agent-prompt link-result` with
`--dispatch-id <invocationId>:attempt-1` and the result path before parsing it;
CLI-wrapper calls follow the planned execution surface after
`core-pre-dispatch` verification: `okstra team dispatch` when
`terminalBackend` is `cmux-pane`, otherwise `okstra worker-dispatch`, and
consume only `modelExecutionValue`. A missing or invalid invocation contract blocks the
round before any host or provider process starts.

1. Lead runs `okstra plan-items prepare --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`, places the fixed output of `okstra plan-items prompt --run-manifest <run-manifest>` verbatim in every verifier prompt, then runs `okstra plan-items validate-prepared --narrative <report-writer-narrative.md> --run-manifest <run-manifest>`. After a self-fix rewrite, pass `--state <plan-body-verification.json>` on prepare and validate-prepared so the dispatch queue is the changed items plus their stage closure, not the full extract. Python resolves the one convergence-owned state path from that run identity. Dispatch only after that exact-match validation succeeds. The prompt is the dispatch queue: `observed` / `deferred` stages are omitted; plan-wide items (`P-Dir-1`, `P-Var-*`, `P-Dep-*`, `P-Opt-*`) stay. Unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stay on the first round, then only when their own `contentHash` changed — a neighbour rewrite must not resweep them. **Enforced:** `okstra_ctl.plan_items.dispatch_item_ids` / `reverify_item_ids`. After `okstra incremental-carry`, the carry copies unchanged checklist verdicts, rewrites `dispatchQueue`, and updates the sibling `plan-items-*.json` so the next `okstra plan-items prompt` does not re-score a carried row.

   **Then seed the landing table (BLOCKING):** `okstra plan-items seed --narrative <report-writer-narrative.md> --state <plan-body-verification.json> --run-manifest <run-manifest.json>`. `apply-verdicts` in step 8 refuses a verdict whose item has no `planBodyVerification.planItems[]` row. The report writer never owns that state, so the deterministic seed is the only creator of its rows. The seed is idempotent by id and never touches existing verdicts, so it is safe to re-run between rounds and after a self-fix re-extraction. It does refresh `contentHash` from the current extract. Skipping it makes step 8 fail with `plan-body state has no row for [...]`.

   **`--run-manifest` is what scopes the gate to the stage you are starting.** Seed uses it to overlay disk `done` / `active` onto `planBodyVerification.stageLedger`. The current plan's depends-on fills `ready` / `blocked` when no prior plan exists, so a first run does not treat every stage as in-scope. **Enforced:** `okstra_ctl.plan_items.planning_stage_ledger`.
2. For each analyser worker in the roster (`claude`, `codex`, and `antigravity` if opted in), lead constructs a reverify prompt using the template in §"Plan-body reverify prompt" below.
3. Dispatch uses the same wrapper infrastructure as finding convergence, so the `--role-slug` is the same canonical `<role>-worker` that convergence uses — not a round-specific slug. Result file path: `runs/<task-type>/worker-results/<role>-worker-plan-verify-r<N>-implementation-planning-<seq>.md` (e.g. `codex-worker-plan-verify-r1-implementation-planning-003.md`). **`<seq>` is the report's sequence** — the one in this run's `final-report-<task-type>-<seq>` filename, NOT the `workerResults` sequence the initial analysis results carry. The two are equal in most runs and diverge in some (`reports: 004` alongside `workerResults: 005` is a real case), and provenance globs on the report's. Picking the other one makes `_validate_plan_body_verdict_provenance` report that no result file exists while the file is sitting in the directory. The `-worker-` token is load-bearing twice over: §"Plan-body reverify prompt" requires the same anchor headers as convergence, whose `**Audit sidecar path:**` is derived by `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()` inserting `-audit-` after that token — a slug without it makes the header underivable and the helper raises. Record each `planItems[].verdicts[].worker` as the same `<role>-worker` string, because provenance compares it to this filename's prefix. **Enforced:** `tests/contract/test_reverify_dispatch_anchors.py` derives the sidecar from the documented name and re-extracts the prefix the provenance resolver uses.
   **Verdict provenance (BLOCKING).** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file at the path above. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` fails any `verdicts[].worker` with no matching `<worker>-plan-verify-r<N>-<task-type>-<seq>.md` result file. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation.

4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:

   **Every in-scope item carries at least one verdict row (BLOCKING).** Aggregation covers the dispatch queue, not deferred or observed stages. An in-scope item left with an empty `verdicts[]` is not a weak signal the gate can discount — it classifies `all-non-result`, states as `needs-reverify`, and folds into `passed-with-dissent` next to items two verifiers actually agreed on, so a plan item nobody judged reads as a passing one. This is the shape a self-fix round produces when the planner adds an in-scope item and the targeted round-N queue never picks it up. A worker that returned nothing is a `verification-error` row (step 3), not a missing row; if an in-scope item was never dispatched, dispatch it before scoring the round. **Enforced:** `validators/validate-run.py` `_validate_round_recorded_verdicts` fails any run whose `roundCount` ≥ 1 leaves an in-scope item with no verdict row.

   - `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
   - `partial-consensus` — majority `AGREE` with two or more blocking `DISAGREE`s. On kinds `b` / `c` / `e` this is scored `majority-disagree` and **blocks approval** so the user decides; it is not folded into a passing gate.
   - `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE`. On a blocking kind (`b` / `c` / `e`, and kind `a` on `P-Var-*`) this is scored `majority-disagree` and **blocks approval**. Advisory-only `DISAGREE(d)` and `P-Rb-*` stay recorded dissent and do not block. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs.)
   - `majority-disagree` — a *majority* of analysers `DISAGREE` (majority needs ≥2 participating non-error votes; rollback-ordering `DISAGREE(d)` votes are advisory and excluded from the tally), OR any blocking-kind dissent with ≥2 participating votes (a minority `DISAGREE` is not outvoted), OR any single-vote-blocking kind fires: one reproduced `DISAGREE(a)` on any item other than a `P-Var-*` one, or one reproduced `DISAGREE(f)` on a `P-Req-*` item (see §"Single-vote-blocking kinds"). This classification **blocks approval**.
   - `needs-reverify` — one of two shapes the round could not settle.
     - **An even split on a blocking kind.** The majority test is strict, so a panel splitting evenly (1-AGREE / 1-DISAGREE, 2-2, …) reaches neither `full-consensus` nor `majority-disagree`. Until this shape existed it folded into `has-dissent` and the gate passed. Do **not** re-run the original two. Dispatch `critic-worker` immediately on those items only (`okstra plan-items prepare --tie-vote`, then `okstra plan-items prompt`). The prompt carries the analyser split and no other plan items. Record the critic vote as `verdicts[].worker = critic-worker` with `okstra plan-items apply-verdicts --append`. Critic `AGREE` / `SUPPLEMENT` settles the split to `has-dissent`. Critic `DISAGREE` on a blocking kind is `majority-disagree`. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` / `_validate_tie_received_extra_vote` / `_validate_unresolved_tie_was_reverified`, `okstra_ctl.plan_items.next_dispatch` kind `critic-tie`.
     - **A lone dissent nobody cross-verified** — a single-vote-blocking kind fired but the item has **fewer than 2 participating non-error votes**, i.e. the lone dissent was never cross-verified because its peer returned `verification-error`. A single-vote-blocking kind means "one *confirmed* DISAGREE is enough"; an unconfirmed one is not, and on a `P-Var-*` item none fires at all — its kind `a` never blocks on one vote and takes a majority like `b` / `e`. This does **not** block approval — blocking on it would make a worker failure produce a stricter gate than a healthy roster, the same paradox the ≥2-vote majority rule already rules out. The item is re-dispatched in the next round (step 7); if it survives the round budget it is promoted per step 8 with a Statement that says verification never completed. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` returns `needs-reverify` for this shape and `_recompute_plan_body_gate` folds it into `passed-with-dissent`.
   - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
5. Gate result resolution:
   - any `majority-disagree` item present AND `gating=true` → `blocked-by-disagreement`
   - all dispatches non-result → `aborted-non-result`
   - any advisory `dissent-isolated` / `needs-reverify` present, no `majority-disagree` → `passed-with-dissent`
   - all items `full-consensus` → `passed`

   **Score the gate with `okstra plan-verify`, never by hand (BLOCKING).** Once this round's verdicts are in the data.json, lead runs

   ```
   okstra plan-verify --report <runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json>
   ```

   and records what it returns: `gate.recomputed` is the round's gate value, `gate.blockedBy` its `gateBlockedBy` causes, `gate.blockingItems` the items that block. The same call runs every plan-body check a round can be judged on alone — provenance, fixability, subject substance, self-fix grouping, round recording, clarification matching, state-file rounds — and exits 2 with `failures[]` when the round is not contract-clean. **A round is not complete while that exit code is non-zero.**

   Reading the rules in this section and tallying the votes in an ad-hoc script is a contract violation, not a shortcut. The classification carries five special cases that a re-derivation drops one at a time — single-vote-blocking `a`/`f`, advisory-only `d`, the `P-Var-*` majority gate, the `P-Rb-*` exemption, and the `needs-reverify` shape — and a hand-written tally re-derives them from scratch every round, so the loop's later rounds score differently from its first. One implementation, called once per round, is what keeps round 5 scored the same way as round 1.

   **Record the cause, not just the outcome.** The gate value names the outcome; `planBodyVerification.gateBlockedBy` (array) names every input that blocked it — `majority-disagree`, `coverage-gap`, `non-result`. Two independent inputs can block: a `majority-disagree` plan item, and a Requirement Coverage `gap` / `blocked C-NNN` row (`prompts/profiles/implementation-planning.md` §"Requirement Coverage"). A coverage-only block still renders as `blocked-by-disagreement` because that is the only blocking non-abort value, so **without `gateBlockedBy` the report asserts a worker disagreement that never happened** and the reader hunts for a dissent that does not exist. Leave the array empty for a passing gate. **Enforced:** `validators/validate-run.py` `_validate_gate_blocked_by` cross-checks the declared causes against the recorded verdicts and coverage rows, and fails a passing gate that has a blocking coverage row — the coverage rule was prose-only before.

   **A coverage row citing this run's own `C-NNN` is not an independent blocker.** When a coverage row's `blocked C-NNN` points at a clarification that step 8 below promoted from a `majority-disagree` item in *this same run*, that blocker is already counted once as the plan item. Counting it again as a coverage gap makes the run block on a clarification it just authored, and the row carries into the next run as a fresh blocker — the Requirement Coverage ↔ Clarification cycle. Such rows are excluded from `coverage-gap`. **Enforced:** `validators/validate-run.py` `_independent_coverage_blockers`.
6. `okstra plan-items complete-round --run-manifest <current-run-manifest.json>` derives `planBodyVerification.participatingAnalysers` from the current assigned roster and persisted votes, then atomically records the completed round. The gate arithmetic is unchanged, but a shrunken roster changes what the round can settle: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it reaches neither consensus nor `majority-disagree` and the item has to go back for a round (see `needs-reverify` above). **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. `validators/validate-run.py` `_detect_uniform_verifier` remains advisory; do not copy its JSON output into state.

   **Check each verifier's verdict distribution before the next round.** After `apply-verdicts` and before opening another worker batch, run `okstra plan-items next-dispatch --state <plan-body-verification.json> --run-manifest <current-run-manifest.json>`. Python owns that decision. Do not invent a full-roster round from a `needs-reverify` label, from every-item `UNVERIFIABLE`, or from `okstra plan-verify` warnings. `_detect_uniform_verifier` remains advisory; do not copy its JSON output into state.

   | `kind` | What the lead does |
   |---|---|
   | `none` | Do not add a worker batch. A round whose only failures are missing-dependency command runs — `UNVERIFIABLE` on a declared `npm` / `pytest` / equivalent, §"Planning-time environment gap" — is this shape. |
   | `worker-correction` | Re-dispatch **only** those workers. Peers are not re-run. The queue does not become a new round. Place the output of `okstra plan-items correction-prompt --worker <id> --run-manifest … --state …` first in that worker's prompt — the environment-exception paragraph is first. A byte-identical re-dispatch reproduces the same failure; a corrected one recovered 37 substantive verdicts from a worker whose first attempt answered `UNVERIFIABLE` to all 80 items. |
   | `queue-reverify` | An unsettled tie on a blocking kind. Legacy kind; current scoring emits `critic-tie` instead. |
   | `critic-tie` | An unsettled analyser tie. Dispatch `critic-worker` on those `itemIds` only. The critic's verdict settles the split. Do not re-run the original two. |

   A referenced **path** that does not exist is still `DISAGREE(b)` / a fact probe, never environment-unverifiable. **Enforced:** `okstra_ctl.plan_items.next_dispatch` / `correction_prompt_text`.

   The environment exception in §"Planning-time environment gap" covers **running build and test commands only** — whether a referenced path exists, whether a command is declared in `package.json`, and whether the plan is internally consistent are all checkable without it, and a blanket "capability constraints prevent workspace resolution" is not a valid answer to any of them.

   **How the corrective round is recorded.** The first prompt was dispatched, so it is immutable — `--replace-undispatched` refuses it, correctly. Materialize the correction under a NEW `--invocation-id` and a new prompt path. Before linking its result, retire the first attempt's link: `okstra agent-prompt reject-result --run-manifest <path> --dispatch-id <first dispatch id> --superseded-by <corrective dispatch id> --reason "<what was wrong with the returned result>"`. Without that step the corrective `link-result` fails with `agent result is already linked to another dispatch`, which is how a worker that ran for twenty minutes and wrote a good result ends up unrecordable. Nothing is deleted: the rejected link stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason`, so the ledger shows both attempts and why the second exists.

   Then run `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. Python appends one immutable round history entry, records each verified item's votes, derives the current projection from the actual assigned roster, and stamps `completedAt` after the preceding verification command succeeds. The file accumulates across rounds; it is never truncated to the latest one. Report assembly later projects the completed nested `planBodyVerification` into the final record.
7. **Self-fix loop (one rewrite, targeting planner-fixable defects).** After round 1, lead may run one report-writer rewrite when at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`. The targeted re-verification after that rewrite is round 2. After round 2, stop automatic self-fix regardless of outcome. Classify every remaining item as `user-decision`, `noncritical-dissent`, or `correctness-critical`. A second automatic self-fix is a contract violation. The fixed order is initial verification → one planner self-fix → targeted re-verification → user gate.
   - **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect. Lead partitions this round's targets into cause groups and instructs each group as **"remove this cause"**, naming the derivatives it accounts for. The convergence command owns the persisted group and correction fields; the lead does not edit JSON state.
   - lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
   - missing or weak `P-Prep-*` contracts are repaired by adding kind-specific inline detail or an AI-prepared PREP item with a concrete proposal. Facts that require user or external authority remain `blocked` and keep their request material; never invent those facts during self-fix.
   - **Drop plan items whose element the round deleted.** A self-fix rewrite may remove a plan element (a validation check, a rollback row). `P-*` ids are positional, so a deletion shifts every later row and silently re-points surviving verdicts at their neighbours — and a verdict recorded against a removed element keeps blocking a gate while being unfindable in the plan, so reading the plan never reveals the cause. After each round, re-extract plan items with `okstra plan-items extract` and re-verify any item whose `subject` no longer matches; never carry the old vote forward across a shift. **Enforced:** `validators/validate-run.py` `_validate_verdicts_match_current_subjects` (re-pointing) and `_validate_plan_item_extraction_completeness` (dangling ids).
   - **Classify each cause group before instructing it (BLOCKING).** A group is either an *authoring* defect — the plan says something wrong, incomplete, or self-contradictory, which self-fix owns — or a *citation* defect, where the plan points at an analysis artifact incorrectly. Only the first is self-fix work. For the second the finding already exists and already went through convergence, so the fix is to re-cite the converged artifact; instructing report-writer to re-derive the fact means the author reads the source material and produces a **finding that never went through convergence**, which the plan then carries as if it had. That is the role boundary the lead contract draws ("keep analysis, execution, verification, and report authoring responsibilities distinct; return defects to the role that owns them"), and report-writer is authoring-only by its own contract. `P-Req-*` items with breakage kind `f` are where this goes wrong most often: the question is usually whether a coverage row points correctly at something already measured, not whether the measurement is right. State the classification in the group's instruction so the author knows which of the two it is being asked to do.
   - **A verdict older than the last self-fix is not a verdict unless the item's content is unchanged (BLOCKING).** A verdict cast in round 1 judged the text before the only automatic rewrite. Once that rewrite runs, a changed item's judgement is about a plan that no longer exists. `--round <N>` on `apply-verdicts` stamps each row and copies `contentHash` onto `verifiedContentHash`. `validators/validate-run.py` `_validate_verdict_rounds_outlive_self_fix` fails an in-scope item whose verdict round is at or before `selfFixRoundsApplied` **and** whose `contentHash` does not match `verifiedContentHash`. Matching hashes keep the prior verdict — that is what avoids a sweep round over unchanged stages. Deferred and observed items are out of the gate and do not need a post-self-fix verdict. **Enforced:** `_validate_verdict_rounds_outlive_self_fix`.
   - Lead re-runs plan-body verification, then records each worker Markdown result through `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker>=<result.md> --round <N>`. Score the result with `okstra plan-verify --narrative <report-writer-narrative.md> --state <plan-body-verification.json>`, then call `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. These commands fail on an assigned item the worker left unanswered, on a verdict for an item outside the queue, and on a duplicate worker result.
   - For a self-fix, record the correction through the typed convergence command rather than writing `selfFixNote` or `selfFixGroups` JSON. A resolved item does not create a clarification.
   - **Each round is a worker batch.** Before dispatching round N ≥ 2, reclaim the previous round's completed verifiers exactly as at any other batch boundary ([okstra-lead-contract](./okstra-lead-contract.md) "Run-scoped worker-resource lifecycle") and emit `PROGRESS: phase-batch-cleanup panes=<n>`, then announce the round with `PROGRESS: phase-5.5.9-plan-verify round=<N> items=<count>`. Saying a round will "reuse" the previous verifiers and then dispatching under fresh names leaves every prior round holding its panes — five rounds of that is what exhausts the pane budget and blocks the next dispatch. **Enforced:** `validators/validate_session_conformance.py` `_check_plan_verify_cleanup_checkpoints` requires both lines once the state file records two or more rounds.
   - **Round completion.** A round is complete only after `okstra plan-verify` exits 0 and `okstra plan-items complete-round` succeeds. A round left with a non-zero exit carries its defect into the next round's inputs. Report assembly and rendering occur only after the convergence state is terminal. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one stored round per round number and a corresponding item vote.
   - **Loop termination.** The convergence command owns the round count and stop reason; the lead does not write either JSON field. A user-directed correction does not consume the automatic self-fix limit, and a verification failure after that correction does not restart the automatic loop:
     - `all-resolved` — no planner-fixable `majority-disagree` item remains. Exit.
     - `no-progress` — the round resolved **zero** planner-fixable items relative to the previous round. Exit even with budget left: the same rewrite would repeat. Newly *introduced* defects count against progress, so a rewrite that trades one defect for another stops the loop rather than churning. A round that re-targets only what the previous round left unresolved is this same conclusion reached one dispatch earlier — exit on it under this reason rather than paying for the round that proves it. **Enforced (advisory):** `validators/validate-run.py` `_detect_self_fix_recurrence` warns on that shape and names this stop reason.
     - `max-rounds-reached` — `selfFixRoundsApplied == selfFixMaxRounds`. Exit.
     - `cause-group-recurrence` — legacy read-only value for reports produced before activity contract v1. A new activity-contract-v1 run MUST NOT emit it because there is no second automatic self-fix round in which a cause group can recur. **Enforced:** `validators/validate-run.py` `_validate_activity_contract_plan_limits`. A legacy report that already carries it is still an *exhausted* loop, so step 8 may promote its surviving items: `_SELF_FIX_EXHAUSTED_REASONS` admits this value alongside `no-progress` / `max-rounds-reached`. Refusing it there left such a report with no exit at all — the loop may not run again, and the item may not be promoted either.
     - `not-attempted` — the loop never ran because no item qualified.
     The `no-progress` and `max-rounds-reached` exits are what make the loop terminate; `selfFixMaxRounds` alone is the backstop.
   - a `majority-disagree` item with a majority of its deciding `DISAGREE` votes at `needs-user-input` is NOT a self-fix target — after correctness-critical precedence, it goes straight to the next step as `user-decision` rather than generic `noncritical-dissent`. **Enforced:** `validators/validate-run.py` `_expected_approval_classification`.
8. For every **in-scope execution** `majority-disagree` item **that remains after the self-fix loop** (items not resolved by self-fix, or with a `needs-user-input` majority from the start), lead adds a row to `## 1. Clarification Items` with:
   - do **not** promote an `observed` / `deferred` / `record` item. Those belong in `setAside`. A `Blocks=approval` C row for a frozen or unreached stage is how the clarification list grew while the next stage was already executable. **Enforced:** `validators/validate-run.py` `_validate_plan_body_clarification_matching` uses `_plan_item_gate_class` (after stage scope), not the raw vote class.
   - new `C-<N>` ID (numbering continues from any existing rows)
   - `Statement` summarising the disagreement and the worker breakage `<kind>`
   - `Kind` chosen per the standard policy (usually `decision` for option-level conflicts, `data-point` for path/symbol mismatches)
   - `Blocks=approval`
   - the report record's `planItems[].clarificationRefs[]` reaching that `C-<N>`. Under contract v3 the report record carries the **plural** field and the v3.0 schema forbids `clarificationId` on a plan item; report assembly derives the refs from the activity ledger's `clarificationRefs[]` + `planItemIds[]`, so record the decision through `okstra approval-decision` rather than writing the link by hand. The lead-owned state file keeps the singular `clarificationId`. `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item reaches no clarification, or reaches one that is missing or not `blocks: approval`.
   - set `approvalContext.classification` to `user-decision` for a majority `needs-user-input` item, `correctness-critical` for `DISAGREE(a)`, `DISAGREE(f)` on `P-Req-*`, or an independent Requirement Coverage blocker, and `noncritical-dissent` for another surviving majority disagreement.
   - record the decision through `okstra approval-decision open`. Each option carries `disposition`, exactly one `reach`, and optional `scopeEffects`. The activity ledger carries affected `planItemIds` and `clarificationRefs`; the approval row never copies those backtrace IDs. `select` is allowed only for `user-decision`. `accept-risk` is allowed for every classification, including `correctness-critical`: it ends the gate and keeps the DISAGREE votes as evidence. `request-revision` / `reject` withhold the next phase. **Enforced:** `scripts/okstra_ctl/approval_decisions.py` and report assembly.
   - **Self-fix exhaustion is not risk acceptance.** A remaining item stays blocking until the user selects `accept-risk`, `select`, or `answer`. Record the user's non-empty original text. Do not require a second verification round to honour `accept-risk`.
   - **Correctness-critical `accept-risk` does not rewrite the votes.** The linked items keep their DISAGREE (or remaining dissent) so a later stage can still see them. `request-revision` is the path that corrects the plan and re-verifies. **Enforced:** `validators/validate-run.py` `_user_accepted_plan_item_ids` / `_validate_correctness_resolution`.
   - When a correctness-critical `planner-fixable` item is promoted, its `Statement` MUST state "planner self-fix attempted but unresolved" and name the stop reason. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when a planner-fixable majority item is promoted while the budget is not exhausted — it requires `selfFixRoundsApplied >= 1` **and** `selfFixStopReason` in `{no-progress, max-rounds-reached}`, so neither `all-resolved` nor `not-attempted` can excuse a promotion.
   - Approval state transitions are fixed:
     - `open → answered` when the raw user response is recorded
     - `answered → resolved` only after the selected disposition is applied and its checks pass
     - `answered → open` when application or checking fails
     - `open → obsolete` only when a plan change removes the question
     `open` blocks until the user judges. `answered` with a proceeding disposition (`accept-risk` / `select` / `answer`) does not block. `request-revision` / `reject` still withhold the next phase until this report's `supersessionLedger` records that the answer was incorporated (`superseded` or `no-dependent-statement`). A user-directed correction does not consume the automatic self-fix limit, and a failed check does not restart the automatic loop or reopen the row.
   - A terminal row preserves its original dissent classification only from the convergence-owned state history. Every `user-decision-required` / `user-decision-evaluated` activity cites the row's `C-NNN` in `clarificationRefs` and affected plan items in `planItemIds`. A resolved decision names only existing `A-NNN` checks. Report assembly validates those links and derives the report backtraces; it does not accept copied IDs from the approval ledger. When an independent coverage-only blocker is corrected, keep the `C-NNN` in the non-blocking Requirement Coverage row's `decisionRefs`. `obsolete` is valid only after current evidence shows that the question or blocker disappeared.
9. Approval lives in the report record `frontmatter.approved` field — there is no in-body marker line. The user may set it to `true` (via `--approve` or the in-session wizard) when remaining `Blocks=approval` rows are user-proceeded (`accept-risk` / `select` / `answer`) even if the recorded `gateResult` is still `blocked-by-disagreement`. `aborted-non-result` still withholds approval. **Enforced:** run-prep (`scripts/okstra_ctl/run.py` `_validate_approved_plan` / `_blocking_gate_survives_user_decision`) and `validators/validate-run.py` `_validate_plan_body_gate_recompute`.

## `plan-body-verification-<task-type>-<seq>.json` schema

**Take the path from the launch prompt, never from this filename (BLOCKING).**
The run's `## Run Paths` block renders `Plan-body verification state:` with the
exact path, and `scripts/okstra_ctl/paths.py` is what computed it. Do not build
the name from the pattern in this heading: a run carries **two seq families** —
`state` (team-state, convergence, lead-events, this file) and `reports` (the
final report and its siblings) — and they diverge whenever the two advance at
different rates. Writing this file under the report's seq puts it where nothing
looks: `validators/validate_session_conformance.py` resolves it from the
team-state name, so the round count reads as 0 and
`verification-round-completed count must match automatic plan-body rounds=0`
fails a run whose rounds all ran.

**Which file is authoritative for what.** Contract v3 keeps both views in one convergence-owned state file before publication:

| | records | what a self-fix round does to it |
|---|---|---|
| `state/plan-body-verification-<task-type>-<seq>.json` top-level `planItems[]` / `roundHistory[]` | round-by-round history, including superseded rounds | **appends** — earlier rounds remain immutable |
| the same file's `planBodyVerification` | final state after the self-fix loop | **overwrites** — each re-verification replaces current `planItems[].verdicts` |
| `data.json` `implementationPlanning.planBodyVerification` | published projection | report assembly copies the validated final state once |

**The gate is computed from the nested final projection.** The top-level audit history may differ because it preserves superseded rounds. Report assembly copies the final projection rather than asking the report writer to transcribe it. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_file` requires the audit keys once a round has run, and `scripts/okstra_ctl/report_assembly.py` reads only the convergence-owned `planBodyVerification` projection.

The per-round structures mirror the finding-convergence state artifact ([convergence](./convergence.md) §"Convergence State Artifact"): `roundHistory[]` is the round-level ledger, and each item's `rounds[]` is its per-round vote history — the same split as that file's `roundHistory[]` / `findings[].rounds[]`.

```json
{
  "schemaVersion": "1.1",
  "owner": "convergence",
  "planBodyVerification": {
    "roundCount": 2,
    "gateResult": "passed-with-dissent",
    "gateBlockedBy": [],
    "planItems": [],
    "dissentLog": []
  },
  "phase": "implementation-planning",
  "effectiveMaxRounds": 1,
  "gating": true,
  "verificationMode": "lightweight",
  "selfFixRoundsApplied": 1,
  "selfFixStopReason": "not-attempted | all-resolved | no-progress | max-rounds-reached | cause-group-recurrence",
  "planItems": [
    {
      "id": "P-Opt-1",
      "subject": "Option A: split upload v2 into a new module",
      "sourceSection": "4.5.1",
      "ticketId": "<id-or-unknown>",
      "rounds": [
        {
          "round": 1,
          "votes": {"claude-worker": "AGREE", "codex-worker": "AGREE"},
          "classification": "full-consensus"
        }
      ],
      "clarificationId": null
    },
    {
      "id": "P-Step-3",
      "subject": "Stage 2 Step 3: run migration then regression test",
      "sourceSection": "4.5.4",
      "ticketId": "TICKET-123",
      "rounds": [
        {
          "round": 1,
          "votes": {"claude-worker": "DISAGREE(a)", "codex-worker": "DISAGREE(a)"},
          "classification": "majority-disagree"
        },
        {
          "round": 2,
          "votes": {"claude-worker": "AGREE", "codex-worker": "AGREE"},
          "classification": "full-consensus"
        }
      ],
      "selfFixNote": "self-fixed in round 1: migration path corrected to db/migrations/0007_add_variable_flag.sql",
      "clarificationId": null
    }
  ],
  "roundHistory": [
    {
      "round": 1,
      "completedAt": "2026-08-15T01:20:00Z",
      "gateResult": "blocked-by-disagreement",
      "gateBlockedBy": ["majority-disagree"],
      "dispatches": [
        {"role": "claude-worker", "resultPath": "...", "terminalStatus": "completed"}
      ]
    },
    {
      "round": 2,
      "completedAt": "2026-08-15T01:35:00Z",
      "gateResult": "passed-with-dissent",
      "gateBlockedBy": [],
      "dispatches": [
        {"role": "claude-worker", "resultPath": "...", "terminalStatus": "completed"}
      ]
    }
  ]
}
```

> Abbreviated example: a one-round run has a single `roundHistory[]` entry and a single `rounds[]` entry per item. `P-Opt-1` above is not re-verified in round 2 because the round is focused on the corrected items and the ones the rewrite touched (step 7) — an item may legitimately carry fewer `rounds[]` entries than `roundHistory[]` has rounds, but every round in `roundHistory[]` must appear on at least one item.

`roundHistory[].gateResult` / `gateBlockedBy` are that round's own gate resolution (§"Round protocol" step 5), not the run's final one — the final value lives in data.json. `roundHistory[].completedAt` is the immutable ISO 8601 UTC timestamp recorded once after the round passes `okstra plan-verify`; it is not copied from a later activity and is never revised. `dispatches[].terminalStatus` mirrors finding convergence (`completed | timeout | error | not-run`). A wrapper-recorded `cli-failure` is a run-error-log event, not a terminal status — record that dispatch's `terminalStatus` as `error`.

`planItems[].rounds[].classification` enum: `full-consensus | partial-consensus | dissent-isolated | majority-disagree | needs-reverify | contested`. `needs-reverify` is the peer-error shape from §"Round protocol" step 4 (a single-vote-blocking kind with fewer than 2 participating non-error votes) — it survives into the state file when the round budget runs out before the re-dispatch resolves it, and `_recompute_plan_body_gate` folds it into `passed-with-dissent`. `contested` only appears when `maxRounds > 1`; at default `maxRounds=1` any otherwise-unresolved item folds into `partial-consensus` per the round protocol above.

`okstra plan-verify` scores the gate in its own vocabulary, which folds two of these labels together because only the `majority-disagree` boundary moves the gate. **Do not re-derive the mapping** — the scorer emits `gate.items[].stateClassification` with the state-file value already resolved, so record that. **Enforced:** `validators/validate-run.py` `_state_classification`.

| `gate.items[].classification` | `planItems[].rounds[].classification` | Condition |
|---|---|---|
| `full-consensus` | `full-consensus` | no `DISAGREE` |
| `has-dissent` | `dissent-isolated` | exactly one advisory `DISAGREE` (`d` or `P-Rb-*`) |
| `has-dissent` | `partial-consensus` | two or more advisory `DISAGREE`s |
| `majority-disagree` | `majority-disagree` | majority, blocking-kind minority, or reproduced single-vote |
| `needs-reverify` | `needs-reverify` | — |
| `all-non-result` | `needs-reverify` | no non-error vote at all |

`all-non-result` has no state-file label of its own: it is the `needs-reverify` shape at its limit, since "fewer than 2 participating non-error votes" covers zero. `contested` never appears on the left because the scorer does not produce it.

`planItems[].rounds[].votes.<worker>` stores `AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT` as emitted. A terminal non-result dispatch produces a non-result `verification-error` for each assigned item; a completed dispatch produces a per-item `verification-error` when that item is `UNVERIFIABLE`. Either `verification-error` MUST NOT be converted to `DISAGREE` and is excluded from consensus. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.

`planBodyVerification.selfFixRoundsApplied` (int, default 0): how many self-fix rounds actually ran. `planBodyVerification.selfFixStopReason` (enum, default `not-attempted`): why the loop exited — see §"Round protocol" step 7. `planItems[].verdicts[].fixability` is each `DISAGREE`'s `planner-fixable | needs-user-input` judgement, recorded in the final data.json (`§5.5.9`).

## Plan-body reverify prompt

Required prompt anchor headers are identical to finding convergence (see [convergence](./convergence.md) §"Required reverify-prompt anchor headers"). The prompt body changes from F-* listing to P-* listing:

The [convergence](./convergence.md) §"Required reverify output contract"
applies unchanged: append it verbatim after the response format below.

The prompt-body contract check runs on every `analysis`-audience prompt, not
just the critic's, so this round needs the same two lines the critic's
instructions need ([convergence](./convergence.md) §"What the critic
task-instructions file MUST contain"): a `**Prompt Delivery Mode:**` header and,
under `## Inputs`, exactly one `- Primary analysis packet:` line whose path ends
in `analysis-packet.md`. The literal label and the backticks are what the check
matches — a bare path or a reworded label counts as zero. They are in the
template below; keep them when you fill it in.

Carrying the packet does not license re-analysis. It is there so the verifier
can resolve a plan item back to the requirement it claims to satisfy; the
posture in §"Adversarial plan-body posture" still applies, and this round does
not revisit the requirements themselves.

Omitting either line fails `okstra team dispatch --dispatch-kind
reverify-planbody-r<n>` before any process starts, reported as `<task-type>
prompt contract: <worker>: exactly one Primary analysis packet path is required
(found 0)`. Fix the instructions file and re-materialize with
`--replace-undispatched` rather than editing the published prompt.

````
Perform plan-body verification for <task-key> (round 1).

**Prompt Delivery Mode:** eager-include

## Inputs

- Primary analysis packet: `<path ending in analysis-packet.md>`

## Instructions

Review the following items extracted from the consolidated implementation plan
authored after your initial analysis. For EACH item, respond with exactly one
verdict:

- **AGREE**: The item is executable as written and internally consistent with
  other items in the plan.
- **DISAGREE(<kind>)**: The item is broken. Cite which kind:
  (a) a concrete referenced file path / symbol contradicts a different concrete path / symbol for the same artifact elsewhere — a genuine mismatch; an abbreviated / ellipsis path is NOT (a), use (b); on a `P-Var-*` item, (a) takes a majority to block like (b)/(e), so raise a variation-point defect as (b) or (e),
  (b) command or referenced path is not executable or is ambiguous — including an abbreviated / ellipsis / under-specified path that does not resolve as written. A command that IS declared but cannot run here because build/test dependencies are not installed is NOT (b) — answer UNVERIFIABLE,
  (c) validation signal is not observable,
  (d) rollback violates commit / dependency order — advisory only: a rollback is run by a human, so a DISAGREE(d), and any DISAGREE on a `P-Rb-*` rollback item, is recorded as dissent but never blocks approval,
  (e) item contradicts the trade-off matrix — **including a `P-Opt-*` option carrying an abstraction (helper module, strategy / factory, indirection layer, interface), a configuration knob every planned call site passes identically, or an optional parameter no planned call site supplies, when no `P-Req-*` requirement-coverage item in this same queue maps to it**: the matrix priced a complexity the option does not buy. Decide this from the queue alone — the requirement-coverage rows are in it, so this is plan-internal consistency, not a re-analysis of the brief. A behavior that already has two implementations is the opposite defect and belongs to `P-Var-*`; do not raise both on one behavior,
  (f) requirement coverage row does not map the stated requirement to a concrete satisfying option / stage / step — citing an existing option counts as concrete even if that option's paths are abbreviated (that is (b) on the option's item, not (f)).
  When you give a DISAGREE, also answer **Fixability** — `planner-fixable` if this defect can be fixed using only the code + this plan draft + the brief, `needs-user-input` if an open user clarification / external information is required.

  On a `DISAGREE(a)`, or a `DISAGREE(f)` on a `P-Req-*` item, also answer **Claim** — those are the kinds that can block on your vote alone, so state what sort of claim it is and give okstra something to check.

  - **`fact`** — write a **Probe** okstra can run, as one line of JSON: `{"kind": "path-absent", "path": "src/missing.ts"}`. The kinds are `path-exists`, `path-absent`, `literal-present`, `literal-absent` (a literal inside a file), and `citations-differ` (two spelled-out references that contradict each other, as `left` / `right`). Paths are project-relative.
  - **`judgement`** — no probe. It takes a quorum, like `b` / `c` / `e`.

  **Do not write a reproduction result.** okstra runs the probe and records the outcome; a result you write is discarded. If your claim cannot be written as one of those probes, it is a `judgement` — that is a classification, not a demotion, and a claim no machine can confirm should never have blocked on one vote.
- **SUPPLEMENT**: The item is sound but a dependency / edge case / precondition
  is missing.
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents
  verification of this item — **including the planning worktree having no
  build/test dependencies installed**, which makes any `npm test` /
  `yarn workspace … build` / `pytest` command fail here by construction. The
  input alias is persisted as `verification-error`; Fixability is not applicable.

You are judging a plan, not running it. For a build/test command, confirm it is
**declared** (a real script/target in `package.json`, `Makefile`, or the task
runner) and that its arguments name real paths. If it is declared but will not
run in this tree for lack of dependencies, that is UNVERIFIABLE — not a defect
of the plan. If it is not declared at all, that IS `DISAGREE(b)`.

Do NOT re-analyze the original requirements. Judge solely from plan internal
consistency and stated commands / paths. Do NOT inspect the original task brief
or worker analyses for this round.

For every `P-*` item, answer this fixed falsification question before choosing
the verdict:

```markdown
What concrete false-positive input, failure ordering, or omitted dependency
would make this plan item incorrect even if its happy path succeeds?
```

Even for `AGREE`, the worker MUST record the considered counterexample and why
the supplied `payload` excludes it in the verdict note. If capability,
credential, network, or service state prevents verification, record
`UNVERIFIABLE` for that item. It is persisted as `verification-error`, excluded from both numerator and denominator, and must not be converted to `DISAGREE`.
Every verdict requires a non-empty explanation.

For `P-Req-*` items, compare only the requirement text embedded in the row
against the cited plan item(s). Do not open the original brief, but do reject
coverage rows that cite no concrete option/stage/step or cite a plan item that
does not satisfy the row's own requirement. For `documented-deviation`, do not
auto-AGREE based on the status token: separately verify the requirement, the
alternative stated in `coveredBy`, each `decisionRefs` target, and the
`approvalDisposition` (`accepted` must be user-confirmed; `blocked C-NNN` must
name an open `Blocks=approval` clarification in this report).

For each `P-Prep-S<stage>-<kind>` item, use only the supplied detector trigger
evidence, its single `designSurfaceCoverage` row, and referenced PREP items.
Apply the disposition-specific AGREE/DISAGREE rules in this contract; do not
rerun a free-form requirements analysis. Every DISAGREE includes Fixability.

## Plan items to verify

### P-Step-3 [TICKETID: <id>]: <one-line summary>
**From section**: 4.5.4 Stepwise Execution Order
**Original text**:
> <verbatim quote of the step>

**Check**:
 - Are referenced file paths consistent with the option's File Structure list?
 - Is the named command executable as written?
 - Does the success criterion produce an observable signal?
**Payload**:
> <lossless persisted payload>

### P-Opt-2 [TICKETID: <id>]: <one-line summary>
...

### P-Prep-S2-external-interface [TICKETID: <id>]: <one-line summary>
**From section**: Stage 2 designSurfaceCoverage + 5.5.10 Implementation Design Preparation
**Trigger evidence**: <detector-produced evidence rows>
**Coverage row**: <the single designSurfaceCoverage row>
**Referenced PREP items**: <verbatim referenced items, or none>
**Payload**:
> <lossless persisted payload>

## Response format

Head each block with `### <plan item id>` at exactly three hashes. The heading
depth is not style: the collector parses `^### ` and nothing else, so a block
written at any other depth is not an unparsed block — it is a verdict that was
never recorded, and the round is scored on the items that remain.

### P-Step-3
**Verdict**: AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT | UNVERIFIABLE
**Fixability** (only when DISAGREE): planner-fixable | needs-user-input — "planner-fixable if it can be fixed using only the code + this plan + the brief". Fixability is not applicable for `UNVERIFIABLE`.
**Claim** (only on `DISAGREE(a)`, or `DISAGREE(f)` on a P-Req item): fact | judgement
**Probe** (only when Claim is fact): <one line of JSON, e.g. {"kind": "path-absent", "path": "src/missing.ts"}>
**Note**: <the falsification candidate considered and why it is excluded; required even for AGREE>
**Explanation**: <2-3 sentences>

### P-Opt-2
...

### P-Prep-S2-external-interface
**Verdict**: AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT | UNVERIFIABLE
**Fixability** (DISAGREE only): planner-fixable | needs-user-input. Fixability is not applicable for `UNVERIFIABLE`.
**Note**: <the falsification candidate considered and why it is excluded; required even for AGREE>
**Explanation**: <2-3 sentences applying the disposition-specific rule>
````

**This template is the round-1 prompt.** Before rendering a round 2 or later,
read §"Re-verification rounds (round 2+)" below first — such a round carries
blocks this template does not have, and re-rendering this one alone is a
contract violation.

When `config.adversarial == true`, the lead prepends the adversarial framing from §"Adversarial plan-body posture" to the `## Instructions` block: the burden of proof is on the plan, the verifier opens and confirms every accessible cited path / command, and evidence that was opened but is insufficient yields the applicable `DISAGREE(<kind>)` rather than `AGREE`. Inability to inspect because of capability, credential, network, or service state yields `UNVERIFIABLE`, not DISAGREE. The verdict tokens, breakage kinds (a–f), classification, and the majority gate threshold are unchanged. This prepended framing supersedes the template's "Judge solely from plan internal consistency" instruction for the adversarial round.

The "Reverify prompt: required-reading suppression" rule in [convergence](./convergence.md) (lightweight mode does NOT inject a `[Required reading]` clause) applies here as well.

## Re-verification rounds (round 2+) — carry the dissent forward (BLOCKING)

The template above is the round-1 prompt. A round 2+ prompt exists to settle the *previous* round's dissent, so it MUST carry that dissent forward. Re-rendering the round-1 template alone is a contract violation: with no record of what was objected to, the dissenting worker restates its verdict unchanged and its peers re-judge from nothing, so the loop spends its whole `selfFixMaxRounds` budget re-deriving the same split. Telling the worker that a prior verdict "carries no weight" is the same defect stated as an instruction — prior dissent is evidence about the plan, and discarding it is what makes the round repeat.

Changes from the round-1 template:

- The heading reads `(round <N>)`.
- Every re-dispatched item carries a `**Prior round dissent**` block, built from the state file's `planItems[].rounds[].votes` and the item's `selfFixNote`:

  ```text
  **Prior round dissent** (round <N-1>):
  - <worker>: DISAGREE(<kind>) — <that worker's verbatim explanation>
  - <worker>: SUPPLEMENT — <verbatim explanation>
  **What the planner changed**: <the item's selfFixNote, or "no correction — this item's
  text shifted under a neighbouring rewrite">
  ```

  An item re-dispatched as `needs-reverify` (its peer returned a non-result, so nothing was objected to) carries the block with `- none — the peer vote was a non-result` in place of the verdict lines.

- The `## Instructions` block gains this paragraph, which supersedes any "judge the corrected text on its own merits" framing:

  > Each item below was objected to in the previous round, or its text shifted under a neighbouring correction. Read the prior dissent and the planner's correction before judging the CURRENT text. If you raised the prior objection and still DISAGREE, you MUST state what you re-opened after the rewrite and why the correction is insufficient — restating your previous explanation without engaging the correction is not a valid verdict. If the correction resolves your objection, say so and AGREE.

- The response format gains one line per item, directly under `**Verdict**`:

  `**Prior dissent**: resolved | unresolved | none — <which prior objection, and what the current text does about it>`

**Enforced — on the answer, not on the question.** `validators/validate-run.py` `_validate_reverify_result_addresses_prior_dissent` fails a run whose round 2+ plan-verify **worker results** lack the `**Prior dissent**` line described directly above. Nothing reads the prompt file, so the `**Prior round dissent**` block is a guideline: it is how you make that line answerable, and a worker cannot write it without one. Note the two spellings are different anchors for different artifacts — `**Prior round dissent**` is the block you put in the prompt, `**Prior dissent**` is the line the worker puts in its result, and only the latter is checked (`scripts/okstra_ctl/verdict_blocks.py` parses it as a response field). A prompt that carries the block while the response format omits the line fails this check with the block present.

## Worker non-result handling in plan-body round (BLOCKING)

Mirrors finding convergence ([convergence](./convergence.md) §"Worker failure handling in reverify"). Concretely:

- A dispatch that returns terminal non-result MUST NOT be aggregated as `DISAGREE`.
- If at least one dispatch was issued AND **all** plan-body dispatches return non-result, the Gate result is `aborted-non-result`. Record one `contract-violation` event per non-result dispatch.
- When the gate is `aborted-non-result`, report-writer MUST keep the frontmatter `approved: false` (publishing `approved: true` under this gate result is a validator failure). A single row is added to `## 1. Clarification Items` with `Statement="plan-body verification could not run — all workers returned non-result"`, `Kind=decision`, `Blocks=approval`, allowing the user to either retry the phase or override by running `--approve` on the resume command (or confirming in the in-session wizard). The row MUST name which dispatches returned no result and what re-running them requires. **Enforced:** `validators/validate-run.py` `_validate_aborted_gate_has_clarification` — `_validate_plan_body_clarification_matching` cannot cover this case because it walks `majority-disagree` items and an aborted round produces none, which is exactly how an aborted run used to reach the user with no stated blocker and stall.
