#!/usr/bin/env python3
"""Fail-closed CI validation for the V3 migration completeness ledger."""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any

import generate_ledger

HERE = Path(__file__).resolve().parent
ROOT = generate_ledger.ROOT
REQUIRED_FIELDS = {
    "id", "category", "v2Source", "v3Disposition", "v3Target",
    "implementationObligation", "v3SupportStatus", "evidenceStatus",
    "deploymentStatus", "missionBoundaryRationale", "exactTargetModules",
    "bugOrRiskCarriedFromV2", "missionPrivacyClassification", "requiredTests",
    "evidence", "ownerAuthorityDependency", "activationGate", "influencePolicy",
}
COMPLETE_DISPOSITIONS = {
    "ported", "redesigned", "compatibility-wrapper", "intentionally-retired",
    "retired",
}
SUPPORT_STATUSES = {
    "ported", "redesigned", "retired", "deferred", "unresolved",
    "not-applicable",
}
PROTECTED_DECISIONS = {
    "custody", "access", "eligibility", "pricing", "slashing", "settlement",
    "disputes", "routing",
}
UNSAFE_CAPABILITIES = {
    "belief", "flourishing", "reputation", "trust-score", "behavioral-routing",
    "biometric", "profiling", "surveillance", "quality", "loyalty",
}
REQUIRED_SURFACE_KINDS = {
    "contract", "sdk", "api-metadata", "wrapper", "cross-chain-message",
    "governance-automation",
}
RETIRED_STRICT_PATHS = {
    "v3-src/contracts/disputes/DisputeRegistryV3.sol",
    "v3-src/contracts/escrow/TaskEscrowV3.sol",
    "v3-src/contracts/mutualaid/MutualAidPoolV3.sol",
    "v3-src/contracts/reputation/ERC8004ValidationRegistryV3Safe.sol",
    "v3-src/contracts/bonds/ObjectiveRuleSchemaV3.sol",
}
RETIRED_ACTIVATION_NAMES = {
    "TaskEscrowV3", "DisputeRegistryV3", "MutualAidPoolV3",
    "ERC8004ValidationRegistryV3Safe",
}
RETIREMENT_ENFORCED_IDENTITIES = {
    "AgentInsurancePool", "AgentSLAEnforcer", "ERC8004ValidationRegistry",
    "VaultfireBondInsurancePool", "VaultfireDisputeResolution",
    "VaultfireTaskEscrow",
}
EXPECTED_DECISIONS = {
    "ThresholdConsensusV3": set(),
    "AccountabilityBondFactoryV3": set(),
    "AccountabilityCanonicalSourceV3": {"slashing"},
    "AccountabilityBondV3Safe": {"custody", "slashing", "settlement"},
    "PartnershipBondFactoryV3": set(),
    "PartnershipBondV3Safe": {"custody", "settlement"},
    "SafeBondFactoryV3": {"access"},
    "EvidenceRegistryV3": set(),
    "MultisigGovernanceV3": PROTECTED_DECISIONS,
    "ERC8004IdentityAdapterV3": set(),
    "MandateExecutionAdapterV3": {"custody", "access", "routing"},
    "MandateRegistryV3": {"custody", "access", "routing"},
    "BasePilotAccessControllerV3": {"custody", "access"},
    "StreetCredV3": set(),
    "VaultfireBIPVaultV3": {"custody", "pricing"},
}


def _sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def check_current_authorities(
    facts: dict[str, Any] | None = None,
) -> list[str]:
    """Verify the final handoff facts against every repository artifact available."""
    errors: list[str] = []
    facts = facts or _load("current-authority-facts.json")
    if facts.get("schemaVersion") != "1.0.0":
        errors.append("current authority facts schemaVersion must be 1.0.0")

    base = facts.get("base", {})
    snapshot = base.get("sourceSnapshot", {})
    snapshot_path = ROOT / str(snapshot.get("path", ""))
    if not snapshot_path.is_file():
        errors.append("Base source snapshot is missing")
    else:
        if _sha256(snapshot_path) != snapshot.get("sha256"):
            errors.append("Base source/build/test snapshot digest drifted")
        snapshot_rows = [
            row for row in snapshot_path.read_text().splitlines() if row.strip()
        ]
        if len(snapshot_rows) != snapshot.get("fileCount"):
            errors.append(
                f"Base source snapshot must contain {snapshot.get('fileCount')} files, "
                f"got {len(snapshot_rows)}"
            )
        snapshot_entries = {}
        for row in snapshot_rows:
            parts = row.split(maxsplit=1)
            if len(parts) == 2:
                snapshot_entries[parts[1].lstrip("*")] = parts[0]
        for relative_path, expected_digest in snapshot_entries.items():
            source_path = ROOT / relative_path
            if not source_path.is_file() or _sha256(source_path) != expected_digest:
                errors.append(f"Base snapshot row mismatch: {relative_path}")
        if any(path.startswith(("v3-migration/", "v3-operations/")) for path in snapshot_entries):
            errors.append("Base snapshot contains mutable migration/operations authority")
        for relative_path, expected_digest in base.get("sourceFiles", {}).items():
            source_path = ROOT / relative_path
            if not source_path.is_file() or _sha256(source_path) != expected_digest:
                errors.append(f"Base authoritative source digest drifted: {relative_path}")
            if snapshot_entries.get(relative_path) != expected_digest:
                errors.append(f"Base snapshot does not bind authoritative source: {relative_path}")
    if base.get("reportedAuditFindingCounts") != {
        "critical": 0, "high": 0, "medium": 0, "low": 0
    }:
        errors.append("Base/bond handoff finding counts must remain C0/H0/M0/L0")
    if base.get("auditEvidenceStatus") != "handoff-reported-no-open-findings":
        errors.append("Base/bond audit status must not be promoted beyond handoff evidence")

    sdk = facts.get("sdk", {})
    identity_path = ROOT / str(sdk.get("identityPath", ""))
    identities = json.loads(identity_path.read_text()).get("deployments", {})
    reconciliation = (ROOT / str(sdk.get("reconciliationPath", ""))).read_text()
    for deployment_name, expected in sdk.get("abis", {}).items():
        artifact_path = ROOT / expected["artifactPath"]
        artifact = json.loads(artifact_path.read_text())
        actual_count = sum(
            entry.get("type") == "function" for entry in artifact.get("abi", [])
        )
        if actual_count != expected["functionCount"]:
            errors.append(
                f"SDK {deployment_name} function count drifted: expected "
                f"{expected['functionCount']}, got {actual_count}"
            )
        identity = identities.get(deployment_name, {})
        if len(identity.get("functions", [])) != expected["functionCount"]:
            errors.append(f"SDK {deployment_name} identity signature closure drifted")
        if identity.get("identity") != expected["identity"]:
            errors.append(f"SDK {deployment_name} ABI identity drifted")
        if str(expected["functionCount"]) not in reconciliation or expected["identity"] not in reconciliation:
            errors.append(f"SDK reconciliation omits final {deployment_name} count or identity")

    open_spec = facts.get("openSpec", {})
    report = json.loads((ROOT / str(open_spec.get("reportPath", ""))).read_text())
    candidate = report.get("candidate", {})
    if _sha256(ROOT / open_spec["mappingPath"]) != open_spec.get("mappingSha256"):
        errors.append("open-spec canonical mapping digest drifted")
    if _sha256(ROOT / open_spec["interfacePath"]) != open_spec.get("interfaceSha256"):
        errors.append("open-spec interface snapshot digest drifted")
    expected_candidate_digests = {
        "artifactManifestSha256": open_spec.get("artifactManifestSha256"),
        "canonicalMappingSha256": open_spec.get("mappingSha256"),
        "interfaceSnapshotSha256": open_spec.get("interfaceSha256"),
    }
    for field, value in expected_candidate_digests.items():
        if candidate.get(field) != value:
            errors.append(f"open-spec report {field} drifted")
    checks = report.get("checks", [])
    failed = [row.get("id") for row in checks if row.get("status") == "fail"]
    summary = report.get("summary", {})
    if (
        len(checks) != open_spec.get("requiredChecks")
        or summary.get("passed") != open_spec.get("passedChecks")
        or summary.get("failed") != 1
        or summary.get("notRun") != 0
        or failed != [open_spec.get("soleFailedCheck")]
    ):
        errors.append("open-spec 28/29 EXACT_COMMIT-only result drifted")
    truth = report.get("truth", {})
    if truth.get("specificationCompliant") is not False or any(
        truth.get(field) is not False
        for field in ("audited", "authorized", "deployed", "endorsed")
    ):
        errors.append("open-spec external truth labels must remain false")
    handoff = (ROOT / str(open_spec.get("handoffPath", ""))).read_text()
    if "64/64" not in handoff or "64/64 pass under `python -O`" not in handoff:
        errors.append("open-spec final 64/64 normal/optimized evidence is missing")

    partner = facts.get("partnerPackage", {})
    partner_evidence = json.loads(
        (ROOT / str(partner.get("repositoryEvidencePath", ""))).read_text()
    )
    verification = partner_evidence.get("verification", {})
    readiness = partner_evidence.get("readiness", {})
    expected_partner_values = (
        (verification.get("fullSuiteNormal", {}).get("passed"), partner.get("normalTests")),
        (verification.get("fullSuiteOptimized", {}).get("passed"), partner.get("optimizedTests")),
        (verification.get("originalHarnessReplay", {}).get("secure"), partner.get("hostileSecure")),
        (verification.get("originalHarnessReplay", {}).get("insecure"), partner.get("hostileInsecure")),
        (partner_evidence.get("remainingBlockerCount"), partner.get("mandatoryEvidenceBlockers")),
        (partner_evidence.get("remainingHumanBlockerCount"), partner.get("externalHumanBlockers")),
    )
    if any(actual != expected for actual, expected in expected_partner_values):
        errors.append("partner-package 309/309, 22/22, or blocker totals drifted")
    if (
        readiness.get("partnerReady") is not False
        or readiness.get("pilotReady") is not False
        or readiness.get("v2Default") is not True
        or readiness.get("activeDesignPartners") != 0
    ):
        errors.append("partner-package blocked readiness truth drifted")
    validator = json.loads(
        (ROOT / "pr10/partner-package/THIRTEENTH_VALIDATOR_RUN2_20260826.json").read_text()
    )
    if validator.get("organizationalPartnerReady") is not False:
        errors.append("organizationalPartnerReady must remain false")
    fourteenth_present = any(
        (ROOT / "pr10/partner-package").glob("*FOURTEENTH*")
    )
    if fourteenth_present != partner.get("fourteenthRepositoryArtifactPresent"):
        errors.append("partner fourteenth-audit artifact availability changed; reconcile authority facts")
    if partner.get("reportedAuditFindingCounts") != {
        "critical": 0, "high": 0, "medium": 0, "low": 0
    }:
        errors.append("partner handoff finding counts must remain C0/H0/M0/L0")
    if partner.get("finalAuditEvidenceStatus") != "handoff-reported-artifact-not-present":
        errors.append("missing fourteenth audit artifact must not be promoted to repository evidence")

    external = facts.get("externalEvidence", {})
    if (
        external.get("x402") != "fail-closed-external-evidence-incomplete"
        or external.get("solana") != "fail-closed-external-evidence-incomplete"
        or external.get("deploymentAuthorized") is not False
        or external.get("readinessAuthorized") is not False
    ):
        errors.append("x402/Solana/deployment/readiness external truth boundary drifted")
    return errors


def _load(name: str) -> dict[str, Any]:
    return json.loads((HERE / name).read_text())


def check_governance_policy(
    policy: dict[str, Any], supported: dict[str, Any], graph: dict[str, Any]
) -> list[str]:
    errors: list[str] = []
    expected_classes = set(supported.get("unsafeCapabilityClasses", []))
    if set(graph.get("unsafeCapabilityClasses", [])) != expected_classes:
        errors.append("mission-boundary unsafe capability classes drifted")
    if set(policy.get("prohibitedInputClasses", [])) != expected_classes:
        errors.append("canonical Base governance policy prohibited classes drifted")
    if set(policy.get("protectedDecisions", [])) != PROTECTED_DECISIONS:
        errors.append("canonical Base governance policy protected decisions are incomplete")
    mission = policy.get("missionOutcome", "")
    boundary = policy.get("flourishingNonControlBoundary", "")
    if not all(term in mission.lower() for term in ("cooperation", "accountable growth", "freedom", "flourishing")):
        errors.append("canonical Base governance policy omits the positive flourishing-bond mission")
    if not all(term in boundary.lower() for term in ("mission outcome", "surveillance-derived", "principal", "slashing")):
        errors.append("canonical Base governance policy omits the flourishing non-control boundary")
    required_representations = {
        "direct", "hashed", "committed", "encoded", "aggregated", "derived", "proxied", "transitive",
    }
    if set(policy.get("prohibitedRepresentations", [])) != required_representations:
        errors.append("canonical Base governance policy prohibited representations are incomplete")
    target_identity = policy.get("targetIdentityRule", {})
    if (
        target_identity.get("address") != "exact"
        or target_identity.get("runtimeCodehash") != "append-only-pinned"
        or target_identity.get("proposalValidation") is not True
        or target_identity.get("executionValidation") is not True
        or target_identity.get("proxyOrDispatcherDrift") != "deny"
    ):
        errors.append("canonical Base governance target identity rule is incomplete")
    return errors


def check_safety_manifests(
    dispositions: dict[str, Any] | None = None,
    supported: dict[str, Any] | None = None,
    graph: dict[str, Any] | None = None,
) -> list[str]:
    """Validate identity closure and transitive protected-decision isolation."""
    errors: list[str] = check_current_authorities()
    dispositions = dispositions or _load("v2-contract-dispositions.json")
    supported = supported or _load("supported-v3-modules.json")
    graph = graph or _load("mission-boundary-graph.json")
    abi = json.loads((ROOT / "legacy/v2/manifest.json").read_text())
    abi_by_name = {record["contract"]: record for record in abi["records"]}
    for label, manifest in (
        ("disposition", dispositions),
        ("supported-module", supported),
        ("mission-boundary", graph),
    ):
        if manifest.get("schemaVersion") != "1.0.0":
            errors.append(f"{label} manifest schemaVersion must be 1.0.0")

    records = dispositions.get("records", [])
    names = [record.get("v2ContractIdentity") for record in records]
    if len(records) != 36 or dispositions.get("recordCount") != 36:
        errors.append("disposition manifest must contain exactly 36 records")
    if len(set(names)) != len(names):
        errors.append("disposition manifest contains duplicate V2 identities")
    if set(names) != set(abi_by_name):
        errors.append("disposition identities do not exactly match immutable V2 ABI identities")

    target_paths: set[str] = set()
    for record in records:
        name = record.get("v2ContractIdentity", "<missing>")
        status = record.get("v3SupportStatus")
        targets = record.get("exactTargetModules")
        if status not in {"ported", "redesigned", "retired", "deferred"}:
            errors.append(f"{name}: invalid V3 support status {status!r}")
        if not isinstance(targets, list):
            errors.append(f"{name}: exactTargetModules must be an array")
            continue
        if status in {"ported", "redesigned"} and not targets:
            errors.append(f"{name}: implemented status requires exact target modules")
        if status in {"retired", "deferred"} and targets:
            errors.append(f"{name}: unavailable status must not claim target modules")
        expected_evidence_status = (
            "retirement-enforced"
            if name in RETIREMENT_ENFORCED_IDENTITIES
            else "source-evidenced"
            if status in {"ported", "redesigned"}
            else "decision-recorded"
        )
        if record.get("evidenceStatus") != expected_evidence_status:
            errors.append(
                f"{name}: evidence status must be {expected_evidence_status!r} "
                f"for support status {status!r}"
            )
        if record.get("deploymentStatus") != "not-deployed-not-authorized":
            errors.append(f"{name}: source disposition must not claim deployment")
        immutable = record.get("immutableV2Evidence", {})
        abi_record = abi_by_name.get(name)
        if abi_record and immutable.get("sha256") != abi_record["sha256"]:
            errors.append(f"{name}: immutable V2 ABI checksum changed")
        if abi_record and immutable.get("abiFile") != f"legacy/v2/{abi_record['file']}":
            errors.append(f"{name}: immutable V2 ABI path changed")
        for target in targets:
            target_paths.add(target)
            if not (ROOT / target).is_file():
                errors.append(f"{name}: target module does not exist: {target}")

    modules = supported.get("modules", [])
    module_paths = [module.get("path") for module in modules]
    if len(set(module_paths)) != len(module_paths):
        errors.append("supported V3 module manifest contains duplicate paths")
    if set(module_paths) != target_paths:
        errors.append("supported V3 modules do not exactly match disposition target closure")
    included_retired = sorted(set(module_paths) & RETIRED_STRICT_PATHS)
    if included_retired:
        errors.append(f"retired strict-boundary modules remain supported: {included_retired}")
    if "v3-src/contracts/bonds/SafeBondFactoryV3.sol" not in module_paths:
        errors.append("supported bond deployment closure omits SafeBondFactoryV3")
    unsafe = set(supported.get("unsafeCapabilityClasses", []))
    if unsafe != UNSAFE_CAPABILITIES:
        errors.append("supported V3 module unsafe capability classes drifted")
    if set(supported.get("protectedDecisions", [])) != PROTECTED_DECISIONS:
        errors.append("supported V3 protected decisions are incomplete")
    for module in modules:
        tags = set(module.get("capabilityTags", []))
        allowed = set(module.get("allowedProtectedDecisions", []))
        expected_allowed = EXPECTED_DECISIONS.get(module.get("module"))
        if expected_allowed is None:
            errors.append(f"{module.get('module')}: no reviewed least-privilege decision policy")
        elif allowed != expected_allowed:
            errors.append(
                f"{module.get('module')}: protected decisions drifted; "
                f"expected {sorted(expected_allowed)}, got {sorted(allowed)}"
            )
        if tags & unsafe and allowed:
            errors.append(
                f"{module.get('module')}: unsafe capability is allowed to influence "
                f"{sorted(allowed)}"
            )
        if not (ROOT / str(module.get("path"))).is_file():
            errors.append(f"supported module path does not exist: {module.get('path')}")
        if module.get("deploymentStatus") != "not-deployed-not-authorized":
            errors.append(f"{module.get('module')}: module must not claim deployment")

    nodes = graph.get("nodes", [])
    node_by_id = {node.get("id"): node for node in nodes}
    if len(node_by_id) != len(nodes):
        errors.append("mission-boundary graph contains duplicate node IDs")
    surface_kinds = {node.get("kind") for node in nodes}
    if set(graph.get("unsafeCapabilityClasses", [])) != unsafe:
        errors.append("mission-boundary unsafe capability classes drifted")
    if set(graph.get("protectedDecisions", [])) != PROTECTED_DECISIONS:
        errors.append("mission-boundary protected decisions are incomplete")
    if set(graph.get("requiredSurfaceKinds", [])) != REQUIRED_SURFACE_KINDS:
        errors.append("mission-boundary required surface declaration is incomplete")
    missing_surfaces = sorted(REQUIRED_SURFACE_KINDS - surface_kinds)
    if missing_surfaces:
        errors.append(f"mission-boundary graph missing surfaces: {missing_surfaces}")
    sink_ids = {f"decision:{decision}" for decision in PROTECTED_DECISIONS}
    if not sink_ids <= set(node_by_id):
        errors.append("mission-boundary graph omits protected-decision sinks")
    unsafe_ids = {f"unsafe:{capability}" for capability in unsafe}
    if not unsafe_ids <= set(node_by_id):
        errors.append("mission-boundary graph omits unsafe capability sources")
    adjacency: dict[str, set[str]] = {}
    for edge in graph.get("edges", []):
        source, target = edge.get("from"), edge.get("to")
        if source not in node_by_id or target not in node_by_id:
            errors.append(f"mission-boundary edge has unknown endpoint: {source}->{target}")
            continue
        if not isinstance(edge.get("carriesDecisionInfluence"), bool):
            errors.append(f"mission-boundary edge lacks boolean influence: {source}->{target}")
        if edge.get("carriesDecisionInfluence") is True:
            adjacency.setdefault(source, set()).add(target)

    for node_id, node in node_by_id.items():
        if not node.get("unsafe"):
            continue
        seen = {node_id}
        work = [node_id]
        while work:
            current = work.pop()
            for target in adjacency.get(current, set()):
                if target not in seen:
                    seen.add(target)
                    work.append(target)
        reached = sorted(seen & sink_ids)
        if reached:
            errors.append(
                f"{node_id}: transitive unsafe influence reaches protected decisions {reached}"
            )
    required_actual_nodes = {
        "source:accountability-canonical", "surface:governance-calldata",
        "surface:mandate-identity-adapter", "surface:mandate-execution-tuple",
        "surface:factory-initcode", "surface:base-pilot-manifest",
        "retired:dispute-registry", "retired:task-escrow",
        "retired:mutual-aid", "retired:validation-registry",
    }
    missing_actual = sorted(required_actual_nodes - set(node_by_id))
    if missing_actual:
        errors.append(f"mission-boundary graph omits actual source/deployment surfaces: {missing_actual}")

    pilot = json.loads((ROOT / "pr8/pilot/base_v3_deployment_manifest.json").read_text())
    enabled = set(pilot.get("scope", {}).get("enabled", []))
    disabled = set(pilot.get("scope", {}).get("disabled", []))
    supported_names = {module.get("module") for module in modules}
    unsupported_enabled = sorted(enabled - supported_names)
    if unsupported_enabled:
        errors.append(f"Base pilot enables modules outside supported closure: {unsupported_enabled}")
    deployed = {row.get("contract") for row in pilot.get("deployments", [])}
    retired_names = {Path(path).stem for path in RETIRED_STRICT_PATHS}
    retired_deployed = sorted(deployed & retired_names)
    if retired_deployed:
        errors.append(f"Base pilot deploys retired modules: {retired_deployed}")
    retired_enabled = sorted(enabled & RETIRED_ACTIVATION_NAMES)
    if retired_enabled:
        errors.append(f"Base pilot enables retired modules: {retired_enabled}")
    missing_disabled = sorted(RETIRED_ACTIVATION_NAMES - disabled)
    if missing_disabled:
        errors.append(f"Base pilot does not explicitly disable retired modules: {missing_disabled}")

    sdk_supported_source = (
        ROOT / "repos/vaultfire-sdk/src/v3/supported.ts"
    ).read_text()
    sdk_source_paths = set(re.findall(r"sourcePath:\s*'([^']+)'", sdk_supported_source))
    unknown_sdk_paths = sorted(sdk_source_paths - set(module_paths))
    if unknown_sdk_paths:
        errors.append(f"SDK exposes modules outside supported closure: {unknown_sdk_paths}")
    for retired_name in RETIRED_ACTIVATION_NAMES:
        if re.search(rf"\b{re.escape(retired_name)}\b", sdk_supported_source):
            errors.append(f"SDK exposes retired module: {retired_name}")


    policy_path = ROOT / "ci/manifests/base-v3-governance-policy.json"
    policy = json.loads(policy_path.read_text())
    errors.extend(check_governance_policy(policy, supported, graph))
    canonical_policy = json.dumps(policy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
    policy_digest = hashlib.sha256(canonical_policy).hexdigest()
    governance_source = (ROOT / "v3-src/contracts/governance/MultisigGovernanceV3.sol").read_text()
    match = re.search(r'CANONICAL_POLICY_SHA256\s*=\s*hex"([0-9a-fA-F]{64})"', governance_source)
    if not match or match.group(1).lower() != policy_digest:
        errors.append("governance source does not expose the exact canonical selector-policy digest")
    return errors


def check(ledger: dict[str, Any], compare_inventory: bool = True) -> list[str]:
    errors: list[str] = check_safety_manifests()
    if ledger.get("schemaVersion") != "1.1.0":
        errors.append("ledger schemaVersion must be 1.1.0")
    items = ledger.get("items")
    if not isinstance(items, list):
        return ["ledger.items must be an array"]

    ids = [entry.get("id") for entry in items if isinstance(entry, dict)]
    duplicates = sorted(key for key, count in Counter(ids).items() if count > 1)
    if duplicates:
        errors.append(f"duplicate stable IDs: {duplicates[:20]}")

    for index, entry in enumerate(items):
        label = entry.get("id", f"items[{index}]") if isinstance(entry, dict) else f"items[{index}]"
        if not isinstance(entry, dict):
            errors.append(f"{label}: item must be an object")
            continue
        missing = sorted(REQUIRED_FIELDS - set(entry))
        if missing:
            errors.append(f"{label}: missing fields {missing}")
            continue
        disposition = entry["v3Disposition"]
        if disposition not in generate_ledger.DISPOSITIONS:
            errors.append(f"{label}: unsupported disposition {disposition!r}")
        if not isinstance(entry["requiredTests"], list) or not entry["requiredTests"]:
            errors.append(f"{label}: requiredTests must be non-empty")
        if entry["v3SupportStatus"] not in SUPPORT_STATUSES:
            errors.append(f"{label}: unsupported V3 support status")
        if not isinstance(entry["exactTargetModules"], list):
            errors.append(f"{label}: exactTargetModules must be an array")
        if disposition in COMPLETE_DISPOSITIONS and not entry["evidence"]:
            errors.append(f"{label}: completion disposition has no evidence")
        if disposition == "ported" and (
            not entry["v3Target"] or "TBD" in entry["v3Target"].upper()
        ):
            errors.append(f"{label}: ported item has no concrete V3 target")
        if entry["category"] == "v2-contract":
            manifest_records = {
                record["v2ContractIdentity"]: record
                for record in _load("v2-contract-dispositions.json")["records"]
            }
            decision = manifest_records.get(entry["v2Source"].get("contract"))
            if decision and any(
                entry[field] != decision[field]
                for field in (
                    "implementationObligation", "v3SupportStatus",
                    "evidenceStatus", "deploymentStatus",
                    "missionBoundaryRationale", "exactTargetModules",
                )
            ):
                errors.append(f"{label}: ledger disposition drifts from canonical manifest")

        for ev in entry["evidence"]:
            if not isinstance(ev, dict) or not ev.get("path") or not ev.get("sha256"):
                errors.append(f"{label}: malformed evidence record")
                continue
            path = ROOT / ev["path"]
            if not path.is_file():
                errors.append(f"{label}: evidence path does not exist: {ev['path']}")
            elif generate_ledger.digest(path) != ev["sha256"]:
                errors.append(f"{label}: evidence checksum drift: {ev['path']}")

        policy = entry["influencePolicy"]
        if not isinstance(policy, dict):
            errors.append(f"{label}: influencePolicy must be an object")
            continue
        signals = set(policy.get("signals", []))
        controls = set(policy.get("controls", []))
        bad_signals = signals & generate_ledger.FORBIDDEN_SIGNALS
        bad_controls = controls & generate_ledger.HIGH_IMPACT_CONTROLS
        if policy.get("controlAllowed") and bad_signals and bad_controls:
            errors.append(
                f"{label}: forbidden signals {sorted(bad_signals)} allowed to control "
                f"{sorted(bad_controls)}"
            )
        if bad_signals and bad_controls and disposition in {
            "ported", "compatibility-wrapper"
        }:
            errors.append(
                f"{label}: prohibited signal/control coupling cannot be activated "
                f"under disposition {disposition}"
            )
        if bad_signals and bad_controls and policy.get("decision") not in {
            "blocked-pending-decoupling", "prohibited", "retired"
        }:
            errors.append(f"{label}: prohibited coupling lacks a fail-closed decision")

    # Declared reconciliations must exactly agree.
    counts = Counter(entry["category"] for entry in items if isinstance(entry, dict))
    for name, reconciliation in ledger.get("reconciliation", {}).items():
        expected = reconciliation.get("expected")
        actual = reconciliation.get("actual")
        if expected != actual:
            errors.append(f"reconciliation {name}: expected {expected}, declared actual {actual}")
    if ledger.get("categoryCounts") != dict(sorted(counts.items())):
        errors.append("categoryCounts does not exactly match items")

    # Omission/drift test: rebuild expected inventory in memory and compare IDs.
    if compare_inventory:
        expected = generate_ledger.build()
        expected_ids = {entry["id"] for entry in expected["items"]}
        actual_ids = set(ids)
        omitted = sorted(expected_ids - actual_ids)
        extra = sorted(actual_ids - expected_ids)
        if omitted:
            errors.append(f"inventory items omitted: {len(omitted)}; first={omitted[:10]}")
        if extra:
            errors.append(f"unknown inventory items present: {len(extra)}; first={extra[:10]}")
        if ledger.get("canonicalSourceSha256") != expected.get("canonicalSourceSha256"):
            errors.append("canonical source checksum reconciliation drift")
        if ledger.get("reconciliation") != expected.get("reconciliation"):
            errors.append("canonical count reconciliation drift")

    return errors


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--ledger", type=Path, default=HERE / "ledger.json")
    parser.add_argument(
        "--no-inventory-compare", action="store_true",
        help="Only for isolated negative unit tests; CI must not use this.",
    )
    args = parser.parse_args()
    try:
        ledger = json.loads(args.ledger.read_text())
    except (OSError, json.JSONDecodeError) as exc:
        print(f"FAIL: cannot read ledger: {exc}", file=sys.stderr)
        return 1
    errors = check(ledger, compare_inventory=not args.no_inventory_compare)
    if errors:
        print(f"FAIL: {len(errors)} migration-ledger error(s)", file=sys.stderr)
        for error in errors:
            print(f"- {error}", file=sys.stderr)
        return 1
    print(f"PASS: {len(ledger['items'])} migration items validated")
    print(json.dumps(ledger["categoryCounts"], sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
