#!/usr/bin/env python3

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import os
import posixpath
import re
import shlex
import sys
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

# Make the okstra packages importable however this validator is reached:
# ``scripts/`` next to the repo checkout, ``python/`` next to the installed
# copy under ~/.okstra/lib. The manifests advertise the bare script path, so
# it must self-bootstrap rather than rely on an inherited PYTHONPATH.
_VALIDATORS_DIR = Path(__file__).resolve().parent
if str(_VALIDATORS_DIR) not in sys.path:
    sys.path.insert(0, str(_VALIDATORS_DIR))
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
    if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
        sys.path.insert(0, str(_ssot_dir))

try:
    from okstra_ctl.final_report_schema import (
        SchemaError,
        load_schema_for_data,
        validate as schema_validate,
    )
except ImportError:  # pragma: no cover — runtime guarantees this import
    SchemaError = None  # type: ignore[assignment]
    load_schema_for_data = None  # type: ignore[assignment]
    schema_validate = None  # type: ignore[assignment]

from okstra_project import project_json_path  # noqa: E402
from okstra_project.dirs import tasks_root as _okstra_tasks_root  # noqa: E402
from okstra_project.resolver import resolve_architecture  # noqa: E402

from okstra_ctl.conformance import (  # noqa: E402
    detect_surfaces,
    evaluate_conformance,
    manifest_required_surfaces,
    missing_declared_scripts,
    normalize_conformance_script as _normalize_conformance_script,
    parse_conformance_tests as _parse_conformance_tests,
    qa_result_from_dict,
    validate_conformance_manifest,
)
from okstra_ctl.dispatch_state import (  # noqa: E402
    DispatchError,
    v2_worker_state_key,
)
from okstra_ctl.paths import RunRef  # noqa: E402
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION  # noqa: E402
from okstra_ctl.release_gate import (  # noqa: E402
    RELEASE_HANDOFF_TARGETS,
    blocking_condition_ids,
    release_handoff_allowed,
)
from okstra_ctl.domain.host import HostNotRegistered  # noqa: E402
from okstra_ctl.registry.host_registry import default_host_registry  # noqa: E402
from okstra_ctl.build_tools import (  # noqa: E402
    command_invokes_build_tool,
    resolve_build_tool_tokens,
)
from okstra_ctl.mutation_probe import (  # noqa: E402
    INTEGRITY_INSPECTION,
    classify_reason,
)
from okstra_ctl.self_mock_signals import selfmock_path_key  # noqa: E402
from okstra_ctl.report_translation import (  # noqa: E402
    HANGUL_PROSE_LIMIT,
    hangul_share,
)
from okstra_ctl.stage_citations import enumerated_stage_numbers  # noqa: E402
from okstra_ctl.plan_items import (  # noqa: E402
    CRITIC_WORKER_ID,
    advisory_plan_body_gating,
    is_critic_worker,
    stage_scope_bucket as _item_stage_scope_bucket,
)
from okstra_ctl.incremental_scope import (  # noqa: E402
    coverage_row_blocked_on,
    stages_for_clarification,
)
from okstra_ctl import next_phase  # noqa: E402
from okstra_ctl.clarification_items import (  # noqa: E402
    APPROVAL_BLOCKS,
    PROCEEDING_DISPOSITIONS,
    clarification_disposition,
    incorporated_clarification_ids,
    progress_blocking_ids,
    row_blocks_progress,
)
from okstra_ctl.workflow import (  # noqa: E402
    ERROR_ANALYSIS_ROUTING_DIRECTIONS,
    PHASE_SEQUENCE,
    REQUIREMENTS_DISCOVERY_ROUTING_TARGETS,
)
from okstra_ctl.md_table import (  # noqa: E402
    is_separator_row as _is_markdown_separator,
    split_pipe_row as _split_pipe_row,
)
from okstra_ctl.final_report_paths import final_report_data_path as _data_path_for  # noqa: E402
from okstra_token_usage.report import _match_worker_index  # noqa: E402
from okstra_ctl.improvement_assignment import (  # noqa: E402
    validate_primary_lens_assignments,
)
from okstra_ctl.implementation_options import (  # noqa: E402
    validate_implementation_option_selection,
)
from okstra_ctl.implementation_direction import (  # noqa: E402
    validate_selected_direction_plan,
)
from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER  # noqa: E402
from okstra_ctl.scope_provenance import (  # noqa: E402
    brief_citation_problem,
    brief_end_state_id_sequence,
    brief_end_state_ids,
    brief_headings,
    parse_source,
    resolve_chain,
)
from okstra_ctl.design_prep import (  # noqa: E402
    DesignPrepError,
    _planning_seq as _design_prep_planning_seq,
    _render_request as _render_design_prep_request,
    _report_language as _design_prep_report_language,
)
from okstra_ctl.design_surfaces import (  # noqa: E402
    RULES as DESIGN_SURFACE_RULES,
    DesignSurfaceError,
    detect_design_surfaces,
    expected_prep_plan_item_id,
)
from okstra_ctl.plan_items import (  # noqa: E402
    expected_plan_item_ids,
    extract_plan_items,
)
from okstra_ctl.worker_prompt_contract import (  # noqa: E402
    PromptRecord,
    validate_initial_prompt_records,
)
from okstra_ctl.domain.role import RoleCatalogError, role_for_duty  # noqa: E402
from okstra_ctl.agent_invocation import (  # noqa: E402
    AgentInvocationError,
    agent_model_assignment_from_payload,
    invocation_execution_identity_from_manifest,
    verify_agent_invocation,
)
from okstra_ctl.execution_identity import ExecutionManifestError  # noqa: E402
from okstra_ctl.execution_manifest import (  # noqa: E402
    validate_execution_manifest_payload,
)
from okstra_ctl.lead_events import LeadEventParseError, read_lead_events  # noqa: E402
from okstra_ctl.worker_audit_ledger import (  # noqa: E402
    READING_CONFIRMATION_HEADING_RE,
    worker_results_audit_findings,
)
from validate_analysis_report import validate_analysis_report  # noqa: E402
from okstra_ctl.convergence_engine import (  # noqa: E402
    grouped_input_digest,
    validate_final_state,
)
from okstra_ctl.convergence_provenance import (  # noqa: E402
    run_dir_provenance_errors,
)

TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed"}
_AGENT_DISPATCH_DIGEST_KEYS = (
    "catalogDigest",
    "assignmentDigest",
    "dutyDigest",
    "instructionDigest",
    "promptDigest",
)
_V2_AGENT_DISPATCH_IDENTITY_KEYS = (
    "participantRef",
    "roleExecutionRef",
    "executionLabel",
    "dutyId",
    "invocationRef",
)


def _validate_execution_identity_v2(
    run_manifest: Mapping[str, Any],
    failures: list[str],
) -> None:
    version = run_manifest.get("schemaVersion")
    identity_version = run_manifest.get("executionIdentityVersion")
    if version in {1, "1", "1.0"} and identity_version is None:
        return
    if version != "2.0" or identity_version != 2:
        failures.append("execution identity v2: mixed execution identity version")
        return
    try:
        validate_execution_manifest_payload(run_manifest)
    except ExecutionManifestError as exc:
        failures.append(f"execution identity v2: {exc}")


def _validate_agent_dispatch_contract(
    *,
    project_root: Path,
    run_manifest_path: Path,
    run_manifest: Mapping[str, Any],
    team_state: Mapping[str, Any],
    failures: list[str],
) -> None:
    """Validate invocation-to-dispatch and result associations for new runs."""
    contract = run_manifest.get("agentContract")
    if not isinstance(contract, Mapping) or contract.get("schemaVersion") != 1:
        return
    assignments = run_manifest.get("invocationAssignments")
    if not isinstance(assignments, Mapping):
        failures.append("agent invocation metadata is missing: invocationAssignments")
        return
    manifest_version = run_manifest.get("schemaVersion")
    execution_identity_version = run_manifest.get("executionIdentityVersion")
    if manifest_version == "2.0" and execution_identity_version == 2:
        uses_v2_identity = True
    elif (
        manifest_version in {None, 1, "1", "1.0"}
        and execution_identity_version is None
    ):
        uses_v2_identity = False
    else:
        failures.append("agent dispatch contract mixes v1 and v2 execution identity")
        return
    canonical_invocations = {
        row.get("invocationRef"): row
        for row in run_manifest.get("invocations") or []
        if isinstance(row, Mapping)
    } if uses_v2_identity else {}
    canonical_attempts = {
        (row.get("invocationRef"), row.get("attempt"))
        for row in run_manifest.get("attempts") or []
        if isinstance(row, Mapping)
    } if uses_v2_identity else set()
    worker_dispatches = [
        row for row in (team_state.get("workerDispatches") or [])
        if isinstance(row, Mapping) and row.get("invocationId")
    ]
    agent_dispatches = [
        row for row in (team_state.get("agentDispatches") or [])
        if isinstance(row, Mapping)
    ]
    dispatches = [*worker_dispatches, *agent_dispatches]
    ids: dict[str, Mapping[str, Any]] = {}
    seen_dispatch_ids: set[str] = set()
    for row in dispatches:
        dispatch_id = str(row.get("dispatchId") or "").strip()
        missing = [
            key for key in (
                "dispatchId", "audience", "invocationId",
                "assignmentRef", "promptMetadataPath", "modelExecutionValue",
                "enforcementMode", *_AGENT_DISPATCH_DIGEST_KEYS,
                *(_V2_AGENT_DISPATCH_IDENTITY_KEYS if uses_v2_identity else ("workerId",)),
            )
            if not str(row.get(key) or "").strip()
        ]
        if missing:
            failures.append(
                "agent invocation metadata is missing from dispatch record: "
                + ", ".join(missing)
            )
            continue
        if uses_v2_identity:
            if "workerId" in row or row.get("executionIdentityVersion") != 2:
                failures.append(
                    f"agent dispatch {dispatch_id} mixes v1 and v2 execution identity"
                )
                continue
            attempt = row.get("attempt")
            if (
                not isinstance(attempt, int)
                or isinstance(attempt, bool)
                or attempt < 1
            ):
                failures.append(
                    f"agent dispatch {dispatch_id}: v2 attempt must be positive"
                )
                continue
            if dispatch_id != f"{row['invocationId']}:attempt-{attempt}":
                failures.append(
                    f"agent dispatch {dispatch_id}: dispatchId does not match v2 attempt"
                )
                continue
        elif any(
            key in row
            for key in (
                "executionIdentityVersion",
                "attempt",
                *_V2_AGENT_DISPATCH_IDENTITY_KEYS,
            )
        ):
            failures.append(
                f"agent dispatch {dispatch_id} mixes v1 and v2 execution identity"
            )
            continue
        if dispatch_id in seen_dispatch_ids:
            failures.append(f"agent dispatch ID is duplicated: {dispatch_id}")
            continue
        seen_dispatch_ids.add(dispatch_id)
        assignment_ref = str(row["assignmentRef"])
        try:
            assignment = agent_model_assignment_from_payload(
                assignments.get(assignment_ref)
            )
        except AgentInvocationError as exc:
            failures.append(f"agent dispatch {dispatch_id}: {exc}")
            continue
        execution_identity = None
        if uses_v2_identity:
            try:
                execution_identity = invocation_execution_identity_from_manifest(
                    run_manifest,
                    assignment=assignment,
                    assignment_ref=assignment_ref,
                    duty_id=str(row["dutyId"]),
                )
            except AgentInvocationError as exc:
                failures.append(f"agent dispatch {dispatch_id}: {exc}")
                continue
            if (
                execution_identity is None
                or row.get("participantRef") != execution_identity.participant_ref
                or row.get("roleExecutionRef") != execution_identity.role_execution_ref
            ):
                failures.append(
                    f"agent dispatch {dispatch_id}: v2 identity does not match "
                    "a canonical role execution"
                )
                continue
            if row.get("executionLabel") != execution_identity.execution_label:
                failures.append(
                    f"agent dispatch {dispatch_id}: executionLabel does not match "
                    "canonical role execution"
                )
                continue
            dispatch_kind = row.get("dispatchKind") or row.get("kind")
            if not isinstance(dispatch_kind, str) or not dispatch_kind:
                failures.append(
                    f"agent dispatch {dispatch_id}: dispatchKind is missing"
                )
                continue
            invocation = canonical_invocations.get(row.get("invocationRef"))
            if not isinstance(invocation, Mapping) or any((
                invocation.get("participantRef") != row.get("participantRef"),
                invocation.get("roleExecutionRef") != row.get("roleExecutionRef"),
                invocation.get("dutyId") != row.get("dutyId"),
                invocation.get("dispatchKind") != dispatch_kind,
                invocation.get("inputDigest") != row.get("promptDigest"),
                )):
                failures.append(
                    f"agent dispatch {dispatch_id}: does not match canonical invocation"
                )
                continue
            if row.get("writePolicyDigest") != invocation.get("writePolicyDigest"):
                failures.append(
                    f"agent dispatch {dispatch_id}: writePolicyDigest does not "
                    "match canonical invocation"
                )
                continue
            if row.get("writeEnforcement") != invocation.get("writeEnforcement"):
                failures.append(
                    f"agent dispatch {dispatch_id}: writeEnforcement does not "
                    "match canonical invocation"
                )
                continue
            mutation_mode = invocation.get("writeEnforcement", {}).get(
                "mutationAudit"
            ) if isinstance(invocation.get("writeEnforcement"), Mapping) else None
            snapshot_value = row.get("mutationAuditSnapshotPath")
            if mutation_mode == "batch" and not (
                isinstance(snapshot_value, str) and snapshot_value.strip()
            ):
                failures.append(
                    f"agent dispatch {dispatch_id}: mutation audit snapshot is missing"
                )
                continue
            if mutation_mode == "none" and snapshot_value not in (None, ""):
                failures.append(
                    f"agent dispatch {dispatch_id}: exact-path dispatch has a "
                    "mutation audit snapshot"
                )
                continue
            if (row.get("invocationRef"), attempt) not in canonical_attempts:
                failures.append(
                    f"agent dispatch {dispatch_id}: has no canonical invocation attempt"
                )
                continue
            status_path_raw = row.get("statusSidecarPath")
            if isinstance(status_path_raw, str) and status_path_raw.strip():
                status_path = _resolve_prompt_record_path(
                    project_root, status_path_raw
                )
                try:
                    status = json.loads(status_path.read_text(encoding="utf-8"))
                except (OSError, json.JSONDecodeError):
                    failures.append(
                        f"agent dispatch {dispatch_id}: wrapper status is missing or invalid"
                    )
                    continue
                status_identity = {
                    key: status.get(key)
                    for key in (*_V2_AGENT_DISPATCH_IDENTITY_KEYS, "attempt")
                } if isinstance(status, Mapping) else {}
                dispatch_identity = {
                    key: row.get(key)
                    for key in (*_V2_AGENT_DISPATCH_IDENTITY_KEYS, "attempt")
                }
                if (
                    not isinstance(status, Mapping)
                    or status.get("schemaVersion") != "2.0"
                    or status.get("executionIdentityVersion") != 2
                    or status_identity != dispatch_identity
                ):
                    failures.append(
                        f"agent dispatch {dispatch_id}: wrapper status identity "
                        "does not match dispatch"
                    )
                    continue
        ids[dispatch_id] = row
        metadata_path = _resolve_prompt_record_path(
            project_root, str(row["promptMetadataPath"])
        )
        errors = verify_agent_invocation(
            metadata_path,
            project_root=project_root,
            expected_run_manifest_path=run_manifest_path,
            expected_assignment=assignment,
            expected_invocation_id=str(row["invocationId"]),
            expected_worker_id=(
                None if uses_v2_identity else str(row["workerId"])
            ),
            expected_assignment_ref=assignment_ref,
            expected_audience=str(row["audience"]),
            expected_participant_ref=(
                str(row["participantRef"]) if uses_v2_identity else None
            ),
            expected_role_execution_ref=(
                str(row["roleExecutionRef"]) if uses_v2_identity else None
            ),
            expected_invocation_ref=(
                str(row["invocationRef"]) if uses_v2_identity else None
            ),
        )
        failures.extend(
            f"agent dispatch {dispatch_id}: {error}" for error in errors
        )
        try:
            metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
            digests = metadata["digests"]
        except (OSError, json.JSONDecodeError, KeyError, TypeError):
            digests = {}
        for key in _AGENT_DISPATCH_DIGEST_KEYS:
            if row.get(key) != digests.get(key):
                failures.append(
                    f"agent dispatch {dispatch_id}: {key} does not match metadata"
                )
        if row.get("modelExecutionValue") != assignment.model_execution_value:
            failures.append(
                f"agent dispatch {dispatch_id}: modelExecutionValue does not match assignment"
            )
        if row.get("hostModelValue") != assignment.host_model_value:
            failures.append(
                f"agent dispatch {dispatch_id}: hostModelValue does not match assignment"
            )
        enforcement = row.get("enforcementMode")
        if enforcement not in {
            "core-pre-dispatch", "host-native-spec-link-gate",
        }:
            failures.append(
                f"agent dispatch {dispatch_id}: enforcementMode is invalid"
            )
        if (
            enforcement == "host-native-spec-link-gate"
            and row.get("promptDeliveryVerified") is not False
        ):
            failures.append(
                "host-native lead delivery cannot be marked verified"
                if row.get("audience") == "lead"
                else "host-native prompt delivery cannot be marked verified"
            )

    if uses_v2_identity:
        role_executions = {
            row.get("roleExecutionRef"): row
            for row in run_manifest.get("roleExecutions") or []
            if isinstance(row, Mapping)
        }
        lead_ids = {
            dispatch_id for dispatch_id, row in ids.items()
            if row.get("audience") == "lead"
            and isinstance(role_executions.get(row.get("roleExecutionRef")), Mapping)
            and role_executions[row["roleExecutionRef"]].get("role") == "leader"
        }
    else:
        lead_ids = {
            dispatch_id for dispatch_id, row in ids.items()
            if row.get("audience") == "lead" and row.get("workerId") == "lead"
        }
    if not lead_ids:
        failures.append("accepted lead result has no lead dispatch record")

    links = [
        row for row in (team_state.get("agentResultLinks") or [])
        if isinstance(row, Mapping) and not row.get("supersededBy")
    ]
    paths: dict[str, str] = {}
    dispatch_link_counts: dict[str, int] = {}
    for link in links:
        dispatch_id = str(link.get("dispatchId") or "").strip()
        result_path = str(link.get("resultPath") or "").strip()
        if dispatch_id not in ids or not result_path:
            failures.append("agent result link has no matching dispatch record")
            continue
        if uses_v2_identity:
            dispatch = ids[dispatch_id]
            identity_keys = (*_V2_AGENT_DISPATCH_IDENTITY_KEYS, "attempt")
            link_identity = {key: link.get(key) for key in identity_keys}
            dispatch_identity = {key: dispatch.get(key) for key in identity_keys}
            if "workerId" in link or link_identity != dispatch_identity:
                failures.append(
                    f"agent result link identity does not match dispatch: {dispatch_id}"
                )
                continue
        elif any(
            key in link
            for key in (*_V2_AGENT_DISPATCH_IDENTITY_KEYS, "attempt")
        ):
            failures.append("agent result link mixes v1 and v2 execution identity")
            continue
        dispatch_link_counts[dispatch_id] = dispatch_link_counts.get(dispatch_id, 0) + 1
        previous = paths.setdefault(result_path, dispatch_id)
        if previous != dispatch_id:
            failures.append(
                f"agent result is linked to multiple dispatches: {result_path}"
            )
    for dispatch_id, count in dispatch_link_counts.items():
        if count > 1:
            failures.append(
                f"agent dispatch is linked to multiple accepted results: {dispatch_id}"
            )

    for worker in team_state.get("workers") or []:
        if not isinstance(worker, Mapping) or worker.get("status") != "completed":
            continue
        worker_id = str(worker.get("workerId") or "").strip()
        worker_result = str(worker.get("resultPath") or "").strip()
        worker_dispatch_ids = {
            dispatch_id for dispatch_id, row in ids.items()
            if (
                row.get("assignmentRef") == f"initial/{worker_id}"
                if uses_v2_identity
                else row.get("workerId") == worker_id
            )
        }
        if worker_id and not worker_dispatch_ids:
            failures.append(
                f"accepted LLM result has no agent invocation record: {worker_id}"
            )
            continue
        matching = [
            link for link in links
            if link.get("dispatchId") in worker_dispatch_ids
            and link.get("resultPath") == worker_result
        ]
        if worker_id and len(matching) != 1:
            failures.append(
                f"accepted LLM result is not linked to its own dispatch: {worker_id}"
            )
    if lead_ids and not any(link.get("dispatchId") in lead_ids for link in links):
        failures.append("accepted lead result has no dispatch result link")


def utc_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# Providers dispatched through a CLI wrapper. Their token accounting lives in
# that CLI, never in the lead's session JSONL, so `usage.source: unavailable`
# is structural rather than a collection failure.
_CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})


def _unavailable_usage_worker_labels(team_state: dict) -> frozenset[str]:
    """Token-table row labels whose `--` cells are honest.

    Two ways a row legitimately renders `--`: a CLI-wrapper provider (tokens
    live inside its own CLI) and any worker whose collected
    `usage.source` is `unavailable`. The latter is not a value a worker typed —
    the renderer (`okstra_token_usage/report.py` `_worker_detail_row`) emits
    null cells for it, and the collector records *why* in the same block. Gating
    it made an in-process worker the collector could not attribute fail the whole
    run (observed dev-10172: no subagent jsonl carried the report-writer's
    agentName, so a completed run was reported as contract-violated).

    Labels mirror `_worker_detail_label` (`"{role} ({agent}, {status})"`) so the
    match is on the rendered label the scanner actually sees.
    """
    labels = set()
    for worker in team_state.get("workers") or []:
        if not isinstance(worker, dict):
            continue
        agent = str(worker.get("agent") or "").strip()
        usage = worker.get("usage") if isinstance(worker.get("usage"), dict) else {}
        if agent not in _CLI_WRAPPER_AGENTS and usage.get("source") != "unavailable":
            continue
        role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
        status = str(worker.get("status") or "").strip()
        suffix = ", ".join(v for v in (agent, status) if v)
        labels.add(f"{role} ({suffix})" if suffix else role)
    return frozenset(labels)


def _session_accounting(team_state: dict) -> str:
    adapter = (
        team_state.get("leadAdapter") if isinstance(team_state.get("leadAdapter"), dict) else {}
    )
    value = str(adapter.get("sessionAccounting", "")).strip()
    if value:
        return value
    runtime = str(team_state.get("leadRuntime", "")).strip()
    try:
        return default_host_registry().resolve(runtime).descriptor.session_accounting
    except HostNotRegistered:
        return "claude-jsonl"




def load_json(path: Path) -> dict:
    try:
        return json.loads(path.read_text())
    except FileNotFoundError:
        raise
    except Exception as exc:
        raise ValueError(f"failed to parse JSON: {path}") from exc


def write_json(path: Path, payload: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")


def _report_already_approved(report_data: Mapping[str, Any] | None) -> bool:
    if not isinstance(report_data, Mapping):
        return False
    frontmatter = report_data.get("frontmatter")
    return isinstance(frontmatter, Mapping) and frontmatter.get("approved") is True


def _derive_awaiting_approval(
    *,
    existing: bool,
    validation_status: str,
    current_phase: str,
    pointer: Mapping[str, str],
    report_data: Mapping[str, Any] | None,
) -> bool:
    """planning 이 승인 가능한 plan-ready 를 남기면 올리고, implementation
    이 그 승인을 소비하면 내린다. 차단 게이트나 열린 Blocks=approval 은
    포인터가 blocked 라 올리지 않는다."""
    if validation_status == "passed" and current_phase == "implementation":
        return False
    if validation_status == "passed" and current_phase == "implementation-planning":
        return (
            pointer.get("phase") == "implementation"
            and pointer.get("status") == next_phase.STATUS_READY
            and not _report_already_approved(report_data)
        )
    return existing


def update_workflow_metadata(
    run_manifest: dict,
    task_manifest: dict,
    validation_status: str,
    report_data: Mapping[str, Any] | None = None,
) -> None:
    workflow = task_manifest.get("workflow", {})
    if not isinstance(workflow, dict):
        workflow = {}

    current_phase = (
        workflow.get("currentPhase")
        or task_manifest.get("taskType")
        or run_manifest.get("taskType")
        or ""
    )
    phase_sequence = workflow.get("phaseSequence", [])
    if not isinstance(phase_sequence, list) or not phase_sequence:
        phase_sequence = list(PHASE_SEQUENCE)

    phase_states = workflow.get("phaseStates", {})
    if not isinstance(phase_states, dict):
        phase_states = {}
    for phase in phase_sequence:
        phase_states.setdefault(phase, "not-started")

    # 포인터는 리드가 리포트에 저작하고, 이 검증기는 그것을 계산하지 않는다.
    # 여기서 하는 일은 리포트의 Phase 라우팅 투영과 대조하는 것뿐이다.
    authored = next_phase.promote(workflow.get("nextRecommendedPhase"))
    workflow.pop("nextRecommendedPhaseCorrection", None)

    if validation_status == "passed":
        current_phase_state = "completed"
        if current_phase:
            phase_states[current_phase] = current_phase_state
        last_completed_phase = current_phase or workflow.get("lastCompletedPhase", "")
        # 어긋나면 투영값으로 정정하되 실행은 죽이지 않는다 — 분석이 통과한 run 을
        # 장부 필드 하나 때문에 버리지 않는다 (ADR-0004).
        projected = next_phase.project(report_data or {})
        if (
            authored["phase"] == projected["phase"]
            and authored["status"] == projected["status"]
        ):
            next_recommended_phase = authored
        else:
            # 리드의 근거는 리드가 고른 phase 를 설명하는 문장이다. phase 를 정정한
            # 뒤에도 들고 오면 포인터가 자기 목적지를 설명하지 않는 문장을 달게 된다.
            # 원문은 아래 correction["authored"] 에 그대로 보존된다.
            next_recommended_phase = next_phase.make(
                phase=projected["phase"],
                status=projected["status"],
                rationale=projected["rationale"]
                or (
                    "리포트 라우팅에서 투영됨. 리드가 쓴 값과 근거는 "
                    "nextRecommendedPhaseCorrection.authored 에 있다."
                ),
            )
            # applied 는 사본이다. 살아 있는 포인터와 같은 dict 를 가리키면
            # 나중에 포인터를 제자리 변형하는 호출부가 감사 기록까지 바꾼다.
            workflow["nextRecommendedPhaseCorrection"] = {
                "authored": authored,
                "applied": dict(next_recommended_phase),
            }
    else:
        current_phase_state = "blocked"
        if current_phase:
            phase_states[current_phase] = current_phase_state
        last_completed_phase = workflow.get("lastCompletedPhase", "")
        next_recommended_phase = next_phase.make(
            status=next_phase.STATUS_BLOCKED, rationale=authored["rationale"]
        )

    awaiting_existing = workflow.get("awaitingApproval")
    if not isinstance(awaiting_existing, bool):
        awaiting_existing = False
    awaiting_approval = _derive_awaiting_approval(
        existing=awaiting_existing,
        validation_status=validation_status,
        current_phase=current_phase,
        pointer=next_recommended_phase,
        report_data=report_data,
    )

    last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
    if not isinstance(last_safe_checkpoint, dict):
        last_safe_checkpoint = {}
    last_safe_checkpoint.update(
        {
            "label": (
                "validation-passed"
                if validation_status == "passed"
                else "validation-failed"
            ),
            "taskManifestPath": task_manifest.get(
                "taskManifestPath", ""
            ),
            "taskIndexPath": task_manifest.get(
                "taskIndexPath", ""
            ),
            "latestRunPath": task_manifest.get(
                "latestRunPath", ""
            ),
            "latestRunManifestPath": run_manifest.get(
                "runManifestPath", ""
            ),
            "latestTeamStatePath": run_manifest.get(
                "teamStatePath", ""
            ),
            "latestReportRecordPath": task_manifest.get(
                "latestReportRecordPath", ""
            ),
            "latestResumeCommandPath": task_manifest.get(
                "latestResumeCommandPath", ""
            ),
        }
    )

    # routingStatus 는 포인터 status 로 흡수됐다. update() 는 키를 더할 뿐이라
    # 이전 run 이 남긴 값은 명시적으로 지워야 사라진다.
    workflow.pop("routingStatus", None)
    workflow.update(
        {
            "phaseSequence": phase_sequence,
            "currentPhase": current_phase,
            "currentPhaseState": current_phase_state,
            "phaseStates": phase_states,
            "lastCompletedPhase": last_completed_phase,
            "nextRecommendedPhase": next_recommended_phase,
            "awaitingApproval": awaiting_approval,
            "lastSafeCheckpoint": last_safe_checkpoint,
        }
    )
    task_manifest["workflow"] = workflow

    workflow_snapshot = run_manifest.get("workflowSnapshot", {})
    if not isinstance(workflow_snapshot, dict):
        workflow_snapshot = {}
    # task manifest 의 workflow 는 다음 run 이 덮어쓰므로 정정 기록이 남지 않는다.
    # run 단위 감사 기록은 이 스냅샷이 유일한 보관처다.
    workflow_snapshot.pop("routingStatus", None)
    workflow_snapshot.pop("nextRecommendedPhaseCorrection", None)
    workflow_snapshot.update(
        {
            "phaseSequence": workflow["phaseSequence"],
            "currentPhase": workflow["currentPhase"],
            "currentPhaseState": workflow["currentPhaseState"],
            "phaseStates": workflow["phaseStates"],
            "lastCompletedPhase": workflow["lastCompletedPhase"],
            # 포인터는 이제 문자열이 아니라 dict 다. 두 매니페스트가 같은 객체를
            # 공유하면 한쪽을 제자리 변형할 때 다른 쪽이 조용히 따라 바뀐다.
            "nextRecommendedPhase": dict(workflow["nextRecommendedPhase"]),
            "awaitingApproval": workflow["awaitingApproval"],
            "lastSafeCheckpoint": workflow["lastSafeCheckpoint"],
        }
    )
    if "nextRecommendedPhaseCorrection" in workflow:
        # 두 매니페스트는 별개 파일로 나간다 — dict 를 공유시키지 않는다.
        # 겉 dict 만 복사하면 authored·applied 는 여전히 같은 객체다.
        workflow_snapshot["nextRecommendedPhaseCorrection"] = {
            key: dict(value)
            for key, value in workflow["nextRecommendedPhaseCorrection"].items()
        }
    run_manifest["workflowSnapshot"] = workflow_snapshot


def update_validation_metadata(
    team_state: dict,
    run_manifest: dict,
    task_manifest: dict,
    validation_status: str,
    failures: list[str],
    report_data: Mapping[str, Any] | None = None,
    advisories: list[str] | None = None,
) -> None:
    checked_at = utc_now()
    advisories = list(advisories or [])

    team_state.setdefault("validator", {})
    team_state["validator"]["status"] = validation_status
    team_state["validator"]["lastValidatedAt"] = checked_at
    team_state["validator"]["failures"] = failures
    team_state["validator"]["advisories"] = advisories

    run_manifest.setdefault("validation", {})
    run_manifest["validation"]["required"] = True
    run_manifest["validation"]["status"] = validation_status
    run_manifest["validation"]["lastCheckedAt"] = checked_at
    run_manifest["validation"]["passed"] = validation_status == "passed"
    run_manifest["validation"]["failures"] = failures
    # Reported, not blocking — a passing run can carry these.
    run_manifest["validation"]["advisories"] = advisories
    run_manifest["status"] = (
        "completed" if validation_status == "passed" else "contract-violated"
    )

    task_manifest.setdefault("contractValidation", {})
    task_manifest["contractValidation"]["required"] = True
    task_manifest["contractValidation"]["status"] = validation_status
    task_manifest["contractValidation"]["lastCheckedAt"] = checked_at
    task_manifest["contractValidation"]["passed"] = validation_status == "passed"
    task_manifest["contractValidation"]["failures"] = failures
    task_manifest["latestRunStatus"] = run_manifest["status"]
    task_manifest["currentStatus"] = (
        "completed" if validation_status == "passed" else "contract-violated"
    )
    update_workflow_metadata(
        run_manifest,
        task_manifest,
        validation_status,
        report_data=report_data,
    )


def extract_contract(
    run_manifest: dict, task_manifest: dict, failures: list[str]
) -> dict:
    run_contract = run_manifest.get("teamContract")
    task_contract = task_manifest.get("resultContract")

    if not isinstance(run_contract, dict):
        run_contract = {}
    if not isinstance(task_contract, dict):
        task_contract = {}

    required_worker_roles = run_contract.get("requiredWorkerRoles")
    if not isinstance(required_worker_roles, list):
        required_worker_roles = task_contract.get("requiredWorkerRoles")
    if not isinstance(required_worker_roles, list):
        required_worker_roles = []
        failures.append("requiredWorkerRoles is missing from run/task manifest")

    optional_worker_roles = run_contract.get("optionalWorkerRoles")
    if not isinstance(optional_worker_roles, list):
        optional_worker_roles = task_contract.get("optionalWorkerRoles")
    if not isinstance(optional_worker_roles, list):
        optional_worker_roles = []

    lead_role = (
        run_contract.get("leadRole")
        or task_contract.get("leadRole")
        or "Okstra lead"
    )

    required_agent_status_entries = run_contract.get("requiredAgentStatusEntries")
    if not isinstance(required_agent_status_entries, list):
        required_agent_status_entries = task_contract.get("requiredAgentStatusEntries")
    if not isinstance(required_agent_status_entries, list):
        required_agent_status_entries = [lead_role] + [
            item.get("role", "")
            for item in required_worker_roles
            if isinstance(item, dict) and item.get("role")
        ]

    return {
        "lead_role": lead_role,
        "lead_agent": run_contract.get("leadAgent")
        or task_contract.get("leadAgent")
        or "claude",
        "lead_model": run_contract.get("leadModel")
        or task_contract.get("leadModel")
        or "",
        "lead_model_execution_value": (
            run_contract.get("leadModelExecutionValue")
            or task_contract.get("leadModelExecutionValue")
            or ""
        ),
        "required_worker_roles": required_worker_roles,
        "optional_worker_roles": optional_worker_roles,
        "required_agent_status_entries": [
            item
            for item in required_agent_status_entries
            if isinstance(item, str) and item.strip()
        ],
    }


def effective_run_task_type(run_manifest: dict, task_manifest: dict) -> str:
    """Return the task type for the specific run being validated.

    `task-manifest.json` is mutable lifecycle state and may point at a later
    phase after the user has continued the task. A final-report belongs to the
    immutable run-manifest, so run-manifest wins here.
    """
    return str(
        run_manifest.get("taskType") or task_manifest.get("taskType") or ""
    ).strip()


def _resolve_prompt_record_path(project_root: Path, prompt_value: str) -> Path:
    prompt_path = Path(prompt_value)
    return prompt_path if prompt_path.is_absolute() else project_root / prompt_path


def _fallback_dispatch_kind(prompt_value: str) -> str:
    match = re.search(r"-reverify-(r\d+)", Path(prompt_value).name)
    return f"reverify-{match.group(1)}" if match else "initial"


def _persisted_prompt_records(
    *,
    project_root: Path,
    selected_ids: set[str],
    team_state: dict,
    run_manifest: Mapping[str, Any],
) -> tuple[list[PromptRecord], list[str]]:
    uses_v2_identity = (
        run_manifest.get("schemaVersion") == "2.0"
        and run_manifest.get("executionIdentityVersion") == 2
    )
    dispatches = team_state.get("workerDispatches")
    if uses_v2_identity:
        source = [
            *(dispatches if isinstance(dispatches, list) else []),
            *(
                team_state.get("agentDispatches")
                if isinstance(team_state.get("agentDispatches"), list)
                else []
            ),
        ]
    else:
        source = dispatches if isinstance(dispatches, list) and dispatches else (
            team_state.get("workers") or []
        )
    records: list[PromptRecord] = []
    seen: set[tuple[str, str, str]] = set()
    errors: list[str] = []
    role_executions = {
        str(row.get("roleExecutionRef") or ""): row
        for row in run_manifest.get("roleExecutions") or []
        if isinstance(row, Mapping)
    } if uses_v2_identity else {}
    for worker in source:
        if not isinstance(worker, dict):
            continue
        assignment_ref = str(worker.get("assignmentRef") or "").strip()
        worker_id = (
            assignment_ref.removeprefix("initial/")
            if uses_v2_identity and assignment_ref.startswith("initial/")
            else str(worker.get("workerId") or "").strip()
        )
        prompt_value = str(worker.get("promptPath") or "").strip()
        if worker_id not in selected_ids:
            continue
        if not prompt_value:
            errors.append(f"{worker_id}: persisted initial prompt path is missing")
            continue
        dispatch_kind = str(
            worker.get("kind") or worker.get("dispatchKind") or ""
        ).strip()
        if not dispatch_kind:
            dispatch_kind = _fallback_dispatch_kind(prompt_value)
        duty_id = ""
        if uses_v2_identity:
            execution = role_executions.get(
                str(worker.get("roleExecutionRef") or "").strip()
            )
            participant_ref = str(worker.get("participantRef") or "").strip()
            duty_id = str(worker.get("dutyId") or "").strip()
            audience = str(worker.get("audience") or "").strip()
            assignments = run_manifest.get("invocationAssignments")
            assignment = (
                assignments.get(assignment_ref)
                if isinstance(assignments, Mapping)
                else None
            )
            if not isinstance(execution, Mapping):
                errors.append(
                    f"{worker_id}: persisted prompt has unknown roleExecutionRef"
                )
                continue
            try:
                expected_role = role_for_duty(duty_id)
            except RoleCatalogError as exc:
                errors.append(f"{worker_id}: {exc}")
                continue
            if (
                execution.get("participantRef") != participant_ref
                or execution.get("role") != expected_role
                or execution.get("executionLabel") != worker.get("executionLabel")
                or not isinstance(assignment, Mapping)
                or execution.get("provider") != assignment.get("provider")
                or not isinstance(execution.get("binding"), Mapping)
                or execution["binding"].get("resolvedExecutionValue")
                != assignment.get("modelExecutionValue")
            ):
                errors.append(
                    f"{worker_id}: persisted prompt identity does not match role execution"
                )
                continue
            if audience != duty_id:
                errors.append(
                    f"{worker_id}: persisted prompt audience does not match duty"
                )
                continue
        identity = (worker_id, dispatch_kind, prompt_value)
        if identity in seen:
            continue
        seen.add(identity)
        metadata_value = str(worker.get("promptMetadataPath") or "").strip()
        records.append(
            PromptRecord(
                worker_id=worker_id,
                dispatch_kind=dispatch_kind,
                path=_resolve_prompt_record_path(project_root, prompt_value),
                expected_model=(
                    str(worker.get("modelExecutionValue") or "").strip() or None
                    if uses_v2_identity else None
                ),
                metadata_path=(
                    _resolve_prompt_record_path(project_root, metadata_value)
                    if uses_v2_identity and metadata_value else None
                ),
                expected_duty_audience=(duty_id if uses_v2_identity else None),
            )
        )
    if uses_v2_identity:
        recorded_ids = {record.worker_id for record in records}
        missing_ids = sorted(selected_ids - recorded_ids)
        if missing_ids:
            errors.append(
                "no persisted initial prompt records for selected role executions: "
                + ", ".join(missing_ids)
            )
    return records, errors


def _markdown_section(text: str, heading: str) -> str:
    pattern = rf"(?ms)^##\s+{re.escape(heading)}\s*$\n(.*?)(?=^##\s|\Z)"
    match = re.search(pattern, text)
    return match.group(1) if match else ""


def _parse_resolved_lenses(grilling_text: str) -> list[str]:
    section = _markdown_section(grilling_text, "Resolved lenses")
    return [
        match.group(1).strip().strip("`")
        for line in section.splitlines()
        if (match := re.match(r"^\s*-\s+(.+?)\s*$", line))
    ]


def _parse_primary_assignments(
    grilling_text: str,
) -> tuple[dict[str, str], list[str]]:
    section = _markdown_section(grilling_text, "Primary Pass Assignments")
    rows = [
        (line.strip(), _split_pipe_row(line.strip()))
        for line in section.splitlines()
        if line.strip().startswith("|") and line.strip().endswith("|")
    ]
    errors: list[str] = []
    if not rows or rows[0][1] != ["Worker ID", "Primary lens"]:
        return {}, [
            "Primary Pass Assignments table must start with "
            "`| Worker ID | Primary lens |`"
        ]
    assignments: dict[str, str] = {}
    for raw_line, row in rows[1:]:
        if _is_markdown_separator(raw_line):
            continue
        if len(row) != 2 or not all(cell.strip() for cell in row):
            errors.append("Primary Pass Assignments rows must contain two values")
            continue
        worker_id, lens = (cell.strip().strip("`") for cell in row)
        if worker_id in assignments:
            errors.append(f"duplicate worker assignment row: {worker_id}")
            continue
        assignments[worker_id] = lens
    return assignments, errors


def _grilling_log_path_from_prompts(
    *,
    project_root: Path,
    worker_ids: list[str],
    records: list[PromptRecord],
) -> tuple[Path | None, list[str]]:
    header = GRILLING_LOG_HEADER
    values: set[str] = set()
    errors: list[str] = []
    for record in records:
        if record.worker_id not in worker_ids or record.dispatch_kind != "initial":
            continue
        try:
            text = record.path.read_text(encoding="utf-8")
        except (OSError, UnicodeError):
            continue
        matches = [
            line.strip()[len(header):].strip().strip("`")
            for line in text.splitlines()
            if line.strip().startswith(header)
        ]
        if len(matches) == 1 and matches[0]:
            values.add(matches[0])
    if not values:
        return None, ["initial analyser prompts have no grilling-log path"]
    if len(values) != 1:
        return None, ["initial analyser prompts reference different grilling logs"]
    return _resolve_prompt_record_path(project_root, values.pop()), errors


def _validate_improvement_primary_assignments(
    *,
    project_root: Path,
    worker_ids: list[str],
    records: list[PromptRecord],
) -> list[str]:
    grilling_path, errors = _grilling_log_path_from_prompts(
        project_root=project_root,
        worker_ids=worker_ids,
        records=records,
    )
    if grilling_path is None:
        return errors
    try:
        grilling_text = grilling_path.read_text(encoding="utf-8")
    except OSError as exc:
        return [*errors, f"cannot read grilling log {grilling_path}: {exc}"]
    lenses = _parse_resolved_lenses(grilling_text)
    assignments, parse_errors = _parse_primary_assignments(grilling_text)
    return [
        *errors,
        *parse_errors,
        *validate_primary_lens_assignments(assignments, worker_ids, lenses),
    ]


def _validate_initial_analysis_prompts(
    data: dict,
    failures: list[str],
) -> None:
    """Validate persisted prompts using their functional audience contract."""
    task_type = str(data.get("taskType") or "").strip()
    if not task_type:
        return
    run_manifest = data.get("runManifest") or {}
    team_contract = run_manifest.get("teamContract") or {}
    selected_workers = team_contract.get("requiredWorkerRoles") or []
    selected_worker_ids = [
        str(worker.get("workerId") or "").strip()
        for worker in selected_workers
        if isinstance(worker, dict)
        and str(worker.get("workerId") or "").strip()
    ]
    selected_ids = set(selected_worker_ids)
    project_root = Path(data["projectRoot"])
    team_state = data.get("teamState") or {}
    manifest = dict(run_manifest)
    manifest["taskType"] = task_type
    records, collection_errors = _persisted_prompt_records(
        project_root=project_root,
        selected_ids=selected_ids,
        team_state=team_state,
        run_manifest=run_manifest,
    )
    errors = [*collection_errors, *validate_initial_prompt_records(
        manifest=manifest,
        records=records,
    )]
    if task_type == "improvement-discovery":
        errors.extend(
            _validate_improvement_primary_assignments(
                project_root=project_root,
                worker_ids=[
                    worker_id
                    for worker_id in selected_worker_ids
                    if worker_id != "report-writer"
                ],
                records=records,
            )
        )
    failures.extend(
        f"{task_type} prompt contract: {error}" for error in errors
    )


def _is_legal_concurrent_run_skip(
    team_create: object, concurrent_run_authorized: bool
) -> bool:
    """prepare 가 run-manifest 에 동시-run 을 기록한 run 에서만, 렌더 게이트
    ("Concurrent-run marker")가 지시한 teamCreate skipped 형태를 legal 터미널
    상태로 인정한다. lead 의 team-state 자기 선언만으로는(앵커 없이) 열리지
    않는다 — 선언이 아닌 prepare-측 사실이 강제 근거다."""
    if not concurrent_run_authorized or not isinstance(team_create, dict):
        return False
    return (
        team_create.get("attempted") is False
        and str(team_create.get("status", "")).strip() == "skipped"
        and str(team_create.get("reason", "")).strip() == "concurrent-run"
    )


def _dispatch_roster_key(row: Mapping[str, Any]) -> str:
    """The roster worker this dispatch row started, v1 or v2.

    A v2 row carries no `workerId` — `_validate_agent_dispatch_contract`
    fails the run when one is present, calling it a v1/v2 identity mix. So
    reading the roster key off that field alone left every v2 row invisible
    and reported workers okstra had in fact started as never dispatched. The
    v2 projection is the one dispatch itself uses.
    """
    worker_id = str(row.get("workerId", "")).strip()
    if worker_id:
        return worker_id
    try:
        return v2_worker_state_key(row)
    except DispatchError:
        return ""


def _validate_cmux_workers_were_dispatched_by_okstra(
    team_state: dict,
    workers: list,
    dispatched_statuses: set[str],
    failures: list[str],
) -> None:
    """Under cmux, okstra owns the worker panes — so it must have started them.

    `prompts/lead/adapters/cmux.md` overrides the host relay's worker-dispatch
    mapping: every lead goes through `okstra team dispatch`, a Claude Code lead
    included. A lead that follows its own relay instead and starts the worker
    in-process gets no pane, and the user cannot see the work — which is the whole
    point of the adapter owning them.

    The tell is the absent record, not the backend: `okstra team` appends a
    `workerDispatches[]` row for every worker it starts, and an in-process worker
    leaves none. Backend alone cannot separate the two, because a pane dispatch
    that degrades legitimately records `backendType: cli-wrapper`
    (`dispatch_core._dispatch_record`). A worker that was never attempted has no
    row either, so only attempted statuses are checked.
    """
    adapter = team_state.get("leadAdapter")
    if not isinstance(adapter, dict):
        return
    if str(adapter.get("name", "")).strip() != "cmux":
        return
    recorded = {
        key
        for row in team_state.get("workerDispatches") or []
        if isinstance(row, dict)
        for key in (_dispatch_roster_key(row),)
        if key
    }
    missing = []
    for worker in workers:
        if not isinstance(worker, dict):
            continue
        if str(worker.get("status", "")).strip() not in dispatched_statuses:
            continue
        worker_id = str(worker.get("workerId", "")).strip()
        if worker_id and worker_id not in recorded:
            missing.append(worker_id)
    if missing:
        failures.append(
            "team-state.workerDispatches has no record for "
            f"{', '.join(sorted(missing))} in a cmux run — those workers ran "
            "without okstra starting them, so they had no pane and the user "
            "could not watch the work. Under cmux every lead dispatches through "
            "`okstra team dispatch`, a Claude Code lead included "
            "(prompts/lead/adapters/cmux.md); the host relay's in-process "
            "`Agent(...)` mapping does not apply to this run."
        )


def validate_team_state(
    team_state: dict,
    project_root: Path,
    contract: dict,
    failures: list[str],
    *,
    concurrent_run_authorized: bool = False,
) -> None:
    artifacts = team_state.get("artifacts")
    if not isinstance(artifacts, dict):
        failures.append("team-state.artifacts must be an object")
    elif not str(artifacts.get("workerPromptsDirectoryPath", "")).strip():
        failures.append(
            "team-state.artifacts.workerPromptsDirectoryPath is missing"
        )
    lead = team_state.get("lead")
    if not isinstance(lead, dict):
        failures.append("team-state.lead is missing")
    else:
        if lead.get("role") != contract["lead_role"]:
            failures.append(f"team-state.lead.role must be `{contract['lead_role']}`")
        if lead.get("agent") != contract["lead_agent"]:
            failures.append(f"team-state.lead.agent must be `{contract['lead_agent']}`")
        expected_lead_model = contract.get("lead_model")
        if expected_lead_model and lead.get("model") != expected_lead_model:
            failures.append(f"team-state.lead.model must be `{expected_lead_model}`")
        expected_lead_model_execution_value = contract.get("lead_model_execution_value")
        if (
            expected_lead_model_execution_value
            and lead.get("modelExecutionValue") != expected_lead_model_execution_value
        ):
            failures.append(
                "team-state.lead.modelExecutionValue must be "
                f"`{expected_lead_model_execution_value}`"
            )

    workers = team_state.get("workers")
    if not isinstance(workers, list):
        failures.append("team-state.workers must be a list")
        return

    dispatched_statuses = {"completed", "timeout", "error", "in-progress"}
    any_dispatched = any(
        isinstance(w, dict) and str(w.get("status", "")).strip() in dispatched_statuses
        for w in workers
    )
    if any_dispatched:
        dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
        if (
            dispatch_mode in WORKER_DISPATCH_MODES
            or _session_accounting(team_state) == "artifact-only"
        ):
            if dispatch_mode not in WORKER_DISPATCH_MODES:
                expected = ", ".join(sorted(WORKER_DISPATCH_MODES))
                failures.append(
                    "team-state.dispatchMode must be set for non-Team worker dispatch "
                    f"(expected one of: {expected})"
                )
        else:
            team_create = team_state.get("teamCreate")
            if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
                pass
            elif not isinstance(team_create, dict) or not str(team_create.get("status", "")).strip():
                failures.append(
                    "team-state.teamCreate must be recorded with a status once any worker "
                    "has been dispatched (status in completed/timeout/error/in-progress). "
                    "CC v2.1.178 removed TeamCreate; every session has an implicit team, so "
                    'Phase 3 records `teamCreate: { attempted: false, status: "implicit" }` — '
                    "the audit marker that drives Phase 7 token attribution. See "
                    "prompts/lead/okstra-lead-contract.md Phase 3. (The concurrent-run path "
                    "is legal ONLY when the run-manifest carries prepare-recorded "
                    '`concurrentRun.detected: true` AND team-state records '
                    '`teamCreate: { attempted: false, status: "skipped", reason: "concurrent-run" }`.)'
                )
            else:
                tc_status = str(team_create.get("status", "")).strip()
                if tc_status != "implicit":
                    failures.append(
                        "team-state.teamCreate.status must be `implicit` once workers have "
                        "been dispatched (the only other legal value is `skipped` with "
                        "reason `concurrent-run` in a prepare-authorized concurrent run). "
                        f"Found: `{tc_status}`."
                    )

    _validate_cmux_workers_were_dispatched_by_okstra(
        team_state, workers, dispatched_statuses, failures
    )

    by_role: dict[str, dict] = {}
    for worker in workers:
        if not isinstance(worker, dict):
            failures.append("team-state.workers contains a non-object entry")
            continue
        role = str(worker.get("role", "")).strip()
        if not role:
            failures.append("team-state.workers contains an entry without role")
            continue
        if role in by_role:
            failures.append(f"duplicate worker role detected: {role}")
            continue
        by_role[role] = worker

    expected_workers: dict[str, dict] = {}
    for worker in contract["required_worker_roles"]:
        if not isinstance(worker, dict):
            failures.append("requiredWorkerRoles contains a non-object entry")
            continue
        role = str(worker.get("role", "")).strip()
        if not role:
            failures.append("requiredWorkerRoles contains an entry without role")
            continue
        expected_workers[role] = worker

    for role, expected in expected_workers.items():
        worker = by_role.get(role)
        if worker is None:
            failures.append(f"missing required worker role: {role}")
            continue

        expected_worker_id = expected.get("workerId")
        if expected_worker_id and worker.get("workerId") != expected_worker_id:
            failures.append(f"{role} must use workerId `{expected_worker_id}`")

        expected_agent = expected.get("agent")
        if expected_agent and worker.get("agent") != expected_agent:
            failures.append(f"{role} must use agent `{expected_agent}`")

        expected_model = expected.get("model")
        if expected_model and worker.get("model") != expected_model:
            failures.append(f"{role} must use model `{expected_model}`")

        expected_model_execution_value = expected.get("modelExecutionValue")
        if (
            expected_model_execution_value
            and worker.get("modelExecutionValue") != expected_model_execution_value
        ):
            failures.append(
                f"{role} must use modelExecutionValue `{expected_model_execution_value}`"
            )

        expected_result_relative = expected.get("resultPath")
        result_relative = worker.get("resultPath", "")
        if expected_result_relative and result_relative != expected_result_relative:
            failures.append(
                f"{role} must use resultPath `{expected_result_relative}`"
            )
        expected_prompt_relative = expected.get("promptPath")
        prompt_relative = worker.get("promptPath", "")
        if expected_prompt_relative and prompt_relative != expected_prompt_relative:
            failures.append(
                f"{role} must use promptPath `{expected_prompt_relative}`"
            )

        status = worker.get("status")
        if status not in TERMINAL_STATUSES:
            failures.append(f"{role} has invalid terminal status: {status}")
            continue

        reason = str(worker.get("reason", "")).strip()
        prompt_exists = bool(prompt_relative) and (project_root / prompt_relative).exists()
        result_exists = (
            bool(result_relative) and (project_root / result_relative).exists()
        )
        if status in ATTEMPTED_STATUSES and not prompt_relative:
            failures.append(
                f"{role} with status `{status}` must include promptPath"
            )
        if status in ATTEMPTED_STATUSES and prompt_relative and not prompt_exists:
            failures.append(
                f"{role} with status `{status}` is missing worker prompt history file: {prompt_relative}"
            )

        if status == "completed" and not result_exists:
            failures.append(
                f"{role} is completed but worker result file is missing: {result_relative}"
            )
        if status != "completed" and not reason:
            failures.append(f"{role} with status `{status}` must include a reason")

    # A declared optional role may appear in the roster and may equally be
    # absent: the run states which ones it can dispatch (today, the critics),
    # and running one is not a contract violation.
    optional_roles = {
        str(worker.get("role", "")).strip()
        for worker in contract.get("optional_worker_roles", [])
        if isinstance(worker, dict) and str(worker.get("role", "")).strip()
    }
    unexpected_roles = set(by_role) - set(expected_workers) - optional_roles
    for role in sorted(unexpected_roles):
        failures.append(f"unexpected worker role detected: {role}")

    generic_roles = [
        role
        for role in by_role
        if "generic" in role.lower() or "parallel worker" in role.lower()
    ]
    for role in generic_roles:
        failures.append(f"generic worker role is not allowed: {role}")


TOKEN_PLACEHOLDERS = (
    "{{LEAD_TOTAL_TOKENS}}",
    "{{LEAD_BILLABLE_TOKENS}}",
    "{{LEAD_COST_USD}}",
    "{{WORKER_TOTAL_TOKENS}}",
    "{{WORKER_BILLABLE_TOKENS}}",
    "{{WORKER_COST_USD}}",
    "{{GRAND_TOTAL_TOKENS}}",
    "{{GRAND_BILLABLE_TOKENS}}",
    "{{GRAND_COST_USD}}",
    "{{CLI_COST_USD}}",
)


# The final-report renderer (render_final_report.py:_inject_anchors) appends a
# scroll anchor ` <a id="slug"></a>` to every H2+ heading. Section-scoping
# regexes that pin a heading to end-of-line MUST tolerate this optional suffix
# or they silently fail to match the rendered markdown — yielding false
# "missing section" failures and dead consistency checks. Mirror of the strip
# pattern in scripts/okstra_ctl/report_views.py:_HEADING_ANCHOR_RE.
_HEADING_TAIL = r'[ \t]*(?:<a id="[^"]+"></a>[ \t]*)?$'

# Token Usage Summary section between its `##` heading and the next `##`
# heading (or end-of-file). Matched non-greedily so the body of the next
# section never bleeds in.
_TOKEN_USAGE_SECTION_RE = re.compile(
    r"^##[ \t]+(?:Token Usage Summary|토큰 사용량 요약)" + _HEADING_TAIL
    + r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
    re.DOTALL | re.MULTILINE,
)

# Backtick-wrapped cell values inside a Token Usage Summary row. We use
# this to inspect actual cell contents rather than fighting markdown
# table parsing rules.
_TOKEN_USAGE_BACKTICK_CELL_RE = re.compile(r"`([^`\n]*)`")

# Sentinel words workers have been observed typing INSTEAD of leaving the
# `{{...}}` placeholders verbatim. These bypass the placeholder check
# because they are valid string values; we must reject them by name.
_TOKEN_USAGE_SENTINEL_VALUES = frozenset(
    {
        "pending",
        "n/a",
        "na",
        "tbd",
        "tba",
        "not-collected",
        "not collected",
        "--",
        "?",
        "unknown",
        "",
    }
)

# Numeric "valid zero" patterns. These ARE allowed in the CLI row when no
# Codex/Antigravity CLI work was billed; rejected everywhere else.
_TOKEN_USAGE_ZERO_VALUES = frozenset({"0", "$0.00", "$0", "0.00"})


def _scan_token_usage_summary(
    content: str,
    failures: list[str],
    *,
    allow_unavailable_tokens: bool = False,
    unavailable_ok_labels: frozenset[str] = frozenset(),
) -> None:
    """Reject sentinel / zero values that workers typed into the Token
    Usage Summary table instead of leaving the `{{...}}` placeholders
    verbatim for Phase 7 substitution.

    The placeholder check (`TOKEN_PLACEHOLDERS`) above catches the
    "didn't substitute" case; this scanner catches the "substituted with
    a sentinel string" case which is invisible to that check and was the
    real source of `0` / `$0.00` / `pending` shipping in real reports.

    Rules:
    - The Codex/Antigravity CLI 추가 비용 row may carry an empty cell or
      `$0.00` (no CLI work was billed). Sentinel words are still
      rejected.
    - Every other row's backtick-wrapped cells must be either a
      comma-grouped integer (e.g. `1,234,567`) or a USD value (`$5.43`).
      Zero values (`0` / `$0.00`) are rejected because no okstra run
      consumes zero tokens — a zero there means the writer fabricated a
      stub.
    """
    match = _TOKEN_USAGE_SECTION_RE.search(content)
    if match is None:
        # The Token Usage Summary section is required in every report
        # (the template emits it unconditionally). A missing section is
        # surfaced elsewhere by the placeholder check (which would also
        # not fire — so we add a dedicated failure here).
        failures.append(
            "final report is missing the `## Token Usage Summary` "
            "(or `## 토큰 사용량 요약`) section — "
            "the template renders it unconditionally and Phase 7 substitution "
            "depends on it being present."
        )
        return

    body = match.group("body")
    for raw_line in body.splitlines():
        line = raw_line.strip()
        if not line.startswith("|") or line.startswith("|--"):
            # Skip non-table lines, the header separator (`|------|`), and
            # blank lines. Header rows have no backticks so they self-skip.
            continue
        cells = _split_pipe_row(line)
        if not cells:
            continue
        label_cell = cells[0].strip("* `")
        # The CLI row's label always contains the word "CLI" — matching
        # `Codex/Antigravity CLI 추가 비용` regardless of formatting variations.
        is_cli_row = "CLI" in label_cell
        # A CLI-wrapper worker's tokens are accounted inside its own CLI, not
        # in the lead's session JSONL, so `--` on that row is the honest
        # value — not a skipped collection. Gating it made every roster
        # containing such a worker unable to pass, leaving the lead a choice
        # between fabricating numbers and shipping `contract-violated`.
        row_label = label_cell.lstrip("- ").strip()
        unavailable_ok = allow_unavailable_tokens or row_label in unavailable_ok_labels
        # One row's three cells carry the same defect, so reporting per cell
        # turned two broken workers into six failures and made the real count
        # unreadable. Collapse to one line per (row, sentinel).
        reported_sentinels: set[str] = set()
        for raw_cell in cells[1:]:
            for value in _TOKEN_USAGE_BACKTICK_CELL_RE.findall(raw_cell):
                stripped = value.strip()
                lowered = stripped.lower()
                if unavailable_ok and stripped == "--":
                    continue
                if lowered in reported_sentinels:
                    continue
                if lowered in _TOKEN_USAGE_SENTINEL_VALUES:
                    reported_sentinels.add(lowered)
                    failures.append(
                        "Token Usage Summary cell contains sentinel value "
                        f"`{stripped}` on row labelled `{label_cell or '<unlabeled>'}` — "
                        "leave the `{{...}}` placeholder verbatim until "
                        "`okstra-token-usage.py --substitute-data` runs "
                        "in Phase 7."
                    )
                    continue
                if stripped in _TOKEN_USAGE_ZERO_VALUES and not is_cli_row:
                    reported_sentinels.add(lowered)
                    failures.append(
                        f"Token Usage Summary row `{label_cell or '<unlabeled>'}` has "
                        f"a zero value `{stripped}` — no okstra run consumes zero "
                        "tokens. Re-run `okstra token-usage "
                        "<team-state> --write --summary --substitute-data "
                        "<report-path>` to repopulate from session jsonls. The "
                        "Codex/Antigravity CLI row is the only place `$0.00` is "
                        "allowed (when no CLI work was billed)."
                    )


# Verdict Card heading (mandatory top-of-report at-a-glance block introduced
# with the report-format readability pass). Matches `## Verdict Card` only as
# a section heading line (not as inline text inside a paragraph or table).
_VERDICT_CARD_HEADING_RE = re.compile(r"^##[ \t]+Verdict Card\b", re.MULTILINE)

def _team_state_worker_label(worker: Mapping[str, Any]) -> str:
    role = str(worker.get("role") or "").strip()
    worker_id = str(worker.get("workerId") or "").strip()
    if role and worker_id:
        return f"{role} (`{worker_id}`)"
    return role or worker_id or "<unnamed>"


def _unmatched_team_state_workers(
    report_data: Mapping[str, Any],
    team_state: Mapping[str, Any],
) -> list[dict]:
    raw = team_state.get("workers")
    workers = [row for row in raw if isinstance(row, dict)] if isinstance(raw, list) else []
    used: set[int] = set()
    for row in report_data.get("executionStatus") or []:
        if not isinstance(row, dict):
            continue
        if "lead" in str(row.get("role") or "").lower():
            continue
        index = _match_worker_index(row, workers, used)
        if index is not None:
            used.add(index)
    return [worker for index, worker in enumerate(workers) if index not in used]


def _validate_v2_report(
    report_data: Mapping[str, Any],
    failures: list[str],
    *,
    team_state: Mapping[str, Any] | None = None,
) -> None:
    """Contract checks for a schema-v2 report, read from its data.json.

    Agent completeness is the team-state worker set versus `executionStatus`
    rows, matched by `_match_worker_index` (role containment, then a unique
    provider+model fallback). Token placeholders stay a serialized-record
    scan. The full reading copy is not read.
    """
    for worker in _unmatched_team_state_workers(report_data, team_state or {}):
        failures.append(
            "executionStatus is missing team-state worker "
            f"{_team_state_worker_label(worker)}"
        )
    serialized = json.dumps(report_data, ensure_ascii=False)
    for placeholder in TOKEN_PLACEHOLDERS:
        if placeholder in serialized:
            failures.append(
                f"final report contains unsubstituted token placeholder `{placeholder}` — "
                "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
            )


# Top-of-report Index block. The renderer
# (scripts/okstra_ctl/render_final_report.py) injects `<a id="report-index">`
# into the index heading; a missing anchor means the markdown was produced
# outside the renderer or hand-edited. Language-independent (the heading text
# itself is localized "Index" / "목차").
_REPORT_INDEX_ANCHOR_RE = re.compile(r'<a id="report-index"')

# An ID-defining table row: `| **FU-001**…` or `| C-001 |`. After the
# renderer's anchor pass the leading token becomes `<a id="…">…`, so a row
# still matching this (ID is the bare leading token) is one the renderer
# never anchored — i.e. an un-anchored ID that the Index cannot link to.
_UNANCHORED_ID_ROW_RE = re.compile(r"^\|[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b", re.MULTILINE)

# Empty Section 0 (Clarification Response Carried In) stub. When no
# carry-in path is provided, the writer must OMIT the `## 0.` heading
# entirely — emitting the heading followed by the "No prior clarification
# response was provided" stub line is the recurring failure mode this
# regex catches. The 400-char window after the heading covers the stub
# line + any boilerplate without crossing into the next section.
_EMPTY_CARRY_IN_RE = re.compile(
    r"^##[ \t]+0\.[ \t]+Clarification Response Carried In"
    r"[\s\S]{0,400}?No prior clarification response was provided",
    re.MULTILINE,
)

# Section 0 heading with an empty `Source file: \`\`` line — the second
# failure shape (writer keeps the heading + Source file row but with an
# empty backtick value because no carry-in was provided). Same remedy:
# omit the entire `## 0.` block when carry-in is absent.
_EMPTY_CARRY_IN_SOURCE_RE = re.compile(
    r"^##[ \t]+0\.[ \t]+Clarification Response Carried In"
    r"[\s\S]{0,400}?Source file:[ \t]*`\s*`",
    re.MULTILINE,
)

# Section 0 incremental audit sub-block. When the data.json records an
# `implementationPlanning.incrementalDecision` with `mode == "incremental"`,
# the rendered report MUST expose the decision so a reader can audit which
# stages were re-verified vs carried forward unchanged.
_INCREMENTAL_AUDIT_HEADING_RE = re.compile(
    r"^###[ \t]+0\.1[ \t]+Incremental Re-Verification Scope\b", re.MULTILINE
)

# Deprecated section headings removed by the report-format readability
# pass. Each entry is (regex, human-readable remedy). The regexes are
# line-anchored to avoid false positives from inline references in prose
# (e.g. this file itself, or skill documentation that mentions the
# deprecated names).
_DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
    (
        re.compile(r"^##[ \t]+User Approval Request\b", re.MULTILINE),
        "deprecated `## User Approval Request` block — approval gate moved to "
        "the YAML frontmatter `approved: true|false` field. Delete the body section.",
    ),
    (
        re.compile(r"^###[ \t]+5\.5\.8[ \t]+User Approval Request\b", re.MULTILINE),
        "deprecated `### 5.5.8 User Approval Request` stub — approval gate moved "
        "to the YAML frontmatter `approved: true|false` field. Delete the §5.5.8 heading + body.",
    ),
    (
        re.compile(r"^###[ \t]+5\.5\.9[ \t]+Open Questions\b", re.MULTILINE),
        "deprecated `### 5.5.9 Open Questions` block — promote each row into "
        "`## 1. Clarification Items` with `Kind=decision` (and `Blocks=approval` "
        "if it gates the frontmatter approval flag).",
    ),
    (
        re.compile(
            r"^###[ \t]+1\.1[ \t]+(?:추가 자료 요청|Additional Materials)\b",
            re.MULTILINE,
        ),
        "deprecated `### 1.1 추가 자료 요청` / `Additional Materials` sub-section — "
        "every clarification item lives as one row of the unified `## 1. "
        "Clarification Items` table (`Kind=material`).",
    ),
    (
        re.compile(
            r"^###[ \t]+1\.2[ \t]+(?:사용자 확인 질문|Questions for the User)\b",
            re.MULTILINE,
        ),
        "deprecated `### 1.2 사용자 확인 질문` / `Questions for the User` "
        "sub-section — collapse into the unified `## 1. Clarification Items` "
        "table (`Kind=decision`).",
    ),
)


def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
    """Load each entry's `result-<stageKey>.json` sidecar.

    Missing or malformed files leave the key absent so gate evaluation receives None.
    """
    results: dict = {}
    for entry in manifest.get("entries", []):
        key = entry.get("stageKey") if isinstance(entry, dict) else None
        if not isinstance(key, str) or not key:
            continue
        sidecar = qa_dir / f"result-{key}.json"
        if not sidecar.is_file():
            continue
        try:
            results[key] = qa_result_from_dict(json.loads(sidecar.read_text()))
        except (OSError, json.JSONDecodeError):
            results[key] = qa_result_from_dict(None)  # → MISSING → conformance verdict
    return results


def _diff_summary_files(report_data: Mapping[str, Any]) -> list[str]:
    """implementation 리포트가 신고한 변경 파일 목록 (`implementation.diffSummary.files[].file`).

    렌더된 §5.7.3 표를 정규식으로 긁던 자리다. 표는 data.json 의 이 배열에서
    렌더되는 파생물이라, 표를 읽는 쪽은 렌더 형식이 바뀔 때마다 조용히 빈
    목록을 돌려주고 — conformance / self-mock 두 게이트가 전부 통과로 열렸다.
    스키마가 `implementation` 블록에서 `diffSummary` 를 required 로 잡고
    `rawStat` 이 비어있지 않으면 `files` 최소 1행을 요구하므로, 여기서는
    구조가 어긋난 경우만 빈 목록으로 떨어뜨린다.

    `diffSummary` 를 가진 task-type 은 implementation 뿐이다. final-verification
    은 diff 를 `diffSummaryQuote` 문자열로만 인용하므로 두 게이트는 거기서
    (md 를 읽던 시절과 똑같이) vacuous 하다.
    """
    implementation = report_data.get("implementation")
    if not isinstance(implementation, Mapping):
        return []
    diff_summary = implementation.get("diffSummary")
    if not isinstance(diff_summary, Mapping):
        return []
    rows = diff_summary.get("files")
    if not isinstance(rows, list):
        return []
    return [
        row["file"]
        for row in rows
        if isinstance(row, Mapping) and isinstance(row.get("file"), str) and row["file"]
    ]


_STAGE_RUN_DIR_RE = re.compile(r"^stage-\d+$")


def _stage_isolated_name(run_dir: Path) -> str | None:
    """Return stage-<N> for implementation or final-verification stage runs."""
    if (
        run_dir.parent.name in ("implementation", "final-verification")
        and _STAGE_RUN_DIR_RE.match(run_dir.name)
    ):
        return run_dir.name
    return None


def _scope_manifest_entries(manifest: dict, stage_name: str | None) -> dict:
    """게이트 평가 대상 entry 를 run 스코프로 좁힌 manifest 를 반환.

    implementation 은 한 run = 한 stage 이므로 자기 stageKey
    (`<task-id>-stage-<N>`) entry 만 게이트한다 — 다른 stage 는 각자의
    implementation run / final-verification(whole-task) 이 검증한다.
    suffix 매칭인 이유: stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라
    task 디렉터리 segment 와 표기가 다를 수 있다.
    """
    if stage_name is None:
        return manifest
    entries = [
        e for e in manifest.get("entries", [])
        if isinstance(e, dict) and str(e.get("stageKey", "")).endswith(f"-{stage_name}")
    ]
    return {"entries": entries}


def _task_root_from_run_dir(run_dir: Path) -> Path:
    """run_dir 에서 task_root 를 복원한다.

    레이아웃 해석은 `RunRef.from_run_dir` 이 소유한다. run 디렉터리가 아닌
    입력에는 기존 동작(두 단계 위)으로 폴백해 검증기가 죽지 않게 한다.
    """
    try:
        return RunRef.from_run_dir(run_dir).task_root
    except ValueError:
        return run_dir.parent.parent


def _read_run_inputs_payload(
    inputs_path: Path,
    failures: list[str],
) -> dict | None:
    """Read the exact run-inputs payload, failing closed on malformed evidence."""
    if not inputs_path.is_file():
        failures.append(
            f"conformance gate BLOCKING: approved plan evidence run inputs missing "
            f"at {inputs_path}"
        )
        return None
    try:
        payload = json.loads(inputs_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        failures.append(
            f"conformance gate BLOCKING: approved plan evidence run inputs "
            f"unreadable at {inputs_path}: {exc}"
        )
        return None
    if not isinstance(payload, dict):
        failures.append(
            "conformance gate BLOCKING: approved plan evidence run inputs "
            f"payload malformed at {inputs_path}"
        )
        return None
    return payload


def _approved_plan_path_from_run_inputs(
    run_manifest_path: Path,
    failures: list[str],
) -> Path | None:
    """Load an absolute approvedPlanPath from the exact run-inputs sibling."""
    name = run_manifest_path.name
    if not name.startswith("run-manifest-") or not name.endswith(".json"):
        failures.append(
            "conformance gate BLOCKING: approved plan evidence cannot derive "
            f"run-inputs sibling from {run_manifest_path}"
        )
        return None
    inputs_path = run_manifest_path.with_name(
        name.replace("run-manifest-", "run-inputs-", 1)
    )
    payload = _read_run_inputs_payload(inputs_path, failures)
    if payload is None:
        return None
    inputs = payload.get("inputs")
    if not isinstance(inputs, dict):
        failures.append(
            "conformance gate BLOCKING: approved plan evidence has malformed "
            f"inputs object at {inputs_path}"
        )
        return None
    raw_path = inputs.get("approvedPlanPath")
    if not isinstance(raw_path, str) or not raw_path.strip():
        failures.append(
            "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
            f"missing or malformed at {inputs_path}"
        )
        return None
    path = Path(raw_path)
    if not path.is_absolute():
        failures.append(
            "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
            f"must be absolute at {inputs_path}: {raw_path}"
        )
        return None
    try:
        return path.resolve()
    except (OSError, ValueError) as exc:
        failures.append(
            "conformance gate BLOCKING: approved plan evidence approvedPlanPath "
            f"malformed at {inputs_path}: {exc}"
        )
        return None


def _approved_plan_stage_entries(
    stages: object,
    data_path: Path,
    failures: list[str],
) -> list[dict] | None:
    """Validate approved-plan stages and return conformance declarations."""
    if not isinstance(stages, list) or not stages:
        _record_malformed_plan_stages(data_path, failures)
        return None
    entries: list[dict] = []
    seen_stage_numbers: set[int] = set()
    for stage in stages:
        valid, entry = _approved_plan_stage_entry(
            stage,
            seen_stage_numbers,
            data_path,
            failures,
        )
        if not valid:
            return None
        if entry is not None:
            entries.append(entry)
    return entries


def _record_malformed_plan_stages(data_path: Path, failures: list[str]) -> None:
    failures.append(
        f"conformance gate BLOCKING: approved plan evidence has malformed stages at {data_path}"
    )


def _approved_plan_stage_entry(
    stage: object,
    seen_stage_numbers: set[int],
    data_path: Path,
    failures: list[str],
) -> tuple[bool, dict | None]:
    if not isinstance(stage, dict):
        _record_malformed_plan_stages(data_path, failures)
        return False, None
    stage_number = stage.get("stage")
    tests = stage.get("conformanceTests")
    exemption = stage.get("conformanceExemption")
    tests_present = "conformanceTests" in stage
    exemption_present = "conformanceExemption" in stage
    fields_are_strings = all(
        value is None or isinstance(value, str) for value in (tests, exemption)
    )
    has_tests = isinstance(tests, str) and bool(tests.strip())
    has_exemption = isinstance(exemption, str) and bool(exemption.strip())
    choice_is_valid = (
        tests_present != exemption_present
        and (has_tests if tests_present else has_exemption)
    )
    stage_is_valid = (
        isinstance(stage_number, int)
        and not isinstance(stage_number, bool)
        and stage_number >= 1
        and stage_number not in seen_stage_numbers
    )
    if not fields_are_strings or not stage_is_valid or not choice_is_valid:
        _record_malformed_plan_stages(data_path, failures)
        return False, None
    seen_stage_numbers.add(stage_number)
    if not has_tests:
        return True, None
    declaration = _parse_conformance_tests(tests)
    if declaration is None:
        failures.append(
            "conformance gate BLOCKING: approved plan evidence has malformed "
            f"conformanceTests for stage {stage_number} at {data_path}"
        )
        return False, None
    script, requires = declaration
    return True, {
        "stageKey": f"approved-plan-stage-{stage_number}",
        "script": script,
        "requires": sorted(requires),
    }


def _approved_plan_conformance_manifest(
    approved_plan_path: Path,
    task_root: Path,
    failures: list[str],
) -> dict | None:
    """Represent approved-plan conformance declarations as stage-key entries."""
    if not approved_plan_path.is_file():
        failures.append(
            f"conformance gate BLOCKING: approved plan evidence missing at {approved_plan_path}"
        )
        return None
    data_path = _data_path_for(approved_plan_path)
    if not data_path.is_file():
        failures.append(
            f"conformance gate BLOCKING: approved plan evidence missing at {data_path}"
        )
        return None
    resolved_data_path = data_path.resolve()
    if not resolved_data_path.is_relative_to(task_root.resolve()):
        failures.append(
            "conformance gate BLOCKING: approved plan data evidence resolves "
            f"outside current task root {task_root}: {resolved_data_path}"
        )
        return None
    data_path = resolved_data_path
    try:
        data = json.loads(data_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        failures.append(
            f"conformance gate BLOCKING: approved plan evidence unreadable at {data_path}: {exc}"
        )
        return None
    planning = data.get("implementationPlanning") if isinstance(data, dict) else None
    stages = planning.get("stages") if isinstance(planning, dict) else None
    entries = _approved_plan_stage_entries(stages, data_path, failures)
    return {"entries": entries} if entries is not None else None


def _declared_conformance_errors(
    declared_manifest: dict,
    actual_manifest: dict,
    stage_name: str | None,
) -> list[str]:
    """Compare scoped plan declarations with their one actual manifest entry."""
    declared = _scope_manifest_entries(declared_manifest, stage_name).get("entries", [])
    actual = _scope_manifest_entries(actual_manifest, stage_name).get("entries", [])
    errors: list[str] = []
    declared_stage_numbers = {
        str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
        for entry in declared
        if isinstance(entry, dict)
    }
    for declaration in declared:
        stage_number = str(declaration.get("stageKey") or "").rsplit("-stage-", 1)[-1]
        matches = [
            entry for entry in actual
            if isinstance(entry, dict)
            and str(entry.get("stageKey") or "").endswith(f"-stage-{stage_number}")
        ]
        if not matches:
            errors.append(f"stage {stage_number} has no matching entry")
            continue
        if len(matches) > 1:
            errors.append(f"stage {stage_number} has multiple matching entries")
            continue
        actual_entry = matches[0]
        actual_script = _normalize_conformance_script(str(actual_entry.get("script") or ""))
        if actual_script != declaration.get("script"):
            errors.append(f"stage {stage_number} script mismatch")
        actual_requires = actual_entry.get("requires")
        actual_capabilities = (
            frozenset(actual_requires)
            if isinstance(actual_requires, list)
            and all(isinstance(value, str) for value in actual_requires)
            else None
        )
        if actual_capabilities != frozenset(declaration.get("requires") or []):
            errors.append(f"stage {stage_number} requires mismatch")
    for actual_entry in actual:
        if not isinstance(actual_entry, dict):
            continue
        stage_number = str(actual_entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
        if stage_number not in declared_stage_numbers:
            errors.append(
                f"stage {stage_number} actual manifest entry is not declared by approved plan"
            )
    return errors


def _planning_conformance_declarations(
    stages: object,
    failures: list[str],
) -> list[dict]:
    declarations: list[dict] = []
    for stage in stages if isinstance(stages, list) else []:
        if not isinstance(stage, dict) or not str(stage.get("conformanceTests") or "").strip():
            continue
        parsed = _parse_conformance_tests(stage.get("conformanceTests"))
        if parsed is None:
            failures.append(
                "final-report data.json: stage "
                f"{stage.get('stage')} has malformed conformanceTests declaration."
            )
            continue
        script, requires = parsed
        declarations.append(
            {
                "stageKey": f"approved-plan-stage-{stage.get('stage')}",
                "script": script,
                "requires": sorted(requires),
            }
        )
    return declarations


def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
    """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식만 본다.

    스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
    선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
    `conformanceTests` 는 여전히 실패한다.
    """
    data_path = report_path.with_suffix(".data.json")
    if not data_path.is_file():
        return
    try:
        data = json.loads(data_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    _planning_conformance_declarations(ip.get("stages"), failures)


def _validate_conformance_surfaces(
    report_data: Mapping[str, Any],
    scoped_manifest: dict,
    surface_patterns: object,
    failures: list[str],
) -> None:
    changed_files = _diff_summary_files(report_data)
    if not changed_files:
        return
    uncovered = (
        detect_surfaces(changed_files, surface_patterns)
        - manifest_required_surfaces(scoped_manifest)
    )
    if uncovered:
        failures.append(
            "conformance gate BLOCKING: implementation diff touches undeclared "
            f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
            "`requires` for them. Declare a conformance entry (requires=[...]) "
            "for the touching stage. "
            "(silent mock-green 방지 — DEV-9184)"
        )


def _validate_conformance(
    report_path: Path,
    failures: list[str],
    surface_patterns: object = None,
    approved_plan_path: Path | None = None,
) -> list[str]:
    """Tier 3 conformance 게이트(implementation / final-verification).

    A missing task-level manifest is inert only without approved-plan evidence.
    When a recorded approved plan declares conformance for the in-scope stage,
    disappearance of that manifest is a blocking structural failure. Otherwise,
    evaluate the manifest and result sidecars normally: BLOCKING verdicts fail
    validation, ADVISORY verdicts become user follow-up warnings, and permitted
    WAIVED/EXEMPT verdicts pass.

    Stage-isolated implementation and final-verification runs evaluate only their
    own stage suffix. Whole-task runs evaluate every entry.
    """
    warnings: list[str] = []
    report_data = _load_final_report_data(report_path)
    # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
    # implementation/final-verification 가 공유한다. report_path 는
    # task_root/runs/<task-type>/reports/final-report.md (implementation 은
    # stage 격리로 runs/implementation/stage-<N>/reports/...) 이므로 고정 parent
    # 카운트 대신 `runs` 디렉터리를 앵커로 task_root 를 찾는다 — stage-<N> 레벨이
    # 있어도 task_root/qa 로 정확히 떨어진다.
    run_dir = report_path.parent.parent
    task_root = _task_root_from_run_dir(run_dir)
    qa_dir = task_root / "qa"
    manifest_path = qa_dir / "conformance-manifest.json"
    stage_name = _stage_isolated_name(run_dir)
    declared_manifest = None
    if approved_plan_path is not None:
        resolved_plan_path = approved_plan_path.resolve()
        if not resolved_plan_path.is_relative_to(task_root.resolve()):
            failures.append(
                "conformance gate BLOCKING: approved plan evidence resolves "
                f"outside current task root {task_root}: {resolved_plan_path}"
            )
            return warnings
        declared_manifest = _approved_plan_conformance_manifest(
            resolved_plan_path,
            task_root,
            failures,
        )
        if declared_manifest is None:
            return warnings
    if declared_manifest is not None:
        scoped_declared = _scope_manifest_entries(declared_manifest, stage_name)
        for error in missing_declared_scripts(
            scoped_declared.get("entries"), task_root
        ):
            failures.append(f"conformance gate BLOCKING: {error}")
    if not manifest_path.is_file():
        empty_scoped_manifest = {"entries": []}
        if declared_manifest is not None:
            for error in _declared_conformance_errors(
                declared_manifest,
                empty_scoped_manifest,
                stage_name,
            ):
                failures.append(
                    f"conformance gate BLOCKING: approved plan {error}; "
                    f"{manifest_path} is absent"
                )
        _validate_conformance_surfaces(
            report_data,
            empty_scoped_manifest,
            surface_patterns,
            failures,
        )
        return warnings
    try:
        manifest = json.loads(manifest_path.read_text())
    except (OSError, json.JSONDecodeError) as exc:
        failures.append(f"conformance manifest unreadable at {manifest_path}: {exc}")
        return warnings
    schema_errors = validate_conformance_manifest(manifest)
    if schema_errors:
        failures.extend(f"conformance manifest: {e}" for e in schema_errors)
        return warnings
    if declared_manifest is not None:
        for error in _declared_conformance_errors(
            declared_manifest,
            manifest,
            stage_name,
        ):
            failures.append(
                f"conformance gate BLOCKING: approved plan {error} "
                f"in {manifest_path}"
            )
    scoped = _scope_manifest_entries(manifest, stage_name)
    entries_by_key = {
        entry.get("stageKey"): entry
        for entry in scoped.get("entries", [])
        if isinstance(entry, dict) and isinstance(entry.get("stageKey"), str)
    }
    for stage_key, entry in entries_by_key.items():
        if entry.get("requires") == [] and entry.get("waiver"):
            failures.append(
                f"conformance gate BLOCKING for stage {stage_key}: "
                "requires=[] is declaration/contract trouble and cannot be waived"
            )
        if entry.get("requires") and entry.get("exemption"):
            failures.append(
                f"conformance gate BLOCKING for stage {stage_key}: "
                f"a stage that declares a gated surface (requires="
                f"{entry.get('requires')}) cannot be exempted — submit a real "
                "conformance result or record a user-acknowledged waiver. See "
                "docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md §7.1."
            )
    results = _load_conformance_results(qa_dir, scoped)
    for verdict in evaluate_conformance(scoped, results):
        if verdict.status == "ADVISORY":
            entry = entries_by_key.get(verdict.stage_key, {})
            warnings.append(
                f"conformance advisory for stage {verdict.stage_key}: "
                f"{verdict.message}; requires={entry.get('requires') or []}; "
                f"user-owned follow-up command: "
                f"{entry.get('runCommand') or '(missing)'}; "
                f"manifest: {manifest_path}"
            )
        elif not verdict.ok:
            failures.append(
                f"conformance gate BLOCKING for stage {verdict.stage_key}: "
                f"{verdict.message}. Run the stage's conformance script (or declare "
                f"an exemption / user waiver) — see "
                f"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md."
            )
    _validate_conformance_surfaces(
        report_data,
        scoped,
        surface_patterns,
        failures,
    )
    return warnings


_TEST_FILE_RE = re.compile(r"(\.spec\.|\.test\.|(^|/)test_|_test\.|(^|/)tests?/)")
# okstra ships deliberately self-mocked samples as detector fixtures; gating
# them would make okstra permanently fail its own gate when verifying itself.
_SELFMOCK_FIXTURE_PREFIX = "tests/fixtures/self_mock/"


_SELFMOCK_WAIVER_FILENAME = "self-mock-waivers.json"


def _selfmock_source_is_canonical(source: object, canonical: Path) -> bool:
    """True when `waiverSource` names the task's own waiver file.

    Compared on the whole task-scoped path, never a tail: `/tmp/qa/` +
    `self-mock-waivers.json` reproduces the canonical basename exactly, so a
    short-suffix comparison would hand the redirect straight back. A LONGER
    absolute prefix on the recorded source is accepted — macOS resolves a
    `/var/...` task root to `/private/var/...` — because the task-scoped segments
    are what carry the property, and false-blocking a legitimate run over a
    realpath prefix costs more than that leniency does. The reverse (a source
    SHORTER than the canonical path) is refused: a bare relative
    `qa/self-mock-waivers.json` resolves against the worktree cwd, which is a
    file the run can write.
    """
    if not isinstance(source, str) or not source.strip():
        return False
    key = selfmock_path_key(source)
    want = selfmock_path_key(str(canonical))
    return key == want or key.endswith("/" + want.lstrip("/"))


def _selfmock_mutation_waiver_failure(mutation: object, sidecar: Path) -> str | None:
    """The BLOCKING message for an invalid mutation waiver set, else None.

    The exact mirror of `_selfmock_waiver_failure`, because gate B's escape hatch
    carries the same risk gate A's does: it is the one input that can talk the
    gate out of a finding about the code this run just wrote. So a waived
    surviving mutant needs a `reason` that makes it reviewable and the
    `acknowledgedBy` of the USER who accepted it, and the entry has to have been
    read from the task's own `qa/self-mock-waivers.json` — the same file gate A
    uses, so the user manages one place and the source stays singular.

    `mutation_probe._apply_waivers` deliberately matches without adjudicating and
    carries an unacknowledged entry into `waived`, so it surfaces here instead of
    being silently dropped by the run that produced the finding.
    """
    if not isinstance(mutation, dict):
        return None
    waived = mutation.get("waived")
    if not waived:
        # Nothing was waived, so there is no acknowledgement to locate.
        return None
    problems: list[str] = []
    if not _selfmock_source_is_canonical(
        mutation.get("waiverSource"), sidecar.parent / _SELFMOCK_WAIVER_FILENAME
    ):
        problems.append(
            f"mutation.waiverSource must be the task's own "
            f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME}, got "
            f"{mutation.get('waiverSource')!r}"
        )
    if not isinstance(waived, list):
        problems.append("mutation.waived must be an array of waiver objects")
    else:
        for idx, entry in enumerate(waived):
            if not isinstance(entry, dict):
                problems.append(f"mutation.waived[{idx}] must be an object")
                continue
            for field in ("reason", "acknowledgedBy"):
                value = entry.get(field)
                if not isinstance(value, str) or not value.strip():
                    problems.append(
                        f"mutation.waived[{idx}].{field} must be a non-empty string"
                    )
    if not problems:
        return None
    return (
        f"self-mock gate BLOCKING: {sidecar} carries invalid mutation waiver(s): "
        + "; ".join(problems)
        + ". A waived surviving mutant needs a `reason` and the `acknowledgedBy` of "
        "the user who accepted it, read from the task's own "
        "`qa/self-mock-waivers.json` — a verifier may report a suspected "
        "false-positive mutant, but only the user waives one, and the "
        "acknowledgement has to land in the task bundle where it can be reviewed. "
        f"Add the entry to {sidecar.parent / _SELFMOCK_WAIVER_FILENAME} (keyed on "
        "`file` + `line` + `mutant`, alongside any `signal` entries) and re-run the "
        "detector with `--waivers` pointing there; never hand-edit the sidecar."
    )


def _selfmock_capped_note(mutation: dict, survived: list) -> str:
    """Explain a survivor list the report cap trimmed, or `""` when it did not.

    The cap makes an EMPTY `survived` reachable on a FAIL — every listed mutant
    waived while the trimmed ones still stand — and a bare "[]" is
    indistinguishable from a gate bug. Naming the counts and the way out keeps
    this message actionable like every other block in this gate: the unlisted
    mutants cannot be waived, so the only route through is killing them.
    """
    total = mutation.get("survivedTotal")
    if not isinstance(total, int) or total <= len(survived):
        return ""
    waived = mutation.get("waived")
    waived_count = len(waived) if isinstance(waived, list) else 0
    unlisted = max(total - len(survived) - waived_count, 0)
    return (
        f" The report lists {len(survived)} of {total} surviving mutant(s) found on "
        f"changed lines ({waived_count} waived); {unlisted} were trimmed by the "
        "report cap and cannot be waived — the only way past them is to make the "
        "tests kill them."
    )


def _selfmock_mutation_failure(mutation: object, sidecar: Path) -> str | None:
    """Gate B's contribution to the fail-closed verdict, or `None` when it passes.

    Three outcomes, and only one of them blocks:

    - `FAIL` — a mutant of a changed line went undetected by the stage's own
      suite, so nothing in it constrains that line. Blocks like a static hit.
    - `unsupported(<reason>)` — splits by the reason's CLASS
      (`mutation_probe.classify_reason`). A capability gap (no adapter, no tool
      installed) or a language with nothing to verify is excluded from the
      verdict and kept in the sidecar for audit: that is the normal case in a
      repo without mutation tooling and must never block. An integrity or
      inspection failure BLOCKS — a diff the verifier built wrong, or a report
      the tool left unreadable, means nothing was checked, and passing it as
      "excluded" is the fail-open this gate exists to prevent.
    - `PASS` — nothing to add.

    A block the gate cannot read is itself blocking: the detector writes this
    structure unconditionally, so a missing or misshapen one means the sidecar
    was hand-edited, and reading it as a pass is the fail-open this gate exists
    to prevent.
    """
    if not isinstance(mutation, dict):
        failure = "absent" if mutation is None else "not an object"
        return (
            f"self-mock gate BLOCKING: {sidecar} has a mutation block that is "
            f"{failure}. The detector writes it on every run, so a sidecar "
            "without one was hand-edited; re-run detect_self_mock.py."
        )
    status = mutation.get("status")
    shape = _selfmock_mutation_shape_error(mutation, status)
    if shape:
        return (
            f"self-mock gate BLOCKING: {sidecar} mutation block is malformed "
            f"({shape}). Never hand-edit the sidecar — re-run "
            "detect_self_mock.py so the probe writes it."
        )
    if status.startswith("unsupported("):
        # Task 9 made every `unsupported` non-blocking so that a repo with no
        # mutation tooling could never wedge. That is right for a boundary gate B
        # declared in advance, and wrong for a fault in this run: a `--diff` the
        # verifier built incorrectly, or a report the tool left unreadable, means
        # the stage was never checked at all. The class comes from the one SSOT
        # in `mutation_probe.py`, the same table the cross-language merge reads.
        if classify_reason(status) != INTEGRITY_INSPECTION:
            return None
        return (
            f"self-mock gate BLOCKING: {sidecar} mutation={status!r} — gate B could "
            "not inspect this stage, so its PASS covers the static scan only. This "
            "is a fault in the run's own inputs or output, not a missing tool. Fix "
            "and re-run the detector: write the diff with "
            "`git diff <base>...HEAD > <task_root>/qa/self-mock-<stage-name>.diff` "
            "using the SAME `<base>` the `--changed-file` list came from "
            "(`git diff --name-only <base>...HEAD`), pass every changed file — "
            "production sources included — and confirm the mutation tool wrote a "
            "readable report. Never hand-edit the sidecar."
        )
    if status != "FAIL":
        return None
    survived = mutation.get("survived") or []
    listed = ", ".join(
        f"{s.get('file')}:{s.get('line')} {s.get('mutant')} ({s.get('status')})"
        for s in survived
        if isinstance(s, dict)
    )
    return (
        f"self-mock gate BLOCKING: {sidecar} mutation={status!r} "
        f"tool={mutation.get('tool')!r}, undetected mutants on changed lines: "
        f"[{listed}].{_selfmock_capped_note(mutation, survived)} A mutant that "
        "survives means no test constrains that line — "
        "`Survived` says the test ran the code and asserted nothing about it, "
        "`NoCoverage` says no test reached the code at all. Assert on the real "
        "behaviour instead of on the test's own wiring."
    )


def _selfmock_mutation_shape_error(mutation: dict, status: object) -> str | None:
    """Name the first structural problem with a mutation block, or `None`."""
    if not isinstance(status, str):
        return "`status` is missing or not a string"
    known = status in ("PASS", "FAIL") or (
        status.startswith("unsupported(")
        and status.endswith(")")
        # An empty reason switches gate B off while naming nothing actionable.
        and status[len("unsupported(") : -1].strip()
    )
    if not known:
        return f"`status` {status!r} is outside PASS / FAIL / unsupported(<reason>)"
    for field in ("survived", "waived"):
        if not isinstance(mutation.get(field), list):
            return f"`{field}` is missing or not a list"
    if not isinstance(mutation.get("tool"), (str, type(None))):
        return "`tool` is neither a string nor null"
    return None


def _selfmock_waiver_failure(static: dict, sidecar: Path) -> str | None:
    """The BLOCKING message for an invalid self-mock waiver set, else None.

    A waiver is the false-positive escape hatch, and it is the ONE place the gate
    can be talked out of a finding — so it carries the same shape a conformance
    waiver does (`okstra_ctl/conformance.py::_check_waiver`): a `reason` that
    makes it reviewable and an `acknowledgedBy` that names the USER who accepted
    it. Both are required precisely because the party the finding is about is the
    agent: an entry with no acknowledgement is an agent excusing its own
    self-mock, which is the review this gate exists to force. The detector
    (`validators/detect_self_mock.py`) deliberately carries such an entry through
    instead of dropping it, so it surfaces here rather than vanishing.

    Requiring the fields is not enough on its own, because the run also chooses
    WHERE they are read from: `--waivers /tmp/mine.json` with a forged
    `acknowledgedBy` satisfies every field check and leaves nothing under
    `.okstra/` to review. So a non-empty `waived` must also come from the task's
    own `qa/self-mock-waivers.json`, which puts the acknowledgement in the task
    bundle where it is auditable. An empty `waived` waived nothing, so the source
    is not asked for.
    """
    waived = static.get("waived")
    if waived is None:
        return None
    problems: list[str] = []
    if waived and not _selfmock_source_is_canonical(
        static.get("waiverSource"), sidecar.parent / _SELFMOCK_WAIVER_FILENAME
    ):
        problems.append(
            f"staticDetect.waiverSource must be the task's own "
            f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME}, got "
            f"{static.get('waiverSource')!r}"
        )
    if not isinstance(waived, list):
        problems.append("staticDetect.waived must be an array of waiver objects")
    else:
        for idx, entry in enumerate(waived):
            if not isinstance(entry, dict):
                problems.append(f"staticDetect.waived[{idx}] must be an object")
                continue
            for field in ("reason", "acknowledgedBy"):
                value = entry.get(field)
                if not isinstance(value, str) or not value.strip():
                    problems.append(
                        f"staticDetect.waived[{idx}].{field} must be a non-empty string"
                    )
    if not problems:
        return None
    return (
        f"self-mock gate BLOCKING: {sidecar} carries invalid self-mock waiver(s): "
        + "; ".join(problems)
        + ". A waived detector hit needs a `reason` and the `acknowledgedBy` of the "
        "user who accepted it, read from the task's own `qa/self-mock-waivers.json` "
        "— a verifier may report a suspected false positive, but only the user "
        "waives one, and the acknowledgement has to land in the task bundle where "
        "it can be reviewed. Add the entry to "
        f"{sidecar.parent / _SELFMOCK_WAIVER_FILENAME} and re-run the detector with "
        "`--waivers` pointing there; never hand-edit the sidecar."
    )


def _selfmock_received_files(sidecar_data: dict) -> list[str] | None:
    """Every `--test-file` the detector reports receiving; None if unreadable.

    The union of `scannedFiles` and `skippedFiles`. Coverage is asked of what the
    verifier PASSED, not of what the detector could read, because this gate's
    trigger (`_TEST_FILE_RE`) is extension- and existence-agnostic while the
    detector's scan set is neither: a Go test, a JSON fixture under `tests/`, and
    a test file the stage deleted all trigger the gate and are all legitimately
    skipped. Demanding they appear among the scanned ones would block those runs
    with a recovery command that reproduces the block. A file the verifier never
    passed is in neither list, so the fail-open hole stays closed.
    """
    received: list[str] = []
    for field in ("scannedFiles", "skippedFiles"):
        value = sidecar_data.get(field)
        if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
            return None
        received.extend(value)
    return received


def _selfmock_unreceived(received: list[str], changed: list[str]) -> list[str]:
    """Return the changed test files the sidecar does NOT show the detector receiving.

    An ABSOLUTE `--test-file` argument names the same file as the report's
    repo-relative row, so it is matched on a whole-segment suffix — false-blocking
    a legitimate run over path spelling would be worse than the hole this closes.
    A relative entry must match exactly: in a monorepo,
    `packages/web/src/cart/x.spec.ts` and a repo-root `src/cart/x.spec.ts` are
    different files, and letting the tail overlap count would reopen the hole.
    """
    keys = {selfmock_path_key(entry) for entry in received}
    absolute = {key for key in keys if posixpath.isabs(key)}
    return [
        path
        for path in changed
        if (key := selfmock_path_key(path)) not in keys
        and not any(entry.endswith(f"/{key}") for entry in absolute)
    ]


def _validate_selfmock(report_path: Path, failures: list[str]) -> None:
    """Fail-closed self-mock gate (implementation / final-verification).

    Two gates are folded here. Gate A is the static SUT-stub scan; gate B is the
    mutation probe, which blocks on `mutation.status == "FAIL"` and is excluded
    from the verdict when it reports `unsupported(<reason>)` — a repo with no
    mutation tool installed must not wedge. See `_selfmock_mutation_failure`.

    A diff that changes test files without a detector sidecar means the detector
    never ran, so absence blocks exactly like a FAIL verdict does. A PASS is not
    taken on trust either: every changed test file must appear in the sidecar's
    `scannedFiles` or its `skippedFiles`, because an empty hit list over an empty
    or wrong input reads identically to a clean scan. The check is against that
    union, not against `scannedFiles` alone — see `_selfmock_received_files`. A
    diff whose test-file selection is empty — no test file at all, or only the
    gate's own fixtures — is a vacuous PASS: changed test files are the trigger,
    not the run.
    Stage-isolated runs read their own `self-mock-stage-<N>.json`; whole-task runs
    read the flat `self-mock.json`.

    Only an implementation report carries `implementation.diffSummary.files[]`;
    a final-verification report records the diff as the `diffSummaryQuote`
    string instead, so this gate is vacuous there by design — self-mock is
    enforced at the implementation stage, and final-verification is a read-only
    re-verify that adds no test files.
    """
    changed = _diff_summary_files(_load_final_report_data(report_path))
    test_files = [
        path
        for path in changed
        if _TEST_FILE_RE.search(path)
        and _SELFMOCK_FIXTURE_PREFIX not in path.replace("\\", "/")
    ]
    if not test_files:
        return
    run_dir = report_path.parent.parent
    stage_name = _stage_isolated_name(run_dir)
    name = f"self-mock-{stage_name}.json" if stage_name else "self-mock.json"
    sidecar = _task_root_from_run_dir(run_dir) / "qa" / name
    if not sidecar.is_file():
        failures.append(
            f"self-mock gate BLOCKING: diff changes test file(s) {test_files} but "
            f"the detector sidecar {sidecar} is absent. Run "
            f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
            f"--test-file <path>... --sidecar {sidecar}` over the changed test "
            f"files and record the result."
        )
        return
    try:
        data = json.loads(sidecar.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        failures.append(
            f"self-mock gate BLOCKING: detector sidecar unreadable at {sidecar}: {exc}"
        )
        return
    if not isinstance(data, dict):
        failures.append(
            f"self-mock gate BLOCKING: detector sidecar payload malformed at {sidecar}"
        )
        return
    static = data.get("staticDetect")
    static = static if isinstance(static, dict) else {}
    # Checked ahead of the verdict: a waiver too weak to hold is BLOCKING whether
    # or not anything survived it, and a PASS bought with one is the case the
    # verdict check cannot see.
    waiver_failure = _selfmock_waiver_failure(static, sidecar)
    if waiver_failure:
        failures.append(waiver_failure)
        return
    # Ahead of the `overall` check: a mutation FAIL also makes `overall` FAIL, and
    # the generic branch below would report it as empty static hits — sending the
    # reader to hunt for a stubbed subject that is not there.
    # Ahead of the verdict, for the same reason gate A's waiver check is: a PASS
    # bought with an unacknowledged waiver is exactly what the verdict cannot see.
    mutation_waiver_failure = _selfmock_mutation_waiver_failure(
        data.get("mutation"), sidecar
    )
    if mutation_waiver_failure:
        failures.append(mutation_waiver_failure)
        return
    mutation_failure = _selfmock_mutation_failure(data.get("mutation"), sidecar)
    if mutation_failure:
        failures.append(mutation_failure)
        return
    if data.get("overall") != "PASS":
        failures.append(
            f"self-mock gate BLOCKING: {sidecar} overall={data.get('overall')!r}, "
            f"staticDetect={static.get('status')!r}, hits={static.get('hits') or []}. "
            "Stubbing the unit under test proves only the test's own wiring — "
            "exercise the real method, or stub injected collaborators only."
        )
        return
    received = _selfmock_received_files(data)
    if received is None:
        failures.append(
            f"self-mock gate BLOCKING: {sidecar} carries no usable `scannedFiles` + "
            "`skippedFiles` lists, so its PASS cannot be told apart from a detector "
            "run over an empty or wrong input. Re-run "
            f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
            f"--test-file <path>... --sidecar {sidecar}` — the detector writes both "
            "fields itself; never hand-edit the sidecar."
        )
        return
    unreceived = _selfmock_unreceived(received, test_files)
    if unreceived:
        failures.append(
            f"self-mock gate BLOCKING: changed test file(s) {unreceived} were never "
            f"fed to the detector — {sidecar} recorded neither scanning nor skipping "
            f"them (received={received}). A PASS over a narrower input says nothing "
            "about the files this diff changed; re-run the detector with one "
            "`--test-file` per changed test file. Pass every test file the diff "
            "touched — the detector decides what it can scan, so a deleted file or "
            "one in a language it has no signals for is recorded as skipped and "
            "clears this check; filtering them out yourself is what trips it."
        )
        return
    _check_selfmock_changed_files(data, changed, sidecar, failures)


def _check_selfmock_changed_files(
    data: dict, changed: list[str], sidecar: Path, failures: list[str]
) -> None:
    """Gate B's counterpart to the `scannedFiles` coverage check.

    `--changed-file` is what the mutation adapters select their production
    sources from. Omit it and every adapter gets an empty target set, which is
    not a finding but reads like one had been looked for. Requiring the sidecar
    to account for every file in `implementation.diffSummary.files[]` keeps
    "gate B saw this stage" apart from "gate B was handed nothing".

    Coverage is asked of the WHOLE diff, not just its test files: the production
    sources are precisely the part gate A never looks at.
    """
    declared = data.get("changedFiles")
    if not isinstance(declared, list) or any(
        not isinstance(entry, str) for entry in declared
    ):
        failures.append(
            f"self-mock gate BLOCKING: {sidecar} carries no usable `changedFiles` "
            "list, so the mutation gate cannot be told apart from one that was "
            "handed nothing to mutate. Re-run "
            f"`python3 ~/.okstra/lib/validators/detect_self_mock.py "
            f"--changed-file <path>... --sidecar {sidecar}` passing every file the "
            "diff changed; the detector writes the field itself."
        )
        return
    unfed = _selfmock_unreceived(declared, changed)
    if unfed:
        failures.append(
            f"self-mock gate BLOCKING: changed file(s) {unfed} were never fed to "
            f"the mutation gate — {sidecar} records changedFiles={declared}. Each "
            "adapter picks its production sources out of that set, so a file left "
            "out is a file no mutant was ever generated for. Pass every path from "
            "the report's `implementation.diffSummary.files[]` with `--changed-file`, "
            "production sources "
            "included — filtering to the test files leaves gate B nothing to run "
            "on and reports a pass it never earned."
        )


def _check_incremental_audit_block(
    report_path: Path,
    content: str,
    failures: list[str],
    report_data: Mapping[str, Any] | None = None,
) -> None:
    """Enforce the Section 0 incremental audit block.

    When the data.json records an `implementationPlanning.incrementalDecision`
    with `mode == "incremental"`, the rendered markdown MUST carry the
    `### 0.1 Incremental Re-Verification Scope` block naming the re-verified
    and carried-forward stages — otherwise the narrowed re-run is silently
    un-auditable. Non-incremental (`full`) runs and reports without the
    decision are exempt, so this never conflicts with the empty-Section-0
    stub rule (that fires only when NO carry-in was provided at all).
    """
    data = (
        report_data
        if report_data is not None
        else _load_final_report_data(report_path)
    )
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return
    decision = planning.get("incrementalDecision")
    if not isinstance(decision, dict) or decision.get("mode") != "incremental":
        return
    if _INCREMENTAL_AUDIT_HEADING_RE.search(content) is None:
        failures.append(
            "final report data.json records an `incremental`-mode "
            "implementationPlanning.incrementalDecision, but the markdown is "
            "missing the `### 0.1 Incremental Re-Verification Scope` audit "
            "block under Section 0. Re-render from the data.json so the "
            "re-verified / carried-forward stages are visible."
        )
        return
    for needle in ("Re-verified stages", "Carried-forward stages"):
        if needle not in content:
            failures.append(
                f"final report's Section 0 incremental audit block is missing "
                f"the `{needle}` line — an `incremental`-mode run must list "
                "both the re-verified and the carried-forward stages."
            )


def validate_report(
    report_path: Path,
    required_agent_status_entries: list[str],
    failures: list[str],
    *,
    allow_unavailable_token_usage: bool = False,
    unavailable_ok_labels: frozenset[str] = frozenset(),
    report_data: Mapping[str, Any] | None = None,
    team_state: Mapping[str, Any] | None = None,
) -> None:
    if (report_data or {}).get("schemaVersion") in {"2.0", "3.0"}:
        _validate_v2_report(report_data or {}, failures, team_state=team_state)
        return

    if not report_path.exists():
        failures.append(f"final report is missing: {report_path}")
        return

    content = report_path.read_text()
    for label in required_agent_status_entries:
        if label not in content:
            failures.append(
                f"final report does not include required agent status entry: {label}"
            )

    for placeholder in TOKEN_PLACEHOLDERS:
        if placeholder in content:
            failures.append(
                f"final report contains unsubstituted token placeholder `{placeholder}` — "
                "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
            )

    # Catch the "workers typed `0` / `pending` instead of the placeholder"
    # failure mode that bypasses the placeholder check above.
    _scan_token_usage_summary(
        content,
        failures,
        allow_unavailable_tokens=allow_unavailable_token_usage,
        unavailable_ok_labels=unavailable_ok_labels,
    )

    # Verdict Card is mandatory in every final-report (introduced with the
    # report-format readability pass). Missing card means the reader has no
    # at-a-glance index — first decision lives 100+ lines down.
    if _VERDICT_CARD_HEADING_RE.search(content) is None:
        failures.append(
            "final report is missing the top-of-report `## Verdict Card` block — "
            "render it between the report header and the (conditional) Approval "
            "block. Its Verdict Token / Direction / Next Step cells must byte-match "
            "the corresponding cells in `## 7. Final Verdict` and `## 3.` first item."
        )

    # Top-of-report Index (목차 / Index) is mandatory in every final-report so
    # the reader can jump to any section / tracked ID. Schema task-types get it
    # from render_final_report.py; `improvement-discovery` (authored free-form)
    # gets it from the `okstra-inject-report-index.py` post-step. A missing
    # index anchor means that injection never ran.
    if _REPORT_INDEX_ANCHOR_RE.search(content) is None:
        failures.append(
            "final report is missing the top-of-report Index block "
            '(`## Index` / `## 목차` carrying `<a id="report-index">`). It is '
            "injected by scripts/okstra_ctl/render_final_report.py (schema "
            "task-types) or scripts/okstra-inject-report-index.py "
            "(improvement-discovery); a missing index means that step never ran."
        )

    # Every ID-defining table row (FU-/E-/S-/C-/R-/I-/… in the first cell) must
    # carry a scroll anchor so the Index can link to it. A row still matching
    # the bare-leading-token shape is one the injector never anchored.
    for match in _UNANCHORED_ID_ROW_RE.finditer(content):
        failures.append(
            f"final report has an ID-defining table row for `{match.group(1)}` "
            'without a scroll anchor (`<a id="…">`). IDs must be anchored so the '
            "top-of-report Index can link to them — run the index injector "
            "(render_final_report.py / okstra-inject-report-index.py) instead of "
            "hand-editing."
        )

    # Reading Confirmation belongs in the worker audit sidecar, not the
    # user-facing final-report.
    if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
        failures.append(
            "final report contains a `## 0. Reading Confirmation` heading — "
            "Reading Confirmation lives in the worker audit sidecar "
            "(`runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md`), "
            "never in the final-report."
        )

    # Empty Section 0 stub — when no carry-in path was provided, the
    # writer must OMIT the `## 0.` heading entirely.
    if _EMPTY_CARRY_IN_RE.search(content) is not None or _EMPTY_CARRY_IN_SOURCE_RE.search(
        content
    ) is not None:
        failures.append(
            "final report has an empty `## 0. Clarification Response Carried In "
            "From Previous Run` stub (either the `Source file:` cell is empty or "
            "the body contains `No prior clarification response was provided`). "
            "When no carry-in path was provided, OMIT the entire `## 0.` heading "
            "and body — do NOT emit a placeholder stub."
        )

    # Incremental audit block — an `incremental`-mode re-run must expose its
    # scope decision in Section 0 so the narrowed re-verification is auditable.
    _check_incremental_audit_block(
        report_path,
        content,
        failures,
        report_data=report_data,
    )

    # Deprecated section headings — pre-1.0 hard removal.
    for pattern, remedy in _DEPRECATED_FINAL_REPORT_PATTERNS:
        if pattern.search(content) is not None:
            failures.append(f"final report contains {remedy}")


_REPORT_BASENAME_SEQ_RE = re.compile(r"-(?P<seq>\d{3})(?:\.data)?\.(?:md|json)$")
_REPORT_BASENAME_TASK_TYPE_RE = re.compile(
    r"^final-report-(?P<task_type>[a-z][a-z-]*?)-\d{3}(?:\.data)?\.(?:md|json)$"
)


def _report_run_seq(report_path: Path) -> str | None:
    """This run's seq, read off `final-report-<task-type>-<seq>.md`. ``None``
    when the name does not carry one, so callers fall back to not filtering
    rather than silently checking nothing."""
    match = _REPORT_BASENAME_SEQ_RE.search(report_path.name)
    return match.group("seq") if match else None


def _report_task_type(report_path: Path) -> str:
    """This run's task type, read off `final-report-<task-type>-<seq>`.

    The report's own `header.taskType` is the first source, but it is not
    always reachable. `report_narrative._allowed_top_level()` has no `header` —
    it is not a writer-owned block — so a gate scored from a narrative, which is
    the only input a report-contract-3.0 run has before assembly, carries no
    task type at all. Globbing with an empty one matched nothing and reported
    every verdict as unbacked under a `runs//worker-results/` path.

    The filename carries it in every caller: the full-run path passes the report
    itself, and `_report_path_for_state` builds the same canonical name from the
    state file. Returns `""` when the name does not carry one, so a caller can
    tell "not resolvable" from a real task type.
    """
    match = _REPORT_BASENAME_TASK_TYPE_RE.match(report_path.name)
    return match.group("task_type") if match else ""


def validate_worker_results_audit(
    report_path: Path,
    task_type: str,
    failures: list[str],
    advisories: list[str] | None = None,
) -> None:
    """Enforce the worker audit sidecar contract at Phase 7.

    The rules themselves live in `okstra_ctl.worker_audit_ledger` so that
    `okstra worker-audit-check` can apply the identical checks mid-run, while
    the worker session is still alive and can fix its own citations. This
    wrapper adds the Phase 7 anchor — the run directory and this run's seq, both
    read off the report path — and routes the citation-ledger findings to
    *advisories*, which do not fail the run. Mid-run the worker can still fix a
    citation; at Phase 7 the only remedy left is to void a finished run over a
    spelling, so the finding is reported instead.
    """
    blocking, ledger = worker_results_audit_findings(
        # `report_path` is `runs/<task-type>/reports/final-report-...md`.
        report_path.parent.parent,
        task_type,
        _report_run_seq(report_path),
    )
    failures.extend(blocking)
    (advisories if advisories is not None else failures).extend(ledger)


def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
    if _session_accounting(team_state) == "artifact-only":
        return
    summary = team_state.get("usageSummary") or {}
    if not summary or not summary.get("collectedAt"):
        failures.append(
            "team-state.usageSummary is empty — Phase 7 token-usage collection was skipped. "
            "Run `okstra token-usage <team-state> --write --summary "
            "--substitute-data <final-report>`."
        )
        return
    # Reject zero-valued usage when the collector flagged any source as
    # `unavailable`. This catches the silent-failure mode where the
    # collector ran but couldn't locate session jsonls (e.g. empty
    # claudeSession.sessionId, missing subagent jsonl).
    grand_total = summary.get("grandTotalTokens", 0)
    if isinstance(grand_total, (int, float)) and grand_total == 0:
        lead = team_state.get("leadUsage") or {}
        if lead.get("source") == "unavailable":
            failures.append(
                "team-state.usageSummary.grandTotalTokens is 0 and leadUsage.source is "
                f"`unavailable` — {lead.get('note', 'reason unknown')}. Re-collect once "
                "the lead session jsonl is locatable."
            )
        for worker in team_state.get("workers") or []:
            role = (worker or {}).get("role") or (worker or {}).get("workerId") or "<worker>"
            usage = (worker or {}).get("usage") or {}
            if usage.get("source") == "unavailable":
                failures.append(
                    f"team-state.workers[{role}].usage.source is `unavailable` while "
                    f"grandTotalTokens is 0 — {usage.get('note', 'reason unknown')}."
                )


# Verdict Card Verdict Token row (top-of-report at-a-glance). Same shape
# as `_FINAL_VERDICT_TOKEN_RE` but matched against the first occurrence in
# the Verdict Card block, scoped to the body between `## Verdict Card`
# heading and the next `##` heading.
_VERDICT_CARD_BLOCK_RE = re.compile(
    r"^##[ \t]+Verdict Card" + _HEADING_TAIL
    + r"\n(?P<body>.*?)(?=^##[ \t]|\Z)",
    re.DOTALL | re.MULTILINE,
)

PLAN_VERIFY_GATE_VALUES = (
    "passed",
    "passed-with-dissent",
    "blocked-by-disagreement",
    "aborted-non-result",
)

# Tolerate a leading UTF-8 BOM and/or blank lines before the opening `---`
# so a hand-edited or differently-rendered report does not silently bypass
# the approved-frontmatter gate (the `.match` would otherwise return None).
_FRONTMATTER_BLOCK_RE = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
    """Verdict Card의 정본 표시 값이 §7의 값과 같은지 모두 검사한다."""
    card = data.get("verdictCard")
    final = data.get("finalVerdict")
    if not isinstance(card, dict) or not isinstance(final, dict):
        return
    for field in ("finalConclusion", "direction", "nextStep"):
        card_value = str(card.get(field) or "").strip()
        final_value = str(final.get(field) or "").strip()
        if not card_value or not final_value or card_value == final_value:
            continue
        failures.append(
            f"final-report data.json: verdictCard.{field} value `{card_value}` "
            f"does not match finalVerdict.{field} value `{final_value}` — the "
            "Card is a non-authoritative index and must preserve the §7 value."
        )


def _route_target_matches(value: Any, target: str, *, command: bool) -> bool:
    if not isinstance(value, str):
        return False
    if not command:
        pattern = rf"(?<![\w-]){re.escape(target)}(?![\w-])"
        return re.search(pattern, value) is not None
    try:
        tokens = shlex.split(value)
    except ValueError:
        return False
    task_type_values: list[str] = []
    for index, token in enumerate(tokens):
        if token in {"task-type", "--task-type"}:
            task_type_values.append(
                tokens[index + 1] if index + 1 < len(tokens) else ""
            )
        for prefix in ("task-type=", "--task-type="):
            if token.startswith(prefix):
                task_type_values.append(token[len(prefix):])
    return bool(task_type_values) and all(
        task_type_value == target for task_type_value in task_type_values
    )


def _upstream_by_candidate(candidates: list[Any]) -> dict[str, list[str]]:
    upstream: dict[str, list[str]] = {}
    for candidate in candidates:
        if not isinstance(candidate, Mapping):
            continue
        candidate_id = candidate.get("id")
        declared = candidate.get("downstreamOf")
        if isinstance(candidate_id, str) and isinstance(declared, list):
            upstream[candidate_id] = [row for row in declared if isinstance(row, str)]
    return upstream


def _chain_cycle(upstream: dict[str, list[str]]) -> list[str]:
    """The first cycle reachable through `downstreamOf`, as the ids that form it.

    A cycle is a diagnosis that says each step is caused by the next, so it
    names no first cause. It also hangs the figure's layering, which relaxes
    until depths settle.
    """
    settled: set[str] = set()
    for start in sorted(upstream):
        stack = [start]
        on_path: list[str] = []
        while stack:
            current = stack.pop()
            if current in on_path:
                return on_path[on_path.index(current):] + [current]
            if current in settled or current not in upstream:
                continue
            on_path.append(current)
            stack.extend(upstream[current])
        settled.update(on_path)
    return []


def _validate_cause_chain(
    candidates: list[Any], candidate_ids: set[str], failures: list[str]
) -> None:
    """`downstreamOf` must name a sibling candidate, and never itself."""
    upstream = _upstream_by_candidate(candidates)
    for candidate_id in sorted(upstream):
        unknown = sorted(set(upstream[candidate_id]) - candidate_ids)
        if unknown:
            failures.append(
                f"final-report data.json: {candidate_id}.downstreamOf names "
                "unknown cause candidate(s): " + ", ".join(unknown) + "."
            )
        if candidate_id in upstream[candidate_id]:
            failures.append(
                f"final-report data.json: {candidate_id}.downstreamOf names itself."
            )
    cycle = _chain_cycle(
        {key: [row for row in value if row in candidate_ids] for key, value in upstream.items()}
    )
    if cycle:
        failures.append(
            "final-report data.json: cause candidates form a downstreamOf cycle: "
            + " -> ".join(cycle)
            + "."
        )


def _validate_error_analysis_consistency(
    data: Mapping[str, Any], failures: list[str]
) -> None:
    error_analysis_value = data.get("errorAnalysis")
    error_analysis = (
        error_analysis_value if isinstance(error_analysis_value, Mapping) else {}
    )
    reproduction_value = error_analysis.get("reproduction")
    reproduction = (
        reproduction_value if isinstance(reproduction_value, Mapping) else {}
    )
    reproduction_status = reproduction.get("status")
    blocked_reason = reproduction.get("blockedReason")
    if reproduction_status == "blocked-before-repro":
        if not isinstance(blocked_reason, str) or not blocked_reason.strip():
            failures.append(
                "final-report data.json: blocked-before-repro requires a non-empty "
                "errorAnalysis.reproduction.blockedReason."
            )
    elif blocked_reason != "":
        failures.append(
            "final-report data.json: errorAnalysis.reproduction.blockedReason "
            "must be exactly empty unless status is blocked-before-repro."
        )

    candidates_value = error_analysis.get("causeCandidates")
    candidates = candidates_value if isinstance(candidates_value, list) else []
    candidate_ids: list[str] = []
    for candidate in candidates:
        if not isinstance(candidate, Mapping):
            continue
        candidate_id = candidate.get("id")
        if isinstance(candidate_id, str):
            candidate_ids.append(candidate_id)
    duplicate_ids = sorted(
        candidate_id
        for candidate_id in set(candidate_ids)
        if candidate_ids.count(candidate_id) > 1
    )
    if duplicate_ids:
        failures.append(
            "final-report data.json: duplicate cause candidate id(s): "
            + ", ".join(duplicate_ids)
            + "."
        )

    _validate_cause_chain(candidates, set(candidate_ids), failures)

    routing_value = error_analysis.get("routing")
    routing = routing_value if isinstance(routing_value, Mapping) else {}
    target = routing.get("nextTaskType")
    leading_cause_id = routing.get("leadingCauseId")
    candidate_id_set = set(candidate_ids)
    if isinstance(target, str) and target not in ERROR_ANALYSIS_ROUTING_DIRECTIONS:
        failures.append(
            "final-report data.json: errorAnalysis.routing has unsupported "
            f"routing target `{target}`."
        )
    if target == "implementation-option-selection":
        if not candidates:
            failures.append(
                "final-report data.json: implementation-option-selection routing requires "
                "at least one cause candidate."
            )
        if (
            not isinstance(leading_cause_id, str)
            or leading_cause_id not in candidate_id_set
        ):
            failures.append(
                "final-report data.json: implementation-option-selection routing "
                "leadingCauseId must reference a cause candidate."
            )
    elif target == "error-analysis" and (
        not isinstance(leading_cause_id, str)
        or (leading_cause_id != "" and leading_cause_id not in candidate_id_set)
    ):
        failures.append(
            "final-report data.json: error-analysis routing leadingCauseId must be "
            "empty or reference a cause candidate."
        )

    expected_direction = ERROR_ANALYSIS_ROUTING_DIRECTIONS.get(target)
    verdict_card_value = data.get("verdictCard")
    verdict_card = (
        verdict_card_value if isinstance(verdict_card_value, Mapping) else {}
    )
    final_verdict_value = data.get("finalVerdict")
    final_verdict = (
        final_verdict_value if isinstance(final_verdict_value, Mapping) else {}
    )
    if expected_direction:
        for field_name, verdict in (
            ("verdictCard", verdict_card),
            ("finalVerdict", final_verdict),
        ):
            if verdict.get("direction") != expected_direction:
                failures.append(
                    f"final-report data.json: {field_name}.direction must be "
                    f"`{expected_direction}` for `{target}` routing."
                )

    follow_up_tasks_value = data.get("followUpTasks")
    follow_up_tasks = (
        follow_up_tasks_value if isinstance(follow_up_tasks_value, list) else []
    )
    continuations = [
        row
        for row in follow_up_tasks
        if isinstance(row, Mapping) and row.get("origin") == "phase-continuation"
    ]
    if len(continuations) != 1:
        failures.append(
            "final-report data.json: followUpTasks must contain exactly one "
            "phase-continuation row."
        )
    else:
        continuation = continuations[0]
        frontmatter_value = data.get("frontmatter")
        frontmatter = (
            frontmatter_value if isinstance(frontmatter_value, Mapping) else {}
        )
        task_id = frontmatter.get("taskId")
        if continuation.get("suggestedTaskType") != target:
            failures.append(
                "final-report data.json: phase-continuation suggestedTaskType "
                "must match the routing target."
            )
        if continuation.get("newTaskId") != task_id:
            failures.append(
                "final-report data.json: phase-continuation newTaskId must match "
                "frontmatter.taskId."
            )
        if continuation.get("priority") != "P0":
            failures.append(
                "final-report data.json: phase-continuation priority must be P0."
            )
        if continuation.get("autoSpawn") != "no":
            failures.append(
                "final-report data.json: phase-continuation autoSpawn must be no."
            )

    if isinstance(target, str) and target in {
        "error-analysis",
        "implementation-option-selection",
    }:
        for field_name, value in (
            ("verdictCard.nextStep", verdict_card.get("nextStep")),
            ("finalVerdict.nextStep", final_verdict.get("nextStep")),
        ):
            if not _route_target_matches(value, target, command=False):
                failures.append(
                    f"final-report data.json: {field_name} must contain routing "
                    f"target `{target}`."
                )

        next_steps_value = data.get("recommendedNextSteps")
        next_steps = next_steps_value if isinstance(next_steps_value, list) else []
        first_step = (
            next_steps[0]
            if next_steps and isinstance(next_steps[0], Mapping)
            else {}
        )
        step_text = first_step.get("text")
        if not _route_target_matches(step_text, target, command=False):
            failures.append(
                "final-report data.json: recommendedNextSteps[0].text must contain "
                f"routing target `{target}`."
            )
        commands_value = first_step.get("commands")
        commands = commands_value if isinstance(commands_value, list) else []
        if not commands:
            failures.append(
                "final-report data.json: recommendedNextSteps[0].commands must "
                "contain at least one command."
            )
        for index, command_value in enumerate(commands):
            command = command_value if isinstance(command_value, Mapping) else {}
            for command_field in ("claudeCode", "terminal"):
                value = command.get(command_field)
                if not _route_target_matches(value, target, command=True):
                    failures.append(
                        "final-report data.json: recommendedNextSteps[0].commands"
                        f"[{index}].{command_field} must contain routing target "
                        f"`{target}`."
                    )


def _load_final_report_data(report_path: Path) -> dict:
    """Best-effort parse of the final-report data.json sibling. Returns {} when
    absent or unparseable — those conditions are already surfaced as failures by
    validate_final_report_data; this loader only feeds cross-field checks."""
    data_path = _data_path_for(report_path)
    if not data_path.is_file():
        return {}
    try:
        return json.loads(data_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}


def validate_final_report_data(
    report_path: Path,
    failures: list[str],
    *,
    report_contracts: set[str] | None = None,
    run_manifest: dict | None = None,
    project_root: Path | None = None,
    clarification_text: str = "",
) -> Mapping[str, Any] | None:
    """Validate final-report data.json against its declared versioned schema.

    The data.json is the source-of-truth that the renderer reads to
    produce the markdown. If schema validation passes here, the rendered
    markdown is guaranteed to contain every section / row the contract
    requires (the template loops over the data), so the schema is the
    only place the deliverable contract is enforced.

    Missing data.json is reported as a single failure rather than a
    cascade of substring failures — that points the writer at the right
    fix (write the data.json) instead of futilely editing the markdown.
    The returned mapping is the exact loaded snapshot consumed by later
    finalization checks and workflow persistence.
    """
    if schema_validate is None or load_schema_for_data is None:
        # Module-load fallback path; should never fire in a real install.
        failures.append(
            "validate-run: okstra_ctl.final_report_schema is not importable — "
            "install may be incomplete (scripts/ not on PYTHONPATH)."
        )
        return

    data_path = _data_path_for(report_path)
    if not data_path.is_file():
        failures.append(
            f"final-report data.json is missing at {data_path} — the renderer "
            "needs this file as its single source of truth. The markdown "
            "alone is no longer a valid run artifact."
        )
        return

    try:
        data = json.loads(data_path.read_text(encoding="utf-8"))
        schema = load_schema_for_data(data)
    except SchemaError as exc:
        failures.append(f"final-report schema could not be loaded: {exc}")
        return
    except json.JSONDecodeError as exc:
        failures.append(f"final-report data.json is not valid JSON: {exc}")
        return

    errors = schema_validate(data, schema)
    for err in errors:
        failures.append(f"final-report data.json: {err}")
    # 스키마 실패가 계약 스캔을 가리지 않는다. 빈 decisionRefs 가
    # minItems 에서 막히면 종료상태 표 누락이 안 보였다.

    manifest = run_manifest or {}
    if data.get("executionIdentityVersion") == 2 or data.get("executionRoles"):
        try:
            from okstra_ctl.report_contract import assert_execution_roles_match_manifest

            assert_execution_roles_match_manifest(data, manifest)
        except ValueError as exc:
            failures.append(str(exc))
    _validate_approval_context(data, manifest, failures, report_path)
    _validate_activity_contract_plan_limits(data, manifest, failures)

    analysis_result = validate_analysis_report(
        data=data,
        report_path=report_path,
        project_root=project_root or report_path.parent,
        run_manifest=manifest,
        clarification_text=clarification_text,
    )
    manifest_task_type = str((run_manifest or {}).get("taskType") or "")
    task_type = manifest_task_type or str(
        (data.get("header") or {}).get("taskType") or ""
    )
    failures.extend(f"{task_type}: {error}" for error in analysis_result.errors)

    _validate_rationale_evidence(data, failures)
    _validate_no_opaque_id_references(data, failures)
    _validate_verdict_card_fields(data, failures)
    # Phase-agnostic: the coverage critic runs in every finding-producing phase.
    _validate_unverified_critic_gaps_recorded(data, failures)
    # Called here rather than from a task-type branch: four profiles raise
    # clarification rows, and the gate scopes itself by task type internally.
    _validate_clarification_options(data, failures)
    _validate_clarification_record_coordinates(data, failures)
    _validate_open_approval_blocker_provenance(data, failures)

    task_type = (data.get("header") or {}).get("taskType")
    _validate_verifier_fail_blocks_verdict(data, failures)
    if task_type == "implementation-option-selection":
        selection = data.get("implementationOptionSelection") or {}
        validation_root = project_root or report_path.parent
        original_ids = brief_end_state_id_sequence(
            _brief_path_from_manifest(manifest, validation_root)
        )
        roster = manifest.get("recommendedWorkers") or ()
        participating_analysers = tuple(
            worker for worker in roster if worker != "report-writer"
        )
        failures.extend(
            f"implementation-option-selection: {error}"
            for error in validate_implementation_option_selection(
                selection,
                original_ids,
                participating_analysers,
            )
        )
    elif task_type == "implementation":
        _validate_stage_carry_sidecar_exists(data, report_path, failures)
    _validate_lead_authored_report(data, report_path, failures)
    if task_type == "error-analysis":
        _validate_error_analysis_consistency(data, failures)
    elif task_type == "final-verification":
        _validate_final_verification_consistency(data, failures)
        _validate_verified_row_recorded(data, report_path, failures)
    elif task_type == "implementation-planning":
        active_report_contracts = report_contracts or set()
        planning = data.get("implementationPlanning") or {}
        selected_direction_contract = (
            isinstance(planning, Mapping)
            and planning.get("planningContract") == "selected-direction"
        )
        if selected_direction_contract:
            validation_root = project_root or report_path.parent
            try:
                brief_path = _brief_path_from_manifest(manifest, validation_root)
            except (OSError, ValueError) as exc:
                failures.append(
                    "implementation-planning selected-direction: run manifest "
                    f"taskBriefPath is malformed: {exc}"
                )
                brief_path = validation_root / "__invalid-brief__"
            task_root = _task_root_from_run_dir(report_path.parent.parent)
            snapshot_path = task_root / "instruction-set" / "selected-direction.json"
            failures.extend(
                f"implementation-planning selected-direction: {error}"
                for error in validate_selected_direction_plan(
                    data, brief_path, snapshot_path
                )
            )
            if planning.get("outcome") == "direction-invalidated":
                return data
        _validate_implementation_planning_cross_project(data, failures)
        _validate_implementation_planning_decision_drafts(data, failures)
        for warning in validate_plan_body_section(data, report_path, failures):
            print(f"validate-run: warning: {warning}", file=sys.stderr)
        for warning in _detect_unmapped_incremental_fallback(data, report_path):
            print(f"validate-run: warning: {warning}", file=sys.stderr)
        for warning in _detect_missing_dependency_precondition(
            data, _project_root_from_report(report_path)
        ):
            print(f"validate-run: warning: {warning}", file=sys.stderr)
        carried = _carried_decision_map(
            manifest, project_root=project_root, report_path=report_path
        )
        _validate_supersession_ledger(data, failures)
        _validate_clarification_evidence_note(data, failures, carried=carried)
        _validate_approval_clarification_backtrace(data, failures)
        _validate_rerun_guidance(data, failures)
        _validate_approval_guidance(data, failures)
        _validate_variation_point_analysis(
            (data.get("implementationPlanning") or {}).get("variationPointAnalysis"),
            resolve_architecture(_project_root_from_report(report_path)),
            failures,
        )
        if not selected_direction_contract:
            _validate_requirement_deviations(
                data,
                failures,
                carried=carried,
            )
            _validate_requirement_coverage_covered_by(data, failures)
        warnings = _validate_design_prep_contract(
            data,
            report_path,
            active_report_contracts,
            failures,
        )
        for warning in warnings:
            print(f"validate-run: warning: {warning}", file=sys.stderr)

    return data


# path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
# namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
# satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
# handled separately by `_validate_no_opaque_id_references`.
_PATH_LINE_EVIDENCE_TOKEN = re.compile(r"[\w./-]+\.\w+:\d+")
_SECTION_EVIDENCE_TOKEN = re.compile(r"§\s*\d")
_IN_REPORT_ID_TOKEN = re.compile(r"\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b")
_NAMESPACED_EVIDENCE_TOKEN = re.compile(
    r"(?<![\w-])[a-z][a-z0-9-]*:[A-Z]{1,3}(?:-[a-z]+)?-\d+(?![\w-])"
)
# Explicit "I don't know" escapes — anti-fabrication's other valid answer.
_INSUFFICIENCY_MARKERS = (
    "근거 불충분", "근거가 불충분", "증거 불충분", "증거가 불충분",
    "증거 없음", "증거가 없", "모름", "알 수 없", "확인 불가",
    "i don't know", "unknown", "insufficient evidence", "no evidence",
)
_RATIONALE_FIELDS = ("motivation", "problem", "approach", "justification")


def _report_reference_ids(data: Mapping[str, Any]) -> set[str]:
    references: set[str] = set()

    def visit(value: Any, key: str = "") -> None:
        if isinstance(value, Mapping):
            for child_key, child_value in value.items():
                visit(child_value, str(child_key))
            return
        if isinstance(value, list):
            for child in value:
                visit(child, key)
            return
        if not isinstance(value, str):
            return
        if (key == "id" or key.endswith("Id")) and _IN_REPORT_ID_TOKEN.fullmatch(value):
            references.add(value)
        if key == "sourceItems":
            references.update(_NAMESPACED_EVIDENCE_TOKEN.findall(value))

    visit(data)
    return references


def _rationale_reference_status(
    text: str,
    known_references: set[str],
) -> tuple[bool, tuple[str, ...]]:
    if _PATH_LINE_EVIDENCE_TOKEN.search(text) or _SECTION_EVIDENCE_TOKEN.search(text):
        return True, ()
    namespaced = tuple(_NAMESPACED_EVIDENCE_TOKEN.findall(text))
    without_namespaced = _NAMESPACED_EVIDENCE_TOKEN.sub("", text)
    report_ids = tuple(_IN_REPORT_ID_TOKEN.findall(without_namespaced))
    candidates = (*namespaced, *report_ids)
    if any(candidate in known_references for candidate in candidates):
        return True, ()
    return False, tuple(dict.fromkeys(candidates))


def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
    """Every `## 작업 배경과 근거` field must anchor its claim: carry at least
    one evidence reference (path:line, report ID, §section) OR an explicit
    insufficiency marker. Neither present → unverifiable narrative, which is
    the fabrication the section exists to prevent. Schema guarantees the
    fields are present and non-empty; this enforces they are *grounded*."""
    rationale = data.get("rationale")
    if not isinstance(rationale, dict):
        return  # absence/shape is the schema's job; don't double-report.
    known_references = _report_reference_ids(data)
    for field in _RATIONALE_FIELDS:
        text = rationale.get(field)
        if not isinstance(text, str):
            continue
        grounded, unknown_references = _rationale_reference_status(
            text,
            known_references,
        )
        if grounded:
            continue
        if any(m in text.lower() for m in _INSUFFICIENCY_MARKERS):
            continue
        if unknown_references:
            failures.append(
                f"final-report data.json: rationale.{field} cites unknown "
                "in-report reference(s): " + ", ".join(unknown_references) + "."
            )
            continue
        failures.append(
            f"final-report data.json: rationale.{field} cites no evidence "
            f"(expected a path:line, an in-report ID like C-001, or a §"
            f"section reference) and gives no explicit insufficiency marker "
            f"(e.g. '근거 불충분'). Anchor the claim or state what is unknown."
        )


# Brief/worker-internal ID families that are NOT surfaced (anchored) in the
# final report: `RC-` reporter confirmations live in the brief, `F-`/`RF-`
# findings live in worker-results. A reader-facing sentence citing a bare one
# of these has no in-report target to resolve — the reader is stuck. Namespaced
# audit citations (`claude:F-005`) are exempt via the `:` negative lookbehind.
_OPAQUE_ID_RE = re.compile(r"(?<![:\w-])(?:RC|RF|F)-\d+(?![\w-])")


def _reader_facing_narrative(data: dict) -> list[tuple[str, str]]:
    """(label, text) for every reader-facing narrative string in the report —
    the fields a human reads to act, where an unresolvable ID is most harmful."""
    out: list[tuple[str, str]] = []
    for section in ("verdictCard", "finalVerdict"):
        block = data.get(section)
        if isinstance(block, dict):
            for field in ("finalConclusion", "nextStep"):
                value = block.get(field)
                if isinstance(value, str):
                    out.append((f"{section}.{field}", value))
    rationale = data.get("rationale")
    if isinstance(rationale, dict):
        for field in _RATIONALE_FIELDS:
            value = rationale.get(field)
            if isinstance(value, str):
                out.append((f"rationale.{field}", value))
    for row in data.get("clarificationItems") or []:
        if isinstance(row, dict) and isinstance(row.get("statement"), str):
            out.append((f"clarificationItems[{row.get('id') or '?'}].statement", row["statement"]))
    return out


def _validate_no_opaque_id_references(data: dict, failures: list[str]) -> None:
    """Reader-facing prose must not cite a bare brief/worker-internal ID
    (`RC-*`, `RF-*`, non-namespaced `F-*`) that the report never surfaces — the
    reader has nothing to click and no definition to find. Expand it inline
    (`the confirmed version target 1.27.47→1.27.48`) or namespace an audit
    citation (`claude:F-005`)."""
    for label, text in _reader_facing_narrative(data):
        offenders = sorted({m.group(0) for m in _OPAQUE_ID_RE.finditer(text)})
        if offenders:
            failures.append(
                f"final-report data.json: {label} cites brief/worker-internal "
                f"ID(s) {', '.join(offenders)} that this report never surfaces "
                "(no anchor to resolve them to). Expand them to plain language "
                "on the reference, or namespace an audit citation like "
                "`claude:F-005` — a reader cannot resolve a bare RC-/RF-/F- token."
            )


def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
    """타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
    `crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이
    반드시 있어야 한다 — 실제 선행 필수 의존이 soft `recommendedNextSteps`
    추천으로 새어나가지 못하게 강제한다. 각 XP 행의 필드 비-빈은 스키마가
    보장하고, 이 검사는 cross-project DM 신호가 있을 때 XP 행이 *존재*하는지를 본다.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    dm_rows = ip.get("dependencyMigrationRisk") or []
    has_cross_project_dm = any(
        isinstance(r, dict) and r.get("kind") == "cross-project" for r in dm_rows
    )
    if not has_cross_project_dm:
        return
    xp_rows = ip.get("crossProjectDependencies") or []
    has_upstream = any(
        isinstance(r, dict) and r.get("direction") == "upstream-precondition"
        for r in xp_rows
    )
    if not has_upstream:
        failures.append(
            "final-report data.json: a dependencyMigrationRisk row has "
            "kind='cross-project' but crossProjectDependencies carries no "
            "direction='upstream-precondition' entry. Record the cross-project "
            "dependency as a mandatory precondition (concrete requiredWork / "
            "verificationSignal / howToStart) — not a soft Recommended Next Step."
        )


def _validate_implementation_planning_decision_drafts(data: dict, failures: list[str]) -> None:
    """`decisionDrafts` 가 비어있지 않으면 어느 stage 의 stepwiseExecution step 이
    `.okstra/decisions/` 파일을 생성하는 materialization step 을 포함해야 한다.
    프로파일 Decision-record evaluation 의 "approved plan stepwise MUST include
    Create .okstra/decisions/<NNNN>-<slug>.md" 를 실제 강제한다. draft 존재 자체가
    trigger 이므로 self-contained 하다.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    if not (ip.get("decisionDrafts") or []):
        return
    has_materialization = any(
        ".okstra/decisions/" in (step.get("files") or "")
        or ".okstra/decisions/" in (step.get("action") or "")
        for stage in (ip.get("stages") or [])
        if isinstance(stage, dict)
        for step in (stage.get("stepwiseExecution") or [])
        if isinstance(step, dict)
    )
    if not has_materialization:
        failures.append(
            "final-report data.json: implementationPlanning.decisionDrafts is "
            "non-empty but no stage's stepwiseExecution creates a "
            "`.okstra/decisions/<NNNN>-<slug>.md` file. The approved plan must "
            "materialize each decision draft via a stepwise step (profile "
            "Decision-record evaluation)."
        )


# Plan-body gate outcomes ranked by how favorable each is to approval.
# A higher rank claims a healthier verification result. The recompute check
# below fails only when the *declared* gate outranks what the recorded
# per-worker verdicts support — i.e. the lead claimed a better outcome than
# the votes justify. A lead writing a conservatively *worse* gate is allowed,
# so genuine edge cases in this recompute never manufacture false failures.
_PLAN_GATE_RANK = {
    "aborted-non-result": 0,
    "blocked-by-disagreement": 0,
    "passed-with-dissent": 1,
    "passed": 2,
}

# Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
# needed), because the defect is concrete, safety-critical, and adversarially
# verifiable: `a` = cited path/symbol mismatch. `b`/`c`/`e` still need a
# majority — `b` in particular is prone to planning-vs-implementation
# environment false positives.
_SINGLE_VOTE_BLOCKING_KINDS = {"a"}

# Rollback ordering (`d`) is executed by a human, not by okstra's workers or
# verifiers, so a rollback-ordering dissent is recorded but never gates
# approval: it is dropped from the blocking-disagree tally entirely, so an item
# whose only DISAGREEs are advisory-only can never rise above `has-dissent`.
_ADVISORY_ONLY_KINDS = {"d"}

# Stop reasons that justify promoting a still-broken planner-fixable item to the
# user: the self-fix budget ran out, or a round produced no net resolution so
# further rounds would repeat themselves.
#
# `cause-group-recurrence` is the legacy spelling of that same exhaustion
# (plan-body-verification.md "Loop termination"). A pre-activity-contract report
# carrying it is a report whose loop stopped because the cause kept recurring —
# refusing it here left such a run with no exit at all: the loop may not run
# again, and the surviving item may not be promoted either.
# `_validate_activity_contract_plan_limits` still forbids a NEW v1 run from
# emitting it, so this admits the legacy shape without reopening it.
_SELF_FIX_EXHAUSTED_REASONS = frozenset(
    {"max-rounds-reached", "no-progress", "cause-group-recurrence"}
)


def _is_variation_point_item(item: dict) -> bool:
    """Whether this is a `P-Var-*` variation-point item, which is majority-gated
    (`prompts/lead/plan-body-verification.md` "`P-Var-<N>` … is majority-gated").
    Whether a behavior has two implementations, and whether the plan extracted the
    right interface for it, is a design judgement — it lacks the concrete certainty
    of kind `a`, where a verifier points at two spelled-out references that
    contradict each other. So kind `a` carries no extra weight on a P-Var item: it
    neither single-vote-blocks nor counts as correctness-critical, exactly like the
    `b` / `c` / `e` kinds the prompt routes P-Var defects to. Only a
    `majority-disagree` gates it — that part is unchanged.
    """
    return str(item.get("id") or "").upper().startswith("P-VAR")


def _single_vote_dissents(item: dict, kinds: set[str]) -> list[dict]:
    """이 항목에서 1표 차단을 주장하는 DISAGREE 행들."""
    return [
        row for row in (item.get("verdicts") or [])
        if isinstance(row, dict)
        and str(row.get("verdict") or "").strip().upper() == "DISAGREE"
        and str(row.get("breakageKind") or "").strip().lower() in kinds
    ]


def _single_vote_block_survives(item: dict, kinds: set[str]) -> bool:
    """1표 차단이 성립하는지.

    1표 차단에는 근거가 있다 — 명시된 두 인용이 서로 모순이라는 것은 한 명이
    실측으로 확정할 수 있는 사실이고, 사실을 다수결로 기각하면 안 된다. 문제는
    1표라는 것이 아니라 **1표에 재현 요구가 없었다**는 것이다. "이 경로는 존재하지
    않는다" 라고 쓰기만 하면 그대로 차단이 됐다.

    이제 주장이 스스로 `fact` 를 선언하고 okstra 가 그것을 재현했을 때만 1표로
    막는다. 선언했는데 재현되지 않았거나 `judgement` 였다면 정족수로 내려간다.

    아무 행도 `claimKind` 를 선언하지 않았으면 종전대로 막는다. 그 필드를 실을 수
    없던 시절의 판정을 뒤에서 뒤집지 않기 위해서다 — 도입은 완화 방향으로만
    작동하고, 선언한 주장만 재현을 요구받는다.
    """
    dissents = _single_vote_dissents(item, kinds)
    declared = [row for row in dissents if row.get("claimKind")]
    if not declared:
        return bool(dissents)
    return any(
        str(row.get("claimKind") or "") == "fact"
        and str(row.get("reproductionResult") or "") == "reproduced"
        for row in declared
    )


def _critic_non_error_verdicts(item: dict) -> list[dict]:
    return [
        row
        for row in (item.get("verdicts") or [])
        if isinstance(row, dict)
        and is_critic_worker(str(row.get("worker") or ""))
        and str(row.get("verdict") or "").strip().upper()
        not in ("", "VERIFICATION-ERROR")
    ]


def _tie_gate_class(item: dict, agree: list, disagree: list) -> str | None:
    """분석자 동수면 critic 이 가르고, 없으면 재검증. 동수가 아니면 None."""
    if not (len(disagree) == len(agree) and disagree):
        return None
    critic = _critic_non_error_verdicts(item)
    if not critic:
        return "needs-reverify"
    if any(
        str(row.get("verdict") or "").strip().upper() == "DISAGREE"
        and str(row.get("breakageKind") or "").strip().lower()
        not in _ADVISORY_ONLY_KINDS
        for row in critic
    ):
        return "majority-disagree"
    return "has-dissent"


def _classify_plan_item_gate(item: dict) -> str:
    """Recompute one plan item's gate class from its per-worker verdicts,
    per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
    ``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
    ``full-consensus`` / ``all-non-result``. Blocking-kind minority dissent
    (``dissent-isolated`` / ``partial-consensus`` on ``b``/``c``/``e``) is
    ``majority-disagree`` so the user gate sees it. ``has-dissent`` remains
    advisory-only, rollback items, and a single-vote kind that lost its
    reproduction. An analyser 1-1 is ``needs-reverify`` until ``critic-worker``
    settles it.
    """
    tokens = [
        (
            str(v.get("verdict") or "").strip().upper(),
            str(v.get("breakageKind") or "").strip().lower(),
        )
        for v in (item.get("verdicts") or [])
        if isinstance(v, dict)
        and not is_critic_worker(str(v.get("worker") or ""))
    ]
    non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
    if not non_error:
        return "all-non-result"
    disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
    agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
    if not disagree:
        return "full-consensus"
    # Rollback is a human-run operation, so rollback dissent never blocks the
    # gate — closed from two angles so a verifier cannot re-block it by relabelling:
    #   (1) a whole rollback plan item (`P-Rb-*`) is advisory regardless of
    #       breakage kind — otherwise a `DISAGREE(b)` "rollback command is
    #       ambiguous" would sail past the kind-`d` exemption and block;
    #   (2) a rollback-ordering dissent (`d`) is advisory on ANY item, since a
    #       rollback-order defect raised against a non-rollback item is still a
    #       human-run concern.
    # Both are recorded as dissent and fold into `has-dissent`, never blocking.
    if str(item.get("id") or "").upper().startswith("P-RB"):
        return "has-dissent"
    blocking_disagree = [(vd, bk) for (vd, bk) in disagree if bk not in _ADVISORY_ONLY_KINDS]
    if not blocking_disagree:
        return "has-dissent"
    blocking_kinds = {bk for (_vd, bk) in blocking_disagree if bk}
    # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
    # safety-critical, adversarially-verifiable defect is enough to block, even
    # in a two-worker roster — a lone correct dissent must not be outvoted here.
    # `a` for any item except `P-Var-*` (majority-gated, see
    # `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
    is_req = str(item.get("id") or "").upper().startswith("P-REQ")
    single_vote_kinds = set(_SINGLE_VOTE_BLOCKING_KINDS) | ({"f"} if is_req else set())
    if (
        not _is_variation_point_item(item)
        and blocking_kinds & single_vote_kinds
        and _single_vote_block_survives(item, single_vote_kinds)
    ):
        # "One confirmed DISAGREE" presupposes the item was actually
        # cross-verified. When the peer returned a non-result nothing confirmed
        # the dissent, so blocking here would reproduce the same
        # worker-failure-makes-the-gate-stricter paradox the majority branch
        # below guards against. Route it to a re-verify round instead.
        if len(non_error) < 2:
            return "needs-reverify"
        return "majority-disagree"
    # Otherwise a genuine majority is required — and a majority needs at least
    # two participating votes, so a lone surviving DISAGREE (its peer returned a
    # non-result) does NOT block. That fixes the paradox where a worker failure
    # made the gate stricter than a healthy roster would.
    if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
        return "majority-disagree"
    settled = _tie_gate_class(item, agree, blocking_disagree)
    if settled is not None and len(non_error) >= 2:
        return settled
    if (
        len(non_error) >= 2
        and blocking_disagree
        and (
            not (blocking_kinds & single_vote_kinds)
            or _is_variation_point_item(item)
        )
    ):
        # 판단 종류의 소수 반대는 표로 기각하지 않는다. 양쪽이 표를 냈으면
        # 사용자가 고른다. 재현에 실패한 1표 종류 `a`/`f` 는 위에서 이미
        # 근거를 잃었으므로 이 분기에 안 들어온다.
        return "majority-disagree"
    return "has-dissent"


def _max_verdict_round(item: dict) -> int:
    """이 항목의 판정이 붙은 가장 늦은 라운드. 스탬프가 없으면 1.

    `apply-verdicts --round <N>` 이 각 행을 찍는다. 스탬프가 없는 행은 자가수정이
    한 번도 없었던 run 에서만 나오고, 그때는 라운드가 하나뿐이다.
    """
    rounds = [
        verdict["round"]
        for verdict in (item.get("verdicts") or [])
        if isinstance(verdict, dict)
        and isinstance(verdict.get("round"), int)
        and not isinstance(verdict.get("round"), bool)
    ]
    return max(rounds, default=1)


def _is_even_analyser_split(item: dict) -> bool:
    tokens = [
        str(row.get("verdict") or "").strip().upper()
        for row in (item.get("verdicts") or [])
        if isinstance(row, dict)
        and not is_critic_worker(str(row.get("worker") or ""))
        and str(row.get("verdict") or "").strip().upper()
        not in ("", "VERIFICATION-ERROR")
    ]
    if len(tokens) < 2:
        return False
    disagree = sum(1 for token in tokens if token == "DISAGREE")
    agree = sum(1 for token in tokens if token in {"AGREE", "SUPPLEMENT"})
    return disagree == agree and disagree > 0


def _is_unsettled_tie(item: dict) -> bool:
    """분석자는 갈렸고 critic 표가 아직 없는 동수 항목."""
    return _is_even_analyser_split(item) and not _critic_non_error_verdicts(item)


def _disagree_breakage_kinds(item: dict) -> set[str]:
    return {
        str(v.get("breakageKind") or "").strip().lower()
        for v in (item.get("verdicts") or [])
        if isinstance(v, dict)
        and str(v.get("verdict") or "").strip().upper() == "DISAGREE"
        and str(v.get("breakageKind") or "").strip()
    }


def _has_planner_fixable_majority(item: dict) -> bool:
    disagrees = [
        v
        for v in (item.get("verdicts") or [])
        if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
    ]
    fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
    return bool(disagrees) and len(fixable) * 2 > len(disagrees)


def _is_correctness_critical(item: dict) -> bool:
    """Whether this item's defect would make `implementation` produce wrong or
    unsafe code — the single-vote-blocking kind `a` (cited path/symbol mismatch)
    on any item but `P-Var-*`, or `f` (requirement-coverage mismatch) on a
    `P-Req-*` item.
    Kinds `b`/`c`/`e` are plan-prose defects: they degrade the document, not the
    resulting code. Rollback ordering (`d`) is advisory — a human runs the
    rollback — so it never counts as correctness-critical. A `P-Var-*` item is
    majority-gated end to end, so a kind-`a` dissent on one is no more critical
    than the `b`/`e` its defect should have been raised under; otherwise the same
    mis-tag that no longer single-vote-blocks would still veto the downgrade.
    """
    if _is_variation_point_item(item):
        return False
    kinds = _disagree_breakage_kinds(item)
    is_req = str(item.get("id") or "").upper().startswith("P-REQ")
    return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)


def _self_fix_budget_exhausted(pbv: dict) -> bool:
    rounds_applied = pbv.get("selfFixRoundsApplied")
    return (
        isinstance(rounds_applied, int)
        and rounds_applied >= 1
        and pbv.get("selfFixStopReason") in _SELF_FIX_EXHAUSTED_REASONS
    )


def _state_classification(item: dict, gate_class: str) -> str:
    """This item's `planItems[].rounds[].classification` for the state file.

    Blocking-kind `dissent-isolated` / `partial-consensus` is already
    `majority-disagree` at the gate. `has-dissent` that remains is advisory
    or a single-vote kind that lost reproduction; the state file then splits
    that remainder into `dissent-isolated` vs `partial-consensus`.

    *gate_class* is passed in rather than recomputed so that the caller's
    effective classification — which may have been downgraded by
    `_is_dissent_downgraded` — is the one this translates.

    `contested` never appears: it is only meaningful at `maxRounds > 1`, and at
    the default `maxRounds=1` the round protocol folds any otherwise-unresolved
    item into `partial-consensus`.
    """
    if gate_class == "all-non-result":
        # No non-error vote at all is the `needs-reverify` shape taken to its
        # limit — "fewer than 2 participating votes" covers zero.
        return "needs-reverify"
    if gate_class != "has-dissent":
        return gate_class
    dissenting = sum(
        1
        for vote in (item.get("verdicts") or [])
        if isinstance(vote, dict)
        and str(vote.get("verdict") or "").strip().upper() == "DISAGREE"
    )
    return "dissent-isolated" if dissenting == 1 else "partial-consensus"


def _clarification_resolution(row: dict, context: dict) -> dict | None:
    """계약 3.0 은 행의 `resolution`, 2.0 은 `approvalContext.resolution`."""
    for candidate in (row.get("resolution"), context.get("resolution")):
        if isinstance(candidate, dict):
            return candidate
    return None


def _clarification_ids_on_activity(activity: dict) -> set[str]:
    refs: set[str] = set()
    for key in ("clarificationRefs", "evidenceRefs"):
        for value in activity.get(key) or []:
            if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(value):
                refs.add(value)
    return refs


def _plan_item_ids_for_clarification(
    row: dict, context: dict, data: dict,
) -> list[str]:
    """이 C 행이 가리키는 계획 항목.

    계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
    `evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
    역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
    """
    linked = [
        item_id
        for item_id in (context.get("planItemIds") or [])
        if isinstance(item_id, str) and item_id
    ]
    if linked:
        return linked
    row_id = str(row.get("id") or "")
    if not row_id:
        return []
    singleton: list[str] = []
    bulk: list[str] = []
    for activity in data.get("agentActivity") or []:
        if not isinstance(activity, dict):
            continue
        refs = _clarification_ids_on_activity(activity)
        if row_id not in refs:
            continue
        ids = [
            item_id
            for item_id in (activity.get("planItemIds") or [])
            if isinstance(item_id, str) and item_id
        ]
        if refs == {row_id}:
            singleton.extend(ids)
        else:
            bulk.extend(ids)
    if singleton or bulk:
        return singleton or bulk
    items = (
        ((data.get("implementationPlanning") or {}).get("planBodyVerification")
         or {}).get("planItems") or []
    )
    return [
        str(item.get("id") or "")
        for item in items
        if isinstance(item, dict)
        and row_id in {
            ref for ref in (item.get("clarificationRefs") or [])
            if isinstance(ref, str)
        }
        and item.get("id")
    ]


def _user_accepted_plan_item_ids(data: dict) -> set[str]:
    """사용자가 진행 처분을 고른 승인 행이 가리키는 계획 항목.

    DISAGREE 표는 그대로 남는다. 게이트만 `has-dissent` 로 내린다.
    """
    accepted: set[str] = set()
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict) or row.get("blocks") != "approval":
            continue
        if row_blocks_progress(
            str(row.get("status") or ""), clarification_disposition(row)
        ):
            continue
        context = row.get("approvalContext")
        if not isinstance(context, dict):
            context = {}
        accepted.update(_plan_item_ids_for_clarification(row, context, data))
    return accepted


def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
    """호환 별칭. 새 코드는 `_user_accepted_plan_item_ids` 를 쓴다."""
    return _user_accepted_plan_item_ids(data)


def _is_dissent_downgraded(
    item: dict,
    pbv: dict,
    accepted_item_ids: set[str],
) -> bool:
    """Whether a surviving `majority-disagree` item stops blocking approval.

    사용자 진행 처분(`accept-risk` / `select` / `answer`)이 있으면 표는 남기고
    게이트만 `has-dissent` 로 내린다. 분류와 자가수정 소진 여부는 보지 않는다.
    """
    return (
        _classify_plan_item_gate(item) == "majority-disagree"
        and str(item.get("id") or "") in accepted_item_ids
    )


def _stage_scope_bucket(item: dict, pbv: dict) -> str:
    """Whether this item has standing to block the stage about to start.

    The plan covers every stage; implementation runs one at a time. Judging all
    of them at once means a defect in a stage nobody has reached, or in one
    already frozen, stops the next stage from starting — and a frozen stage's
    item cannot be fixed at all, because the Stage Ledger forbids editing its
    commands. Measured on one run, 9 of 13 blockers were that shape, 6 of them
    frozen.

    Returns `in-scope` (may block), `observed` (only frozen stages), or
    `deferred` (only stages not yet startable). Anything unresolvable is
    `in-scope`: an absent ledger is no basis to narrow. Plan-wide items
    (`P-Opt-*`, `P-Var-*`, `P-Dep-*`, `P-Dir-1`) with no `stageScope` stay
    in-scope. An unscoped `P-Val-*` / `P-Req-*` / `P-Rb-*` stays in-scope only
    until a stage is `done`; after that it is `deferred` so a re-plan does not
    re-score the whole checklist.

    디스패치 큐와 같은 함수를 쓴다. 검증기가 다른 통을 내면 워커가 안 본
    항목이 승인을 막거나, 본 항목이 게이트에서 빠진다.
    """
    ledger = pbv.get("stageLedger")
    return _item_stage_scope_bucket(
        item, ledger if isinstance(ledger, dict) else None,
    )


def _set_aside_reason(item: dict, pbv: dict, accepted_item_ids: set[str]) -> str | None:
    """Why this item stopped blocking, or ``None`` if it never did.

    A gate that passes while defects were set aside has to say which ones and on
    what grounds. Without that the two halves of the acceptance condition — the
    next stage can start, and the known risks are written down — collapse into
    the first, and a defect deferred for a good reason is indistinguishable in
    the record from one nobody found.
    """
    raw = (
        "has-dissent"
        if _is_dissent_downgraded(item, pbv, accepted_item_ids)
        else _classify_plan_item_gate(item)
    )
    if raw != "majority-disagree":
        return None
    bucket = _stage_scope_bucket(item, pbv)
    if bucket != "in-scope":
        return bucket
    return "record" if str(item.get("block") or "") == "record" else None


def _set_aside_register(pbv: dict, accepted_item_ids: set[str]) -> list[dict]:
    """Every set-aside item, in id order, as the gate records them."""
    register = [
        {"id": str(item.get("id") or ""), "reason": reason}
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
        for reason in [_set_aside_reason(item, pbv, accepted_item_ids)]
        if reason is not None
    ]
    return sorted(register, key=lambda row: row["id"])


def _plan_item_gate_class(
    item: dict, pbv: dict, accepted_item_ids: set[str],
) -> str:
    """The gate class for one item, after stage scope is applied.

    An out-of-scope blocker is not dropped — it lands on `has-dissent`, so the
    gate still reads `passed-with-dissent` rather than `passed` and the record
    says something is outstanding. Silently scoring it `passed` would hide the
    defect instead of deferring it.
    """
    classification = (
        "has-dissent"
        if _is_dissent_downgraded(item, pbv, accepted_item_ids)
        else _classify_plan_item_gate(item)
    )
    if classification != "majority-disagree":
        return classification
    if _stage_scope_bucket(item, pbv) != "in-scope":
        return "has-dissent"
    if str(item.get("block") or "") == "record":
        # 자기 기록의 부정확은 기록되고 다음 run 의 입력이 되지, 구현 착수를 막지
        # 않는다. 요구사항이 실제로 안 만들어지는 경우는 이 경로가 아니라
        # `_independent_coverage_blockers` 의 `coverage-gap` 이 계속 막는다.
        return "has-dissent"
    return classification


def _recompute_plan_body_gate(
    pbv: dict,
    accepted_item_ids: set[str] | None = None,
) -> str | None:
    """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
    Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
    no plan items to judge (disabled / empty round)."""
    accepted = accepted_item_ids or set()
    classes = [
        _plan_item_gate_class(it, pbv, accepted)
        for it in (pbv.get("planItems") or [])
        if isinstance(it, dict)
        and (
            _stage_scope_bucket(it, pbv) == "in-scope"
            or it.get("verdicts")
        )
    ]
    if not classes:
        return None
    if all(c == "all-non-result" for c in classes):
        return "aborted-non-result"
    if pbv.get("gating") is False:
        if any(c in ("majority-disagree", "has-dissent", "needs-reverify", "all-non-result") for c in classes):
            return "passed-with-dissent"
        return "passed"
    if any(c == "majority-disagree" for c in classes):
        return "blocked-by-disagreement"
    if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
        # `all-non-result` belongs here for the same reason `needs-reverify`
        # does — it IS that shape with zero participating votes instead of one
        # (`_state_classification` maps it there, and the contract's step 5
        # lists `needs-reverify` under `passed-with-dissent`). Left out, an
        # item no verifier could judge scored `passed`: the all-error case
        # already reads `needs-reverify` in the state file while the gate it
        # feeds says every item reached consensus.
        return "passed-with-dissent"
    return "passed"


def _validate_set_aside_register(
    data: dict, failures: list[str], accepted_item_ids: set[str] | None = None,
) -> None:
    """A gate that set defects aside must say which ones and why.

    The gate stops blocking on a defect that belongs to a frozen or unreached
    stage, or to the plan's own record. That is the point — but a reader of the
    report cannot tell such a defect from one that was never raised unless the
    run writes the register down. `gateBlockedBy` names what blocked; this names
    what did not, and why.

    Declared against recomputed, the same shape as `_validate_gate_blocked_by`:
    a hand-written register drifts from the verdicts it claims to summarise, and
    the drift is invisible precisely because nothing else reads it.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    accepted = (
        _resolved_noncritical_dissent_ids(data)
        if accepted_item_ids is None
        else accepted_item_ids
    )
    expected = _set_aside_register(pbv, accepted)
    declared_raw = pbv.get("setAside")
    declared = sorted(
        (
            {"id": str(row.get("id") or ""), "reason": str(row.get("reason") or "")}
            for row in declared_raw
            if isinstance(row, dict)
        ),
        key=lambda row: row["id"],
    ) if isinstance(declared_raw, list) else None
    if declared == expected:
        return
    if declared is None and not expected:
        return
    failures.append(
        "final-report data.json: planBodyVerification.setAside is "
        f"{declared!r} but the recorded verdicts set aside {expected!r}. The "
        "gate stopped blocking on those items — a frozen or unreached stage, or "
        "the plan's own record — and a run that passes without listing them "
        "leaves a deferred defect indistinguishable from one nobody raised. "
        "Re-record the round with `okstra plan-items complete-round`, which "
        "writes this register from the same computation."
    )


def _validate_plan_body_gate_recompute(
    data: dict,
    failures: list[str],
    accepted_item_ids: set[str] | None = None,
) -> None:
    """H1 — the declared `Gate result` must not claim a healthier outcome than
    the recorded per-worker verdicts support. Closes the forgery hole where a
    lead writes `gateResult: passed` while workers actually voted DISAGREE:
    the verdicts live in `planItems[].verdicts`, so the gate is recomputable
    and no longer depends on the lead's honesty alone.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    declared = str(pbv.get("gateResult") or "").strip().lower()
    accepted = (
        _resolved_noncritical_dissent_ids(data)
        if accepted_item_ids is None
        else accepted_item_ids
    )
    recomputed = _recompute_plan_body_gate(pbv, accepted)
    if recomputed is None or declared not in _PLAN_GATE_RANK:
        return
    if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
            f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
            "a P-Req item, or all-non-result dispatches were recorded). The gate "
            "value must honestly aggregate the worker votes — do not upgrade it "
            "to unblock the run (plan-body-verification.md Round protocol)."
        )


def _cited_clarification_id(row: dict) -> str | None:
    """The `C-NNN` a coverage row's `status` / `approvalDisposition` cites."""
    for field in ("status", "approvalDisposition"):
        value = str(row.get(field) or "").strip()
        if value.startswith("blocked "):
            return value.split(" ", 1)[1].strip()
    return None


def _blocks_approval(row: dict) -> bool:
    """Whether one Requirement Coverage row blocks approval on its face, per
    `prompts/profiles/implementation-planning.md` §"Requirement Coverage": a
    `gap`, a plain `blocked C-NNN`, or a deviation whose approval disposition
    is blocked.
    """
    status = str(row.get("status") or "").strip()
    if status == "gap" or status.startswith("blocked C-"):
        return True
    disposition = str(row.get("approvalDisposition") or "").strip()
    return status == "documented-deviation" and disposition.startswith("blocked C-")


def _plan_item_clarification_ids(item: object) -> set[str]:
    """이 plan item 이 가리키는 `C-NNN` 들.

    계약 v3 에서 리포트 정본의 이 링크는 복수형 `clarificationRefs[]` 다 —
    `report_assembly` 가 활동 원장의 `clarificationRefs[]` + `planItemIds[]` 에서
    유도해 쓰고, v3.0 스키마의 `planItems[]` 는 `additionalProperties: false` 아래
    그 이름만 허용한다. 단수형 `clarificationId` 는 lead 가 쓰는 plan-body 상태
    파일과 v2 리포트에 남아 있으므로 읽을 때는 둘 다 받는다
    (`incremental_scope` 가 이미 그렇게 한다).
    """
    if not isinstance(item, dict):
        return set()
    ids = {
        str(ref).strip()
        for ref in (item.get("clarificationRefs") or [])
        if str(ref).strip()
    }
    single = item.get("clarificationId")
    if isinstance(single, str) and single.strip():
        ids.add(single.strip())
    return ids


def _plan_body_promoted_clarification_ids(pbv: dict) -> set[str]:
    """`C-NNN` ids this run's own plan-body round created by promoting a
    majority-disagree item (step 8). Used to break the Requirement Coverage
    ↔ Clarification cycle: a coverage row citing one of these echoes a blocker
    the gate already counted, rather than contributing an independent one.
    """
    return {
        clarification_id
        for item in (pbv.get("planItems") or [])
        for clarification_id in _plan_item_clarification_ids(item)
    }


def _independent_coverage_blockers(ip: dict, pbv: dict) -> list[str]:
    """Coverage rows that block the gate on their own — excluding rows whose
    blocker is a `C-NNN` this same run's plan-body round promoted."""
    promoted = _plan_body_promoted_clarification_ids(pbv)
    return [
        str(row.get("id") or "<unknown>")
        for row in (ip.get("requirementCoverage") or [])
        if isinstance(row, dict)
        and _blocks_approval(row)
        and _cited_clarification_id(row) not in promoted
    ]


def _gate_blocking_causes(
    pbv: dict,
    coverage_blockers: list[str],
    accepted_item_ids: set[str] | None = None,
) -> set[str]:
    """Which inputs actually block approval, as `gateBlockedBy` enum values."""
    causes = set()
    recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
    if pbv.get("gating") is False:
        if recomputed == "aborted-non-result":
            causes.add("non-result")
        return causes
    if recomputed == "blocked-by-disagreement":
        causes.add("majority-disagree")
    elif recomputed == "aborted-non-result":
        causes.add("non-result")
    if coverage_blockers:
        causes.add("coverage-gap")
    return causes


_APPROVAL_DISPOSITIONS_BY_CLASSIFICATION = {
    "user-decision": frozenset(
        {"select", "accept-risk", "request-revision", "reject"}
    ),
    "noncritical-dissent": frozenset(
        {"accept-risk", "request-revision", "reject"}
    ),
    "correctness-critical": frozenset(
        {"accept-risk", "request-revision", "reject"}
    ),
}


def _is_activity_contract_v1_planning(run_manifest: dict) -> bool:
    return (
        run_manifest.get("activityContractVersion") == 1
        and run_manifest.get("taskType") == "implementation-planning"
    )


def _independent_coverage_clarification_ids(ip: dict, pbv: dict) -> set[str]:
    promoted = _plan_body_promoted_clarification_ids(pbv)
    return {
        clarification_id
        for row in (ip.get("requirementCoverage") or [])
        if isinstance(row, dict) and _blocks_approval(row)
        for clarification_id in [_cited_clarification_id(row)]
        if clarification_id and clarification_id not in promoted
    }


def _expected_approval_classification(
    row: dict,
    plan_items_by_id: dict[str, dict],
    independent_coverage_clarification_ids: set[str],
) -> str:
    linked = [
        plan_items_by_id[item_id]
        for item_id in (row.get("approvalContext") or {}).get("planItemIds") or []
        if item_id in plan_items_by_id
    ]
    if any(_is_correctness_critical(item) for item in linked):
        return "correctness-critical"
    if str(row.get("id") or "") in independent_coverage_clarification_ids:
        return "correctness-critical"
    for item in linked:
        disagree_votes = [
            verdict
            for verdict in (item.get("verdicts") or [])
            if isinstance(verdict, dict)
            and str(verdict.get("verdict") or "").upper() == "DISAGREE"
        ]
        needs_user_input = sum(
            verdict.get("fixability") == "needs-user-input"
            for verdict in disagree_votes
        )
        if disagree_votes and needs_user_input * 2 > len(disagree_votes):
            return "user-decision"
    if any(_classify_plan_item_gate(item) == "majority-disagree" for item in linked):
        return "noncritical-dissent"
    return "user-decision"


_STATE_DISAGREE_VOTE_RE = re.compile(r"^DISAGREE\(([a-f])\)$")
_APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")


def _state_round_as_plan_item(item_id: str, round_row: dict) -> dict:
    verdicts = []
    votes = round_row.get("votes")
    for worker, raw_vote in (votes.items() if isinstance(votes, dict) else ()):
        vote = str(raw_vote or "").strip()
        match = _STATE_DISAGREE_VOTE_RE.fullmatch(vote)
        if match:
            verdicts.append(
                {"worker": worker, "verdict": "DISAGREE", "breakageKind": match.group(1)}
            )
        elif vote in {"AGREE", "SUPPLEMENT", "verification-error"}:
            verdicts.append({"worker": worker, "verdict": vote})
    return {"id": item_id, "verdicts": verdicts}


def _historical_plan_item_evidence(state: dict) -> tuple[dict[str, str], set[str]]:
    classifications: dict[str, str] = {}
    item_ids: set[str] = set()
    for item in state.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        item_id = str(item.get("id") or "").strip()
        if not item_id:
            continue
        item_ids.add(item_id)
        for round_row in item.get("rounds") or []:
            if not isinstance(round_row, dict):
                continue
            historical = _state_round_as_plan_item(item_id, round_row)
            if _is_correctness_critical(historical):
                classifications[item_id] = "correctness-critical"
                break
            if _classify_plan_item_gate(historical) == "majority-disagree":
                classifications.setdefault(item_id, "noncritical-dissent")
    return classifications, item_ids


def _historical_coverage_clarification_ids(
    state: dict,
    plan_classifications: dict[str, str],
) -> set[str]:
    coverage_gap_rounds = {
        round_row.get("round")
        for round_row in (state.get("roundHistory") or [])
        if isinstance(round_row, dict)
        and isinstance(round_row.get("round"), int)
        and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
    }
    return {
        clarification_id
        for item in (state.get("planItems") or [])
        if isinstance(item, dict)
        for item_id in [str(item.get("id") or "").strip()]
        for clarification_id in [str(item.get("clarificationId") or "").strip()]
        if item_id
        and item_id not in plan_classifications
        and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(clarification_id)
        and any(
            isinstance(round_row, dict)
            and round_row.get("round") in coverage_gap_rounds
            for round_row in (item.get("rounds") or [])
        )
    }


def _read_approval_history(
    report_path: Path | None,
) -> tuple[dict[str, str], set[str], set[str], dict, Path | None]:
    if report_path is None or (seq := _report_run_seq(report_path)) is None:
        return {}, set(), set(), {}, None
    state_path = (
        report_path.parent.parent
        / "state"
        / f"plan-body-verification-implementation-planning-{seq}.json"
    )
    try:
        state = json.loads(state_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}, set(), set(), {}, None
    if not isinstance(state, dict):
        return {}, set(), set(), {}, None
    classifications, item_ids = _historical_plan_item_evidence(state)
    coverage_ids = _historical_coverage_clarification_ids(
        state,
        classifications,
    )
    return classifications, item_ids, coverage_ids, state, state_path


def _nonblocking_coverage_clarification_ids(ip: dict) -> set[str]:
    return {
        ref
        for row in (ip.get("requirementCoverage") or [])
        if isinstance(row, dict) and not _blocks_approval(row)
        for ref in (row.get("decisionRefs") or [])
        if isinstance(ref, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
    }


def _historical_approval_classification(
    row_id: str,
    linked_ids: list[str],
    historical_plan_classifications: dict[str, str],
    historical_coverage_ids: set[str],
) -> str | None:
    if row_id in historical_coverage_ids:
        return "correctness-critical"
    classes = {
        historical_plan_classifications[item_id]
        for item_id in linked_ids
        if item_id in historical_plan_classifications
    }
    if "correctness-critical" in classes:
        return "correctness-critical"
    if "noncritical-dissent" in classes:
        return "noncritical-dissent"
    return None


def _approval_activities_by_id(data: dict) -> dict[str, dict]:
    return {
        activity_id: activity
        for activity in (data.get("agentActivity") or [])
        if isinstance(activity, dict)
        for activity_id in [activity.get("activityId")]
        if isinstance(activity_id, str) and activity_id
    }


def _canonical_activity_timestamps(
    run_manifest: Mapping[str, Any],
    report_path: Path | None,
) -> dict[str, str]:
    raw_path = run_manifest.get("leadEventsPath")
    if not isinstance(raw_path, str) or not raw_path.strip():
        return {}
    path = Path(raw_path)
    if not path.is_absolute() and report_path is not None:
        path = _project_root_from_report(report_path) / path
    try:
        events = read_lead_events(path)
    except (LeadEventParseError, OSError):
        return {}
    return {
        str(event.details.get("activityId")): event.timestamp
        for event in events
        if event.event_type == "activity"
        and isinstance(event.details.get("activityId"), str)
    }


def _is_decision_required_activity(activity: dict | None) -> bool:
    return bool(
        activity
        and activity.get("kind") == "user-decision-required"
        and activity.get("outcome") == "blocked"
    )


def _is_applied_decision_check(activity: dict | None) -> bool:
    commands = (activity or {}).get("commands")
    return bool(
        activity
        and activity.get("kind") == "user-decision-evaluated"
        and activity.get("outcome") == "resolved"
        and str(activity.get("resultPath") or "").strip()
        and isinstance(commands, list)
        and bool(commands)
        and all(
            isinstance(command, dict) and command.get("exitCode") == 0
            for command in commands
        )
    )


def _activity_matches_approval_context(
    activity: dict | None,
    row_id: str,
    context: dict,
) -> bool:
    if not activity:
        return False
    evidence_refs = {
        ref
        for ref in (activity.get("evidenceRefs") or [])
        if isinstance(ref, str)
    }
    activity_item_ids = {
        item_id
        for item_id in (activity.get("planItemIds") or [])
        if isinstance(item_id, str)
    }
    context_item_ids = {
        item_id
        for item_id in (context.get("planItemIds") or [])
        if isinstance(item_id, str)
    }
    clarification_refs = {
        ref for ref in evidence_refs if _APPROVAL_CLARIFICATION_ID_RE.fullmatch(ref)
    }
    return clarification_refs == {row_id} and context_item_ids == activity_item_ids


_TARGETED_REVERIFICATION_REF_RE = re.compile(
    r"^plan-body-verification:round-(?P<round>\d+)$"
)


def _targeted_reverification_round(activity: dict | None) -> int | None:
    rounds = {
        int(match.group("round"))
        for ref in ((activity or {}).get("evidenceRefs") or [])
        if isinstance(ref, str)
        for match in [_TARGETED_REVERIFICATION_REF_RE.fullmatch(ref)]
        if match is not None
    }
    if len(rounds) != 1:
        return None
    return next(iter(rounds))


def _approval_context_activity_refs_exist(
    data: dict,
    row_id: str,
    context: dict,
    resolution: dict,
) -> bool:
    activities = _approval_activities_by_id(data)
    activity_ids = {
        value for value in (context.get("activityIds") or []) if isinstance(value, str)
    }
    check_refs = {
        value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
    }
    activity_order = {
        activity.get("activityId"): index
        for index, activity in enumerate(data.get("agentActivity") or [])
        if isinstance(activity, dict)
    }
    ordered = bool(activity_ids and check_refs) and max(
        activity_order.get(ref, -1) for ref in activity_ids
    ) < min(activity_order.get(ref, -1) for ref in check_refs)
    return (
        bool(activity_ids)
        and bool(check_refs)
        and all(
            _is_decision_required_activity(activities.get(ref))
            and _activity_matches_approval_context(
                activities.get(ref), row_id, context
            )
            for ref in activity_ids
        )
        and all(
            _is_applied_decision_check(activities.get(ref))
            and _targeted_reverification_round(activities.get(ref)) is not None
            and _activity_matches_approval_context(
                activities.get(ref), row_id, context
            )
            for ref in check_refs
        )
        and ordered
    )


def _validate_approval_activity_refs(
    row_id: str,
    context: dict,
    activities: dict[str, dict],
    failures: list[str],
) -> None:
    activity_ids = {
        value for value in (context.get("activityIds") or []) if isinstance(value, str)
    }
    unknown_activity_ids = sorted(activity_ids - set(activities))
    if not activity_ids or unknown_activity_ids:
        # `recorded` names what the ACTIVITY LEDGER holds, not what this row
        # cited. Printing the row's own ids put the same value on both sides —
        # `unknown=['A-501'], recorded=['A-501']` — and hid the actual cause,
        # which is an empty `agentActivity[]` (the run never called
        # `okstra agent-activity append`). A reader cannot reach that from a
        # message that contradicts itself.
        recorded = sorted(activities)
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` activityIds "
            f"must reference agentActivity[].activityId values; unknown="
            f"{unknown_activity_ids or 'none'}, recorded in agentActivity[]="
            f"{recorded or 'none — the activity ledger is empty'}."
        )
    elif not all(
        _is_decision_required_activity(activities.get(ref)) for ref in activity_ids
    ):
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` activityIds "
            "must reference blocked user-decision-required activities."
        )
    elif not all(
        _activity_matches_approval_context(activities.get(ref), row_id, context)
        for ref in activity_ids
    ):
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` activityIds "
            f"must cite exactly one clarification (`{row_id}`) in evidenceRefs "
            "and exactly match approvalContext.planItemIds."
        )
    resolution = context.get("resolution")
    if not isinstance(resolution, dict):
        return
    check_refs = {
        value for value in (resolution.get("checkRefs") or []) if isinstance(value, str)
    }
    unknown_check_refs = sorted(check_refs - set(activities))
    if check_refs and unknown_check_refs:
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` resolution."
            f"checkRefs must reference agentActivity[].activityId values; unknown="
            f"{unknown_check_refs}."
        )
    elif check_refs and not all(
        _is_applied_decision_check(activities.get(ref))
        and _targeted_reverification_round(activities.get(ref)) is not None
        for ref in check_refs
    ):
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` resolution."
            "checkRefs must reference resolved user-decision-evaluated activities "
            "with successful check evidence and one "
            "`plan-body-verification:round-N` evidenceRef."
        )
    elif check_refs and not all(
        _activity_matches_approval_context(activities.get(ref), row_id, context)
        for ref in check_refs
    ):
        failures.append(
            f"final-report data.json: approval clarification `{row_id}` resolution."
            f"checkRefs must cite exactly one clarification (`{row_id}`) in "
            "evidenceRefs and exactly match approvalContext.planItemIds."
        )
    elif check_refs:
        order = {
            activity.get("activityId"): index
            for index, activity in enumerate(activities.values())
        }
        if activity_ids and max(order.get(ref, -1) for ref in activity_ids) >= min(
            order.get(ref, -1) for ref in check_refs
        ):
            failures.append(
                f"final-report data.json: approval clarification `{row_id}` "
                "user-decision-evaluated activity must occur after every "
                "user-decision-required activity."
            )


def _validate_approval_dispositions(
    row: dict,
    context: dict,
    failures: list[str],
    *,
    schema_version: str = "2.0",
) -> None:
    row_id = str(row.get("id") or "<unknown>")
    classification = str(context.get("classification") or "")
    allowed = _APPROVAL_DISPOSITIONS_BY_CLASSIFICATION.get(classification, frozenset())
    candidates = [("recommendedDisposition", context.get("recommendedDisposition"))]
    candidates.extend(
        (f"options[{index}].disposition", option.get("disposition"))
        for index, option in enumerate(row.get("options") or [])
        if isinstance(option, dict)
    )
    resolution = (
        row.get("resolution")
        if schema_version == "3.0"
        else context.get("resolution")
    )
    if isinstance(resolution, dict):
        candidates.append(("resolution.disposition", resolution.get("disposition")))
    for field, disposition in candidates:
        if disposition not in allowed:
            failures.append(
                f"final-report data.json: approval clarification `{row_id}` "
                f"classification `{classification}` does not allow `{disposition}` "
                f"in {field}; allowed dispositions are {sorted(allowed)}."
            )


def _validate_resolved_approval(
    row: dict,
    context: dict,
    failures: list[str],
    *,
    schema_version: str = "2.0",
) -> None:
    if row.get("status") != "resolved":
        return
    row_id = str(row.get("id") or "<unknown>")
    resolution = (
        row.get("resolution")
        if schema_version == "3.0"
        else context.get("resolution")
    )
    if not isinstance(resolution, dict):
        failures.append(
            f"final-report data.json: resolved approval clarification `{row_id}` "
            "requires resolution.userText and non-empty resolution.checkRefs."
        )
        return
    if not str(resolution.get("userText") or "").strip():
        failures.append(
            f"final-report data.json: resolved approval clarification `{row_id}` "
            "requires non-empty resolution.userText."
        )
    if str(resolution.get("disposition") or "") in PROCEEDING_DISPOSITIONS:
        return
    check_refs = resolution.get("checkRefs")
    if not isinstance(check_refs, list) or not any(
        isinstance(value, str) and value for value in check_refs
    ):
        failures.append(
            f"final-report data.json: resolved approval clarification `{row_id}` "
            "requires non-empty resolution.checkRefs."
        )


def _has_successful_targeted_reverification(item: dict) -> bool:
    verdicts = [
        str(verdict.get("verdict") or "").strip().upper()
        for verdict in (item.get("verdicts") or [])
        if isinstance(verdict, dict)
    ]
    return bool(verdicts) and all(
        verdict in {"AGREE", "SUPPLEMENT"} for verdict in verdicts
    )


def _parse_approval_timestamp(value: Any) -> datetime | None:
    if not isinstance(value, str) or not value.strip():
        return None
    try:
        parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
    except ValueError:
        return None
    if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
        return None
    return parsed


def _referenced_approval_timestamps(
    refs: Any,
    activity_timestamps: dict[str, str],
) -> list[datetime | None]:
    return [
        _parse_approval_timestamp(activity_timestamps.get(ref))
        for ref in (refs or [])
        if isinstance(ref, str)
    ]


def _target_round_completed_at(
    approval_state: dict,
    target_round: int,
) -> datetime | None:
    matching_rounds = [
        row
        for row in (approval_state.get("roundHistory") or [])
        if isinstance(row, dict) and row.get("round") == target_round
    ]
    if len(matching_rounds) != 1:
        return None
    return _parse_approval_timestamp(matching_rounds[0].get("completedAt"))


def _validate_target_round_causality(
    row_id: str,
    target_round: int,
    approval_state: dict,
    context: dict,
    resolution: dict,
    activity_timestamps: dict[str, str],
    failures: list[str],
) -> None:
    completed_at = _target_round_completed_at(approval_state, target_round)
    required_at = _referenced_approval_timestamps(
        context.get("activityIds"),
        activity_timestamps,
    )
    evaluated_at = _referenced_approval_timestamps(
        resolution.get("checkRefs"),
        activity_timestamps,
    )
    if (
        completed_at is None
        or not required_at
        or not evaluated_at
        or None in required_at
        or None in evaluated_at
    ):
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            f"state round {target_round} requires one UTC completedAt plus canonical "
            "timestamps for every required and evaluated activity."
        )
        return
    if completed_at <= max(required_at):
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            f"state round {target_round} completedAt must be after every referenced "
            "user-decision-required activity."
        )
    if completed_at > min(evaluated_at):
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            f"state round {target_round} completedAt must be no later than every "
            "referenced user-decision-evaluated activity."
        )


def _required_activity_plan_item_ids(
    context: dict,
    activities: dict[str, dict],
) -> set[str]:
    return {
        item_id
        for ref in (context.get("activityIds") or [])
        if isinstance(ref, str) and _is_decision_required_activity(activities.get(ref))
        for item_id in (activities[ref].get("planItemIds") or [])
        if isinstance(item_id, str)
    }


def _terminal_unknown_plan_items_are_historical(
    row: dict,
    context: dict,
    unknown_ids: set[str],
    historical_item_ids: set[str],
    historical_coverage_ids: set[str],
    activities: dict[str, dict],
) -> bool:
    status = row.get("status")
    if status not in {"resolved", "obsolete"}:
        return False
    if status == "resolved" and str(row.get("id") or "") not in historical_coverage_ids:
        return False
    required_item_ids = _required_activity_plan_item_ids(context, activities)
    return bool(unknown_ids) and unknown_ids <= historical_item_ids & required_item_ids


def _validate_correctness_resolution(
    row: dict,
    linked_ids: list[str],
    linked_items: list[dict],
    independent_coverage_clarification_ids: set[str],
    activities: dict[str, dict],
    approval_state: dict,
    approval_state_path: Path | None,
    activity_timestamps: dict[str, str],
    failures: list[str],
) -> None:
    context = row.get("approvalContext") or {}
    if (
        context.get("classification") != "correctness-critical"
        or row.get("status") != "resolved"
        or clarification_disposition(row) in PROCEEDING_DISPOSITIONS
    ):
        return
    row_id = str(row.get("id") or "<unknown>")
    resolution = context.get("resolution") or {}
    target_rounds = {
        round_number
        for ref in (resolution.get("checkRefs") or [])
        if isinstance(ref, str)
        for round_number in [_targeted_reverification_round(activities.get(ref))]
        if round_number is not None
    }
    if len(target_rounds) != 1:
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            "requires exactly one evidenced targeted reverification state round."
        )
        return
    target_round = next(iter(target_rounds))
    _validate_target_round_causality(
        row_id,
        target_round,
        approval_state,
        context,
        resolution,
        activity_timestamps,
        failures,
    )
    state_name = approval_state_path.name if approval_state_path is not None else ""
    result_paths_match = bool(state_name) and all(
        tuple(
            Path(str(activities[ref].get("resultPath") or "")).parts[-2:]
        ) == ("state", state_name)
        for ref in (resolution.get("checkRefs") or [])
        if isinstance(ref, str) and ref in activities
    )
    if not result_paths_match:
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            "evaluation resultPath must reference the matching plan-body "
            "verification state artifact."
        )
    state_items = {
        str(item.get("id") or ""): item
        for item in (approval_state.get("planItems") or [])
        if isinstance(item, dict) and str(item.get("id") or "")
    }
    state_failures: dict[str, str] = {}
    current_by_id = {str(item.get("id") or ""): item for item in linked_items}
    for item_id in linked_ids:
        state_item = state_items.get(item_id)
        rounds = state_item.get("rounds") if isinstance(state_item, dict) else None
        target_state_round = next(
            (
                round_row
                for round_row in (rounds or [])
                if isinstance(round_row, dict)
                and round_row.get("round") == target_round
            ),
            None,
        )
        blocking_rounds = [
            round_row.get("round")
            for round_row in (rounds or [])
            if isinstance(round_row, dict)
            and isinstance(round_row.get("round"), int)
            and round_row.get("round") < target_round
            and (
                _is_correctness_critical(
                    _state_round_as_plan_item(item_id, round_row)
                )
                or _classify_plan_item_gate(
                    _state_round_as_plan_item(item_id, round_row)
                )
                == "majority-disagree"
            )
        ]
        if (
            isinstance(state_item, dict)
            and state_item.get("clarificationId") == row_id
        ):
            blocking_rounds.extend(
                round_row.get("round")
                for round_row in (approval_state.get("roundHistory") or [])
                if isinstance(round_row, dict)
                and isinstance(round_row.get("round"), int)
                and round_row.get("round") < target_round
                and "coverage-gap" in (round_row.get("gateBlockedBy") or [])
            )
        target_votes = (
            target_state_round.get("votes")
            if isinstance(target_state_round, dict)
            else None
        )
        successful = bool(target_votes) and all(
            vote in {"AGREE", "SUPPLEMENT"} for vote in target_votes.values()
        )
        if not blocking_rounds or not successful:
            state_failures[item_id] = (
                f"state round {target_round} is not a successful post-blocker round"
            )
            continue
        current = current_by_id.get(item_id)
        if current is not None:
            report_votes = {
                str(verdict.get("worker") or ""): str(verdict.get("verdict") or "")
                for verdict in (current.get("verdicts") or [])
                if isinstance(verdict, dict)
            }
            if report_votes != target_votes:
                state_failures[item_id] = (
                    f"state round {target_round} votes do not match final report verdicts"
                )
    if state_failures:
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            f"targeted reverification state round {target_round} is not bound to "
            f"the resolved evidence; failures={state_failures}."
        )
    unresolved = {
        str(item.get("id") or "<unknown>"): [
            str(verdict.get("verdict") or "")
            for verdict in (item.get("verdicts") or [])
            if isinstance(verdict, dict)
        ]
        for item in linked_items
        if not _has_successful_targeted_reverification(item)
    }
    if unresolved:
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            "cannot resolve until targeted reverification records only AGREE or "
            f"acceptable SUPPLEMENT verdicts; unresolved plan items={unresolved}."
        )
    if row_id in independent_coverage_clarification_ids:
        failures.append(
            f"final-report data.json: correctness-critical clarification `{row_id}` "
            "cannot resolve while an independent requirement coverage blocker remains."
        )


def _validate_approval_context(
    data: dict,
    run_manifest: dict,
    failures: list[str],
    report_path: Path | None = None,
) -> None:
    if not _is_activity_contract_v1_planning(run_manifest):
        return
    if data.get("schemaVersion") == "3.0":
        _validate_v3_approval_context(data, failures)
        return
    ip = data.get("implementationPlanning") or {}
    pbv = ip.get("planBodyVerification") or {}
    plan_items_by_id = {
        str(item.get("id")): item
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict) and str(item.get("id") or "")
    }
    coverage_ids = _independent_coverage_clarification_ids(ip, pbv)
    (
        historical_classes,
        historical_item_ids,
        historical_coverage_ids,
        approval_state,
        approval_state_path,
    ) = _read_approval_history(report_path)
    historical_coverage_ids &= _nonblocking_coverage_clarification_ids(
        ip
    )
    activities = _approval_activities_by_id(data)
    activity_timestamps = _canonical_activity_timestamps(run_manifest, report_path)
    report_approved = (data.get("frontmatter") or {}).get("approved") is True
    incorporated = incorporated_clarification_ids(data)
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict) or row.get("blocks") != "approval":
            continue
        row_id = str(row.get("id") or "<unknown>")
        context = row.get("approvalContext")
        if not isinstance(context, dict):
            failures.append(
                f"final-report data.json: approval clarification `{row_id}` requires "
                "approvalContext under activity contract v1."
            )
            continue
        linked_ids = [
            value
            for value in context.get("planItemIds") or []
            if isinstance(value, str)
        ]
        unknown_ids = set(linked_ids) - set(plan_items_by_id)
        if unknown_ids and not _terminal_unknown_plan_items_are_historical(
            row,
            context,
            unknown_ids,
            historical_item_ids,
            historical_coverage_ids,
            activities,
        ):
            # 사람이 읽는 옵션 라벨(`P-Opt-C`)을 ID 로 착각하는 경우가 여기로
            # 온다. ID 는 추출기가 발행한 서수(`P-Opt-1`)이고, 문자 라벨은 그
            # 항목의 `subject` 가 나른다. 무엇을 써야 하는지 알려 주지 않으면
            # 작성자가 두 체계 사이에서 되돌아갈 곳이 없다.
            failures.append(
                f"final-report data.json: approval clarification `{row_id}` planItemIds "
                f"reference unknown plan items {sorted(unknown_ids)}. Use the "
                "extracted item id verbatim (ordinal, e.g. `P-Opt-1`) — the human "
                "label it carries (\"Option C\") lives in that item's `subject`, "
                f"not in its id. Extracted ids in this report: "
                f"{sorted(plan_items_by_id)[:12] or 'none'}."
            )
        linked_items = [
            plan_items_by_id[item_id]
            for item_id in linked_ids
            if item_id in plan_items_by_id
        ]
        current_expected = _expected_approval_classification(
            row, plan_items_by_id, coverage_ids
        )
        historical_expected = _historical_approval_classification(
            row_id, linked_ids, historical_classes, historical_coverage_ids
        )
        expected = (
            historical_expected
            if row.get("status") in {"resolved", "obsolete"} and historical_expected
            else current_expected
        )
        if context.get("classification") != expected:
            failures.append(
                f"final-report data.json: approval clarification `{row_id}` classification "
                f"is `{context.get('classification')}` but plan evidence requires `{expected}`."
            )
        obsolete_has_active_cause = current_expected != "user-decision" or any(
            item_id in plan_items_by_id for item_id in linked_ids
        )
        if row.get("status") == "obsolete" and obsolete_has_active_cause:
            failures.append(
                f"final-report data.json: obsolete approval clarification `{row_id}` "
                f"still has an active `{current_expected}` cause in the current plan."
            )
        _validate_approval_activity_refs(row_id, context, activities, failures)
        _validate_approval_dispositions(row, context, failures)
        _validate_resolved_approval(row, context, failures)
        _validate_correctness_resolution(
            row,
            linked_ids,
            linked_items,
            coverage_ids,
            activities,
            approval_state,
            approval_state_path,
            activity_timestamps,
            failures,
        )
        if report_approved and row_blocks_progress(
            str(row.get("status") or ""),
            clarification_disposition(row),
            incorporated=row_id in incorporated,
        ):
            failures.append(
                f"final-report data.json: approval is true while clarification `{row_id}` "
                f"has status `{row.get('status')}`; open and return-disposition "
                "approval rows remain blocking."
            )


def _v3_expected_plan_backlinks(
    activities: Mapping[str, dict],
) -> dict[str, set[str]]:
    expected: dict[str, set[str]] = {}
    for activity in activities.values():
        refs = {str(value) for value in activity.get("clarificationRefs") or []}
        for item_id in activity.get("planItemIds") or []:
            expected.setdefault(str(item_id), set()).update(refs)
    return expected


def _validate_v3_plan_backlinks(
    data: dict, activities: Mapping[str, dict], failures: list[str],
) -> None:
    expected = _v3_expected_plan_backlinks(activities)
    planning = data.get("implementationPlanning") or {}
    verification = planning.get("planBodyVerification") or {}
    for item in verification.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        item_id = str(item.get("id") or "")
        actual = {str(value) for value in item.get("clarificationRefs") or []}
        if actual != expected.get(item_id, set()):
            failures.append(
                f"final-report data.json: plan item `{item_id}` clarificationRefs "
                "do not match activity-ledger backlinks."
            )


def _validate_v3_resolution_links(
    row: dict, activities: Mapping[str, dict], failures: list[str],
) -> None:
    resolution = row.get("resolution")
    if not isinstance(resolution, dict):
        return
    row_id = str(row.get("id") or "<unknown>")
    for activity_id in resolution.get("checkRefs") or []:
        activity = activities.get(activity_id)
        if activity is None:
            failures.append(
                f"final-report data.json: clarification `{row_id}` references "
                f"unknown activity `{activity_id}`."
            )
        elif row_id not in (activity.get("clarificationRefs") or []):
            failures.append(
                f"final-report data.json: activity `{activity_id}` does not "
                f"link back to clarification `{row_id}`."
            )


def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
    """검증 가능한 원장 참조만으로 v3 승인 역추적을 다시 계산한다."""
    activities = _approval_activities_by_id(data)
    _validate_v3_plan_backlinks(data, activities, failures)
    approved = (data.get("frontmatter") or {}).get("approved") is True
    incorporated = incorporated_clarification_ids(data)
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict) or row.get("blocks") != "approval":
            continue
        context = row.get("approvalContext")
        if not isinstance(context, dict):
            continue
        _validate_approval_dispositions(
            row, context, failures, schema_version="3.0"
        )
        _validate_resolved_approval(
            row, context, failures, schema_version="3.0"
        )
        _validate_v3_resolution_links(row, activities, failures)
        row_id = str(row.get("id") or "")
        if approved and row_blocks_progress(
            str(row.get("status") or ""),
            clarification_disposition(row),
            incorporated=row_id in incorporated,
        ):
            failures.append(
                f"final-report data.json: approval is true while clarification "
                f"`{row.get('id')}` remains `{row.get('status')}`."
            )


def _validate_activity_contract_plan_limits(
    data: dict,
    run_manifest: dict,
    failures: list[str],
) -> None:
    if not _is_activity_contract_v1_planning(run_manifest):
        return
    pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
    # 리포트 칸은 이어진 런의 누적이다. 자동 자가수정 1회 상한은 이번 창의
    # 상태 파일이 세고, 세션 적합성이 그 횟수와 `self-fix-applied` 를 맞춘다.
    if pbv.get("selfFixStopReason") == "cause-group-recurrence":
        failures.append(
            "final-report data.json: activity contract v1 cannot newly emit "
            "cause-group-recurrence"
        )


_CHECKLIST_REF_RE = re.compile(r"VC-\d+")


def _project_root_from_report(report_path: Path) -> Path:
    """Walk out of `<project>/.okstra/tasks/.../reports/` to the project root."""
    for parent in report_path.parents:
        if parent.name == ".okstra":
            return parent.parent
    return report_path.parent


def _detect_missing_dependency_precondition(
    data: dict, project_root: Path
) -> list[str]:
    """A stage that runs the toolchain must point at a declared precondition.

    The planning worktree installs no dependencies, so `yarn … test` cannot run
    there. A plan that says nothing about it produces steps whose commands die
    on `exit 127`, and the verification round then spends itself on a defect the
    planner cannot fix by editing the plan.

    The shape checked is the one an observed self-fix loop arrived at after two
    rounds: one `phase: pre` checklist item declaring the install, referenced by
    every stage that needs it. Only the reference is machine-checked — whether
    the cited item genuinely covers dependencies is a semantic judgement left to
    the §5.5.9 round, the same boundary the scope-provenance gate draws.
    """
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return []
    tokens = resolve_build_tool_tokens(project_root)
    if not tokens:
        return []

    checklist = {
        str(row.get("id")): str(row.get("phase") or "")
        for row in (planning.get("validationChecklist") or [])
        if isinstance(row, dict) and row.get("id")
    }

    warnings: list[str] = []
    for stage in planning.get("stages") or []:
        if not isinstance(stage, dict):
            continue
        commands = [
            str(step.get("command") or "")
            for step in (stage.get("stepwiseExecution") or [])
            if isinstance(step, dict)
        ]
        if not any(command_invokes_build_tool(c, tokens=tokens) for c in commands):
            continue
        refs = _CHECKLIST_REF_RE.findall(str(stage.get("stageValidation") or ""))
        if not refs:
            warnings.append(
                f"Stage {stage.get('stage')} runs the project toolchain but its "
                "Stage Validation cites no `VC-NNN` precondition. The planning "
                "worktree has no dependencies installed, so declare the install "
                "once as a `phase: pre` Validation Checklist item and reference "
                "it here."
            )
            continue
        if not any(checklist.get(ref) == "pre" for ref in refs):
            cited = ", ".join(sorted(set(refs)))
            warnings.append(
                f"Stage {stage.get('stage')} runs the project toolchain and cites "
                f"{cited}, but none of those is a `phase: pre` Validation "
                "Checklist item — a precondition verified after the fact is not a "
                "precondition."
            )
    return warnings


_UNMAPPED_FALLBACK_REASON = "no impacted stages resolved"


def _prior_planning_data(report_path: Path) -> dict | None:
    """The newest implementation-planning data.json preceding *report_path*."""
    match = re.search(r"-(\d+)\.md$", report_path.name)
    if not match:
        return None
    current_seq = int(match.group(1))
    candidates = sorted(
        (
            path
            for path in report_path.parent.glob(
                "final-report-implementation-planning-*.data.json"
            )
            if (m := re.search(r"-(\d+)\.data\.json$", path.name))
            and int(m.group(1)) < current_seq
        ),
        key=lambda p: p.name,
    )
    for path in reversed(candidates):
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if isinstance(payload, dict):
            return payload
    return None


def _detect_unmapped_incremental_fallback(
    data: dict, report_path: Path
) -> list[str]:
    """A re-run that fell back to full while the prior report could have mapped it.

    Both "the answer restructures the plan" and "no stage could be resolved"
    return `mode: full`, and only the second is a missed narrowing. The lead
    declares the first through `--full-reason`, so the reason prefix separates
    them; this reports the second only when the trace would have succeeded.

    Advisory: a lead that never passed `--full-reason` produces the fallback
    reason for both cases, so failing here would punish runs written before the
    flag existed.
    """
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return []
    decision = planning.get("incrementalDecision")
    if not isinstance(decision, dict) or decision.get("mode") != "full":
        return []
    if _UNMAPPED_FALLBACK_REASON not in str(decision.get("reason") or ""):
        return []

    answered = _answered_clarification_ids(data)
    if not answered:
        return []
    prior = _prior_planning_data(report_path)
    if prior is None:
        return []

    try:
        from okstra_ctl.incremental_scope import clarification_impacted_stages

        stages = clarification_impacted_stages(prior, set(answered))
    except (ImportError, ValueError):
        return []
    if not stages:
        return []
    return [
        "incrementalDecision fell back to full for lack of a resolved stage, but "
        f"the prior report maps {', '.join(sorted(answered))} to stage(s) "
        f"{', '.join(str(s) for s in sorted(stages))}. Pass the answered ids "
        "through `--answered-clarifications` so the re-run narrows, or declare "
        "the structural change with `--full-reason` when full is the judgement."
    ]


_SELF_FIX_NOTE_ROUND_RE = re.compile(r"self-fixed in round\s*(\d+)", re.IGNORECASE)


def _items_resolved_in_round(plan_items: object, round_number: int) -> set[str]:
    resolved: set[str] = set()
    for item in plan_items if isinstance(plan_items, list) else []:
        if not isinstance(item, dict):
            continue
        match = _SELF_FIX_NOTE_ROUND_RE.search(str(item.get("selfFixNote") or ""))
        if match and int(match.group(1)) == round_number:
            resolved.add(str(item.get("id")))
    return resolved


def _detect_self_fix_recurrence(pbv: dict) -> list[str]:
    """Rounds that re-target ground the previous round already worked, unresolved.

    `no-progress` is judged at the round's end from what it resolved. Repeating
    the previous round's *unresolved* remainder is the same conclusion reached
    one dispatch earlier — the observed shape was two rounds spent on one
    identical seven-item set. This names that shape so the loop can exit on it
    rather than paying for the round that proves it.

    Advisory only. Narrowing onto what the last round genuinely left open is
    legitimate progress, and the rule separating that from re-digging the same
    hole is not settled (design D-1), so this reports rather than fails.
    """
    groups = pbv.get("selfFixGroups") if isinstance(pbv, dict) else None
    if not isinstance(groups, list):
        return []
    by_round: dict[int, set[str]] = {}
    for group in groups:
        if not isinstance(group, dict) or not isinstance(group.get("round"), int):
            continue
        ids = {str(i) for i in group.get("itemIds") or []}
        by_round.setdefault(group["round"], set()).update(ids)

    warnings: list[str] = []
    plan_items = pbv.get("planItems")
    for round_number in sorted(by_round)[1:]:
        previous = by_round.get(round_number - 1)
        if not previous:
            continue
        unresolved = previous - _items_resolved_in_round(plan_items, round_number - 1)
        current = by_round[round_number]
        if current and current <= unresolved:
            warnings.append(
                f"self-fix round {round_number} re-targets only items round "
                f"{round_number - 1} left unresolved ({', '.join(sorted(current))}) "
                "— the previous round's correction did not move this cause. "
                "Consider exiting with `no-progress` instead of spending the "
                "remaining budget on the same ground."
            )
    return warnings


def _validate_self_fix_rewrite_scope(data: dict, failures: list[str]) -> None:
    """Keep the rewrite-reach measurement internally consistent.

    No threshold is applied: the contract calls a self-fix round a targeted
    correction rather than a full regeneration, but how far a correct one
    reaches is not yet known from enough runs to name a number. What is
    checkable now is that the narrower figure sits inside the wider one — an
    outside-scope path missing from the rewritten set makes the reach read
    smaller than it was.
    """
    groups = (
        (data.get("implementationPlanning") or {})
        .get("planBodyVerification") or {}
    ).get("selfFixGroups")
    for group in groups if isinstance(groups, list) else []:
        if not isinstance(group, dict):
            continue
        outside = group.get("outsideScopePaths")
        if not isinstance(outside, list) or not outside:
            continue
        rewritten = group.get("rewrittenPaths")
        if not isinstance(rewritten, list):
            failures.append(
                "final-report data.json: selfFixGroups round "
                f"{group.get('round')} lists outsideScopePaths without "
                "rewrittenPaths — the wider set is what the narrower one is a "
                "subset of, so it cannot be omitted."
            )
            continue
        for path in outside:
            if path not in rewritten:
                failures.append(
                    "final-report data.json: selfFixGroups round "
                    f"{group.get('round')} lists `{path}` as outside-scope but "
                    "not among rewrittenPaths — a path the round touched belongs "
                    "in both."
                )


def _analyser_key(worker: str) -> str:
    """`codex` 와 `codex-worker` 는 같은 분석기다."""
    name = worker.strip()
    if name.endswith("-worker"):
        return name[: -len("-worker")]
    return name


def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
    """The gate's own arithmetic base, checked against the votes it ran on.

    A majority over two votes and a majority over three are different claims,
    and a shrunken roster loosens the gate silently: with two analysers,
    1-AGREE/1-DISAGREE is a tie, so it never reaches `majority-disagree`. The
    field only reports; the arithmetic is unchanged. It is recomputable from
    the recorded verdicts, so a figure the table denies is a defect.
    """
    pbv = ((data.get("implementationPlanning") or {}).get("planBodyVerification") or {})
    declared = pbv.get("participatingAnalysers")
    if not isinstance(declared, dict):
        return

    rostered = declared.get("rostered")
    voting = declared.get("voting")
    if not isinstance(rostered, int) or not isinstance(voting, int):
        failures.append(
            "final-report data.json: planBodyVerification.participatingAnalysers "
            "needs integer `rostered` and `voting`."
        )
        return
    if voting > rostered:
        failures.append(
            "final-report data.json: planBodyVerification.participatingAnalysers "
            f"claims {voting} voting of {rostered} rostered — more workers voted "
            "than were on the roster."
        )
        return

    observed = {
        _analyser_key(str(v.get("worker")))
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
        for v in (item.get("verdicts") or [])
        if isinstance(v, dict) and str(v.get("verdict") or "") != "verification-error"
    }
    observed.discard("")
    if observed and voting != len(observed):
        failures.append(
            "final-report data.json: planBodyVerification.participatingAnalysers "
            f"declares {voting} voting analyser(s) but the recorded verdicts carry "
            f"{len(observed)} ({', '.join(sorted(observed))}). A worker whose "
            "dispatch returned no result is excluded from the gate arithmetic and "
            "must not be counted here either."
        )


def _validate_gate_blocked_by(
    data: dict,
    failures: list[str],
    accepted_item_ids: set[str] | None = None,
) -> None:
    """The gate value names an *outcome*; `gateBlockedBy` names the *cause*.

    Two independent inputs can block approval — a `majority-disagree` plan item
    and a Requirement Coverage `gap` / `blocked C-NNN` row — and before this
    check both surfaced as the single value `blocked-by-disagreement`. A
    coverage-only block therefore asserted a worker disagreement that never
    happened, sending the reader hunting for a dissent that does not exist.
    `gateBlockedBy` records which input actually fired, and this check makes the
    coverage rule enforceable rather than prose-only.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return

    declared_gate = str(pbv.get("gateResult") or "").strip().lower()
    declared_causes = {
        str(c).strip()
        for c in (pbv.get("gateBlockedBy") or [])
        if isinstance(c, str) and str(c).strip()
    }
    coverage_blockers = _independent_coverage_blockers(ip, pbv)
    accepted = (
        _resolved_noncritical_dissent_ids(data)
        if accepted_item_ids is None
        else accepted_item_ids
    )
    actual_causes = _gate_blocking_causes(pbv, coverage_blockers, accepted)

    if actual_causes and declared_gate in ("passed", "passed-with-dissent"):
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            f"`gateResult` is `{declared_gate}` but "
            f"{sorted(actual_causes)} blocks approval "
            f"(coverage rows: {coverage_blockers or 'none'}). A Requirement "
            "Coverage `gap` / `blocked C-NNN` row blocks the gate independently "
            "of the worker verdicts (implementation-planning.md "
            '§"Requirement Coverage").'
        )
        return

    if not actual_causes and _PLAN_GATE_RANK.get(declared_gate) == 0:
        # A blocking value with nothing left blocking it. The two checks around
        # this one both walk from a recorded cause outward, so a gate that
        # simply stopped being updated fell between them: a self-fix loop
        # resolved every majority-disagree item, `gateBlockedBy` emptied
        # correctly, and the gate token stayed at its round-1 value. The plan
        # was approvable and nothing said so — run-prep refused the approval,
        # and the refusal propagated far enough to take the run wizard down
        # with it, so no run could be started in that project at all. Rescoring
        # with `okstra plan-verify` and recording what it returns is the fix;
        # the round is not complete until that call agrees with the report.
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            f"`gateResult` is `{declared_gate}` but nothing blocks approval — "
            "no plan item is `majority-disagree`, no dispatch was a non-result, "
            "and no Requirement Coverage row blocks independently. A gate that "
            "withholds approval with no recorded cause is almost always a value "
            "left behind by an earlier round: rescore with `okstra plan-verify` "
            "and record its `gate.recomputed` "
            '(plan-body-verification.md §"Round protocol" step 5).'
        )
        return

    if declared_causes != actual_causes:
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            f"`gateBlockedBy` is {sorted(declared_causes)} but the recorded "
            f"inputs support {sorted(actual_causes)} (coverage rows blocking "
            f"independently: {coverage_blockers or 'none'}). Every blocking "
            "input must be named so the reader is not sent looking for a "
            "worker disagreement that never happened "
            '(plan-body-verification.md §"Round protocol" step 5).'
        )


_EVIDENCE_NOTE_RE = re.compile(r"Evidence checked:\s*(?P<body>.+?)(?:\.\s|\.$|$)", re.S)
_EVIDENCE_NONE_RE = re.compile(r"^none\s*[—-]\s*\S")
# `path/to/file.ext:123`, the form the codebase-first rule asks the row to cite.
_EVIDENCE_PATH_LINE_RE = re.compile(r"\S+\.\w+:\d+")


def _validate_clarification_evidence_note(
    data: dict, failures: list[str], *, carried: dict | None = None,
) -> None:
    """Every clarification row must show its codebase-first work.

    The profile requires any ambiguity answerable by `Read` / `Grep` to be
    resolved that way, and calls a row for something the code already answers a
    defect of the phase — but nothing checked it, so rows the codebase could
    have settled reached the user as approval blockers. Requiring the
    `Evidence checked:` note to exist and be well-formed does not prove the
    lookup happened; it makes its absence a failure and a false `none` an
    attributable claim rather than a silent omission.
    """
    if (data.get("header") or {}).get("taskType") != "implementation-planning":
        return
    carried_ids = set((carried or {}).keys())
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict):
            continue
        row_id = str(row.get("id") or "<unknown>")
        if row_id in carried_ids:
            continue
        statement = str(row.get("statement") or "")
        match = _EVIDENCE_NOTE_RE.search(statement)
        if not match:
            failures.append(
                f"final-report data.json: clarification `{row_id}` has no "
                "`Evidence checked:` note in its `statement`. Every row must "
                "record the codebase lookup that failed to settle it — either "
                "`Evidence checked: <path:line>` or `Evidence checked: none — "
                "<human-only reason>` (implementation-planning.md "
                '§"Evidence note required inside `Statement`").'
            )
            continue
        body = match.group("body").strip()
        if _EVIDENCE_NONE_RE.match(body) or _EVIDENCE_PATH_LINE_RE.search(body):
            continue
        failures.append(
            f"final-report data.json: clarification `{row_id}` has a malformed "
            f"`Evidence checked:` note ({body[:60]!r}). It must cite a concrete "
            "`<path>:<line>` or use the form `none — <human-only reason>`; a "
            "bare assertion records no lookup."
        )


_CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
# The four profiles that read `_clarification-recommendation.md`. Unlike the
# evidence-note gate above — which is called from inside the
# `implementation-planning` branch — this one is called phase-agnostically, so
# the task-type filter here is the only thing scoping it.
_CLARIFICATION_OPTION_TASK_TYPES = frozenset({
    "error-analysis",
    "implementation-planning",
    "improvement-discovery",
    "requirements-discovery",
})
# `in-repo` and `cross-repo` answer the same question, so exactly one may hold.
_REACH_TOKENS = frozenset({"in-repo", "cross-repo"})
_LEGACY_EXPECTED_FORM_RE = re.compile(r"\b(?:Recommended|Alternatives):")


_CLARIFICATION_SECTION_CITE_RE = re.compile(r"§\d")


def _validate_clarification_record_coordinates(
    data: dict, failures: list[str]
) -> None:
    """A clarification row may cite a record coordinate, not a section number.

    `§4.7` exists only on one full reading copy. The report record identifies
    a row by `id` (`RB-002`). schema-v1 has no record and is exempt.
    """
    if data.get("schemaVersion") != "2.0":
        return
    texts: list[tuple[str, str]] = []
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict):
            continue
        row_id = str(row.get("id") or "<unknown>")
        for field in ("statement", "expectedForm"):
            texts.append((f"`{row_id}` {field}", str(row.get(field) or "")))
        options = row.get("options") or []
        if isinstance(options, list):
            for index, option in enumerate(options):
                if not isinstance(option, dict):
                    continue
                for field in ("answer", "rationale"):
                    texts.append(
                        (
                            f"`{row_id}` options[{index}].{field}",
                            str(option.get(field) or ""),
                        )
                    )
    for label, text in texts:
        if _CLARIFICATION_SECTION_CITE_RE.search(text):
            failures.append(
                f"final-report data.json: clarification {label} cites a "
                "section number (`§…`). Cite a record coordinate (`RB-002`) "
                "instead — a section number exists only on one full reading "
                "copy."
            )


def _validate_clarification_options(data: dict, failures: list[str]) -> None:
    """A `decision` row must carry its choices as data, not as prose.

    The choices used to live inside the `expectedForm` string, where two
    separate parsers split them differently and neither was checked — the board
    the user picked from could disagree with the board the report meant.
    Structured options remove the parsing; this gate keeps the structure
    honest. Whether an impact claim is *true* is the adversarial round's job,
    exactly as with `Evidence checked:`.

    schema-v1 is exempt because it cannot comply: it keeps clarifications as a
    Markdown table of strings and its schema forbids an `options` property, so
    demanding one would fail every v1 `decision` row for a structure the format
    has no place to hold.
    """
    if data.get("schemaVersion") not in _CLARIFICATION_OPTION_SCHEMA_VERSIONS:
        return
    task_type = (data.get("header") or {}).get("taskType")
    if task_type not in _CLARIFICATION_OPTION_TASK_TYPES:
        return
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict) or row.get("kind") != "decision":
            continue
        row_id = str(row.get("id") or "<unknown>")
        _validate_option_set(
            row.get("options"), row_id, failures,
            schema_version=str(data.get("schemaVersion") or ""),
        )
        if _LEGACY_EXPECTED_FORM_RE.search(str(row.get("expectedForm") or "")):
            failures.append(
                f"final-report data.json: clarification `{row_id}` still encodes "
                "its choices in `expectedForm` (`Recommended:` / `Alternatives:`). "
                "Choices belong in `options[]`; `expectedForm` states only the "
                "shape of the answer. Two sources for one fact leave consumers "
                "disagreeing about which is authoritative."
            )


def _validate_option_set(
    options: object, row_id: str, failures: list[str], *, schema_version: str = "2.0"
) -> None:
    """Check one `decision` row's `options[]` for pickability and reach."""
    if not isinstance(options, list) or len(options) < 2:
        failures.append(
            f"final-report data.json: clarification `{row_id}` is a `decision` "
            "but does not offer at least two `options[]`. A decision the user "
            "cannot choose between is not a decision."
        )
        return
    entries = [option for option in options if isinstance(option, dict)]
    recommended = sum(1 for option in entries if option.get("role") == "recommended")
    if recommended != 1:
        failures.append(
            f"final-report data.json: clarification `{row_id}` must carry exactly "
            f"one `role: recommended` option (found {recommended}). The reader "
            "needs to know which answer the run stands behind."
        )
    for index, option in enumerate(entries):
        tokens = option.get("scopeImpact")
        reach = (
            [str(option.get("reach"))]
            if schema_version == "3.0" and option.get("reach") in _REACH_TOKENS
            else [token for token in tokens if token in _REACH_TOKENS]
            if isinstance(tokens, list)
            else []
        )
        if len(reach) != 1:
            failures.append(
                f"final-report data.json: clarification `{row_id}` option "
                f"[{index}] must declare exactly one reach token — `in-repo` or "
                f"`cross-repo` (found {len(reach)}). The reader cannot weigh an "
                "option whose reach is unstated or self-contradictory."
            )


def _validate_open_approval_blocker_provenance(
    data: dict, failures: list[str]
) -> None:
    """An open approval blocker records who raised it and what the lead did about it.

    A lead can foresee a blocker, write it into the report as a
    `blocks: approval` row, and let the run finish — the row then withholds
    approval until a separate user-response cycle answers it. That path cost a
    full planning run: the user pointed at a reduction document and said "remove
    what it flags", the lead read that document's own "user decision required"
    note as outranking the instruction, and told the report writer to raise the
    item instead of applying it. The two verifiers then agreed unanimously that
    it was a user decision — not an independent finding, but the lead's own
    instruction returning to it. The run spent its whole self-fix budget on a
    gate no round could clear, because the thing blocking it was already
    answered before the run started.

    `origin` names who produced the row, so a lead-authored blocker can no
    longer arrive dressed as a worker consensus. `userConfirmation` records what
    the lead did before writing it. Neither is required on a row the user has
    already answered (`answered` / `resolved`) — the record is in `userInput`.

    The one shape rejected outright is a lead-directed blocker raised with no
    interactive session: with nobody to ask, a defect the lead itself surfaced
    belongs in `## 5. Missing Information and Risks` as a Working Assumption,
    the same outlet the surviving planner-fixable items already use. Blocking a
    plan on the lead's own judgment, in a run where that judgment cannot be put
    to the user, only defers the work to a re-run.
    """
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict):
            continue
        if row.get("blocks") != "approval" or row.get("status") != "open":
            continue
        row_id = str(row.get("id") or "<unknown>")
        origin = row.get("origin")
        confirmation = row.get("userConfirmation")
        if not origin:
            failures.append(
                f"final-report data.json: clarification `{row_id}` is an open "
                "approval blocker with no `origin`. Record who raised it — "
                "`worker-finding` (an analyser or verifier reached it "
                "independently), `material-gap` (the brief and the codebase "
                "both leave it unanswered), or `lead-directed` (the lead's own "
                "judgment, including anything the lead instructed a worker to "
                "raise). A lead-authored blocker recorded as a worker consensus "
                "is how a run blocks on a question the user already answered."
            )
        if not confirmation:
            failures.append(
                f"final-report data.json: clarification `{row_id}` is an open "
                "approval blocker with no `userConfirmation`. Record what "
                "happened before the row was written — `asked-and-answered`, "
                "`asked-awaiting`, or `deferred-no-interactive-session`. "
                "Withholding approval is the most expensive thing a report can "
                "do to a run; doing it without recording whether the user was "
                "ever asked leaves the next run unable to tell a real blocker "
                "from a question nobody put to them."
            )
        if (
            origin == "lead-directed"
            and confirmation == "deferred-no-interactive-session"
        ):
            failures.append(
                f"final-report data.json: clarification `{row_id}` blocks "
                "approval on the lead's own judgment (`origin: lead-directed`) "
                "in a run with no interactive session to answer it. Record it "
                "as a Working Assumption in `## 5. Missing Information and "
                "Risks` and let the plan proceed — that is the outlet surviving "
                "planner-fixable items already use. A blocker nobody can answer "
                "inside this run only moves the work to a re-run."
            )


def _has_clarification_backtrace(
    row_id: str, plan_items: object, coverage: object
) -> bool:
    """Whether the plan records anything this clarification blocks.

    Two link shapes, both authored by the same run: the `P-*` plan item that
    carries the `clarificationId`, and the requirement-coverage row blocked on
    the id. `incremental-scope` resolves impacted stages from exactly these
    two, and the coverage side goes through its predicate so the gate and the
    resolver cannot disagree about what counts as a link.
    """
    if isinstance(plan_items, list) and any(
        row_id in _plan_item_clarification_ids(item) for item in plan_items
    ):
        return True
    return isinstance(coverage, list) and any(
        coverage_row_blocked_on(row, row_id) for row in coverage
    )


def _validate_approval_clarification_backtrace(
    data: dict, failures: list[str]
) -> None:
    """An approval blocker must record what it blocks.

    `_validate_plan_body_clarification_matching` already walks the other
    direction — a majority-disagree plan item must cite a `blocks: approval`
    row. Nothing walked this way, so a row could withhold approval while
    recording no blast radius at all. The cost lands on the re-run:
    `incremental-scope` resolves impacted stages from these links and will not
    silently narrow past an id that traces to no stage, so this report fails
    rather than forcing a full re-run.

    The link must also *resolve to a stage*, which is the thing the re-run
    actually reads. Checking only that a link exists let a row satisfy this
    gate while the next re-run still could not place the answer: `P-Req-*`
    and `P-Val-*` ids are numbered by position in their own array, so they
    carry no stage, and a blocked coverage row whose `coveredBy` is prose
    cites none either.
    """
    if (data.get("header") or {}).get("taskType") != "implementation-planning":
        return
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return
    coverage = planning.get("requirementCoverage")
    verification = planning.get("planBodyVerification")
    plan_items = (
        verification.get("planItems") if isinstance(verification, dict) else None
    )
    for row in data.get("clarificationItems") or []:
        if not isinstance(row, dict) or row.get("blocks") != "approval":
            continue
        row_id = str(row.get("id") or "<unknown>")
        if not _has_clarification_backtrace(row_id, plan_items, coverage):
            failures.append(
                f"final-report data.json: clarification `{row_id}` blocks approval "
                "but has no back-trace into the plan — no plan item carries it as "
                "`clarificationId`, and no requirement-coverage row is `blocked "
                f"{row_id}` in its `status` or `approvalDisposition`. An item that "
                "withholds approval without recording what it affects cannot "
                "place the next re-run's scope; this report fails rather than "
                "forcing a full re-run."
            )
            continue
        if stages_for_clarification(data, row_id):
            continue
        failures.append(
            f"final-report data.json: clarification `{row_id}` blocks approval "
            "and is linked, but the link resolves to no stage. `incremental-"
            "scope` reads the stage from a `P-Step-<stage>.<step>` / `P-Prep-"
            "S<stage>-<kind>` plan-item id, from `stageScope` / `stageRefs` on "
            "the linked plan item or coverage row, or from a `Stage N` citation "
            f"in the blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` "
            "id carries no stage number, so a row linked only that way must "
            "carry `stageRefs` or cite the stage in `coveredBy`. A blocker "
            "whose blast radius resolves to no stage cannot auto-narrow the "
            "next re-run; this report fails rather than forcing a full re-run."
        )


_RERUN_FLAG = "--answered-clarifications"
_USER_RESPONSE_HINT = re.compile(r"okstra-user-response", re.IGNORECASE)
_APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)


def _next_step_texts(steps: object) -> list[str]:
    """Every reader-visible string in `recommendedNextSteps`, prose and command."""
    texts: list[str] = []
    for step in steps if isinstance(steps, list) else []:
        if not isinstance(step, dict):
            continue
        texts.append(str(step.get("text") or ""))
        for command in step.get("commands") or []:
            if isinstance(command, dict):
                texts.append(str(command.get("claudeCode") or ""))
                texts.append(str(command.get("terminal") or ""))
    return texts


def _has_unresolved_approval_blocker(data: dict) -> bool:
    return bool(
        progress_blocking_ids(
            data.get("clarificationItems"),
            APPROVAL_BLOCKS,
            report_data=data,
        )
    )


def _planning_gate_blocks_approval(data: dict) -> bool:
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return False
    verification = planning.get("planBodyVerification")
    if not isinstance(verification, dict):
        return False
    gate = str(verification.get("gateResult") or "").strip().lower()
    if gate == "aborted-non-result":
        return True
    if gate != "blocked-by-disagreement":
        return False
    return _has_unresolved_approval_blocker(data) or not any(
        isinstance(row, dict) and row.get("blocks") == "approval"
        for row in data.get("clarificationItems") or []
    )


def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
    """A report that withholds approval must say how to come back from it.

    The way forward — answer the blockers, then re-run carrying those ids —
    lived only in the lead prompt, which is read *after* the next run has
    already started. The person who has to act reads the report instead, and it
    told them nothing about the next command. Requiring the flag by name is a
    low bar deliberately: it does not check that the rest of the step is right,
    only that the report stops leaving the reader to work the mechanics out.
    """
    if (data.get("header") or {}).get("taskType") != "implementation-planning":
        return
    if not _has_unresolved_approval_blocker(data):
        return
    texts = _next_step_texts(data.get("recommendedNextSteps"))
    if any(_RERUN_FLAG in text for text in texts) and any(
        _USER_RESPONSE_HINT.search(text) for text in texts
    ):
        return
    failures.append(
        "final-report data.json: this plan withholds approval on an unresolved "
        "`blocks: approval` clarification, but no `recommendedNextSteps` entry "
        "tells the reader the command to run now — name `/okstra-user-response` "
        f"and the `{_RERUN_FLAG}` re-run in a step's `text` or one of its "
        "`commands`. `okstra recap assemble` prints the exact ids and flag "
        "value once the answers are recorded."
    )


def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
    """승인 가능한 plan-ready 는 사용자에게 승인하라고 말해야 한다.

    포인터가 implementation/ready 여도 승인은 사용자만 뒤집는다. 다음 단계
    안내가 계획 재실행이면 승인 칸을 건너뛰고 같은 단계를 다시 돈다.
    """
    if (data.get("header") or {}).get("taskType") != "implementation-planning":
        return
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
        return
    if _has_unresolved_approval_blocker(data) or _planning_gate_blocks_approval(data):
        return
    if _report_already_approved(data):
        return
    if any(_APPROVE_HINT.search(text) for text in _next_step_texts(
        data.get("recommendedNextSteps")
    )):
        return
    failures.append(
        "final-report data.json: this plan is ready for the user to approve, "
        "but no `recommendedNextSteps` entry tells the reader to approve — "
        "name `--approve` or the in-session wizard in a step's `text` or "
        "one of its `commands`. Do not recommend another "
        "implementation-planning run."
    )


def _validate_self_fix_grouping(data: dict, failures: list[str]) -> None:
    """A self-fix round must be instructed by cause, not as a flat item list.

    Blocked items are usually several derivatives of one defect. Instructed
    item-by-item, each patch corrects its own section and leaves the sibling
    sections still asserting the old value, so the next round re-finds the same
    family and the budget drains without converging. Recording the grouping
    makes the lead commit to a diagnosis and makes a one-group-per-item
    non-diagnosis visible in the artifact rather than invisible in a prompt.

    Recording rounds here also ties `selfFixRoundsApplied` to work that exists
    in the data: it was a free-floating self-reported integer, yet
    `_validate_self_fix_before_clarification` gates promotion on its value.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    rounds_applied = pbv.get("selfFixRoundsApplied")
    if not isinstance(rounds_applied, int) or rounds_applied < 1:
        return

    groups = [g for g in (pbv.get("selfFixGroups") or []) if isinstance(g, dict)]
    if not groups:
        failures.append(
            "final-report data.json: planBodyVerification declares "
            f"`selfFixRoundsApplied`={rounds_applied} but records no "
            "`selfFixGroups`. Each round's targets MUST be grouped by common "
            "cause before being handed to report-writer — a flat item list "
            "makes every patch leave its siblings' contradictions standing "
            '(plan-body-verification.md §"Round protocol" step 7).'
        )
        return

    rounds = [g.get("round") for g in groups if isinstance(g.get("round"), int)]
    if rounds and max(rounds) != rounds_applied:
        failures.append(
            "final-report data.json: planBodyVerification "
            f"`selfFixRoundsApplied`={rounds_applied} does not match the highest "
            f"round recorded in `selfFixGroups` ({max(rounds)}). The round count "
            "must be derivable from recorded work, not asserted independently of "
            "it — promotion eligibility is gated on this number."
        )

    known_ids = {
        str(item.get("id")).strip()
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict) and str(item.get("id") or "").strip()
    }
    grouped_ids = [
        str(item_id).strip()
        for group in groups
        for item_id in (group.get("itemIds") or [])
        if str(item_id or "").strip()
    ]
    unknown = sorted({i for i in grouped_ids if i not in known_ids})
    if unknown:
        failures.append(
            "final-report data.json: planBodyVerification.selfFixGroups targets "
            f"plan item(s) {unknown} that do not exist in `planItems`."
        )

    fixed_ids = {
        str(item.get("id")).strip()
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
        and str(item.get("selfFixNote") or "").strip()
        and str(item.get("id") or "").strip()
    }
    ungrouped = sorted(fixed_ids - set(grouped_ids))
    if ungrouped:
        failures.append(
            "final-report data.json: plan item(s) "
            f"{ungrouped} carry a `selfFixNote` but appear in no "
            "`selfFixGroups` entry. Every item a round corrected must be "
            "attributable to the cause group it was instructed under."
        )


_ANSWERED_CLARIFICATION_STATUSES = frozenset({"answered", "resolved"})


def _answered_clarification_ids(data: dict) -> list[str]:
    """Clarifications this run incorporated an answer for — the rows whose
    answers can invalidate statements the previous run wrote."""
    return [
        str(row.get("id")).strip()
        for row in (data.get("clarificationItems") or [])
        if isinstance(row, dict)
        and str(row.get("status") or "").strip() in _ANSWERED_CLARIFICATION_STATUSES
        and str(row.get("userInput") or "").strip()
        and str(row.get("id") or "").strip()
    ]


def _validate_supersession_ledger(data: dict, failures: list[str]) -> None:
    """Incorporating an answer means retiring what it invalidates, not only
    adding what it decides.

    A re-run reconciles each `C-*` row's `Status` and writes the new decision
    into the plan, but nothing required it to remove the sentences the answer
    made false. The result is one plan carrying two opposite instructions for
    the same symbol — the implementer then has to guess which one is live, and
    the §5.5.9 round correctly blocks on it. This check makes the writer state,
    per answered clarification, what it retired or why nothing was contingent
    on that answer. The claim's *truth* is what the §5.5.9 adversarial round
    tests; this only forces the claim to exist and be attributable.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    answered = _answered_clarification_ids(data)
    if not answered:
        return
    ledger = [e for e in (ip.get("supersessionLedger") or []) if isinstance(e, dict)]
    covered = {
        str(entry.get("clarificationId") or "").strip()
        for entry in ledger
        if str(entry.get("clarificationId") or "").strip()
    }
    missing = [cid for cid in answered if cid not in covered]
    if missing:
        failures.append(
            "final-report data.json: implementationPlanning.supersessionLedger has "
            f"no entry for answered clarification(s) {sorted(missing)}. Every "
            "answer this run incorporated MUST record what it superseded "
            "(`disposition: superseded` with the retired statement and the "
            "sections revised) or state that no plan statement was contingent "
            "on it (`disposition: no-dependent-statement` with a rationale). "
            "Adding the new decision while leaving the contradicting sentence "
            "in place is what puts two opposite instructions in one plan "
            '(_common-contract.md §"clarification response carry-in").'
        )
    stale = sorted(covered - set(answered))
    if stale:
        failures.append(
            "final-report data.json: implementationPlanning.supersessionLedger "
            f"cites {stale}, which this run did not answer. A ledger entry must "
            "correspond 1:1 to a clarification whose answer this run "
            "incorporated."
        )


def _safe_resolve(path: Path) -> Path | None:
    """``path.resolve()`` that returns None instead of raising, so a
    malformed recorded path degrades to "no match" rather than a crash."""
    try:
        return path.resolve()
    except (OSError, ValueError):
        return None


def _carry_in_source_for_run(run_manifest_path: Path) -> str:
    """The carry-in source path THIS run was launched with, read off the
    per-run ``run-inputs-<task-type>-<seq>.json`` sibling. Empty string when
    the run had no carry-in or the evidence is unreadable.

    File existence is NOT a usable signal here: ``instruction-set/`` lives at
    the task root and is shared by every run of the task-key, and
    ``run.py:_write_instruction_set_sources`` only mkdirs and overwrites — it
    never clears the directory. So a ``clarification-response.md`` on disk may
    have been staged by an earlier run, and keying off the file alone would
    fail every later run of a task that ever used ``--clarification-response``.
    The run-inputs record is the only per-run evidence.
    """
    name = run_manifest_path.name
    if not name.startswith("run-manifest-") or not name.endswith(".json"):
        return ""
    inputs_path = run_manifest_path.with_name(
        name.replace("run-manifest-", "run-inputs-", 1)
    )
    # Absent/malformed run-inputs has its own reporting path
    # (`_approved_plan_path_from_run_inputs`); discard the reader's
    # conformance-gate failures so this check stays silent on that input.
    payload = _read_run_inputs_payload(inputs_path, [])
    if payload is None:
        return ""
    inputs = payload.get("inputs")
    if not isinstance(inputs, dict):
        return ""
    raw = inputs.get("clarificationResponsePath")
    return raw.strip() if isinstance(raw, str) else ""


def _clarification_text_for_run(
    run_manifest_path: Path, report_path: Path
) -> str:
    """Read only the clarification input explicitly attached to this run."""
    if not _carry_in_source_for_run(run_manifest_path):
        return ""
    task_root = _task_root_from_run_dir(report_path.parent.parent)
    staged = task_root / "instruction-set" / "clarification-response.md"
    if not staged.is_file():
        return ""
    try:
        return staged.read_text(encoding="utf-8")
    except OSError:
        return ""


def _validate_clarification_carry_in_recorded(
    data: dict,
    report_path: Path,
    run_manifest_path: Path,
    project_root: Path,
    failures: list[str],
) -> None:
    """A clarification response carried into this run must be recorded in §0.

    Enforcement ran in one direction only: `_EMPTY_CARRY_IN_RE` fails a report
    that emits an empty `## 0.` stub when nothing was carried in. Nothing
    failed the opposite — a run launched with `--clarification-response` whose
    report never records `clarificationCarryIn.sourceFile`. The template gates
    the whole `## 0.` section on that field, so an unrecorded carry-in makes
    the section vanish and the user's answers leave no audit trace at all.
    `_validate_supersession_ledger` covers a slice of this but returns unless
    `implementationPlanning` is a dict, so it is inert for `implementation`
    and every other non-planning phase.

    Two spellings of `sourceFile` are accepted because two shipped contracts
    disagree: `_implementation-deliverable.md` and `launch.template.md` name
    the staged copy, while `report-writer.md` and the schema description name
    the source the run was launched with. Both identify a real file this run
    consumed, so failing either one would be a false accusation.
    """
    source = _carry_in_source_for_run(run_manifest_path)
    if not source:
        return
    task_root = _task_root_from_run_dir(report_path.parent.parent)
    staged = task_root / "instruction-set" / "clarification-response.md"
    if not staged.is_file():
        return

    carry_in = data.get("clarificationCarryIn")
    recorded = (
        str(carry_in.get("sourceFile") or "").strip()
        if isinstance(carry_in, dict)
        else ""
    )
    if not recorded:
        failures.append(
            "final report does not record the clarification response this run "
            f"was launched with (`{source}`, staged at `{staged}`). "
            "`clarificationCarryIn.sourceFile` is missing from the data.json, "
            "so the renderer omits `## 0. Clarification Response Carried In "
            "From Previous Run` entirely and nothing in the report shows which "
            "user answers it incorporated. Set `clarificationCarryIn."
            "sourceFile` to the staged carry-in path."
        )
        return

    accepted = {p for p in (_safe_resolve(staged),) if p is not None}
    source_path = Path(source)
    if source_path.is_absolute():
        resolved_source = _safe_resolve(source_path)
        if resolved_source is not None:
            accepted.add(resolved_source)

    recorded_path = Path(recorded)
    # A recorded relative value may be project-root-relative (schema wording)
    # or task-root-relative (`instruction-set/clarification-response.md`, the
    # deliverable profile's wording); accept whichever lands on a real target.
    bases = (
        (recorded_path,)
        if recorded_path.is_absolute()
        else (project_root / recorded_path, task_root / recorded_path)
    )
    candidates = {p for p in (_safe_resolve(b) for b in bases) if p is not None}
    if candidates & accepted:
        return

    failures.append(
        f"final report records `clarificationCarryIn.sourceFile` as "
        f"`{recorded}`, which resolves to neither the carry-in this run staged "
        f"(`{staged}`) nor the source it was launched with (`{source}`). "
        "`## 0.` must name the file whose answers this run actually "
        "incorporated — pointing it at some other file makes the section "
        "unauditable."
    )


_PASSING_VERDICT_TOKENS = frozenset({"accepted", "conditional-accept"})


def _consumers_rows(report_path: Path) -> list[dict] | None:
    """`runs/implementation-planning/consumers.jsonl` rows for this task.

    ``None`` when the report path is unreadable as a run reference or the file
    is absent, so the caller does not turn a missing artifact into a false
    accusation.
    """
    try:
        ref = RunRef.from_report_path(report_path)
    except ValueError:
        return None
    path = ref.sibling("implementation-planning").run_dir / "consumers.jsonl"
    if not path.is_file():
        return None
    rows = []
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    except (OSError, json.JSONDecodeError):
        return None
    return rows


_TARGET_FIELD_RES = {
    "scope": re.compile(r"\*\*Verification scope:\*\*\s*`([^`]*)`"),
    "worktree": re.compile(r"\*\*Worktree:\*\*\s*`([^`]*)`"),
    "base": re.compile(r"\*\*Verification base ref:\*\*\s*`([^`]*)`"),
    "head": re.compile(r"\*\*Verification head ref:\*\*\s*`([^`]*)`"),
}
_TARGET_STAGES_RE = re.compile(r"\*\*Stages under verification:\*\*\s*\[([^\]]*)\]")
# Anchored at the line start so the cut excludes the `- ` list marker; the
# digest covers the snapshot body only, so leaving the marker in shifts the
# hash and makes every well-formed target look tampered with.
_TARGET_DIGEST_RE = re.compile(
    r"^- \*\*Verification target digest:\*\*\s*`([^`]*)`", re.M
)


def _read_verification_target(project_root: Path, relative: str) -> dict | None:
    """The prepared target snapshot, but only when its digest still checks out.

    The digest covers the snapshot body (everything before the digest line, as
    `write_verification_target_snapshot` normalizes it). A file that no longer
    matches its own digest is not evidence of anything, so return ``None``
    rather than compare against text someone edited after prep.
    """
    path = project_root / relative
    if not path.is_file():
        return None
    try:
        content = path.read_text(encoding="utf-8")
    except OSError:
        return None
    digest_match = _TARGET_DIGEST_RE.search(content)
    if digest_match is None:
        return None
    body = content[: digest_match.start()]
    body = body.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
    recomputed = "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest()
    if recomputed != digest_match.group(1).strip():
        return None
    parsed = {
        key: (match.group(1).strip() if (match := pattern.search(body)) else "")
        for key, pattern in _TARGET_FIELD_RES.items()
    }
    stages_match = _TARGET_STAGES_RE.search(body)
    parsed["stages"] = (
        {int(v) for v in re.findall(r"\d+", stages_match.group(1))}
        if stages_match
        else set()
    )
    return parsed


_PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")


def _validate_plan_body_state_file(
    data: dict,
    report_path: Path,
    failures: list[str],
    state_path: Path | None = None,
) -> None:
    """The per-round state file must exist once a round has run.

    Nothing read this file, so its documented schema was dead contract — yet
    it is the only record of *superseded* rounds. `planItems[].verdicts` in
    data.json is overwritten by each self-fix re-verification, so after the
    loop the report shows the final votes and no trace of what the earlier
    rounds found. Both defect investigations of this phase depended on the
    sidecar to recover that history.

    Deliberately does NOT cross-check any gate against data.json: the two are
    different views by design (per-round history vs. final state), and
    demanding equality would fail every run whose self-fix loop worked.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    state_dir = report_path.parent.parent / "state"
    if state_path is not None:
        # 호출자가 경로를 넘겼으면 그걸 본다. 리드는 launch 프롬프트의
        # `Run Paths` 에서 정본 경로를 받으므로, 여기서 이름을 다시 만들면
        # 그 정본과 어긋날 수 있다 — 실제로 그랬다.
        written = [state_path] if state_path.is_file() else []
    else:
        # 이름을 유도할 근거가 없다. run 은 seq 계열을 둘 갖고(`state` /
        # `reports`) 리포트 정본은 자기 run 의 state seq 를 담지 않으므로,
        # 리포트 seq 로 만든 이름은 추측이다. 이 검사가 묻는 것은 "덮어써진
        # 라운드의 기록이 남았는가" 이지 파일 이름이 아니므로, 이 run 의 상태
        # 디렉터리에 사이드카가 있는지만 본다. 이름의 정본은 `paths.py` 다.
        written = sorted(
            state_dir.glob("plan-body-verification-implementation-planning-*.json")
        )
    if not written:
        failures.append(
            f"plan-body verification ran ({round_count} round(s)) but no "
            f"`state/plan-body-verification-*.json` was written. It is the only "
            "record of superseded rounds — data.json keeps just the final "
            "verdicts, so without it a self-fixed run leaves no trace of what "
            'the earlier rounds found (plan-body-verification.md §"schema"). '
            "The path is rendered into the launch prompt's `Run Paths` block; "
            "write it there rather than deriving a name."
        )
        return
    # 여럿이면 가장 최신(seq 가 큰) 것이 이 run 의 것이다.
    expected = written[-1]
    try:
        state = json.loads(expected.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        failures.append(f"plan-body verification state file is unreadable: {exc}")
        return
    missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
    for key in missing:
        failures.append(
            f"plan-body verification state file `{expected.name}` is "
            f"missing required key `{key}`."
        )
    if not missing:
        _validate_plan_body_state_rounds(
            state, pbv, expected.name, round_count, failures
        )


def _validate_plan_body_state_rounds(
    state: dict,
    pbv: dict,
    name: str,
    round_count: int,
    failures: list[str],
) -> None:
    """Every round that ran must survive in the sidecar, its votes included.

    The round protocol used to write this file once, before the self-fix loop,
    and never asked for it again — so a run with three re-verifications kept
    round 1 only, and the superseded rounds this file exists to preserve were
    exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
    data.json against `round` 1 here).
    """
    history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
    # A non-int `round` names no round, so it cannot cover one — and reading it
    # into a set would abort the whole validation on unhashable lead-authored JSON.
    recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
    if recorded != set(range(1, round_count + 1)):
        seen = sorted(recorded)
        failures.append(
            f"plan-body verification state file `{name}` records `roundHistory[]` "
            f"rounds {seen} but the report declares `roundCount`={round_count}. "
            f"One entry per round 1..{round_count} is required: data.json keeps "
            "only the final verdicts, so a sidecar frozen at an earlier round "
            "loses every round it superseded (plan-body-verification.md "
            '§"Round protocol" step 7 "Round completion").'
        )
    gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
    if gateless:
        failures.append(
            f"plan-body verification state file `{name}`: `roundHistory[]` "
            f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
            "gate is what tells the reader which round blocked and on what, and "
            "the sidecar is the only place it survives."
        )
    declared = pbv.get("selfFixRoundsApplied")
    if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
        failures.append(
            f"plan-body verification state file `{name}` records "
            f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
            f"report declares {declared}. The sidecar is rewritten at each round's "
            "end, so a stale count means the later rounds were never written to it."
        )
    voted = {
        vote["round"]
        for item in (state.get("planItems") or [])
        if isinstance(item, dict)
        for vote in (item.get("rounds") or [])
        if isinstance(vote, dict) and isinstance(vote.get("round"), int)
    }
    uncited = [n for n in sorted(recorded & set(range(1, round_count + 1)))
               if n not in voted]
    if uncited:
        failures.append(
            f"plan-body verification state file `{name}`: round(s) {uncited} "
            "appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
            "records a vote cast in them. A re-verification round whose verdicts "
            "were never written down is precisely the history this file holds."
        )


_QA_NOT_CONFIGURED_TEMPLATE = "qa-command not configured: {category}"


def _validate_missing_qa_categories_recorded(
    data: dict,
    project_root: Path,
    failures: list[str],
) -> None:
    """"lint is clean" and "lint never ran" must not read the same.

    The verifier contract requires one `qa-command not configured: <category>`
    line per category absent from `project.json.qaCommands`, but the literal
    appeared nowhere in the codebase — so a category that was never executed
    was indistinguishable in the report from one that passed. That reads as
    verification coverage the run does not have.
    """
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    results = [r for r in (implementation.get("verifierResults") or []) if isinstance(r, dict)]
    if not results:
        return
    # `db-test` is deliberately excluded: the contract requires its note only
    # when the diff touches DB/IO/SQL, and that condition is not decidable
    # here. Demanding it unconditionally would fail every project that has no
    # database — a false positive, which in a blocking validator is worse than
    # the gap it closes. The DB case keeps its own gate (the blocking finding
    # `db-test not configured — DB change unverified`).
    unconditional_categories = ("lint", "format", "typecheck", "test")
    path = project_json_path(project_root)
    if not path.is_file():
        return
    try:
        configured = json.loads(path.read_text(encoding="utf-8")).get("qaCommands") or {}
    except (OSError, json.JSONDecodeError):
        return
    if not isinstance(configured, dict):
        return

    recorded = "\n".join(
        str(value)
        for row in results
        for value in row.values()
        if isinstance(value, str)
    )
    missing = [
        category
        for category in unconditional_categories
        if not configured.get(category)
        and _QA_NOT_CONFIGURED_TEMPLATE.format(category=category) not in recorded
    ]
    if missing:
        failures.append(
            "final-report data.json: `project.json.qaCommands` declares no "
            f"command for {missing}, but no verifier result records "
            f"`{_QA_NOT_CONFIGURED_TEMPLATE.format(category='<category>')}` for "
            "them. An unrun category MUST be stated as unrun — otherwise the "
            "report reads as though it passed "
            '(_implementation-verifier.md §"Missing-tier handling").'
        )


def _validate_verification_target_match(
    data: dict,
    run_manifest: dict,
    project_root: Path,
    failures: list[str],
) -> None:
    """The verification report must mirror the target it was prepared against.

    `verificationScope`, the worktree, and the base/head refs were entirely
    self-declared: the schema required the fields to exist but nothing compared
    them to the digest-verified snapshot written at prep time. That matters
    because both `handoff.compute_eligibility` and the `release-handoff`
    routing check read `verificationScope` — a single-stage run that writes
    `whole-task` passes both, and an `accepted` verdict can be rendered against
    a worktree or head nobody verified.
    """
    instruction_set = run_manifest.get("instructionSet")
    if not isinstance(instruction_set, dict):
        return
    relative = str(instruction_set.get("verificationTargetPath") or "").strip()
    if not relative:
        return
    target = _read_verification_target(project_root, relative)
    if target is None:
        return

    source = (data.get("finalVerification") or {}).get("sourceImplementationReport") or {}
    declared = {
        "scope": str(data.get("verificationScope") or "").strip(),
        "worktree": str(source.get("worktreePath") or "").strip(),
        "base": str(source.get("implementationBaseRef") or "").strip(),
        "head": str(source.get("capturedHeadSha") or "").strip(),
    }
    for key, expected in ((k, target[k]) for k in _TARGET_FIELD_RES):
        actual = declared[key]
        if expected and actual and actual != expected:
            failures.append(
                f"final-verification report declares {key} `{actual}` but the "
                f"prepared verification target says `{expected}` "
                f"(`{relative}`). The report must mirror the target it was "
                "prepared against — `verificationScope` in particular gates "
                "both stage-group eligibility and release-handoff routing, so "
                "a self-declared value lets a run be judged as something it "
                "was not."
            )

    declared_stages = {
        row.get("stage")
        for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
        if isinstance(row, dict) and isinstance(row.get("stage"), int)
    }
    if target["stages"] and declared_stages and declared_stages != target["stages"]:
        failures.append(
            f"final-verification report covers stages {sorted(declared_stages)} "
            f"but the prepared target names {sorted(target['stages'])} "
            f"(`{relative}`). A verdict must not be rendered for a stage set "
            "nobody prepared evidence for."
        )


def _validate_verified_row_recorded(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> None:
    """A release-ready single-stage verification must leave its `verified` row.

    `okstra handoff record-verified` validates its own inputs, but nothing
    checked that it ever ran. Skipping it leaves the report saying `accepted`
    while `consumers.jsonl` says the stage was never verified, so
    `handoff.compute_eligibility` never offers it for a stage-group PR — and
    the only recoveries are re-running an expensive phase or hand-editing the
    registry.
    """
    if str(data.get("verificationScope") or "") != "single-stage":
        return
    if not release_handoff_allowed(data):
        return
    stages = {
        row.get("stage")
        for row in ((data.get("finalVerification") or {}).get("stageReports") or [])
        if isinstance(row, dict) and isinstance(row.get("stage"), int)
    }
    if not stages:
        return
    rows = _consumers_rows(report_path)
    if rows is None:
        return
    verified = {
        row.get("stage")
        for row in rows
        if isinstance(row, dict) and row.get("status") == "verified"
    }
    missing = sorted(s for s in stages if s not in verified)
    if missing:
        failures.append(
            f"final-verification cleared stage(s) {missing} for release but "
            "`runs/implementation-planning/consumers.jsonl` carries no "
            "`verified` row for them. Run `okstra handoff record-verified` "
            "before finishing — without that row the report says accepted "
            "while the registry says unverified, and the stage is never "
            "offered for a stage-group PR "
            '(final-verification.md §"Verified-row recording").'
        )


def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> None:
    """A verifier FAIL cannot be dropped during synthesis.

    `implementation.verifierResults[]` was written, read by the stage-fix carry
    helper, and by nothing else — no check compared a recorded `FAIL` against
    the verdict the lead published. A FAIL lost in synthesis lets
    `final-verification` reach `accepted` and `release-handoff` push work a
    verifier rejected.
    """
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    failed = sorted({
        str(row.get("verifier") or "<unknown>")
        for row in (implementation.get("verifierResults") or [])
        if isinstance(row, dict) and str(row.get("verdict") or "").strip() == "FAIL"
    })
    if not failed:
        return
    token = str((data.get("finalVerdict") or {}).get("verdictToken") or "").strip()
    if token in _PASSING_VERDICT_TOKENS:
        failures.append(
            f"final-report data.json: verifier(s) {failed} recorded "
            f"`verdict: FAIL` but `finalVerdict.verdictToken` is `{token}`. A "
            "verifier rejection MUST survive into the published verdict — "
            "dropping it during synthesis is how rejected work reaches "
            "`release-handoff`. Either carry the FAIL into a blocking verdict "
            "or record why the verifier's finding was withdrawn."
        )



_LEAD_AUTHORED = "Okstra lead"
_REPORT_AUTHORING_HEADING_RE = re.compile(r"^## REPORT AUTHORING\s*$", re.MULTILINE)
_REPORT_AUTHORING_APPROVED = "approved"
# `report-writer.md` "Lead-authored fallback": the attempt must have reached one
# of these with a concrete reason. `completed` means the worker produced the
# report, so the lead had nothing to fall back from.
_DISPATCH_FAILURE_STATUSES = {"error", "timeout", "not-run"}


def _report_authoring_approval(report_path: Path) -> str:
    """The user's recorded answer on letting the lead author this report.

    Read from the run's `user-responses/` sidecars, the same channel the
    clarification and plan-decision answers already use. The file is written by
    the user through `okstra user-response write`, which is the point: an
    approval the lead could author itself would be the self-report this gate
    exists to remove.
    """
    sidecar_dir = report_path.parent.parent / "user-responses"
    if not sidecar_dir.is_dir():
        return ""
    for sidecar in sorted(sidecar_dir.glob("*.md")):
        try:
            text = sidecar.read_text(encoding="utf-8")
        except OSError:
            continue
        match = _REPORT_AUTHORING_HEADING_RE.search(text)
        if not match:
            continue
        block = text[match.end():]
        next_heading = re.search(r"^## ", block, re.MULTILINE)
        if next_heading:
            block = block[: next_heading.start()]
        status = re.search(r"^-\s*Status:\s*(.+?)\s*$", block, re.MULTILINE)
        if status:
            return status.group(1).strip()
    return ""


def _validate_lead_authored_report(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> None:
    """A lead-authored final report needs a failed dispatch AND a user approval.

    `header.reportAuthor` renders in the report but nothing read it, so a lead
    could name itself the author with no dispatch behind it. The contract has
    always required a real attempt that recorded a terminal failure with a
    reason; this adds the second door, because a lead that dispatches once,
    lets it fail, and proceeds has still decided alone. Neither door retires
    the other: an approval does not excuse a missing attempt, and an attempt
    that failed is the cue to ask, not the permission.

    The record is not consumed by passing. The failure reason and the approval
    both stay on disk, and `header.reportAuthor` stays `Okstra lead` in the
    rendered report, so a later reader sees that this run took the fallback and
    why.
    """
    header = data.get("header")
    if not isinstance(header, Mapping):
        return
    if str(header.get("reportAuthor") or "").strip() != _LEAD_AUTHORED:
        return
    # release-handoff has no worker roster at all: it is single-lead by design,
    # so there is no dispatch to fail and nothing for the user to permit.
    if str(header.get("taskType") or "").strip() == "release-handoff":
        return

    team_state = data.get("teamState")
    dispatches = (
        team_state.get("workerDispatches") if isinstance(team_state, Mapping) else None
    )
    attempts = [
        row
        for row in (dispatches if isinstance(dispatches, list) else [])
        if isinstance(row, Mapping)
        and str(row.get("workerId") or "").strip() == "report-writer"
    ]
    failed = [
        row
        for row in attempts
        if str(row.get("status") or "").strip() in _DISPATCH_FAILURE_STATUSES
    ]
    if not failed:
        failures.append(
            "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
            "no report-writer dispatch recorded a terminal failure "
            f"({', '.join(sorted(_DISPATCH_FAILURE_STATUSES))}) in team-state. "
            "The lead-authored fallback is reachable only from an attempt that "
            "actually failed (prompts/lead/report-writer.md "
            "'Lead-authored fallback')"
        )
    elif not any(str(row.get("reason") or "").strip() for row in failed):
        failures.append(
            "final-report data.json: the report-writer dispatch failed but "
            "recorded no reason, so the lead-authored fallback rests on an "
            "unexplained failure. Record the tool error, the timeout, or the "
            "external blocker on the dispatch row"
        )

    fallback = header.get("leadAuthoredFallback")
    if not isinstance(fallback, Mapping):
        failures.append(
            "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
            "`header.leadAuthoredFallback` is absent. The approval passes the "
            "gate; it does not erase it — the failure reason and the approving "
            "sidecar belong in the report a human reads, not only in the "
            "sidecars they would have to go find"
        )
    else:
        recorded = str(fallback.get("dispatchFailureReason") or "").strip()
        reasons = {str(row.get("reason") or "").strip() for row in failed}
        if recorded and reasons and recorded not in reasons:
            failures.append(
                "final-report data.json: "
                "`header.leadAuthoredFallback.dispatchFailureReason` does not "
                "match any reason recorded on a failed report-writer dispatch. "
                "Quote the dispatch row verbatim rather than restating it"
            )

    approval = _report_authoring_approval(report_path)
    if approval != _REPORT_AUTHORING_APPROVED:
        found = f"`{approval}`" if approval else "no `## REPORT AUTHORING` block"
        failures.append(
            "final-report data.json: `header.reportAuthor` is `Okstra lead` but "
            f"the run's `user-responses/` sidecars carry {found}. Only the user "
            "may permit the lead to author the report; ask at a gate and have "
            "the answer written through `okstra user-response write`"
        )

def _validate_stage_carry_sidecar_exists(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> None:
    """The stage carry sidecar must exist on disk, not only be transcribed.

    `implementation.stageSidecarEvidence` is prose the report quotes, so a
    report can describe a sidecar that was never written. `consumers` treats
    the carry file as the source of truth for marking a stage `done`, so a
    missing file leaves the stage permanently un-done and blocks every
    dependent stage with a `PrepareError` — while the run that caused it
    finished reporting success.
    """
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    evidence = implementation.get("stageSidecarEvidence")
    if not isinstance(evidence, dict):
        return
    stage = evidence.get("stageNumber")
    if not isinstance(stage, int):
        return
    # A stage whose verifier returned FAIL must NOT persist its carry: the carry
    # file is what marks the stage `done`, and doing that would stack the next
    # stage on a confirmed regression. Such a run states the reason in
    # `withheld` and records a `failed` consumers row instead, so the absent
    # file is the correct outcome, not a gap.
    if str(evidence.get("withheld") or "").strip():
        return
    # Carry sidecars are stage-SHARED: the next stage's carry-in and
    # `consumers.backfill_done_from_carry` glob them without knowing the
    # producing run's layout. `RunRef.carry()` owns that flat-vs-staged rule;
    # resolving it under the stage run dir forced the lead to write it twice.
    carry_path = RunRef.from_report_path(report_path).carry(stage)
    if not carry_path.exists():
        failures.append(
            f"implementation run declares stage-{stage} sidecar evidence but "
            f"`{carry_path.parent.name}/{carry_path.name}` does not exist. The "
            "carry file is what marks the stage `done` for dependent stages; "
            "without it this stage never completes and every successor fails "
            "to prepare, even though this run reported success "
            '(_implementation-executor.md §"Sidecar evidence writer").'
        )


def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
    """A round that ran must leave the votes it ran on — item by item.

    The gate is re-derived from `planItems[].verdicts[]`, so an empty table
    removes the very evidence the recompute judges. A *healthier* declared gate
    is already caught — empty verdicts recompute to `aborted-non-result`, which
    every passing value outranks. What slipped through was the conservative
    declaration: a lead writing `aborted-non-result` over an empty table
    produces a gate nothing can audit, indistinguishable from a round that was
    dispatched and whose results were never transcribed.

    The per-item form is what survives a self-fix loop. Round 2+ queues are
    targeted, so an item the planner adds mid-loop and never puts in one keeps
    an empty `verdicts[]` while every neighbour carries votes — and nothing
    downstream reads that as a gap. An empty table classifies `all-non-result`
    (`_classify_plan_item_gate`), which states as `needs-reverify`, which
    `_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
    no verifier ever judged leaves the gate in a passing value. The whole-table
    check could not see it, since it stands down the moment any one item has a
    vote.

    An unjudged item is distinguishable from a legitimately unresolved one, and
    the difference is what is recorded rather than what is missing. A peer that
    returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
    so an all-error item still carries rows and still folds to `needs-reverify`
    on purpose. An empty table means no dispatch was accounted for at all.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    items = [
        it for it in (pbv.get("planItems") or [])
        if isinstance(it, dict) and _stage_scope_bucket(it, pbv) == "in-scope"
    ]
    if not items:
        return
    empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
    if not empty:
        return
    if len(empty) == len(items):
        failures.append(
            "final-report data.json: planBodyVerification declares "
            f"`roundCount`={round_count} but every one of the {len(items)} "
            "`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
            "record the votes it produced — the gate is re-derived from this "
            "table, so an empty one leaves the declared `gateResult` unauditable. "
            "A dispatch that returned nothing is recorded as `verification-error`, "
            'not omitted (plan-body-verification.md §"Round protocol" step 4).'
        )
        return
    shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
    more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
    failures.append(
        "final-report data.json: planBodyVerification declares "
        f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
        f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
        "extracted plan item MUST be judged by the round — an item with no "
        "vote at all is not a dissent the gate can weigh, it is a plan item "
        "nobody verified, and it currently folds into `passed-with-dissent` "
        "alongside items that were properly cross-checked. Either dispatch it "
        "in this round's queue, or record the non-result as a "
        "`verification-error` verdict per plan-body-verification.md "
        '§"Round protocol" step 3 — an item is never left with no row.'
    )


def _validate_unresolved_tie_was_reverified(
    data: dict,
    failures: list[str],
) -> None:
    """A split panel goes to critic-worker before the gate is declared.

    The gate needs a strict majority to block, so a panel splitting evenly on a
    blocking kind reaches neither consensus nor `majority-disagree`. That state
    is classified `needs-reverify` until `critic-worker` settles it. Passing
    without that vote records a dissent nobody acted on.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    unsettled = sorted({
        str(item.get("id") or "").strip()
        for item in pbv.get("planItems") or []
        if isinstance(item, dict)
        and not item.get("carriedForwardFromSeq")
        and _stage_scope_bucket(item, pbv) == "in-scope"
        and _is_unsettled_tie(item)
    })
    if not unsettled:
        return
    failures.append(
        "final-report data.json: plan item(s) "
        f"{unsettled} carry an even split on a blocking breakage kind and "
        f"have no `{CRITIC_WORKER_ID}` vote. A tie is not consensus. Dispatch "
        f"`{CRITIC_WORKER_ID}` on those items only (`okstra plan-items "
        "prepare --tie-vote`) and record the vote with `okstra plan-items "
        "apply-verdicts --append --round 2`. Critic AGREE settles the split; "
        "critic DISAGREE blocks."
    )


def _validate_tie_received_extra_vote(
    data: dict,
    failures: list[str],
) -> None:
    """동수는 같은 둘을 다시 돌리는 것이 아니라 critic 이 가른다."""
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    accepted = _resolved_noncritical_dissent_ids(data)
    missing = sorted({
        str(item.get("id") or "").strip()
        for item in pbv.get("planItems") or []
        if isinstance(item, dict)
        and not item.get("carriedForwardFromSeq")
        and str(item.get("id") or "").strip() not in accepted
        and _stage_scope_bucket(item, pbv) == "in-scope"
        and _is_even_blocking_split(item)
        and not _critic_non_error_verdicts(item)
    })
    if not missing:
        return
    failures.append(
        f"final-report data.json: plan item(s) {missing} carry an even split "
        "on a blocking breakage kind and have no critic vote. Re-running the "
        "original two does not settle a 1-1 split. Dispatch "
        f"`{CRITIC_WORKER_ID}` whose prompt is those items only "
        "(`okstra plan-items prepare --tie-vote`) and record the vote with "
        "`okstra plan-items apply-verdicts --append --round 2`."
    )


def _is_even_blocking_split(item: dict) -> bool:
    """라운드 승격 없이 차단 kind 의 짝수 분할인지."""
    forced = {
        **item,
        "verdicts": [
            {**row, "round": 1}
            for row in (item.get("verdicts") or [])
            if isinstance(row, dict)
        ],
    }
    return _is_unsettled_tie(forced)


def _validate_advisory_plan_body_gating(data: dict, failures: list[str]) -> None:
    """gating=false 는 검출 표면 0 + 스테이지 1 일 때만 받는다."""
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict) or pbv.get("gating") is not False:
        return
    facts = ip.get("designPreparation") is not None or ip.get("stageMap") or ip.get("stages")
    if facts and not advisory_plan_body_gating(ip):
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            "`gating` is false, but that is only legal when "
            "designPreparation.mode is `no-design-inputs` (empty items) and the "
            "Stage Map has exactly one row. Two-or-more stages, a PREP item, or "
            "non-empty designPreparation items keep the gating contract."
        )
    applied = pbv.get("selfFixRoundsApplied")
    if isinstance(applied, int) and applied > 0:
        failures.append(
            "final-report data.json: implementationPlanning.planBodyVerification "
            "`gating` is false, so the self-fix loop must not run "
            f"(`selfFixRoundsApplied`={applied}). Keep extraction and one "
            "verification round."
        )


def _validate_verdict_rounds_outlive_self_fix(
    data: dict,
    failures: list[str],
) -> None:
    """A verdict must judge the plan the gate is about to pass.

    Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
    so a verdict cast in round R judged the text as it stood after self-fix
    R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
    text has changed and the verdict is stale by construction. No semantic
    analysis is needed to know that; the arithmetic settles it.

    The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
    compares each row's own recorded `subject`, which catches a positional shift
    but not the case that matters here: an item whose own wording never changed
    while the stage it points at was rewritten under it. Observed on a real run
    — the gate read `passed-with-dissent` with zero blockers, and re-running one
    round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
    because their surviving verdicts predated two self-fix rounds.

    Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
    the prior run's record and is judged by that run's seq, not this one's
    rounds.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    applied = pbv.get("selfFixRoundsApplied")
    if not isinstance(applied, int) or applied < 1:
        # With no rewrite after any round there is nothing a verdict can be
        # stale against, and an unstamped row is then simply unremarkable.
        return

    stale: list[str] = []
    unstamped: list[str] = []
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
            continue
        if _stage_scope_bucket(item, pbv) != "in-scope":
            continue
        item_id = str(item.get("id") or "").strip()
        verified = item.get("verifiedContentHash")
        current = item.get("contentHash")
        if (
            isinstance(verified, str)
            and isinstance(current, str)
            and verified == current
        ):
            # 본문이 같으면 라운드 번호가 self-fix 이전이어도 같은 텍스트다.
            continue
        for verdict in item.get("verdicts") or []:
            if not isinstance(verdict, dict):
                continue
            round_number = verdict.get("round")
            if not isinstance(round_number, int) or isinstance(round_number, bool):
                unstamped.append(item_id)
            elif round_number <= applied:
                stale.append(item_id)
    if unstamped:
        failures.append(
            f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
            f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
            "the plan. Without the round there is no way to tell whether the "
            "verdict judged the current text or a version two rewrites old. "
            "Re-record the round's votes with `okstra plan-items apply-verdicts "
            "--round <N>`."
        )
    if stale:
        failures.append(
            f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
            f"verdict from a round at or before self-fix round {applied}, so the "
            "text they judged has since been rewritten. The gate is computed "
            "from these votes, so passing on them declares a plan verified that "
            "nobody verified. Re-verify those items in a round after the last "
            'self-fix (plan-body-verification.md §"Round protocol" step 7).'
        )


def _validate_verdicts_match_current_subjects(
    data: dict,
    failures: list[str],
) -> None:
    """A verdict must still be attached to the element it was cast on.

    `P-*` ids are positional (`plan_items.py` numbers rows by array index), so
    when a self-fix round deletes a plan element every later row shifts up one.
    A dangling id at the tail is already caught by
    `_validate_plan_item_extraction_completeness`, but the shift itself is not:
    the id set still matches while each surviving verdict now points at its
    neighbour. The recorded `subject` is what makes the shift visible — it is a
    snapshot of the row the worker actually judged.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    try:
        current = {
            str(item["id"]): str(item.get("subject") or "")
            for item in extract_plan_items(ip)
        }
    except Exception:  # noqa: BLE001
        # Extraction failure is already reported by the completeness check;
        # do not double-report it here as a spurious subject mismatch.
        return

    drifted = []
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        item_id = str(item.get("id") or "").strip()
        recorded = str(item.get("subject") or "").strip()
        expected = current.get(item_id)
        if expected is None or not recorded:
            continue
        if recorded != expected.strip():
            drifted.append(item_id)
    if drifted:
        failures.append(
            f"final-report data.json: plan item(s) {sorted(drifted)} carry a "
            "`subject` that no longer matches the plan element at that "
            "position. `P-*` ids are positional, so deleting an element during "
            "self-fix shifts every later row and silently re-points its "
            "verdicts at a different element — a recorded blocker then refers "
            "to something the reader cannot find. Re-extract the plan items "
            "and re-verify the shifted ones instead of carrying the old votes "
            'forward (plan-body-verification.md §"Round protocol" step 7).'
        )


def _validate_aborted_gate_has_clarification(data: dict, failures: list[str]) -> None:
    """A gate nobody can act on is a stalled task.

    `aborted-non-result` correctly refuses approval and run-prep fail-closes
    the `implementation` entry, but the clarification matcher only walks
    `majority-disagree` items — and an aborted round has none. So the report
    stated no blocker, `okstra-user-response` had nothing to present, and the
    run stalled with no remedy until someone read the gate value by hand.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    if str(pbv.get("gateResult") or "").strip() != "aborted-non-result":
        return
    has_open_blocker = any(
        isinstance(row, dict)
        and row.get("blocks") == "approval"
        and str(row.get("status") or "").strip() == "open"
        for row in (data.get("clarificationItems") or [])
    )
    if not has_open_blocker:
        failures.append(
            "final-report data.json: planBodyVerification `gateResult` is "
            "`aborted-non-result` but no open `Blocks=approval` clarification "
            "row explains it. An aborted round blocks approval without "
            "producing any majority-disagree item, so without this row the "
            "report names no blocker, `okstra-user-response` has nothing to "
            "present, and the task stalls with no stated remedy. Add a row "
            "naming which dispatches returned no result and what re-running "
            'them requires (plan-body-verification.md §"Round protocol").'
        )


def _plan_verify_result_workers(report_path: Path, task_type: str) -> set[str] | None:
    """Worker roles that actually returned a plan-body reverify result.

    Result files are named
    ``<role-slug>-plan-verify-r<N>-<task-type>-<seq>.md`` per
    `plan-body-verification.md` §"Round protocol" step 3, and the role slug is
    ``<role>-plan-verify-r<N>``. Returns ``None`` when the directory is absent
    so the caller can distinguish "no artifacts to check against" from "nobody
    voted".
    """
    worker_results_dir = report_path.parent.parent / "worker-results"
    if not worker_results_dir.is_dir():
        return None
    # Scoped to this run's seq for the same reason the audit check is: the
    # directory accumulates every run, so an unscoped glob would let a prior
    # run's result file vouch for a vote this run never collected.
    seq = _report_run_seq(report_path)
    pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
    workers = set()
    for path in worker_results_dir.glob(pattern):
        role = path.name.split("-plan-verify-r", 1)[0]
        if role:
            workers.add(role)
    return workers


def _plan_verify_seq_near_misses(report_path: Path, task_type: str) -> list[str]:
    """Plan-verify results in the directory that this run's seq filter excluded.

    The seq comes from the report filename, and a run whose `reports` and
    `workerResults` sequences differ makes the other one look equally plausible
    to a lead naming the file by hand. Naming a near miss is the difference
    between "the file is missing" and "the file is there under a different seq"
    — the first sends a lead to re-dispatch two workers that already ran.
    """
    seq = _report_run_seq(report_path)
    if not seq:
        return []
    directory = report_path.parent.parent / "worker-results"
    matched = set(directory.glob(f"*-plan-verify-r*-{task_type}-{seq}.md"))
    return sorted(
        path.name
        for path in directory.glob(f"*-plan-verify-r*-{task_type}-*.md")
        if path not in matched
    )


def _validate_plan_body_verdict_provenance(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> None:
    """A recorded verdict must trace back to a worker that was actually asked.

    Every §5.5.9 gate computation reads `planItems[].verdicts[]` out of the
    data.json the lead authored, and nothing tied a vote to a dispatch. A lead
    that skipped the round entirely and wrote `AGREE` for two workers produced
    `gateResult: passed`, a flippable `approved:`, and a clean validator run —
    the same self-report weakness `selfFixRoundsApplied` had, but on the votes
    the whole gate is computed from.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return

    voters = {
        str(v.get("worker") or "").strip()
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
        for v in (item.get("verdicts") or [])
        if isinstance(v, dict) and str(v.get("worker") or "").strip()
    }
    if not voters:
        return

    task_type = (
        str((data.get("header") or {}).get("taskType") or "")
        or _report_task_type(report_path)
    )
    if not task_type:
        # Neither source names it, so the glob below would be built from an
        # empty segment and match nothing — reporting every verdict as unbacked
        # on the strength of a path this check could not construct.
        return
    dispatched = _plan_verify_result_workers(report_path, task_type)
    if dispatched is None:
        return
    unbacked = sorted(voters - dispatched)
    if unbacked:
        failures.append(
            "final-report data.json: planBodyVerification records verdicts from "
            f"{unbacked} but no matching plan-body reverify result file exists "
            f"under `runs/{task_type}/worker-results/` "
            f"(globbed `*-plan-verify-r*-{task_type}-"
            f"{_report_run_seq(report_path) or '*'}.md`, where the seq is the "
            f"report's own — `final-report-{task_type}-<seq>` — not this run's "
            f"`workerResults` sequence)"
            + _near_miss_clause(report_path, task_type)
            + ". A vote the gate is computed from MUST trace back to a dispatch "
            "that actually returned — otherwise the round can be skipped and "
            'the gate still read `passed` (plan-body-verification.md §"Round '
            'protocol" step 3).'
        )


def _near_miss_clause(report_path: Path, task_type: str) -> str:
    near = _plan_verify_seq_near_misses(report_path, task_type)
    if not near:
        return ""
    return (
        f"; the directory does hold {near}, which the seq filter excluded — "
        f"rename to this run's report seq rather than re-dispatching"
    )


_UNIFORM_VERIFIER_MIN_ITEMS = 5


def _detect_uniform_verifier(pbv: dict) -> list[str]:
    """Verifiers whose every vote in the round was the same verdict.

    `participatingAnalysers` counts whether a worker voted, not whether the
    votes carried information. A verifier that answers AGREE to every item is
    counted as a third opinion while contributing no refutation signal, so the
    report reads as a three-way cross-check backed by two. (fontsninja-nlpvibe
    `nlpvibe-vs-fontradar-baseline` seq 001: 63/63 AGREE off six inspected
    evidence paths, on a round where the two other analysers jointly refuted a
    real defect.)

    Advisory only. A unanimous round is a legitimate outcome, and any ratio
    strict enough to catch a rubber stamp also fails honest agreement, so this
    reports the counts and leaves the judgement to the reader.
    """
    items = pbv.get("planItems") if isinstance(pbv, dict) else None
    if not isinstance(items, list):
        return []
    verdicts_by_worker: dict[str, set[str]] = {}
    counts: dict[str, int] = {}
    for item in items:
        if not isinstance(item, dict):
            continue
        for verdict in item.get("verdicts") or []:
            if not isinstance(verdict, dict):
                continue
            worker = str(verdict.get("worker") or "").strip()
            value = str(verdict.get("verdict") or "").strip()
            if not worker or not value or value == "verification-error":
                continue
            verdicts_by_worker.setdefault(worker, set()).add(value)
            counts[worker] = counts.get(worker, 0) + 1
    warnings = []
    for worker in sorted(verdicts_by_worker):
        distinct = verdicts_by_worker[worker]
        total = counts[worker]
        if len(distinct) != 1 or total < _UNIFORM_VERIFIER_MIN_ITEMS:
            continue
        warnings.append(
            f"plan-body verification: {worker} returned `{next(iter(distinct))}` "
            f"for all {total} items it voted on, so this round's refutation "
            "signal came from its peers alone. Confirm the worker actually "
            "opened the cited evidence (its `-audit-` sidecar lists what it "
            "read) before reading the gate as a full cross-check."
        )
    return warnings


_PRIOR_DISSENT_ANCHOR = "**Prior dissent**"
_PLAN_VERIFY_ROUND_RE = re.compile(r"-plan-verify-r(?P<round>\d+)-")


def _plan_verify_round_number(file_name: str) -> int | None:
    match = _PLAN_VERIFY_ROUND_RE.search(file_name)
    return int(match.group("round")) if match else None


def _validate_reverify_result_addresses_prior_dissent(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> None:
    """A round 2+ plan-body verdict must engage the dissent it re-verifies.

    The self-fix loop re-dispatches the same workers against a corrected plan,
    but nothing carried the previous round's objection into the new prompt. The
    worker that objected then restates its verdict unchanged and its peers
    re-judge from nothing, so the loop spends its whole `selfFixMaxRounds`
    budget re-deriving one split instead of settling it. (fontsninja-nlpvibe
    `nlpvibe-vs-fontradar-baseline` seq 001: three self-fix rounds to
    `max-rounds-reached`, gate still `blocked-by-disagreement`, codex holding
    every DISAGREE it opened.)
    """
    worker_results_dir = report_path.parent.parent / "worker-results"
    if not worker_results_dir.is_dir():
        return
    task_type = str((data.get("header") or {}).get("taskType") or "")
    seq = _report_run_seq(report_path)
    pattern = f"*-plan-verify-r*-{task_type}-{seq or '*'}.md"
    silent = []
    for path in sorted(worker_results_dir.glob(pattern)):
        if "-audit-plan-verify-r" in path.name:
            continue
        round_number = _plan_verify_round_number(path.name)
        if round_number is None or round_number < 2:
            continue
        body = path.read_text(encoding="utf-8", errors="replace")
        if _PRIOR_DISSENT_ANCHOR not in body:
            silent.append(path.name)
    if silent:
        failures.append(
            "plan-body re-verification: "
            f"{silent} carry no `{_PRIOR_DISSENT_ANCHOR}` line. A round 2+ "
            "verdict exists to settle the previous round's objection, so the "
            "prompt MUST carry that dissent forward and the worker MUST answer "
            "whether the correction resolved it. Without it the objecting "
            "worker repeats its verdict and its peers judge from nothing, and "
            "the self-fix budget drains on the same split "
            '(plan-body-verification.md §"Re-verification rounds (round 2+)").'
        )


def _validate_plan_item_extraction_completeness(
    data: dict,
    failures: list[str],
) -> None:
    """Require the exact deterministic P-* extraction when a round ran."""
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    try:
        expected_sequence = expected_plan_item_ids(ip)
    except Exception as exc:  # noqa: BLE001
        failures.append(
            "final-report data.json: deterministic plan-item extraction failed: "
            f"{exc}"
        )
        return

    actual_sequence = [
        str(item.get("id") or "").strip()
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
    ]
    expected_ids = set(expected_sequence)
    actual_ids = set(actual_sequence)
    missing = expected_ids - actual_ids
    unexpected = actual_ids - expected_ids
    duplicate_ids = {
        item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
    }

    if missing:
        failures.append(
            "final-report data.json: planBodyVerification.planItems is missing "
            "deterministically extracted verdict item ID(s): "
            + ", ".join(sorted(missing))
        )
    if unexpected:
        failures.append(
            "final-report data.json: planBodyVerification.planItems contains "
            "unexpected verdict item ID(s): "
            + ", ".join(sorted(unexpected))
        )
    if duplicate_ids:
        failures.append(
            "final-report data.json: planBodyVerification.planItems contains "
            "duplicate verdict item ID(s): "
            + ", ".join(sorted(duplicate_ids))
        )


def _validate_variation_point_analysis(
    vpa: object,
    architecture_style: str,
    failures: list[str],
) -> None:
    """Conditional rules + architecture-style overlay for variation points.

    The schema enforces shape only. Two layers of meaning sit on top:

    Layer 1 is style-agnostic. Declaring "no variation exists" is a claim that
    needs a written reason, and it must not be paired with declared points —
    `plan_items._extract_variation_point_items` emits a lone `P-Var-0` in that
    branch and drops them, so the contradiction would silently exempt every
    declared point from per-point verification. `extract: true` is a claim in
    the same way: it names the interface the next implementation plugs into and
    the Stage Map stage that builds it, so both fields have to be filled. The
    schema cannot carry this as a `minLength` — the empty string is the natural
    shape of an `extract: false` decision.

    Layer 2 fires only for a project that declares `architecture.style`
    `hexagonal`: extracting a variation point there means introducing a port,
    not a helper. An unconfigured project resolves to `none` and keeps layer-1
    behaviour only.
    """
    if not isinstance(vpa, dict):
        failures.append("variationPointAnalysis is missing or not an object")
        return
    # Schema violations are reported, not raised, so this check still runs on a
    # malformed block. A type guard here keeps a bad field from aborting the
    # whole validation and discarding every failure collected so far.
    raw_points = vpa.get("points")
    if raw_points is not None and not isinstance(raw_points, list):
        failures.append("variationPointAnalysis: points must be an array")
        return
    points = raw_points or []
    if not bool(vpa.get("hasMultipleImplementations")):
        rationale = vpa.get("noVariationRationale")
        if not isinstance(rationale, str) or not rationale.strip():
            failures.append(
                "variationPointAnalysis: hasMultipleImplementations=false "
                "requires a non-empty noVariationRationale"
            )
        if points:
            failures.append(
                "variationPointAnalysis: hasMultipleImplementations=false but "
                "points is non-empty — declared variation points would be "
                "silently dropped"
            )
        return
    if not points:
        failures.append(
            "variationPointAnalysis: hasMultipleImplementations=true requires "
            "at least one point"
        )
        return
    for index, point in enumerate(points, start=1):
        if not isinstance(point, dict):
            failures.append(
                f"variationPointAnalysis point {index}: must be an object"
            )
            continue
        decision = point.get("extractionDecision")
        if not isinstance(decision, dict):
            continue  # shape is the schema's job; don't double-report it.
        if not decision.get("extract"):
            continue
        for field in ("interfaceKind", "coveredBy"):
            value = decision.get(field)
            if not isinstance(value, str) or not value.strip():
                failures.append(
                    f"variationPointAnalysis point {index}: extract=true "
                    f"requires a non-empty {field}, got {value!r}"
                )
        if architecture_style == "hexagonal" and decision.get("interfaceKind") != "port":
            failures.append(
                f"variationPointAnalysis point {index}: architecture style "
                f"'hexagonal' requires interfaceKind 'port', got "
                f"{decision.get('interfaceKind')!r}"
            )


_DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
_DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
_DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}


def _normalize_report_contracts(raw_contracts: object) -> set[str]:
    if isinstance(raw_contracts, str):
        candidates = [raw_contracts]
    elif isinstance(raw_contracts, (list, tuple, set, frozenset)):
        candidates = raw_contracts
    else:
        return set()
    return {
        value.strip()
        for value in candidates
        if isinstance(value, str) and value.strip()
    }


def _design_prep_rows(
    planning: dict,
    failures: list[str],
) -> tuple[list[dict], dict[str, dict]] | None:
    preparation = planning.get("designPreparation")
    if not isinstance(preparation, dict):
        failures.append(
            "final-report data.json: implementationPlanning.designPreparation "
            "is malformed; expected an object"
        )
        return None
    raw_items = preparation.get("items")
    if not isinstance(raw_items, list) or any(
        not isinstance(item, dict) for item in raw_items
    ):
        failures.append(
            "final-report data.json: implementationPlanning.designPreparation.items "
            "is malformed; expected an array of objects"
        )
        return None
    items = list(raw_items)
    items_by_id: dict[str, dict] = {}
    for item in items:
        item_id = item.get("id")
        if not isinstance(item_id, str) or not item_id:
            failures.append(
                "final-report data.json: designPreparation item has malformed id"
            )
            continue
        if item_id in items_by_id:
            failures.append(
                f"final-report data.json: duplicate designPreparation item {item_id}"
            )
        items_by_id[item_id] = item
    return items, items_by_id


def _design_surface_coverage(
    planning: dict,
    failures: list[str],
) -> dict[tuple[int, str], list[dict]] | None:
    raw_stages = planning.get("stages")
    if not isinstance(raw_stages, list):
        failures.append(
            "final-report data.json: implementationPlanning.stages is malformed"
        )
        return None
    coverage_by_key: dict[tuple[int, str], list[dict]] = {}
    for stage in raw_stages:
        if not isinstance(stage, dict) or not isinstance(stage.get("stage"), int):
            failures.append("final-report data.json: planning stage is malformed")
            continue
        rows = stage.get("designSurfaceCoverage", [])
        if not isinstance(rows, list):
            failures.append(
                f"final-report data.json: Stage {stage['stage']} "
                "designSurfaceCoverage is malformed"
            )
            continue
        for row in rows:
            if not isinstance(row, dict) or not isinstance(row.get("kind"), str):
                failures.append(
                    f"final-report data.json: Stage {stage['stage']} has malformed "
                    "designSurfaceCoverage row"
                )
                continue
            key = (stage["stage"], row["kind"])
            coverage_by_key.setdefault(key, []).append(row)
    return coverage_by_key


def _trigger_evidence_identity(raw_evidence: object) -> set[tuple[object, object, object]]:
    if not isinstance(raw_evidence, (list, tuple)):
        return set()
    return {
        (row.get("step"), row.get("field"), row.get("match"))
        for row in raw_evidence
        if isinstance(row, dict)
    }


def _validate_detector_coverage(
    planning: dict,
    coverage_by_key: dict[tuple[int, str], list[dict]],
    failures: list[str],
) -> None:
    triggers = detect_design_surfaces(planning)
    expected_by_key = {(trigger.stage, trigger.kind): trigger for trigger in triggers}
    detector_kinds = {rule.kind for rule in DESIGN_SURFACE_RULES}
    for key, trigger in expected_by_key.items():
        stage, kind = key
        if kind == "manual-user-test":
            failures.append(
                "final-report data.json: detector must not generate "
                f"manual-user-test for Stage {stage}"
            )
            continue
        rows = coverage_by_key.get(key, [])
        if len(rows) != 1:
            failures.append(
                f"final-report data.json: detector trigger Stage {stage} kind "
                f"{kind} requires exactly one designSurfaceCoverage row; "
                f"found {len(rows)}"
            )
            continue
        expected_evidence = {
            (evidence.step, evidence.field, evidence.match)
            for evidence in trigger.evidence
        }
        actual_evidence = _trigger_evidence_identity(rows[0].get("triggerEvidence"))
        if actual_evidence != expected_evidence:
            failures.append(
                f"final-report data.json: Stage {stage} {kind} triggerEvidence "
                "does not match detector output"
            )
    for stage, kind in coverage_by_key:
        if kind in detector_kinds and (stage, kind) not in expected_by_key:
            failures.append(
                f"final-report data.json: Stage {stage} {kind} coverage has no "
                "matching detector trigger"
            )


def _coverage_prep_ids(row: dict) -> list[str]:
    if isinstance(row.get("prepItemId"), str):
        return [row["prepItemId"]]
    raw_ids = row.get("prepItemIds")
    if isinstance(raw_ids, list):
        return [item_id for item_id in raw_ids if isinstance(item_id, str)]
    return []


def _validate_prep_references(
    items: list[dict],
    items_by_id: dict[str, dict],
    coverage_by_key: dict[tuple[int, str], list[dict]],
    failures: list[str],
) -> None:
    referenced: set[tuple[str, int, str]] = set()
    for (stage, kind), rows in coverage_by_key.items():
        for row in rows:
            if row.get("disposition") != "prep-item":
                continue
            for item_id in _coverage_prep_ids(row):
                item = items_by_id.get(item_id)
                if item is None:
                    failures.append(
                        f"final-report data.json: coverage references missing {item_id}"
                    )
                    continue
                if stage not in (item.get("stageRefs") or []):
                    failures.append(
                        f"final-report data.json: {item_id}.stageRefs does not "
                        f"include coverage Stage {stage}"
                    )
                if item.get("kind") != kind:
                    failures.append(
                        f"final-report data.json: {item_id}.kind does not match "
                        f"coverage kind {kind}"
                    )
                referenced.add((item_id, stage, kind))
    for item in items:
        item_id = item.get("id")
        kind = item.get("kind")
        stage_refs = item.get("stageRefs")
        if not isinstance(item_id, str) or not isinstance(kind, str):
            continue
        if not isinstance(stage_refs, list):
            failures.append(
                f"final-report data.json: {item_id}.stageRefs is malformed"
            )
            continue
        for stage in stage_refs:
            if (item_id, stage, kind) not in referenced:
                failures.append(
                    f"final-report data.json: {item_id} Stage {stage} kind {kind} "
                    "has no matching prep-item coverage reference"
                )


def _validate_design_prep_states(items: list[dict], failures: list[str]) -> None:
    for item in items:
        item_id = str(item.get("id") or "<missing>")
        stage_refs = item.get("stageRefs") or []
        review_at = item.get("reviewAt")
        if isinstance(review_at, dict) and "stage" in review_at:
            if review_at.get("stage") not in stage_refs:
                failures.append(
                    f"final-report data.json: {item_id}.reviewAt.stage must belong "
                    "to stageRefs"
                )
        status = item.get("status")
        if status == "blocked" and (
            not isinstance(item.get("humanConfirmation"), dict)
            or item["humanConfirmation"].get("required") is not True
        ):
            failures.append(
                f"final-report data.json: blocked {item_id} requires "
                "humanConfirmation.required=true"
            )
        if status in _DESIGN_PREP_REQUEST_STATUSES and not isinstance(
            item.get("requestPath"), str
        ):
            failures.append(
                f"final-report data.json: {status} {item_id} requires requestPath"
            )
        if status in _DESIGN_PREP_TERMINAL_STATUSES and "requestPath" in item:
            failures.append(
                f"final-report data.json: terminal {item_id} must not carry requestPath"
            )


def _validate_design_prep_requests(
    data: dict,
    report_path: Path,
    items: list[dict],
    failures: list[str],
) -> None:
    data_path = _data_path_for(report_path)
    planning_seq = _design_prep_planning_seq(data_path)
    report_language = _design_prep_report_language(data)
    for item in items:
        if item.get("status") not in _DESIGN_PREP_REQUEST_STATUSES:
            continue
        item_id = str(item.get("id") or "<missing>")
        target, expected = _render_design_prep_request(
            data_path=data_path,
            planning_seq=planning_seq,
            report_language=report_language,
            item=item,
        )
        if not target.is_file():
            failures.append(
                f"final-report data.json: design-prep request is missing for {item_id}: "
                f"{target}"
            )
            continue
        try:
            actual = target.read_bytes()
        except OSError as exc:
            failures.append(
                f"final-report data.json: cannot read design-prep request for "
                f"{item_id}: {exc}"
            )
            continue
        if actual != expected:
            failures.append(
                f"final-report data.json: design-prep request content or "
                f"fingerprint is stale for {item_id}: {target}"
            )


def _validate_design_prep_contract(
    data: dict,
    report_path: Path | None,
    report_contracts: set[str],
    failures: list[str],
) -> list[str]:
    warnings: list[str] = []
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        if _DESIGN_PREP_CONTRACT in report_contracts:
            failures.append(
                "final-report data.json: implementationPlanning is malformed"
            )
        return warnings
    preparation = planning.get("designPreparation")
    if not isinstance(preparation, dict):
        if _DESIGN_PREP_CONTRACT in report_contracts:
            failures.append(
                "final-report data.json: marker implementation-design-prep-v1 "
                "requires implementationPlanning.designPreparation"
            )
        else:
            warnings.append("legacy-unassessed")
        return warnings
    if _DESIGN_PREP_CONTRACT not in report_contracts:
        return warnings
    try:
        parsed = _design_prep_rows(planning, failures)
        coverage_by_key = _design_surface_coverage(planning, failures)
        if parsed is None or coverage_by_key is None:
            return warnings
        items, items_by_id = parsed
        _validate_detector_coverage(planning, coverage_by_key, failures)
        _validate_prep_references(items, items_by_id, coverage_by_key, failures)
        _validate_design_prep_states(items, failures)
        if report_path is not None:
            _validate_design_prep_requests(data, report_path, items, failures)
    except (DesignSurfaceError, DesignPrepError, KeyError, TypeError, ValueError) as exc:
        failures.append(
            "final-report data.json: design-preparation contract is malformed: "
            f"{exc}"
        )
    except Exception as exc:  # noqa: BLE001
        failures.append(
            "final-report data.json: design-preparation validation failed closed "
            f"on malformed input: {exc}"
        )
    return warnings


# A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
# not the plain-language "what this item is" label §5.5.9 renders as a heading.
_MIN_SUBJECT_LEN = 3
_BARE_PLAN_ITEM_ID_RE = re.compile(r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE)


def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
    """H2 follow-up — `planItems[].subject` must be a real label, not a
    placeholder. The schema only enforces non-empty, so `"x"` or a copied
    `P-Opt-1` id would otherwise slip through and defeat the whole point of the
    subject (letting a reader see *what* each AGREE/DISAGREE is about).
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        item_id = str(item.get("id") or "").strip()
        subject = str(item.get("subject") or "").strip()
        if (
            len(subject) < _MIN_SUBJECT_LEN
            or subject == item_id
            or _BARE_PLAN_ITEM_ID_RE.match(subject)
        ):
            failures.append(
                f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
                f"placeholder subject `{subject}`. Give a plain-language label of "
                "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
                "the §5.5.9 reader knows what each verdict is about "
                "(plan-body-verification.md Plan-item extraction)."
            )


def _validate_plan_body_clarification_matching(
    data: dict,
    failures: list[str],
    accepted_item_ids: set[str] | None = None,
) -> None:
    """H5 — every plan item whose *gate class after stage scope* is
    `majority-disagree` must point at an existing `blocks: approval`
    clarification row. Observed / deferred / record items stay in `setAside`
    and must not become a new C row — that is what grew the clarification
    list while the next stage was already executable.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    accepted = (
        _resolved_noncritical_dissent_ids(data)
        if accepted_item_ids is None
        else accepted_item_ids
    )
    clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
    all_ids = {r.get("id") for r in clar_rows if r.get("id")}
    approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        if _plan_item_gate_class(item, pbv, accepted) != "majority-disagree":
            continue
        item_id = item.get("id") or "<unknown>"
        cids = _plan_item_clarification_ids(item)
        if not cids:
            failures.append(
                f"final-report data.json: plan item `{item_id}` is majority-disagree "
                "but carries no `clarificationRefs`. A blocking disagreement MUST "
                "surface as a `## 1. Clarification Items` row (blocks=approval) so "
                "the user sees the blocker (implementation-planning.md self-review "
                "step 12). Report assembly derives this link from the activity "
                "ledger's `clarificationRefs[]` + `planItemIds[]`, so record the "
                "decision through `okstra approval-decision` rather than editing "
                "the report."
            )
            continue
        for cid in sorted(cids - approval_ids):
            reason = (
                "references a non-existent §1 row"
                if cid not in all_ids
                else "references a §1 row whose `blocks` is not `approval`"
            )
            failures.append(
                f"final-report data.json: plan item `{item_id}` (majority-disagree) "
                f"has clarificationRefs entry `{cid}` which {reason}. Every "
                "majority-disagree item MUST reach a `blocks: approval` "
                "Clarification row."
            )


def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
    """A planner-fixable defect MUST exhaust the self-fix budget before it is
    promoted to a `## 1. Clarification Items` row. Closes the hole where the
    lead dumps a fixable plan defect (abbreviated path, prose command,
    placeholder, coverage remap) onto the user instead of having report-writer
    correct it (plan-body-verification.md "Self-fix round").
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    if _self_fix_budget_exhausted(pbv):
        return
    rounds_applied = pbv.get("selfFixRoundsApplied")
    stop_reason = pbv.get("selfFixStopReason")
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        if _plan_item_gate_class(item, pbv, set()) != "majority-disagree":
            continue
        if _has_planner_fixable_majority(item):
            allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
            failures.append(
                "final-report data.json: plan item "
                f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
                f"planner-fixable majority but the self-fix budget is not "
                f"exhausted (`selfFixRoundsApplied`={rounds_applied!r}, "
                f"`selfFixStopReason`={stop_reason!r}; need >=1 rounds and a stop "
                f"reason of {allowed}). A planner-fixable defect MUST be corrected "
                "by report-writer self-fix rounds until the budget runs out or a "
                "round makes no progress, before it becomes a clarification row "
                "(plan-body-verification.md Self-fix round)."
            )


CRITIC_UNVERIFIED_SOURCE = "critic-unverified"


def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -> None:
    """A coverage gap nobody judged must survive as a Missing Information row.

    Gap verification drops `contested` / `worker-unique` gaps as hallucinations,
    which is sound *because voters looked at them*. A gap left unjudged — the
    verification dispatch timed out, covered only part of the batch, or no
    eligible analyser voted — has no verdict at all, and treating it as rejected
    invents one. The failure mode is adversarial: the batch that times out is
    the batch of gaps too expensive to check, so the highest-risk items are the
    ones that would silently disappear (prompts/lead/convergence.md
    "Gap verification").
    """
    cross = data.get("crossVerification")
    if not isinstance(cross, dict):
        return
    critic = cross.get("criticGaps")
    if not isinstance(critic, dict):
        return
    unverified = critic.get("unverified")
    if not isinstance(unverified, int) or unverified <= 0:
        return
    recorded = [
        row for row in (data.get("missingInformation") or [])
        if isinstance(row, dict) and row.get("source") == CRITIC_UNVERIFIED_SOURCE
    ]
    if len(recorded) < unverified:
        failures.append(
            "final-report data.json: crossVerification.criticGaps.unverified is "
            f"{unverified} but only {len(recorded)} missingInformation row(s) carry "
            f"`source: \"{CRITIC_UNVERIFIED_SOURCE}\"`. A gap the voters never "
            "judged MUST be recorded as a Missing Information row naming the gap "
            "and why verification did not complete — dropping it fabricates a "
            "rejection (prompts/lead/convergence.md \"Gap verification\")."
        )

    proposed = critic.get("proposed")
    merged = critic.get("merged")
    if isinstance(proposed, int) and isinstance(merged, int):
        if merged + unverified > proposed:
            failures.append(
                "final-report data.json: crossVerification.criticGaps has "
                f"merged({merged}) + unverified({unverified}) > proposed({proposed}) "
                "— the accounting does not describe a real set of gaps."
            )


# Allowed `fixability` values, mirroring the schema enum
# (schemas/final-report-v2.0.schema.json planItems[].verdicts[].fixability).
_FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})


def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
    """Every `DISAGREE` verdict MUST carry a valid `fixability`
    (`planner-fixable` / `needs-user-input`). The schema enum only constrains a
    *present* value; it does not require the field, so a DISAGREE with a missing
    or mislabelled fixability is schema-valid. That silently degrades
    `_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
    DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
    skip the self-fix round and land on the user. This is the enforcement point
    for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    round_count = pbv.get("roundCount")
    if not isinstance(round_count, int) or round_count < 1:
        return
    for item in pbv.get("planItems") or []:
        if not isinstance(item, dict):
            continue
        item_id = item.get("id") or "<unknown>"
        for verdict in item.get("verdicts") or []:
            if not isinstance(verdict, dict):
                continue
            if str(verdict.get("verdict") or "").upper() != "DISAGREE":
                continue
            fixability = verdict.get("fixability")
            if fixability not in _FIXABILITY_VALUES:
                worker = verdict.get("worker") or "<worker>"
                allowed = " / ".join(sorted(_FIXABILITY_VALUES))
                failures.append(
                    f"final-report data.json: plan item `{item_id}` has a "
                    f"`DISAGREE` verdict from `{worker}` with "
                    f"fixability `{fixability}` — a DISAGREE MUST declare a "
                    f"fixability of {allowed}. A missing/invalid value is "
                    "counted as non-fixable and lets a planner-fixable defect "
                    "skip the self-fix round (plan-body-verification.md "
                    "\"fixability (DISAGREE 전용, 필수)\")."
                )


def validate_plan_body_section(
    data: dict,
    report_path: Path,
    failures: list[str],
) -> list[str]:
    """Run every §5.5.9 plan-body check and return the advisory warnings.

    Grouped into one callable so the round protocol can run the same checks at
    each round boundary that the full run validation runs at the end. Before
    this seam existed the only way to reach them was a finished report plus all
    four manifests, so a lead computing the gate by hand mid-loop had nothing to
    check itself against until Phase 7 — and a whole self-fix budget could be
    spent against a mis-scored gate.
    """
    pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
    accepted_item_ids = _resolved_noncritical_dissent_ids(data)
    _validate_plan_body_gate_recompute(data, failures, accepted_item_ids)
    _validate_gate_blocked_by(data, failures, accepted_item_ids)
    _validate_participating_analysers(data, failures)
    _validate_self_fix_rewrite_scope(data, failures)
    _validate_self_fix_grouping(data, failures)
    _validate_plan_body_verdict_provenance(data, report_path, failures)
    _validate_reverify_result_addresses_prior_dissent(data, report_path, failures)
    _validate_aborted_gate_has_clarification(data, failures)
    _validate_round_recorded_verdicts(data, failures)
    _validate_verdicts_match_current_subjects(data, failures)
    _validate_set_aside_register(data, failures, accepted_item_ids)
    _validate_verdict_rounds_outlive_self_fix(data, failures)
    _validate_unresolved_tie_was_reverified(data, failures)
    _validate_tie_received_extra_vote(data, failures)
    _validate_advisory_plan_body_gating(data, failures)
    _validate_plan_item_extraction_completeness(data, failures)
    _validate_plan_item_subject_substance(data, failures)
    _validate_plan_body_clarification_matching(data, failures, accepted_item_ids)
    _validate_disagree_has_fixability(data, failures)
    _validate_self_fix_before_clarification(data, failures)
    return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]


def _gate_summary_item(
    item: dict,
    pbv: dict,
    accepted_item_ids: set[str],
) -> dict:
    """One `gate.items[]` row: the gate class plus its state-file counterpart."""
    classification = _plan_item_gate_class(item, pbv, accepted_item_ids)
    return {
        "id": item.get("id"),
        "classification": classification,
        "stateClassification": _state_classification(item, classification),
        "correctnessCritical": _is_correctness_critical(item),
        # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
        # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
        "stageScope": _stage_scope_bucket(item, pbv),
        "block": str(item.get("block") or "execution"),
    }


def plan_body_gate_summary(data: dict) -> dict | None:
    """The §5.5.9 gate as the round protocol's step 5 needs it — per-item
    classification, the whole-gate value, and the `gateBlockedBy` causes, all
    recomputed from `planItems[].verdicts`. Returns ``None`` when the report
    carries no plan items to judge.

    This is what a lead records instead of tallying the verdicts by hand: the
    single-vote-blocking kinds, the advisory-only kinds and the P-Var / P-Rb
    exemptions are one implementation here, not a rule to be re-derived per
    round from the prompt's prose.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return None
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return None
    accepted_item_ids = _resolved_noncritical_dissent_ids(data)
    recomputed = _recompute_plan_body_gate(pbv, accepted_item_ids)
    if recomputed is None:
        return None
    items = [
        _gate_summary_item(item, pbv, accepted_item_ids)
        for item in (pbv.get("planItems") or [])
        if isinstance(item, dict)
    ]
    coverage_blockers = _independent_coverage_blockers(ip, pbv)
    return {
        "declared": pbv.get("gateResult"),
        "recomputed": recomputed,
        "declaredBlockedBy": sorted(
            str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
        ),
        "blockedBy": sorted(
            _gate_blocking_causes(pbv, coverage_blockers, accepted_item_ids)
        ),
        "coverageBlockers": coverage_blockers,
        "setAside": _set_aside_register(pbv, accepted_item_ids),
        "blockingItems": [
            item["id"] for item in items if item["classification"] == "majority-disagree"
        ],
        "items": items,
    }


_COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
_COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
_COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
_DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
_DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")


def _carried_decision_map(
    run_manifest: Mapping[str, Any] | None,
    *,
    project_root: Path | None,
    report_path: Path,
) -> dict[str, dict]:
    """이전 런에서 carry 한 결정. active `clarificationItems` 에 다시 올리지 않는다."""
    raw = (run_manifest or {}).get("approvalDecisionsPath")
    if not isinstance(raw, str) or not raw.strip():
        return {}
    path = Path(raw.strip())
    if not path.is_absolute():
        root = project_root or _project_root_from_report(report_path)
        path = root / path
    if not path.is_file():
        return {}
    try:
        ledger = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    if not isinstance(ledger, dict):
        return {}
    carried: dict[str, dict] = {}
    for row in ledger.get("carriedDecisions") or []:
        if not isinstance(row, dict):
            continue
        decision = row.get("decision")
        if not isinstance(decision, dict):
            continue
        cid = decision.get("id")
        if isinstance(cid, str) and cid:
            carried[cid] = decision
    return carried


def _deviation_target(
    ref: str,
    clarifications: dict,
    decisions: dict,
    carried: dict,
) -> dict | None:
    if ref.startswith("C-"):
        row = clarifications.get(ref) or carried.get(ref)
        return row if isinstance(row, dict) else None
    row = decisions.get(ref)
    return row if isinstance(row, dict) else None


def _deviation_is_user_confirmed(
    ref: str, clarifications: dict, carried: dict,
) -> bool:
    row = clarifications.get(ref)
    if (
        isinstance(row, dict)
        and row.get("status") in {"answered", "resolved"}
        and str(row.get("userInput") or "").strip()
    ):
        return True
    carried_row = carried.get(ref)
    if not isinstance(carried_row, dict):
        return False
    resolution = carried_row.get("resolutionInput")
    if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
        return True
    return bool(str(carried_row.get("userConfirmation") or "").strip())


def _resolved_deviation_refs(
    row_id: str,
    refs: object,
    clarifications: dict,
    decisions: dict,
    failures: list[str],
    carried: dict | None = None,
) -> list[str]:
    valid_refs: list[str] = []
    carried_rows = carried or {}
    for ref in refs if isinstance(refs, list) else []:
        if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
            failures.append(
                f"final-report data.json: requirementCoverage `{row_id}` has "
                f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
            )
            continue
        if _deviation_target(ref, clarifications, decisions, carried_rows) is None:
            failures.append(
                f"final-report data.json: requirementCoverage `{row_id}` "
                f"decisionRef `{ref}` does not exist in this report."
            )
            continue
        valid_refs.append(ref)
    return valid_refs


def _validate_deviation_disposition(
    row_id: str,
    disposition: object,
    refs: list[str],
    clarifications: dict,
    failures: list[str],
    carried: dict | None = None,
) -> None:
    carried_rows = carried or {}
    if disposition == "accepted":
        confirmed = any(
            ref.startswith("C-")
            and _deviation_is_user_confirmed(ref, clarifications, carried_rows)
            for ref in refs
        )
        if not confirmed:
            failures.append(
                f"final-report data.json: requirementCoverage `{row_id}` is "
                "documented-deviation with approvalDisposition `accepted`, but "
                "none of its decisionRefs is a user-confirmed clarification "
                "(`status` answered/resolved with non-empty `userInput`)."
            )
        return
    blocked = (
        _DEVIATION_BLOCKED_DISPOSITION_RE.fullmatch(disposition)
        if isinstance(disposition, str)
        else None
    )
    if blocked is None:
        return
    clarification_id = blocked.group(1)
    clarification = clarifications.get(clarification_id) or carried_rows.get(
        clarification_id
    )
    if clarification is None:
        failures.append(
            f"final-report data.json: requirementCoverage `{row_id}` "
            f"approvalDisposition references `{clarification_id}`, which does "
            "not exist in this report."
        )
    elif (
        clarification.get("status") != "open"
        or clarification.get("blocks") != "approval"
    ):
        failures.append(
            f"final-report data.json: requirementCoverage `{row_id}` "
            f"approvalDisposition `{disposition}` must point to an open "
            "clarification with `blocks: approval`."
        )


def _validate_requirement_deviations(
    data: dict, failures: list[str], *, carried: dict | None = None,
) -> None:
    """Require documented deviations to reference real decisions and approval."""
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        return
    clarifications = {
        row.get("id"): row
        for row in (data.get("clarificationItems") or [])
        if isinstance(row, dict) and row.get("id")
    }
    decisions = {
        f"D-{row.get('number')}": row
        for row in (planning.get("decisionDrafts") or [])
        if isinstance(row, dict) and row.get("number")
    }
    carried_rows = carried or {}
    for row in planning.get("requirementCoverage") or []:
        if not isinstance(row, dict) or row.get("status") != "documented-deviation":
            continue
        row_id = row.get("id") or "<row>"
        refs = _resolved_deviation_refs(
            row_id,
            row.get("decisionRefs"),
            clarifications,
            decisions,
            failures,
            carried_rows,
        )
        _validate_deviation_disposition(
            row_id,
            row.get("approvalDisposition"),
            refs,
            clarifications,
            failures,
            carried_rows,
        )


def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
    """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
    concrete plan element that actually exists, not the spec-forbidden bare
    "recommended option" nor a phantom stage. Whether the cited step truly
    *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
    the coarser hole where `coveredBy` points at nothing real.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
    if not rows:
        return
    stage_numbers = {
        s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
    }
    for row in rows:
        if row.get("status") != "covered":
            continue
        rid = row.get("id") or "<row>"
        covered = str(row.get("coveredBy") or "").strip()
        if covered.lower() in _COVERED_BY_VAGUE:
            failures.append(
                f"final-report data.json: requirementCoverage `{rid}` is `covered` "
                f"but coveredBy is just `{covered}`. Name the specific Option "
                "Candidate and Stage/Step that satisfies it, not 'recommended "
                "option' (profile Requirement Coverage)."
            )
            continue
        if not _COVERED_BY_ANCHOR_RE.search(covered):
            failures.append(
                f"final-report data.json: requirementCoverage `{rid}` coveredBy "
                f"`{covered}` names no Option / Stage / Step. A `covered` row must "
                "cite the concrete plan element that satisfies the requirement."
            )
            continue
        phantom = [
            n for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
            if stage_numbers and (n := int(m.group(1))) not in stage_numbers
        ]
        if phantom:
            failures.append(
                f"final-report data.json: requirementCoverage `{rid}` coveredBy "
                f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
                "A coverage row must point at a real stage."
            )


# The phases that read the run's brief. Resolving the brief outside this set
# would fire the missing-brief warning on runs (implementation,
# final-verification, release-handoff) that never consult one.
_BRIEF_DERIVED_PHASES = frozenset(
    {
        "requirements-discovery",
        "error-analysis",
        "implementation-planning",
        "improvement-discovery",
    }
)
# The subset that maps the brief's end state. improvement-discovery is absent
# on purpose: its report is free-form markdown outside the data.json schema, so
# it has nowhere to carry endStateCoverage.
_END_STATE_PHASES = frozenset(
    {"requirements-discovery", "error-analysis", "implementation-planning"}
)


def _validate_end_state_coverage(
    data: dict, brief_path: Path, failures: list[str]
) -> None:
    """Every end-state id the brief pinned is accounted for exactly once.

    This is the lower bound the phase contracts previously lacked: a phase could
    quietly drop a reporter requirement and still produce a clean report. The
    gate does not judge whether the mapping is true — that is the Phase 5.5
    round's job — but it makes an omission an explicit, attributable statement
    rather than a silence.

    Legacy briefs declare no ids, so the gate self-disables for them instead of
    wedging runs authored before the contract existed.
    """
    declared = brief_end_state_ids(brief_path)
    if not declared:
        return

    rows = [r for r in (data.get("endStateCoverage") or []) if isinstance(r, dict)]
    seen: dict[str, int] = {}
    for row in rows:
        row_id = str(row.get("id") or "").strip()

        # Counting only declared ids keeps one mistake to one failure: an
        # undeclared id repeated twice is one "not declared" per row, not that
        # plus a duplicate report about an id the brief never had.
        if row_id not in declared:
            failures.append(
                f"final-report data.json: endStateCoverage cites {row_id!r}, which "
                "is not declared by this run's brief. Map only ids the reporter "
                "pinned."
            )
            continue
        seen[row_id] = seen.get(row_id, 0) + 1

        disposition = str(row.get("disposition") or "").strip()
        if disposition == "addressed" and not str(row.get("coveredBy") or "").strip():
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` is `addressed` "
                "but names no `coveredBy` anchor. State which deliverable of this "
                "phase accounts for it."
            )
        if disposition != "addressed" and not str(row.get("rationale") or "").strip():
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` is "
                f"`{disposition or '(empty)'}` but records no `rationale`. Dropping a "
                "reporter requirement is allowed; dropping it silently is not."
            )

    for row_id, count in seen.items():
        if count > 1:
            failures.append(
                f"final-report data.json: endStateCoverage maps `{row_id}` more than "
                f"once ({count} rows). One id, one disposition."
            )

    for missing in sorted(declared - seen.keys()):
        failures.append(
            f"final-report data.json: brief end-state `{missing}` has no "
            "endStateCoverage row. Every pinned id needs a disposition, including "
            "`not-applicable` with a rationale."
        )


def _validate_requirement_provenance(
    data: dict, brief_path: Path, failures: list[str]
) -> None:
    """Every requirement must declare where it came from.

    The planning input template already states that any change beyond what
    `Requirement Summary` demands is out of scope by default; without this
    check that sentence has no enforcement point and a phase can invent
    requirements freely.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
    if not rows:
        return

    refs = {
        str(r.get("id") or "").strip(): parse_source(str(r.get("source") or ""))
        for r in rows
    }
    headings = brief_headings(brief_path)
    end_state = brief_end_state_ids(brief_path)

    for rid, ref in refs.items():
        problem = brief_citation_problem(ref, headings, end_state)
        if problem:
            failures.append(
                f"final-report data.json: requirementCoverage `{rid}` {problem}. A "
                "requirement must trace to a line the reporter actually wrote."
            )

    for rid, verdict in resolve_chain(refs).items():
        if verdict != "ok":
            failures.append(
                f"final-report data.json: requirementCoverage `{rid}` provenance "
                f"failed — {verdict}. An item with no admissible source is not a "
                "requirement: raise it as a `Blocks=approval` clarification instead."
            )


# Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
# what "no match" means — forward treats it as nothing to verify and passes,
# reverse treats it as nothing cited and orphans every stage — so the reverse
# reader must cover `Stages 1, 2` and `Stage 1 and 2` too.
#
# It reads hand-enumerated numbers only. A range's interior is deliberately NOT
# coverage evidence: one `Stages 1-64` cell would otherwise stamp every stage in
# the map while the planner confirmed none of them. The incremental-scope
# back-trace keeps the widening reader (`cited_stage_numbers`) because "could
# this answer reach stage 5" is the opposite question and must over-approximate.
_enumerated_stage_numbers = enumerated_stage_numbers


# Statuses under which a row asserts the plan does work for the requirement.
# `gap` is excluded: it states the plan does NOT cover the requirement, so it
# can justify no stage. `blocked C-NNN` and `documented-deviation` are included:
# each records a real requirement with a planned treatment, even when the
# treatment awaits approval or deliberately differs from the original request.
_COVERAGE_CLAIMING_STATUS_RE = re.compile(
    r"^(covered|blocked C-\d{3,}|documented-deviation)$"
)


def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
    """Reverse of `_validate_requirement_coverage_covered_by`.

    That check proves every requirement reaches a stage; this one proves every
    stage traces back to a requirement. Without it a plan can carry stages no
    one asked for and still pass every gate.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    stage_numbers = {
        s.get("stage")
        for s in (ip.get("stages") or [])
        if isinstance(s, dict) and isinstance(s.get("stage"), int)
    }
    if not stage_numbers:
        return

    cited: set[int] = set()
    for row in (ip.get("requirementCoverage") or []):
        if not isinstance(row, dict):
            continue
        if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
            continue
        cited |= _enumerated_stage_numbers(str(row.get("coveredBy") or ""))

    for orphan in sorted(stage_numbers - cited):
        failures.append(
            f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
            "row — it traces back to nothing the brief asked for. Either cite the "
            "requirement it serves, or drop it and raise it as a `Blocks=approval` "
            "clarification (profile: scope provenance). A range cites only its "
            "endpoints here: write `Stages 1, 2, 3` rather than `Stages 1-3`, so each "
            "stage this requirement covers is one the planner named."
        )


_ADDED_SURFACE_NO_CALLER_RE = re.compile(r"^\s*none\b", re.IGNORECASE)


def _validate_added_surface_audit(data: dict, failures: list[str]) -> None:
    """Every surface the diff added is traced to a requirement, exempted, or paid for.

    The coverage table proves each requirement reached the diff. Nothing proved
    the reverse — that each thing the diff added answers a requirement — so work
    nobody asked for passed every gate. This check reads the reverse table and
    refuses a row that calls itself over-delivery without the blocker or
    condition it became: a caller-less surface is an acceptance blocker, and a
    surface with callers but no requirement is a conditional-acceptance
    condition (ADR-0009 grades the two differently on purpose).
    """
    fv = data.get("finalVerification")
    if not isinstance(fv, Mapping):
        return
    rows = fv.get("addedSurfaceAudit")
    if not isinstance(rows, list):
        return
    blocker_ids = {
        str(row.get("id"))
        for row in (fv.get("acceptanceBlockers") or [])
        if isinstance(row, Mapping)
    }
    condition_ids = {
        str(row.get("id"))
        for row in ((data.get("finalVerdict") or {}).get(
            "conditionalAcceptanceConditions") or [])
        if isinstance(row, Mapping)
    }
    for row in rows:
        if not isinstance(row, Mapping):
            continue
        row_id = str(row.get("id") or "<id 없음>")
        disposition = str(row.get("disposition") or "")
        note = str(row.get("note") or "")
        if disposition == "traced" and not str(row.get("requirement") or "").strip():
            failures.append(
                f"final-verification: addedSurfaceAudit {row_id} is `traced` but "
                "names no requirement — a surface is traced to something the "
                "brief asked for, or it is not traced."
            )
            continue
        if disposition != "over-delivery":
            continue
        caller_less = bool(
            _ADDED_SURFACE_NO_CALLER_RE.match(str(row.get("callers") or ""))
        )
        expected, known = (
            ("AB", blocker_ids) if caller_less else ("CA", condition_ids)
        )
        cited = set(re.findall(rf"\b{expected}-\d{{3,}}\b", note))
        if not cited:
            failures.append(
                f"final-verification: addedSurfaceAudit {row_id} is "
                f"`over-delivery` with callers "
                f"{'none' if caller_less else 'recorded'}, so its note MUST cite "
                f"the `{expected}-NNN` row it became — "
                + (
                    "a caller-less surface is an acceptance blocker"
                    if caller_less
                    else "a surface with callers but no requirement is a "
                         "conditional-acceptance condition"
                )
                + "."
            )
            continue
        missing = sorted(cited - known)
        if missing:
            failures.append(
                f"final-verification: addedSurfaceAudit {row_id} cites "
                f"{missing}, which the report does not carry."
            )


def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
    """Enforce verdict ↔ blocker/condition/routing consistency on the
    final-verification data.json (SSOT). The schema guarantees field SHAPE;
    these are the cross-field invariants the release-handoff gate depends on.

    No-op for non-final-verification data so the caller's gate stays defensive.
    """
    if (data.get("header") or {}).get("taskType") != "final-verification":
        return
    _validate_added_surface_audit(data, failures)
    verdict = data.get("finalVerdict") or {}
    token = (verdict.get("verdictToken") or "").strip().lower()
    fv = data.get("finalVerification") or {}
    blockers = fv.get("acceptanceBlockers") or []
    conditions = verdict.get("conditionalAcceptanceConditions") or []
    routing_value = fv.get("routingRecommendation")
    routing_token = ""
    if isinstance(routing_value, dict):
        routing_token = str(routing_value.get("target") or "")
    if not routing_token:
        failures.append(
            "final-verification: routingRecommendation.target must name exactly one "
            "supported routing target."
        )
        routing_token = None

    if token == "accepted" and blockers:
        failures.append(
            "final-verification: verdict `accepted` but acceptanceBlockers is "
            "non-empty — an accepted verdict must have zero blockers."
        )
    if token == "blocked" and not blockers:
        failures.append(
            "final-verification: verdict `blocked` but acceptanceBlockers is "
            "empty — a blocked verdict must list at least one blocker."
        )
    if token == "conditional-accept" and not conditions:
        failures.append(
            "final-verification: verdict `conditional-accept` but "
            "conditionalAcceptanceConditions is empty — list every condition."
        )
    if routing_token in RELEASE_HANDOFF_TARGETS and not release_handoff_allowed(data):
        blocking = blocking_condition_ids(data)
        reason = (
            f"condition(s) {blocking} declare `blocksReleaseHandoff: true` "
            "(a condition with no declaration counts as blocking)"
            if blocking
            else f"verdict is `{token}`"
        )
        failures.append(
            f"final-verification: routingRecommendation cites `release-handoff` "
            f"but {reason} — release-handoff routing needs an `accepted` verdict, "
            "or a `conditional-accept` whose every condition declares "
            "`blocksReleaseHandoff: false`."
        )

    scope = data.get("verificationScope", "whole-task")
    if scope not in ("whole-task", "single-stage"):
        failures.append(
            f"final-verification: verificationScope must be `whole-task` or "
            f"`single-stage`, got {scope!r}."
        )
    if scope == "single-stage" and routing_token == "release-handoff":
        failures.append(
            "final-verification: verificationScope `single-stage` cannot recommend "
            "plain release-handoff routing — a single-stage accepted verdict may "
            "only route to `release-handoff(stage-group)` (partial-PR mode); "
            "whole-task release-handoff requires whole-task verification."
        )


def validate_report_views(report_path: Path, failures: list[str]) -> None:
    """Enforce Phase 7 step 1.5 (BLOCKING) — the self-contained HTML
    view must exist next to the final-report MD and satisfy the
    contract checked by ``validators/validate-report-views.py``.

    Delegated to that script as a subprocess so the contract surface
    stays in one place. Failures from the delegate are folded back as
    structured ``report-views: <line>`` failure strings.
    """
    import subprocess

    here = Path(__file__).resolve().parent
    delegate = here / "validate-report-views.py"
    if not delegate.is_file():
        # The delegate is part of the same install bundle; absence is
        # itself a broken installation rather than an optional feature.
        failures.append(
            f"validate-report-views.py missing under {here} — okstra install incomplete"
        )
        return
    try:
        proc = subprocess.run(
            [sys.executable, str(delegate), str(report_path)],
            capture_output=True,
            text=True,
            timeout=30,
        )
    except subprocess.TimeoutExpired:
        failures.append("report-views validator timed out (30s)")
        return
    if proc.returncode != 0:
        for line in proc.stderr.splitlines():
            line = line.strip()
            if line:
                failures.append(f"report-views: {line}")


_STAGE_VALIDATOR_PATH = _VALIDATORS_DIR / "validate-implementation-plan-stages.py"


def _load_stage_validator():
    spec = importlib.util.spec_from_file_location(
        "_ip_stage_validator", _STAGE_VALIDATOR_PATH
    )
    if spec is None or spec.loader is None:
        return None
    mod = importlib.util.module_from_spec(spec)
    # Register before exec so the dataclass field-type resolution can find the
    # module in sys.modules (mirrors run.py._parse_stage_map_into_ctx).
    sys.modules["_ip_stage_validator"] = mod
    try:
        spec.loader.exec_module(mod)
    finally:
        sys.modules.pop("_ip_stage_validator", None)
    return mod


def _append_stage_data_failures(data: Mapping[str, Any], failures: list[str]) -> None:
    """Run the stage relationship checks that schema v2 cannot express.

    The depends-on DAG, parallel-stage file safety, RED→GREEN ordering, and
    the TDD-exemption vocabulary are relationships between stages, which a
    JSON Schema cannot state. They are enforced here, against the data.json,
    by the same validator that owns the rule vocabulary.
    """
    if (data or {}).get("schemaVersion") != CURRENT_REPORT_SCHEMA_VERSION:
        return  # Schema validation already rejected an unknown version.
    planning = (data or {}).get("implementationPlanning")
    if not isinstance(planning, Mapping):
        return  # Schema validation already reported the missing block.
    mod = _load_stage_validator()
    if mod is None:  # pragma: no cover — repo/runtime always ship the file
        failures.append(f"cannot load Stage Map validator at {_STAGE_VALIDATOR_PATH}")
        return
    for e in mod.collect_data_validation_errors(dict(planning)):
        failures.append(
            f"implementation-planning stage contract invalid "
            f"[{e.code} stage={e.stage}]: {e.message}"
        )


def _brief_path_from_manifest(task_manifest: dict, project_root: Path) -> Path:
    """This run's brief, or a non-existent path when the manifest names none.

    Readers of the returned path all degrade to "no brief information" on a
    missing file, so an absent brief disables brief-derived gates rather than
    failing the run.

    That degrade is why a named-but-missing brief warns: it is indistinguishable
    from a legacy brief at every reader (both yield an empty id set), so a
    new-format run whose brief path rotted would pass every brief-derived gate
    without anyone seeing it happen. Warning rather than failing keeps the
    existing contract — an absent brief is not by itself a run failure.
    """
    relative = str(task_manifest.get("taskBriefPath") or "").strip()
    if not relative:
        return project_root / "__no-brief__"
    resolved = (project_root / relative).resolve()
    if not resolved.is_file():
        print(
            f"validate-run: warning: task manifest names taskBriefPath "
            f"{relative!r} but no file exists there — every brief-derived check "
            "(end-state coverage, requirement provenance, fan-out provenance) "
            "is skipped for this run",
            file=sys.stderr,
        )
    return resolved


def _parse_brief_frontmatter(brief_path: Path) -> dict:
    """Parse YAML frontmatter from a brief file into a flat dict.

    Handles the scalar and inline-flow-sequence shapes used by okstra briefs:
      key: scalar_value
      key: [item1, item2]

    Returns {} when the file is absent, has no ``---`` delimiters, or the
    frontmatter block is empty. Does not raise.
    """
    if not brief_path.is_file():
        return {}
    try:
        text = brief_path.read_text(encoding="utf-8")
    except OSError:
        return {}

    fm_match = _FRONTMATTER_BLOCK_RE.match(text)
    if fm_match is None:
        return {}

    result: dict = {}
    for line in fm_match.group(1).splitlines():
        if ":" not in line:
            continue
        key, _, raw_val = line.partition(":")
        key = key.strip()
        raw_val = raw_val.strip()
        if not key:
            continue
        # Inline flow sequence: [a, b, c]
        if raw_val.startswith("[") and raw_val.endswith("]"):
            inner = raw_val[1:-1]
            items = [s.strip() for s in inner.split(",") if s.strip()]
            result[key] = items
        else:
            result[key] = raw_val
    return result


def _validate_improvement_discovery(
    report_path: Path,
    run_dir: Path,
    brief_path: Path,
    failures: list[str],
) -> None:
    """Call validate_improvement_report and fold errors into failures.

    Errors from the phase-specific validator are prefixed with
    ``improvement-discovery: `` to match the style used by sibling validators
    (e.g. ``report-views: <line>``).
    """
    _VALIDATORS_DIR_LOCAL = Path(__file__).resolve().parent
    if str(_VALIDATORS_DIR_LOCAL) not in sys.path:
        sys.path.insert(0, str(_VALIDATORS_DIR_LOCAL))

    try:
        from validate_improvement_report import validate_improvement_report  # noqa: E402
    except ImportError as exc:
        failures.append(
            f"improvement-discovery: validate_improvement_report import failed — {exc}"
        )
        return

    brief_frontmatter = _parse_brief_frontmatter(brief_path)
    result = validate_improvement_report(
        report_path=report_path,
        run_dir=run_dir,
        brief_frontmatter=brief_frontmatter,
    )
    if not result.ok:
        for err in result.errors:
            failures.append(f"improvement-discovery: {err}")


def _validate_ssot_is_english(data: dict, failures: list[str]) -> None:
    """Enforce: the final-report data.json is authored in English.

    `meta.reportLanguage` names the language the human HTML renders in, and
    Phase 7's translator serves it from a sidecar. The data.json itself is the
    record every later phase, validator and agent reads, so a worker that
    authors it in the reader's language instead splits the record — and does so
    silently, because rendering, follow-up spawning and validation all succeed
    on it.

    `report-finalize` runs the same check as its first step, before anything
    derives from the report. This is the second gate, for a report that reached
    validation by some other path.
    """
    if not data:
        return
    share, length = hangul_share(data)
    if length and share >= HANGUL_PROSE_LIMIT:
        failures.append(
            f"final-report data.json was authored in Korean ({share:.0%} of its "
            f"prose, limit {HANGUL_PROSE_LIMIT:.0%}). The data.json is the "
            "English SSOT; meta.reportLanguage selects the human HTML's "
            "language and is served by the Phase 7 translator sidecar."
        )


def _validate_fix_cycle(run_manifest: dict, data: dict, failures: list[str]) -> None:
    """Enforce: when the run-manifest carries a fixCycleId, the final-report
    data.json MUST contain a fixCycle block whose ``cycle`` matches it.

    Direction is one-way: a fixCycle block present without a run-manifest
    fixCycleId is NOT rejected — same posture as the schema-optional block.
    This check owns only the run→report direction (no missing/mismatched
    block when the run is attached), never the reverse."""
    cycle_id = (run_manifest or {}).get("fixCycleId", "")
    if not cycle_id:
        return
    block = (data or {}).get("fixCycle")
    if not isinstance(block, dict):
        failures.append(
            f"fix-cycle: run-manifest fixCycleId={cycle_id} but data.json has "
            "no fixCycle block"
        )
        return
    if block.get("cycle") != cycle_id:
        failures.append(
            f"fix-cycle: data.json fixCycle.cycle={block.get('cycle')!r} does "
            f"not match run-manifest fixCycleId={cycle_id!r}"
        )


def _validate_session_conformance(
    team_state: dict,
    team_state_path: Path,
    run_manifest: Mapping[str, Any],
    project_root: Path,
    report_path: Path,
    task_type: str,
    claude_projects_dir: str | None,
    failures: list[str],
    advisories: list[str] | None = None,
) -> None:
    """prompts/lead/okstra-lead-contract.md의 PROGRESS / activity / heartbeat /
    implementation entry guard 사후 검사를 위임하고 실패를
    ``session-conformance: `` 접두로 folding 한다. 설계:
    docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md

    PROGRESS 서술 라인 누락은 ``result.advisories`` 로 돌아오며 run 을
    실패시키지 않는다 — 그 라인을 쓸 세션은 이 검사가 도는 시점에 이미 끝나 있어
    남은 구제책이 phase 폐기뿐이다.
    """
    _validators_dir = Path(__file__).resolve().parent
    if str(_validators_dir) not in sys.path:
        sys.path.insert(0, str(_validators_dir))
    try:
        from validate_session_conformance import validate_session_conformance  # noqa: E402
    except ImportError as exc:
        failures.append(
            f"session-conformance: validate_session_conformance import failed — {exc}"
        )
        return
    result = validate_session_conformance(
        team_state=team_state,
        team_state_path=team_state_path,
        run_manifest=run_manifest,
        project_root=project_root,
        report_path=report_path,
        task_type=task_type,
        claude_projects_dir=Path(claude_projects_dir) if claude_projects_dir else None,
    )
    failures.extend(f"session-conformance: {err}" for err in result.errors)
    (advisories if advisories is not None else failures).extend(
        f"session-conformance: {note}" for note in result.advisories
    )


def _validate_forbidden_actions(
    team_state: dict,
    team_state_path: Path,
    project_root: Path,
    task_type: str,
    claude_projects_dir: str | None,
    failures: list[str],
) -> None:
    """phase deny-list(publish/deploy/force-push, 비-release-handoff bare push)
    위반을 세션 transcript 에서 스캔해 ``forbidden-action: `` 접두로 folding 한다.
    설계: docs/superpowers/plans/2026-06-13-repo-risk-hardening.md (P2-3)."""
    _validators_dir = Path(__file__).resolve().parent
    if str(_validators_dir) not in sys.path:
        sys.path.insert(0, str(_validators_dir))
    try:
        from forbidden_actions import scan_forbidden_actions  # noqa: E402
    except ImportError as exc:
        failures.append(
            f"forbidden-action: scan_forbidden_actions import failed — {exc}"
        )
        return
    violations = scan_forbidden_actions(
        team_state=team_state,
        team_state_path=team_state_path,
        project_root=project_root,
        task_type=task_type,
        claude_projects_dir=Path(claude_projects_dir) if claude_projects_dir else None,
    )
    failures.extend(f"forbidden-action: {v}" for v in violations)


_PHASE_BOUNDARY_ERROR_MARKERS = (
    "phase boundary",
    "forbidden action",
    "forbidden-action",
    "reverify ran ",
    "reverify executed ",
    "crossed phase",
)


def _validate_phase_boundary_error_log(
    run_dir: Path,
    task_type: str,
    failures: list[str],
) -> None:
    """Fail a run when its error log records a phase-boundary violation."""
    logs_dir = run_dir / "logs"
    if not logs_dir.is_dir():
        return
    for log_path in sorted(logs_dir.glob("errors-*.jsonl")):
        try:
            lines = log_path.read_text(encoding="utf-8").splitlines()
        except OSError:
            continue
        for raw in lines:
            try:
                record = json.loads(raw)
            except json.JSONDecodeError:
                continue
            if not isinstance(record, Mapping):
                continue
            if record.get("errorType") != "contract-violation":
                continue
            if record.get("phase") != task_type:
                continue
            message = str(record.get("message") or "").strip()
            boundary_type = str(
                record.get("violationType") or record.get("contractBoundary") or ""
            ).strip()
            boundary_recorded = boundary_type == "phase-boundary" or any(
                marker in message.lower() for marker in _PHASE_BOUNDARY_ERROR_MARKERS
            )
            if boundary_recorded:
                failures.append(f"phase-boundary: {message or 'recorded violation'}")


_CONVERGENCE_INTERMEDIATE_PREFIXES = (
    "convergence-groups-",
    "convergence-work-",
    "convergence-round-",
    "convergence-critic-",
)


def _validate_convergence_rounds_match_manifest(
    run_dir,
    run_manifest: dict,
    failures: list[str],
) -> None:
    """The round budget must be the one the run was configured with.

    The engine only checks `effectiveMaxRounds <= maxRounds` *within* the
    artifact, and the `okstra convergence` CLI never reads the manifest — so
    both numbers were lead-authored and unlinked to the `convergence.maxRounds`
    the run was prepared with. Writing `maxRounds: 1` into the artifact when
    the manifest says 2 legally short-circuits Round 2 via
    `round2SkippedReason: max-rounds-1` and halves cross-verification depth,
    with every self-consistency check satisfied.
    """
    from pathlib import Path as _Path

    configured = (run_manifest.get("convergence") or {}).get("maxRounds")
    if not isinstance(configured, int):
        return
    state_dir = _Path(run_dir) / "state"
    if not state_dir.is_dir():
        return
    for state_path in sorted(state_dir.glob("convergence-*.json")):
        if state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES):
            continue
        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        declared = (state.get("config") or {}).get("maxRounds")
        if isinstance(declared, int) and declared != configured:
            failures.append(
                f"convergence state `{state_path.name}` declares "
                f"`config.maxRounds`={declared} but the run manifest configured "
                f"{configured}. The round budget is not the lead's to restate — "
                "lowering it in the artifact silently halves cross-verification "
                "depth while every self-consistency check still passes."
            )


def _validate_convergence_states(
    run_dir, failures, run_manifest: dict | None = None, project_root: Path | None = None,
) -> None:
    """이번 런이 가리키는 수렴 상태만 본다. 디렉터리의 옛 seq 파일은 건너뛴다.

    매니페스트가 경로를 채워도 파일이 없으면 검사하지 않는다. render-only 와
    workflow 픽스처는 수렴을 돌리지 않아 파일이 없고, 없는 파일을 실패로 치면
    예전 glob 이 빈 결과를 내던 계약이 깨진다. 목적은 현재 런이 아닌 seq 를
    보지 않는 것이다.
    """
    from pathlib import Path as _Path

    declared = (run_manifest or {}).get("convergenceStatePath")
    if isinstance(declared, str) and declared.strip():
        path = _Path(declared.strip())
        if not path.is_absolute():
            if project_root is None:
                return
            path = project_root / path
        paths = [path] if path.is_file() else []
    else:
        state_dir = _Path(run_dir) / "state"
        if not state_dir.is_dir():
            return
        paths = [
            state_path
            for state_path in sorted(state_dir.glob("convergence-*.json"))
            if not state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES)
        ]
    for state_path in paths:
        if not state_path.is_file():
            continue
        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            failures.append(
                f"convergence state {state_path.name}: unreadable JSON: {exc}"
            )
            continue
        failures.extend(
            f"convergence state {state_path.name}: {error}"
            for error in validate_final_state(state)
        )


def _nonempty_string(value) -> bool:
    """True for a non-blank string, matching the schema's `\\S` pattern."""
    return isinstance(value, str) and value.strip() != ""


# Round-0 grouping filename: `convergence-groups-<task-type>-<seq>.json`. The
# `<task-type>-<seq>` suffix is shared verbatim with the worker-result and
# working-state basenames, so capture it as one token to reconstruct both.
def _validate_convergence_group_provenance(run_dir, failures) -> None:
    """Every source item a round-0 grouping cites must exist in the worker file.

    Shared with `okstra convergence seed`, which runs the same check when the
    grouping is handed over — this pass re-runs it over the finished run so a
    grouping written out of band is still caught. A validator failure blocks
    user approval, so unjudgeable input is skipped silently
    (`okstra_ctl.convergence_provenance.run_dir_provenance_errors`).
    """
    failures.extend(run_dir_provenance_errors(Path(run_dir)))


# Reverify-prompt basename: `<role-slug>-reverify-r<N>-<task-type>-<seq>.md`.
# The role slug ends in `-worker` like the worker-result files, but the round
# plan's `dispatches[].worker` may carry either that full slug or the bare
# provider token (`claude` vs `claude-worker`), depending on how the roster was
# seeded — worker resolution below accepts both. `-reverify-r` never occurs
# inside a slug, so the non-greedy slug capture stops at that literal anchor.
_REVERIFY_PROMPT_BASENAME_RE = re.compile(
    r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
    r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
)

# The reverify prompt's finding identifier is the lead-minted `### F-NNN` /
# `### C-NNN` H3 heading inside the `## Findings to verify` block. The older
# `### VF-N (origin ...)` / `G-NNN` heading shape this token regex deliberately
# does not match, so historical prompts parse zero ids and are skipped.
_FINDINGS_TO_VERIFY_HEADING_RE = re.compile(r"^##\s+Findings to verify\s*$")
_TWO_HASH_HEADING_RE = re.compile(r"^##\s")
_FINDING_HEADING_TOKEN_RE = re.compile(r"^###\s+(F-\d+|C-\d+)")


def _reverify_prompt_finding_ids(content: str) -> set[str] | None:
    """Distinct `### F-NNN`/`### C-NNN` tokens in the `## Findings to verify`
    block, or ``None`` when the prompt has no such block.

    The `## Response format` section re-echoes the same headings, so the block
    is bounded at the next `## ` heading and the tokens are collected into a
    set — order and re-echoed duplicates are irrelevant because votes are keyed
    by findingId. An empty set means the block exists but yielded no recognized
    heading (the historical `VF-N`/`G-NNN` shape); the caller skips both.
    """
    lines = content.splitlines()
    start = None
    for index, line in enumerate(lines):
        if _FINDINGS_TO_VERIFY_HEADING_RE.match(line):
            start = index + 1
            break
    if start is None:
        return None
    ids: set[str] = set()
    for line in lines[start:]:
        if _TWO_HASH_HEADING_RE.match(line):
            break  # next `## ` section (e.g. `## Response format`) ends the block
        match = _FINDING_HEADING_TOKEN_RE.match(line)
        if match:
            ids.add(match.group(1))
    return ids


def _plan_dispatch_finding_ids(plan, prompt_slug: str) -> set[str] | None:
    """The `findingIds` set of the single `dispatches[]` row the prompt slug
    resolves to, or ``None`` when the plan shape is malformed or the slug does
    not resolve to exactly one row (refuse to judge either way).

    A prompt slug `claude-worker` matches a row whose `worker` is `claude-worker`
    (exact) or `claude` (bare token, `worker + "-worker" == slug`). The round
    plan has no JSON schema, so the `dispatches[]` shape is validated defensively.
    """
    if not isinstance(plan, dict):
        return None
    dispatches = plan.get("dispatches")
    if not isinstance(dispatches, list):
        return None
    matched: list[set[str]] = []
    for row in dispatches:
        if not isinstance(row, dict):
            return None
        worker = row.get("worker")
        finding_ids = row.get("findingIds")
        if not _nonempty_string(worker) or not isinstance(finding_ids, list):
            return None
        if any(not _nonempty_string(fid) for fid in finding_ids):
            return None
        if worker == prompt_slug or f"{worker}-worker" == prompt_slug:
            matched.append(set(finding_ids))
    if len(matched) != 1:
        return None
    return matched[0]


def _validate_reverify_prompt_matches_plan(run_dir, failures) -> None:
    """Each reverify prompt must show exactly the findings its plan row assigned.

    `okstra convergence apply-round` checks *who* voted (`votes ⊆ plan`), but
    nothing checks that the reverify *prompt* carried the findings the plan
    assigned. A prompt-generation bug or a tampered prompt that drops or adds a
    `### F-NNN` heading relative to `dispatches[].findingIds` goes undetected,
    and a vote can be attributed to a finding the worker was never shown. This
    replays `plan == prompt`, which with the engine's check yields
    `votes ⊆ what-was-shown`. It stays independent of the engine's own check.

    A validator failure blocks user approval, so any unjudgeable input is
    skipped silently: no paired plan file for the prompt's (round, task-type,
    seq) — which also excludes a superseded plan whose seq/task-type differs;
    no recognizable `## Findings to verify` block or zero headings (guards the
    historical `VF-N`/`G-NNN` formats); a worker slug that resolves to other
    than exactly one `dispatches[]` row; and an unreadable/malformed plan.
    """
    from pathlib import Path as _Path

    run_dir = _Path(run_dir)
    prompts_dir = run_dir / "prompts"
    state_dir = run_dir / "state"
    if not prompts_dir.is_dir() or not state_dir.is_dir():
        return
    for prompt_path in sorted(prompts_dir.glob("*-reverify-r*.md")):
        match = _REVERIFY_PROMPT_BASENAME_RE.match(prompt_path.name)
        if match is None:
            continue
        round_n = match.group("round")
        plan_path = state_dir / (
            f"convergence-round-{round_n}-plan-"
            f"{match.group('task_type')}-{match.group('seq')}.json"
        )
        if not plan_path.is_file():
            continue  # no paired plan (~96% of artifacts) — never infer from the prompt
        try:
            content = prompt_path.read_text(encoding="utf-8")
            plan = json.loads(plan_path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            continue
        prompt_ids = _reverify_prompt_finding_ids(content)
        if not prompt_ids:  # None (no block) or empty set (no F-/C- headings)
            continue
        plan_ids = _plan_dispatch_finding_ids(plan, match.group("slug"))
        if plan_ids is None or prompt_ids == plan_ids:
            continue
        failures.append(
            f"reverify prompt `{prompt_path.name}` (round {round_n}, worker "
            f"`{match.group('slug')}`) does not show the findings its plan row "
            f"assigned: in the plan but not the prompt {sorted(plan_ids - prompt_ids)}; "
            f"in the prompt but not the plan {sorted(prompt_ids - plan_ids)}. The "
            f"prompt must carry exactly `dispatches[].findingIds` so a vote cannot "
            f"be attributed to a finding the worker was never shown."
        )


def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
    """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
    실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
    """
    from pathlib import Path as _Path
    if not (_Path(run_dir) / "fan-out").is_dir():
        return
    _validators_dir = _Path(__file__).resolve().parent
    if str(_validators_dir) not in sys.path:
        sys.path.insert(0, str(_validators_dir))
    try:
        from validate_fanout import validate_fanout  # noqa: E402
    except Exception as exc:  # pragma: no cover - import guard
        failures.append(
            f"requirements-discovery: validate_fanout import failed — {exc}"
        )
        return
    result = validate_fanout(_Path(run_dir), brief_path)
    if not result.ok:
        for err in result.errors:
            failures.append(f"requirements-discovery: {err}")


def _refresh_task_catalog(project_root: Path, task_manifest: dict) -> tuple[bool, str]:
    """Regenerate `discovery/task-catalog.json` so it stops trailing the
    authoritative `task-manifest.json` after validation.

    Resolves the catalog output path from the manifest, scans every
    `task-manifest.json` under the tasks root, and rewrites the catalog
    via `render_task_catalog_discovery`. Returns (ok, message); failure
    is non-fatal — the validator logs a warning instead of breaking the
    overall validation result.
    """
    catalog_relative = (task_manifest.get("taskCatalogPath") or "").strip()
    if not catalog_relative:
        return False, "taskCatalogPath missing from task-manifest — skip catalog refresh"

    here = Path(__file__).resolve().parent
    candidates = [
        here.parent / "scripts",
        here.parent / "python",
    ]
    env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
    if env_pp:
        candidates.append(Path(env_pp))
    for candidate in candidates:
        if candidate.is_dir() and (candidate / "okstra_ctl").is_dir():
            if str(candidate) not in sys.path:
                sys.path.insert(0, str(candidate))
            break

    try:
        from okstra_ctl.render import render_task_catalog_discovery  # noqa: E402
    except Exception as exc:  # noqa: BLE001
        return False, f"okstra_ctl import failed: {exc}"

    tasks_root = _okstra_tasks_root(project_root).resolve()
    catalog_path = (project_root / catalog_relative).resolve()
    ctx = {
        "PROJECT_ROOT": str(project_root),
        "OKSTRA_TASKS_ROOT": str(tasks_root),
        "PROJECT_ID": task_manifest.get("projectId", ""),
        "RUN_TIMESTAMP_ISO": utc_now(),
        "TASK_KEY": task_manifest.get("taskKey", ""),
        "OKSTRA_LATEST_TASK_RELATIVE_PATH": "",
    }
    try:
        render_task_catalog_discovery(str(catalog_path), ctx)
    except Exception as exc:  # noqa: BLE001
        return False, f"render_task_catalog_discovery raised: {exc}"
    return True, f"task-catalog refreshed at {catalog_relative}"


def _import_token_usage():
    """Resolve and import the okstra_token_usage package across layouts.

    Source tree:    <repo>/scripts/okstra_token_usage
    Built runtime:  <runtime>/python/okstra_token_usage   (next to validators/)
    Installed:      $OKSTRA_PYTHONPATH/okstra_token_usage (~/.okstra/lib/python)
    """
    here = Path(__file__).resolve().parent
    candidates = [
        here.parent / "scripts",
        here.parent / "python",
    ]
    env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
    if env_pp:
        candidates.append(Path(env_pp))
    for candidate in candidates:
        if candidate.is_dir() and (candidate / "okstra_token_usage").is_dir():
            if str(candidate) not in sys.path:
                sys.path.insert(0, str(candidate))
            break
    from okstra_token_usage.collect import collect  # noqa: E402
    from okstra_token_usage.report import populate_token_cells  # noqa: E402
    return collect, populate_token_cells


def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
    if _session_accounting(team_state) == "artifact-only":
        return False
    summary = team_state.get("usageSummary") or {}
    if not summary or not summary.get("collectedAt"):
        return True
    if report_path.is_file():
        content = report_path.read_text()
        if any(p in content for p in TOKEN_PLACEHOLDERS):
            return True
    # Even if the collector already ran (collectedAt is set), trigger when
    # every recorded usage is zero AND at least one source is "unavailable".
    # That combination means the previous collection silently failed to
    # locate session jsonls — we must surface accuracy failures rather than
    # let zeroed data ship as the final answer.
    grand_total = summary.get("grandTotalTokens", 0)
    if isinstance(grand_total, (int, float)) and grand_total == 0:
        lead_unavailable = (
            (team_state.get("leadUsage") or {}).get("source") == "unavailable"
        )
        workers_unavailable = any(
            ((w or {}).get("usage") or {}).get("source") == "unavailable"
            for w in (team_state.get("workers") or [])
        )
        if lead_unavailable or workers_unavailable:
            return True
    return False


def _accuracy_failures(updated: dict) -> list[str]:
    """Return human-readable reasons the collected usage is incomplete.

    Goal: never let zero-valued usage be silently written or substituted into
    the final report. If a session jsonl is missing, the operator must know
    which one and why so they can re-collect — recording accurate token usage
    is the contract this autofix preserves.
    """
    reasons: list[str] = []
    lead_usage = updated.get("leadUsage") or {}
    if lead_usage.get("source") == "unavailable":
        reasons.append(
            "lead Claude session jsonl was not found — "
            f"{lead_usage.get('note', 'reason unknown')}. "
            "Token usage cannot be recorded accurately until the lead session is locatable."
        )
    for worker in updated.get("workers") or []:
        role = worker.get("role") or worker.get("workerId") or "<unknown worker>"
        status = worker.get("status")
        usage = worker.get("usage") or {}
        if status == "completed" and usage.get("source") == "unavailable":
            reasons.append(
                f"worker `{role}` (status=completed) has no usage data — "
                f"{usage.get('note', 'reason unknown')}."
            )
        if worker.get("agent") in ("codex", "antigravity") and usage.get("source") != "unavailable":
            if "cliTotalTokens" not in usage:
                reasons.append(
                    f"worker `{role}` ({worker.get('agent')}) wrapper jsonl was located "
                    f"but its underlying CLI session usage was not — "
                    f"{usage.get('cliNote', 'reason unknown')}."
                )
    return reasons


def attempt_token_usage_autofix(
    team_state: dict,
    team_state_path: Path,
    report_path: Path,
    project_root: Path,
) -> tuple[str, list[str]]:
    """Run the Phase 7 token-usage collector in-process when artifacts indicate
    Phase 7 was skipped.

    Returns ``(state, messages)`` where ``state`` is one of:

    - ``"skipped"`` — opt-out or autofix not needed; messages is empty.
    - ``"recovered"`` — collector ran AND every session that should have a
      jsonl was found; team-state is rewritten and the final report's token
      placeholders are substituted with real values. messages carries a
      single info line.
    - ``"accuracy-failed"`` — collector ran but at least one expected
      session is missing. Nothing is written to disk; messages contains the
      contract violations the validator must surface so the operator can
      re-collect accurately rather than ship a report containing zeros.
    - ``"import-failed"`` / ``"collector-error"`` — autofix could not run;
      caller falls back to the original contract failures.
    """
    if os.environ.get("OKSTRA_VALIDATE_NO_AUTOFIX") == "1":
        return "skipped", []
    if not _needs_token_autofix(team_state, report_path):
        return "skipped", []
    try:
        collect, populate_token_cells = _import_token_usage()
    except Exception as exc:  # noqa: BLE001
        return "import-failed", [f"okstra_token_usage import failed: {exc}"]
    try:
        updated = collect(team_state_path, project_root)
    except Exception as exc:  # noqa: BLE001
        return "collector-error", [f"token-usage collector raised: {exc}"]

    accuracy_problems = _accuracy_failures(updated)
    if accuracy_problems:
        # Refuse to persist zeroed usage. Surface specific reasons so the
        # operator can locate the missing session(s) instead of silently
        # shipping a report with `0` token counts.
        return "accuracy-failed", [
            f"Phase 7 token-usage auto-recovery refused to write incomplete data: {reason}"
            for reason in accuracy_problems
        ]

    team_state_path.write_text(
        json.dumps(updated, indent=2, ensure_ascii=False) + "\n"
    )
    data_path = _data_path_for(report_path)
    try:
        replaced = populate_token_cells(data_path, updated)
    except Exception as exc:  # noqa: BLE001
        # `SubstituteRefusedError` (or any unexpected substitution
        # failure) — report it as an accuracy failure so the validator
        # surfaces a concrete remediation instead of silently shipping
        # a report with zeros / sentinels.
        return "accuracy-failed", [
            f"Phase 7 token-usage substitution refused: {exc}"
        ]

    # Phase 7 step 1.5 is BLOCKING and the autofix just mutated the
    # report record — any pre-existing html sibling is now stale by
    # construction. Re-render the html view in lock-step so the
    # downstream report-views validator does not trip over the
    # autofix's own side effect.
    rerender_note = _rerender_report_views_after_autofix(report_path)

    detail = (
        f"replaced {replaced} placeholder(s)"
        if replaced > 0
        else "no placeholders to replace"
        if replaced == 0
        else "report file missing"
    )
    msg = f"usageSummary repopulated; {detail}"
    if rerender_note:
        msg += f"; {rerender_note}"
    return "recovered", [msg]


def _rerender_report_views_after_autofix(report_path: Path) -> str:
    """Re-render the ``*.html`` sibling against the just-substituted MD.
    Returns a short status note for the autofix message (empty on no-op,
    descriptive on failure).

    Delegates to ``scripts/okstra-render-report-views.py`` rather than calling
    a renderer directly: that script is the single reference point that routes
    schema-v2 reports to the data.json-driven task view and schema-v1 reports
    to the legacy markdown renderer. Rendering here would have to re-derive
    both the routing and the run-meta, and getting either wrong overwrites a
    schema-v2 report's HTML — the user's only way to answer its clarifications
    — with a legacy render that carries no answer controls.
    """
    import subprocess

    if not report_path.is_file():
        return ""
    renderer = (
        Path(__file__).resolve().parent.parent
        / "scripts" / "okstra-render-report-views.py"
    )
    if not renderer.is_file():
        return f"report-views re-render skipped (renderer missing under {renderer.parent})"
    try:
        proc = subprocess.run(
            [sys.executable, str(renderer), str(report_path)],
            capture_output=True,
            text=True,
            timeout=60,
        )
    except subprocess.TimeoutExpired:
        return "report-views re-render failed: renderer timed out (60s)"
    if proc.returncode != 0:
        detail = (proc.stderr or proc.stdout).strip().splitlines()
        return f"report-views re-render failed: {detail[-1] if detail else 'unknown error'}"
    emitted = (proc.stdout or "").strip().splitlines()
    if emitted and emitted[-1].startswith("html: skipped"):
        return "report-views skipped (no §1 clarification rows)"
    return "report-views re-rendered"


SECTION_FULL = "full"
SECTION_PLAN_BODY = "plan-body"

# `full` needs the whole run assembled; the round-boundary section reads only
# the report and its data.json sibling, which is what exists mid-loop.
_FULL_ONLY_REQUIRED_FLAGS = ("team_state", "run_manifest", "task_manifest")


def run_plan_body_section(
    report_path: Path, state_path: Path | None = None
) -> int:
    """`--section plan-body` — the §5.5.9 checks a self-fix round can run on
    its own, plus the recomputed gate the lead records for that round.

    Emits one JSON object on stdout so the caller reads the gate instead of
    re-deriving it: exit 0 when the section is clean, 2 when it is not.
    """
    data_path = _data_path_for(report_path)
    if not data_path.is_file():
        print(
            f"validate-run: --section plan-body needs {data_path}, which does "
            "not exist yet — render the round's data.json first.",
            file=sys.stderr,
        )
        return 2
    try:
        data = json.loads(data_path.read_text(encoding="utf-8"))
    except ValueError as exc:
        print(
            f"validate-run: {data_path} is not parseable JSON ({exc}). "
            "Restore it from the sibling `.data.json.last-valid` snapshot the "
            "renderer writes after each successful render.",
            file=sys.stderr,
        )
        return 2
    failures = _data_schema_failures(data)
    warnings = validate_plan_body_section(data, report_path, failures)
    _validate_plan_body_state_file(data, report_path, failures, state_path)
    payload = {
        "ok": not failures,
        "section": SECTION_PLAN_BODY,
        "gate": plan_body_gate_summary(data),
        "failures": failures,
        "warnings": warnings,
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0 if not failures else 2


def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
    """게시 전 서사와 수렴 소유 상태에서 같은 계획 게이트를 채점한다."""
    from okstra_ctl.report_narrative import parse_narrative
    from okstra_ctl.final_report_schema import load_schema_version

    try:
        narrative = parse_narrative(
            narrative_path.read_text(encoding="utf-8"),
            load_schema_version("3.0"),
        )
        state = json.loads(state_path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, ValueError) as exc:
        print(f"validate-run: plan-body inputs are invalid ({exc})", file=sys.stderr)
        return 2
    state_owner = state.get("owner") if isinstance(state, dict) else None
    verification = state.get("planBodyVerification") if isinstance(state, dict) else None
    planning = narrative.get("implementationPlanning")
    if state_owner != "convergence" or not isinstance(verification, dict):
        print("validate-run: plan-body state must be convergence-owned", file=sys.stderr)
        return 2
    if not isinstance(planning, dict):
        print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
        return 2
    data = {**narrative, "schemaVersion": "3.0"}
    data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
    failures: list[str] = []
    warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
    payload = {
        "ok": not failures,
        "section": SECTION_PLAN_BODY,
        "gate": plan_body_gate_summary(data),
        "failures": failures,
        "warnings": warnings,
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0 if not failures else 2


def _report_path_for_state(state_path: Path) -> Path:
    match = re.search(r"-(\d{3})\.json$", state_path.name)
    seq = match.group(1) if match else "001"
    return state_path.parent.parent / "reports" / (
        f"final-report-implementation-planning-{seq}.data.json"
    )


def _data_schema_failures(data: dict) -> list[str]:
    """Schema errors in the round's data.json, as failures.

    The gate block is hand-edited between rounds, so a structure written in the
    lead's own internal shape is schema-invalid while still being readable
    JSON. Nothing caught that until the renderer ran, which is one round too
    late — by then the previous verdicts have already been overwritten.
    """
    if schema_validate is None or load_schema_for_data is None:
        return []
    try:
        schema = load_schema_for_data(data)
    except SchemaError as exc:
        return [f"final-report data.json: schema not locatable ({exc})"]
    return [
        f"final-report data.json: schema violation — {error}"
        for error in schema_validate(data, schema)
    ]


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Validate okstra run contract artifacts."
    )
    parser.add_argument(
        "--section",
        choices=(SECTION_FULL, SECTION_PLAN_BODY),
        default=SECTION_FULL,
        help=(
            "Which contract surface to validate. `full` (default) validates the "
            "finished run. `plan-body` validates only the §5.5.9 plan-body "
            "verification surface from the report and its data.json, and prints "
            "the recomputed gate — the round-boundary check a self-fix loop runs "
            "before it records a round."
        ),
    )
    parser.add_argument(
        "--team-state",
        required=False,
        help="Project-relative or absolute path to the team state JSON.",
    )
    parser.add_argument(
        "--report",
        required=False,
        help="Project-relative or absolute path to the report record (.data.json). Schema-v1 reports still use the Markdown file.",
    )
    parser.add_argument("--narrative", required=False)
    parser.add_argument("--state", required=False)
    parser.add_argument(
        "--run-manifest",
        required=False,
        help="Project-relative or absolute path to the run manifest JSON.",
    )
    parser.add_argument(
        "--task-manifest",
        required=False,
        help="Project-relative or absolute path to the task manifest JSON.",
    )
    parser.add_argument(
        "--final-status", required=False, help="Optional final status file to write."
    )
    parser.add_argument(
        "--claude-projects-dir",
        required=False,
        default=None,
        help=(
            "Override the Claude Code projects root used for session-conformance "
            "jsonl lookup (test/diagnostic seam; default: ~/.claude/projects)."
        ),
    )
    args = parser.parse_args()

    if args.section == SECTION_PLAN_BODY:
        if args.narrative and args.state and not args.report:
            return run_plan_body_inputs(
                Path(args.narrative).resolve(), Path(args.state).resolve()
            )
        if args.report and not args.narrative and not args.state:
            return run_plan_body_section(
                Path(args.report).resolve(),
                Path(args.state).resolve() if args.state else None,
            )
        parser.error(
            "--section plan-body requires either --report or both --narrative and --state"
        )

    missing = [
        f"--{flag.replace('_', '-')}"
        for flag in _FULL_ONLY_REQUIRED_FLAGS
        if not getattr(args, flag)
    ]
    if missing:
        parser.error(
            f"--section {SECTION_FULL} requires {', '.join(missing)}"
        )

    run_manifest_path = Path(args.run_manifest).resolve()
    run_manifest = load_json(run_manifest_path)
    task_manifest_path = Path(args.task_manifest).resolve()
    task_manifest = load_json(task_manifest_path)

    project_root_raw = str(task_manifest.get("projectRoot") or "").strip()
    if not project_root_raw:
        raise ValueError("projectRoot is missing from task manifest")
    project_root = Path(project_root_raw)

    def resolve_input(raw_path: str) -> Path:
        path = Path(raw_path)
        if path.is_absolute():
            return path
        return (project_root / raw_path).resolve()

    team_state_path = resolve_input(args.team_state)
    report_path = resolve_input(args.report)
    team_state = load_json(team_state_path)

    autofix_state, autofix_messages = attempt_token_usage_autofix(
        team_state, team_state_path, report_path, project_root
    )
    if autofix_state == "recovered":
        team_state = load_json(team_state_path)
        for msg in autofix_messages:
            print(f"validate-run: Phase 7 auto-recovery — {msg}", file=sys.stderr)
    elif autofix_state in ("import-failed", "collector-error"):
        for msg in autofix_messages:
            print(f"validate-run: Phase 7 auto-recovery skipped — {msg}", file=sys.stderr)

    failures: list[str] = []
    # Findings that are reported but do not fail the run.
    #
    # The line is whether a remedy still exists at Phase 7. A defect in the
    # report is fixable — re-author it and re-render. A defect in what a
    # *finished session* wrote down is not: the worker that would restate its
    # citation and the lead pane that would emit its PROGRESS line have both
    # ended, so the only remaining move is to void the phase. That blocks the
    # next phase without establishing that any conclusion in the report is
    # wrong. Those findings are surfaced and carried in the run manifest
    # instead — currently the worker citation ledger and the PROGRESS narration
    # lines.
    advisories: list[str] = []
    if autofix_state == "accuracy-failed":
        failures.extend(autofix_messages)
    _validate_execution_identity_v2(run_manifest, failures)
    contract = extract_contract(run_manifest, task_manifest, failures)
    concurrent_run_authorized = bool(
        (run_manifest.get("concurrentRun") or {}).get("detected")
    )
    validate_team_state(
        team_state,
        project_root,
        contract,
        failures,
        concurrent_run_authorized=concurrent_run_authorized,
    )
    _validate_agent_dispatch_contract(
        project_root=project_root,
        run_manifest_path=run_manifest_path,
        run_manifest=run_manifest,
        team_state=team_state,
        failures=failures,
    )
    # Schema validation runs BEFORE markdown substring checks: if the
    # data.json is well-formed, the rendered markdown is guaranteed to
    # contain every required section. Substring checks below are a
    # safety net for hand-edited or pre-v1.0 reports.
    task_type = effective_run_task_type(run_manifest, task_manifest)
    _validate_initial_analysis_prompts(
        {
            "taskType": task_type,
            "projectRoot": project_root,
            "runManifest": run_manifest,
            "teamState": team_state,
        },
        failures,
    )
    report_contracts = _normalize_report_contracts(
        run_manifest.get("reportContracts")
    )
    report_data = validate_final_report_data(
        report_path,
        failures,
        report_contracts=report_contracts,
        run_manifest=run_manifest,
        project_root=project_root,
        clarification_text=_clarification_text_for_run(
            run_manifest_path, report_path
        ),
    )
    validation_data = report_data if isinstance(report_data, Mapping) else {}
    _validate_ssot_is_english(validation_data, failures)
    _validate_fix_cycle(run_manifest, validation_data, failures)
    validate_report(
        report_path,
        contract["required_agent_status_entries"],
        failures,
        allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
        unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
        report_data=validation_data,
        team_state=team_state,
    )
    validate_team_state_usage(team_state, failures)

    _validate_phase_boundary_error_log(
        report_path.parent.parent,
        task_type,
        failures,
    )
    if task_type:
        validate_worker_results_audit(report_path, task_type, failures, advisories)
        _validate_session_conformance(
            team_state,
            team_state_path,
            run_manifest,
            project_root,
            report_path,
            task_type,
            args.claude_projects_dir,
            failures,
            advisories,
        )
        _validate_forbidden_actions(
            team_state,
            team_state_path,
            project_root,
            task_type,
            args.claude_projects_dir,
            failures,
        )
    if task_type in ("implementation", "final-verification"):
        _sp = None
        _pj = project_json_path(project_root)
        if _pj.is_file():
            try:
                _sp = (json.loads(_pj.read_text()).get("qaEnv") or {}).get("surfacePatterns")
            except (OSError, json.JSONDecodeError):
                _sp = None
        conformance_warnings = _validate_conformance(
            report_path,
            failures,
            surface_patterns=_sp,
            approved_plan_path=_approved_plan_path_from_run_inputs(
                run_manifest_path,
                failures,
            ),
        )
        _validate_selfmock(report_path, failures)
        for warning in conformance_warnings:
            print(f"validate-run: warning: {warning}", file=sys.stderr)
    if task_type in _BRIEF_DERIVED_PHASES:
        planning = validation_data.get("implementationPlanning")
        selected_direction_plan = (
            task_type == "implementation-planning"
            and isinstance(planning, Mapping)
            and planning.get("planningContract") == "selected-direction"
        )
        brief_path = (
            project_root / "__selected-direction-brief-validated-from-run-manifest__"
            if selected_direction_plan
            else _brief_path_from_manifest(task_manifest, project_root)
        )
        if task_type in _END_STATE_PHASES:
            if task_type == "implementation-planning":
                _validate_planning_conformance_declared(report_path, failures)
            if not selected_direction_plan:
                _validate_end_state_coverage(validation_data, brief_path, failures)
            if task_type == "implementation-planning" and not selected_direction_plan:
                _validate_requirement_provenance(
                    validation_data, brief_path, failures
                )
                _validate_stage_has_requirement(validation_data, failures)
            if task_type == "implementation-planning":
                _append_stage_data_failures(validation_data, failures)
        if task_type == "improvement-discovery":
            run_dir = report_path.parent.parent
            _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
        if task_type == "requirements-discovery":
            run_dir = report_path.parent.parent
            _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
    # Phase-agnostic: convergence runs in every finding-producing phase.
    _validate_convergence_states(
        report_path.parent.parent, failures, run_manifest, project_root
    )
    _validate_convergence_rounds_match_manifest(
        report_path.parent.parent, run_manifest, failures
    )
    _validate_convergence_group_provenance(report_path.parent.parent, failures)
    _validate_reverify_prompt_matches_plan(report_path.parent.parent, failures)
    validate_report_views(report_path, failures)
    if task_type == "implementation":
        _validate_missing_qa_categories_recorded(
            validation_data, project_root, failures
        )
    if task_type == "implementation-planning":
        _validate_plan_body_state_file(
            validation_data,
            report_path,
            failures,
            Path(args.state).resolve() if args.state else None,
        )
    if task_type == "final-verification":
        _validate_verification_target_match(
            validation_data,
            run_manifest,
            project_root,
            failures,
        )
    # Phase-agnostic: any task-type can be launched with a carry-in.
    _validate_clarification_carry_in_recorded(
        validation_data,
        report_path,
        run_manifest_path,
        project_root,
        failures,
    )

    validation_status = "passed" if not failures else "failed"
    update_validation_metadata(
        team_state,
        run_manifest,
        task_manifest,
        validation_status,
        failures,
        report_data=validation_data,
        advisories=advisories,
    )

    write_json(team_state_path, team_state)
    write_json(run_manifest_path, run_manifest)
    write_json(task_manifest_path, task_manifest)

    # Best-effort: regenerate discovery/task-catalog.json so downstream
    # tools (okstra-schedule-gen, FleetView listings, etc.) don't read a stale
    # snapshot frozen at instruction-set generation time.
    catalog_ok, catalog_msg = _refresh_task_catalog(project_root, task_manifest)
    if catalog_ok:
        print(f"validate-run: {catalog_msg}", file=sys.stderr)
    else:
        print(
            f"validate-run: task-catalog refresh skipped — {catalog_msg}",
            file=sys.stderr,
        )

    if args.final_status:
        final_status_path = resolve_input(args.final_status)
        final_status_path.parent.mkdir(parents=True, exist_ok=True)
        final_status_path.write_text(
            ("completed" if validation_status == "passed" else "contract-violated")
            + "\n"
        )

    for note in advisories:
        print(f"validate-run: advisory — {note}", file=sys.stderr)

    result = {
        "validationStatus": validation_status,
        "finalRunStatus": run_manifest.get("status"),
        "failures": failures,
        "advisories": advisories,
        "teamStatePath": str(team_state_path),
        "reportPath": str(report_path),
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0 if validation_status == "passed" else 1


if __name__ == "__main__":
    sys.exit(main())
