"""Ledger entry validation (§K9.7 / §K9.8 / §PE.3–§PE.4).""" from __future__ import annotations import re from typing import Any from tools.honesty.provenance import validate_provenance from tools.honesty.types import ( ACTOR_ROLES, AFF_POSTURES, AFF_VERDICTS, BV_VERDICTS, ENTRY_KINDS, FREEZE_VERDICTS, ISR_VERDICTS, VERIFICATION_ARTIFACT_TYPES, EntryValidationError, ) _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") def _require_mapping(value: Any, field: str) -> dict[str, Any]: if not isinstance(value, dict): raise EntryValidationError(2, f"{field} must be an object") return value def _validate_sha256(value: Any, field: str) -> str: if not isinstance(value, str) or not _SHA256_RE.match(value): raise EntryValidationError(2, f"{field} must be lowercase 64-char hex sha256") return value def validate_verification_artifacts(artifacts: Any) -> list[dict[str, Any]]: """Validate ``verification_evidence.artifacts`` per §PE.4.""" if not isinstance(artifacts, list) or not artifacts: raise EntryValidationError(24, "artifacts must be a non-empty list") normalized: list[dict[str, Any]] = [] for index, item in enumerate(artifacts): obj = _require_mapping(item, f"artifacts[{index}]") art_type = obj.get("type") if art_type not in VERIFICATION_ARTIFACT_TYPES: raise EntryValidationError( 2, f"artifacts[{index}].type must be test_output|deploy_health|screenshot", ) sha256 = _validate_sha256(obj.get("sha256"), f"artifacts[{index}].sha256") ref = obj.get("ref") if art_type in {"deploy_health", "screenshot"}: _require_non_empty_str(ref, f"artifacts[{index}].ref") elif ref is not None and not isinstance(ref, str): raise EntryValidationError(2, f"artifacts[{index}].ref must be a string when present") notes = obj.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, f"artifacts[{index}].notes must be a string when present") entry: dict[str, Any] = {"type": art_type, "sha256": sha256} if ref is not None: entry["ref"] = ref if notes is not None: entry["notes"] = notes normalized.append(entry) return normalized def find_matching_verification_evidence( entries: list[dict[str, Any]], *, phase_id: str, frozen_spec: str | None, ) -> dict[str, Any] | None: """Return the last matching pass entry for Mode B (§PE.6.1).""" matches: list[dict[str, Any]] = [] for entry in entries: if entry.get("kind") != "verification_evidence": continue if entry.get("actor_role") != "verifier": continue if entry.get("bv_verdict") != "pass": continue if entry.get("phase_id") != phase_id: continue if frozen_spec is not None and entry.get("frozen_spec") != frozen_spec: continue matches.append(entry) return matches[-1] if matches else None def find_matching_deploy_health( entries: list[dict[str, Any]], *, phase_id: str, frozen_spec: str | None, ) -> dict[str, Any] | None: """Return the last Mode C match: pass + ≥1 ``deploy_health`` artifact (§PD.3). Does not open network or treat ``ref`` as a URL. """ matches: list[dict[str, Any]] = [] for entry in entries: if entry.get("kind") != "verification_evidence": continue if entry.get("actor_role") != "verifier": continue if entry.get("bv_verdict") != "pass": continue if entry.get("phase_id") != phase_id: continue if frozen_spec is not None and entry.get("frozen_spec") != frozen_spec: continue artifacts = entry.get("artifacts") if not isinstance(artifacts, list): continue if not any( isinstance(item, dict) and item.get("type") == "deploy_health" for item in artifacts ): continue matches.append(entry) return matches[-1] if matches else None def find_matching_independent_second_review( entries: list[dict[str, Any]], *, phase_id: str, frozen_spec: str | None, producer_session: str | None, ) -> dict[str, Any] | None: """Return the last matching ISR pass entry for Mode D (§ISR.5.3). Does not open a network connection, call a model, or read IDE session ids. """ matches: list[dict[str, Any]] = [] for entry in entries: if entry.get("kind") != "independent_second_review": continue if entry.get("actor_role") != "verifier": continue if entry.get("isr_verdict") != "pass": continue if entry.get("phase_id") != phase_id: continue if frozen_spec is not None and entry.get("frozen_spec") != frozen_spec: continue actor_session = entry.get("actor_session_id") producer_id = entry.get("producer_session_id") if not isinstance(actor_session, str) or not isinstance(producer_id, str): continue if actor_session == producer_id: continue if producer_session is not None: if producer_id != producer_session: continue if actor_session == producer_session: continue matches.append(entry) return matches[-1] if matches else None def find_matching_freeze_review( entries: list[dict[str, Any]], *, phase_id: str, frozen_spec: str | None, artifact_digest: str | None, ) -> dict[str, Any] | None: """Return the last matching freeze_review pass entry (§FRV.6.3). Does not open a network connection, call a model, or read IDE session ids. """ matches: list[dict[str, Any]] = [] for entry in entries: if entry.get("kind") != "freeze_review": continue if entry.get("actor_role") != "verifier": continue if entry.get("gate") != "substantive": continue if entry.get("freeze_verdict") != "pass": continue if entry.get("phase_id") != phase_id: continue if frozen_spec is not None and entry.get("frozen_spec") != frozen_spec: continue if artifact_digest is not None and entry.get("artifact_digest") != artifact_digest: continue producer_id = entry.get("producer_session_id") actor_session = entry.get("actor_session_id") if producer_id is not None: if not isinstance(producer_id, str) or not isinstance(actor_session, str): continue if producer_id == actor_session: continue matches.append(entry) return matches[-1] if matches else None def _aff_phase_id_ok(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) def _aff_round_ok(value: Any) -> bool: return type(value) is int and value >= 1 def _aff_reviewer_model_ok(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) def _adversarial_freeze_eligible( entry: dict[str, Any], *, frozen_spec: str, artifact_digest: str, phase_id: str | None, producer_session: str | None, ) -> bool: """Return True when an AFF entry is eligible for the latest-verdict resolver (§AFF.5.5).""" if entry.get("kind") != "adversarial_freeze": return False verdict = entry.get("aff_verdict") if verdict not in AFF_VERDICTS: return False if entry.get("frozen_spec") != frozen_spec: return False if entry.get("artifact_digest") != artifact_digest: return False if not _aff_phase_id_ok(entry.get("phase_id")): return False if not _aff_round_ok(entry.get("round")): return False if phase_id is not None and entry.get("phase_id") != phase_id: return False if verdict == "skip": if entry.get("actor_role") != "owner": return False if "aff_posture" in entry or "producer_session_id" in entry: return False return True # pass | findings | blocked if entry.get("actor_role") != "verifier": return False if entry.get("aff_posture") != "attack": return False if not _aff_reviewer_model_ok(entry.get("reviewer_model")): return False actor_session = entry.get("actor_session_id") producer_id = entry.get("producer_session_id") if not isinstance(actor_session, str) or not actor_session.strip(): return False if not isinstance(producer_id, str) or not producer_id.strip(): return False if actor_session == producer_id: return False if producer_session is not None: if producer_id != producer_session: return False if actor_session == producer_session: return False return True def find_latest_adversarial_freeze_verdict( entries: list[dict[str, Any]], *, frozen_spec: str, artifact_digest: str, phase_id: str | None = None, producer_session: str | None = None, ) -> dict[str, Any] | None: """Return the last eligible AFF verdict across all four verdicts (§AFF.5.5). Does not open a network connection, call a model, or read IDE session ids. """ winner: dict[str, Any] | None = None for entry in entries: if _adversarial_freeze_eligible( entry, frozen_spec=frozen_spec, artifact_digest=artifact_digest, phase_id=phase_id, producer_session=producer_session, ): winner = entry return winner def find_matching_adversarial_freeze_pass( entries: list[dict[str, Any]], *, frozen_spec: str, artifact_digest: str, phase_id: str | None = None, producer_session: str | None = None, ) -> dict[str, Any] | None: """Return the latest eligible verdict only when it is ``pass``.""" winner = find_latest_adversarial_freeze_verdict( entries, frozen_spec=frozen_spec, artifact_digest=artifact_digest, phase_id=phase_id, producer_session=producer_session, ) if winner is not None and winner.get("aff_verdict") == "pass": return winner return None def find_matching_adversarial_freeze_skip( entries: list[dict[str, Any]], *, frozen_spec: str, artifact_digest: str, phase_id: str | None = None, ) -> dict[str, Any] | None: """Return the latest eligible verdict only when it is ``skip``.""" winner = find_latest_adversarial_freeze_verdict( entries, frozen_spec=frozen_spec, artifact_digest=artifact_digest, phase_id=phase_id, producer_session=None, ) if winner is not None and winner.get("aff_verdict") == "skip": return winner return None def _require_non_empty_str(value: Any, field: str) -> str: if not isinstance(value, str) or not value.strip(): raise EntryValidationError(2, f"{field} must be a non-empty string") return value def _require_exact_positive_int(value: Any, field: str) -> int: """Exact-type positive integer (rejects JSON/Python boolean).""" if type(value) is not int or value < 1: raise EntryValidationError(2, f"{field} must be an integer >= 1") return value def validate_append_body(*, kind: str, body: dict[str, Any]) -> dict[str, Any]: """Validate and normalize an append body before hashing.""" if kind not in ENTRY_KINDS: raise EntryValidationError(2, f"unknown entry kind: {kind}") if "entry_hash" in body or "prev_hash" in body: raise EntryValidationError(2, "client must not supply entry_hash or prev_hash") body_kind = body.get("kind") if body_kind is not None and body_kind != kind: raise EntryValidationError(2, "body kind must match --kind when present") merged = dict(body) merged["kind"] = kind if "v" in merged: version = merged["v"] if type(version) is not int or version != 1: raise EntryValidationError(2, "v must be integer 1") else: merged["v"] = 1 if "ts" in merged: ts = merged["ts"] if not isinstance(ts, str) or not ts.strip(): raise EntryValidationError(2, "ts must be a non-empty string when supplied") if kind == "genesis": if "actor_role" in merged or "actor_session_id" in merged: raise EntryValidationError(2, "genesis must not carry actor fields") for key in ( "assignment", "artifact_sha256", "passed", "evidence", "subject", "ruling", "bound_verdict_hash", "hook", "ok", "reason", "provenance", "phase_id", "frozen_spec", "round", "bv_verdict", "artifacts", "subject_sha256", "isr_verdict", "producer_session_id", "producer_agent_id", "verifier_agent_id", "bound_verification_evidence_hash", "gate", "freeze_verdict", "artifact_digest", "checklist_ids", "findings_count", "aff_verdict", "aff_posture", "bound_freeze_review_hash", "side_check_path", "producer_model", "reviewer_model", "notes", ): if key in merged: raise EntryValidationError(2, f"genesis must not carry {key}") return merged actor_role = merged.get("actor_role") if actor_role not in ACTOR_ROLES: raise EntryValidationError( 23 if kind in { "verdict", "verification_evidence", "independent_second_review", "freeze_review", "adversarial_freeze", } else 2, "invalid or missing actor_role", ) actor_session = merged.get("actor_session_id") _require_non_empty_str(actor_session, "actor_session_id") if kind == "task_assigned": if actor_role != "overseer": raise EntryValidationError(23, "task_assigned requires actor_role=overseer") _require_mapping(merged.get("assignment"), "assignment") elif kind == "verdict": if actor_role != "verifier": raise EntryValidationError(23, "verdict requires actor_role=verifier") _require_non_empty_str(merged.get("artifact_sha256"), "artifact_sha256") passed = merged.get("passed") if not isinstance(passed, bool): raise EntryValidationError(2, "passed must be a boolean") evidence = _require_mapping(merged.get("evidence"), "evidence") reexecuted = evidence.get("reexecuted") if not isinstance(reexecuted, list) or not reexecuted: raise EntryValidationError(24, "evidence.reexecuted must be a non-empty list") if not all(isinstance(item, str) for item in reexecuted): raise EntryValidationError(2, "evidence.reexecuted entries must be strings") elif kind == "dispute_opened": _require_non_empty_str(merged.get("subject"), "subject") elif kind == "overseer_ruling": if actor_role != "overseer": raise EntryValidationError(23, "overseer_ruling requires actor_role=overseer") _require_non_empty_str(merged.get("ruling"), "ruling") elif kind == "approval_recorded": if actor_role != "owner": raise EntryValidationError(23, "approval_recorded requires actor_role=owner") _require_non_empty_str(merged.get("artifact_sha256"), "artifact_sha256") _require_non_empty_str(merged.get("bound_verdict_hash"), "bound_verdict_hash") elif kind == "board_advance": _require_non_empty_str(merged.get("artifact_sha256"), "artifact_sha256") _require_non_empty_str(merged.get("bound_verdict_hash"), "bound_verdict_hash") elif kind == "hook_check": hook = merged.get("hook") if hook not in {"board_done", "handoff", "register"}: raise EntryValidationError(2, "hook_check.hook must be board_done|handoff|register") ok = merged.get("ok") if not isinstance(ok, bool): raise EntryValidationError(2, "ok must be a boolean") reason = merged.get("reason") if reason is not None and not isinstance(reason, str): raise EntryValidationError(2, "reason must be a string when present") elif kind == "verification_evidence": if actor_role != "verifier": raise EntryValidationError(23, "verification_evidence requires actor_role=verifier") _require_non_empty_str(merged.get("phase_id"), "phase_id") _require_non_empty_str(merged.get("frozen_spec"), "frozen_spec") _require_exact_positive_int(merged.get("round"), "round") bv_verdict = merged.get("bv_verdict") if bv_verdict not in BV_VERDICTS: raise EntryValidationError(2, "bv_verdict must be pass|findings|blocked") merged["artifacts"] = validate_verification_artifacts(merged.get("artifacts")) subject_sha256 = merged.get("subject_sha256") if subject_sha256 is not None: _validate_sha256(subject_sha256, "subject_sha256") notes = merged.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, "notes must be a string when present") elif kind == "independent_second_review": if actor_role != "verifier": raise EntryValidationError( 23, "independent_second_review requires actor_role=verifier" ) _require_non_empty_str(merged.get("phase_id"), "phase_id") _require_non_empty_str(merged.get("frozen_spec"), "frozen_spec") _require_exact_positive_int(merged.get("round"), "round") isr_verdict = merged.get("isr_verdict") if isr_verdict not in ISR_VERDICTS: raise EntryValidationError(2, "isr_verdict must be pass|findings|blocked") producer_session_id = _require_non_empty_str( merged.get("producer_session_id"), "producer_session_id" ) if actor_session == producer_session_id: raise EntryValidationError( 2, "actor_session_id must differ from producer_session_id" ) producer_agent_id = merged.get("producer_agent_id") verifier_agent_id = merged.get("verifier_agent_id") if producer_agent_id is not None and not isinstance(producer_agent_id, str): raise EntryValidationError(2, "producer_agent_id must be a string when present") if verifier_agent_id is not None and not isinstance(verifier_agent_id, str): raise EntryValidationError(2, "verifier_agent_id must be a string when present") if ( isinstance(producer_agent_id, str) and isinstance(verifier_agent_id, str) and producer_agent_id == verifier_agent_id ): raise EntryValidationError( 2, "producer_agent_id must differ from verifier_agent_id when both present" ) bound_hash = merged.get("bound_verification_evidence_hash") if bound_hash is not None: _require_non_empty_str(bound_hash, "bound_verification_evidence_hash") notes = merged.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, "notes must be a string when present") elif kind == "freeze_review": if actor_role != "verifier": raise EntryValidationError(23, "freeze_review requires actor_role=verifier") _require_non_empty_str(merged.get("phase_id"), "phase_id") _require_non_empty_str(merged.get("frozen_spec"), "frozen_spec") _require_exact_positive_int(merged.get("round"), "round") if merged.get("gate") != "substantive": raise EntryValidationError(2, "gate must be substantive") freeze_verdict = merged.get("freeze_verdict") if freeze_verdict not in FREEZE_VERDICTS: raise EntryValidationError(2, "freeze_verdict must be pass|findings|blocked") digest = merged.get("artifact_digest") if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): raise EntryValidationError(2, "artifact_digest must be sha256: + 64 lowercase hex") _require_non_empty_str(merged.get("reviewer_model"), "reviewer_model") checklist_ids = merged.get("checklist_ids") if checklist_ids is not None: if ( not isinstance(checklist_ids, list) or not checklist_ids or not all(isinstance(item, str) and item.strip() for item in checklist_ids) ): raise EntryValidationError(2, "checklist_ids must be a non-empty list of non-empty strings") findings_count = merged.get("findings_count") if findings_count is not None: if type(findings_count) is not int or findings_count < 0: raise EntryValidationError(2, "findings_count must be an integer >= 0") producer_session_id = merged.get("producer_session_id") if producer_session_id is not None: producer_session_id = _require_non_empty_str(producer_session_id, "producer_session_id") if actor_session == producer_session_id: raise EntryValidationError( 2, "actor_session_id must differ from producer_session_id" ) notes = merged.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, "notes must be a string when present") elif kind == "adversarial_freeze": aff_verdict = merged.get("aff_verdict") if aff_verdict not in AFF_VERDICTS: raise EntryValidationError(2, "aff_verdict must be pass|findings|blocked|skip") _require_non_empty_str(merged.get("phase_id"), "phase_id") _require_non_empty_str(merged.get("frozen_spec"), "frozen_spec") _require_exact_positive_int(merged.get("round"), "round") digest = merged.get("artifact_digest") if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): raise EntryValidationError(2, "artifact_digest must be sha256: + 64 lowercase hex") if aff_verdict == "skip": if actor_role != "owner": raise EntryValidationError(23, "adversarial_freeze skip requires actor_role=owner") if "aff_posture" in merged: raise EntryValidationError(2, "skip must not carry aff_posture") if "producer_session_id" in merged: raise EntryValidationError(2, "skip must not carry producer_session_id") notes = merged.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, "notes must be a string when present") else: if actor_role != "verifier": raise EntryValidationError( 23, "adversarial_freeze pass/findings/blocked requires actor_role=verifier" ) aff_posture = merged.get("aff_posture") if aff_posture not in AFF_POSTURES: raise EntryValidationError(2, "aff_posture must be attack") producer_session_id = _require_non_empty_str( merged.get("producer_session_id"), "producer_session_id" ) if actor_session == producer_session_id: raise EntryValidationError( 2, "actor_session_id must differ from producer_session_id" ) _require_non_empty_str(merged.get("reviewer_model"), "reviewer_model") producer_model = merged.get("producer_model") if producer_model is not None and not isinstance(producer_model, str): raise EntryValidationError(2, "producer_model must be a string when present") producer_agent_id = merged.get("producer_agent_id") verifier_agent_id = merged.get("verifier_agent_id") if producer_agent_id is not None and not isinstance(producer_agent_id, str): raise EntryValidationError(2, "producer_agent_id must be a string when present") if verifier_agent_id is not None and not isinstance(verifier_agent_id, str): raise EntryValidationError(2, "verifier_agent_id must be a string when present") if ( isinstance(producer_agent_id, str) and isinstance(verifier_agent_id, str) and producer_agent_id == verifier_agent_id ): raise EntryValidationError( 2, "producer_agent_id must differ from verifier_agent_id when both present" ) bound_hash = merged.get("bound_freeze_review_hash") if bound_hash is not None: _require_non_empty_str(bound_hash, "bound_freeze_review_hash") side_check = merged.get("side_check_path") if side_check is not None: _require_non_empty_str(side_check, "side_check_path") notes = merged.get("notes") if notes is not None and not isinstance(notes, str): raise EntryValidationError(2, "notes must be a string when present") if "provenance" in merged: merged["provenance"] = validate_provenance(merged["provenance"]) return merged def find_passing_verdict( entries: list[dict[str, Any]], *, artifact_sha256: str, bound_verdict_hash: str, ) -> bool: """Return True when a passing verifier verdict matches the bound hash.""" for entry in entries: if entry.get("kind") != "verdict": continue if entry.get("actor_role") != "verifier": continue if entry.get("passed") is not True: continue if entry.get("artifact_sha256") != artifact_sha256: continue if entry.get("entry_hash") == bound_verdict_hash: return True return False