#!/usr/bin/env python3

from __future__ import annotations

import argparse
import importlib.util
import json
import os
import posixpath
import re
import sys
from collections.abc import Mapping, Sequence
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
    conformance_result_file,
    detect_surfaces,
    exempt_stage_surface_conflicts,
    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, okstra_home, project_rel  # noqa: E402
from okstra_ctl.tdd_bypass import (  # noqa: E402
    bypass_file as tdd_bypass_file,
    granted_stages,
)
from okstra_ctl.reconcile import settle_run_row  # 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.validation_contract import (  # noqa: E402
    CURRENT_VALIDATION_CONTRACT_VERSION,
)
from okstra_ctl.blocking_checks import partition as partition_blocking  # noqa: E402
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,
    requires_plan_repair,
    analyser_key as _analyser_key,
    is_critic_worker,
    lead_decision_basis,
    self_fix_rounds,
    stage_scope_bucket as _item_stage_scope_bucket,
    voting_analyser_keys,
)
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,
    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.final_report_paths import (  # noqa: E402
    final_report_data_path as _data_path_for,
    translation_sidecar_path,
)
from okstra_token_usage.report import _match_worker_index  # noqa: E402
from okstra_ctl.technical_verification import validate_technical_verification_report
from okstra_ctl.implementation_options import (  # noqa: E402
    validate_blocked_answer_channel,
    validate_implementation_option_selection,
)
from okstra_ctl.implementation_direction import (  # noqa: E402
    validate_selected_direction_plan,
)
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,
    _request_identity as _design_prep_request_identity,
)
from okstra_ctl.design_surfaces import DesignSurfaceError  # noqa: E402
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,
    invocation_input_digest,
    verify_agent_invocation,
)
from okstra_ctl.execution_identity import ExecutionManifestError  # noqa: E402
from okstra_ctl.execution_manifest import (  # noqa: E402
    hoist_legacy_attempt_write_contract,
    validate_execution_manifest_payload,
)
from okstra_ctl.worker_audit_ledger import (  # noqa: E402
    worker_results_audit_findings,
)
from validate_analysis_report import validate_analysis_report  # noqa: E402
from okstra_ctl.convergence_engine import validate_final_state  # noqa: E402
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", "cmux-pane"}
_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 _dispatch_belongs_to_worker(row: Mapping[str, Any], worker_id: str) -> bool:
    """v2 디스패치가 이 workerId 의 표인지.

    `initial/{id}` 만 보면 critic/scope 와 reverify/claude 가 완료 워커로
    남고 invocation 이 없다고 실패한다.
    """
    ref = str(row.get("assignmentRef") or "").strip()
    if not worker_id or not ref:
        return False
    kind, separator, assigned = ref.partition("/")
    return bool(separator) and assigned == worker_id and kind in {
        "initial", "reverify", "critic", "lead",
    }


def _result_link_attempt_status_failure(
    dispatch_id: str,
    dispatch: Mapping[str, Any],
    canonical_attempts: Mapping[tuple[Any, Any], Mapping[str, Any]],
) -> str:
    """An accepted result must sit on an attempt the ledger closed as ``ok``.

    The mutation audit discards a result by setting the attempt's terminal status
    (`contract-failed-unattributed`, `mutation-present-unresolved`) and clearing
    its `resultPath`; the file stays on disk, so a lead can still link it. A
    `started` attempt has no terminal status at all — its result was never
    collected through `team await`. Either way the link claims an acceptance the
    execution ledger does not back. Observed 2026-09-06 (`fontsninja-v3-site`
    final-verification 001): a discarded verifier result seeded 9 of 10
    convergence groups while its attempt row said discarded.
    """
    if dispatch.get("audience") == "lead":
        # 리드의 attempt 는 Phase 7 이 닫는다 — 이 검증 자체가 그 phase 안에서
        # 도니, 검증 시점의 리드 행은 `started` 인 것이 정상이다. 워커 결과의
        # 채택 여부만 묻는다.
        return ""
    attempt = canonical_attempts.get(
        (dispatch.get("invocationRef"), dispatch.get("attempt"))
    )
    if not isinstance(attempt, Mapping):
        return ""
    status = str(attempt.get("status") or "")
    if status == "ok":
        return ""
    return (
        f"accepted result is linked to a non-ok attempt: {dispatch_id} has status "
        f"`{status or 'unknown'}` — a discarded or unfinished attempt cannot back "
        "an accepted result (re-dispatch as a new invocation, or run `okstra team "
        "await` so the attempt closes before linking)"
    )


def _dispatch_input_digest(
    project_root: Path, row: Mapping[str, Any]
) -> tuple[str | None, str]:
    """이 디스패치의 예약 입력 해시, 예약이 계산한 것과 같은 방법으로.

    `inputDigest` 와 `promptDigest` 는 서로 다른 대상이다. 전달 계약
    `execution-identity-v1` 부터 예약은 **논리 작업**을 해시한다 — 시도별 전달값과
    prompt history 경로를 뺀 본문(`agent_prompt_task_bytes`) — 반면 `promptDigest`
    는 프롬프트 파일 전체 바이트다. 둘을 동등 비교하면 그 계약을 쓰는 run 은
    통과할 수 있는 값이 하나도 없다(2026-09-08 f56ec08 이후 전부, 2026-09-10
    dev-10642-15 final-verification 001 에서 7/7 디스패치가 이 규칙에 걸렸다).

    그래서 재구현하지 않고 예약이 쓰는 함수를 그대로 부른다. 메타데이터 경로가
    없는 구형 행만 `promptDigest` 로 돌아간다 — 그 계약에서는 두 값이 같다.
    """
    metadata_value = row.get("promptMetadataPath")
    if not isinstance(metadata_value, str) or not metadata_value.strip():
        return row.get("promptDigest"), ""
    try:
        metadata = json.loads(
            _resolve_prompt_record_path(project_root, metadata_value)
            .read_text(encoding="utf-8")
        )
    except (OSError, json.JSONDecodeError):
        return None, "prompt metadata is missing or invalid"
    if not isinstance(metadata, Mapping):
        return None, "prompt metadata is not an object"
    try:
        return invocation_input_digest(metadata, project_root), ""
    except (AgentInvocationError, KeyError, OSError, ValueError) as exc:
        return None, f"input digest cannot be recomputed: {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")): row
        for row in run_manifest.get("attempts") or []
        if isinstance(row, Mapping)
    } if uses_v2_identity else {}
    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"))
            input_digest, digest_error = _dispatch_input_digest(project_root, row)
            if digest_error:
                failures.append(f"agent dispatch {dispatch_id}: {digest_error}")
                continue
            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") != input_digest,
                )):
                failures.append(
                    f"agent dispatch {dispatch_id}: does not match canonical invocation"
                )
                continue
            canonical_attempt = canonical_attempts.get(
                (row.get("invocationRef"), attempt)
            )
            if not isinstance(canonical_attempt, Mapping):
                failures.append(
                    f"agent dispatch {dispatch_id}: has no canonical invocation attempt"
                )
                continue
            if row.get("writePolicyDigest") != canonical_attempt.get("writePolicyDigest"):
                failures.append(
                    f"agent dispatch {dispatch_id}: writePolicyDigest does not "
                    "match canonical attempt"
                )
                continue
            if row.get("writeEnforcement") != canonical_attempt.get("writeEnforcement"):
                failures.append(
                    f"agent dispatch {dispatch_id}: writeEnforcement does not "
                    "match canonical attempt"
                )
                continue
            mutation_mode = canonical_attempt.get("writeEnforcement", {}).get(
                "mutationAudit"
            ) if isinstance(canonical_attempt.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
            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: "
                f"dispatchId={dispatch_id or '<empty>'} "
                f"resultPath={Path(result_path).name if result_path else '<empty>'}"
            )
            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
            status_failure = _result_link_attempt_status_failure(
                dispatch_id, dispatch, canonical_attempts
            )
            if status_failure:
                failures.append(status_failure)
                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 (
                _dispatch_belongs_to_worker(row, 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
        if not worker_result:
            # critic 은 workers[].resultPath 를 비운 채 completed 가 된다.
            # 링크가 그 디스패치에 있으면 빈 경로와 파일 경로를 비교하지 않는다.
            if not any(
                link.get("dispatchId") in worker_dispatch_ids for link in links
            ):
                failures.append(
                    f"accepted LLM result is not linked to its own dispatch: {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")


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


# 폐기된 정정 기록이 rationale 자리에 심던 안내 문구. 이 문구가 가리키던
# `nextRecommendedPhaseCorrection` 은 더 이상 쓰이지 않으므로, 이미 배포된
# 매니페스트에서 물려받으면 존재하지 않는 필드를 가리킨 채 사용자에게 간다.
# 물려받기 경로에서 걸러내려고 남긴다.
_LEGACY_CORRECTION_NOTICE = (
    "리포트 라우팅에서 투영됨. 리드가 쓴 값과 근거는 "
    "nextRecommendedPhaseCorrection.authored 에 있다."
)


_BLOCKED_RATIONALE_HEAD = "직전 검증이 실패해 "
_CARRY_MARKER = " 이 phase 로 온 근거: "


def _arrival_reason(inherited: str) -> str:
    """물려받은 근거에서 "왜 이 phase 로 왔는가" 만 뽑는다.

    물려받은 값을 그대로 이어 붙이면 실패가 반복될 때 근거가 한 라운드에 한
    겹씩 자란다 — 새 머리말 + 직전 근거(그 자체가 머리말 + 그 직전 근거).
    dev-10341 에서 8겹 2353자까지 자랐고, 포인터만 읽는 소비자에게는 같은
    문장이 여덟 번 도착했다.

    이어 붙일 값이 있는 경우는 그것이 통과한 리포트의 라우팅에서 온 도착
    사유일 때뿐이다. 물려받은 값이 이미 blocked 근거면 머리말은 이번 라운드가
    다시 쓰므로 버리고, 그 안에 감싸여 있던 도착 사유만 남긴다.
    """
    carried = inherited.strip()
    if not carried or carried == _LEGACY_CORRECTION_NOTICE:
        return ""
    if not carried.startswith(_BLOCKED_RATIONALE_HEAD):
        return carried
    _, marker, tail = carried.rpartition(_CARRY_MARKER)
    if not marker:
        return ""
    return _arrival_reason(tail)


def _blocked_rationale(
    current_phase: str,
    inherited: str,
    failures: Sequence[str] = (),
    run_manifest_path: str = "",
) -> str:
    """검증이 실패한 run 이 남기는 근거 문장.

    실패해도 사용자가 할 일은 있다 — 같은 phase 를 다시 실행하는 것이다.
    포인터의 `phase` 는 실패 run 에서 비워지므로(재실행 대상은 `currentPhase`
    에서 읽는다) 그 이름을 문장에 직접 넣는다. 이름이 어디에도 없으면 다음
    작업을 추천받는 사람이 무엇을 다시 돌려야 하는지 모른다.

    무엇이 실패했는지도 이 문장이 싣는다. 전체 목록은 실패한 run 의 run
    매니페스트 `validation.failures` 에 있지만, 포인터만 읽는 소비자 — 리드의
    closeout, wizard 의 다음 작업 추천, manager sync — 는 그 파일을 열지 않는다.
    위치로는 태스크 매니페스트의 `contractValidation.failures` 가 아니라 run
    매니페스트를 가리킨다 — 다음 prepare 가 `contractValidation` 을 `not-run`/
    빈 목록으로 다시 쓰면서(`render.py` 의 task-manifest 렌더) 포인터는 그대로
    물려주므로, 태스크 매니페스트를 가리킨 문장은 한 prepare 뒤 빈 목록을 가리킨다.
    실측(2026-08-26 → 2026-09-06, fontsninja-nlpvibe nestjs-migration): r25 의
    72건을 가리키던 pointer 가 r26 prepare 뒤 `failures: []` 를 가리키고 있었다.
    그들에게 `blocked` 한 단어만 도착하면 무엇을 고쳐야 하는지 알 수 없고,
    남는 선택지는 같은 phase 를 그대로 다시 돌리는 것뿐이다. 그래서 건수와
    첫 실패를 문장 안으로 옮긴다.

    직전 run 이 남긴 근거는 "왜 이 phase 로 왔는가" 이므로 재실행 뒤에도
    유효하다. `_arrival_reason` 이 뽑아낸 것만 뒤에 이어 붙인다 — 폐기된 정정
    안내와, 직전 라운드가 쓴 머리말은 걸러진다.
    """
    target = current_phase or "직전 phase"
    # 개행을 접는다 — 근거는 한 문장으로 읽히는 자리이고, 여러 줄 실패 메시지가
    # 그대로 들어오면 포인터를 인용하는 표면이 전부 깨진다.
    rows = [" ".join(str(row).split()) for row in failures]
    rows = [row for row in rows if row]
    where = (
        f"run 매니페스트(`{run_manifest_path}`)의 `validation.failures`"
        if run_manifest_path
        else "이 run 의 run 매니페스트 `validation.failures`"
    )
    if not rows:
        # 실패 목록 없이 failed 로 온 경우 — 호출부가 넘기지 않았거나 목록이
        # 비었다. 위치만 가리키던 종전 문장을 그대로 쓴다.
        head = (
            f"직전 검증이 실패해 `{target}` 를 다시 실행해야 한다. "
            f"실패 사유는 {where} 에 있다."
        )
    else:
        first = rows[0][:200]
        lead_in = (
            f"실패 {len(rows)}건 중 첫 항목" if len(rows) > 1 else "실패 사유"
        )
        head = (
            f"직전 검증이 실패해 `{target}` 를 다시 실행해야 한다. "
            f"{lead_in}: {first} "
            f"(전체 목록은 {where})."
        )
    carried = _arrival_reason(inherited)
    if not carried:
        return head
    return f"{head}{_CARRY_MARKER}{carried}"


def update_workflow_metadata(
    run_manifest: dict,
    task_manifest: dict,
    validation_status: str,
    report_data: Mapping[str, Any] | None = None,
    failures: Sequence[str] = (),
) -> 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")

    # 포인터는 이 run 의 리포트 라우팅에서 투영된다. 매니페스트에 들어 있던
    # 값은 직전 run 이 남긴 것이지 이번 run 의 판단이 아니므로, 통과 분기는
    # 그것과 대조하지 않는다 — 대조하면 물려받은 값과 이번 투영이 어긋나는
    # 정상 상태가 매번 "정정" 으로 기록됐다.
    inherited_pointer = 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", "")
        next_recommended_phase = next_phase.project(report_data or {})
    else:
        current_phase_state = "blocked"
        if current_phase:
            phase_states[current_phase] = current_phase_state
        last_completed_phase = workflow.get("lastCompletedPhase", "")
        recovery = next_phase.project(report_data or {})
        recovery_phase = recovery["phase"]
        can_backtrack = (
            current_phase in PHASE_SEQUENCE
            and recovery_phase in PHASE_SEQUENCE
            and PHASE_SEQUENCE.index(recovery_phase)
            < PHASE_SEQUENCE.index(current_phase)
        )
        next_recommended_phase = next_phase.make(
            phase=recovery_phase if can_backtrack else "",
            status=next_phase.STATUS_BLOCKED,
            rationale=_blocked_rationale(
                recovery_phase if can_backtrack else current_phase,
                recovery["rationale"]
                if can_backtrack
                else inherited_pointer["rationale"],
                failures,
                run_manifest_path=str(run_manifest.get("runManifestPath") or ""),
            ),
        )

    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 = {}
    # 폐기된 필드는 옛 스냅샷에서 걷어낸다.
    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"],
        }
    )
    run_manifest["workflowSnapshot"] = workflow_snapshot


def _route_versioned_session_failures(
    run_manifest: Mapping[str, Any],
    session_failures: Sequence[str],
    failures: list[str],
    advisories: list[str],
) -> None:
    """종료된 구형 세션에 새 검증 계약을 소급하지 않는다."""
    if not session_failures:
        return

    version = run_manifest.get("validationContractVersion")
    if version is None:
        for failure in session_failures:
            is_legacy_contract_change = (
                failure.startswith("agent dispatch ")
                and "prompt model header does not match model assignment" in failure
            ) or (
                " prompt contract: " in failure
                and (
                    "exactly one non-empty **Model:**" in failure
                    or "normalized initial analysis prompts differ across workers"
                    in failure
                )
            ) or (
                failure.startswith("convergence state ")
                and (
                    "does not match replayed classification" in failure
                    or "do not match finding ledgers" in failure
                )
            )
            if is_legacy_contract_change:
                advisories.append(f"legacy-validation-contract: {failure}")
            else:
                failures.append(failure)
        return

    if version != CURRENT_VALIDATION_CONTRACT_VERSION:
        failures.append(
            "validationContractVersion must equal "
            f"{CURRENT_VALIDATION_CONTRACT_VERSION}, got {version!r}"
        )
    failures.extend(session_failures)


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,
        failures=failures,
    )


def record_validation_in_central_index(
    task_manifest: Mapping[str, Any],
    run_manifest_path: Path,
    project_root: Path,
    validation_status: str,
) -> bool:
    """방금 확정한 검증 판정을 전역 run-index(`~/.okstra`)의 그 run 행에 기록한다.

    검증 완료가 run 이 종결되는 지점이다. 전역 인덱스는 `record_start` 로 시작만
    기록하고 끝은 기록하지 않으므로, 여기서 쓰지 않으면 행이 영영 `running` 으로
    남는다 — `okstra run-audit` 은 그런 행을 "아직 돌지 않은 run" 으로 읽어 리포트
    검사를 통째로 건너뛴다(`run_audit._never_ran`).

    인덱스 갱신 실패는 검증 결과를 무르지 않는다. 판정은 이미 team-state ·
    run-manifest · task-manifest 에 기록됐고 인덱스는 그 사실의 사본이다.

    반환값: 인덱스 행을 찾아 기록했으면 True.
    """
    project_id = str(task_manifest.get("projectId", ""))
    if not project_id:
        print("validate-run: central index update skipped — "
              "task manifest has no projectId", file=sys.stderr)
        return False
    try:
        recorded = settle_run_row(
            okstra_home(), project_id=project_id,
            execution_manifest_rel=project_rel(run_manifest_path, project_root),
            validation_status=validation_status)
    except Exception as exc:  # noqa: BLE001 — 인덱스 갱신은 검증에 비치명적
        print(f"validate-run: central index update failed ({exc})",
              file=sys.stderr)
        return False
    if not recorded:
        # 이 run 을 시작할 때 record_start 가 실패했거나, executionManifestPath
        # 를 싣지 않던 옛 행이다. `run._reconcile_prior_runs` 의 일반 스윕이
        # 다음 run 시작 때 디스크에서 추론한다.
        print("validate-run: no central index row for this run — "
              "leaving it to the next run's reconcile", file=sys.stderr)
    return recorded


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 _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,
    )]
    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"],
        *contract.get("optional_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")

    unexpected_roles = set(by_role) - set(expected_workers)
    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}}",
)


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"
            )


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 = conformance_result_file(qa_dir, key)
        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 _approved_plan_exempted_stages(stages: object) -> list[str]:
    """`conformanceExemption` 을 선언한 stage 번호들(문자열, stageKey 접미 비교용).

    `_approved_plan_stage_entries` 가 이미 형식을 판정한 뒤에만 부른다."""
    return sorted(
        str(stage.get("stage"))
        for stage in (stages if isinstance(stages, list) else [])
        if isinstance(stage, dict)
        and isinstance(stage.get("conformanceExemption"), str)
        and stage["conformanceExemption"].strip()
    )


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)
    if entries is None:
        return None
    return {
        "entries": entries,
        "exemptedStages": _approved_plan_exempted_stages(stages),
    }


def _conformance_script_matches(actual: str, declared: str) -> bool:
    """두 script 표기가 같은 파일을 가리키는지 판정한다.

    1순위는 task-root 상대형끼리의 일치다. task_root 아래로 접히지 않는
    절대경로(예: 워크트리 경로로 적힌 선언)가 남으면, 남은 쪽이 다른 쪽을
    경로 경계에서 후행 일치하는지까지 본다 — `/…/wt/qa/scripts/s.ts` 와
    `qa/scripts/s.ts` 는 같은 파일이다. 후행 일치는 경계(`/`)를 요구하므로
    `renamed-stage-1.ts` 같은 다른 파일은 걸리지 않는다.
    """
    if actual == declared:
        return True
    if not actual or not declared:
        return False
    if actual.startswith("/") != declared.startswith("/"):
        longer, shorter = (
            (actual, declared) if actual.startswith("/") else (declared, actual)
        )
        return longer.endswith("/" + shorter)
    return False


def _declared_conformance_errors(
    declared_manifest: dict,
    actual_manifest: dict,
    stage_name: str | None,
    task_root: Path,
) -> 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]
        # 양쪽을 같은 task-root 상대형으로 접은 뒤 대조한다 — 계획이 절대경로를,
        # 실행자가 상대형을 쓰면 같은 파일이 문자열로는 영영 안 맞는다.
        actual_script = _normalize_conformance_script(
            str(actual_entry.get("script") or ""), task_root
        )
        declared_script = _normalize_conformance_script(
            str(declaration.get("script") or ""), task_root
        )
        if not _conformance_script_matches(actual_script, declared_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")
    # 계획이 면제한 stage 에 구현이 실제 Tier 3 항목을 붙이는 것은 허용한다 —
    # 면제 stage 의 diff 가 db/io/http/external 표면을 건드려 diff-surface 대조에
    # 걸렸을 때, 승인된 계획은 불변이므로 그 항목이 유일한 진행 경로다
    # (2026-09-09 dev-10784 Stage 2). 계획에 없는 stage 의 항목은 여전히 거절한다.
    exempted_stage_numbers = {
        str(value) for value in declared_manifest.get("exemptedStages") or []
    }
    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
            and stage_number not in exempted_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 "
                f"declaration: got {str(stage.get('conformanceTests'))!r}; "
                "expected `<task_root>/qa/scripts/stage-<N>.<ext> "
                "(requires=[db|io|http|external,...])`."
            )
            continue
        script, requires = parsed
        declarations.append(
            {
                "stageKey": f"approved-plan-stage-{stage.get('stage')}",
                "script": script,
                "requires": sorted(requires),
            }
        )
    return declarations


def _project_surface_patterns(project_root: Path) -> object:
    """project.json `qaEnv.surfacePatterns` — 계획·구현 두 게이트가 같은 표를 쓴다."""
    path = project_json_path(project_root)
    if not path.is_file():
        return None
    try:
        return (json.loads(path.read_text()).get("qaEnv") or {}).get("surfacePatterns")
    except (OSError, json.JSONDecodeError):
        return None


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

    스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
    선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
    `conformanceTests` 는 여전히 실패한다.

    면제 stage 의 `plannedPaths` 가 db/io/http/external 표면을 건드리면 여기서
    막는다 — 구현 게이트의 diff-surface 대조(`_validate_conformance_surfaces`)와
    같은 패턴이다. 종전에는 그 대조가 구현이 끝난 뒤에만 돌아, 승인된 계획을
    고칠 수 없는 자리에서 run 전체가 막혔다(2026-09-09 dev-10784 Stage 2).
    """
    data_path = _data_path_for(report_path)
    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)
    if ip.get("planningContract") != "selected-direction":
        from okstra_ctl.implementation_direction import (
            stage_validation_executability_errors,
        )

        failures.extend(stage_validation_executability_errors(ip))
    for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
        failures.append(
            "conformance gate BLOCKING: stage "
            f"{conflict['stage']} declares `Conformance exemption:` but its "
            f"planned paths touch surface(s) {conflict['surfaces']}: "
            f"{', '.join(conflict['paths'])} — an exemption cannot hide a "
            "db/io/http/external change (prompts/profiles/implementation-planning.md "
            "\"Per-stage conformance declaration\"); declare `Conformance tests:` "
            f"with requires={conflict['surfaces']} for that stage, or move those "
            "paths out of it. The implementation run's diff-surface check "
            "blocks the same stage after the work is done, where the approved "
            "plan can no longer be corrected."
        )


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 — also when the approved plan exempted "
            "that stage: write the Tier 3 script under <task_root>/qa/scripts/ "
            "and its conformance-manifest.json entry with requires covering "
            "those surfaces; the approved plan's exemption is not rewritten. "
            "(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,
                task_root,
            ):
                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,
            task_root,
        ):
            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 validate_report(
    report_path: Path,
    failures: list[str],
    *,
    report_data: Mapping[str, Any] | None = None,
    team_state: Mapping[str, Any] | None = None,
) -> None:
    """리포트 레코드 검사. 마크다운 본문은 읽지 않는다.

    schemaVersion 2.0/3.0 은 data.json 이 정본이므로 `_validate_v2_report` 로
    위임한다. 그 아래 남은 것은 파일 부재 한 갈래뿐 — 표시 제목·앵커·산문
    문자열 검사는 ADR-0024("표시 제목은 차단 규칙이 아니다") 이후 정본이 없는
    본문 스캔이었고, 새 run 은 전부 3.0 이라 도달조차 하지 않았다.
    """
    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}")


_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')}."
                )


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 _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."
            )
        # `newTaskId` 의 형식과 `priority` 값은 여기서 보지 않는다. 이 행은
        # `okstra-spawn-followups.py` 의 `NON_SPAWNING_ORIGINS` 에 들어 있어
        # 아무것도 스폰하지 않는 표식이고, 그래서 두 필드에는 소비자가 없다.
        # 게다가 그 스크립트의 priority 기본값은 `P1` 인데 여기서는 `P0` 를
        # 요구해 두 값이 정면으로 어긋났고, 두 규칙 중 어느 쪽도 report-writer
        # 가 읽는 스키마·프로필·템플릿 어디에도 적혀 있지 않았다 — 작성자가
        # 알 수 없는 규칙을 소비자 없는 필드에 걸어 두고 있었다.
        if continuation.get("autoSpawn") != "no":
            failures.append(
                "final-report data.json: phase-continuation autoSpawn must be no."
            )

    # 산문(nextStep / recommendedNextSteps.text / commands)이 라우팅 문자열을
    # 포함하는지 강제하던 블록은 삭제했다. 라우팅 대상은 위의 enum 검사와
    # `leadingCauseId` 정합이 판정하고, 표기 강제는 소비자가 없다.


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:
        # `schema:` 접두는 허용목록(scripts/okstra_ctl/blocking_checks.py)이 스키마
        # 구조 위반을 같은 파일의 문서 기록 항목과 구분하기 위한 것이다.
        failures.append(f"final-report data.json schema: {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)

    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_no_opaque_id_references(data, failures)
    _validate_no_null_literals_in_prose(data, failures)
    for warning in _unbridged_worker_finding_refs(data):
        print(f"validate-run: warning: {warning}", file=sys.stderr)
    # 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_open_approval_blocker_provenance(data, failures)

    task_type = (data.get("header") or {}).get("taskType")
    _validate_verifier_fail_blocks_verdict(data, failures)
    _validate_verifier_discrepancy_names_checklist_phase(
        data, report_path, project_root, failures
    )
    failures.extend(validate_technical_verification_report(data, report_path, project_root or report_path.parent))
    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,
            )
        )
        failures.extend(
            f"implementation-option-selection: {error}"
            for error in validate_blocked_answer_channel(data)
        )
    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, carried=carried, new_plan=selected_direction_contract
        )
        _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


_RATIONALE_FIELDS = ("motivation", "problem", "approach", "justification")


# 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."
            )


# A stringified null is not prose. Lowercase `none` is a token the clarification
# option contract uses on purpose ("reverses nothing"), so only the capitalised
# and language-native spellings count — 2026-09-05 audit: `"None"` in 16
# trade-off cells and 2 option fields across the shipped reports.
_NULL_LITERALS = frozenset({"None", "null", "undefined", "NaN"})
_FINDING_REF_RE = re.compile(r"^F-\d{3,}$")
# Citation lists the human page renders as links or plain text.
_CITATION_LIST_KEYS = ("evidenceRefs", "supportingEvidence", "falsifyingEvidenceChecked")


def _prose_pointers(node: object, key: str = "", pointer: str = "") -> list[tuple[str, str]]:
    """(JSON pointer, value) for every string under a reader-facing prose key."""
    from okstra_ctl.report_translation import PROSE_KEYS

    out: list[tuple[str, str]] = []
    if isinstance(node, dict):
        for child_key, value in node.items():
            out.extend(_prose_pointers(value, str(child_key), f"{pointer}/{child_key}"))
    elif isinstance(node, list):
        for index, value in enumerate(node):
            out.extend(_prose_pointers(value, key, f"{pointer}/{index}"))
    elif isinstance(node, str) and key in PROSE_KEYS:
        out.append((pointer, node))
    return out


def _validate_no_null_literals_in_prose(data: dict, failures: list[str]) -> None:
    """A prose cell holding `None`/`null`/`undefined` is a serialisation
    artefact the page prints verbatim — nlpvibe planning-009…014 showed
    `None` under Test cost and Rollout cost. An empty value is spelled by
    omitting the field or leaving it empty, never by naming the language's
    null."""
    offenders = [
        f"{pointer} = {value.strip()!r}"
        for pointer, value in _prose_pointers(data)
        if value.strip() in _NULL_LITERALS
    ]
    if offenders:
        failures.append(
            "final-report data.json: prose field(s) hold a null literal — "
            + ", ".join(offenders[:8])
            + (f" (+{len(offenders) - 8} more)" if len(offenders) > 8 else "")
            + ". Leave the field empty or omit it; the page prints the literal as text."
        )


def _unbridged_worker_finding_refs(data: dict) -> list[str]:
    """Advisory: citation lists naming a worker's own finding number (`F-NNN`)
    that no `evidence.primary[].sourceItems` entry carries.

    The page links a bare `F-NNN` only when exactly one promoted evidence row
    cites it as `<worker>:F-NNN` (`report_html/common.py`
    `worker_finding_links`); every other one is dead text. The 2026-09-05
    audit found 22 to 156 such citations per shipped report, so this stays a
    warning until the writer contracts have caught up — cite the promoted
    `E-` row, or add the finding to that row's `sourceItems`.
    """
    bridged: set[str] = set()
    for row in ((data.get("evidence") or {}).get("primary") or []):
        if not isinstance(row, dict):
            continue
        for item in row.get("sourceItems") or []:
            for token in re.findall(r"\b(F-\d{3,})\b", str(item)):
                bridged.add(token)
    unbridged: dict[str, list[str]] = {}

    def walk(node: object, key: str = "", pointer: str = "") -> None:
        if isinstance(node, dict):
            for child_key, value in node.items():
                walk(value, str(child_key), f"{pointer}/{child_key}")
        elif isinstance(node, list):
            if key in _CITATION_LIST_KEYS:
                for value in node:
                    if isinstance(value, str) and _FINDING_REF_RE.match(value.strip()) and value.strip() not in bridged:
                        unbridged.setdefault(value.strip(), []).append(pointer)
            else:
                for index, value in enumerate(node):
                    walk(value, key, f"{pointer}/{index}")

    walk(data)
    if not unbridged:
        return []
    listed = ", ".join(f"{ref} ({len(where)}×)" for ref, where in sorted(unbridged.items())[:10])
    return [
        f"citation lists name worker finding number(s) no evidence.primary "
        f"sourceItems entry carries — {listed}"
        + (f" (+{len(unbridged) - 10} more)" if len(unbridged) > 10 else "")
        + "; cite the promoted E- row or add `<worker>:F-NNN` to its sourceItems"
    ]


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.
_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]:
    """현재 기록된 비판 검토자의 최신 유효 판정."""
    rows = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
    critic = [row for row in rows
              if is_critic_worker(row.get("worker", ""))
              and str(row.get("verdict", "")).upper() in {"AGREE", "SUPPLEMENT", "DISAGREE"}]
    latest = max((row.get("round", 1) for row in critic), default=0)
    return [row for row in critic if row.get("round", 1) == latest]


def _critic_gate_class(item: dict) -> str | None:
    """비판 검토자의 교정 권한은 분석자의 표수나 동수 여부에 의존하지 않는다."""
    critic = _critic_non_error_verdicts(item)
    if not critic:
        return None
    critic_dissent = [row for row in critic if str(row.get("verdict", "")).upper() == "DISAGREE"]
    if critic_dissent:
        if str(item.get("id", "")).upper().startswith("P-RB"):
            return "has-dissent"
        return "majority-disagree" if any(
            str(row.get("breakageKind", "")).lower() not in _ADVISORY_ONLY_KINDS
            for row in critic_dissent
        ) else "has-dissent"
    dissent = any(str(row.get("verdict", "")).upper() == "DISAGREE" for row in item.get("verdicts", []))
    return "has-dissent" if dissent else "full-consensus"


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.
    """
    corrected = _critic_gate_class(item)
    if corrected is not None:
        return corrected
    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"
    if len(blocking_disagree) == len(agree) and len(non_error) >= 2:
        return "needs-reverify"
    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 _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_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 _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
    """자동 수정 이후의 설계 판단만 리드가 결정하며 사실·사용자 권한은 남긴다."""
    classification = _classify_plan_item_gate(item)
    votes = [row for row in item.get("verdicts", []) if isinstance(row, dict)]
    non_result = any(row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes)
    if classification not in {"majority-disagree", "needs-reverify", "all-non-result"} and not non_result:
        return None
    if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
        return None
    disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
    verified = item.get("contentHash")
    if (
        not self_fix_rounds(pbv) or pbv.get("gating") is False
        or not verified or item.get("verifiedContentHash") != verified
        or _is_correctness_critical(item) or not disagrees
        or len(voting_analyser_keys([item])) < 2
        or non_result
        or any(row.get("claimKind") not in {None, "judgement"}
               or row.get("fixability") != "planner-fixable"
               or row.get("breakageKind") not in {"b", "c", "e"} for row in disagrees)
    ):
        return "user"
    return "lead"


def _lead_decision_applies(item: dict, pbv: dict) -> bool:
    decision = item.get("leadDecision")
    return (
        isinstance(decision, dict)
        and bool(str(decision.get("decision") or "").strip())
        and decision.get("basisHash") == lead_decision_basis(item)
        and _plan_item_decision_authority(item, pbv) == "lead"
    )


def _is_dissent_downgraded(
    item: dict,
    pbv: dict,
    accepted_item_ids: set[str],
) -> bool:
    """유효한 리드 결정 또는 사용자 진행 처분은 반대 표를 보존하며 차단을 해소한다."""
    return _lead_decision_applies(item, pbv) or (
        _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 and not requires_plan_repair(pbv):
        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_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 and not requires_plan_repair(pbv):
        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


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


_APPROVAL_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")


def _validate_approval_context(
    data: dict,
    run_manifest: dict,
    failures: list[str],
) -> None:
    """활동 계약 v1 계획 run 의 승인 검사를 v3 경로로 넘긴다.

    v2 전용 본문은 삭제했다. 같은 요구를 조립 시점의 `report_assembly.py` /
    `approval_decisions.py` 가 이미 거부하고, 새 run 은 전부 schemaVersion 3.0
    이라 v2 갈래에 도달하는 값이 없었다.
    """
    if not _is_activity_contract_v1_planning(run_manifest):
        return
    if data.get("schemaVersion") == "3.0":
        _validate_v3_approval_context(data, failures)


def _validate_v3_approval_context(data: dict, failures: list[str]) -> None:
    """승인 플래그가 아직 진행을 막는 행과 공존하지 못하게 한다.

    backlinks / dispositions / resolution-link 대조 세 갈래는 뺐다. 셋 다
    조립(`report_assembly.py`, `approval_decisions.py`)이 같은 입력으로 만든
    값을 같은 식으로 되계산하는 항등식이라 조립을 우회하지 않는 한 걸릴 값이
    없다.
    """
    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
        # `approvalContext` 없는 행을 건너뛰는 것은 이전과 같은 범위다. 여기서
        # 범위를 넓히면 삭제한 세 갈래가 막던 행이 새 차단으로 되살아난다.
        if not isinstance(row.get("approvalContext"), dict):
            continue
        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')}`."
            )


_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_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.

    재계산은 `okstra_ctl.plan_items.voting_analyser_keys` 하나뿐이고, 기록하는
    쪽(`okstra plan-items complete-round`)도 같은 함수를 부른다. 두 곳이 각자
    세던 동안 생산자는 이번 라운드 큐만, 이쪽은 전 항목·전 라운드를 세서
    critic 이 동수만 가른 라운드에서 값이 갈렸다.
    """
    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 = voting_analyser_keys(pbv.get("planItems") or [])
    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:
    """선언된 `gateResult` 가 실제로 남아 있는 차단 원인과 맞는지 본다.

    승인을 막는 입력은 둘이다 — `majority-disagree` 플랜 항목, 그리고
    Requirement Coverage 의 `gap` / `blocked C-NNN` 행. 두 갈래를 남긴다:
    (a) 막는 원인이 있는데 `passed` 계열을 선언한 경우, (b) 막는 원인이 하나도
    없는데 차단 값을 그대로 둔 경우. 둘 다 실측 사고에서 나왔다 — (b) 는 gate
    토큰이 1라운드 값에 멈춰 프로젝트 전체에서 run 을 못 열게 만들었다.
    """
    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()
    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).'
        )

    # 선언 `gateBlockedBy` 집합과 재계산 집합을 대조하던 갈래는 삭제했다.
    # 생산자(`okstra plan-items complete-round`)가 이 검증기의 계산 함수를
    # 그대로 import 해 필드를 쓰므로 값이 갈릴 자리가 없고, 그 필드를 읽어
    # 실행을 구동하는 소비자도 없다.


_CLARIFICATION_OPTION_SCHEMA_VERSIONS = frozenset({"2.0", "3.0"})
# The four profiles that read `_clarification-recommendation.md`. This gate 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):")


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.

    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
        if str(row.get("status") or "") in {"answered", "resolved"}:
            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 len(set(rounds)) > 1:
        failures.append(
            "final-report data.json: automatic self-fix is limited to one rewrite; "
            "resolve remaining items through lead decisions or user confirmation."
        )
    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],
    *,
    carried: dict | None = None,
    new_plan: bool = False,
) -> None:
    """Incorporating an answer means retiring what it invalidates, not only
    adding what it decides.

    `new_plan` marks a plan built from a selected direction. Prepare seeds
    that run's ledger with every answer the option-selection record carried
    (2026-09-05), and a first plan has no earlier statement those answers
    could retire — an entry per carried row would be `no-dependent-statement`
    by construction. Those ids are exempt; answers the plan itself raised and
    settled still need their entry.

    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 = set(_answered_clarification_ids(data))
    if new_plan:
        answered.difference_update((carried or {}).keys())
    else:
        answered.update((carried or {}).keys())
    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 = covered - answered
    carry_in = data.get("clarificationCarryIn")
    # 이월 원장은 이전 런에서 받은 답을 이번 런이 반영한 기록이다.
    # 이번 런 clarificationItems 에 userInput 이 없다고 stale 로 보면
    # C-024 같은 이월 행이 "this run did not answer" 가 된다.
    if stale and isinstance(carry_in, dict) and str(carry_in.get("sourceFile") or "").strip():
        stale = set()
    if stale:
        failures.append(
            "final-report data.json: implementationPlanning.supersessionLedger "
            f"cites {sorted(stale)}, which this run did not answer. A ledger "
            "entry must correspond 1:1 to a clarification whose answer this "
            "run incorporated."
        )


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 ""


_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


# 이 스냅샷의 다이제스트 규칙과 파싱은 `okstra_ctl.verification_target` 하나가
# 쥔다. 조립도 같은 파일을 읽어 `verificationScope` 를 기록하므로, 사본을 두면
# 규칙이 갈리는 순간 한쪽이 정상 target 을 변조로 판정한다.
from okstra_ctl.verification_target import (  # noqa: E402
    TARGET_FIELD_RES as _TARGET_FIELD_RES,
    read_verification_target as _read_verification_target_impl,
)


def _read_verification_target(project_root: Path, relative: str) -> dict | None:
    return _read_verification_target_impl(project_root, relative)


_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."
        )


def _warn_out_of_plan_edits_not_in_diff(data: dict, warnings: list[str]) -> None:
    """소스 차이 목록은 별도 QA 산출물의 변경 여부를 증명하지 못한다."""
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    rows = implementation.get("outOfPlanEdits")
    if not isinstance(rows, list) or not rows:
        return
    changed = set(_diff_summary_files(data))
    if not changed:
        return
    for row in rows:
        if not isinstance(row, dict):
            continue
        target = row.get("file")
        if isinstance(target, str) and target and target not in changed:
            warnings.append(
                f"out-of-plan-edit: {row.get('id') or 'OOP-???'} 가 `{target}` 을 "
                "계획 밖 편집으로 신고했지만 diffSummary 에 그 파일이 없다"
            )


def _validate_verifier_reran_independently(data: dict, failures: list[str]) -> None:
    """`independentValidationRerun` 칸이 비어 있지 않아야 한다.

    스키마는 필드 존재만 강제하고 값은 보지 않는다. 빈 칸은 재현 없이
    통과시킨 것과 구별되지 않는다.

    executor 인용 표현을 잡던 정규식 갈래는 삭제했다. 표현을 세는 검사라
    같은 재현을 어떻게 서술했느냐로 통과가 갈렸다.
    """
    for who, row in _verifier_rows(data):
        rerun = row.get("independentValidationRerun")
        if not isinstance(rerun, str) or not rerun.strip():
            failures.append(
                f"verifier-rerun: {who} 가 independentValidationRerun 을 비워 뒀다 — "
                "재현 없이 통과시킨 것과 구별되지 않는다"
            )


def _validate_verifier_discrepancy_is_not_passed(
    data: dict, failures: list[str]
) -> None:
    """재현 결과가 executor 보고와 갈렸는데 PASS 로 넘기지 못하게 한다.

    규칙(§"Discrepancy rule")은 Tier 1/2 와 blocking io-only Tier 3 의 divergence 에
    `FAIL` 을 요구하고, Tier 3 외부 자문 divergence 만 제외한다. 리포트 구조에는
    tier 필드가 없어 그 둘을 여기서 가릴 수 없다. 그래서 `PASS` 만 막는다 —
    자문 divergence 는 `CONCERNS` 로 기록할 자리가 이미 있고, `PASS` 는 "갈렸는데
    아무 일도 없었다" 는 뜻이라 어느 tier 로도 정당화되지 않는다.
    """
    for who, row in _verifier_rows(data):
        discrepancy = row.get("discrepancy")
        if not isinstance(discrepancy, str) or not discrepancy.strip():
            continue
        if row.get("verdict") == "PASS":
            failures.append(
                f"verifier-discrepancy: {who} 가 divergence 를 기록하고도 PASS 를 냈다 "
                f"— FAIL(또는 자문 divergence 면 CONCERNS)이어야 한다: "
                f"{discrepancy.strip()[:120]}"
            )


_CHECKLIST_ID_RE = re.compile(r"\bVC-\d{3,}\b")
_CHECKLIST_PHASE_RE = re.compile(r"\bphase\W{0,3}(pre|mid|post)\b", re.IGNORECASE)


def _approved_plan_record(
    data: dict, report_path: Path, project_root: Path | None
) -> dict | None:
    """이 구현 리포트가 가리키는 승인 계획 레코드(data.json). 못 찾으면 None.

    `approvedPlanReference.planFile` 은 실물에서 프로젝트 상대(`.okstra/tasks/...`)로,
    fixture 에서 태스크 상대(`runs/implementation-planning/...`)로 나오고 확장자는
    `.md` 와 `.data.json` 둘 다 쓰인다. 어느 형태든 레코드로 되짚고, 없으면 None 을
    돌려 호출자가 판정을 건너뛰게 한다 — 계획 부재는 다른 검사의 몫이고, 없는
    파일을 여기서 위반으로 세지 않는다.
    """
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return None
    reference = implementation.get("approvedPlanReference")
    plan_file = reference.get("planFile") if isinstance(reference, dict) else None
    if not isinstance(plan_file, str) or not plan_file.strip():
        return None
    from okstra_ctl.final_report_paths import final_report_data_path

    candidate = Path(plan_file.strip())
    if candidate.name.endswith(".md"):
        candidate = final_report_data_path(candidate)
    task_root = next(
        (parent.parent for parent in report_path.parents if parent.name == "runs"),
        None,
    )
    roots: list[Path | None] = (
        [None] if candidate.is_absolute()
        else [root for root in (project_root, task_root) if root is not None]
    )
    for root in roots:
        path = candidate if root is None else root / candidate
        if not path.is_file():
            continue
        try:
            loaded = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return None
        return loaded if isinstance(loaded, dict) else None
    return None


def _validate_verifier_discrepancy_names_checklist_phase(
    data: dict,
    report_path: Path,
    project_root: Path | None,
    failures: list[str],
) -> None:
    """계획 `validationChecklist` 행을 근거로 적은 divergence 는 그 행의 `phase` 를 인용한다.

    2026-09-05 실측(fontsninja-v3-site dev-10626 stage-1): codex 검증자가 `VC-003` 의
    `git diff --name-only` 가 커밋 뒤 빈 출력이라며 FAIL 을 냈다. 그 행은 계획 레코드에
    `phase: mid` — 편집과 커밋 사이의 체크포인트 — 로 선언돼 있어, 커밋 뒤의 빈 출력은
    계획의 단계 순서 그 자체였다. 수렴에서 제기자 본인이 반대 읽기에 AGREE 했지만 FAIL
    행은 남아 stage 가 `failed` 로 갔고, 리드도 그 주장을 열어 보지 않고 라우팅에 옮겼다.
    `pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로, 행을 인용하는 문장이 그 값을
    함께 적어야 한다 — 읽지 않은 행을 근거로 쓰는 문장은 그러면 쓸 수 없다.

    행에 `phase` 가 없거나 계획 레코드를 못 찾으면 판정하지 않는다.
    """
    plan = _approved_plan_record(data, report_path, project_root)
    if plan is None:
        return
    planning = plan.get("implementationPlanning")
    rows = planning.get("validationChecklist") if isinstance(planning, dict) else None
    phases = {
        str(row["id"]): str(row["phase"]).strip().lower()
        for row in (rows if isinstance(rows, list) else [])
        if isinstance(row, dict)
        and isinstance(row.get("id"), str)
        and isinstance(row.get("phase"), str)
    }
    if not phases:
        return
    for who, row in _verifier_rows(data):
        discrepancy = row.get("discrepancy")
        if not isinstance(discrepancy, str) or not discrepancy.strip():
            continue
        cited = sorted(set(_CHECKLIST_ID_RE.findall(discrepancy)) & set(phases))
        if not cited:
            continue
        named = {
            match.group(1).lower()
            for match in _CHECKLIST_PHASE_RE.finditer(discrepancy)
        }
        missing = [
            f"{row_id} (phase: {phases[row_id]})"
            for row_id in cited
            if phases[row_id] not in named
        ]
        if missing:
            failures.append(
                f"verifier-discrepancy: {who} 가 계획 체크리스트 행을 근거로 divergence 를 "
                f"적었지만 그 행의 phase 를 인용하지 않았다 — {', '.join(missing)}. "
                "`pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로 인용 문장에 "
                "`VC-NNN (phase: <값>)` 으로 적는다 (`_implementation-verifier.md` § Tier 1)."
            )


def _verifier_rows(data: dict):
    """(표시 이름, verifierResults 행) 쌍."""
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    for row in implementation.get("verifierResults") or []:
        if not isinstance(row, dict):
            continue
        yield str(row.get("verifier") or row.get("role") or "verifier"), row


def _validate_verifier_command_log_is_read_only(
    data: dict,
    failures: list[str],
) -> None:
    """검증자의 Read-only command log 에 변조 모드가 없어야 한다.

    규칙은 prompts/profiles/_implementation-verifier.md 가 "런타임 AND 검증자가
    거부해야 한다"고 BLOCKING 으로 선언해 왔지만 런타임 검사는 없었다. 스키마는
    로그의 **존재**만 강제하고 내용은 아무도 읽지 않았다 — 검증자가
    `eslint --fix` 로 자기가 검증할 소스를 고쳐 놓아도 통과한다.

    로그는 리포트에 그대로 복사되므로 여기서 읽는 것이 정본이다.
    """
    implementation = data.get("implementation")
    if not isinstance(implementation, dict):
        return
    rows = [r for r in (implementation.get("verifierResults") or []) if isinstance(r, dict)]
    if not rows:
        return
    _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 verifier_mutation_hits  # noqa: E402
    except ImportError as exc:
        failures.append(
            f"verifier-command-log: verifier_mutation_hits import failed — {exc}"
        )
        return
    for row in rows:
        log = row.get("readOnlyCommandLog")
        if not isinstance(log, str) or not log.strip():
            continue
        who = str(row.get("workerId") or row.get("role") or "verifier")
        for label, line in verifier_mutation_hits(log):
            failures.append(
                f"verifier-command-log: {who} 의 read-only 로그에 변조 모드 "
                f"`{label}` 이 있다: {line[:120]}"
            )



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.
    """
    # 최상위 `verificationTargetPath` 가 run 매니페스트의 실물 키다(render.py).
    # `instructionSet` 블록은 active-run-context 의 것이라 여기서 읽으면 검사가
    # 통째로 건너뛰어졌다(실측 2026-09-06, dev-10626 final-verification 001).
    relative = str(run_manifest.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
    head = ((data.get("finalVerification") or {}).get("sourceImplementationReport") or {}).get("capturedHeadSha")
    verified = {
        row.get("stage")
        for row in rows
        if isinstance(row, dict) and row.get("status") == "verified"
        and head and row.get("head_commit") == head
        and row.get("final_verdict") == data.get("finalVerdict")
        and _data_path_for(Path(str(row.get("report_path") or ""))).resolve() == _data_path_for(report_path).resolve()
    }
    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 matching this report and captured commit. 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`. Carry the FAIL into a blocking verdict. There "
            "is no synthesis-time override: a verifier that produced no "
            "verdict records `not-run` and a Tier 3 advisory divergence "
            "records `CONCERNS`, and both are the verifier's to write, not "
            "the lead's to substitute "
            '(`_implementation-verifier.md` "All-verifier-failure policy").'
        )



_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 _plan_items_routed_to_a_user_decision(data: dict) -> set[str]:
    """`blocks: approval` C 행과 이어진 계획 항목 id.

    처분 여부는 보지 않는다. 행이 존재한다는 것 자체가 그 항목이 사용자 결정
    채널로 나갔다는 뜻이고, 아직 답이 없는 행은 `row_blocks_progress` 가
    승인을 막는다 — `_validate_v3_approval_context` 가 그 상태의 `approved:
    true` 를 거부한다. 링크는 양방향으로 읽는다: 항목 쪽 `clarificationRefs`
    와, 행 → 활동 원장 역추적(`_plan_item_ids_for_clarification`). 계약 3.0
    리포트는 후자로만 이어지는 경우가 있다.
    """
    rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
    approval_rows = [
        row for row in rows if row.get("blocks") == "approval" and row.get("id")
    ]
    approval_ids = {str(row["id"]) for row in approval_rows}
    linked: set[str] = set()
    items = (
        ((data.get("implementationPlanning") or {}).get("planBodyVerification") or {})
        .get("planItems") or []
    )
    for item in items:
        if isinstance(item, dict) and _plan_item_clarification_ids(item) & approval_ids:
            linked.add(str(item.get("id") or "").strip())
    for row in approval_rows:
        context = row.get("approvalContext")
        linked.update(
            str(item_id).strip()
            for item_id in _plan_item_ids_for_clarification(
                row, context if isinstance(context, dict) else {}, data
            )
        )
    linked.discard("")
    return linked


def _validate_unresolved_tie_was_reverified(
    data: dict,
    failures: list[str],
) -> None:
    """A split panel is settled by critic-worker or by the user, not by silence.

    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`, which `_recompute_plan_body_gate` folds into
    `passed-with-dissent` — so without a settlement the split passes with nobody
    deciding it.

    두 가지 해소가 있고 로스터가 어느 쪽인지 정한다. critic 이 배정된 run 은
    `critic-worker` 표가 가른다. critic 이 없는 로스터(`invocationAssignments`
    에 `critic/*` 없음, `okstra_ctl.plan_items.critic_is_rostered`)는 라운드
    안에 가를 표가 아예 없으므로 `next_dispatch` 가 `user-decision` 을 내고
    리드가 항목마다 승인 결정을 연다. 그 결정 행이 이 항목의 해소다. 검증기는
    로스터를 볼 수 없으므로(이 검사의 입력은 리포트 정본뿐) 둘 중 하나가
    기록되어 있으면 해소로 읽고, 둘 다 없을 때만 발화한다.

    같은 조건을 다른 술어로 한 번 더 세던 두 번째 동수 검사를 여기로 합쳤다.
    그쪽 술어는 판정 행의 `round` 를 1 로 눌러 놓고 `_is_unsettled_tie` 를
    불렀는데, `_is_even_analyser_split` 는 `round` 를 보지 않으므로 두 술어의
    값이 언제나 같았다 — 같은 항목이 두 번 실패로 올라왔다. 여기 남은 조건이
    두 집합의 합집합이다.
    """
    ip = data.get("implementationPlanning")
    if not isinstance(ip, dict):
        return
    pbv = ip.get("planBodyVerification")
    if not isinstance(pbv, dict):
        return
    # 사용자 결정 채널로 나간 항목은 제외한다. 진행 처분이 이미 붙은 항목
    # (accept-risk 등)도 여기 포함된다 — 리드 계약이 accept-risk 를 "게이트를
    # 끝내고 재검증 AGREE 를 요구하지 않는" 처분으로 정의하므로, 계속 실패를
    # 올리면 승인 처분으로 빠져나갈 수 없는 규칙이 된다.
    decided = _plan_items_routed_to_a_user_decision(data)
    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 str(item.get("id") or "").strip() not in decided
        and not _lead_decision_applies(item, pbv)
        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 and no `blocks: approval` "
        "clarification row. A tie is not consensus. With a critic on the "
        f"roster, dispatch `{CRITIC_WORKER_ID}` on those items only (`okstra "
        "plan-items prepare --tie-vote`), read its answer with `okstra "
        "plan-items collect-verdicts --items <the --tie-vote plan-items "
        f"artifact> --result {CRITIC_WORKER_ID}=<path> --output <envelope>`, "
        "then record it with `okstra plan-items apply-verdicts --append "
        "--round 2`. Skipping collect-verdicts and pointing apply-verdicts at "
        "the raw result is refused: this round's queue is the tie items, not "
        "the round's full dispatch queue. Critic AGREE settles the split; "
        "critic DISAGREE blocks. With no critic on the roster `okstra "
        "plan-items next-dispatch` answers `user-decision` instead: open one "
        "`okstra approval-decision open` per item (classification "
        "`noncritical-dissent`) and write the matching `## 1. Clarification "
        "Items` row, and that row settles the tie here while it gates approval."
    )


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 requires_plan_repair(pbv):
        failures.append("plan-body-verification: objective verification defects require gating=true; repair the affected items")
    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.
    seqs = _plan_verify_seq_aliases(report_path)
    workers = set()
    for seq in seqs or {_report_run_seq(report_path) or "*"}:
        for path in worker_results_dir.glob(f"*-plan-verify-r*-{task_type}-{seq}.md"):
            role = path.name.split("-plan-verify-r", 1)[0]
            if role:
                workers.add(role)
    return workers


def _manifest_seq_for_report(report_path: Path, category: str) -> set[str]:
    """이 리포트 seq 를 낸 매니페스트가 그 카테고리에 기록한 seq 들.

    `paths.compute_run_paths` 는 7개 카테고리 seq 를 디렉터리별로 따로 스캔해
    배정한다(`paths.next_run_seq`). 같은 run dir 로 재실행하면 카테고리마다
    다른 속도로 올라가 reports 017 / state 025 같은 분기가 실제로 생긴다.
    매니페스트의 `runSequencesByCategory` 만이 그 분기를 한 런으로 묶는 기록이다.
    """
    seq = _report_run_seq(report_path)
    manifests_dir = report_path.parent.parent / "manifests"
    if not seq or not manifests_dir.is_dir():
        return set()
    found: set[str] = set()
    for path in manifests_dir.glob("run-manifest-*.json"):
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError, UnicodeError):
            continue
        if not isinstance(payload, dict):
            continue
        categories = payload.get("runSequencesByCategory")
        if not isinstance(categories, dict):
            continue
        if str(categories.get("reports") or "") != seq:
            continue
        value = str(categories.get(category) or "").strip()
        if value:
            found.add(value)
    return found


def _plan_verify_seq_aliases(report_path: Path) -> set[str]:
    """이 리포트 seq 와, 같은 리포트를 가리키는 런의 workerResults seq.

    reports 와 workerResults 가 갈라지면 워커는 018 로 쓰고 검사는 014 만
    본다. 같은 리포트를 연 매니페스트의 두 seq 를 모두 인정한다.
    """
    seq = _report_run_seq(report_path)
    aliases: set[str] = {seq} if seq else set()
    return aliases | _manifest_seq_for_report(report_path, "workerResults")


def _plan_verify_dispatched_results(
    report_path: Path, task_type: str
) -> dict[str, set[str]] | None:
    """이 런이 실제로 디스패치한 plan-body 재검증 결과 파일명(역할별).

    파일명을 seq 로 되짚는 대신 **기록된 디스패치**에서 읽는다. `okstra team`
    은 워커를 띄울 때마다 team-state 에 `workerDispatches[]` 행을 남기고
    (`dispatch_core._dispatch_record` — `kind`, `workerResultPath` 포함), 그
    `workerResultPath` 는 리드가 디스패치 요청에 실어 보낸 경로 그대로다
    (`dispatch_state.py` 의 `require_string(item, "workerResultPath")`). 결과
    파일이 어느 seq 로 쓰였든 그 기록이 정답을 들고 있다.

    plan-body 상태 파일(`plan-body-verification-<task-type>-<seq>.json`)의
    라운드/판정 행에는 파일명이 없어서 이 용도로 못 쓴다.

    team-state 를 못 찾으면 ``None`` — 호출자가 seq 글롭으로 내려간다.
    빈 dict 은 "기록은 있는데 재검증 디스패치가 한 건도 없다" 로, 라운드가
    아예 안 돈 경우다.
    """
    state_dir = report_path.parent.parent / "state"
    if not state_dir.is_dir():
        return None
    seqs = {_report_run_seq(report_path) or ""} | _manifest_seq_for_report(
        report_path, "state"
    )
    dispatched: dict[str, set[str]] = {}
    seen_state = False
    for seq in sorted(s for s in seqs if s):
        path = state_dir / f"team-state-{task_type}-{seq}.json"
        if not path.is_file():
            continue
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError, UnicodeError):
            continue
        if not isinstance(payload, dict):
            continue
        seen_state = True
        for row in payload.get("workerDispatches") or []:
            if not isinstance(row, dict):
                continue
            # critic 동수 라운드는 `kind: "critic"` 으로 나간다 — 결과 파일명은
            # 같은 `-plan-verify-r<N>-` 꼴이다. reverify 계열만 세면 동수가 있던
            # run 마다 critic 표가 "디스패치 기록 없음" 으로 오탐된다(2026-09-09,
            # fontsninja-v3-site dev-10627 planning 002). 계획 본문 라운드 자신의
            # kind 인 `plan-verify-r<N>` 도 같은 이유로 센다.
            kind = str(row.get("kind") or "")
            if not (
                kind.startswith("reverify-r")
                or kind.startswith("plan-verify-r")
                or kind == "critic"
            ):
                continue
            name = Path(str(row.get("workerResultPath") or "")).name
            if "-plan-verify-r" not in name:
                continue
            role = name.split("-plan-verify-r", 1)[0]
            if role:
                dispatched.setdefault(role, set()).add(name)
    return dispatched if seen_state else None


def _plan_verify_seq_near_misses(report_path: Path, task_type: str) -> list[str]:
    """이 런의 것으로 인정되지 않은, 같은 디렉터리의 plan-verify 결과 파일.

    "파일이 없다" 와 "파일은 있는데 이 런의 seq 가 아니다" 는 해소책이 다르다.
    앞의 것은 디스패치를 다시 돌려야 하고, 뒤의 것은 이미 나온 결과로 게이트를
    다시 계산해야 한다. 다만 재실행이 누적된 디렉터리에서는 이 목록이 수백 건이
    되므로, 호출부가 표본만 싣는다(`_unbacked_remedy_clause`).
    """
    seq = _report_run_seq(report_path)
    if not seq:
        return []
    directory = report_path.parent.parent / "worker-results"
    accepted: set[Path] = set()
    for alias in _plan_verify_seq_aliases(report_path):
        accepted.update(directory.glob(f"*-plan-verify-r*-{task_type}-{alias}.md"))
    for names in (_plan_verify_dispatched_results(report_path, task_type) or {}).values():
        accepted.update(directory / name for name in names)
    return sorted(
        path.name
        for path in directory.glob(f"*-plan-verify-r*-{task_type}-*.md")
        if path not in accepted
    )


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
    results_dir = report_path.parent.parent / "worker-results"
    recorded = _plan_verify_dispatched_results(report_path, task_type)
    if recorded is None:
        # 기록된 디스패치가 없다 — seq 글롭으로 내려간다.
        dispatched = _plan_verify_result_workers(report_path, task_type)
        if dispatched is None:
            return
        source = (
            f"no team-state for this run was readable under `runs/{task_type}/"
            f"state/`, so this fell back to globbing `*-plan-verify-r*-"
            f"{task_type}-<seq>.md` under `runs/{task_type}/worker-results/` "
            f"for seq(s) {sorted(_plan_verify_seq_aliases(report_path))}"
        )
        returned = {_analyser_key(name) for name in dispatched}
        never_dispatched: set[str] = set()
    else:
        # 기록된 디스패치가 정답이다. 투표가 뒷받침되려면 (1) 그 역할로 나간
        # 재검증 디스패치 기록이 있고 (2) 그 기록이 적어 둔 결과 파일이 디스크에
        # 실제로 있어야 한다. seq 는 어디에도 안 쓴다 — 갈라진 seq 로 나간
        # 디스패치도 기록에는 자기 파일명 그대로 남아 있다.
        source = (
            f"resolved from the `workerDispatches[]` rows this run's team-state "
            f"recorded under `runs/{task_type}/state/` (each row's own "
            f"`workerResultPath`, not a seq glob)"
        )
        recorded_keys = {_analyser_key(role) for role in recorded}
        returned = {
            _analyser_key(role)
            for role, names in recorded.items()
            if any((results_dir / name).is_file() for name in names)
        }
        never_dispatched = {
            _analyser_key(voter)
            for voter in voters
            if _analyser_key(voter) not in recorded_keys
        }
    # 파일명 슬러그와 투표 키를 같은 축으로 놓는다. 결과 파일명은 cmux 어댑터가
    # `-worker-` 토큰을 요구하는데(`workerResultPath`) 투표 키는 워커 id 그대로다.
    # 워커 id 가 `-worker` 로 끝나던 기본 로스터에서는 두 규칙이 우연히 같은
    # 이름을 냈지만, `grok-planner` 처럼 역할 접미사가 붙은 id 에서는 두 규칙을
    # 동시에 만족하는 이름이 존재하지 않는다.
    unbacked = sorted(
        voter for voter in voters if _analyser_key(voter) not in returned
    )
    if unbacked:
        no_dispatch = sorted(
            voter for voter in unbacked
            if _analyser_key(voter) in never_dispatched
        )
        failures.append(
            "final-report data.json: planBodyVerification records verdicts from "
            f"{unbacked} but no plan-body reverify result file backs them — "
            f"{source}."
            + _unbacked_remedy_clause(report_path, task_type, no_dispatch)
            + " 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).'
        )


_NEAR_MISS_SAMPLE = 6


def _unbacked_remedy_clause(
    report_path: Path, task_type: str, no_dispatch: list[str]
) -> str:
    """뒷받침 없는 투표에 남길 실제 갈래와 정당한 해소책.

    파일 이름을 바꾸라는 안내를 여기서 걷어냈다. 그 안내는 실행됐고(디렉터리에
    개명 사본이 남았다), 개명은 어느 디스패치가 그 결과를 냈는지를 지워 검사가
    막으려던 바로 그 상태 — 대조할 기록이 없는 투표 — 를 만든다.
    """
    parts: list[str] = []
    if no_dispatch:
        parts.append(
            f" No reverify dispatch was recorded at all for {no_dispatch}, so "
            "those verdicts are unbacked: either the round genuinely never ran "
            "(re-dispatch it, or record `verification-error` for the workers "
            "that produced no result) or it ran outside `okstra team dispatch` "
            "and left no `workerDispatches[]` row, which is itself the "
            "violation."
        )
    near = _plan_verify_seq_near_misses(report_path, task_type)
    if near:
        sample = near[:_NEAR_MISS_SAMPLE]
        more = (
            f" (+{len(near) - len(sample)} more; the directory accumulates "
            "every rerun of this task-type)"
            if len(near) > len(sample) else ""
        )
        parts.append(
            f" The directory does hold plan-verify results under other "
            f"sequences — {sample}{more}. If one of those is this round's "
            "output, the round was dispatched under a sequence this report does "
            "not carry: recompute the gate from the files that exist (re-run "
            "`okstra plan-items apply-verdicts --result <worker>=<file>` "
            "against them and re-record the round) so the verdicts and their "
            "evidence agree. Do NOT rename a result file to this report's seq — "
            "renaming destroys the link between a vote and the dispatch that "
            "produced it, which is exactly what this check reads."
        )
    return "".join(parts)


_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


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 _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
        # 동일성 판정은 조립(`design_prep._request_conflicts`)과 같은 신원을 쓴다.
        # 두 곳이 다른 기준을 걸면 조립이 통과시킨 파일을 검증이 stale 로 떨어뜨려,
        # run 이 고칠 수 없는 실패에 갇힌다 — 이월된 요청은 발행 리포트 줄만
        # 다르고, 그 줄을 현행화하려면 이전 run 의 기록을 덮어써야 한다.
        if _design_prep_request_identity(actual) != _design_prep_request_identity(
            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:
        # 탐지기 재실행 대조와 prep 항목 ↔ coverage 양방향 참조 대조는
        # 삭제했다. 둘 다 `design_snapshot.build` 가 한 번에 만든 값을 같은
        # 입력으로 되계산해 자기 자신과 맞춰 보는 항등식이었다.
        parsed = _design_prep_rows(planning, failures)
        if parsed is None:
            return warnings
        items, _ = parsed
        _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
    # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
    # "If `false`, the round is advisory-only and never blocks approval".
    # 게이트 계산은 이미 그것을 존중한다(`_recompute_plan_body_gate`,
    # `_gate_blocking_causes`). 이 검사만 그 상태를 안 보면 자문 라운드가
    # 승인 차단 행을 강제하게 되어, 막지 않기로 한 판정이 다시 막는다.
    if pbv.get("gating") is False and not requires_plan_repair(pbv):
        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
    # `gating=false` 는 이 라운드를 자문으로 돌린다 — plan-body-verification.md
    # "If `false`, the round is advisory-only and never blocks approval" 이고
    # 같은 행이 "does not run the self-fix loop" 라고 못박는다.
    #
    # 이 검사가 그 상태를 안 보면 `_validate_advisory_plan_body_gating` 과 정면
    # 충돌한다: 그쪽은 `gating=false` 에서 `selfFixRoundsApplied > 0` 을 실패로
    # 잡는데 이 검사는 `>= 1` 을 요구한다. 한 필드에 반대 방향 요구가 걸리므로
    # 자문 라운드에 planner-fixable 과반 반대가 하나라도 나오면 통과 가능한
    # 값이 없다. `okstra plan-items complete-round` 도 자문 라운드의 self-fix
    # 기록을 거부하므로(plan_items_cli.py) 우회로도 없다.
    if pbv.get("gating") is False and not requires_plan_repair(pbv):
        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_grouping(data, failures)
    _validate_plan_body_verdict_provenance(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_verdict_rounds_outlive_self_fix(data, failures)
    _validate_unresolved_tie_was_reverified(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),
        "decisionAuthority": _plan_item_decision_authority(item, pbv),
        "leadDecisionApplied": _lead_decision_applies(item, pbv),
        # 왜 안 막는지가 기록에 남아야 한다. 이 값이 없으면 범위 밖 강등과
        # 실제 합의가 산출물에서 같은 모양으로 읽힌다.
        "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,
    ledger_ids: set[str] | None = None,
) -> list[str]:
    valid_refs: list[str] = []
    carried_rows = carried or {}
    known_ledger = ledger_ids or set()
    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
            and ref not in known_ledger
        ):
            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,
    ledger_ids: set[str] | None = None,
) -> None:
    carried_rows = carried or {}
    known_ledger = ledger_ids or set()
    if disposition == "accepted":
        confirmed = any(
            ref.startswith("C-")
            and (
                ref in known_ledger
                or _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 {}
    ledger_ids = {
        str(entry.get("clarificationId") or "").strip()
        for entry in (planning.get("supersessionLedger") or [])
        if isinstance(entry, dict) and str(entry.get("clarificationId") or "").strip()
    }
    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,
            ledger_ids,
        )
        _validate_deviation_disposition(
            row_id,
            row.get("approvalDisposition"),
            refs,
            clarifications,
            failures,
            carried_rows,
            ledger_ids,
        )


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"}
)


_BLOCKED_BY_KINDS = frozenset(
    {"clarification", "finding", "execution", "upstream", "not-observed"}
)
_BLOCKED_BY_NEEDS_REF = frozenset({"clarification", "finding"})
_CLARIFICATION_ID_RE = re.compile(r"^C-\d{3,}$")


def _known_clarification_ids(data: dict, carried: dict | None) -> set[str]:
    known = {
        str(row.get("id") or "").strip()
        for row in (data.get("clarificationItems") or [])
        if isinstance(row, dict) and str(row.get("id") or "").strip()
    }
    known |= {str(row_id).strip() for row_id in (carried or {})}
    planning = data.get("implementationPlanning")
    if isinstance(planning, dict):
        known |= {
            str(entry.get("clarificationId") or "").strip()
            for entry in (planning.get("supersessionLedger") or [])
            if isinstance(entry, dict)
            and str(entry.get("clarificationId") or "").strip()
        }
    return known


def _validate_end_state_blocked_by(
    data: dict, failures: list[str], *, carried: dict | None = None
) -> None:
    """A `blocked` end state must name what blocks it, in a field, not in prose.

    `blocked` on its own says five different things — the census behind the
    schema enum found a reporter decision, a cross-verification finding, a
    server that would not boot, a disposition carried from an earlier stage, and
    a check nobody re-ran, all under the same word. Nothing downstream could
    tell them apart, so a requirements-discovery run shipped three end states
    "blocked" on clarifications it had never recorded: the report told the
    reader to answer `C-001`, the decisions section rendered "No further
    decision is needed", and `okstra user-response` had no row to offer.

    Only a `clarification` ref is resolved. A `finding` ref legitimately points
    at the approved plan or an upstream report (`VC-003`, `CA-001` — 13 of the
    94 ids the shipped blocked rows name), and this document cannot see those.
    """
    known = _known_clarification_ids(data, carried)
    for index, row in enumerate(data.get("endStateCoverage") or []):
        if not isinstance(row, dict) or row.get("disposition") != "blocked":
            continue
        row_id = str(row.get("id") or f"[{index}]")
        blocked_by = row.get("blockedBy")
        if not isinstance(blocked_by, dict):
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` is "
                "`blocked` but records no `blockedBy`. Name what holds it: "
                f"one of {', '.join(sorted(_BLOCKED_BY_KINDS))}. `blocked` "
                "alone does not say whether a person has to answer something."
            )
            continue
        kind = str(blocked_by.get("kind") or "").strip()
        if kind not in _BLOCKED_BY_KINDS:
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` has "
                f"`blockedBy.kind` `{kind or '(empty)'}`; expected one of "
                f"{', '.join(sorted(_BLOCKED_BY_KINDS))}."
            )
            continue
        ref = str(blocked_by.get("ref") or "").strip()
        if kind in _BLOCKED_BY_NEEDS_REF and not ref:
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` is "
                f"blocked by a `{kind}` but names no `blockedBy.ref`. A blocker "
                "nobody can look up is not a blocker anyone can clear."
            )
            continue
        if kind != "clarification":
            continue
        if not _CLARIFICATION_ID_RE.match(ref):
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` has "
                f"`blockedBy.ref` `{ref}`, which is not a clarification id. Use "
                "`C-NNN`, or a different `blockedBy.kind`."
            )
            continue
        if ref not in known:
            failures.append(
                f"final-report data.json: endStateCoverage `{row_id}` is held "
                f"behind clarification `{ref}`, which is not a row of this "
                "report. Record it with `okstra approval-decision open --ledger "
                "<approvalDecisionsPath>` before assembly, or — when an earlier "
                "run already answered it — bring it in with `okstra "
                "approval-decision carry`. Assembly reads `clarificationItems[]` "
                "from that ledger and from nowhere else, so a decision raised "
                "only in prose leaves the reader and `okstra user-response` "
                "with nothing to answer."
            )


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], task_root: Path | None = None,
) -> 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.

    `tddExemption: user-bypass` is the one reason the plan cannot assert by
    itself, so the user's grants are read from the task's own bypass ledger
    and passed in; without the task root no stage counts as granted.
    """
    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
    granted = (
        frozenset(granted_stages(tdd_bypass_file(task_root)))
        if task_root is not None else frozenset()
    )
    for e in mod.collect_data_validation_errors(dict(planning), granted):
        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_translation_sidecar(
    data: Mapping[str, Any], report_path: Path, failures: list[str]
) -> None:
    """영어가 아닌 사람 HTML 은 번역 사이드카가 있어야 한다.

    사이드카가 없으면 렌더러가 크롬만 번역하고 본문은 영어로 남긴다.
    `render-views` 가 그 조합을 거부하고, 여기가 같은 규칙의 두 번째 게이트다.
    """
    if not data:
        return
    lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
    if lang == "en":
        return
    sidecar = translation_sidecar_path(report_path, lang)
    if not sidecar.is_file():
        failures.append(
            f"final-report has reportLanguage {lang!r} but no translation "
            f"sidecar at {sidecar.name}. The human HTML rendered from the "
            "English source instead; re-run `report-finalize --only translate "
            "--only render-views` to dispatch the translator and overlay it."
        )


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)


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


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, suffix=None) -> 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]


# 두 큐의 ID 네임스페이스. finding 은 F-/C-, plan item 은 P- 로 시작한다
# (prompts/lead/plan-body-verification.md §"MUTUAL EXCLUSION").
_FINDING_ID_RE = re.compile(r"^[FC]-", re.I)
_PLAN_ITEM_ID_RE = re.compile(r"^P-", re.I)
_H3_ID_RE = re.compile(r"^###\s+([A-Za-z]+-[A-Za-z0-9-]+)", re.M)


# 워커가 결과를 내지 못한 dispatch 상태. 이 상태의 워커 표는 실패이지 반대의견이 아니다.
_TERMINAL_NON_RESULT = {"timeout", "error", "not-run"}
# 큐에서 빠져나가는 분류. 이 분류를 받은 finding 은 이후 라운드에 다시 나오지 않는다.
_RESOLVED_CLASSIFICATIONS = {"full-consensus", "partial-consensus", "worker-unique"}


def _run_artifact_suffix(run_manifest: Mapping[str, Any] | None) -> str | None:
    """이 run 의 아티팩트 접미사 `<task-type>-<seq>`.

    한 run 디렉터리에는 그 태스크의 **모든 seq** 산출물이 함께 쌓인다. 이 값이
    이번 run 의 것을 가른다. 매니페스트가 없거나 어느 경로 필드도 없으면
    ``None`` — 호출자는 종전대로 전부 본다(매니페스트 없는 픽스처 보존).
    """
    for field, prefix in (
        ("teamStatePath", "team-state-"),
        ("convergenceStatePath", "convergence-"),
        ("approvalDecisionsPath", "approval-decisions-"),
    ):
        value = (run_manifest or {}).get(field)
        if not isinstance(value, str) or not value.strip():
            continue
        stem = Path(value.strip()).stem
        if stem.startswith(prefix) and len(stem) > len(prefix):
            return stem[len(prefix):]
    return None


def _run_scoped_glob(pattern: str, suffix: str | None) -> str:
    """`pattern` 을 이번 run 의 아티팩트만 고르도록 좁힌다.

    좁히지 않으면 한 run 의 검증 결과에 그 run 이 고칠 수 없는 선대 seq 의
    실패가 섞이고, 통과/실패가 이 run 에 대해 말해 주는 것이 없어진다.
    실측(`fontsninja-nlpvibe`, run 021): 잔여 실패 11건이 전부 seq 001·005·013
    의 것이었다.

    접미사는 확장자 앞에 `*<suffix>` 로 끼운다 — `convergence-*.json` 은
    `convergence-work-<suffix>.json` 같은 중간 산출물까지 종전처럼 포함하고,
    `*-reverify-r*.md` 는 라운드 라벨(`r1`·`r1b`)을 그대로 흡수한다.

    호출부 패턴은 전부 `*` 로 끝나는 stem 이라 그냥 이어 붙이면 `**` 가 된다.
    Python 3.13+ 는 그것을 `*` 와 같게 읽지만 3.11(CI 기준 버전)은
    `ValueError: '**' can only be an entire path component` 로 죽는다. 두 버전이
    같은 결과를 내므로 stem 끝의 `*` 를 흡수해 하나로 만든다.
    """
    if not suffix:
        return pattern
    stem, _dot, ext = pattern.rpartition(".")
    return f"{stem.rstrip('*')}*{suffix}.{ext}"


def _convergence_states(run_dir, suffix=None):
    """(경로, payload) 쌍. 읽을 수 없는 파일은 건너뛴다."""
    from pathlib import Path as _Path

    state_dir = _Path(run_dir) / "state"
    if not state_dir.is_dir():
        return
    for path in sorted(state_dir.glob(_run_scoped_glob("convergence-*.json", suffix))):
        payload = _load_json_or_none(path)
        if isinstance(payload, dict):
            yield path, payload


def _validate_worker_failure_is_not_a_disagree(run_dir, failures, suffix=None) -> None:
    """결과를 내지 못한 워커의 표가 DISAGREE 로 집계되면 안 된다.

    실패를 반대의견으로 세면 큐가 `contested`/`worker-unique` 쪽으로 기울고 최종
    분류가 무의미해진다. 계약(§"Worker failure handling in reverify")은 이것을
    BLOCKING 으로 선언해 왔지만, 상태 파일에서 그 조합을 확인하는 검사는 없었다.

    같은 라운드의 `dispatches[].status` 와 `findings[].rounds[].votes` 를 맞춰 본다.
    """
    for path, payload in _convergence_states(run_dir, suffix):
        for row in payload.get("roundHistory") or []:
            if not isinstance(row, dict):
                continue
            round_n = row.get("round")
            failed = {
                str(d.get("worker"))
                for d in row.get("dispatches") or []
                if isinstance(d, dict) and d.get("status") in _TERMINAL_NON_RESULT
            }
            if not failed:
                continue
            for finding in payload.get("findings") or []:
                if not isinstance(finding, dict):
                    continue
                for entry in finding.get("rounds") or []:
                    if not isinstance(entry, dict) or entry.get("round") != round_n:
                        continue
                    for worker, vote in (entry.get("votes") or {}).items():
                        if worker not in failed or not isinstance(vote, dict):
                            continue
                        if vote.get("verdict") == "disagree":
                            failures.append(
                                f"convergence-worker-failure: {path.name} round {round_n} "
                                f"의 {worker} 는 결과를 내지 못했는데(dispatch status) 표가 "
                                f"`disagree` 로 집계됐다 — {finding.get('findingId')}. "
                                "실패는 `verification-error` 다"
                            )


def _validate_adversarial_disagree_carries_a_basis(run_dir, failures, suffix=None) -> None:
    """adversarial 모드의 `disagree` 는 반드시 `disagreeBasis` 를 갖는다.

    근거 없는 반박은 재검사 없이 "동의하지 않는다"고 말한 것과 구별되지 않는다.
    계약(§"Adversarial verdict semantics")은 이를 계약 위반으로 규정하지만 검사가
    없었다. 비-adversarial 라운드에는 적용하지 않는다 — 그쪽 verdict 의미가 다르고,
    실물 픽스처(`mixed-round2.json`)가 basis 없는 `disagree` 를 정상으로 담고 있다.
    """
    for path, payload in _convergence_states(run_dir, suffix):
        if not (payload.get("config") or {}).get("adversarial"):
            continue
        for finding in payload.get("findings") or []:
            if not isinstance(finding, dict):
                continue
            for entry in finding.get("rounds") or []:
                if not isinstance(entry, dict):
                    continue
                for worker, vote in (entry.get("votes") or {}).items():
                    if not isinstance(vote, dict):
                        continue
                    if vote.get("verdict") == "disagree" and not vote.get("disagreeBasis"):
                        failures.append(
                            f"convergence-adversarial-basis: {path.name} 의 "
                            f"{finding.get('findingId')} round {entry.get('round')} "
                            f"에서 {worker} 가 근거 없는 `disagree` 를 냈다 — adversarial "
                            "모드는 counter-evidence 또는 burden-not-met 을 요구한다"
                        )


def _validate_resolved_findings_leave_the_queue(run_dir, failures, suffix=None) -> None:
    """분류가 끝난 finding 은 이후 라운드에 다시 나오지 않는다.

    큐는 단조 감소한다 — `full-consensus` / `partial-consensus` / `worker-unique`
    로 분류된 finding 이 다음 라운드 프롬프트에 다시 실리면 이미 끝난 판정을 다시
    묻는 것이고, 그 표가 최종 분류를 덮는다. 계약(§"Scope and Terminology")은
    BLOCKING 으로 선언해 왔지만 픽스처 계약 테스트만 있었고 실제 run 의 상태
    파일은 아무도 보지 않았다.

    `rounds[]` 의 라운드 번호가 1..N 연속인지로 잰다 — 중간에 빠졌다가 다시
    나타난 finding 이 정확히 이 위반이다.
    """
    for path, payload in _convergence_states(run_dir, suffix):
        for finding in payload.get("findings") or []:
            if not isinstance(finding, dict):
                continue
            if finding.get("classification") not in _RESOLVED_CLASSIFICATIONS:
                continue
            rounds = sorted(
                entry.get("round")
                for entry in finding.get("rounds") or []
                if isinstance(entry, dict) and isinstance(entry.get("round"), int)
            )
            if not rounds:
                continue
            if rounds != list(range(1, len(rounds) + 1)):
                failures.append(
                    f"convergence-queue-pruning: {path.name} 의 "
                    f"{finding.get('findingId')} 는 `{finding.get('classification')}` "
                    f"인데 라운드 이력이 연속이 아니다 {rounds} — 분류된 뒤 큐에 "
                    "다시 실렸다"
                )


def _validate_no_in_round_queue_insertion(run_dir, failures, suffix=None) -> None:
    """reverify 라운드는 finding 을 분류만 한다 — 새로 넣지 않는다.

    검증 큐는 Round 0 의 통합 목록에서 만들어지고 그 뒤로는 줄어들기만 한다
    (§"When", §"Convergence State Artifact" `carriedForwardCount`). 라운드 중간에
    끼어든 finding 은 앞선 라운드의 표를 받은 적이 없는데도 같은 분류 규칙으로
    처리되어, 실제보다 적은 검증으로 합의에 도달한 것처럼 보인다.

    처음 등장한 라운드가 1보다 큰 finding 이 정확히 이 위반이다.
    """
    for path, payload in _convergence_states(run_dir, suffix):
        for finding in payload.get("findings") or []:
            if not isinstance(finding, dict):
                continue
            rounds = [
                entry.get("round")
                for entry in finding.get("rounds") or []
                if isinstance(entry, dict) and isinstance(entry.get("round"), int)
            ]
            if not rounds:
                continue
            first = min(rounds)
            if first > 1:
                failures.append(
                    f"convergence-queue-insertion: {path.name} 의 "
                    f"{finding.get('findingId')} 는 round {first} 에서 처음 나타난다 "
                    "— 큐는 Round 0 에서 확정되고 라운드 중간 삽입은 금지다"
                )


def _validate_full_reanalysis_prompt_omits_the_report_template(run_dir, failures, suffix=None) -> None:
    """full-reanalysis reverify 도 리포트 템플릿까지 다시 읽히지는 않는다.

    계약(§"Scoped full-reanalysis")은 재분석 범위를 공격받는 finding 이 인용한
    증거로 한정하고, 검증자가 task brief·instruction-set·`final-report-template.md`
    전체를 다시 읽는 것을 금지한다. 그중 템플릿 참조만 기계로 잴 수 있다 —
    파일 이름이 고정이기 때문이다.

    금지형이라 규칙을 지키는 프롬프트는 이 검사로 깨지지 않는다.
    """
    from pathlib import Path as _Path

    prompts_dir = _Path(run_dir) / "prompts"
    if not prompts_dir.is_dir():
        return
    for prompt_path in sorted(
        prompts_dir.glob(_run_scoped_glob("*-reverify-r*.md", suffix))
    ):
        try:
            text = prompt_path.read_text(encoding="utf-8")
        except OSError:
            continue
        if "final-report-template.md" in text:
            failures.append(
                f"reverify-scope: {prompt_path.name} 이 `final-report-template.md` 를 "
                "참조한다 — reverify 범위는 finding 이 인용한 증거까지다"
            )


def _validate_reverify_prompt_suppresses_required_reading(run_dir, failures, suffix=None) -> None:
    """reverify 프롬프트는 Phase 2 `[Required reading]` 절을 싣지 않는다.

    lightweight reverify 의 입력은 그 워커의 `findingIds` 배치와 거기 박힌 증거가
    전부다. 필수 읽기 절을 넣으면 워커가 라운드마다·워커마다 instruction-set 전체를
    다시 읽는다 — 계약이 "가장 큰 회피 가능 비용"으로 지목한 바로 그 지출이고,
    "원본을 재분석하지 말라"는 같은 프롬프트의 지시와 정면으로 충돌한다.

    찾는 것은 **절**이지 그 이름의 언급이 아니다. 런타임이 모든 reverify 프롬프트에
    강제로 싣는 `**Read scope:**` 헤더(`worker_prompt_headers.py`)가 읽기 allowlist 의
    열거 표면을 대느라 `` `[Required reading]` `` 이라는 토큰을 본문 안에 담고 있다.
    단순 부분문자열 검사는 그 헤더에서 전건 걸렸다 — 규칙을 만족하는 프롬프트가
    하나도 없는 검사였다. 절은 언제나 줄머리에서 시작하므로 줄 단위로 본다.

    금지형이라 규칙을 지키는 프롬프트는 이 검사로 깨지지 않는다.
    """
    from pathlib import Path as _Path

    prompts_dir = _Path(run_dir) / "prompts"
    if not prompts_dir.is_dir():
        return
    for prompt_path in sorted(
        prompts_dir.glob(_run_scoped_glob("*-reverify-r*.md", suffix))
    ):
        try:
            text = prompt_path.read_text(encoding="utf-8")
        except OSError:
            continue
        if any(
            line.lstrip().startswith("[Required reading]")
            for line in text.splitlines()
        ):
            failures.append(
                f"reverify-required-reading: {prompt_path.name} 이 Phase 2 "
                "`[Required reading]` 절을 실었다 — reverify 는 원본 자료를 다시 "
                "읽지 않는다"
            )


def _validate_queue_mutual_exclusion(run_dir, failures, suffix=None) -> None:
    """finding 큐와 plan-item 큐는 서로의 ID 를 담지 않는다.

    두 라운드는 verdict 의미가 다르다. 한쪽 ID 가 다른 쪽 큐에 섞이면 집계가
    조용히 오염되고, 그 결과는 gate 통과 여부로 직접 이어진다. 계약은 이것을
    BLOCKING 으로 선언해 왔지만 검사는 없었다 — 섞였는지 아무도 보지 않았다.

    상태 파일을 정본으로 본다(구조화 JSON). 프롬프트는 보조로 훑는다: 프롬프트의
    `### <ID>` H3 헤딩은 워커에게 실제로 보여 준 항목이라, 상태가 깨끗해도
    프롬프트가 섞였다면 잘못된 대상에 대한 표가 돌아온다.

    판정할 수 없는 입력은 조용히 건너뛴다 — 차단 검증기에서 오탐은 막으려는
    공백보다 나쁘다.
    """
    from pathlib import Path as _Path

    run_dir = _Path(run_dir)
    state_dir = run_dir / "state"
    prompts_dir = run_dir / "prompts"

    for path in sorted(
        state_dir.glob(_run_scoped_glob("convergence-*.json", suffix))
    ) if state_dir.is_dir() else []:
        payload = _load_json_or_none(path)
        if not isinstance(payload, dict):
            continue
        for finding in payload.get("findings") or []:
            if not isinstance(finding, dict):
                continue
            fid = str(finding.get("findingId") or "")
            if _PLAN_ITEM_ID_RE.match(fid):
                failures.append(
                    f"queue-mutual-exclusion: {path.name} 의 findings[] 에 plan-item "
                    f"ID `{fid}` 가 있다 — plan 큐 항목이 finding 큐에 섞였다"
                )
        for row in payload.get("roundHistory") or []:
            if not isinstance(row, dict):
                continue
            for dispatch in row.get("dispatches") or []:
                if not isinstance(dispatch, dict):
                    continue
                for fid in dispatch.get("findingIds") or []:
                    if _PLAN_ITEM_ID_RE.match(str(fid)):
                        failures.append(
                            f"queue-mutual-exclusion: {path.name} 의 dispatches[].findingIds "
                            f"에 plan-item ID `{fid}` 가 있다"
                        )

    for path in sorted(
        state_dir.glob(_run_scoped_glob("plan-body-verification-*.json", suffix))
    ) if state_dir.is_dir() else []:
        payload = _load_json_or_none(path)
        if not isinstance(payload, dict):
            continue
        for item in payload.get("planItems") or []:
            if not isinstance(item, dict):
                continue
            iid = str(item.get("id") or "")
            if _FINDING_ID_RE.match(iid):
                failures.append(
                    f"queue-mutual-exclusion: {path.name} 의 planItems[] 에 finding "
                    f"ID `{iid}` 가 있다 — finding 큐 항목이 plan 큐에 섞였다"
                )

    if not prompts_dir.is_dir():
        return
    for prompt_path in sorted(prompts_dir.glob(_run_scoped_glob("*.md", suffix))):
        name = prompt_path.name
        if "-plan-verify-r" in name:
            wrong, label = _FINDING_ID_RE, "finding"
        elif "-reverify-r" in name:
            wrong, label = _PLAN_ITEM_ID_RE, "plan-item"
        else:
            continue
        try:
            text = prompt_path.read_text(encoding="utf-8")
        except OSError:
            continue
        bad = sorted({i for i in _H3_ID_RE.findall(text) if wrong.match(i)})
        if bad:
            failures.append(
                f"queue-mutual-exclusion: {name} 이 {label} ID 를 항목으로 담았다: "
                + ", ".join(bad[:5])
            )


def _load_json_or_none(path):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None


def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> 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(_run_scoped_glob("*-reverify-r*.md", suffix))
    ):
        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 _partition_round_failures(failures: list[str]) -> tuple[list[str], list[str]]:
    """라운드 경계 실패를 (차단, advisory) 로 나눈다.

    끝난 run 의 전체 검증이 쓰는 허용목록과 같은 것을 쓴다
    (`scripts/okstra_ctl/blocking_checks.py`). 이유는 등급 역전을 막기
    위해서다 — 여기서만 차단하면, 끝에서는 advisory 로 통과할 실패 하나가
    §5.5.9 라운드를 영구히 못 닫게 만든다. 실제로 1-1 분석자 동률(critic
    표 없음), critic-tie 라운드 뒤의 `participatingAnalysers` 재계산 차이,
    subject 드리프트 세 갈래가 그 모양으로 라운드를 막았고, 셋 다 run
    종료 시점에는 advisory 다.

    강등된 실패는 사라지지 않는다. payload 의 `advisories` 와 stderr 에
    그대로 실려 리드가 기록한다.
    """
    return partition_blocking(failures)


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)
    failures, advisories = _partition_round_failures(failures)
    payload = {
        "ok": not failures,
        "section": SECTION_PLAN_BODY,
        "gate": plan_body_gate_summary(data),
        "failures": failures,
        "advisories": advisories,
        "warnings": warnings,
    }
    for note in advisories:
        print(f"validate-run: advisory — {note}", file=sys.stderr)
    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)
    failures, advisories = _partition_round_failures(failures)
    payload = {
        "ok": not failures,
        "section": SECTION_PLAN_BODY,
        "gate": plan_body_gate_summary(data),
        "failures": failures,
        "advisories": advisories,
        "warnings": warnings,
    }
    for note in advisories:
        print(f"validate-run: advisory — {note}", file=sys.stderr)
    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.

    문구는 종료 시점 경로(`validate_final_report_data`)와 같은 것을 쓴다.
    차단 허용목록은 메시지 조각으로 판정하므로, 같은 검사에 다른 문구를
    쓰면 한쪽 경로에서만 차단되는 등급 역전이 생긴다.
    """
    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 schema could not be loaded: {exc}"]
    return [
        f"final-report data.json schema: {error}"
        for error in schema_validate(data, schema)
    ]


def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
    """번역·표시·상태 갱신 전에 정본의 구조와 적합성만 검사한다."""
    try:
        data = load_json(_data_path_for(report_path))
        manifest = load_json(run_manifest_path)
        if not isinstance(data, dict) or not isinstance(manifest, dict):
            raise TypeError("preflight requires report and run-manifest JSON objects")
    except (OSError, ValueError, TypeError) as exc:
        print(json.dumps({"ok": False, "failures": [str(exc)]}))
        return 2
    failures = _data_schema_failures(data)
    task_type = manifest.get("taskType")
    warnings: list[str] = []
    if task_type in ("implementation", "final-verification"):
        project_root = Path(
            str(manifest.get("projectRoot") or run_manifest_path.parent)
        )
        warnings = _validate_conformance(
            report_path,
            failures,
            surface_patterns=_project_surface_patterns(project_root),
            approved_plan_path=_approved_plan_path_from_run_inputs(
                run_manifest_path, failures
            ),
        )
    elif task_type == "implementation-planning":
        from okstra_ctl.report_assembly import selected_direction_plan_errors
        from okstra_ctl.implementation_direction import (
            stage_validation_executability_errors,
        )

        failures.extend(
            stage_validation_executability_errors(
                data.get("implementationPlanning") or {}
            )
        )
        failures.extend(selected_direction_plan_errors(
            data, Path(str(manifest.get("projectRoot") or run_manifest_path.parent)), manifest
        ))
        _append_stage_data_failures(
            data, failures, _task_root_from_run_dir(report_path.parent.parent),
        )
        _validate_planning_conformance_declared(report_path, failures)
    print(
        json.dumps(
            {
                "ok": not failures,
                "failures": list(dict.fromkeys(failures)),
                "warnings": warnings,
            },
            ensure_ascii=False,
        )
    )
    return 2 if failures else 0


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Validate okstra run contract artifacts."
    )
    parser.add_argument(
        "--section",
        choices=(SECTION_FULL, SECTION_PLAN_BODY, "preflight"),
        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 == "preflight":
        if not args.report or not args.run_manifest:
            parser.error("--section preflight requires --report and --run-manifest")
        return run_preflight(
            Path(args.report).resolve(), Path(args.run_manifest).resolve()
        )

    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)
    # 이 검증기는 파일을 그대로 읽으므로, 쓰기 계약이 아직 invocation 에 실린
    # 진행 중 태스크는 여기서도 옮겨 놓고 봐야 한다(읽기 전용 — 파일은 안 고친다).
    hoist_legacy_attempt_write_contract(run_manifest)
    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] = []
    versioned_session_failures: 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=versioned_session_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,
        },
        versioned_session_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_translation_sidecar(
        validation_data, _data_path_for(report_path), failures
    )
    validate_report(
        report_path,
        failures,
        report_data=validation_data,
        team_state=team_state,
    )
    validate_team_state_usage(team_state, 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"):
        conformance_warnings = _validate_conformance(
            report_path,
            failures,
            surface_patterns=_project_surface_patterns(project_root),
            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,
                    surface_patterns=_project_surface_patterns(project_root),
                )
            if not selected_direction_plan:
                _validate_end_state_coverage(validation_data, brief_path, failures)
                _validate_end_state_blocked_by(
                    validation_data,
                    failures,
                    carried=_carried_decision_map(
                        run_manifest,
                        project_root=project_root,
                        report_path=report_path,
                    ),
                )
            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,
                    _task_root_from_run_dir(report_path.parent.parent),
                )
        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,
        versioned_session_failures,
        run_manifest,
        project_root,
    )
    # 한 run 디렉터리에는 그 태스크의 모든 seq 산출물이 쌓인다. 이 접미사가
    # 없으면 아래 검사들이 선대 seq 의 실패를 이 run 의 결과로 되돌려준다 —
    # 이 run 이 고칠 수 없는 실패라 통과/실패가 아무것도 말해 주지 않게 된다.
    run_suffix = _run_artifact_suffix(run_manifest)
    _validate_convergence_group_provenance(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_reverify_prompt_matches_plan(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_queue_mutual_exclusion(report_path.parent.parent, failures, run_suffix)
    _validate_worker_failure_is_not_a_disagree(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_adversarial_disagree_carries_a_basis(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_resolved_findings_leave_the_queue(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_reverify_prompt_suppresses_required_reading(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_full_reanalysis_prompt_omits_the_report_template(
        report_path.parent.parent, failures, run_suffix
    )
    _validate_no_in_round_queue_insertion(
        report_path.parent.parent, failures, run_suffix
    )
    validate_report_views(report_path, failures)
    if task_type == "implementation":
        _validate_verifier_command_log_is_read_only(validation_data, failures)
        _validate_verifier_reran_independently(validation_data, failures)
        _validate_verifier_discrepancy_is_not_passed(validation_data, failures)
        declaration_warnings: list[str] = []
        _warn_out_of_plan_edits_not_in_diff(validation_data, declaration_warnings)
        for warning in declaration_warnings:
            print(f"validate-run: warning: {warning}", file=sys.stderr)
    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,
        )
    _route_versioned_session_failures(
        run_manifest,
        versioned_session_failures,
        failures,
        advisories,
    )

    # 차단은 허용목록으로만 결정한다 — 등록되지 않은 실패는 advisory 로
    # 강등되어 기록·출력은 되지만 phase 를 막지 않는다.
    # 근거와 정책: scripts/okstra_ctl/blocking_checks.py
    failures, demoted = partition_blocking(failures)
    advisories.extend(demoted)

    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)

    record_validation_in_central_index(
        task_manifest, run_manifest_path, project_root, validation_status)

    # 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())
