# Okstra Convergence Contract

## Index

- [Scope and Terminology (BLOCKING)](#scope-and-terminology-blocking)
- [When to Use](#when-to-use)
- [Configuration](#configuration)
- [Finding Category](#finding-category)
- [Convergence Algorithm](#convergence-algorithm)
  - [Round 0: Parse worker results](#round-0-parse-worker-results)
  - [Round 1-N: Re-verification Loop (queue-pruned)](#round-1-n-re-verification-loop-queue-pruned)
  - [Convergence Test](#convergence-test)
- [Verification Mode](#verification-mode)
- [Adversarial Verification Mode](#adversarial-verification-mode)
- [Re-verification Dispatch](#re-verification-dispatch)
- [Convergence State Artifact](#convergence-state-artifact)
- [Coverage critic pass](#coverage-critic-pass)
- [Acceptance critic pass (final-verification)](#acceptance-critic-pass-final-verification)
- [Output](#output)
- [Convergence Disabled](#convergence-disabled)
- [Plan-body verification mode (implementation-planning only)](#plan-body-verification-mode-implementation-planning-only)

## Scope and Terminology (BLOCKING)

This contract governs **Phase 5.5 (Convergence loop)** — a *lead operating phase* inside a single okstra run, not a task-type lifecycle phase. It leaves the 7 task-type lifecycle phases (`requirements-discovery` → `error-analysis` → `implementation-option-selection` → `implementation-planning` → `implementation` → `final-verification` → `release-handoff`, see [okstra-lead-contract](./okstra-lead-contract.md) "Lifecycle Phase Boundaries") unchanged; the lead operating phases (Phase 1 Intake → Phase 7 Persist, see [okstra-lead-contract](./okstra-lead-contract.md) "Quick Reference") drive a *single* task-type run.

**`contested` is a terminal classification, never an intermediate queue label.** The verification queue carries findings that are *unique to a single worker* (entered in Round 0) or *mixed/unresolved after a re-verification round* (carried forward). A finding is labelled `contested` in two places, both of which remove it from the queue: at the round where an adversarial `counter-evidence` refute lands (§"Adversarial Verification Mode"), and when the **last executed round** completes with the queue still non-empty. A `contested` finding is never re-dispatched.

When this contract says "queue" without qualifier, it means the *verification queue*: the set of findings that are still candidates for re-verification in subsequent rounds. The queue shrinks monotonically as findings get classified as `full-consensus`, `partial-consensus`, or `worker-unique`. Findings classified into any of these three categories MUST NOT appear in any subsequent round's reverify prompt, for any worker.

**Enforced:** `_validate_resolved_findings_leave_the_queue` in `validators/validate-run.py` fails a resolved finding whose `rounds[]` ledger is not a contiguous `1..N` — a gap is the finding re-entering the queue after classification.

An initial pane role `verifier` is still a Phase 4/5 analysis worker; it does not mean Phase 5.5 reverify. Only a queue-scoped dispatch whose prompt/result path carries `-reverify-r<N>-` performs the reverify step described by this contract.

The end-to-end artifact lifecycle is worker results → Round 0 grouping → reducer-owned queue → analyser-instance re-verification → optional `okstra convergence apply-critic-gaps` transition → validated terminal state (newly finalized v1.4, or unchanged historical v1.0–v1.3 from `reuse-final`) → report-writer narrative → deterministic report assembly. Cross-verification is queue-scoped: it does not mean that one worker reviews another worker's complete result. The reducer asks independent analyser instances to vote only on non-consensus findings selected by the persisted plan; the report writer never votes.

Initial and reverify worker prompts carry `**Audit sidecar path:**`. Initial workers write their reading confirmation there; reverify workers use their own canonical sidecar for the reverify session without reopening the initial worker's full reading packet.

## When to Use

- When okstra lead Phase 5.5 (convergence loop) begins — immediately after all workers complete Phases 4 and 5
- When findings need systematic classification by consensus level

## Configuration

Configure this in the `convergence` block of `task-manifest.json`. If the block is missing, the default values are used.

| Setting | Default | Description |
|------|--------|------|
| `enabled` | `true` | If `false`, skip the convergence loop and use the existing consensus/divergence method |
| `maxRounds` | phase-aware: `1` for `requirements-discovery`, `2` otherwise (range 1–3) | Maximum number of re-verification rounds. Discovery's routing/missing-input outputs gain little from a second round; other phases (especially `error-analysis`) keep `2`. Lead resolves the effective value when the manifest omits the key and records it in `config.effectiveMaxRounds` of the convergence state artifact. |
| `verificationMode` | `"lightweight"` | `"lightweight"` or `"full-reanalysis"` |
| `adversarial` | phase-aware: `true` for `requirements-discovery` / `error-analysis` / `implementation-option-selection` / `implementation-planning` / `project-analysis` / `feature-analysis` / `change-impact-analysis`, `false` otherwise | When `true`, Phase 5.5 runs in **adversarial mode** (see §"Adversarial Verification Mode"): verifiers actively try to refute each finding, the burden of proof sits on the claim, and `verificationMode` is forced to `"full-reanalysis"` scoped to the finding's cited evidence. Resolved by `scripts/okstra_ctl/render.py` `_build_convergence_block` and recorded in `config.adversarial` of the convergence state artifact. |

**Auto-disable rule (BLOCKING).** Convergence requires ≥2 analyser workers to produce a meaningful consensus tally. When the active profile's `Required workers:` block (see `prompts/profiles/*.md`) resolves to fewer than 2 analyser workers — e.g. `release-handoff` (zero analyser workers, lead-only) — the lead MUST treat `convergence.enabled` as `false` for that run regardless of manifest configuration, skip Phases 5.5 and the plan-body verification round ([plan-body-verification](./plan-body-verification.md)), and record `finalState: "converged"` with `totalRounds: 0`, `round2SkippedReason: "auto-disabled"`, an empty `roundHistory`, and an explanatory note in `config` (e.g. `"autoDisabled": "fewer-than-two-analysers"`). The plan-body round inherits the same rule via its `gating=false` advisory path.

## Finding Category

| Category | Definition | Included in Report |
|------|------|------------|
| `full-consensus` | All participating workers agree | Required |
| `partial-consensus` | Majority of workers agree; dissenting opinions are recorded | Required |
| `contested` | Terminal classification. Assigned to a finding that remains in the verification queue after the **last executed round** completes (round index = `effectiveMaxRounds`), and — in adversarial mode only — to a finding refuted with `counter-evidence` at the round that refute lands. Each worker's position across all executed rounds is recorded. Either way the finding leaves the queue and is never re-dispatched. | Required |
| `unverified` | Final classification only. Assigned to a finding that reached the last executed round with **every recorded vote** `verification-error` — a terminal non-result dispatch, or no analyser available to vote. Nobody inspected it, so `contested` would state a dispute that never happened. The gap ledger already applies the same rule (§'Gap verification'). | Required |
| `worker-unique` | Only the discoverer confirms and ALL other non-error votes are `DISAGREE`. `verification-error` votes are excluded from the tally per §"Worker failure handling in reverify"; a finding where every non-discoverer vote is `verification-error` is carried forward, never classified `worker-unique`. | Required |

## Convergence Algorithm

**Majority definition (BLOCKING).** "Majority" means *strictly greater than half* of the non-error votes for that finding (`verification-error` votes are excluded from both numerator and denominator). Ties — including the 1-AGREE / 1-DISAGREE case in a two-analyser roster — are NOT a majority: in intermediate rounds the finding is **carried forward**; in the final executed round the finding is classified `contested`. In adversarial mode a tie whose DISAGREE carries `counter-evidence` does not carry forward — it is classified `contested` in that round (§"Adversarial Verification Mode"). This rule applies identically to the plan-body verification round ([plan-body-verification](./plan-body-verification.md)) where the same verdict tokens are reused.

**Enforced:** the engine owns the classifier and replays it — `scripts/okstra_ctl/convergence_engine.py` `validate_final_state` recomputes each finding's expected final classification from its recorded votes and rejects the state when the persisted value differs, and `finalize` runs that same check before it writes, so a tie scored as a consensus never reaches the artifact. `okstra convergence validate` is the same call on demand. Nothing re-derives the majority rule outside the engine; a second implementation would only drift from it.

### Round 0: Parse worker results

Read the worker result files generated in Phase 4/5 and extract individual findings.

**Convergence scope.** Convergence operates on sections 1–5 of the worker output (the common core, see the worker preamble §"Worker output sections"). Section 6 ("Specialization Lens") is additive worker-specific depth and MUST NOT be fed into the consensus grouping, the verification queue, or the round-N reverify prompts. Carry Section 6 forward into the final report verbatim through the report-writer worker — do not let it inflate `unique` counts or trigger spurious `verification-error` statuses.

**Incremental re-verification scope (implementation-planning clarification re-runs).** When the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), only findings the lead attributes to a stage in `reverify_stages` enter the verification queue. Findings and plan-item verdicts carried forward for `carry_stages` are NOT re-queued. The report writer preserves those stage rows in its narrative, and `okstra incremental-carry` verifies that they are unchanged before copying their prior verdicts into the convergence-owned plan state. When the decision is `mode == "full"` (the default), every finding enters the queue as usual.

1. In the "Findings" section of each worker's results, identify individual items by number (F-001, F-002, ...) and parse the ticket identifier attached to each item:
   - For table-form findings, read the `Ticket ID` column.
   - For bullet/numbered findings, parse `[TICKETID: <id>]` from the item title.
   - Items with multiple tickets (e.g. `TICKET-123, TICKET-456`) expand to a set of ticket keys.
   - Items tagged `unknown` keep the literal `unknown` as their ticket key.
2. For each finding, record the summary, evidence (file path, line number, basis), the discovering worker, **the worker-internal item ID that worker assigned** (e.g. `F-001`, `1.1`, `F-3` — see `prompts/profiles/_common-contract.md` "Cross-worker traceability" SSOT), and the parsed ticket set. Persist the item ID as `findings[].discoveredBy.<worker>.itemId` and each cross-worker confirmation as `findings[].sourceItems[]` (one entry per contributing `<worker>:<item-id>` pair). The final-report `## 6.1 Consensus` / `## 6.2 Differences` / `## 2.1 Primary Evidence` tables read this verbatim into their `Source items` columns; without it the synthesised `C-NNN` row loses its link back to the original worker wording.
3. The lead groups findings based on semantic similarity AND ticket-set equality:
  - Same semantics + same ticket set across 2+ workers → one multi-source group.
  - Same semantics but disjoint ticket sets → separate groups (do NOT over-merge across tickets).
  - Only one worker confirms a finding → one single-source group.
4. When grouping is ambiguous, prefer splitting over merging (avoid over-merging). Semantic matching, ticket-set equality, and evidence interpretation remain lead judgments; the engine does not perform fuzzy matching or decide whether evidence is credible.
5. Author the fixed grouping Markdown accepted by `okstra convergence prepare-groups --run-manifest <run-manifest> --input <grouping.md>`, then run that command. Python owns the artifact identifier, target path, schema version, task identity, run-manifest reference, and every participant reference. Each Markdown group records ticket IDs, origin worker and evidence, discovering workers, source worker item IDs, and optional captured evidence. An analysis sidetrack with no ticket uses an empty `Tickets:` value, never a placeholder. Use the ordered functional roster: finding workers have the `analysis` audience, the report author has `report-writer`, and the lead uses `lead`. A lead source never votes. For `implementation` runs the convergence sources are the verifier-role results only — the executor's result is deliverable evidence, not a convergence source (**Enforced:** `_validate_worker_execution_identity` in `scripts/okstra_ctl/convergence_engine.py` rejects an `implementer` source with `analysis audience source role is not allowed`). Never infer live evidence or functional scope from wording, provider, model, or execution label.

   The command sets each worker's paired `participantRef` and `sourceRoleExecutionRef` from the run manifest's canonical role state. It sets `sourceRoleExecutionRef` to the selected source `RoleExecution` row's `roleExecutionRef`, not that row's `sourceRoleExecutionRef` field.
6. Do not write a queue or classification in this grouped-input artifact. `okstra convergence seed` classifies Round 0 the same way in both modes: a group whose sources are **two or more distinct role executions** becomes `full-consensus` immediately, and only single-source groups enter the working queue. Independent co-derivation is already cross-verification — the adversarial burden of proof targets single-source claims, not a finding two roles reached on their own. A source is counted once per analysis worker, and one analysis worker is exactly one `sourceRoleExecutionRef` — the same identity the reverify roster uses for independence — so two roles held by one provider count as two and no role can count twice. **Enforced:** `_parse_workers` rejects a duplicate `workerId` and `_validate_worker_execution_identity` rejects a duplicate `sourceRoleExecutionRef`, both in `scripts/okstra_ctl/convergence_engine.py`. Semantic grouping merges provenance only; it does not decide a single-source finding is reliable. Section 6 never enters the grouped input.

### Round 1-N: Re-verification Loop (queue-pruned)

The `ConvergenceEngine` reducer owns the working queue, classification strategy, pruning, round arithmetic, gate precedence, skip reason, final state, and classification counts. The lead owns only semantic grouping plus translation of observed worker outcomes into the structured round-results schema. Runtime adapters transport persisted batches and terminal outcomes only.

Working artifacts are stored beside the final state:

```text
convergence-groups-<task-type>-<seq>.json
convergence-work-<task-type>-<seq>.json
convergence-round-<N>-plan-<task-type>-<seq>.json
convergence-round-<N>-results-<task-type>-<seq>.json
convergence-<task-type>-<seq>.json
```

Follow this protocol exactly:

0. Version-selected schemas describe what the reducer reads: `schemas/convergence-groups-v1.0.schema.json` accepts only legacy groups, `schemas/convergence-groups-v2.0.schema.json` accepts only explicit v2 execution identity, and `schemas/convergence-round-results-v1.0.schema.json` feeds step 4's `apply-round --results`. `schemas/convergence-critic-results-v1.0.schema.json` is a fourth shape but **not** a reducer input — it describes the critic worker's own result document. Step 6's `apply-critic-gaps --results` takes the coverage batch you assemble from those candidates plus each analyser's vote (`{schemaVersion, taskKey, mode, provider, modelExecutionValue, dispatches[], gaps[]}`, spelled out in §"Coverage critic pass" §"State"); feeding the critic document straight in is rejected, by design. `okstra convergence example --kind <groups|round-results|critic-results>` prints a deterministic valid v1 instance of each and writes only JSON to stdout.
1. Run `okstra convergence seed --groups <groups> --run-manifest <current-run-manifest> --work-state <work> --final-state <final> --migration-dir <state/migrations>` for a v2 worker roster. `--run-manifest` must be the exact current manifest named by the groups document's `runManifestPath`; a previous run from the same task is not interchangeable. `seed` refuses a grouping that cites a worker whose initial attempt the mutation audit discarded (`contract-failed-unattributed`, `mutation-present-unresolved`) — the result file is still on disk, but the ledger says it was never accepted. Re-dispatch that worker as a new invocation and cite the new result, or leave it out of the grouping. **Enforced:** `discarded_worker_errors` in `scripts/okstra_ctl/convergence_provenance.py`, called by `_seed`. Omit the flag for a legacy v1 roster. A `reuse-final` action means validate the existing final and continue to Phase 6. `create-work`, `resume-work`, and `restart-round0` continue with planning.
2. Run `okstra convergence plan-round --work-state <work> --plan <round-plan>`. This is read-only with respect to the working state.
3. Every plan carries `dispatchable`: `true` on an `action: "dispatch"` plan, `false` on an `action: "finalize"` plan. When the plan action is `dispatch`, create exactly one reverify prompt for each `dispatches[]` row and dispatch it through the selected runtime adapter. Its findings are exactly that row's `findingIds`.
4. Run `okstra convergence collect-results --plan <round-plan> --mode <adversarial|collaborative> --result <worker>=<path>… --run-manifest <current-run-manifest> --output convergence-round-<N>-results-<task-type>-<seq>.json`. Each planned worker's terminal outcome and `durationMs` come from the manifest's attempt ledger, not from the lead: an attempt still `started` refuses the collect — run `okstra team await` first so the attempt closes — and a worker whose attempt did not close `ok` cannot contribute a vote even if its result file exists. **Enforced:** `_dispatch_rows_from_manifest` in `scripts/okstra_ctl/convergence.py`. Then run `okstra convergence apply-round --work-state <work> --plan <round-plan> --results <round-results>`.
5. Repeat `plan-round` and `apply-round` until the plan action is `finalize`. A `finalize` plan is **never dispatched**: it carries `dispatchable: false`, an empty `dispatches[]`, and a `note` naming the next command. Its `round` is only the `<N>` in `convergence-round-<N>-plan-<task-type>-<seq>.json` — not a round to run — so do not open reverify workers for it. **Enforced:** `okstra convergence apply-round` refuses a non-dispatch plan and returns the gate's closing reason with the remedy (`scripts/okstra_ctl/convergence_engine.py` `apply_round_results`).
6. When the coverage critic is enabled, convert its verification batch to the canonical `dispatches[]` / `gaps[]` result shape and run `okstra convergence apply-critic-gaps --work-state <work> --results <critic-results>` exactly once. The reducer, not the lead, merges verified gaps.
7. Run `okstra convergence finalize --work-state <work> --output <final>`.
8. Run `okstra convergence validate --state <final> --kind final`. Newly finalized convergence output is schema v1.4. Under `reuse-final`, a valid historical final schema v1.0, v1.1, v1.2, or v1.3 remains consumable by the report-writer without rewrite; do not finalize, upgrade, or otherwise rewrite that reused artifact. Deliver the validated terminal state to the report-writer, which does not vote.

The planner preserves roster and queue order, excludes that finding's origin worker, emits at most one batch per analysis worker per round, and ensures the report-writer never appears in `dispatches` or `skippedWorkers`. Queue pruning is monotonic: a finding absent from the current queue cannot reappear in a later plan.

After `seed`, the lead MUST NOT hand-edit queue IDs, classifications, `roundHistory` arithmetic, `round2SkippedReason`, `finalState`, `totalRounds`, or `finalClassificationCounts`. The persisted plan is the assignment audit record; adapters and leads may not recalculate it. `scripts/okstra_ctl/convergence_engine.py` and `okstra convergence validate` enforce these rules.

#### Resume and legacy-state recovery (BLOCKING)

A valid historical final schema v1.0, v1.1, or v1.2 is reused unchanged under `reuse-final`; it remains read-only and is not upgraded in place. A malformed, unreadable, or partial legacy final is archived byte-for-byte under `state/migrations/` and restarted from Round 0 because its queue provenance cannot be reconstructed. A matching valid working state resumes. A malformed new-engine working state fails closed and names the explicit `--restart-from-round0` recovery flag; when supplied, every invalid existing state is archived before replacement. The final path can be replaced only when its recorded migration archive still matches the original bytes.

**Enforced:** `scripts/okstra_ctl/convergence_migration.py` `decide_seed_action` picks the seed action (`reuse-final`, archive-and-restart, resume, or fail-closed) from the on-disk state and the `--restart-from-round0` flag.

#### Engine-owned Round 2 gate

`plan-round` applies gate precedence in one place: auto-disabled, all reverify non-result, effective maximum of one, empty queue, then maximum rounds reached. `finalize` maps that internal reason to the public `round2SkippedReason` and `finalState`. The lead and adapters never reproduce this predicate.

A gate-closed plan states its own terminality in `dispatchable: false` and `note`; read those, not `round`. With `effectiveMaxRounds: 2` the closing plan reads `{"action": "finalize", "round": 3, "dispatchable": false, "reason": "max-rounds-reached"}` — `round: 3` names the artifact, and there is no round 3 to dispatch.

#### Worker failure handling in reverify (BLOCKING)

A reverify dispatch that returns a **terminal non-result** (`timeout`, `error`, no result file, or the wrapper records `cli-failure`) MUST NOT be aggregated as `DISAGREE`. Misclassifying a worker failure as DISAGREE biases the queue toward `contested`/`worker-unique` and produces meaningless final classifications.

**Enforced:** `_validate_worker_failure_is_not_a_disagree` in `validators/validate-run.py` matches each round's `dispatches[].status` against that round's `votes` and fails a `disagree` recorded for a worker whose dispatch status is `timeout` / `error` / `not-run`.

Rules:

1. For each failed dispatch, put its actual terminal status and duration in the round-results `dispatches[]`; do not invent a vote. `okstra convergence apply-round` appends `votes[W].verdict = "verification-error"` with the terminal reason for every affected finding. A completed dispatch may separately return `UNVERIFIABLE` for a particular finding; the input alias is persisted as `verification-error` with its required non-empty explanation while the dispatch remains `completed`.
2. Record one event per failed dispatch via `okstra error-log append-observed --error-type cli-failure --agent <worker> ...` (the worker wrapper does this for wrapper failures; for in-process worker timeouts the lead does it).
3. `apply-round` adds the worker to the persisted round's `skippedWorkers[]` with `{worker: <W>, reason: "dispatch-non-result", terminalStatus: <timeout|error|not-run>}`.
4. If at least one dispatch was issued and every dispatch terminates as non-result, `apply-round` records the `all-reverify-non-result` stop state. The next `plan-round` returns `action: "finalize"`; record one `contract-violation` event per non-result dispatch.
5. Section 6 (Specialization Lens) of a worker output is OUT of convergence scope per "Convergence scope" above — its absence is NEVER a `verification-error`.

The engine's classifiers treat `verification-error` as "no usable vote" — it counts neither toward AGREE nor toward DISAGREE and is excluded from both numerator and denominator.

The public shorthand is `UNVERIFIABLE → verification-error`: `UNVERIFIABLE` is the worker-facing input token, while `verification-error` is the persisted state token. This mapping records unavailable evidence honestly and never turns inability to verify into a negative vote.

### Convergence Test

The executable source is `scripts/okstra_ctl/convergence_engine.py`; `okstra convergence validate --kind final` replays its persisted-state invariants. This document defines orchestration and semantic boundaries, not a second implementation of the reducer.

## Verification Mode

### Lightweight (Default)

Decide solely on the findings and evidence other reviewers present; do not reanalyze the original code or data. Fast and cost-effective, but accuracy drops when evidence is insufficient.

### Full Re-analysis (opt-in)

Use each finding as a guide but reanalyze the original code/data yourself. High accuracy at 2–3× the cost and time.

## Adversarial Verification Mode

Active only when `config.adversarial == true` (default for `requirements-discovery`, `error-analysis`, `implementation-option-selection`, `implementation-planning`, `project-analysis`, `feature-analysis`, and `change-impact-analysis`; see §"Configuration"); when `false`, every rule in this section is inert and the collaborative behaviour elsewhere in this contract applies unchanged. In adversarial mode the verifier's job inverts: instead of confirming a peer's finding, the verifier **tries to break it**, and the burden of proof sits on the claim — a finding survives only if refutation attempts fail.

### Read-only analysis task contract

For `project-analysis`, `feature-analysis`, and `change-impact-analysis`, every analysis worker independently analyses the full confirmed target. Provider or model diversity is an independent evidence source, never a reason to split the target into disjoint worker assignments. Only the `project-analysis` first exploration pass may divide navigation by component; every worker then returns to the whole confirmed target before producing findings.

A single evidence-backed refutation makes the affected finding `contested` while that refutation remains unresolved. Lead MUST NOT use majority voting to override it and MUST NOT promote a lead-only finding into confirmed facts. The report writer records current-run refutations and resolutions from the convergence state under `crossVerification`. `analysisReviewResolution` is reserved for a prior report's `## ANALYSIS REVIEW` carry-in and MUST remain empty without that carry-in. A `still-unresolved` carry-in item cannot appear in `analysisCommon.confirmedFacts`. **Enforcement:** `validators/validate_analysis_report.py` rejects non-empty `analysisReviewResolution` without a prior review and rejects a still-unresolved reviewed ID that appears in confirmed facts; the convergence-state validator preserves the engine's `contested` classification.

If every required analysis worker produces a non-result, the run verdict is `blocked`; Lead synthesis is not a worker result. A partial worker failure stays in `executionStatus`, but it does not by itself change the deterministic `analysis-complete` / `analysis-partial` scope verdict. **Enforcement:** `validators/validate_analysis_report.py` recomputes these verdict conditions from structured `data.json`.

### Scoped full-reanalysis

Adversarial mode forces `verificationMode = "full-reanalysis"`, but the re-analysis is **scoped to the evidence the finding under attack cites** (the file paths / line ranges / log lines in its `originEvidence`), plus the immediately surrounding context. The verifier MUST NOT re-read the whole task brief, instruction-set, or `final-report-template.md`. **Enforced (template clause only):** `_validate_full_reanalysis_prompt_omits_the_report_template` in `validators/validate-run.py` fails a reverify prompt that references `final-report-template.md`; the brief and instruction-set clauses have no fixed literal to match on. This keeps the documented "single largest avoidable cost in requirements-discovery, error-analysis, and implementation-planning" (see §"Reverify prompt: required-reading suppression") bounded while making the refutation real rather than a text-only argument.

### Adversarial verdict semantics

The persisted `verdict` enum is unchanged (`agree | disagree | supplement | verification-error`). The prompt-facing labels are adversarial and map down on persistence:

| Prompt label | Persisted `verdict` | Meaning |
|---|---|---|
| SURVIVES | `agree` | Actively tried to refute and failed — the claim withstood the attack. |
| SURVIVES-WITH-CAVEAT | `supplement` | Holds, but a scope limit / extra condition / precondition was found. |
| REFUTED | `disagree` | The claim was broken (or failed to prove itself). MUST carry a `disagreeBasis`. |
| UNVERIFIABLE | `verification-error` | Capability, credential, network, or service state prevented verification of this finding. |

Each `disagree` vote records a new field `disagreeBasis`:

| `disagreeBasis` | Meaning |
|---|---|
| `counter-evidence` | The verifier opened and inspected the cited evidence and found a contradiction (`file:line` / log line) recorded in `explanation`. A **hard refute**. |
| `burden-not-met` | The verifier opened and inspected the cited evidence, but its contents were insufficient to establish the claim. |

A `disagree` with `disagreeBasis == null` is a contract violation in adversarial mode — every refutation must state which of the two grounds it rests on. **Enforced:** `_validate_adversarial_disagree_carries_a_basis` in `validators/validate-run.py` fails such a vote when `config.adversarial` is set; non-adversarial rounds are out of scope because their verdict semantics differ. Bare "I disagree" without re-inspection is not allowed. If capability, credential, network, or service state prevents that inspection, the verdict is `UNVERIFIABLE`, persisted as `verification-error`; verifier failure is never converted to `DISAGREE` or `burden-not-met`. A live/external claim without `evidenceArtifacts` remains schema-valid, but a verifier that needs the missing artifact and cannot independently access the source MUST answer `UNVERIFIABLE` with a non-empty explanation.

### Adversarial classification (replaces the §"Convergence Algorithm" per-round classifier when `adversarial == true`)

`verification-error` votes are excluded from numerator and denominator exactly as in the collaborative classifier. For each finding `F` in the queue at a round:

```text
disagrees    = [v for v in non-error votes if v.verdict == "disagree"]
hard_refutes = [v for v in disagrees if v.disagreeBasis == "counter-evidence"]
all_others_disagree = (every non-discoverer non-error vote is "disagree")

IF len(disagrees) == 0:
    resolve F as "partial-consensus" if SUPPLEMENT/caveat votes are a majority of
    non-error votes, otherwise "full-consensus"
ELIF all_others_disagree:
    resolve F as "worker-unique"    # only the discoverer still holds it
ELIF len(hard_refutes) >= 1:
    # an evidence-backed refute exists and the roster is split → the claim is disputed
    resolve F as "contested" IN THIS ROUND; F leaves the queue
ELIF burden-not-met disagrees are a majority of non-error votes (per the Majority definition in the Convergence Algorithm section):
    carry F forward; at the LAST executed round classify it "contested"
ELSE:
    # a lone weak (burden-not-met) doubt against an otherwise-surviving claim
    resolve F as "partial-consensus"
```

`contested` stays terminal (per §"Scope and Terminology") and is reached by two routes. A finding split on `burden-not-met` doubt alone is carried forward through intermediate rounds and labelled `contested` at the last executed round; a finding carrying a `counter-evidence` hard refute is labelled `contested` in the round that refute lands and leaves the queue there. For `requirements-discovery` (`effectiveMaxRounds = 1`) the two routes coincide — the single round IS the last round. The final-classifier block of §"Convergence Algorithm" honours this: its first branch classifies an adversarially carried-forward finding `contested` regardless of the AGREE tally, so the two sections cannot assign the same finding different labels.

Design intent: one `counter-evidence` refute denies a claim consensus (it cannot rise above `contested` however many others AGREE). Because that refute is permanent, re-dispatching the finding buys nothing — the verifier gets no new information and re-casts the same refute, so the round is spent by construction. The refute therefore settles the finding where it lands, and the finding is reported under `## 6.2 Differences` exactly as a last-round `contested` finding is. A lone `burden-not-met` doubt does not sink an otherwise-surviving claim — only a majority of them does; that doubt IS resolvable by another round, so it still carries forward. When every non-discoverer refutes (all_others_disagree) the finding is worker-unique regardless of refute basis — only the discoverer still holds it. A caveat is weighed the same way a weak doubt is: with zero disagrees, SUPPLEMENT lands partial-consensus only when caveats are a **majority** of the non-error votes. A single verifier's scope note does not by itself deny a claim the rest of the roster passed cleanly — the caveat is still recorded in the dissent log either way, so the majority rule changes the label, never the record. (The collaborative classifier is more permissive still: there SUPPLEMENT counts as full agreement at any count.)

## Re-verification Dispatch

Every finding re-verification, coverage critic, acceptance critic, and critic-verification instruction passes [okstra-lead-contract](./okstra-lead-contract.md) "Worker instruction quality gate" before materialization. The lead resolves exact finding IDs, evidence paths, requested verdict fields, allowed verdict literals, and completion conditions from the current convergence state and cited results; a worker is not asked to infer them from a round summary.

### Invocation materialization gate (BLOCKING)

For every finding reverify row and critic-gap verification row, first write a
call-specific task-instructions file under the current run's `state/`
directory — for a finding reverify row, the verbatim output of `okstra
convergence reverify-prompt --run-manifest <run-manifest> --plan
<round-plan.json> --worker <workerId>`. For a v2 run, take `participantRef` and
`sourceRoleExecutionRef` from the canonical round-plan dispatch row or the
matching canonical working-state worker row. That stored reference is the
selected source `RoleExecution` row's `roleExecutionRef`, not that row's
`sourceRoleExecutionRef` field. Then run `okstra agent-prompt
materialize` with `--audience
reverification-worker`, `--assignment-ref reverify/<workerId>`,
`--source-role-execution-ref
<sourceRoleExecutionRef>`, the exact `--worker-id`, `--dispatch-kind
reverify-r<N>`, and the authorized prompt/result/audit paths. Do not derive the
source role execution from `workerId`, provider, model, or execution-label
text. A legacy v1 run omits `--source-role-execution-ref`; the materializer
keeps its legacy read-only identity resolution for that schema version. The
returned `promptPath` is the only body that may be dispatched; do not append
role prose or reconstruct model headers after materialization.

If the dispatch gate then rejects that prompt, preserve its prompt, metadata,
reservation, and append-only Invocation bytes. A v2 reverify correction uses a
fresh `--invocation-id` and fresh prompt/metadata path; the materializer rejects
`--replace-undispatched` for this dynamic identity path. A legacy v1 correction
may fix the task-instructions file and re-run the same `materialize` call with
`--replace-undispatched` while keeping the `--invocation-id`. Once any dispatch
row names the invocation, replacement is rejected even for v1 and the prompt is
history. Do not delete prompt, metadata, or reservation files by hand.

Run `okstra agent-prompt verify --run-manifest <path> --metadata
<metadataPath> --text` immediately before dispatch. A failed verification is a
pre-dispatch contract failure. For `runner=native-session`, pass only the
returned `hostModelValue` to the host model argument. For
`runner=cli-wrapper`, follow the planned execution surface after
`core-pre-dispatch` verification: invoke `okstra team dispatch` when
`terminalBackend` is `cmux-pane`, otherwise invoke `okstra worker-dispatch`
and let it consume the returned `modelExecutionValue`; never pass that
value as a native-host model token. Before a host-native call, run `okstra agent-prompt record-dispatch`
with the project root, run manifest, metadata path, and
`--enforcement-mode host-native-spec-link-gate`. After its Result Path exists,
run `okstra agent-prompt link-result` with the same run manifest,
`--dispatch-id <invocationId>:attempt-1`, and that result path before reading
the result. This link proves that the accepted result belongs to a verified
call specification; it does not prove which bytes the host primitive delivered.

### Sponsorship Optimization

For each persisted round plan, build exactly one prompt per `dispatches[]` row and call `redispatch_worker(assignment, prompt, reason)` once through the selected runtime adapter. The prompt contains exactly that row's `findingIds` in plan order and MUST NOT add, remove, or reorder findings. **Enforced (membership only):** `_validate_reverify_prompt_matches_plan` in `validators/validate-run.py` replays `plan == prompt` as a set — it fails an added or dropped finding. Order is not machine-checked; the engine row is the order of record. This excludes Section 6, every resolved finding, and every finding owned by the receiving origin worker because none can appear in the engine row. **Ownership is compared by `sourceRoleExecutionRef`, not by `participantRef`** — a worker sharing the origin's provider and model in a *different* role is a different role contract, a different duty and a different session, so it stays in the panel (ADR-0017; the same doctrine §"Critic gaps" states for critics). **Enforced:** `_worker_is_independent_from_finding` in `scripts/okstra_ctl/convergence_engine.py`. The assignment, model, prompt path, Result Path, worker-results path, errors paths, and `dispatchKind` come from the current run artifacts. Every reverify is a fresh one-shot session.

The persisted round plan is the audit record for batch membership. The lead and adapter do not branch on task type, provider, model identity, classification labels, or their own view of the queue. They dispatch only the engine-returned row through the selected runtime adapter.

Call `await_workers(handles)` through the same adapter and apply the shared terminal-status/completion-path contract before counting a vote. The selected adapter owns native invocation spelling and any jobs-file/CLI fields. This contract owns only the reverify payload and verdict semantics.

**Completion detection per round (BLOCKING).** A dispatch acknowledgement is NOT completion — detect each round's completion via the SSOT protocol in [team-contract](./team-contract.md) "Worker-completion detection", with the pending set reconstructed from that round's dispatched workers' Result Paths. Do NOT end the turn with a prose "waiting" statement.

### Required reverify-prompt anchor headers (BLOCKING)

**Enforced:** `verify_agent_invocation` in `scripts/okstra_ctl/agent/invocation.py` and `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` validate generated delivery before dispatch.

Use `okstra agent-prompt materialize` for every reverify prompt. Render the instruction file with `okstra convergence reverify-prompt --run-manifest <run-manifest> --plan <round-plan.json> --worker <worker-id>` and write its output verbatim; it carries the round's mandate, every planned finding with the origin worker's result file, item id, and audit sidecar (which the verifier is told it may open), and the response format the collector parses. Do not hand-copy evidence or the response format: a hand-copied `**Cited evidence**` line carried part of the origin's citation, so the verifier judged the lead's transcription and refuted five claims as `burden-not-met`, and a hand-written `- Verdict:` format cost the same round (2026-09-09). **Enforced (rendering):** `okstra_ctl.convergence_reverify_prompt`. **Enforced (pre-dispatch):** the rendered body's first line is `**Rendered by:** okstra convergence reverify-prompt`, and `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` refuses a `reverify-r*` instruction without that line — a hand-written instruction cannot be materialized. The materializer generates path headers through `worker_prompt_headers`, the source Worktree from the active run, and the execution identity from the run manifest. It also generates the model, task type, and exact `workflow.forbiddenActions` before the instructions. These values are checked by `verify_agent_invocation` and `validate_reverify_prompt` before publication and dispatch.

The generated `**Project Root:**` owns .okstra artifacts; `**Worktree:**` names the source checkout. Use the prompt's `**Invocation metadata path:**` for its invocation metadata. The result passed as `--result` is the worker's own result and carries the canonical `-worker-` token used by `audit_sidecar_rel`. Reverify has one result path, so omit `--audit-source`. The materializer supplies the audit, errors, read scope, and provider-specific plain-file write instructions.

The generated `**Errors log path:**` and `**Errors sidecar path:**` headers use the run's error wiring. Report observed failures with `okstra error-log append-observed` as specified in [team-contract](./team-contract.md). The errors sidecar is runtime-owned; no model-authored error JSON file is part of reverify.

An older instruction file may already contain the fixed boundary. Matching values are retained once; conflicting model, task type, or forbidden-actions values are reported with the expected value and a materialization remedy. Do not edit a dispatched prompt. Use a fresh invocation ID and prompt path for a dynamic reverify correction; undispatched initial prompts can be regenerated in place by the existing recovery path.

After materialization, generate the batch file from the returned metadata paths:

Name the reverify result `<role-slug>-worker-reverify-r<N>-<task-type>-<seq>.md` under the run's authorized worker-results directory. Its generated audit path is checked before dispatch.

```bash
okstra agent-prompt jobs --project-root <root> --run-manifest <manifest> --dispatch-kind reverify-r<N> --metadata <first-meta.json> --metadata <second-meta.json> --out <run-state>/reverify-jobs-r<N>.json
```

Pass only the current engine-planned batch. The generator reads the actual result headers and canonical role execution, verifies all inputs, then publishes the file. Dispatch the emitted file with the selected adapter's `--jobs-file`; do not copy identity, path, or digest fields by hand. Existing v1 jobs-file consumers remain available.

### Required reverify output contract (BLOCKING)

**Enforced:** `validate_reverify_prompt` and `_validate_output_contract_block` in `scripts/okstra_ctl/worker_prompt_contract.py` validate the three persistence clauses before dispatch.

The materializer appends [the canonical output contract](../../templates/reverify-output-contract.md) when the instruction has none. It requires a persisted Result Path and Audit sidecar path before returning and rejects an inline-only response. An existing output block is checked for the same three clauses by `validate_reverify_prompt`. Plan-body verification retains its own response format; its instructions can reference the same output contract.

### Reverify prompt: required-reading suppression

Reverify prompts MUST NOT inject the Phase 2 `[Required reading]` clause:

**Enforced:** `_validate_reverify_prompt_suppresses_required_reading` in `validators/validate-run.py` fails any `*-reverify-r*.md` prompt containing the clause.

Lightweight reverify does not require the original `analysis-packet.md`, `analysis-profile.md`, `task-brief.md`, or instruction set. Its complete input is the receiving worker's current engine-planned `findingIds` batch and the evidence embedded in those findings.

- **Lightweight mode**: the clause directly contradicts the "Do NOT re-analyze the original source materials" instruction below. Including it forces workers to re-read the entire instruction-set per round per worker (3 workers × 2 rounds × 5+ files in the worst case) for no quality gain.
- **Full-reanalysis mode**: workers DO need to re-read source materials, but only the analysis-worker file list (no `final-report-template.md`). If lead chooses to inject a reading clause here, it MUST mirror the audience-scoped enumeration in [okstra-lead-contract](./okstra-lead-contract.md) Phase 2 (no template).

This is the single largest avoidable cost in `requirements-discovery`, `error-analysis`, `implementation-option-selection`, and `implementation-planning` runs. Treat as mandatory.

## Conditional reference reading

The common read retains finding classification, queue pruning, dispatch gates,
state ownership, and output rules. Use the generated `okstra convergence
reverify-prompt` body for every verifier. The following reads are guidance for
avoiding unused examples; queue and dispatch validators still enforce execution.

| Current operation | Additional section to read |
|---|---|
| Diagnosing a rejected verifier prompt | The reference prompt matching the selected verification mode |
| `config.critic.enabled` is true | Coverage critic pass, including its shared dispatch procedure |
| An enabled critic uses acceptance mode for `final-verification` | Acceptance critic pass as well as the shared Coverage critic dispatch procedure |

Read the matching sections before their dispatch. A disabled critic needs neither
critic section. Generated prompts retain the selected mode's instructions even
when the lead does not read the example text.

### Lightweight Re-verification Prompt

Rendered by `okstra convergence reverify-prompt` when `config.adversarial` is false; the block below is the reference shape, and the rendered body additionally carries each finding's `**Origin item**` and `**Origin audit sidecar**` lines.

```
## Instructions

**Rendered by:** okstra convergence reverify-prompt

Perform re-verification for <task-key> (round <N>).

Review the following findings discovered by other workers.
For EACH finding, respond with exactly one verdict:

- **AGREE**: The finding is valid based on the evidence presented
- **DISAGREE**: The finding is incorrect or unsupported (explain briefly why)
- **SUPPLEMENT**: The finding is valid AND you have additional supporting evidence or context
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
  from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.

Do NOT re-analyze the original source materials. Judge based on the evidence provided.

## Findings to verify

### F-001: <one-line summary>
**Origin**: <worker role>
**Evidence**: <file paths, line numbers, reasoning from origin worker>

### F-002: <one-line summary>
**Origin**: <worker role>
**Evidence**: <...>

## Response format

For each finding, respond as:

### F-001
**Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
**Explanation** (required for every verdict, AGREE included): <2-3 sentences>

### F-002
**Verdict**: ...
```

### Adversarial Re-verification Prompt

Used instead of the lightweight/full-reanalysis prompt when `config.adversarial == true`. Rendered by `okstra convergence reverify-prompt`; the block below is the reference shape, and the rendered body additionally carries each finding's `**Origin item**` and `**Origin audit sidecar**` lines. The required anchor headers (§"Required reverify-prompt anchor headers") are identical. The `[Required reading]` clause is suppressed; only the cited-evidence paths of the items under attack are injected (see §"Adversarial Verification Mode" → Scoped full-reanalysis).

```
## Instructions

**Rendered by:** okstra convergence reverify-prompt

Perform ADVERSARIAL re-verification for <task-key> (round <N>).

Your job is to BREAK each finding below, not to confirm it. For EACH finding,
open the cited evidence directly and actively search for evidence that the claim
is wrong, overstated, or unproven. Then respond with exactly one verdict:

- **REFUTED**: You broke the claim. State the basis:
  - counter-evidence — you found contradicting evidence (give file:line or log line), OR
  - burden-not-met — you re-inspected the cited evidence and could neither confirm
    nor refute it (the claim has not proven itself).
- **SURVIVES**: You actively tried to refute it and failed — the claim withstood the
  attack. Name the attack you tried and why it failed.
- **SURVIVES-WITH-CAVEAT**: It holds, but a scope limit / extra condition / missing
  precondition exists (state it).
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
  from opening or reproducing the cited evidence. Do not use REFUTED as a substitute.

Every verdict carries an `**Explanation**`, SURVIVES included — it is what you did,
not what the verdict already says. Only `**Basis**` is conditional. A block with a
verdict and no explanation is not collected and the whole response is refused.

The burden of proof is on the claim. If after inspecting the cited evidence you remain
uncertain, your verdict is REFUTED with basis = burden-not-met.

Inspect ONLY the evidence each finding cites and its immediate surroundings. Do NOT
re-read the task brief, instruction-set, or report template.

## Findings to verify

### F-001: <one-line summary>
**Origin**: <worker role>
**Cited evidence**: <file paths, line numbers, log lines from origin worker>

### F-002: <one-line summary>
...

## Response format

### F-001
**Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
**Basis** (only if REFUTED): counter-evidence | burden-not-met
**Explanation** (required for every verdict, SURVIVES included): <2-3 sentences; for
SURVIVES say what you attacked and why the attack failed; for counter-evidence include
the file:line you found>

### F-002
...
```

When persisting votes, map SURVIVES→`agree`, SURVIVES-WITH-CAVEAT→`supplement`, REFUTED→`disagree`, and UNVERIFIABLE→`unverifiable`; copy the stated Basis into `votes.<worker>.disagreeBasis` (null for non-REFUTED verdicts). Every vote requires a non-empty `explanation`.

UNVERIFIABLE is **not** `verification-error`. A verifier that opened the evidence and could not check it participated in the round; a `verification-error` is a verifier that failed to answer. The classifier counts only non-error votes, so folding the two shrinks the participating roster without saying so — a round where one analyser answers UNVERIFIABLE throughout would read as a two-way cross-check while reporting three voters. **Enforced:** `okstra convergence collect-results` applies this mapping (`okstra_ctl.verdict_blocks.ADVERSARIAL_VERDICTS`); do not transcribe votes by hand.

### Full Re-analysis Re-verification Prompt

```
## Instructions

**Rendered by:** okstra convergence reverify-prompt

Perform deep re-verification for <task-key> (round <N>).

Independently verify the following findings by examining the original materials.
Use each finding as a starting point, NOT as a confirmed conclusion.
If capability, credential, network, or service state prevents access to evidence
needed for a finding, you MUST answer UNVERIFIABLE with a non-empty explanation.
You MUST NOT answer DISAGREE merely because access or reproduction failed;
UNVERIFIABLE is persisted as `verification-error` and excluded from consensus.

**Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_VERDICTS` accepts the verdict as `verification-error`, and `classify_collaborative_round` drops those votes before counting consensus.

## Task bundle paths
- Instruction set: <instruction-set path>
- Task brief: <task-brief path>
- Analysis material: <analysis-material path>
- Reference expectations: <reference-expectations path>

## Findings to verify

### F-001: <one-line summary>
**Origin**: <worker role>
**Original evidence**: <file paths, line numbers>

### F-002: <one-line summary>
**Origin**: <worker role>
**Original evidence**: <...>

## Response format

For each finding:

### F-001
**Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
**Your evidence**: <your independent evidence trail with file paths and line numbers>
**Explanation**: <detailed analysis>
```

## Convergence State Artifact

Save it to `runs/<task-type>/state/convergence-<task-type>-<seq>.json`.

```json
{
  "schemaVersion": "1.3",
  "taskKey": "<task-key>",
  "config": {
    "enabled": true,
    "adversarial": false,
    "maxRounds": 2,
    "effectiveMaxRounds": 2,
    "verificationMode": "lightweight"
  },
  "findings": [
    {
      "findingId": "F-001",
      "summary": "<one-line summary>",
      "category": "<bug|risk|missing|observation|...>",
      "ticketIds": ["TICKET-123"],
      "originWorker": "claude-worker",
      "originEvidence": "<evidence text>",
      "classification": "full-consensus",
      "rounds": [
        {
          "round": 1,
          "votes": {
            "codex-worker": { "verdict": "agree", "disagreeBasis": null, "explanation": "<brief>" },
            "antigravity-worker": { "verdict": "supplement", "explanation": "<brief>" }
          }
        }
      ],
      "consensusWorkers": ["claude-worker", "codex-worker", "antigravity-worker"],
      "dissentingWorkers": []
    }
  ],
  "roundHistory": [
    {
      "round": 1,
      "inputQueueSize": 3,
      "resolvedCount": 3,
      "carriedForwardCount": 0,
      "dispatches": [
        { "worker": "codex-worker",  "status": "completed", "durationMs": 184221 },
        { "worker": "antigravity-worker", "status": "completed", "durationMs": 201337 }
      ],
      "skippedWorkers": [
        { "worker": "claude-worker", "reason": "no items to verify" }
      ]
    }
  ],
  "round2SkippedReason": "queue-empty",
  "finalState": "converged",
  "totalRounds": 1,
  "finalClassificationCounts": {
    "fullConsensus": 5,
    "partialConsensus": 1,
    "contested": 0,
    "workerUnique": 1
  }
}
```

> Abbreviated example: `findings[]` shows only `F-001` though `finalClassificationCounts` totals 7 — a real artifact has one `findings[]` entry per finding. This is a clean one-round queue-drained run; a Round 2 run adds a second `roundHistory[]` entry of the same shape.

Schema rules:

- `schemaVersion`: literal string `"1.4"` for all new runs — both adversarial and collaborative. Historical readers accept `"1.0"` / `"1.1"` / `"1.2"` / `"1.3"` unchanged and never rewrite those artifacts during validation. v1.3 added the strict coverage-critic ledger and the rejection of unknown top-level fields; v1.4 adds the `unverified` classification and its `finalClassificationCounts.unverified` key. Work-state remains v1.0.
- `config.adversarial`: boolean. `true` when this run used adversarial verification (default for `requirements-discovery` / `error-analysis` / `implementation-option-selection` / `implementation-planning` / `project-analysis` / `feature-analysis` / `change-impact-analysis`). When `true`, `config.verificationMode` is `"full-reanalysis"` (scoped) and every `disagree` vote carries a non-null `disagreeBasis`.
- `config.effectiveMaxRounds`: the integer the lead actually used after resolving the phase-aware default (`1` for `requirements-discovery`, `2` otherwise). It may be lower than `config.maxRounds` — a phase-aware default that resolves below the manifest ceiling is the normal case — but never higher. **Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_config` raises `ConvergenceContractError` on `effectiveMaxRounds > maxRounds`, so a state carrying that pair cannot be loaded, and `plan_next_round` finalizes with reason `max-rounds-reached` once the executed rounds reach the effective budget rather than dispatching another one. `totalRounds` is therefore bounded by construction, not by a later re-count.
- `findings[].ticketIds`: array of ticket keys from Phase 4 grouping (parsed per the Round 0 step 5 rule). It is empty when the phase does not require ticket tagging; `"unknown"` is not a ticket key and must not be synthesized.
- `findings[].rounds[].votes.<worker>.verdict`: enum, one of `agree | disagree | supplement | verification-error`. Lower-case tokens; map upper-case AGREE/DISAGREE/SUPPLEMENT verdicts emitted by workers to their lower-case form and map the input alias `unverifiable` to persisted `verification-error`. The latter represents either a terminal non-result dispatch or a completed dispatch that could not verify a particular finding (§"Worker failure handling in reverify"). Every vote has a non-empty `explanation`.
- `findings[].rounds[].votes.<worker>.disagreeBasis`: enum `counter-evidence | burden-not-met | null`. Non-null only when `verdict == "disagree"` AND `config.adversarial == true`; `null` (or absent, treated as null) otherwise. See §"Adversarial Verification Mode".
- `findings[].classification`: enum, one of `full-consensus | partial-consensus | worker-unique | contested | unverified`. No other value is permitted. `unverified` exists from final schema v1.4 onward; a historical v1.0-v1.3 artifact read under `reuse-final` uses the four-value vocabulary and MUST NOT be rewritten to add it.
- `roundHistory[].inputQueueSize`: queue size at the start of this round.
- `roundHistory[].resolvedCount`: number of findings that exited the queue this round (sum of full+partial+worker-unique classifications produced this round).
- `roundHistory[].carriedForwardCount`: queue size at the END of this round — the single definition. In-round insertions into the queue are forbidden, so this always equals `inputQueueSize - resolvedCount`. The pseudocode's per-item `carriedForwardCount += 1` accumulator is a counting convenience that lands on the same value; persist the post-round queue length, not the loop accumulator, if the two ever diverge.
- `roundHistory[].dispatches[]`: one entry per worker that was actually dispatched in this round. Each entry is `{worker, status, durationMs}`. `status ∈ {completed, timeout, error, not-run}`. `durationMs` is integer milliseconds and is always present, even for terminal-non-result dispatches (use the elapsed time before the wrapper gave up).
- `roundHistory[].skippedWorkers[]`: per-worker `{worker, reason}` for workers with no items to verify OR with a non-result dispatch.
- `round2SkippedReason`: literal enum `queue-empty | max-rounds-1 | all-reverify-non-result | not-skipped | auto-disabled`. Always present and derived by `finalize`; the engine owns precedence so the lead never writes it manually.
- `finalClassificationCounts`: post-loop counts. Required field with keys `fullConsensus`, `partialConsensus`, `contested`, `workerUnique`.
- `finalState ∈ {converged, max-rounds-reached, aborted-non-result}`. Derived by `finalize` from the validated queue, history, and stop reason; the lead does not assign it.
- `totalRounds`: count of rounds actually executed (not `effectiveMaxRounds`). May be `0` when Round 0 produced no queue items (all findings reached consensus during grouping).

## Coverage critic pass

Runs when `convergence.critic.enabled == true`. Critic is opt-in on `requirements-discovery`, `error-analysis`, `implementation-planning`, and `final-verification` (role `min` 0, `recommended`/`max` 1; the user chooses whether to add the slot and picks the model). A run without a critic slot renders `enabled: false` and skips this pass. For `final-verification` the critic runs in a different mode — see §"Acceptance critic pass (final-verification)". This pass targets **scope in both directions** — findings that are missing (coverage) and work the findings propose that no requirement asked for (over-scope) — distinct from convergence, which targets **agreement quality** among the findings already raised. The pass keeps its `coverage` mode id and `gaps` vocabulary for both halves; the two are told apart by each candidate's `category`, so no schema or reducer distinguishes them. In `implementation-planning` the same critic slot also settles plan-body analyser 1-1 splits as `critic-worker`.

### When

The critic input is the Round 0 consolidated finding list. Reverify rounds only classify findings — they never add or remove them (in-round queue insertions are forbidden, see §"Convergence State Artifact" `carriedForwardCount`) — **Enforced:** `_validate_no_in_round_queue_insertion` in `validators/validate-run.py` fails a finding whose earliest `rounds[].round` is greater than 1 — so the critic dispatch MUST NOT wait for classification to finish:

- **Dispatch**: immediately after Round 0 grouping, CONCURRENTLY with the first reverify round's dispatches. When the verification queue is empty after Round 0 (no reverify round runs), dispatch right after grouping. Concurrent dispatch to the same provider is safe — the critic result path (`<provider>-worker-critic-...`) never collides with a reverify result path.
- **Gap verification + merge**: only after BOTH the finding-convergence loop has exited AND the critic result is collected, and BEFORE the Phase 6 report-writer dispatch. If the loop exited `aborted-non-result`, do NOT dispatch a gap-verification round — record every gap in `unverifiedGaps[]` per §"Gap verification".

### Dispatch (fresh one-shot)
Render the critic-only task instructions with `okstra convergence critic-prompt
--run-manifest <run-manifest>` and write that output to the instructions file
verbatim — the same pattern as `okstra plan-items prompt` at round 1. Then run
`okstra agent-prompt
materialize` with `--audience scope-critic`, `--assignment-ref critic/scope`,
the critic worker ID, and `--dispatch-kind critic`. Verify the returned
`metadataPath` before dispatch and use its `promptPath` without modification.
For `runner=native-session`, use only `hostModelValue`; for
`runner=cli-wrapper`, use `okstra team dispatch` when `terminalBackend` is
`cmux-pane`, otherwise `okstra worker-dispatch`, which consumes
`modelExecutionValue`. Record host-native linkage with
`enforcementMode=host-native-spec-link-gate` and the metadata path. If the
persisted assignment or either model value required by its runner is absent,
record `critic-skipped: model-unresolved`; never resolve a replacement model.
Result path: `runs/<task-type>/worker-results/<provider>-worker-critic-<task-type>-<seq>.md`.

**What the generated critic task-instructions file contains.** A critic dispatch
is not a reverify dispatch: `dispatchKind = "critic"` keeps
`audience = "analysis"`, so `worker_prompt_contract.validate_initial_prompts`
judges it by the full initial-analysis contract. Two of those requirements are
satisfied by the generated body for a Phase 4 worker
(`okstra_ctl.worker_prompt_body`) and by nothing in the materializer's anchor
block for a critic — `critic-prompt` emits both itself: the
`**Prompt Delivery Mode:** eager-include` header, and under `## Inputs` exactly
one `Primary analysis packet` line whose backticked path ends in
`analysis-packet.md`, read from the run manifest's `analysisPacketPath` (the
command fails when that field is missing, rather than emitting a body the
dispatch will reject). The rest of the body is:

- the Round 0 consolidated finding list, from the run's published grouping;
- one line per Phase 4 analyser — worker id, its result path, and the finding
  ids that worker sourced — so "open the named result" points at a real file;
- when the task already has an implementation-planning report on disk (a rerun),
  an **already-covered** index from it: requirement-coverage row ids,
  clarification row ids, and stage titles. Ids and titles only, never body text;
- the two mandates below, including the `duplicateOf` declaration rule.

The lead writes none of it and edits none of it. **Enforced:**
`tests/contract/test_critic_prompt_equality_exemption.py`
`test_generated_critic_seed_satisfies_the_real_dispatch_checks` runs the
generated body through the same two checks the dispatch runs. A hand-edited file
that drops either line fails `okstra team dispatch --dispatch-kind critic`
before any process starts, reported as `<task-type> prompt contract: <worker>:
exactly one Primary analysis packet path is required (found 0)` and `exactly one
non-empty **Prompt Delivery Mode:** header is required`. Re-render and
re-materialize with `--replace-undispatched` (§"Invocation materialization
gate") rather than editing the published prompt.

The `-worker-` token is load-bearing, not decoration: the critic prompt carries the same generated anchor headers as every other worker ([team-contract](./team-contract.md) §"Worker prompts"), and its `**Audit sidecar path:**` comes from passing that result path through `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()`, which inserts `-audit-` after the token and raises without it. A `<provider>-critic-...` name leaves the lead choosing between breaking the contract and hand-inventing the sidecar name. Note that `originWorker` stays `"<provider>-critic"` — that is a worker id in the convergence state, not a filename, and the two do not have to match.

The critic prompt carries the full analysis contract those anchor headers belong to — worker preamble, error-contract path, audit sidecar, packet boundary — but it is **exempt from the initial-analysis equality group**. Initial analysis workers must receive byte-identical normalized bodies (`worker_prompt_contract.validate_analysis_prompt_set`), and a critic body is deliberately unlike them, so `okstra_ctl.worker_prompt_policy.resolve_prompt_plan` resolves `dispatchKind = "critic"` to `equality_group = None` while keeping `audience = "analysis"`. Never reshape a critic prompt to match the initial one to satisfy that check — matching it would delete the pass. **Enforced:** `tests/contract/test_critic_prompt_equality_exemption.py` pins both directions — the critic is exempt, and two mismatched *initial* prompts still fail.

The critic prompt seeds the consolidated findings and asks for two things only — coverage gaps and unrequested work. The second half exists because every other scope check in the lifecycle runs *later*: the plan-body `P-Opt-*` verdict judges options this phase has not authored yet (report-writer writes the plan in Phase 6), and the `implementation` verifier judges a diff. Here the unit is a **finding**, which is the input those stages build on — catching unrequested work while it is still a finding is the cheapest place to catch it at all.

Required reading before proposing a gap or an over-scope candidate:
- the current run's `analysis-packet.md` for requirements and phase scope — for the over-scope half this is the authority you search against, so read it before judging any finding unrequested;
- `convergence-groups-<task-type>-<seq>.json` for the complete Round 0 ledger;
- every initial analysis-worker result named by team-state;
- each matching audit sidecar, to distinguish an uninspected path from a claim that was inspected but summarized during grouping.

Operational guardrails are not task requirements. A gap must trace to a brief requirement, an analysis-packet scope item, a source path the packet authorizes, or an evidence claim in a worker result. Do NOT infer missing verification from a one-line summary; open the named result and audit sidecar first.

The two mandates and the `duplicateOf` rule live in the generator
(`okstra_ctl/convergence_critic_prompt.py` `_MANDATES`), not here. There is no
second copy to keep in step: `critic-prompt` renders that text under `## Mandate`
above the Round 0 list, and the lead pastes the output whole.

**A candidate that overlaps an existing finding is declared, not restated.** The
critic sets `duplicateOf` to that finding's id (`schemas/convergence-critic-results-v1.0.schema.json` `$defs.Candidate`). A restated finding costs a whole gap-verification round to reject; a declared duplicate costs none — it is recorded in the ledger, never dispatched, and counted in `config.critic.gapsDuplicate`. **Enforced:** `okstra convergence apply-critic-gaps` rejects a `duplicateOf` that names no finding the state carries, and rejects a duplicate row carrying votes.

### Gap verification (1 adversarial reverify round)
Each critic gap enters the verification queue as a finding with `originWorker = "<provider>-critic"` and `source = "critic"`, except a gap the critic declared `duplicateOf` — that one is recorded and never dispatched. The lead runs ONE adversarial reverify round (§"Adversarial Verification Mode" classifier) in which **each gap is verified by exactly one Phase 4 analyser**: walk the analyser roster in `criticVerification.analyserRoster` order and assign gap *i* to `roster[i % len(roster)]`, then dispatch each assigned analyser once with its own gaps. Rejecting a gap costs the same as accepting one and this round is off the books (`rounds: []`, no `roundHistory` entry) on the serial path, so a batch of two gaps no longer wakes four analysers. **Enforced:** `okstra_ctl.convergence_engine._critic_gap_coverage_errors` accepts a dispatch set that is either the assignee set or the full roster, and rejects anything else — the full roster stays valid because a run finished before this rule cannot say which shape it used, the same dual acceptance `_validate_round_ledger_counts` gives the two round-counting arithmetics. Choosing a critic provider that is already in the analyser roster costs nothing: the critic is a different role contract, a different duty and a different session, so an analyser is not disqualified by sharing its provider name (ADR-0017 — provider and model are not role identity, and the same model assigned to two roles gets two independent workers). The critic cannot judge its own gaps because it is not an analyser: the voter roster is `workers[]` filtered to `audience == "analysis"`, and a critic is not even representable there (the allowed values are `analysis` / `lead` / `report-writer`). `okstra apply-critic-gaps` refuses a vote from anyone outside that roster (`critic voter must be a non-critic analyser`). Only gaps classified `full-consensus` / `partial-consensus` merge into the final report findings; `contested` / `worker-unique` gaps are treated as hallucinations and dropped (recorded in the convergence state, not promoted).

**Dispatching the gap round.** The gap round is off the round ledger, so `plan-round` writes no plan for it and `reverify-prompt` cannot render it; it has its own generator and dispatch kind. Assemble the coverage batch first — the same `{ schemaVersion, taskKey, mode: "coverage", provider, modelExecutionValue, gaps[] }` document `apply-critic-gaps` will take, with each candidate as a gap (`gapId` = the critic's item id, `summary`, `category`, `ticketIds`, `originEvidence`, `duplicateOf` where declared) and no `votes` or `dispatches` yet. Then, for each assigned analyser, render `okstra convergence critic-verify-prompt --run-manifest <run-manifest> --gaps <coverage-batch.json> --worker <worker-id>` and write its output verbatim as the instruction file: it applies the same round-robin as `apply-critic-gaps` (`okstra_ctl.convergence_engine.critic_gap_assignees`) and carries only that analyser's gaps, each with the critic's result file and `### [<gapId>]` section, the critic audit sidecar the verifier may open, and the adversarial response format the collector parses (gap votes are always read as adversarial). Materialize with the analyser's existing `reverify/<worker-id>` assignment ref, `--dispatch-kind critic-verify`, `--audience reverification-worker`, and on a v2 run the analyser's own `--source-role-execution-ref` exactly as a numbered reverify round; name the prompt `<worker-id>-worker-critic-verify-<task-type>-<seq>.md` and the result `worker-results/<worker-id>-worker-critic-verify-<task-type>-<seq>.md`. Collect each result with `parse_finding_votes` semantics (the `### <gapId>` blocks), write the votes and one `dispatches[]` row per assigned analyser into the batch, and run `apply-critic-gaps`. **Enforced (rendering):** `okstra_ctl.convergence_critic_verify_prompt`. **Enforced (pre-dispatch):** `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` requires the `**Rendered by:** okstra convergence critic-verify-prompt` line for dispatch kind `critic-verify`, so a hand-written gap instruction cannot be materialized; `worker_prompt_policy.is_verification_dispatch_kind` routes the kind through the reverify prompt plan, and `convergence_store.reserve_dynamic_verifier` records the kind on the v2 reservation. Before this path existed (2026-09-09, dev-10642 requirements-discovery 001) every attempt to dispatch the round was refused and all three gaps ended `gapsUnverified`.

**A gap that received no verdict is NOT a rejected gap (BLOCKING).** Dropping applies only to gaps the voters actually judged. A gap can also end the round *unjudged* — the verification dispatch returned a terminal non-result (`timeout`, `error`, no result file), the returned result covered only some of the gaps, or no non-critic analyser was available to vote at all. Nobody inspected those, so classifying them as hallucinations is a fabricated verdict. Each one MUST be recorded as a `## 5. Missing Information and Risks` row (`missingInformation`, `source: "critic-unverified"`) whose `risk` names the gap and the reason verification did not complete, and counted in `config.critic.gapsUnverified`. They are **not** promoted to findings (unverified) and **not** raised as `clarification` items — an unverified gap needs an analyser to verify it on the next run, not a decision from the user. Silently losing them is a contract violation: the batch that times out is exactly the batch of gaps too expensive to check, so the highest-risk items are the ones that vanish.

**`category: "unrequested-scope"` candidates are classified the same way but disposed of differently.** A coverage gap the voters contest is a hallucination — nothing was actually missing, so dropping it costs one wasted verification. An over-scope candidate the voters contest is a *disagreement about whether the work was asked for*, and dropping that silently returns the run to the state this half exists to change. So:

- `full-consensus` / `partial-consensus` → merge as a finding, exactly like a coverage gap. The merged finding names the unrequested work and the requirement search that came up empty; the phase's own deliverable rules decide whether it lands as a dropped item or a clarification row.
- `contested` / `worker-unique` → counted in `config.critic.gapsRejected` (unchanged accounting) but ALSO recorded as a `## 5. Missing Information and Risks` row with `source: "critic-unconfirmed-scope"`, whose `risk` quotes the proposed work and the split verdict. It is **not** promoted to a finding: a contested over-scope claim must not block a plan on one worker's taste. The user reads the row and decides.
- no verdict at all → the `critic-unverified` rule above applies unchanged.

The asymmetry is deliberate and runs the opposite way from the coverage half: a false "you missed something" costs a verification, while a false "you built too much" costs a real requirement — so the first may be dropped outright and the second is recorded either way. This is the same reasoning that makes the `final-verification` acceptance critic never drop a candidate, applied to the one direction where dropping is otherwise the default.

### State
- `convergence.critic` manifest block: `{ enabled, provider, modelExecutionValue }`.
- Each candidate's `category` tells the two halves apart: literal `"unrequested-scope"` for the over-scope half, any other value for a coverage gap. `schemas/convergence-critic-results-v1.0.schema.json` leaves `category` a free string, so this needs no schema or reducer change — but it also means nothing machine-checks the spelling. A misspelled category is read as a coverage gap and silently takes the drop-on-contested path.
- The lead passes one canonical coverage batch with `{ schemaVersion, taskKey, mode, provider, modelExecutionValue, dispatches, gaps }`; each gap carries its candidate fields plus `gapId` and `votes`, or `duplicateOf` and no votes. `dispatches[]` contains exactly one row per **assigned** analyser (§"Gap verification"), even when execution did not produce a result: persist `status: timeout | error | not-run` and the elapsed `durationMs` instead of omitting that analyser. A batch that dispatched every Phase 4 analyser instead is still accepted. `apply-critic-gaps` rejects a non-terminal main queue, a dispatch set matching neither shape, duplicate analysers, unknown workers, critic dispatches/votes, votes without a completed dispatch, a `duplicateOf` naming no existing finding, and a second batch.
- Convergence state artifact: merged gaps appear in `findings[]` with `source: "critic"` and `rounds: []`. The separate `criticVerification.gaps[]` ledger retains each gap's `summary`, `category`, `ticketIds`, `originEvidence`, optional `evidenceArtifacts`, classification, merge link, votes, and `duplicateOf` when the critic declared one. Strict validation deterministically replays each complete critic-origin finding from that ledger; a critic batch never increments `roundHistory` or `totalRounds` and never creates a fake main round.
- `config.critic` is `{ provider, modelExecutionValue, gapsProposed, gapsMerged, gapsRejected, gapsUnverified, gapsDuplicate }`, with `gapsProposed = gapsMerged + gapsRejected + gapsUnverified + gapsDuplicate`. `full-consensus` / `partial-consensus` gaps merge, `contested` / `worker-unique` gaps count as rejected, gaps with no usable analyser vote appear in both the ledger and final `unverifiedGaps[]`, and a declared duplicate takes classification `duplicate` — it makes no finding and joins no other counter. A state written before `gapsDuplicate` existed omits the key and stays valid; the validator reads its absence as 0.

## Acceptance critic pass (final-verification)

The `final-verification` phase uses the same fresh one-shot `redispatch_worker` pattern and the same dispatch timing as §"Coverage critic pass" §"When" (provider + `config.critic.modelExecutionValue` from the `convergence.critic` block; critic is opt-in — the pass is skipped when the run resolved no critic; same model-unresolved skip rule) — the delivered work the critic inspects is likewise fixed before the reverify round starts. Only the prompt, the verification semantics, and the output sink differ — final-verification's findings are defects/blockers, so the critic acts as an **acceptance devil's advocate** (find reasons NOT to accept), and its candidate blockers are NEVER dropped (that would suppress real defects).

Before that call, write the acceptance-only task instructions and run `okstra
agent-prompt materialize` with `--audience acceptance-critic`,
`--assignment-ref critic/acceptance`, the critic worker ID, and
`--dispatch-kind critic`. **The final-verification 96-nonblank-line prompt-body
cap applies to this dispatch too** — `resolve_prompt_plan` exempts a critic from
the analysis equality group, not from the cap. The anchors and the duty contract
account for roughly 51 of those lines, so the instructions you write have a
budget of about 45 nonblank lines; the §Prompt text below is 20 and fits. Adding
your own restatement of the run's findings on top of it is what overruns the cap
(measured 117 on 2026-09-10, dev-10642-15 final-verification 001). Verify the returned `metadataPath`, dispatch only the
returned `promptPath`, and select `hostModelValue` for a native host or
`modelExecutionValue` through `okstra worker-dispatch`. Native dispatch linkage
uses `enforcementMode=host-native-spec-link-gate`; it does not claim prompt
delivery was observed.

### Prompt

```
Challenge acceptance for <task-key>. The delivered work is about
to be judged for acceptance. Your ONLY job is to find reasons it should NOT be
accepted — surface candidate acceptance BLOCKERS the verifiers may have missed:
- requirements / acceptance points with no covering evidence,
- DB / IO / SQL changes lacking real-execution evidence,
- regressions or broken error paths,
- scope / contract violations.
For each, emit a candidate blocker with a one-line statement, evidence (file:line /
log / test output), and a severity (critical / major). A finding that would not stop the
release is not a blocker — say so, and it is recorded as a conditional-acceptance
condition instead. Do NOT restate an existing Acceptance Blocker. If you find none, say
so explicitly.
```

### Verification — confirm-or-downgrade (BLOCKING)

Each candidate blocker is verified by the Phase 4 analysers — all of them, on the same roster rule as §"critic gaps" above; sharing the critic's provider name does not disqualify an analyser. Do NOT use the adversarial finding classifier's "uncertain → reject" rule here.
- Do NOT run `apply-critic-gaps` for this mode. That reducer implements coverage merge/drop semantics and rejects `acceptance-devils-advocate` input.
- **Confirmed** (an analyser reproduces it or cites supporting evidence) → promote to a `## 5.8 Acceptance Blockers` row (keep severity + recommended follow-up phase).
- **Not confirmed** (cannot reproduce, or evidence is weak) → **downgrade to a Residual Risk row — never drop it.** Record the escalation trigger so the user can re-judge a high-severity-but-unconfirmed candidate.

**Enforced:** `validators/validate-run.py` `_validate_final_verification_consistency` fails a report whose `acceptanceBlockers` and `finalVerdict` disagree, so a promoted blocker cannot sit beside an `accepted` verdict and an `accepted` report cannot carry one.

### Verdict impact

Promoted blockers enter `## 5.8 Acceptance Blockers`; since `accepted` requires zero blockers, the verdict moves to `conditional-accept` / `blocked` automatically. The existing verdict↔blocker consistency validator (`validators/validate-run.py` `_validate_final_verification_consistency`) enforces this unchanged — no new enum or validator.

### State

Critic output lives in the run's `worker-results/` directory (`runs/final-verification/worker-results/` for whole-task verification, `runs/final-verification/stage-<N>/worker-results/` for single-stage), filename `<provider>-worker-critic-final-verification-<seq>.md` (same `-worker-` token rule as §"Coverage critic pass" — the audit sidecar is derived from it). The convergence state `config.critic` summary records `mode: "acceptance-devils-advocate"`, `candidatesProposed`, `confirmedBlockers`, `downgradedToResidual`; v1.4 enforces `candidatesProposed = confirmedBlockers + downgradedToResidual`, so no candidate can be silently dropped.

Write that summary with `okstra convergence apply-acceptance-critic --work-state <path> --results <batch>`, once the confirm-or-downgrade verdicts are in. The batch is `{schemaVersion, taskKey, mode: "acceptance-devils-advocate", provider, modelExecutionValue, candidates[]}`, one `{candidateId, verdict}` per candidate with `verdict` either `confirmed` or `downgraded` — print a valid one with `okstra convergence example --kind acceptance-batch`. **Do not pre-count the three totals**: the command derives them from the batch, so a hand-count that disagrees with the candidate list cannot pass the v1.4 equation by restating it. An empty `candidates[]` is a recorded pass with zero candidates, which is a different fact from a run whose critic never ran. **Enforced:** `convergence_engine.apply_acceptance_critic_results` derives the counts and re-validates the working state; `_validate_acceptance_critic_summary` rejects a summary carrying coverage-mode fields.

## Output

Information to be passed to Phase 6 after completing this contract:

- Newly finalized convergence output is schema v1.4. Under `reuse-final`, a valid historical final schema v1.0, v1.1, v1.2, or v1.3 remains consumable by the report-writer without rewrite. Either validated terminal artifact contains the classification of every finding; the report-writer consumes it and does not vote
- Round history and votes per worker for each finding
- Path to the convergence state artifact
- Convergence summary (count per category)
- Whether early convergence occurred and the total number of rounds

## Convergence Disabled

If `convergence.enabled: false`, this contract is skipped. Phase 6 operates using the existing consensus/divergence method.

## Plan-body verification mode (implementation-planning only)

Moved to its own contract: [plan-body-verification](./plan-body-verification.md). It fires only for `task-type = implementation-planning`, as a Phase 6 sub-step after the report-writer draft — read that file at that sub-step, NOT during the Phase 5.5 finding convergence this contract governs. The finding queue (`F-*`, this contract) and the plan-item queue (`P-*`, that contract) are disjoint — see its "MUTUAL EXCLUSION" section.
