From dec47d135078098f6bac22d5fa4ca8e9619d439f Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 12:16:28 +0200 Subject: [PATCH 01/12] feat(maintenance): add immutable live-proof receipts Add the fixed read-only, candidate, and existing-apply receipt protocol for the reindex campaign. Receipts bind current archive, semantic, candidate, and input evidence while consumer seams reject stale or failed proof results. The maintenance command is deliberately evidence-only: it accepts only static proof ids and writes a new receipt outside the archive. Ref polylogue-x97cf. Co-Authored-By: Codex --- docs/maintenance.md | 14 + .../cli/commands/maintenance/__init__.py | 6 + .../cli/commands/maintenance/_live_proof.py | 55 ++ polylogue/maintenance/live_proof.py | 605 ++++++++++++++++++ .../cli/test_maintenance_live_proof_cli.py | 62 ++ tests/unit/maintenance/test_live_proof.py | 160 +++++ 6 files changed, 902 insertions(+) create mode 100644 polylogue/cli/commands/maintenance/_live_proof.py create mode 100644 polylogue/maintenance/live_proof.py create mode 100644 tests/unit/cli/test_maintenance_live_proof_cli.py create mode 100644 tests/unit/maintenance/test_live_proof.py diff --git a/docs/maintenance.md b/docs/maintenance.md index ff7e9dbd7e..37d0716f49 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -480,6 +480,20 @@ Exit code is non-zero when any check reports `error` (or, with `--strict`, temporarily busy under a concurrent rebuild — never aborts the rest; each check independently reports its own outcome. +### `polylogue ops maintenance live-proof` — immutable campaign evidence + +Read-only evidence collection for the reindex campaign. The command accepts a fixed registered proof id and writes one new self-hashed JSON receipt outside the archive. It has no command-execution option and cannot apply a mutation, control the daemon, migrate a tier, or promote a generation. + +```bash +polylogue ops maintenance live-proof \ + --proof-id archive-verification \ + --output /path/to/new/live-proof.json +``` + +The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt`; every other combination is rejected. + +Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, observed schema versions, parser and lowering fingerprints, candidate generation and index hash where applicable, typed residues, and input receipt digests. Private local paths are represented only as a SHA-256 digest plus basename. The collector validates those bindings again when an aggregate, candidate, or final-proof consumer reads the receipt, so a changed source snapshot, candidate index, schema, or semantic fingerprint makes the receipt stale. + ### `--operation-id` and `--resume`: worked example Replay execution writes a small JSON state file under diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index 6c29815aef..6513730dbf 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -189,6 +189,12 @@ "verify_archive_command", "Prove the archive is coherent after a rebuild, restore, or promotion. Read-only.", ), + ( + "live-proof", + "_live_proof", + "live_proof_command", + "Collect one fixed, immutable live-proof receipt. Read-only.", + ), ( "cursor-authority-reconcile", "_cursor_authority", diff --git a/polylogue/cli/commands/maintenance/_live_proof.py b/polylogue/cli/commands/maintenance/_live_proof.py new file mode 100644 index 0000000000..bfe1fa800d --- /dev/null +++ b/polylogue/cli/commands/maintenance/_live_proof.py @@ -0,0 +1,55 @@ +"""``maintenance live-proof``: collect one fixed, immutable evidence receipt.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import click + +from polylogue.paths import archive_root + + +@click.command("live-proof") +@click.option("--proof-id", required=True, help="One registered live-proof id.") +@click.option("--candidate-generation", type=str, help="Inactive generation id for the candidate proof route only.") +@click.option( + "--apply-receipt", + type=click.Path(path_type=Path, file_okay=True, dir_okay=False, readable=True), + help="Existing immutable apply receipt for the existing-apply route only.", +) +@click.option( + "--output", + type=click.Path(path_type=Path, file_okay=True, dir_okay=False, writable=True), + required=True, + help="New receipt path outside the archive. Existing files are refused.", +) +def live_proof_command( + proof_id: str, + candidate_generation: str | None, + apply_receipt: Path | None, + output: Path, +) -> None: + """Collect one registered proof without mutating archive or daemon state.""" + + from polylogue.maintenance.live_proof import LiveProofError, collect_live_proof, write_live_proof_receipt + + root = archive_root().resolve() + target = output.expanduser().resolve() + try: + target.relative_to(root) + except ValueError: + pass + else: + raise click.BadParameter("receipt output must be outside the archive root", param_hint="--output") + try: + receipt = collect_live_proof( + proof_id, + root, + candidate_generation_id=candidate_generation, + apply_receipt_path=apply_receipt, + ) + write_live_proof_receipt(target, receipt) + except LiveProofError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(json.dumps(receipt.to_document(), indent=2, sort_keys=True)) diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py new file mode 100644 index 0000000000..07da4142ea --- /dev/null +++ b/polylogue/maintenance/live_proof.py @@ -0,0 +1,605 @@ +"""Static, read-only live-proof receipt protocol for the reindex campaign. + +This module deliberately collects evidence only. Its registry has no plugin +or runtime-registration seam, and its CLI adapter accepts neither commands nor +callables. A future campaign consumer can therefore validate a receipt without +turning the proof protocol into an executor, scheduler, or mutation authority. +""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Final, Literal, cast + +from polylogue.core.hashing import hash_file, hash_payload, hash_text +from polylogue.core.json import JSONDocument, is_json_document, require_json_document + +LIVE_PROOF_RECEIPT_SCHEMA: Final = "polylogue.live-proof-receipt.v1" +EXISTING_APPLY_RECEIPT_SCHEMA: Final = "polylogue.apply-receipt.v1" +LIVE_PROOF_REGISTRY_VERSION: Final = 1 + +_SHA256_RE: Final = re.compile(r"[0-9a-f]{64}") +_CODE_SHA_RE: Final = re.compile(r"[0-9a-f]{40,64}") +_GENERATION_ID_RE: Final = re.compile(r"gen-[A-Za-z0-9][A-Za-z0-9._-]{0,127}") + + +class LiveProofError(ValueError): + """A live-proof request, receipt, or current binding is invalid.""" + + +class LiveProofMode(StrEnum): + READ_ONLY = "read_only" + CANDIDATE = "candidate" + EXISTING_APPLY_RECEIPT = "existing_apply_receipt" + + +class LiveProofResidueKind(StrEnum): + BLOCKED = "blocked" + NOT_APPLICABLE = "not_applicable" + CHECK_FAILED = "check_failed" + UNVERIFIED = "unverified" + + +class LiveProofStatus(StrEnum): + PASSED = "passed" + FAILED = "failed" + BLOCKED = "blocked" + NOT_APPLICABLE = "not_applicable" + + +class LiveProofId(StrEnum): + ARCHIVE_VERIFICATION = "archive-verification" + CANDIDATE_ARCHIVE_VERIFICATION = "candidate-archive-verification" + EXISTING_APPLY_RECEIPT = "existing-apply-receipt" + + +@dataclass(frozen=True, slots=True) +class LiveProofResidue: + """One closed-vocabulary residual recorded instead of silently omitting it.""" + + kind: LiveProofResidueKind + code: str + + def to_document(self) -> JSONDocument: + return {"kind": self.kind.value, "code": self.code} + + +@dataclass(frozen=True, slots=True) +class PrivatePathReference: + """A private local path represented only by its basename and opaque digest.""" + + basename: str + sha256: str + + @classmethod + def capture(cls, path: Path) -> PrivatePathReference: + resolved = path.expanduser().resolve() + return cls(basename=resolved.name, sha256=hash_text(str(resolved))) + + def to_document(self) -> JSONDocument: + return {"basename": self.basename, "sha256": self.sha256} + + +@dataclass(frozen=True, slots=True) +class LiveProofSpec: + """One compile-time proof route. + + ``producer`` is intentionally a symbolic key rather than a callable. The + collector recognizes only the fixed literal producer keys below, so command + input can never supply executable behavior. + """ + + proof_id: LiveProofId + bead_id: str + mode: LiveProofMode + producer: Literal["archive_verification", "existing_apply_receipt"] + archive_checks: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class LiveProofBindings: + code_sha: str + archive_identity_digest: str + source_snapshot: str + schema_versions: tuple[tuple[str, int], ...] + parser_fingerprints: tuple[tuple[str, str], ...] + lowering_fingerprint: str + candidate_generation_id: str | None + candidate_index_sha256: str | None + private_paths: tuple[tuple[str, PrivatePathReference], ...] + + def to_document(self) -> JSONDocument: + return { + "code_sha": self.code_sha, + "archive_identity_digest": self.archive_identity_digest, + "source_snapshot": self.source_snapshot, + "schema_versions": dict(self.schema_versions), + "parser_fingerprints": dict(self.parser_fingerprints), + "lowering_fingerprint": self.lowering_fingerprint, + "candidate_generation_id": self.candidate_generation_id, + "candidate_index_sha256": self.candidate_index_sha256, + "private_paths": {name: ref.to_document() for name, ref in self.private_paths}, + } + + +@dataclass(frozen=True, slots=True) +class LiveProofReceipt: + proof_id: LiveProofId + bead_id: str + mode: LiveProofMode + registry_version: int + bindings: LiveProofBindings + result: JSONDocument + residues: tuple[LiveProofResidue, ...] + input_receipt_digests: tuple[str, ...] + + def payload(self) -> JSONDocument: + return { + "receipt_schema": LIVE_PROOF_RECEIPT_SCHEMA, + "proof_id": self.proof_id.value, + "bead_id": self.bead_id, + "mode": self.mode.value, + "registry_version": self.registry_version, + "bindings": self.bindings.to_document(), + "result": self.result, + "residues": [residue.to_document() for residue in self.residues], + "input_receipt_digests": list(self.input_receipt_digests), + } + + @property + def receipt_sha256(self) -> str: + return hash_payload(self.payload()) + + def to_document(self) -> JSONDocument: + return {**self.payload(), "receipt_sha256": self.receipt_sha256} + + +LIVE_PROOF_SPECS: Final[tuple[LiveProofSpec, ...]] = ( + LiveProofSpec( + proof_id=LiveProofId.ARCHIVE_VERIFICATION, + bead_id="polylogue-x97cf", + mode=LiveProofMode.READ_ONLY, + producer="archive_verification", + archive_checks=("tier-schema", "counts-summary"), + ), + LiveProofSpec( + proof_id=LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION, + bead_id="polylogue-x97cf", + mode=LiveProofMode.CANDIDATE, + producer="archive_verification", + archive_checks=("corpus-absences",), + ), + LiveProofSpec( + proof_id=LiveProofId.EXISTING_APPLY_RECEIPT, + bead_id="polylogue-x97cf", + mode=LiveProofMode.EXISTING_APPLY_RECEIPT, + producer="existing_apply_receipt", + ), +) + + +def validate_live_proof_registry(specs: Sequence[LiveProofSpec] = LIVE_PROOF_SPECS) -> None: + """Require the complete fixed protocol registry and no executable seam.""" + + from polylogue.maintenance.archive_verification import ARCHIVE_VERIFICATION_CHECKS + + expected = set(LiveProofId) + actual = {spec.proof_id for spec in specs} + if actual != expected or len(specs) != len(expected): + raise LiveProofError("live-proof registry must contain every fixed proof id exactly once") + for spec in specs: + if not spec.bead_id.startswith("polylogue-"): + raise LiveProofError("live-proof spec has an invalid bead id") + if spec.mode is LiveProofMode.EXISTING_APPLY_RECEIPT: + if spec.producer != "existing_apply_receipt" or spec.archive_checks: + raise LiveProofError("existing-apply proof spec may only validate an input receipt") + elif spec.producer != "archive_verification" or not spec.archive_checks: + raise LiveProofError("read-only and candidate proof specs require registered archive checks") + for check_name in spec.archive_checks: + check = next((candidate for candidate in ARCHIVE_VERIFICATION_CHECKS if candidate.name == check_name), None) + if check is None: + raise LiveProofError("live-proof spec references an unknown archive verification check") + if spec.mode is LiveProofMode.CANDIDATE and check.candidate_run is None: + raise LiveProofError("candidate live-proof spec requires candidate-capable archive checks") + + +def live_proof_spec(proof_id: str) -> LiveProofSpec: + try: + parsed = LiveProofId(proof_id) + except ValueError as exc: + raise LiveProofError("unknown live-proof id") from exc + for spec in LIVE_PROOF_SPECS: + if spec.proof_id is parsed: + return spec + raise LiveProofError("live-proof registry is incomplete") + + +def _code_sha() -> str: + configured = os.environ.get("POLYLOGUE_CODE_SHA", "").strip().lower() + if configured: + if not _CODE_SHA_RE.fullmatch(configured): + raise LiveProofError("POLYLOGUE_CODE_SHA must be an exact git commit SHA") + return configured + repository = Path(__file__).resolve().parents[2] + completed = subprocess.run( + ("git", "-C", str(repository), "rev-parse", "--verify", "HEAD"), + check=False, + capture_output=True, + text=True, + ) + sha = completed.stdout.strip().lower() + if completed.returncode != 0 or not _CODE_SHA_RE.fullmatch(sha): + raise LiveProofError("exact code SHA is unavailable") + return sha + + +def _schema_versions(root: Path, *, candidate_index: Path | None) -> tuple[tuple[str, int], ...]: + paths = { + "source": root / "source.db", + "index": candidate_index or root / "index.db", + "embeddings": root / "embeddings.db", + "user": root / "user.db", + } + versions: list[tuple[str, int]] = [] + for name, path in sorted(paths.items()): + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + try: + version = int(connection.execute("PRAGMA user_version").fetchone()[0]) + finally: + connection.close() + except sqlite3.Error as exc: + raise LiveProofError("live-proof schema binding is unavailable") from exc + versions.append((name, version)) + return tuple(versions) + + +def _candidate_index(root: Path, generation_id: str) -> Path: + if not _GENERATION_ID_RE.fullmatch(generation_id): + raise LiveProofError("candidate generation id is invalid") + generation_root = root / ".index-generations" / generation_id + metadata_path = generation_root / "generation.json" + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LiveProofError("candidate generation metadata is unavailable") from exc + if not isinstance(metadata, dict): + raise LiveProofError("candidate generation metadata is invalid") + index_path = generation_root / "index.db" + if ( + metadata.get("generation_id") != generation_id + or metadata.get("state") != "inactive" + or metadata.get("archive_root") != str(root) + or metadata.get("index_path") != str(index_path) + or not index_path.is_file() + ): + raise LiveProofError("candidate generation binding is stale or invalid") + return index_path + + +def capture_live_proof_bindings(archive_root: Path, *, candidate_generation_id: str | None = None) -> LiveProofBindings: + """Capture every current, read-only binding required by a receipt.""" + + from polylogue.maintenance.schema_inference_gate import rebuild_source_revision_snapshot + from polylogue.sources.origin_specs import lowering_fingerprint, parser_fingerprint_for_origin + from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation + + root = Path(archive_root).expanduser().resolve() + candidate_index = _candidate_index(root, candidate_generation_id) if candidate_generation_id is not None else None + try: + location = ArchiveLocation.resolve(root) + identity = ArchiveIdentity.resolve_location( + location, + generation_owner=None, + generation_state="inactive" if candidate_index is not None else "active", + ) + with sqlite3.connect(f"file:{root / 'source.db'}?mode=ro", uri=True) as source: + origins = sorted(str(row[0]) for row in source.execute("SELECT DISTINCT origin FROM raw_sessions")) + except (OSError, RuntimeError, sqlite3.Error, ValueError) as exc: + raise LiveProofError("live-proof archive binding is unavailable") from exc + try: + parser_fingerprints = tuple((origin, parser_fingerprint_for_origin(origin)) for origin in origins) + source_snapshot = rebuild_source_revision_snapshot(root) + lowering = lowering_fingerprint() + except (OSError, RuntimeError, ValueError) as exc: + raise LiveProofError("live-proof semantic binding is unavailable") from exc + private_paths: list[tuple[str, PrivatePathReference]] = [("archive_root", PrivatePathReference.capture(root))] + if candidate_index is not None: + private_paths.append(("candidate_index", PrivatePathReference.capture(candidate_index))) + return LiveProofBindings( + code_sha=_code_sha(), + archive_identity_digest=identity.authority_identity_digest, + source_snapshot=source_snapshot, + schema_versions=_schema_versions(root, candidate_index=candidate_index), + parser_fingerprints=parser_fingerprints, + lowering_fingerprint=lowering, + candidate_generation_id=candidate_generation_id, + candidate_index_sha256=hash_file(candidate_index) if candidate_index is not None else None, + private_paths=tuple(private_paths), + ) + + +def _archive_verification_result( + spec: LiveProofSpec, archive_root: Path, *, candidate_generation_id: str | None +) -> tuple[JSONDocument, tuple[LiveProofResidue, ...]]: + from polylogue.maintenance.archive_verification import verify_archive + + candidate_index = ( + _candidate_index(archive_root, candidate_generation_id) if candidate_generation_id is not None else None + ) + report = verify_archive(archive_root, checks=spec.archive_checks, index_path_override=candidate_index) + statuses: JSONDocument = {check.name: check.status.value for check in report.checks} + residues = tuple( + LiveProofResidue(LiveProofResidueKind.CHECK_FAILED, check.name) + for check in report.checks + if check.status.value in {"error", "warning"} + ) + archive_verification: JSONDocument = {"checks": statuses, "blocking": report.blocking} + result: JSONDocument = { + "status": LiveProofStatus.FAILED.value if report.blocking else LiveProofStatus.PASSED.value, + "archive_verification": archive_verification, + } + return result, residues + + +def _validate_private_path_references(value: object) -> None: + if not isinstance(value, Mapping): + raise LiveProofError("input receipt private paths are malformed") + for reference in value.values(): + if not isinstance(reference, Mapping): + raise LiveProofError("input receipt private paths are malformed") + basename = reference.get("basename") + digest = reference.get("sha256") + if ( + not isinstance(basename, str) + or Path(basename).name != basename + or not isinstance(digest, str) + or not _SHA256_RE.fullmatch(digest) + ): + raise LiveProofError("input receipt private paths are malformed") + + +def _validated_existing_apply_receipt(path: Path, bindings: LiveProofBindings) -> tuple[JSONDocument, str]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LiveProofError("existing apply receipt is unavailable") from exc + if not is_json_document(document): + raise LiveProofError("existing apply receipt is malformed") + payload = dict(document) + digest = payload.pop("receipt_sha256", None) + if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest) or hash_payload(payload) != digest: + raise LiveProofError("existing apply receipt self-hash is invalid") + if payload.get("receipt_schema") != EXISTING_APPLY_RECEIPT_SCHEMA: + raise LiveProofError("existing apply receipt schema is not accepted") + if not isinstance(payload.get("operation_id"), str) or not payload["operation_id"]: + raise LiveProofError("existing apply receipt operation binding is invalid") + receipt_bindings = payload.get("bindings") + if not isinstance(receipt_bindings, Mapping) or receipt_bindings != bindings.to_document(): + raise LiveProofError("existing apply receipt bindings are stale or mismatched") + if not is_json_document(payload.get("result")): + raise LiveProofError("existing apply receipt result is malformed") + _validate_private_path_references(receipt_bindings.get("private_paths")) + return require_json_document(payload["result"], context="existing apply receipt result"), digest + + +def collect_live_proof( + proof_id: str, + archive_root: Path, + *, + candidate_generation_id: str | None = None, + apply_receipt_path: Path | None = None, +) -> LiveProofReceipt: + """Collect exactly one fixed proof route without operating on the archive.""" + + validate_live_proof_registry() + spec = live_proof_spec(proof_id) + if spec.mode is LiveProofMode.CANDIDATE: + if candidate_generation_id is None or apply_receipt_path is not None: + raise LiveProofError("candidate proof requires only an inactive candidate generation id") + elif spec.mode is LiveProofMode.EXISTING_APPLY_RECEIPT: + if apply_receipt_path is None or candidate_generation_id is not None: + raise LiveProofError("existing-apply proof requires only an existing apply receipt") + elif candidate_generation_id is not None or apply_receipt_path is not None: + raise LiveProofError("read-only proof accepts no candidate or apply receipt input") + + bindings = capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) + if spec.producer == "archive_verification": + result, residues = _archive_verification_result( + spec, archive_root, candidate_generation_id=candidate_generation_id + ) + input_digests: tuple[str, ...] = () + else: + assert apply_receipt_path is not None + apply_result, digest = _validated_existing_apply_receipt(apply_receipt_path, bindings) + result = {"status": LiveProofStatus.PASSED.value, "apply_receipt": apply_result} + input_digests = (digest,) + residues = () + return LiveProofReceipt( + proof_id=spec.proof_id, + bead_id=spec.bead_id, + mode=spec.mode, + registry_version=LIVE_PROOF_REGISTRY_VERSION, + bindings=bindings, + result=result, + residues=residues, + input_receipt_digests=input_digests, + ) + + +def validate_live_proof_receipt( + document: object, + archive_root: Path, + *, + candidate_generation_id: str | None = None, +) -> LiveProofReceipt: + """Validate a self-hashed proof receipt against present archive bindings.""" + + if not is_json_document(document): + raise LiveProofError("live-proof receipt is malformed") + payload = dict(document) + digest = payload.pop("receipt_sha256", None) + if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest) or hash_payload(payload) != digest: + raise LiveProofError("live-proof receipt self-hash is invalid") + try: + proof_id = LiveProofId(cast(str, payload["proof_id"])) + mode = LiveProofMode(cast(str, payload["mode"])) + except (KeyError, TypeError, ValueError) as exc: + raise LiveProofError("live-proof receipt route is invalid") from exc + spec = live_proof_spec(proof_id.value) + if payload.get("receipt_schema") != LIVE_PROOF_RECEIPT_SCHEMA or payload.get("bead_id") != spec.bead_id: + raise LiveProofError("live-proof receipt protocol identity is invalid") + if payload.get("registry_version") != LIVE_PROOF_REGISTRY_VERSION or mode is not spec.mode: + raise LiveProofError("live-proof receipt registry binding is stale") + if candidate_generation_id is not None and mode is not LiveProofMode.CANDIDATE: + raise LiveProofError("candidate validation requires a candidate proof receipt") + receipt_bindings = payload.get("bindings") + if not isinstance(receipt_bindings, Mapping): + raise LiveProofError("live-proof receipt bindings are malformed") + if mode is LiveProofMode.CANDIDATE: + recorded_candidate = receipt_bindings.get("candidate_generation_id") + if not isinstance(recorded_candidate, str): + raise LiveProofError("candidate proof receipt has no candidate binding") + if candidate_generation_id is None: + candidate_generation_id = recorded_candidate + elif candidate_generation_id != recorded_candidate: + raise LiveProofError("candidate proof receipt targets a different generation") + expected = capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) + if payload.get("bindings") != expected.to_document(): + raise LiveProofError("live-proof receipt bindings are stale or mismatched") + result = payload.get("result") + residues = payload.get("residues") + input_digests = payload.get("input_receipt_digests") + if not is_json_document(result) or not isinstance(residues, list) or not isinstance(input_digests, list): + raise LiveProofError("live-proof receipt evidence is malformed") + try: + LiveProofStatus(cast(str, result["status"])) + except (KeyError, TypeError, ValueError) as exc: + raise LiveProofError("live-proof receipt result status is malformed") from exc + parsed_residues: list[LiveProofResidue] = [] + for residue in residues: + if not isinstance(residue, Mapping): + raise LiveProofError("live-proof receipt residues are malformed") + try: + parsed_residues.append( + LiveProofResidue(LiveProofResidueKind(cast(str, residue["kind"])), cast(str, residue["code"])) + ) + except (KeyError, TypeError, ValueError) as exc: + raise LiveProofError("live-proof receipt residues are malformed") from exc + if any(not isinstance(value, str) or not _SHA256_RE.fullmatch(value) for value in input_digests): + raise LiveProofError("live-proof receipt input digests are malformed") + return LiveProofReceipt( + proof_id=proof_id, + bead_id=spec.bead_id, + mode=mode, + registry_version=LIVE_PROOF_REGISTRY_VERSION, + bindings=expected, + result=result, + residues=tuple(parsed_residues), + input_receipt_digests=tuple(cast(list[str], input_digests)), + ) + + +def _require_acceptable_result(receipt: LiveProofReceipt) -> None: + """Reject failed proof evidence at every aggregate boundary.""" + + try: + status = LiveProofStatus(cast(str, receipt.result["status"])) + except (KeyError, TypeError, ValueError) as exc: # validated above; keeps this boundary total. + raise LiveProofError("live-proof receipt result status is malformed") from exc + if status is LiveProofStatus.PASSED: + return + if status is LiveProofStatus.NOT_APPLICABLE and any( + residue.kind is LiveProofResidueKind.NOT_APPLICABLE for residue in receipt.residues + ): + return + raise LiveProofError("live-proof receipt result is not acceptable to an aggregate") + + +def validate_live_operation_aggregate(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: + """Consumer seam for the live-operation aggregate, without scheduling work.""" + + validated = tuple(validate_live_proof_receipt(receipt, archive_root) for receipt in receipts) + if not validated: + raise LiveProofError("live-operation aggregate requires at least one proof receipt") + for receipt in validated: + _require_acceptable_result(receipt) + return validated + + +def validate_candidate_proof_receipts( + receipts: Sequence[object], archive_root: Path, *, candidate_generation_id: str +) -> tuple[LiveProofReceipt, ...]: + """Consumer seam for candidate acceptance, restricted to one inactive generation.""" + + validated = tuple( + validate_live_proof_receipt(receipt, archive_root, candidate_generation_id=candidate_generation_id) + for receipt in receipts + ) + if not validated: + raise LiveProofError("candidate proof consumer requires at least one proof receipt") + for receipt in validated: + _require_acceptable_result(receipt) + return validated + + +def validate_final_proof_receipts(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: + """Consumer seam for final-proof aggregation, without emitting a terminal proof.""" + + return validate_live_operation_aggregate(receipts, archive_root) + + +def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: + """Write a receipt once, outside the archive, with exclusive creation.""" + + target = Path(path).expanduser().resolve() + target.parent.mkdir(parents=True, exist_ok=True) + encoded = ( + json.dumps(receipt.to_document(), sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n" + ).encode("utf-8") + try: + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as exc: + raise LiveProofError("live-proof receipt output already exists") from exc + try: + with os.fdopen(descriptor, "wb", closefd=False) as stream: + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + finally: + os.close(descriptor) + + +validate_live_proof_registry() + +__all__ = [ + "EXISTING_APPLY_RECEIPT_SCHEMA", + "LIVE_PROOF_RECEIPT_SCHEMA", + "LIVE_PROOF_REGISTRY_VERSION", + "LIVE_PROOF_SPECS", + "LiveProofBindings", + "LiveProofError", + "LiveProofId", + "LiveProofMode", + "LiveProofReceipt", + "LiveProofResidue", + "LiveProofResidueKind", + "LiveProofSpec", + "capture_live_proof_bindings", + "collect_live_proof", + "live_proof_spec", + "validate_candidate_proof_receipts", + "validate_final_proof_receipts", + "validate_live_operation_aggregate", + "validate_live_proof_receipt", + "validate_live_proof_registry", + "write_live_proof_receipt", +] diff --git a/tests/unit/cli/test_maintenance_live_proof_cli.py b/tests/unit/cli/test_maintenance_live_proof_cli.py new file mode 100644 index 0000000000..bd2315bc66 --- /dev/null +++ b/tests/unit/cli/test_maintenance_live_proof_cli.py @@ -0,0 +1,62 @@ +"""Real Click dispatch tests for the fixed maintenance live-proof command.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from polylogue.cli.click_app import cli + + +def test_live_proof_cli_dispatches_registered_read_only_proof( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "b" * 40) + output = cli_workspace["archive_root"].parent / "live-proof.json" + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "live-proof", + "--proof-id", + "archive-verification", + "--output", + str(output), + ], + ) + + assert result.exit_code == 0, result.output + receipt = json.loads(output.read_text(encoding="utf-8")) + assert receipt["proof_id"] == "archive-verification" + assert receipt["mode"] == "read_only" + assert str(cli_workspace["archive_root"]) not in output.read_text(encoding="utf-8") + + +def test_live_proof_cli_rejects_unknown_route_without_creating_output( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + output = cli_workspace["archive_root"].parent / "unknown-live-proof.json" + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "live-proof", + "--proof-id", + "arbitrary-shell-command", + "--output", + str(output), + ], + ) + + assert result.exit_code != 0 + assert "unknown live-proof id" in result.output + assert not output.exists() diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py new file mode 100644 index 0000000000..c0aaccb6c2 --- /dev/null +++ b/tests/unit/maintenance/test_live_proof.py @@ -0,0 +1,160 @@ +"""Tests for the fixed live-proof protocol. + +The production dependency exercised here is the archive-verification registry +and the source/index binding readers. The red mutations alter one captured +binding or candidate metadata after collection; validation must reject them, +which a receipt-only serializer would incorrectly accept. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from polylogue.core.hashing import hash_payload +from polylogue.maintenance.live_proof import ( + EXISTING_APPLY_RECEIPT_SCHEMA, + LIVE_PROOF_SPECS, + LiveProofBindings, + LiveProofError, + LiveProofId, + LiveProofMode, + capture_live_proof_bindings, + collect_live_proof, + validate_candidate_proof_receipts, + validate_live_operation_aggregate, + validate_live_proof_receipt, + validate_live_proof_registry, +) +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + +@pytest.fixture +def archive_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "private-archive" + initialize_active_archive_root(root) + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "a" * 40) + return root + + +def _apply_receipt(bindings: LiveProofBindings) -> dict[str, object]: + document = { + "receipt_schema": EXISTING_APPLY_RECEIPT_SCHEMA, + "operation_id": "known-source-remediation", + "bindings": bindings.to_document(), + "result": {"status": "applied", "changed_count": 1}, + } + return {**document, "receipt_sha256": hash_payload(document)} + + +def _candidate(root: Path) -> str: + generation_id = "gen-live-proof" + generation = root / ".index-generations" / generation_id + generation.mkdir(parents=True) + candidate_index = generation / "index.db" + shutil.copy2(root / "index.db", candidate_index) + (generation / "generation.json").write_text( + json.dumps( + { + "generation_id": generation_id, + "owner_id": "proof-owner", + "archive_root": str(root), + "index_path": str(candidate_index), + "state": "inactive", + "source_snapshot": "candidate-source-snapshot", + } + ), + encoding="utf-8", + ) + return generation_id + + +def test_fixed_registry_has_exactly_the_three_supported_modes() -> None: + validate_live_proof_registry() + + assert {spec.mode for spec in LIVE_PROOF_SPECS} == set(LiveProofMode) + assert {spec.proof_id for spec in LIVE_PROOF_SPECS} == set(LiveProofId) + assert all(not callable(spec.producer) for spec in LIVE_PROOF_SPECS) + + +def test_read_only_receipt_is_deterministic_self_hashed_and_private_path_safe(archive_root: Path) -> None: + first = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + second = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + + assert first.to_document() == second.to_document() + assert first.to_document()["receipt_sha256"] == first.receipt_sha256 + assert str(archive_root) not in json.dumps(first.to_document()) + assert validate_live_proof_receipt(first.to_document(), archive_root) == first + + +def test_mode_inputs_are_isolated(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "apply.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + + with pytest.raises(LiveProofError, match="read-only proof"): + collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root, apply_receipt_path=apply_path) + with pytest.raises(LiveProofError, match="candidate proof"): + collect_live_proof(LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, archive_root) + with pytest.raises(LiveProofError, match="existing-apply proof"): + collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root) + + +def test_existing_apply_receipt_is_bound_and_rejects_controlled_binding_mutation( + archive_root: Path, tmp_path: Path +) -> None: + apply_path = tmp_path / "private-apply-receipt.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + + receipt = collect_live_proof( + LiveProofId.EXISTING_APPLY_RECEIPT.value, + archive_root, + apply_receipt_path=apply_path, + ) + + assert receipt.input_receipt_digests + assert str(apply_path) not in json.dumps(receipt.to_document()) + mutated = receipt.to_document() + binding_value = mutated["bindings"] + assert isinstance(binding_value, dict) + bindings = dict(binding_value) + bindings["source_snapshot"] = "0" * 64 + mutated["bindings"] = bindings + unsigned = dict(mutated) + unsigned.pop("receipt_sha256") + mutated["receipt_sha256"] = hash_payload(unsigned) + with pytest.raises(LiveProofError, match="bindings are stale"): + validate_live_proof_receipt(mutated, archive_root) + + +def test_candidate_receipt_binds_exact_inactive_generation_and_detects_content_mutation(archive_root: Path) -> None: + generation_id = _candidate(archive_root) + receipt = collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + + assert receipt.bindings.candidate_generation_id == generation_id + assert receipt.bindings.candidate_index_sha256 is not None + candidate_index = archive_root / ".index-generations" / generation_id / "index.db" + with candidate_index.open("ab") as stream: + stream.write(b"binding mutation") + with pytest.raises(LiveProofError, match="bindings are stale"): + validate_candidate_proof_receipts((receipt.to_document(),), archive_root, candidate_generation_id=generation_id) + + +def test_aggregate_rejects_a_self_hashed_failed_proof_result(archive_root: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + failed = receipt.to_document() + result = failed["result"] + assert isinstance(result, dict) + result["status"] = "failed" + unsigned = dict(failed) + unsigned.pop("receipt_sha256") + failed["receipt_sha256"] = hash_payload(unsigned) + + with pytest.raises(LiveProofError, match="not acceptable"): + validate_live_operation_aggregate((failed,), archive_root) From 7ec146b783949997c3f5440830f1edae67b0dceb Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 12:46:56 +0200 Subject: [PATCH 02/12] fix(maintenance): bind every archive tier in live proofs Problem: live-proof bindings omitted the canonical ops and audit tiers, so a receipt could remain valid while one of those schema files changed or was absent.\n\nWhat changed: include all six archive-tier schema versions in the binding and document the complete contract. Add a regression assertion for the tier vocabulary.\n\nCompatibility/migration: receipts produced before this change are stale because the binding shape is intentionally stronger. --- docs/maintenance.md | 2 +- polylogue/maintenance/live_proof.py | 2 ++ tests/unit/maintenance/test_live_proof.py | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index 37d0716f49..cee8ee73fe 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -492,7 +492,7 @@ polylogue ops maintenance live-proof \ The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt`; every other combination is rejected. -Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, observed schema versions, parser and lowering fingerprints, candidate generation and index hash where applicable, typed residues, and input receipt digests. Private local paths are represented only as a SHA-256 digest plus basename. The collector validates those bindings again when an aggregate, candidate, or final-proof consumer reads the receipt, so a changed source snapshot, candidate index, schema, or semantic fingerprint makes the receipt stale. +Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, all six archive-tier schema versions, parser and lowering fingerprints, candidate generation and index hash where applicable, typed residues, and input receipt digests. Private local paths are represented only as a SHA-256 digest plus basename. The collector validates those bindings again when an aggregate, candidate, or final-proof consumer reads the receipt, so a changed source snapshot, candidate index, schema, or semantic fingerprint makes the receipt stale. ### `--operation-id` and `--resume`: worked example diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index 07da4142ea..ce59495480 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -243,9 +243,11 @@ def _code_sha() -> str: def _schema_versions(root: Path, *, candidate_index: Path | None) -> tuple[tuple[str, int], ...]: paths = { + "audit": root / "audit.db", "source": root / "source.db", "index": candidate_index or root / "index.db", "embeddings": root / "embeddings.db", + "ops": root / "ops.db", "user": root / "user.db", } versions: list[tuple[str, int]] = [] diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index c0aaccb6c2..9e0ab0e4fc 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -87,6 +87,14 @@ def test_read_only_receipt_is_deterministic_self_hashed_and_private_path_safe(ar assert first.to_document() == second.to_document() assert first.to_document()["receipt_sha256"] == first.receipt_sha256 assert str(archive_root) not in json.dumps(first.to_document()) + assert {name for name, _version in first.bindings.schema_versions} == { + "audit", + "embeddings", + "index", + "ops", + "source", + "user", + } assert validate_live_proof_receipt(first.to_document(), archive_root) == first From ecdc1966a05baead6d388f1f1b713fccf33c7287 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 12:57:11 +0200 Subject: [PATCH 03/12] fix(maintenance): reject failed apply proof inputs Problem: the existing-apply live-proof route wrapped any input result as a passed proof, including blocked, failed, or unknown mutation outcomes.\n\nWhat changed: accept only applied and already_satisfied input outcomes and add a regression test for unknown results.\n\nCompatibility/migration: previously collected live-proof receipts remain bound to their original protocol version; new existing-apply inputs fail closed unless their mutation succeeded. --- polylogue/maintenance/live_proof.py | 8 ++++++-- tests/unit/maintenance/test_live_proof.py | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index ce59495480..690285d6ee 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -29,6 +29,7 @@ _SHA256_RE: Final = re.compile(r"[0-9a-f]{64}") _CODE_SHA_RE: Final = re.compile(r"[0-9a-f]{40,64}") _GENERATION_ID_RE: Final = re.compile(r"gen-[A-Za-z0-9][A-Za-z0-9._-]{0,127}") +_ACCEPTED_APPLY_STATUSES: Final = frozenset({"applied", "already_satisfied"}) class LiveProofError(ValueError): @@ -387,10 +388,13 @@ def _validated_existing_apply_receipt(path: Path, bindings: LiveProofBindings) - receipt_bindings = payload.get("bindings") if not isinstance(receipt_bindings, Mapping) or receipt_bindings != bindings.to_document(): raise LiveProofError("existing apply receipt bindings are stale or mismatched") - if not is_json_document(payload.get("result")): + result = payload.get("result") + if not isinstance(result, Mapping) or not is_json_document(result): raise LiveProofError("existing apply receipt result is malformed") + if result.get("status") not in _ACCEPTED_APPLY_STATUSES: + raise LiveProofError("existing apply receipt result status is not successful") _validate_private_path_references(receipt_bindings.get("private_paths")) - return require_json_document(payload["result"], context="existing apply receipt result"), digest + return require_json_document(result, context="existing apply receipt result"), digest def collect_live_proof( diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 9e0ab0e4fc..7bad31d659 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -40,12 +40,12 @@ def archive_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return root -def _apply_receipt(bindings: LiveProofBindings) -> dict[str, object]: +def _apply_receipt(bindings: LiveProofBindings, *, status: str = "applied") -> dict[str, object]: document = { "receipt_schema": EXISTING_APPLY_RECEIPT_SCHEMA, "operation_id": "known-source-remediation", "bindings": bindings.to_document(), - "result": {"status": "applied", "changed_count": 1}, + "result": {"status": status, "changed_count": 1}, } return {**document, "receipt_sha256": hash_payload(document)} @@ -137,6 +137,20 @@ def test_existing_apply_receipt_is_bound_and_rejects_controlled_binding_mutation validate_live_proof_receipt(mutated, archive_root) +def test_existing_apply_receipt_rejects_non_successful_result(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "failed-apply-receipt.json" + apply_path.write_text( + json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status="unknown")), encoding="utf-8" + ) + + with pytest.raises(LiveProofError, match="result status is not successful"): + collect_live_proof( + LiveProofId.EXISTING_APPLY_RECEIPT.value, + archive_root, + apply_receipt_path=apply_path, + ) + + def test_candidate_receipt_binds_exact_inactive_generation_and_detects_content_mutation(archive_root: Path) -> None: generation_id = _candidate(archive_root) receipt = collect_live_proof( From 93794158d1ef8d14b4c66fa4386af0e116b68893 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 14:11:50 +0200 Subject: [PATCH 04/12] fix(maintenance): harden live-proof evidence bindings Problem: The live-proof protocol accepted incomplete archive evidence and could bind receipts to stale code, candidate metadata, or mutable SQLite files. Existing apply results also lost typed terminal status when converted into proof outcomes.\n\nWhat changed: Bind installed or clean-checkout code identity, canonical archive and generation paths, source snapshots, all tier schemas, active and candidate SQLite file sets, complete verification profiles, status/residue pairs, route coverage, and embedded apply evidence. Add atomic exclusive receipt publication and CLI write-error handling. Extend focused route tests and documentation.\n\nAlternatives rejected: The protocol remains a read-only evidence collector. It does not acquire, repair, promote, restart, or schedule archive work.\n\nCompatibility/migration: Existing receipts are intentionally stale unless they match the stricter bindings. No production archive was mutated. Ref polylogue-x97cf Co-Authored-By: Codex --- docs/maintenance.md | 4 +- .../cli/commands/maintenance/_live_proof.py | 2 + polylogue/maintenance/live_proof.py | 764 ++++++++++++++---- .../cli/test_maintenance_live_proof_cli.py | 30 + tests/unit/maintenance/test_live_proof.py | 384 +++++++-- 5 files changed, 970 insertions(+), 214 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index cee8ee73fe..dade9439ba 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -492,7 +492,9 @@ polylogue ops maintenance live-proof \ The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt`; every other combination is rejected. -Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, all six archive-tier schema versions, parser and lowering fingerprints, candidate generation and index hash where applicable, typed residues, and input receipt digests. Private local paths are represented only as a SHA-256 digest plus basename. The collector validates those bindings again when an aggregate, candidate, or final-proof consumer reads the receipt, so a changed source snapshot, candidate index, schema, or semantic fingerprint makes the receipt stale. +Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, all six active archive-tier schema versions, parser and lowering fingerprints, the active SQLite file set, and the candidate generation, schema, and SQLite file set where applicable. SQLite bindings include the database and WAL/journal sidecars and refuse a file set that changes while it is captured. Candidate metadata must name the canonical inactive generation and the same source snapshot. Archives whose configured paths contain SQLite URI query characters are rejected before proof dependencies open them. Private local paths are represented only as a SHA-256 digest plus basename. + +The receipt keeps complete structured archive-verification evidence after redacting archive paths and any absolute paths emitted by checks. `archive-verification` runs the entire live archive profile. `candidate-archive-verification` runs both canonical candidate acceptance profiles: the index-candidate checks against the inactive generation and the cross-tier checks against that generation plus the durable archive. Existing-apply evidence embeds the validated input receipt, so consumers revalidate its self-hash, bindings, and match to the recorded input digest. Verification consumers validate the fixed profile membership, check outcomes, typed status/residue relationship, bindings, and input hashes again. Aggregate validation captures a single candidate-aware binding snapshot, deriving the active binding from that same snapshot. Final proof consumption requires every registered route exactly once; `not_applicable` is accepted only with its typed residue. Output creation is exclusive and failure-atomic: a failed write removes its partial file and syncs the destination directory. ### `--operation-id` and `--resume`: worked example diff --git a/polylogue/cli/commands/maintenance/_live_proof.py b/polylogue/cli/commands/maintenance/_live_proof.py index bfe1fa800d..dbcab6f2d2 100644 --- a/polylogue/cli/commands/maintenance/_live_proof.py +++ b/polylogue/cli/commands/maintenance/_live_proof.py @@ -52,4 +52,6 @@ def live_proof_command( write_live_proof_receipt(target, receipt) except LiveProofError as exc: raise click.ClickException(str(exc)) from exc + except OSError as exc: + raise click.ClickException("live-proof receipt output could not be written") from exc click.echo(json.dumps(receipt.to_document(), indent=2, sort_keys=True)) diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index 690285d6ee..fb14fbab4d 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -1,9 +1,9 @@ """Static, read-only live-proof receipt protocol for the reindex campaign. -This module deliberately collects evidence only. Its registry has no plugin -or runtime-registration seam, and its CLI adapter accepts neither commands nor -callables. A future campaign consumer can therefore validate a receipt without -turning the proof protocol into an executor, scheduler, or mutation authority. +This module collects immutable evidence only. Its fixed registry has no +runtime-registration seam, and the CLI adapter accepts neither commands nor +callables. A consumer can therefore validate a receipt without turning this +proof protocol into mutation authority. """ from __future__ import annotations @@ -13,14 +13,16 @@ import re import sqlite3 import subprocess +import tempfile from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum from pathlib import Path from typing import Final, Literal, cast from polylogue.core.hashing import hash_file, hash_payload, hash_text -from polylogue.core.json import JSONDocument, is_json_document, require_json_document +from polylogue.core.json import JSONDocument, JSONValue, is_json_document, require_json_document +from polylogue.version import VERSION_INFO LIVE_PROOF_RECEIPT_SCHEMA: Final = "polylogue.live-proof-receipt.v1" EXISTING_APPLY_RECEIPT_SCHEMA: Final = "polylogue.apply-receipt.v1" @@ -29,7 +31,11 @@ _SHA256_RE: Final = re.compile(r"[0-9a-f]{64}") _CODE_SHA_RE: Final = re.compile(r"[0-9a-f]{40,64}") _GENERATION_ID_RE: Final = re.compile(r"gen-[A-Za-z0-9][A-Za-z0-9._-]{0,127}") -_ACCEPTED_APPLY_STATUSES: Final = frozenset({"applied", "already_satisfied"}) +_RESIDUE_CODE_RE: Final = re.compile(r"[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*") +_ABSOLUTE_PATH_RE: Final = re.compile(r"(? JSONDocument: @dataclass(frozen=True, slots=True) -class LiveProofSpec: - """One compile-time proof route. +class LiveProofArchiveProfile: + """One fixed archive-verification profile for a proof route.""" + + name: str + checks: tuple[str, ...] + target: Literal["active", "candidate_index", "candidate_cross_tier"] + - ``producer`` is intentionally a symbolic key rather than a callable. The - collector recognizes only the fixed literal producer keys below, so command - input can never supply executable behavior. - """ +@dataclass(frozen=True, slots=True) +class LiveProofSpec: + """One compile-time proof route with no executable registration seam.""" proof_id: LiveProofId bead_id: str mode: LiveProofMode producer: Literal["archive_verification", "existing_apply_receipt"] - archive_checks: tuple[str, ...] = () + archive_profiles: tuple[LiveProofArchiveProfile, ...] = () @dataclass(frozen=True, slots=True) @@ -111,8 +121,10 @@ class LiveProofBindings: archive_identity_digest: str source_snapshot: str schema_versions: tuple[tuple[str, int], ...] + candidate_index_schema_version: int | None parser_fingerprints: tuple[tuple[str, str], ...] lowering_fingerprint: str + active_index_sha256: str candidate_generation_id: str | None candidate_index_sha256: str | None private_paths: tuple[tuple[str, PrivatePathReference], ...] @@ -123,8 +135,10 @@ def to_document(self) -> JSONDocument: "archive_identity_digest": self.archive_identity_digest, "source_snapshot": self.source_snapshot, "schema_versions": dict(self.schema_versions), + "candidate_index_schema_version": self.candidate_index_schema_version, "parser_fingerprints": dict(self.parser_fingerprints), "lowering_fingerprint": self.lowering_fingerprint, + "active_index_sha256": self.active_index_sha256, "candidate_generation_id": self.candidate_generation_id, "candidate_index_sha256": self.candidate_index_sha256, "private_paths": {name: ref.to_document() for name, ref in self.private_paths}, @@ -163,20 +177,36 @@ def to_document(self) -> JSONDocument: return {**self.payload(), "receipt_sha256": self.receipt_sha256} +def _archive_profiles() -> tuple[LiveProofArchiveProfile, ...]: + from polylogue.maintenance.archive_verification import ( + ARCHIVE_VERIFICATION_CHECK_NAMES, + REINDEX_ACCEPTANCE_CHECKS, + REINDEX_CROSS_TIER_ACCEPTANCE_CHECKS, + ) + + return ( + LiveProofArchiveProfile("active-archive", ARCHIVE_VERIFICATION_CHECK_NAMES, "active"), + LiveProofArchiveProfile("candidate-index", REINDEX_ACCEPTANCE_CHECKS, "candidate_index"), + LiveProofArchiveProfile("candidate-cross-tier", REINDEX_CROSS_TIER_ACCEPTANCE_CHECKS, "candidate_cross_tier"), + ) + + +_ACTIVE_ARCHIVE_PROFILE, _CANDIDATE_INDEX_PROFILE, _CANDIDATE_CROSS_TIER_PROFILE = _archive_profiles() + LIVE_PROOF_SPECS: Final[tuple[LiveProofSpec, ...]] = ( LiveProofSpec( proof_id=LiveProofId.ARCHIVE_VERIFICATION, bead_id="polylogue-x97cf", mode=LiveProofMode.READ_ONLY, producer="archive_verification", - archive_checks=("tier-schema", "counts-summary"), + archive_profiles=(_ACTIVE_ARCHIVE_PROFILE,), ), LiveProofSpec( proof_id=LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION, bead_id="polylogue-x97cf", mode=LiveProofMode.CANDIDATE, producer="archive_verification", - archive_checks=("corpus-absences",), + archive_profiles=(_CANDIDATE_INDEX_PROFILE, _CANDIDATE_CROSS_TIER_PROFILE), ), LiveProofSpec( proof_id=LiveProofId.EXISTING_APPLY_RECEIPT, @@ -188,7 +218,7 @@ def to_document(self) -> JSONDocument: def validate_live_proof_registry(specs: Sequence[LiveProofSpec] = LIVE_PROOF_SPECS) -> None: - """Require the complete fixed protocol registry and no executable seam.""" + """Require the complete fixed protocol registry and canonical profiles.""" from polylogue.maintenance.archive_verification import ARCHIVE_VERIFICATION_CHECKS @@ -200,16 +230,25 @@ def validate_live_proof_registry(specs: Sequence[LiveProofSpec] = LIVE_PROOF_SPE if not spec.bead_id.startswith("polylogue-"): raise LiveProofError("live-proof spec has an invalid bead id") if spec.mode is LiveProofMode.EXISTING_APPLY_RECEIPT: - if spec.producer != "existing_apply_receipt" or spec.archive_checks: + if spec.producer != "existing_apply_receipt" or spec.archive_profiles: raise LiveProofError("existing-apply proof spec may only validate an input receipt") - elif spec.producer != "archive_verification" or not spec.archive_checks: - raise LiveProofError("read-only and candidate proof specs require registered archive checks") - for check_name in spec.archive_checks: - check = next((candidate for candidate in ARCHIVE_VERIFICATION_CHECKS if candidate.name == check_name), None) - if check is None: - raise LiveProofError("live-proof spec references an unknown archive verification check") - if spec.mode is LiveProofMode.CANDIDATE and check.candidate_run is None: - raise LiveProofError("candidate live-proof spec requires candidate-capable archive checks") + continue + if spec.producer != "archive_verification" or not spec.archive_profiles: + raise LiveProofError("archive proof specs require registered archive checks") + for profile in spec.archive_profiles: + if not profile.checks or len(set(profile.checks)) != len(profile.checks): + raise LiveProofError("live-proof archive profile is incomplete") + for check_name in profile.checks: + if next((check for check in ARCHIVE_VERIFICATION_CHECKS if check.name == check_name), None) is None: + raise LiveProofError("live-proof spec references an unknown archive verification check") + by_id = {spec.proof_id: spec for spec in specs} + if by_id[LiveProofId.ARCHIVE_VERIFICATION].archive_profiles != (_ACTIVE_ARCHIVE_PROFILE,): + raise LiveProofError("read-only proof must use the complete active archive profile") + if by_id[LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION].archive_profiles != ( + _CANDIDATE_INDEX_PROFILE, + _CANDIDATE_CROSS_TIER_PROFILE, + ): + raise LiveProofError("candidate proof must use both canonical candidate acceptance profiles") def live_proof_spec(proof_id: str) -> LiveProofSpec: @@ -223,6 +262,26 @@ def live_proof_spec(proof_id: str) -> LiveProofSpec: raise LiveProofError("live-proof registry is incomplete") +def _run_git(repository: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ("git", "-C", str(repository), *arguments), + check=False, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT_SECONDS, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc: + raise LiveProofError("exact code SHA is unavailable") from exc + + +def _installed_code_sha() -> str: + commit = (VERSION_INFO.commit or "").lower() + if not _CODE_SHA_RE.fullmatch(commit): + raise LiveProofError("installed package has no exact build commit") + return commit + + def _code_sha() -> str: configured = os.environ.get("POLYLOGUE_CODE_SHA", "").strip().lower() if configured: @@ -230,127 +289,287 @@ def _code_sha() -> str: raise LiveProofError("POLYLOGUE_CODE_SHA must be an exact git commit SHA") return configured repository = Path(__file__).resolve().parents[2] - completed = subprocess.run( - ("git", "-C", str(repository), "rev-parse", "--verify", "HEAD"), - check=False, - capture_output=True, - text=True, - ) + if not (repository / ".git").exists(): + return _installed_code_sha() + dirty = _run_git(repository, "status", "--porcelain=v1", "--untracked-files=all") + if dirty.returncode != 0 or dirty.stdout.strip(): + raise LiveProofError("live proofs require a clean git worktree") + completed = _run_git(repository, "rev-parse", "--verify", "HEAD") sha = completed.stdout.strip().lower() if completed.returncode != 0 or not _CODE_SHA_RE.fullmatch(sha): raise LiveProofError("exact code SHA is unavailable") return sha -def _schema_versions(root: Path, *, candidate_index: Path | None) -> tuple[tuple[str, int], ...]: - paths = { - "audit": root / "audit.db", - "source": root / "source.db", - "index": candidate_index or root / "index.db", - "embeddings": root / "embeddings.db", - "ops": root / "ops.db", - "user": root / "user.db", +def _readonly_uri(path: Path) -> str: + return f"{path.resolve(strict=True).as_uri()}?mode=ro&immutable=1" + + +def _open_readonly(path: Path) -> sqlite3.Connection: + return sqlite3.connect(_readonly_uri(path), uri=True, timeout=2) + + +def _require_quiescent_sqlite(paths: Sequence[Path]) -> None: + if any( + path.with_name(path.name + "-wal").exists() or path.with_name(path.name + "-wal").is_symlink() for path in paths + ): + raise LiveProofError("live-proof requires a quiescent archive without SQLite WAL files") + + +def _require_uri_safe_location(location: object) -> None: + """Fail closed before dependencies that still interpolate SQLite URIs.""" + + from polylogue.storage.archive_identity import ArchiveLocation + + assert isinstance(location, ArchiveLocation) + paths = (location.configured_root, location.active_index_path) + tuple( + location.configured_tier(name).configured_path for name in ("audit", "source", "embeddings", "ops", "user") + ) + if any(any(character in str(path) for character in ("%", "?", "#")) for path in paths): + raise LiveProofError("live-proof archive paths cannot contain SQLite URI query characters") + _require_quiescent_sqlite(paths[1:]) + + +def _sqlite_file_state(path: Path, *, allow_symlink: bool) -> JSONDocument: + try: + metadata = path.lstat() + except OSError as exc: + raise LiveProofError("live-proof SQLite binding is unavailable") from exc + if path.is_symlink(): + if not allow_symlink: + raise LiveProofError("live-proof SQLite binding is not a regular file") + path = path.resolve(strict=True) + metadata = path.stat() + if not path.is_file(): + raise LiveProofError("live-proof SQLite binding is not a regular file") + files: dict[str, JSONValue] = { + "database": { + "size": metadata.st_size, + "mtime_ns": metadata.st_mtime_ns, + "sha256": hash_file(path), + } } - versions: list[tuple[str, int]] = [] - for name, path in sorted(paths.items()): + for suffix in _SQLITE_SIDECARS: + sidecar = path.with_name(path.name + suffix) + if not sidecar.exists() and not sidecar.is_symlink(): + files[suffix] = {"exists": False} + continue try: - connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) - try: - version = int(connection.execute("PRAGMA user_version").fetchone()[0]) - finally: - connection.close() - except sqlite3.Error as exc: - raise LiveProofError("live-proof schema binding is unavailable") from exc - versions.append((name, version)) - return tuple(versions) + sidecar_metadata = sidecar.lstat() + except OSError as exc: + raise LiveProofError("live-proof SQLite sidecar is unavailable") from exc + if sidecar.is_symlink() or not sidecar.is_file(): + raise LiveProofError("live-proof SQLite sidecar is not a regular file") + # SQLite may create an empty WAL for a read-only connection. It holds + # no logical pages and is equivalent to an absent WAL, so normalize it + # before comparing the pre/post quiescence snapshots. + if sidecar_metadata.st_size == 0: + files[suffix] = {"exists": False} + continue + files[suffix] = { + "exists": True, + "size": sidecar_metadata.st_size, + "mtime_ns": sidecar_metadata.st_mtime_ns, + "sha256": hash_file(sidecar), + } + return files + + +def _sqlite_file_set_digest(path: Path, *, allow_symlink: bool = False) -> str: + """Bind a stable SQLite database plus WAL/journal sidecars as one file set.""" + before = _sqlite_file_state(path, allow_symlink=allow_symlink) + try: + with _open_readonly(path) as connection: + connection.execute("PRAGMA query_only = ON") + connection.execute("BEGIN") + connection.execute("PRAGMA schema_version").fetchone() + except (OSError, sqlite3.Error) as exc: + raise LiveProofError("live-proof SQLite binding is unavailable") from exc + after = _sqlite_file_state(path, allow_symlink=allow_symlink) + if after != before: + raise LiveProofError("live-proof SQLite file set changed while capturing binding") + return hash_payload(before) + + +def _schema_version(path: Path) -> int: + try: + with _open_readonly(path) as connection: + row = connection.execute("PRAGMA user_version").fetchone() + except (OSError, sqlite3.Error) as exc: + raise LiveProofError("live-proof schema binding is unavailable") from exc + return int(row[0]) if row is not None else 0 + + +def _schema_versions(location: object) -> tuple[tuple[str, int], ...]: + from polylogue.storage.archive_identity import ArchiveLocation + + assert isinstance(location, ArchiveLocation) + return tuple( + (name, _schema_version(location.active_tier(name).configured_path)) + for name in ("audit", "source", "index", "embeddings", "ops", "user") + ) + + +def _candidate_index(location: object, generation_id: str, *, source_snapshot: str) -> Path: + """Resolve one inactive generation through the lifecycle store's canonical root.""" -def _candidate_index(root: Path, generation_id: str) -> Path: + from polylogue.storage.archive_identity import ArchiveLocation, TierFileIdentity + from polylogue.storage.index_generation import IndexGenerationStore + + assert isinstance(location, ArchiveLocation) if not _GENERATION_ID_RE.fullmatch(generation_id): raise LiveProofError("candidate generation id is invalid") - generation_root = root / ".index-generations" / generation_id - metadata_path = generation_root / "generation.json" + # The store bootstraps an absent pointer, which would violate this module's + # read-only contract. Real generations can only exist after that lifecycle + # anchor exists, so fail closed instead of asking the store to create it. + if location.active_pointer is None: + raise LiveProofError("candidate generation requires an archive lifecycle pointer") + if ".index-generations" in location.active_pointer.parts: + raise LiveProofError("candidate generation requires a canonical active-index pointer") + store = IndexGenerationStore(location) try: - metadata = json.loads(metadata_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + generation = store.load(generation_id) + index_path = Path(generation.index_path) + expected_root = store.generations_root / generation_id + index_resolved = index_path.resolve(strict=True) + expected_resolved = expected_root.resolve(strict=True) / "index.db" + except (OSError, RuntimeError, TypeError, ValueError, json.JSONDecodeError) as exc: raise LiveProofError("candidate generation metadata is unavailable") from exc - if not isinstance(metadata, dict): - raise LiveProofError("candidate generation metadata is invalid") - index_path = generation_root / "index.db" if ( - metadata.get("generation_id") != generation_id - or metadata.get("state") != "inactive" - or metadata.get("archive_root") != str(root) - or metadata.get("index_path") != str(index_path) + generation.generation_id != generation_id + or generation.state != "inactive" + or Path(generation.archive_root).resolve() != location.configured_root.resolve() + or generation.source_snapshot != source_snapshot + or index_path != expected_root / "index.db" + or index_path.is_symlink() or not index_path.is_file() + or index_resolved != expected_resolved + or location.active_index.same_file(TierFileIdentity.resolve("index", index_path)) ): raise LiveProofError("candidate generation binding is stale or invalid") + _require_quiescent_sqlite((index_path,)) return index_path def capture_live_proof_bindings(archive_root: Path, *, candidate_generation_id: str | None = None) -> LiveProofBindings: - """Capture every current, read-only binding required by a receipt.""" + """Capture one coherent, read-only binding snapshot for a proof receipt.""" from polylogue.maintenance.schema_inference_gate import rebuild_source_revision_snapshot from polylogue.sources.origin_specs import lowering_fingerprint, parser_fingerprint_for_origin from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation root = Path(archive_root).expanduser().resolve() - candidate_index = _candidate_index(root, candidate_generation_id) if candidate_generation_id is not None else None try: location = ArchiveLocation.resolve(root) - identity = ArchiveIdentity.resolve_location( - location, - generation_owner=None, - generation_state="inactive" if candidate_index is not None else "active", - ) - with sqlite3.connect(f"file:{root / 'source.db'}?mode=ro", uri=True) as source: + _require_uri_safe_location(location) + source_snapshot = rebuild_source_revision_snapshot(root) + with _open_readonly(location.configured_tier("source").configured_path) as source: origins = sorted(str(row[0]) for row in source.execute("SELECT DISTINCT origin FROM raw_sessions")) - except (OSError, RuntimeError, sqlite3.Error, ValueError) as exc: - raise LiveProofError("live-proof archive binding is unavailable") from exc - try: + candidate_index = ( + _candidate_index(location, candidate_generation_id, source_snapshot=source_snapshot) + if candidate_generation_id is not None + else None + ) + identity = ArchiveIdentity.resolve_location(location) + active_index_sha256 = _sqlite_file_set_digest(location.active_index_path, allow_symlink=True) + candidate_index_sha256 = _sqlite_file_set_digest(candidate_index) if candidate_index is not None else None + schema_versions = _schema_versions(location) + candidate_index_schema_version = _schema_version(candidate_index) if candidate_index is not None else None parser_fingerprints = tuple((origin, parser_fingerprint_for_origin(origin)) for origin in origins) - source_snapshot = rebuild_source_revision_snapshot(root) lowering = lowering_fingerprint() - except (OSError, RuntimeError, ValueError) as exc: - raise LiveProofError("live-proof semantic binding is unavailable") from exc - private_paths: list[tuple[str, PrivatePathReference]] = [("archive_root", PrivatePathReference.capture(root))] + except LiveProofError: + raise + except (OSError, RuntimeError, sqlite3.Error, ValueError) as exc: + raise LiveProofError("live-proof archive binding is unavailable") from exc + private_paths: list[tuple[str, PrivatePathReference]] = [ + ("archive_root", PrivatePathReference.capture(root)), + ("active_index", PrivatePathReference.capture(location.active_index_path)), + ] if candidate_index is not None: private_paths.append(("candidate_index", PrivatePathReference.capture(candidate_index))) return LiveProofBindings( code_sha=_code_sha(), archive_identity_digest=identity.authority_identity_digest, source_snapshot=source_snapshot, - schema_versions=_schema_versions(root, candidate_index=candidate_index), + schema_versions=schema_versions, + candidate_index_schema_version=candidate_index_schema_version, parser_fingerprints=parser_fingerprints, lowering_fingerprint=lowering, + active_index_sha256=active_index_sha256, candidate_generation_id=candidate_generation_id, - candidate_index_sha256=hash_file(candidate_index) if candidate_index is not None else None, + candidate_index_sha256=candidate_index_sha256, private_paths=tuple(private_paths), ) +def _redacted_report(report: object, *, roots: Sequence[Path]) -> JSONDocument: + if not hasattr(report, "to_json"): + raise LiveProofError("archive verification report is unavailable") + document = cast(JSONDocument, report.to_json()) + if not isinstance(document, Mapping): + raise LiveProofError("archive verification report is malformed") + replacements = {str(path.resolve()): f"[private-path:{hash_text(str(path.resolve()))}]" for path in roots} + + def redact(value: object) -> JSONValue: + if isinstance(value, Mapping): + return {str(key): redact(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [redact(item) for item in value] + if isinstance(value, str): + for raw, replacement in sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True): + value = value.replace(raw, replacement) + if _ABSOLUTE_PATH_RE.search(value): + return f"[private-text:{hash_text(value)}]" + return value + if value is None or isinstance(value, bool | int | float): + return value + raise LiveProofError("archive verification emitted non-JSON evidence") + + evidence = {key: value for key, value in document.items() if key not in {"archive_root", "generated_at"}} + redacted = redact(evidence) + if not isinstance(redacted, dict): + raise LiveProofError("archive verification emitted malformed evidence") + return redacted + + def _archive_verification_result( - spec: LiveProofSpec, archive_root: Path, *, candidate_generation_id: str | None + spec: LiveProofSpec, + archive_root: Path, + *, + candidate_generation_id: str | None, + bindings: LiveProofBindings, ) -> tuple[JSONDocument, tuple[LiveProofResidue, ...]]: from polylogue.maintenance.archive_verification import verify_archive - - candidate_index = ( - _candidate_index(archive_root, candidate_generation_id) if candidate_generation_id is not None else None - ) - report = verify_archive(archive_root, checks=spec.archive_checks, index_path_override=candidate_index) - statuses: JSONDocument = {check.name: check.status.value for check in report.checks} - residues = tuple( - LiveProofResidue(LiveProofResidueKind.CHECK_FAILED, check.name) - for check in report.checks - if check.status.value in {"error", "warning"} - ) - archive_verification: JSONDocument = {"checks": statuses, "blocking": report.blocking} - result: JSONDocument = { - "status": LiveProofStatus.FAILED.value if report.blocking else LiveProofStatus.PASSED.value, - "archive_verification": archive_verification, - } - return result, residues + from polylogue.storage.archive_identity import ArchiveLocation + + location = ArchiveLocation.resolve(archive_root) + archive_paths = (archive_root, location.active_index_path) + candidate_index: Path | None = None + candidate_root: Path | None = None + if candidate_generation_id is not None: + candidate_index = _candidate_index(location, candidate_generation_id, source_snapshot=bindings.source_snapshot) + candidate_root = candidate_index.parent + profiles: dict[str, JSONValue] = {} + residues: list[LiveProofResidue] = [] + for profile in spec.archive_profiles: + if profile.target == "active": + report = verify_archive(archive_root, checks=profile.checks) + roots: tuple[Path, ...] = archive_paths + elif profile.target == "candidate_index": + assert candidate_root is not None + report = verify_archive(candidate_root, checks=profile.checks) + roots = (*archive_paths, candidate_root) + else: + assert candidate_index is not None + report = verify_archive(archive_root, checks=profile.checks, index_path_override=candidate_index) + roots = (*archive_paths, candidate_index.parent) + profiles[profile.name] = _redacted_report(report, roots=roots) + for check in report.checks: + if check.status.value != "ok": + residues.append(LiveProofResidue(LiveProofResidueKind.CHECK_FAILED, f"{profile.name}:{check.name}")) + status = LiveProofStatus.PASSED if not residues else LiveProofStatus.FAILED + return cast(JSONDocument, {"status": status.value, "archive_verification": {"profiles": profiles}}), tuple(residues) def _validate_private_path_references(value: object) -> None: @@ -370,11 +589,19 @@ def _validate_private_path_references(value: object) -> None: raise LiveProofError("input receipt private paths are malformed") -def _validated_existing_apply_receipt(path: Path, bindings: LiveProofBindings) -> tuple[JSONDocument, str]: - try: - document = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise LiveProofError("existing apply receipt is unavailable") from exc +def _apply_proof_status(status: str) -> LiveProofStatus: + if status in {"applied", "already_satisfied"}: + return LiveProofStatus.PASSED + if status == "not_applicable": + return LiveProofStatus.NOT_APPLICABLE + if status == "blocked": + return LiveProofStatus.BLOCKED + if status == "failed": + return LiveProofStatus.FAILED + raise LiveProofError("existing apply receipt result status is invalid") + + +def _validate_existing_apply_document(document: object, bindings: LiveProofBindings) -> tuple[JSONDocument, str]: if not is_json_document(document): raise LiveProofError("existing apply receipt is malformed") payload = dict(document) @@ -391,10 +618,29 @@ def _validated_existing_apply_receipt(path: Path, bindings: LiveProofBindings) - result = payload.get("result") if not isinstance(result, Mapping) or not is_json_document(result): raise LiveProofError("existing apply receipt result is malformed") - if result.get("status") not in _ACCEPTED_APPLY_STATUSES: + status = result.get("status") + if not isinstance(status, str) or status not in _APPLY_RESULT_STATUSES: raise LiveProofError("existing apply receipt result status is not successful") _validate_private_path_references(receipt_bindings.get("private_paths")) - return require_json_document(result, context="existing apply receipt result"), digest + return require_json_document(document, context="existing apply receipt"), digest + + +def _validated_existing_apply_receipt(path: Path, bindings: LiveProofBindings) -> tuple[JSONDocument, str]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LiveProofError("existing apply receipt is unavailable") from exc + return _validate_existing_apply_document(document, bindings) + + +def _apply_residues(status: LiveProofStatus) -> tuple[LiveProofResidue, ...]: + if status is LiveProofStatus.NOT_APPLICABLE: + return (LiveProofResidue(LiveProofResidueKind.NOT_APPLICABLE, "apply-result-not-applicable"),) + if status is LiveProofStatus.BLOCKED: + return (LiveProofResidue(LiveProofResidueKind.BLOCKED, "apply-result-blocked"),) + if status is LiveProofStatus.FAILED: + return (LiveProofResidue(LiveProofResidueKind.CHECK_FAILED, "apply-result-failed"),) + return () def collect_live_proof( @@ -420,15 +666,20 @@ def collect_live_proof( bindings = capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) if spec.producer == "archive_verification": result, residues = _archive_verification_result( - spec, archive_root, candidate_generation_id=candidate_generation_id + spec, + archive_root, + candidate_generation_id=candidate_generation_id, + bindings=bindings, ) input_digests: tuple[str, ...] = () else: assert apply_receipt_path is not None - apply_result, digest = _validated_existing_apply_receipt(apply_receipt_path, bindings) - result = {"status": LiveProofStatus.PASSED.value, "apply_receipt": apply_result} + apply_receipt, digest = _validated_existing_apply_receipt(apply_receipt_path, bindings) + apply_result = require_json_document(apply_receipt["result"], context="existing apply receipt result") + proof_status = _apply_proof_status(cast(str, apply_result["status"])) + result = {"status": proof_status.value, "apply_receipt": apply_receipt} input_digests = (digest,) - residues = () + residues = _apply_residues(proof_status) return LiveProofReceipt( proof_id=spec.proof_id, bead_id=spec.bead_id, @@ -441,14 +692,124 @@ def collect_live_proof( ) -def validate_live_proof_receipt( +def _parse_residues(value: object) -> tuple[LiveProofResidue, ...]: + if not isinstance(value, list): + raise LiveProofError("live-proof receipt residues are malformed") + parsed: list[LiveProofResidue] = [] + seen: set[tuple[LiveProofResidueKind, str]] = set() + for residue in value: + if not isinstance(residue, Mapping): + raise LiveProofError("live-proof receipt residues are malformed") + try: + parsed_residue = LiveProofResidue( + LiveProofResidueKind(cast(str, residue["kind"])), cast(str, residue["code"]) + ) + except (KeyError, TypeError, ValueError) as exc: + raise LiveProofError("live-proof receipt residues are malformed") from exc + residue_key = (parsed_residue.kind, parsed_residue.code) + if not _RESIDUE_CODE_RE.fullmatch(parsed_residue.code) or residue_key in seen: + raise LiveProofError("live-proof receipt residues are malformed") + seen.add(residue_key) + parsed.append(parsed_residue) + return tuple(parsed) + + +def _validate_status_residues(status: LiveProofStatus, residues: tuple[LiveProofResidue, ...]) -> None: + expected = { + LiveProofStatus.PASSED: frozenset(), + LiveProofStatus.FAILED: frozenset({LiveProofResidueKind.CHECK_FAILED, LiveProofResidueKind.UNVERIFIED}), + LiveProofStatus.BLOCKED: frozenset({LiveProofResidueKind.BLOCKED}), + LiveProofStatus.NOT_APPLICABLE: frozenset({LiveProofResidueKind.NOT_APPLICABLE}), + }[status] + if (not residues and status is not LiveProofStatus.PASSED) or any( + residue.kind not in expected for residue in residues + ): + raise LiveProofError("live-proof receipt status and residues are inconsistent") + if status is LiveProofStatus.PASSED and residues: + raise LiveProofError("live-proof receipt status and residues are inconsistent") + + +def _validate_archive_result( + spec: LiveProofSpec, result: JSONDocument +) -> tuple[LiveProofStatus, tuple[LiveProofResidue, ...]]: + archive_verification = result.get("archive_verification") + if not isinstance(archive_verification, Mapping): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + profiles = archive_verification.get("profiles") + if not isinstance(profiles, Mapping) or set(profiles) != {profile.name for profile in spec.archive_profiles}: + raise LiveProofError("live-proof receipt archive verification evidence is incomplete") + residues: list[LiveProofResidue] = [] + for profile in spec.archive_profiles: + evidence = profiles.get(profile.name) + if not isinstance(evidence, Mapping): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + checks = evidence.get("checks") + if not isinstance(checks, list): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + names: list[str] = [] + statuses: list[str] = [] + for check in checks: + if not isinstance(check, Mapping): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + name = check.get("name") + status = check.get("status") + if not isinstance(name, str) or not isinstance(status, str): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + names.append(name) + statuses.append(status) + if tuple(names) != profile.checks: + raise LiveProofError("live-proof receipt archive verification profile is not canonical") + residues.extend( + LiveProofResidue(LiveProofResidueKind.CHECK_FAILED, f"{profile.name}:{name}") + for name, status in zip(names, statuses, strict=True) + if status != "ok" + ) + return (LiveProofStatus.FAILED, tuple(residues)) if residues else (LiveProofStatus.PASSED, ()) + + +def _validate_route_result( + spec: LiveProofSpec, result: JSONDocument +) -> tuple[LiveProofStatus, tuple[LiveProofResidue, ...]]: + status = result.get("status") + try: + parsed_status = LiveProofStatus(cast(str, status)) + except (TypeError, ValueError) as exc: + raise LiveProofError("live-proof receipt result status is malformed") from exc + if spec.producer == "archive_verification": + expected, residues = _validate_archive_result(spec, result) + if parsed_status is not expected: + raise LiveProofError("live-proof receipt archive verification status is inconsistent") + else: + apply_receipt = result.get("apply_receipt") + if not isinstance(apply_receipt, Mapping) or not is_json_document(apply_receipt): + raise LiveProofError("live-proof receipt apply evidence is malformed") + apply_result = apply_receipt.get("result") + if not isinstance(apply_result, Mapping) or not is_json_document(apply_result): + raise LiveProofError("live-proof receipt apply evidence is malformed") + raw_status = apply_result.get("status") + if not isinstance(raw_status, str) or raw_status not in _APPLY_RESULT_STATUSES: + raise LiveProofError("live-proof receipt apply evidence is malformed") + expected = _apply_proof_status(raw_status) + if parsed_status is not expected: + raise LiveProofError("live-proof receipt apply status is inconsistent") + residues = _apply_residues(expected) + return parsed_status, residues + + +def _capture_expected_bindings(archive_root: Path, candidate_generation_id: str | None) -> LiveProofBindings: + try: + return capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) + except LiveProofError as exc: + raise LiveProofError("live-proof receipt bindings are stale or mismatched") from exc + + +def _validate_live_proof_receipt( document: object, archive_root: Path, *, candidate_generation_id: str | None = None, + expected_bindings: LiveProofBindings | None = None, ) -> LiveProofReceipt: - """Validate a self-hashed proof receipt against present archive bindings.""" - if not is_json_document(document): raise LiveProofError("live-proof receipt is malformed") payload = dict(document) @@ -465,8 +826,6 @@ def validate_live_proof_receipt( raise LiveProofError("live-proof receipt protocol identity is invalid") if payload.get("registry_version") != LIVE_PROOF_REGISTRY_VERSION or mode is not spec.mode: raise LiveProofError("live-proof receipt registry binding is stale") - if candidate_generation_id is not None and mode is not LiveProofMode.CANDIDATE: - raise LiveProofError("candidate validation requires a candidate proof receipt") receipt_bindings = payload.get("bindings") if not isinstance(receipt_bindings, Mapping): raise LiveProofError("live-proof receipt bindings are malformed") @@ -478,30 +837,29 @@ def validate_live_proof_receipt( candidate_generation_id = recorded_candidate elif candidate_generation_id != recorded_candidate: raise LiveProofError("candidate proof receipt targets a different generation") - expected = capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) - if payload.get("bindings") != expected.to_document(): + elif candidate_generation_id is not None: + raise LiveProofError("candidate validation requires a candidate proof receipt") + expected = expected_bindings or _capture_expected_bindings(archive_root, candidate_generation_id) + if receipt_bindings != expected.to_document(): raise LiveProofError("live-proof receipt bindings are stale or mismatched") result = payload.get("result") - residues = payload.get("residues") input_digests = payload.get("input_receipt_digests") - if not is_json_document(result) or not isinstance(residues, list) or not isinstance(input_digests, list): + if not is_json_document(result) or not isinstance(input_digests, list): raise LiveProofError("live-proof receipt evidence is malformed") - try: - LiveProofStatus(cast(str, result["status"])) - except (KeyError, TypeError, ValueError) as exc: - raise LiveProofError("live-proof receipt result status is malformed") from exc - parsed_residues: list[LiveProofResidue] = [] - for residue in residues: - if not isinstance(residue, Mapping): - raise LiveProofError("live-proof receipt residues are malformed") - try: - parsed_residues.append( - LiveProofResidue(LiveProofResidueKind(cast(str, residue["kind"])), cast(str, residue["code"])) - ) - except (KeyError, TypeError, ValueError) as exc: - raise LiveProofError("live-proof receipt residues are malformed") from exc + residues = _parse_residues(payload.get("residues")) + status, expected_residues = _validate_route_result(spec, result) + if residues != expected_residues: + raise LiveProofError("live-proof receipt status and residues are inconsistent") + _validate_status_residues(status, residues) if any(not isinstance(value, str) or not _SHA256_RE.fullmatch(value) for value in input_digests): raise LiveProofError("live-proof receipt input digests are malformed") + if spec.mode is LiveProofMode.EXISTING_APPLY_RECEIPT: + apply_receipt = result.get("apply_receipt") + _apply_result, embedded_digest = _validate_existing_apply_document(apply_receipt, expected) + if input_digests != [embedded_digest]: + raise LiveProofError("live-proof receipt apply evidence is not bound to its input digest") + if spec.mode is not LiveProofMode.EXISTING_APPLY_RECEIPT and input_digests: + raise LiveProofError("live-proof receipt input digests are malformed") return LiveProofReceipt( proof_id=proof_id, bead_id=spec.bead_id, @@ -509,33 +867,80 @@ def validate_live_proof_receipt( registry_version=LIVE_PROOF_REGISTRY_VERSION, bindings=expected, result=result, - residues=tuple(parsed_residues), + residues=residues, input_receipt_digests=tuple(cast(list[str], input_digests)), ) -def _require_acceptable_result(receipt: LiveProofReceipt) -> None: - """Reject failed proof evidence at every aggregate boundary.""" +def validate_live_proof_receipt( + document: object, + archive_root: Path, + *, + candidate_generation_id: str | None = None, +) -> LiveProofReceipt: + """Validate a self-hashed proof receipt against current archive bindings.""" - try: - status = LiveProofStatus(cast(str, receipt.result["status"])) - except (KeyError, TypeError, ValueError) as exc: # validated above; keeps this boundary total. - raise LiveProofError("live-proof receipt result status is malformed") from exc + return _validate_live_proof_receipt(document, archive_root, candidate_generation_id=candidate_generation_id) + + +def _require_acceptable_result(receipt: LiveProofReceipt) -> None: + status, expected_residues = _validate_route_result(live_proof_spec(receipt.proof_id.value), receipt.result) + if receipt.residues != expected_residues: + raise LiveProofError("live-proof receipt status and residues are inconsistent") + _validate_status_residues(status, receipt.residues) if status is LiveProofStatus.PASSED: return - if status is LiveProofStatus.NOT_APPLICABLE and any( - residue.kind is LiveProofResidueKind.NOT_APPLICABLE for residue in receipt.residues - ): + if status is LiveProofStatus.NOT_APPLICABLE: return raise LiveProofError("live-proof receipt result is not acceptable to an aggregate") -def validate_live_operation_aggregate(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: - """Consumer seam for the live-operation aggregate, without scheduling work.""" +def _candidate_id_from_documents(receipts: Sequence[object]) -> str | None: + candidates: set[str] = set() + for receipt in receipts: + if not isinstance(receipt, Mapping) or receipt.get("mode") != LiveProofMode.CANDIDATE.value: + continue + bindings = receipt.get("bindings") + if not isinstance(bindings, Mapping) or not isinstance(bindings.get("candidate_generation_id"), str): + raise LiveProofError("candidate proof receipt has no candidate binding") + candidates.add(cast(str, bindings["candidate_generation_id"])) + if len(candidates) > 1: + raise LiveProofError("proof aggregate targets multiple candidate generations") + return next(iter(candidates), None) + + +def _active_bindings(bindings: LiveProofBindings) -> LiveProofBindings: + return replace( + bindings, + candidate_generation_id=None, + candidate_index_sha256=None, + candidate_index_schema_version=None, + private_paths=tuple((name, path) for name, path in bindings.private_paths if name != "candidate_index"), + ) - validated = tuple(validate_live_proof_receipt(receipt, archive_root) for receipt in receipts) - if not validated: + +def _validate_aggregate(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: + if not receipts: raise LiveProofError("live-operation aggregate requires at least one proof receipt") + candidate_id = _candidate_id_from_documents(receipts) + candidate_bindings = _capture_expected_bindings(archive_root, candidate_id) if candidate_id is not None else None + active_bindings = ( + _active_bindings(candidate_bindings) + if candidate_bindings is not None + else _capture_expected_bindings(archive_root, None) + ) + validated: list[LiveProofReceipt] = [] + for receipt in receipts: + mode = receipt.get("mode") if isinstance(receipt, Mapping) else None + expected = candidate_bindings if mode == LiveProofMode.CANDIDATE.value else active_bindings + validated.append(_validate_live_proof_receipt(receipt, archive_root, expected_bindings=expected)) + return tuple(validated) + + +def validate_live_operation_aggregate(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: + """Consumer seam for a validated aggregate, without scheduling work.""" + + validated = _validate_aggregate(receipts, archive_root) for receipt in validated: _require_acceptable_result(receipt) return validated @@ -544,44 +949,82 @@ def validate_live_operation_aggregate(receipts: Sequence[object], archive_root: def validate_candidate_proof_receipts( receipts: Sequence[object], archive_root: Path, *, candidate_generation_id: str ) -> tuple[LiveProofReceipt, ...]: - """Consumer seam for candidate acceptance, restricted to one inactive generation.""" + """Accept only the canonical candidate route for one inactive generation.""" + candidate_bindings = _capture_expected_bindings(archive_root, candidate_generation_id) validated = tuple( - validate_live_proof_receipt(receipt, archive_root, candidate_generation_id=candidate_generation_id) + _validate_live_proof_receipt( + receipt, + archive_root, + candidate_generation_id=candidate_generation_id, + expected_bindings=candidate_bindings, + ) for receipt in receipts ) if not validated: raise LiveProofError("candidate proof consumer requires at least one proof receipt") + if any(receipt.proof_id is not LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION for receipt in validated): + raise LiveProofError("candidate proof consumer requires candidate route evidence") for receipt in validated: _require_acceptable_result(receipt) return validated def validate_final_proof_receipts(receipts: Sequence[object], archive_root: Path) -> tuple[LiveProofReceipt, ...]: - """Consumer seam for final-proof aggregation, without emitting a terminal proof.""" + """Require every fixed proof route before treating a campaign proof as final.""" + + validated = validate_live_operation_aggregate(receipts, archive_root) + proof_ids = [receipt.proof_id for receipt in validated] + if len(proof_ids) != len(LiveProofId) or set(proof_ids) != set(LiveProofId): + raise LiveProofError("final proof requires complete route coverage exactly once") + return validated - return validate_live_operation_aggregate(receipts, archive_root) + +def _fsync_directory(path: Path) -> None: + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: - """Write a receipt once, outside the archive, with exclusive creation.""" + """Atomically publish a fully durable receipt without replacing an existing one.""" target = Path(path).expanduser().resolve() - target.parent.mkdir(parents=True, exist_ok=True) encoded = ( json.dumps(receipt.to_document(), sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n" ).encode("utf-8") + descriptor: int | None = None + temporary: Path | None = None try: - descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + target.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + temporary = Path(temporary_name) + os.fchmod(descriptor, 0o600) + offset = 0 + while offset < len(encoded): + offset += os.write(descriptor, encoded[offset:]) + os.fsync(descriptor) + os.close(descriptor) + descriptor = None + os.link(temporary, target) + _fsync_directory(target.parent) + temporary.unlink() + temporary = None + _fsync_directory(target.parent) except FileExistsError as exc: raise LiveProofError("live-proof receipt output already exists") from exc - try: - with os.fdopen(descriptor, "wb", closefd=False) as stream: - stream.write(encoded) - stream.flush() - os.fsync(stream.fileno()) - finally: - os.close(descriptor) + except OSError as exc: + if descriptor is not None: + os.close(descriptor) + if temporary is not None: + try: + temporary.unlink() + _fsync_directory(target.parent) + except OSError: + pass + raise LiveProofError("live-proof receipt output could not be written") from exc validate_live_proof_registry() @@ -591,6 +1034,7 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: "LIVE_PROOF_RECEIPT_SCHEMA", "LIVE_PROOF_REGISTRY_VERSION", "LIVE_PROOF_SPECS", + "LiveProofArchiveProfile", "LiveProofBindings", "LiveProofError", "LiveProofId", diff --git a/tests/unit/cli/test_maintenance_live_proof_cli.py b/tests/unit/cli/test_maintenance_live_proof_cli.py index bd2315bc66..63cfe87e15 100644 --- a/tests/unit/cli/test_maintenance_live_proof_cli.py +++ b/tests/unit/cli/test_maintenance_live_proof_cli.py @@ -9,6 +9,7 @@ from click.testing import CliRunner from polylogue.cli.click_app import cli +from polylogue.maintenance import live_proof def test_live_proof_cli_dispatches_registered_read_only_proof( @@ -60,3 +61,32 @@ def test_live_proof_cli_rejects_unknown_route_without_creating_output( assert result.exit_code != 0 assert "unknown live-proof id" in result.output assert not output.exists() + + +def test_live_proof_cli_translates_output_os_error( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "c" * 40) + output = cli_workspace["archive_root"].parent / "live-proof-output-error.json" + + def fail_write(_path: Path, _receipt: object) -> None: + raise OSError("read-only filesystem") + + monkeypatch.setattr(live_proof, "write_live_proof_receipt", fail_write) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "live-proof", + "--proof-id", + "archive-verification", + "--output", + str(output), + ], + ) + + assert result.exit_code != 0 + assert "live-proof receipt output could not be written" in result.output + assert not output.exists() diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 7bad31d659..6787fb3b46 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -1,20 +1,24 @@ """Tests for the fixed live-proof protocol. -The production dependency exercised here is the archive-verification registry -and the source/index binding readers. The red mutations alter one captured -binding or candidate metadata after collection; validation must reject them, -which a receipt-only serializer would incorrectly accept. +The production dependencies exercised here are the archive-verification +profiles and archive generation store. Red mutations alter a captured binding, +generation record, result status, or output write so consumers must reject +evidence a receipt-only serializer would otherwise accept. """ from __future__ import annotations import json -import shutil +import os +import sqlite3 +import subprocess from pathlib import Path import pytest from polylogue.core.hashing import hash_payload +from polylogue.core.json import JSONDocument +from polylogue.maintenance import live_proof from polylogue.maintenance.live_proof import ( EXISTING_APPLY_RECEIPT_SCHEMA, LIVE_PROOF_SPECS, @@ -25,11 +29,17 @@ capture_live_proof_bindings, collect_live_proof, validate_candidate_proof_receipts, + validate_final_proof_receipts, validate_live_operation_aggregate, validate_live_proof_receipt, validate_live_proof_registry, + write_live_proof_receipt, ) +from polylogue.maintenance.schema_inference_gate import rebuild_source_revision_snapshot +from polylogue.storage.archive_identity import ArchiveLocation +from polylogue.storage.index_generation import IndexGenerationStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.version import VERSION_INFO @pytest.fixture @@ -51,25 +61,16 @@ def _apply_receipt(bindings: LiveProofBindings, *, status: str = "applied") -> d def _candidate(root: Path) -> str: - generation_id = "gen-live-proof" - generation = root / ".index-generations" / generation_id - generation.mkdir(parents=True) - candidate_index = generation / "index.db" - shutil.copy2(root / "index.db", candidate_index) - (generation / "generation.json").write_text( - json.dumps( - { - "generation_id": generation_id, - "owner_id": "proof-owner", - "archive_root": str(root), - "index_path": str(candidate_index), - "state": "inactive", - "source_snapshot": "candidate-source-snapshot", - } - ), - encoding="utf-8", - ) - return generation_id + store = IndexGenerationStore(ArchiveLocation.resolve(root)) + generation = store.create(source_snapshot=rebuild_source_revision_snapshot(root)) + return generation.generation_id + + +def _rehash(document: JSONDocument) -> JSONDocument: + unsigned = dict(document) + unsigned.pop("receipt_sha256") + document["receipt_sha256"] = hash_payload(unsigned) + return document def test_fixed_registry_has_exactly_the_three_supported_modes() -> None: @@ -80,14 +81,24 @@ def test_fixed_registry_has_exactly_the_three_supported_modes() -> None: assert all(not callable(spec.producer) for spec in LIVE_PROOF_SPECS) -def test_read_only_receipt_is_deterministic_self_hashed_and_private_path_safe(archive_root: Path) -> None: - first = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) - second = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) - - assert first.to_document() == second.to_document() - assert first.to_document()["receipt_sha256"] == first.receipt_sha256 - assert str(archive_root) not in json.dumps(first.to_document()) - assert {name for name, _version in first.bindings.schema_versions} == { +def test_read_only_receipt_preserves_full_redacted_canonical_evidence(archive_root: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + repeated = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + + assert receipt.to_document() == repeated.to_document() + evidence = receipt.result["archive_verification"] + assert isinstance(evidence, dict) + profiles = evidence["profiles"] + assert isinstance(profiles, dict) + assert list(profiles) == ["active-archive"] + active = profiles["active-archive"] + assert isinstance(active, dict) + checks = active["checks"] + assert isinstance(checks, list) + assert len(checks) > 2 + assert str(archive_root) not in json.dumps(receipt.to_document()) + assert receipt.to_document()["receipt_sha256"] == receipt.receipt_sha256 + assert {name for name, _version in receipt.bindings.schema_versions} == { "audit", "embeddings", "index", @@ -95,7 +106,38 @@ def test_read_only_receipt_is_deterministic_self_hashed_and_private_path_safe(ar "source", "user", } - assert validate_live_proof_receipt(first.to_document(), archive_root) == first + assert receipt.bindings.active_index_sha256 + assert validate_live_proof_receipt(receipt.to_document(), archive_root) == receipt + + +def test_read_only_receipt_redacts_an_external_active_index_path(archive_root: Path) -> None: + external_root = archive_root.parent / "private-active-index" + external_root.mkdir() + external_index = external_root / "index.db" + os.link(archive_root / "index.db", external_index) + (archive_root / ".index-active-pointer").write_text(str(external_index), encoding="utf-8") + + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + + assert str(external_root) not in json.dumps(receipt.to_document()) + + +def test_read_only_receipt_redacts_external_verification_evidence(archive_root: Path) -> None: + external_source = archive_root.parent / "private source" / "session.jsonl" + with sqlite3.connect(archive_root / "ops.db") as connection: + connection.execute( + """ + INSERT INTO ingest_cursor(source_path, excluded, stat_size, byte_offset, updated_at_ms) + VALUES (?, 0, 100000000, 5000000, 0) + """, + (str(external_source),), + ) + + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + + serialized = json.dumps(receipt.to_document()) + assert str(external_source) not in serialized + assert "[private-path:" in serialized def test_mode_inputs_are_isolated(archive_root: Path, tmp_path: Path) -> None: @@ -130,28 +172,77 @@ def test_existing_apply_receipt_is_bound_and_rejects_controlled_binding_mutation bindings = dict(binding_value) bindings["source_snapshot"] = "0" * 64 mutated["bindings"] = bindings - unsigned = dict(mutated) - unsigned.pop("receipt_sha256") - mutated["receipt_sha256"] = hash_payload(unsigned) with pytest.raises(LiveProofError, match="bindings are stale"): - validate_live_proof_receipt(mutated, archive_root) + validate_live_proof_receipt(_rehash(mutated), archive_root) + + +@pytest.mark.parametrize( + ("apply_status", "proof_status"), + [("applied", "passed"), ("already_satisfied", "passed"), ("not_applicable", "not_applicable")], +) +def test_existing_apply_receipt_preserves_terminal_result_status( + archive_root: Path, tmp_path: Path, apply_status: str, proof_status: str +) -> None: + apply_path = tmp_path / "apply-receipt.json" + apply_path.write_text( + json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status=apply_status)), encoding="utf-8" + ) + + receipt = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + + assert receipt.result["status"] == proof_status + apply_receipt = receipt.result["apply_receipt"] + assert isinstance(apply_receipt, dict) + apply_result = apply_receipt["result"] + assert isinstance(apply_result, dict) + assert apply_result["status"] == apply_status + assert validate_live_operation_aggregate((receipt.to_document(),), archive_root) == (receipt,) + + +def test_existing_apply_receipt_embeds_the_digest_bound_input(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "apply-receipt.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + receipt = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + mutated = receipt.to_document() + result = mutated["result"] + assert isinstance(result, dict) + embedded = result["apply_receipt"] + assert isinstance(embedded, dict) + embedded_result = embedded["result"] + assert isinstance(embedded_result, dict) + embedded_result["status"] = "failed" + _rehash(embedded) + result["status"] = "failed" + mutated["residues"] = [{"kind": "check_failed", "code": "apply-result-failed"}] + + with pytest.raises(LiveProofError, match="input digest"): + validate_live_proof_receipt(_rehash(mutated), archive_root) -def test_existing_apply_receipt_rejects_non_successful_result(archive_root: Path, tmp_path: Path) -> None: +def test_existing_apply_receipt_keeps_failure_as_failed_evidence(archive_root: Path, tmp_path: Path) -> None: apply_path = tmp_path / "failed-apply-receipt.json" + apply_path.write_text( + json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status="failed")), encoding="utf-8" + ) + + receipt = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + + assert receipt.result["status"] == "failed" + with pytest.raises(LiveProofError, match="not acceptable"): + validate_live_operation_aggregate((receipt.to_document(),), archive_root) + + +def test_existing_apply_receipt_rejects_unknown_result(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "unknown-apply-receipt.json" apply_path.write_text( json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status="unknown")), encoding="utf-8" ) with pytest.raises(LiveProofError, match="result status is not successful"): - collect_live_proof( - LiveProofId.EXISTING_APPLY_RECEIPT.value, - archive_root, - apply_receipt_path=apply_path, - ) + collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) -def test_candidate_receipt_binds_exact_inactive_generation_and_detects_content_mutation(archive_root: Path) -> None: +def test_candidate_receipt_binds_canonical_generation_and_all_profiles(archive_root: Path) -> None: generation_id = _candidate(archive_root) receipt = collect_live_proof( LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, @@ -161,22 +252,209 @@ def test_candidate_receipt_binds_exact_inactive_generation_and_detects_content_m assert receipt.bindings.candidate_generation_id == generation_id assert receipt.bindings.candidate_index_sha256 is not None - candidate_index = archive_root / ".index-generations" / generation_id / "index.db" - with candidate_index.open("ab") as stream: + verification = receipt.result["archive_verification"] + assert isinstance(verification, dict) + profiles = verification["profiles"] + assert isinstance(profiles, dict) + assert set(profiles) == {"candidate-index", "candidate-cross-tier"} + candidate_path = Path(IndexGenerationStore(ArchiveLocation.resolve(archive_root)).load(generation_id).index_path) + with candidate_path.open("ab") as stream: stream.write(b"binding mutation") with pytest.raises(LiveProofError, match="bindings are stale"): validate_candidate_proof_receipts((receipt.to_document(),), archive_root, candidate_generation_id=generation_id) -def test_aggregate_rejects_a_self_hashed_failed_proof_result(archive_root: Path) -> None: +def test_candidate_rejects_source_snapshot_drift(archive_root: Path) -> None: + generation_id = _candidate(archive_root) + store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) + generation_path = store.generations_root / generation_id / "generation.json" + payload = json.loads(generation_path.read_text(encoding="utf-8")) + payload["source_snapshot"] = "outdated" + generation_path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(LiveProofError, match="candidate generation binding"): + collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + + +def test_candidate_rejects_symlink_index(archive_root: Path) -> None: + generation_id = _candidate(archive_root) + store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) + candidate_path = Path(store.load(generation_id).index_path) + candidate_path.unlink() + candidate_path.symlink_to(archive_root / "index.db") + + with pytest.raises(LiveProofError, match="candidate generation binding"): + collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + + +def test_candidate_rejects_poisoned_active_pointer_without_repairing_it(archive_root: Path) -> None: + generation_id = _candidate(archive_root) + store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) + candidate_path = Path(store.load(generation_id).index_path) + pointer = archive_root / ".index-active-pointer" + pointer.write_text(str(candidate_path), encoding="utf-8") + + with pytest.raises(LiveProofError, match="canonical active-index pointer"): + collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + + assert pointer.read_text(encoding="utf-8") == str(candidate_path) + + +def test_receipt_rejects_invalid_residue_code(archive_root: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + mutated = receipt.to_document() + residues = mutated["residues"] + assert isinstance(residues, list) and residues + residue = residues[0] + assert isinstance(residue, dict) + residue["code"] = "not a valid code" + + with pytest.raises(LiveProofError, match="residues are malformed"): + validate_live_proof_receipt(_rehash(mutated), archive_root) + + +def test_receipt_rejects_residues_mismatched_to_check_evidence(archive_root: Path) -> None: receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) - failed = receipt.to_document() - result = failed["result"] + mutated = receipt.to_document() + result = mutated["result"] assert isinstance(result, dict) result["status"] = "failed" - unsigned = dict(failed) - unsigned.pop("receipt_sha256") - failed["receipt_sha256"] = hash_payload(unsigned) + verification = result["archive_verification"] + assert isinstance(verification, dict) + profiles = verification["profiles"] + assert isinstance(profiles, dict) + profile = profiles["active-archive"] + assert isinstance(profile, dict) + checks = profile["checks"] + assert isinstance(checks, list) and checks + check = checks[0] + assert isinstance(check, dict) + check["status"] = "error" + + with pytest.raises(LiveProofError, match="status and residues are inconsistent"): + validate_live_proof_receipt(_rehash(mutated), archive_root) + + +def test_aggregate_rejects_a_self_hashed_failed_proof_result(archive_root: Path) -> None: + bindings = capture_live_proof_bindings(archive_root) + document = _apply_receipt(bindings, status="failed") + apply_path = archive_root.parent / "apply.json" + apply_path.write_text(json.dumps(document), encoding="utf-8") + failed = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) with pytest.raises(LiveProofError, match="not acceptable"): - validate_live_operation_aggregate((failed,), archive_root) + validate_live_operation_aggregate((failed.to_document(),), archive_root) + + +def test_final_proof_requires_every_route_exactly_once(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "apply.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + receipt = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + + with pytest.raises(LiveProofError, match="complete route coverage"): + validate_final_proof_receipts((receipt.to_document(),), archive_root) + + +def test_aggregate_captures_candidate_and_active_bindings_once( + archive_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + generation_id = _candidate(archive_root) + apply_path = tmp_path / "apply.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + receipts = ( + collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root).to_document(), + collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ).to_document(), + collect_live_proof( + LiveProofId.EXISTING_APPLY_RECEIPT.value, + archive_root, + apply_receipt_path=apply_path, + ).to_document(), + ) + original = live_proof._capture_expected_bindings + calls = 0 + + def capture_once(root: Path, candidate_id: str | None) -> LiveProofBindings: + nonlocal calls + calls += 1 + if calls > 1: + raise AssertionError("aggregate captured another active snapshot") + return original(root, candidate_id) + + monkeypatch.setattr(live_proof, "_capture_expected_bindings", capture_once) + + assert live_proof._validate_aggregate(receipts, archive_root) + assert calls == 1 + + +def test_readonly_uri_encodes_sqlite_metacharacters(tmp_path: Path) -> None: + database = tmp_path / "archive?name#fragment.db" + sqlite3.connect(database).close() + + uri = live_proof._readonly_uri(database) + + assert "%3F" in uri and "%23" in uri + with sqlite3.connect(uri, uri=True) as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (0,) + + +def test_capture_rejects_archive_paths_unsafe_for_dependency_uris( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "archive?name#fragment" + initialize_active_archive_root(root) + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "a" * 40) + + with pytest.raises(LiveProofError, match="SQLite URI query characters"): + capture_live_proof_bindings(root) + + +def test_git_fallback_rejects_dirty_worktree(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("POLYLOGUE_CODE_SHA", raising=False) + monkeypatch.setattr( + live_proof, + "_run_git", + lambda *_args: subprocess.CompletedProcess(args=(), returncode=0, stdout=" M live_proof.py\n", stderr=""), + ) + + with pytest.raises(LiveProofError, match="clean git worktree"): + live_proof._code_sha() + + +def test_installed_code_identity_uses_version_info_commit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("POLYLOGUE_CODE_SHA", raising=False) + monkeypatch.setattr(VERSION_INFO, "commit", "B" * 40) + monkeypatch.setattr(live_proof, "__file__", "/installed/polylogue/maintenance/live_proof.py") + + assert live_proof._code_sha() == "b" * 40 + + +def test_receipt_write_removes_partial_output_after_os_error( + archive_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + target = tmp_path / "new-receipt.json" + + def fail_write(_descriptor: int, _payload: bytes) -> int: + raise OSError("disk full") + + monkeypatch.setattr(os, "write", fail_write) + with pytest.raises(LiveProofError, match="could not be written"): + write_live_proof_receipt(target, receipt) + + assert not target.exists() From 5b8d02e6c87a243ed576c9ae147e24d51b39c05e Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 14:57:08 +0200 Subject: [PATCH 05/12] fix(maintenance): protect external archive generation outputs Problem: the live-proof CLI rejected receipt paths inside the configured archive root but accepted paths inside an externally pointed active generation or rebuild directory. Candidate proof mutation tests also changed SQLite bytes outside a supported database operation.\n\nWhat changed: move archive-owned root discovery into the maintenance layer, reject receipt paths under configured and externally selected lifecycle roots, replace raw candidate-byte mutation with a SQLite schema mutation, and add CLI coverage for an external active generation.\n\nCompatibility/migration: receipt output remains allowed in unrelated private directories. No archive files are mutated by the proof command.\n\nRef polylogue-x97cf\n\nCo-Authored-By: Codex --- .../cli/commands/maintenance/_live_proof.py | 20 ++++++++---- polylogue/maintenance/live_proof.py | 16 ++++++++++ .../cli/test_maintenance_live_proof_cli.py | 32 +++++++++++++++++++ tests/unit/maintenance/test_live_proof.py | 9 ++++-- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/polylogue/cli/commands/maintenance/_live_proof.py b/polylogue/cli/commands/maintenance/_live_proof.py index dbcab6f2d2..0a18139bbb 100644 --- a/polylogue/cli/commands/maintenance/_live_proof.py +++ b/polylogue/cli/commands/maintenance/_live_proof.py @@ -10,6 +10,10 @@ from polylogue.paths import archive_root +def _is_under_any(path: Path, roots: tuple[Path, ...]) -> bool: + return any(path == root or root in path.parents for root in roots) + + @click.command("live-proof") @click.option("--proof-id", required=True, help="One registered live-proof id.") @click.option("--candidate-generation", type=str, help="Inactive generation id for the candidate proof route only.") @@ -32,17 +36,19 @@ def live_proof_command( ) -> None: """Collect one registered proof without mutating archive or daemon state.""" - from polylogue.maintenance.live_proof import LiveProofError, collect_live_proof, write_live_proof_receipt + from polylogue.maintenance.live_proof import ( + LiveProofError, + archive_owned_storage_roots, + collect_live_proof, + write_live_proof_receipt, + ) root = archive_root().resolve() target = output.expanduser().resolve() try: - target.relative_to(root) - except ValueError: - pass - else: - raise click.BadParameter("receipt output must be outside the archive root", param_hint="--output") - try: + owned_roots = archive_owned_storage_roots(root) + if _is_under_any(target, owned_roots): + raise click.BadParameter("receipt output must be outside archive-owned storage", param_hint="--output") receipt = collect_live_proof( proof_id, root, diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index fb14fbab4d..b138a0641e 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -330,6 +330,22 @@ def _require_uri_safe_location(location: object) -> None: _require_quiescent_sqlite(paths[1:]) +def archive_owned_storage_roots(archive_root: Path) -> tuple[Path, ...]: + """Return every resolved root whose files belong to archive lifecycle state.""" + + from polylogue.storage.archive_identity import ArchiveLocation + + location = ArchiveLocation.resolve(archive_root) + bases = {location.configured_root.resolve(), location.active_index_path.parent.resolve()} + if location.active_pointer is not None: + bases.add(location.active_pointer.parent.resolve()) + roots = set(bases) + for base in bases: + roots.add((base / ".index-generations").resolve()) + roots.add((base / ".index-rebuild-transactions").resolve()) + return tuple(sorted(roots)) + + def _sqlite_file_state(path: Path, *, allow_symlink: bool) -> JSONDocument: try: metadata = path.lstat() diff --git a/tests/unit/cli/test_maintenance_live_proof_cli.py b/tests/unit/cli/test_maintenance_live_proof_cli.py index 63cfe87e15..f56938023c 100644 --- a/tests/unit/cli/test_maintenance_live_proof_cli.py +++ b/tests/unit/cli/test_maintenance_live_proof_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path import pytest @@ -63,6 +64,37 @@ def test_live_proof_cli_rejects_unknown_route_without_creating_output( assert not output.exists() +def test_live_proof_cli_rejects_output_under_external_active_generation( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "d" * 40) + archive_root = cli_workspace["archive_root"] + external_root = archive_root.parent / "external-active-generation" + external_root.mkdir() + external_index = external_root / "index.db" + os.link(archive_root / "index.db", external_index) + (archive_root / ".index-active-pointer").write_text(str(external_index), encoding="utf-8") + output = external_root / "live-proof.json" + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "live-proof", + "--proof-id", + "archive-verification", + "--output", + str(output), + ], + ) + + assert result.exit_code != 0 + assert "archive-owned storage" in result.output + assert not output.exists() + + def test_live_proof_cli_translates_output_os_error( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 6787fb3b46..24c2879cdd 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -78,7 +78,10 @@ def test_fixed_registry_has_exactly_the_three_supported_modes() -> None: assert {spec.mode for spec in LIVE_PROOF_SPECS} == set(LiveProofMode) assert {spec.proof_id for spec in LIVE_PROOF_SPECS} == set(LiveProofId) - assert all(not callable(spec.producer) for spec in LIVE_PROOF_SPECS) + assert {spec.producer for spec in LIVE_PROOF_SPECS} == { + "archive_verification", + "existing_apply_receipt", + } def test_read_only_receipt_preserves_full_redacted_canonical_evidence(archive_root: Path) -> None: @@ -258,8 +261,8 @@ def test_candidate_receipt_binds_canonical_generation_and_all_profiles(archive_r assert isinstance(profiles, dict) assert set(profiles) == {"candidate-index", "candidate-cross-tier"} candidate_path = Path(IndexGenerationStore(ArchiveLocation.resolve(archive_root)).load(generation_id).index_path) - with candidate_path.open("ab") as stream: - stream.write(b"binding mutation") + with sqlite3.connect(candidate_path) as connection: + connection.execute("CREATE TABLE proof_binding_mutation (marker TEXT NOT NULL)") with pytest.raises(LiveProofError, match="bindings are stale"): validate_candidate_proof_receipts((receipt.to_document(),), archive_root, candidate_generation_id=generation_id) From 6068926946951887cc9189dec5077066fdeedb91 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 15:23:10 +0200 Subject: [PATCH 06/12] test(maintenance): cover normalized live-proof inputs Problem: live-proof collection accepted path aliases without normalizing the archive root once, and the aggregate path lacked a direct not-applicable residue test. What changed: normalize the archive root before binding and verification, and add an aggregate test proving not-applicable status requires its typed residue. Verification: devtools test tests/unit/maintenance/test_live_proof.py tests/unit/cli/test_maintenance_live_proof_cli.py; devtools verify --quick Co-Authored-By: Claude --- polylogue/maintenance/live_proof.py | 5 +++-- tests/unit/maintenance/test_live_proof.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index b138a0641e..442c5a1f8a 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -669,6 +669,7 @@ def collect_live_proof( """Collect exactly one fixed proof route without operating on the archive.""" validate_live_proof_registry() + resolved_archive_root = Path(archive_root).expanduser().resolve() spec = live_proof_spec(proof_id) if spec.mode is LiveProofMode.CANDIDATE: if candidate_generation_id is None or apply_receipt_path is not None: @@ -679,11 +680,11 @@ def collect_live_proof( elif candidate_generation_id is not None or apply_receipt_path is not None: raise LiveProofError("read-only proof accepts no candidate or apply receipt input") - bindings = capture_live_proof_bindings(archive_root, candidate_generation_id=candidate_generation_id) + bindings = capture_live_proof_bindings(resolved_archive_root, candidate_generation_id=candidate_generation_id) if spec.producer == "archive_verification": result, residues = _archive_verification_result( spec, - archive_root, + resolved_archive_root, candidate_generation_id=candidate_generation_id, bindings=bindings, ) diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 24c2879cdd..9640a7d839 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -44,7 +44,7 @@ @pytest.fixture def archive_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - root = tmp_path / "private-archive" + root = (tmp_path / "private-archive").resolve() initialize_active_archive_root(root) monkeypatch.setenv("POLYLOGUE_CODE_SHA", "a" * 40) return root @@ -235,6 +235,22 @@ def test_existing_apply_receipt_keeps_failure_as_failed_evidence(archive_root: P validate_live_operation_aggregate((receipt.to_document(),), archive_root) +def test_existing_apply_receipt_requires_not_applicable_residue(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "not-applicable-apply-receipt.json" + apply_path.write_text( + json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status="not_applicable")), + encoding="utf-8", + ) + + receipt = collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + + assert validate_live_operation_aggregate((receipt.to_document(),), archive_root) == (receipt,) + mutated = receipt.to_document() + mutated["residues"] = [] + with pytest.raises(LiveProofError, match="status and residues are inconsistent"): + validate_live_operation_aggregate((_rehash(mutated),), archive_root) + + def test_existing_apply_receipt_rejects_unknown_result(archive_root: Path, tmp_path: Path) -> None: apply_path = tmp_path / "unknown-apply-receipt.json" apply_path.write_text( From dc51e99e21d3538987a910bda9278ffc61de5cb9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 15:33:58 +0200 Subject: [PATCH 07/12] test(maintenance): cover archive-root alias binding Problem: the live-proof root-normalization fix lacked a regression test for a symlinked archive path. What changed: collect a read-only receipt through an archive-root alias and compare its private binding with the canonical root reference. Verification: devtools test tests/unit/maintenance/test_live_proof.py tests/unit/cli/test_maintenance_live_proof_cli.py; devtools verify --quick Co-Authored-By: Claude --- tests/unit/maintenance/test_live_proof.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 9640a7d839..7acda4d105 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -113,6 +113,16 @@ def test_read_only_receipt_preserves_full_redacted_canonical_evidence(archive_ro assert validate_live_proof_receipt(receipt.to_document(), archive_root) == receipt +def test_read_only_receipt_normalizes_archive_root_alias(archive_root: Path, tmp_path: Path) -> None: + alias = tmp_path / "archive-alias" + alias.symlink_to(archive_root, target_is_directory=True) + + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, alias) + + private_paths = dict(receipt.bindings.private_paths) + assert private_paths["archive_root"] == live_proof.PrivatePathReference.capture(archive_root) + + def test_read_only_receipt_redacts_an_external_active_index_path(archive_root: Path) -> None: external_root = archive_root.parent / "private-active-index" external_root.mkdir() From 968c241b2ed06118d18d8ca45724c285fed68f36 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 16:10:08 +0200 Subject: [PATCH 08/12] test(maintenance): validate aliased live-proof receipts Problem: the archive-root alias regression covered capture bindings but did not exercise validation through the same alias.\n\nWhat changed: validate the captured receipt with the symlinked archive root so the capture and verification paths must normalize to the same canonical root.\n\nVerification: devtools test tests/unit/maintenance/test_live_proof.py tests/unit/cli/test_maintenance_live_proof_cli.py passed with 32 tests.\n\nRef polylogue-x97cf.\n\nCo-Authored-By: Codex --- tests/unit/maintenance/test_live_proof.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 7acda4d105..729ffa93be 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -121,6 +121,7 @@ def test_read_only_receipt_normalizes_archive_root_alias(archive_root: Path, tmp private_paths = dict(receipt.bindings.private_paths) assert private_paths["archive_root"] == live_proof.PrivatePathReference.capture(archive_root) + assert validate_live_proof_receipt(receipt.to_document(), alias) == receipt def test_read_only_receipt_redacts_an_external_active_index_path(archive_root: Path) -> None: From 1fbd1331b75493566116601e8fe72d1c2f05ebde Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 16:15:56 +0200 Subject: [PATCH 09/12] test(maintenance): redact aliased proof paths Problem: the archive-root alias test did not assert that the alias itself stayed out of serialized evidence.\n\nWhat changed: require the live-proof document to redact the symlink alias in addition to validating through that alias.\n\nVerification: devtools test tests/unit/maintenance/test_live_proof.py tests/unit/cli/test_maintenance_live_proof_cli.py passed with 32 tests.\n\nRef polylogue-x97cf.\n\nCo-Authored-By: Codex --- tests/unit/maintenance/test_live_proof.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 729ffa93be..4e065b4f92 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -121,6 +121,7 @@ def test_read_only_receipt_normalizes_archive_root_alias(archive_root: Path, tmp private_paths = dict(receipt.bindings.private_paths) assert private_paths["archive_root"] == live_proof.PrivatePathReference.capture(archive_root) + assert str(alias) not in json.dumps(receipt.to_document()) assert validate_live_proof_receipt(receipt.to_document(), alias) == receipt From f9089016bb48e7c381c7ec673183868d3829b35b Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 16:28:28 +0200 Subject: [PATCH 10/12] fix(maintenance): harden live-proof freshness and storage safety Problem: live-proof receipts could outlive time-sensitive checks, omit content changes in verified tiers, reject harmless checkpointed WAL files, attest candidates without a ready rebuild transaction, leak colon-prefixed paths, or leave a published receipt after directory sync failure.\n\nWhat changed: bind all active tier file sets, timestamp and expire receipts, read WAL-visible SQLite state, require the owning rebuild transaction to be ready, tighten path redaction, normalize archive pointer failures at the maintenance boundary, and remove published output on post-publication errors. Add focused regressions for each safety boundary.\n\nVerification: devtools test tests/unit/maintenance/test_live_proof.py tests/unit/cli/test_maintenance_live_proof_cli.py passed with 36 tests. devtools verify --quick passed all 24 steps.\n\nRef polylogue-x97cf.\n\nCo-Authored-By: Codex --- polylogue/maintenance/live_proof.py | 71 ++++++++++++++++----- tests/unit/maintenance/test_live_proof.py | 75 ++++++++++++++++++++++- 2 files changed, 128 insertions(+), 18 deletions(-) diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index 442c5a1f8a..279cb8cb1d 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -14,6 +14,7 @@ import sqlite3 import subprocess import tempfile +import time from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from enum import StrEnum @@ -32,9 +33,11 @@ _CODE_SHA_RE: Final = re.compile(r"[0-9a-f]{40,64}") _GENERATION_ID_RE: Final = re.compile(r"gen-[A-Za-z0-9][A-Za-z0-9._-]{0,127}") _RESIDUE_CODE_RE: Final = re.compile(r"[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*") -_ABSOLUTE_PATH_RE: Final = re.compile(r"(? JSONDocument: "archive_identity_digest": self.archive_identity_digest, "source_snapshot": self.source_snapshot, "schema_versions": dict(self.schema_versions), + "tier_file_set_digests": dict(self.tier_file_set_digests), "candidate_index_schema_version": self.candidate_index_schema_version, "parser_fingerprints": dict(self.parser_fingerprints), "lowering_fingerprint": self.lowering_fingerprint, @@ -151,6 +156,7 @@ class LiveProofReceipt: bead_id: str mode: LiveProofMode registry_version: int + generated_at_ms: int bindings: LiveProofBindings result: JSONDocument residues: tuple[LiveProofResidue, ...] @@ -163,6 +169,7 @@ def payload(self) -> JSONDocument: "bead_id": self.bead_id, "mode": self.mode.value, "registry_version": self.registry_version, + "generated_at_ms": self.generated_at_ms, "bindings": self.bindings.to_document(), "result": self.result, "residues": [residue.to_document() for residue in self.residues], @@ -302,20 +309,13 @@ def _code_sha() -> str: def _readonly_uri(path: Path) -> str: - return f"{path.resolve(strict=True).as_uri()}?mode=ro&immutable=1" + return f"{path.resolve(strict=True).as_uri()}?mode=ro" def _open_readonly(path: Path) -> sqlite3.Connection: return sqlite3.connect(_readonly_uri(path), uri=True, timeout=2) -def _require_quiescent_sqlite(paths: Sequence[Path]) -> None: - if any( - path.with_name(path.name + "-wal").exists() or path.with_name(path.name + "-wal").is_symlink() for path in paths - ): - raise LiveProofError("live-proof requires a quiescent archive without SQLite WAL files") - - def _require_uri_safe_location(location: object) -> None: """Fail closed before dependencies that still interpolate SQLite URIs.""" @@ -327,7 +327,6 @@ def _require_uri_safe_location(location: object) -> None: ) if any(any(character in str(path) for character in ("%", "?", "#")) for path in paths): raise LiveProofError("live-proof archive paths cannot contain SQLite URI query characters") - _require_quiescent_sqlite(paths[1:]) def archive_owned_storage_roots(archive_root: Path) -> tuple[Path, ...]: @@ -335,7 +334,10 @@ def archive_owned_storage_roots(archive_root: Path) -> tuple[Path, ...]: from polylogue.storage.archive_identity import ArchiveLocation - location = ArchiveLocation.resolve(archive_root) + try: + location = ArchiveLocation.resolve(archive_root) + except RuntimeError as exc: + raise LiveProofError("archive-owned storage roots are unavailable") from exc bases = {location.configured_root.resolve(), location.active_index_path.parent.resolve()} if location.active_pointer is not None: bases.add(location.active_pointer.parent.resolve()) @@ -417,16 +419,20 @@ def _schema_version(path: Path) -> int: return int(row[0]) if row is not None else 0 -def _schema_versions(location: object) -> tuple[tuple[str, int], ...]: +def _active_tier_paths(location: object) -> tuple[tuple[str, Path], ...]: from polylogue.storage.archive_identity import ArchiveLocation assert isinstance(location, ArchiveLocation) return tuple( - (name, _schema_version(location.active_tier(name).configured_path)) - for name in ("audit", "source", "index", "embeddings", "ops", "user") + (name, location.active_index_path if name == "index" else location.active_tier(name).configured_path) + for name in _ARCHIVE_TIER_NAMES ) +def _schema_versions(location: object) -> tuple[tuple[str, int], ...]: + return tuple((name, _schema_version(path)) for name, path in _active_tier_paths(location)) + + def _candidate_index(location: object, generation_id: str, *, source_snapshot: str) -> Path: """Resolve one inactive generation through the lifecycle store's canonical root.""" @@ -450,6 +456,14 @@ def _candidate_index(location: object, generation_id: str, *, source_snapshot: s expected_root = store.generations_root / generation_id index_resolved = index_path.resolve(strict=True) expected_resolved = expected_root.resolve(strict=True) / "index.db" + transactions = [] + for transaction_path in store.transactions_root.glob("*.json"): + try: + transaction = store.load_transaction(transaction_path.stem) + except (OSError, RuntimeError, TypeError, ValueError, json.JSONDecodeError): + continue + if transaction.generation_id == generation_id: + transactions.append(transaction) except (OSError, RuntimeError, TypeError, ValueError, json.JSONDecodeError) as exc: raise LiveProofError("candidate generation metadata is unavailable") from exc if ( @@ -457,6 +471,10 @@ def _candidate_index(location: object, generation_id: str, *, source_snapshot: s or generation.state != "inactive" or Path(generation.archive_root).resolve() != location.configured_root.resolve() or generation.source_snapshot != source_snapshot + or len(transactions) != 1 + or transactions[0].generation_owner_id != generation.owner_id + or transactions[0].source_snapshot != source_snapshot + or transactions[0].status != "ready" or index_path != expected_root / "index.db" or index_path.is_symlink() or not index_path.is_file() @@ -464,7 +482,6 @@ def _candidate_index(location: object, generation_id: str, *, source_snapshot: s or location.active_index.same_file(TierFileIdentity.resolve("index", index_path)) ): raise LiveProofError("candidate generation binding is stale or invalid") - _require_quiescent_sqlite((index_path,)) return index_path @@ -491,6 +508,10 @@ def capture_live_proof_bindings(archive_root: Path, *, candidate_generation_id: active_index_sha256 = _sqlite_file_set_digest(location.active_index_path, allow_symlink=True) candidate_index_sha256 = _sqlite_file_set_digest(candidate_index) if candidate_index is not None else None schema_versions = _schema_versions(location) + tier_file_set_digests = tuple( + (name, _sqlite_file_set_digest(path, allow_symlink=name == "index")) + for name, path in _active_tier_paths(location) + ) candidate_index_schema_version = _schema_version(candidate_index) if candidate_index is not None else None parser_fingerprints = tuple((origin, parser_fingerprint_for_origin(origin)) for origin in origins) lowering = lowering_fingerprint() @@ -509,6 +530,7 @@ def capture_live_proof_bindings(archive_root: Path, *, candidate_generation_id: archive_identity_digest=identity.authority_identity_digest, source_snapshot=source_snapshot, schema_versions=schema_versions, + tier_file_set_digests=tier_file_set_digests, candidate_index_schema_version=candidate_index_schema_version, parser_fingerprints=parser_fingerprints, lowering_fingerprint=lowering, @@ -702,6 +724,7 @@ def collect_live_proof( bead_id=spec.bead_id, mode=spec.mode, registry_version=LIVE_PROOF_REGISTRY_VERSION, + generated_at_ms=int(time.time() * 1000), bindings=bindings, result=result, residues=residues, @@ -843,6 +866,15 @@ def _validate_live_proof_receipt( raise LiveProofError("live-proof receipt protocol identity is invalid") if payload.get("registry_version") != LIVE_PROOF_REGISTRY_VERSION or mode is not spec.mode: raise LiveProofError("live-proof receipt registry binding is stale") + generated_at_ms = payload.get("generated_at_ms") + now_ms = int(time.time() * 1000) + if ( + isinstance(generated_at_ms, bool) + or not isinstance(generated_at_ms, int) + or generated_at_ms > now_ms + 5_000 + or now_ms - generated_at_ms > _LIVE_PROOF_MAX_AGE_MS + ): + raise LiveProofError("live-proof receipt is stale or has an invalid generation time") receipt_bindings = payload.get("bindings") if not isinstance(receipt_bindings, Mapping): raise LiveProofError("live-proof receipt bindings are malformed") @@ -882,6 +914,7 @@ def _validate_live_proof_receipt( bead_id=spec.bead_id, mode=mode, registry_version=LIVE_PROOF_REGISTRY_VERSION, + generated_at_ms=generated_at_ms, bindings=expected, result=result, residues=residues, @@ -1014,6 +1047,7 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: ).encode("utf-8") descriptor: int | None = None temporary: Path | None = None + published = False try: target.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) @@ -1026,6 +1060,7 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: os.close(descriptor) descriptor = None os.link(temporary, target) + published = True _fsync_directory(target.parent) temporary.unlink() temporary = None @@ -1035,6 +1070,12 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: except OSError as exc: if descriptor is not None: os.close(descriptor) + if published: + try: + target.unlink() + _fsync_directory(target.parent) + except OSError: + pass if temporary is not None: try: temporary.unlink() diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 4e065b4f92..621d164075 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -12,6 +12,7 @@ import os import sqlite3 import subprocess +from dataclasses import replace from pathlib import Path import pytest @@ -62,8 +63,9 @@ def _apply_receipt(bindings: LiveProofBindings, *, status: str = "applied") -> d def _candidate(root: Path) -> str: store = IndexGenerationStore(ArchiveLocation.resolve(root)) - generation = store.create(source_snapshot=rebuild_source_revision_snapshot(root)) - return generation.generation_id + transaction = store.create_transaction(source_snapshot=rebuild_source_revision_snapshot(root)) + store.save_transaction(replace(transaction, status="ready")) + return transaction.generation_id def _rehash(document: JSONDocument) -> JSONDocument: @@ -88,7 +90,14 @@ def test_read_only_receipt_preserves_full_redacted_canonical_evidence(archive_ro receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) repeated = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) - assert receipt.to_document() == repeated.to_document() + first_document = receipt.to_document() + repeated_document = repeated.to_document() + first_document.pop("generated_at_ms") + repeated_document.pop("generated_at_ms") + first_document.pop("receipt_sha256") + repeated_document.pop("receipt_sha256") + assert first_document == repeated_document + assert repeated.generated_at_ms >= receipt.generated_at_ms evidence = receipt.result["archive_verification"] assert isinstance(evidence, dict) profiles = evidence["profiles"] @@ -109,6 +118,14 @@ def test_read_only_receipt_preserves_full_redacted_canonical_evidence(archive_ro "source", "user", } + assert {name for name, _digest in receipt.bindings.tier_file_set_digests} == { + "audit", + "embeddings", + "index", + "ops", + "source", + "user", + } assert receipt.bindings.active_index_sha256 assert validate_live_proof_receipt(receipt.to_document(), archive_root) == receipt @@ -155,6 +172,23 @@ def test_read_only_receipt_redacts_external_verification_evidence(archive_root: assert "[private-path:" in serialized +def test_receipt_rejects_expired_generation_time(archive_root: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + mutated = receipt.to_document() + mutated["generated_at_ms"] = receipt.generated_at_ms - live_proof._LIVE_PROOF_MAX_AGE_MS - 1 + + with pytest.raises(LiveProofError, match="stale or has an invalid generation time"): + validate_live_proof_receipt(_rehash(mutated), archive_root) + + +def test_capture_accepts_empty_checkpointed_wal_sidecar(archive_root: Path) -> None: + (archive_root / "index.db-wal").write_bytes(b"") + + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + + assert receipt.bindings.active_index_sha256 + + def test_mode_inputs_are_isolated(archive_root: Path, tmp_path: Path) -> None: apply_path = tmp_path / "apply.json" apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") @@ -295,6 +329,21 @@ def test_candidate_receipt_binds_canonical_generation_and_all_profiles(archive_r validate_candidate_proof_receipts((receipt.to_document(),), archive_root, candidate_generation_id=generation_id) +def test_candidate_receipt_requires_ready_rebuild_transaction(archive_root: Path) -> None: + generation_id = _candidate(archive_root) + store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) + transaction_path = next(store.transactions_root.glob("*.json")) + transaction = store.load_transaction(transaction_path.stem) + store.save_transaction(replace(transaction, status="running")) + + with pytest.raises(LiveProofError, match="candidate generation binding"): + collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + + def test_candidate_rejects_source_snapshot_drift(archive_root: Path) -> None: generation_id = _candidate(archive_root) store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) @@ -489,3 +538,23 @@ def fail_write(_descriptor: int, _payload: bytes) -> int: write_live_proof_receipt(target, receipt) assert not target.exists() + + +def test_receipt_write_removes_published_output_when_directory_sync_fails( + archive_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + target = tmp_path / "new-receipt.json" + calls = 0 + + def fail_after_publish(_path: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("directory sync failed") + + monkeypatch.setattr(live_proof, "_fsync_directory", fail_after_publish) + with pytest.raises(LiveProofError, match="could not be written"): + write_live_proof_receipt(target, receipt) + + assert not target.exists() From 9e67d5ef930c8e3f316ca7cf0baffcaf5683f4aa Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 16:54:54 +0200 Subject: [PATCH 11/12] fix(maintenance): preserve live proof safety across promotion Problem Automated review found that live-proof validation still leaked open SQLite connections, accepted incomplete archive evidence, allowed arbitrary apply operation ids, and could not validate a candidate receipt after promotion. What changed Close read-only connections, validate the complete archive report shape, require the registered source-remediation operation id, keep receipt writes failure-atomic, reject dirty checkout SHA overrides, and validate retained candidate generations by their current source-side bindings and candidate file digest. Add regression coverage for each boundary and document the post-promotion route. Compatibility/migration Existing apply receipts must use the registered source-remediation operation id. No archive mutation or production operation was performed. Ref polylogue-x97cf. Co-Authored-By: Claude --- docs/maintenance.md | 4 +- polylogue/maintenance/live_proof.py | 310 ++++++++++++++++-- .../cli/test_maintenance_live_proof_cli.py | 15 + tests/unit/maintenance/test_live_proof.py | 106 +++++- 4 files changed, 396 insertions(+), 39 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index dade9439ba..c6073112f6 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -490,11 +490,11 @@ polylogue ops maintenance live-proof \ --output /path/to/new/live-proof.json ``` -The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt`; every other combination is rejected. +The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt` and a registered source-remediation operation id; every other combination is rejected. An arbitrary nonempty operation id is not accepted. Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, all six active archive-tier schema versions, parser and lowering fingerprints, the active SQLite file set, and the candidate generation, schema, and SQLite file set where applicable. SQLite bindings include the database and WAL/journal sidecars and refuse a file set that changes while it is captured. Candidate metadata must name the canonical inactive generation and the same source snapshot. Archives whose configured paths contain SQLite URI query characters are rejected before proof dependencies open them. Private local paths are represented only as a SHA-256 digest plus basename. -The receipt keeps complete structured archive-verification evidence after redacting archive paths and any absolute paths emitted by checks. `archive-verification` runs the entire live archive profile. `candidate-archive-verification` runs both canonical candidate acceptance profiles: the index-candidate checks against the inactive generation and the cross-tier checks against that generation plus the durable archive. Existing-apply evidence embeds the validated input receipt, so consumers revalidate its self-hash, bindings, and match to the recorded input digest. Verification consumers validate the fixed profile membership, check outcomes, typed status/residue relationship, bindings, and input hashes again. Aggregate validation captures a single candidate-aware binding snapshot, deriving the active binding from that same snapshot. Final proof consumption requires every registered route exactly once; `not_applicable` is accepted only with its typed residue. Output creation is exclusive and failure-atomic: a failed write removes its partial file and syncs the destination directory. +The receipt keeps complete structured archive-verification evidence after redacting archive paths and any absolute paths emitted by checks. `archive-verification` runs the entire live archive profile. `candidate-archive-verification` runs both canonical candidate acceptance profiles: the index-candidate checks against the inactive generation and the cross-tier checks against that generation plus the durable archive. Existing-apply evidence embeds the validated input receipt, so consumers revalidate its self-hash, bindings, registered operation id, and match to the recorded input digest. Verification consumers validate the fixed profile membership, check outcomes, typed status/residue relationship, bindings, and input hashes again. Aggregate validation binds pre-promotion candidate evidence to the inactive generation. After promotion, it validates the retained generation and candidate file set directly while recapturing the current active binding for post-promotion receipts. Final proof consumption requires every registered route exactly once; `not_applicable` is accepted only with its typed residue. Output creation is exclusive and failure-atomic: a failed write removes its partial file and syncs the destination directory. ### `--operation-id` and `--resume`: worked example diff --git a/polylogue/maintenance/live_proof.py b/polylogue/maintenance/live_proof.py index 279cb8cb1d..ec53640aaf 100644 --- a/polylogue/maintenance/live_proof.py +++ b/polylogue/maintenance/live_proof.py @@ -16,6 +16,7 @@ import tempfile import time from collections.abc import Mapping, Sequence +from contextlib import closing from dataclasses import dataclass, replace from enum import StrEnum from pathlib import Path @@ -39,6 +40,7 @@ _SQLITE_SIDECARS: Final = ("-wal", "-journal") _ARCHIVE_TIER_NAMES: Final = ("audit", "source", "index", "embeddings", "ops", "user") _APPLY_RESULT_STATUSES: Final = frozenset({"applied", "already_satisfied", "not_applicable", "failed", "blocked"}) +_REGISTERED_APPLY_OPERATION_IDS: Final = frozenset({"known-source-remediation"}) class LiveProofError(ValueError): @@ -291,21 +293,25 @@ def _installed_code_sha() -> str: def _code_sha() -> str: configured = os.environ.get("POLYLOGUE_CODE_SHA", "").strip().lower() + repository = Path(__file__).resolve().parents[2] + if (repository / ".git").exists(): + dirty = _run_git(repository, "status", "--porcelain=v1", "--untracked-files=all") + if dirty.returncode != 0 or dirty.stdout.strip(): + raise LiveProofError("live proofs require a clean git worktree") + if configured: + if not _CODE_SHA_RE.fullmatch(configured): + raise LiveProofError("POLYLOGUE_CODE_SHA must be an exact git commit SHA") + return configured + completed = _run_git(repository, "rev-parse", "--verify", "HEAD") + sha = completed.stdout.strip().lower() + if completed.returncode != 0 or not _CODE_SHA_RE.fullmatch(sha): + raise LiveProofError("exact code SHA is unavailable") + return sha if configured: if not _CODE_SHA_RE.fullmatch(configured): raise LiveProofError("POLYLOGUE_CODE_SHA must be an exact git commit SHA") return configured - repository = Path(__file__).resolve().parents[2] - if not (repository / ".git").exists(): - return _installed_code_sha() - dirty = _run_git(repository, "status", "--porcelain=v1", "--untracked-files=all") - if dirty.returncode != 0 or dirty.stdout.strip(): - raise LiveProofError("live proofs require a clean git worktree") - completed = _run_git(repository, "rev-parse", "--verify", "HEAD") - sha = completed.stdout.strip().lower() - if completed.returncode != 0 or not _CODE_SHA_RE.fullmatch(sha): - raise LiveProofError("exact code SHA is unavailable") - return sha + return _installed_code_sha() def _readonly_uri(path: Path) -> str: @@ -363,7 +369,6 @@ def _sqlite_file_state(path: Path, *, allow_symlink: bool) -> JSONDocument: files: dict[str, JSONValue] = { "database": { "size": metadata.st_size, - "mtime_ns": metadata.st_mtime_ns, "sha256": hash_file(path), } } @@ -387,7 +392,6 @@ def _sqlite_file_state(path: Path, *, allow_symlink: bool) -> JSONDocument: files[suffix] = { "exists": True, "size": sidecar_metadata.st_size, - "mtime_ns": sidecar_metadata.st_mtime_ns, "sha256": hash_file(sidecar), } return files @@ -398,7 +402,7 @@ def _sqlite_file_set_digest(path: Path, *, allow_symlink: bool = False) -> str: before = _sqlite_file_state(path, allow_symlink=allow_symlink) try: - with _open_readonly(path) as connection: + with closing(_open_readonly(path)) as connection: connection.execute("PRAGMA query_only = ON") connection.execute("BEGIN") connection.execute("PRAGMA schema_version").fetchone() @@ -412,7 +416,7 @@ def _sqlite_file_set_digest(path: Path, *, allow_symlink: bool = False) -> str: def _schema_version(path: Path) -> int: try: - with _open_readonly(path) as connection: + with closing(_open_readonly(path)) as connection: row = connection.execute("PRAGMA user_version").fetchone() except (OSError, sqlite3.Error) as exc: raise LiveProofError("live-proof schema binding is unavailable") from exc @@ -497,7 +501,7 @@ def capture_live_proof_bindings(archive_root: Path, *, candidate_generation_id: location = ArchiveLocation.resolve(root) _require_uri_safe_location(location) source_snapshot = rebuild_source_revision_snapshot(root) - with _open_readonly(location.configured_tier("source").configured_path) as source: + with closing(_open_readonly(location.configured_tier("source").configured_path)) as source: origins = sorted(str(row[0]) for row in source.execute("SELECT DISTINCT origin FROM raw_sessions")) candidate_index = ( _candidate_index(location, candidate_generation_id, source_snapshot=source_snapshot) @@ -627,6 +631,113 @@ def _validate_private_path_references(value: object) -> None: raise LiveProofError("input receipt private paths are malformed") +def _parse_bindings_document(value: object) -> LiveProofBindings: + if not isinstance(value, Mapping): + raise LiveProofError("live-proof receipt bindings are malformed") + + def string_field(name: str) -> str: + field = value.get(name) + if not isinstance(field, str) or not field: + raise LiveProofError("live-proof receipt bindings are malformed") + return field + + def mapping_field(name: str) -> Mapping[object, object]: + field = value.get(name) + if not isinstance(field, Mapping): + raise LiveProofError("live-proof receipt bindings are malformed") + return field + + def digest_mapping(name: str) -> tuple[tuple[str, str], ...]: + field = mapping_field(name) + if set(field) != set(_ARCHIVE_TIER_NAMES) and name == "tier_file_set_digests": + raise LiveProofError("live-proof receipt bindings are malformed") + result: list[tuple[str, str]] = [] + keys: tuple[str, ...] + if name == "tier_file_set_digests": + keys = _ARCHIVE_TIER_NAMES + else: + if any(not isinstance(key, str) for key in field): + raise LiveProofError("live-proof receipt bindings are malformed") + keys = tuple(sorted(cast(str, key) for key in field)) + for key in keys: + digest = field.get(key) + if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest): + raise LiveProofError("live-proof receipt bindings are malformed") + result.append((key, digest)) + return tuple(result) + + schema_mapping = mapping_field("schema_versions") + if set(schema_mapping) != set(_ARCHIVE_TIER_NAMES): + raise LiveProofError("live-proof receipt bindings are malformed") + schema_versions: list[tuple[str, int]] = [] + for name in _ARCHIVE_TIER_NAMES: + version = schema_mapping.get(name) + if isinstance(version, bool) or not isinstance(version, int): + raise LiveProofError("live-proof receipt bindings are malformed") + schema_versions.append((name, version)) + + parser_mapping = mapping_field("parser_fingerprints") + parser_items: list[tuple[str, str]] = [] + for raw_name, raw_fingerprint in parser_mapping.items(): + if not isinstance(raw_name, str) or not isinstance(raw_fingerprint, str) or not raw_fingerprint: + raise LiveProofError("live-proof receipt bindings are malformed") + parser_items.append((raw_name, raw_fingerprint)) + parser_fingerprints = tuple(sorted(parser_items)) + if len(parser_fingerprints) != len(parser_mapping): + raise LiveProofError("live-proof receipt bindings are malformed") + + candidate_generation_id = value.get("candidate_generation_id") + if candidate_generation_id is not None and ( + not isinstance(candidate_generation_id, str) or not _GENERATION_ID_RE.fullmatch(candidate_generation_id) + ): + raise LiveProofError("live-proof receipt bindings are malformed") + candidate_schema = value.get("candidate_index_schema_version") + if candidate_schema is not None and (isinstance(candidate_schema, bool) or not isinstance(candidate_schema, int)): + raise LiveProofError("live-proof receipt bindings are malformed") + candidate_digest = value.get("candidate_index_sha256") + if candidate_digest is not None and ( + not isinstance(candidate_digest, str) or not _SHA256_RE.fullmatch(candidate_digest) + ): + raise LiveProofError("live-proof receipt bindings are malformed") + + private_paths = mapping_field("private_paths") + parsed_private_paths: list[tuple[str, PrivatePathReference]] = [] + for raw_name, raw_reference in private_paths.items(): + if not isinstance(raw_name, str) or not isinstance(raw_reference, Mapping): + raise LiveProofError("live-proof receipt bindings are malformed") + name = raw_name + reference = raw_reference + basename = reference.get("basename") + digest = reference.get("sha256") + if ( + not isinstance(basename, str) + or Path(basename).name != basename + or not isinstance(digest, str) + or not _SHA256_RE.fullmatch(digest) + ): + raise LiveProofError("live-proof receipt bindings are malformed") + parsed_private_paths.append((name, PrivatePathReference(basename, digest))) + + active_digest = string_field("active_index_sha256") + if not _SHA256_RE.fullmatch(active_digest): + raise LiveProofError("live-proof receipt bindings are malformed") + lowering = string_field("lowering_fingerprint") + return LiveProofBindings( + code_sha=string_field("code_sha"), + archive_identity_digest=string_field("archive_identity_digest"), + source_snapshot=string_field("source_snapshot"), + schema_versions=tuple(schema_versions), + tier_file_set_digests=digest_mapping("tier_file_set_digests"), + candidate_index_schema_version=candidate_schema, + parser_fingerprints=parser_fingerprints, + lowering_fingerprint=lowering, + active_index_sha256=active_digest, + candidate_generation_id=candidate_generation_id, + candidate_index_sha256=candidate_digest, + private_paths=tuple(parsed_private_paths), + ) + + def _apply_proof_status(status: str) -> LiveProofStatus: if status in {"applied", "already_satisfied"}: return LiveProofStatus.PASSED @@ -648,7 +759,7 @@ def _validate_existing_apply_document(document: object, bindings: LiveProofBindi raise LiveProofError("existing apply receipt self-hash is invalid") if payload.get("receipt_schema") != EXISTING_APPLY_RECEIPT_SCHEMA: raise LiveProofError("existing apply receipt schema is not accepted") - if not isinstance(payload.get("operation_id"), str) or not payload["operation_id"]: + if payload.get("operation_id") not in _REGISTERED_APPLY_OPERATION_IDS: raise LiveProofError("existing apply receipt operation binding is invalid") receipt_bindings = payload.get("bindings") if not isinstance(receipt_bindings, Mapping) or receipt_bindings != bindings.to_document(): @@ -658,7 +769,7 @@ def _validate_existing_apply_document(document: object, bindings: LiveProofBindi raise LiveProofError("existing apply receipt result is malformed") status = result.get("status") if not isinstance(status, str) or status not in _APPLY_RESULT_STATUSES: - raise LiveProofError("existing apply receipt result status is not successful") + raise LiveProofError("existing apply receipt result status is not recognized") _validate_private_path_references(receipt_bindings.get("private_paths")) return require_json_document(document, context="existing apply receipt"), digest @@ -740,10 +851,12 @@ def _parse_residues(value: object) -> tuple[LiveProofResidue, ...]: for residue in value: if not isinstance(residue, Mapping): raise LiveProofError("live-proof receipt residues are malformed") + kind = residue.get("kind") + code = residue.get("code") + if not isinstance(kind, str) or not isinstance(code, str) or not code: + raise LiveProofError("live-proof receipt residues are malformed") try: - parsed_residue = LiveProofResidue( - LiveProofResidueKind(cast(str, residue["kind"])), cast(str, residue["code"]) - ) + parsed_residue = LiveProofResidue(LiveProofResidueKind(kind), code) except (KeyError, TypeError, ValueError) as exc: raise LiveProofError("live-proof receipt residues are malformed") from exc residue_key = (parsed_residue.kind, parsed_residue.code) @@ -772,6 +885,43 @@ def _validate_status_residues(status: LiveProofStatus, residues: tuple[LiveProof def _validate_archive_result( spec: LiveProofSpec, result: JSONDocument ) -> tuple[LiveProofStatus, tuple[LiveProofResidue, ...]]: + check_keys = frozenset( + {"name", "status", "summary", "count", "details", "breakdown", "evidence", "check_class", "waived_bead_id"} + ) + + def validate_check(check: object) -> tuple[str, str]: + if not isinstance(check, Mapping) or frozenset(check) != check_keys: + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + name = check.get("name") + status = check.get("status") + summary = check.get("summary") + count = check.get("count") + details = check.get("details") + breakdown = check.get("breakdown") + evidence = check.get("evidence") + check_class = check.get("check_class") + waived_bead_id = check.get("waived_bead_id") + if ( + not isinstance(name, str) + or not isinstance(status, str) + or status not in {"ok", "warning", "error", "skip"} + or not isinstance(summary, str) + or isinstance(count, bool) + or not isinstance(count, int) + or not isinstance(details, list) + or any(not isinstance(detail, str) for detail in details) + or not isinstance(breakdown, Mapping) + or any( + not isinstance(key, str) or isinstance(value, bool) or not isinstance(value, int) + for key, value in breakdown.items() + ) + or not is_json_document(evidence) + or not isinstance(check_class, str) + or (waived_bead_id is not None and not isinstance(waived_bead_id, str)) + ): + raise LiveProofError("live-proof receipt archive verification evidence is malformed") + return name, status + archive_verification = result.get("archive_verification") if not isinstance(archive_verification, Mapping): raise LiveProofError("live-proof receipt archive verification evidence is malformed") @@ -783,18 +933,22 @@ def _validate_archive_result( evidence = profiles.get(profile.name) if not isinstance(evidence, Mapping): raise LiveProofError("live-proof receipt archive verification evidence is malformed") + summary = evidence.get("summary") + blocking = evidence.get("blocking") checks = evidence.get("checks") - if not isinstance(checks, list): + if ( + frozenset(evidence) != frozenset({"summary", "blocking", "checks"}) + or not isinstance(summary, Mapping) + or frozenset(summary) != frozenset({"ok", "warning", "error", "skip"}) + or any(isinstance(value, bool) or not isinstance(value, int) for value in summary.values()) + or not isinstance(blocking, bool) + or not isinstance(checks, list) + ): raise LiveProofError("live-proof receipt archive verification evidence is malformed") names: list[str] = [] statuses: list[str] = [] for check in checks: - if not isinstance(check, Mapping): - raise LiveProofError("live-proof receipt archive verification evidence is malformed") - name = check.get("name") - status = check.get("status") - if not isinstance(name, str) or not isinstance(status, str): - raise LiveProofError("live-proof receipt archive verification evidence is malformed") + name, status = validate_check(check) names.append(name) statuses.append(status) if tuple(names) != profile.checks: @@ -843,6 +997,78 @@ def _capture_expected_bindings(archive_root: Path, candidate_generation_id: str raise LiveProofError("live-proof receipt bindings are stale or mismatched") from exc +def _capture_promoted_candidate_bindings( + document: object, archive_root: Path, candidate_generation_id: str +) -> LiveProofBindings: + """Validate a candidate receipt against a promoted or retained generation. + + The receipt's active-index fields intentionally describe the pre-promotion + state. Promotion changes that state, so validation instead rechecks the + retained candidate content and the durable source-side bindings while + preserving the historical receipt document as the expected binding. + """ + + from polylogue.maintenance.schema_inference_gate import rebuild_source_revision_snapshot + from polylogue.storage.archive_identity import ArchiveLocation + from polylogue.storage.index_generation import IndexGenerationStore + + if not isinstance(document, Mapping): + raise LiveProofError("candidate proof receipt is malformed") + recorded = _parse_bindings_document(document.get("bindings")) + if recorded.candidate_generation_id != candidate_generation_id: + raise LiveProofError("candidate proof receipt targets a different generation") + if recorded.candidate_index_sha256 is None or recorded.candidate_index_schema_version is None: + raise LiveProofError("candidate proof receipt has incomplete candidate binding") + + try: + location = ArchiveLocation.resolve(archive_root) + store = IndexGenerationStore(location) + generation = store.load(candidate_generation_id) + candidate_path = Path(generation.index_path) + expected_path = store.generations_root / candidate_generation_id / "index.db" + generation_checks = { + "state": generation.state in {"active", "retained"}, + "archive_root": Path(generation.archive_root).resolve() == location.configured_root.resolve(), + "candidate_path": candidate_path == expected_path, + "regular_file": not candidate_path.is_symlink() and candidate_path.is_file(), + "resolved_path": candidate_path.resolve(strict=True) == expected_path.resolve(strict=True), + "source_snapshot": rebuild_source_revision_snapshot(archive_root) == generation.source_snapshot, + } + if not all(generation_checks.values()): + raise LiveProofError("promoted candidate generation binding is stale or invalid") + current = capture_live_proof_bindings(archive_root) + current_schema = dict(current.schema_versions) + recorded_schema = dict(recorded.schema_versions) + current_tiers = dict(current.tier_file_set_digests) + recorded_tiers = dict(recorded.tier_file_set_digests) + binding_checks = { + "code_sha": recorded.code_sha == current.code_sha, + "source_snapshot": recorded.source_snapshot == current.source_snapshot, + "parser_fingerprints": recorded.parser_fingerprints == current.parser_fingerprints, + "lowering": recorded.lowering_fingerprint == current.lowering_fingerprint, + "schema_versions": all( + recorded_schema[name] == current_schema[name] for name in _ARCHIVE_TIER_NAMES if name != "index" + ), + "tier_digests": all( + recorded_tiers[name] == current_tiers[name] for name in _ARCHIVE_TIER_NAMES if name != "index" + ), + "candidate_schema": recorded_schema["index"] == recorded.candidate_index_schema_version, + "candidate_schema_current": _schema_version(candidate_path) == recorded.candidate_index_schema_version, + "candidate_digest": _sqlite_file_set_digest(candidate_path) == recorded.candidate_index_sha256, + "archive_private_path": dict(recorded.private_paths).get("archive_root") + == dict(current.private_paths).get("archive_root"), + "candidate_private_path": dict(recorded.private_paths).get("candidate_index") + == PrivatePathReference.capture(candidate_path), + } + if not all(binding_checks.values()): + raise LiveProofError("promoted candidate generation binding is stale or invalid") + except LiveProofError: + raise + except (OSError, RuntimeError, sqlite3.Error, ValueError, json.JSONDecodeError) as exc: + raise LiveProofError("promoted candidate generation binding is unavailable") from exc + return recorded + + def _validate_live_proof_receipt( document: object, archive_root: Path, @@ -973,9 +1199,28 @@ def _validate_aggregate(receipts: Sequence[object], archive_root: Path) -> tuple if not receipts: raise LiveProofError("live-operation aggregate requires at least one proof receipt") candidate_id = _candidate_id_from_documents(receipts) - candidate_bindings = _capture_expected_bindings(archive_root, candidate_id) if candidate_id is not None else None + candidate_document = ( + next( + receipt + for receipt in receipts + if isinstance(receipt, Mapping) and receipt.get("mode") == LiveProofMode.CANDIDATE.value + ) + if candidate_id is not None + else None + ) + promoted_candidate = False + if candidate_id is not None: + try: + candidate_bindings = _capture_expected_bindings(archive_root, candidate_id) + except LiveProofError: + candidate_bindings = _capture_promoted_candidate_bindings(candidate_document, archive_root, candidate_id) + promoted_candidate = True + else: + candidate_bindings = None active_bindings = ( - _active_bindings(candidate_bindings) + _capture_expected_bindings(archive_root, None) + if promoted_candidate + else _active_bindings(candidate_bindings) if candidate_bindings is not None else _capture_expected_bindings(archive_root, None) ) @@ -1065,11 +1310,10 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: temporary.unlink() temporary = None _fsync_directory(target.parent) - except FileExistsError as exc: - raise LiveProofError("live-proof receipt output already exists") from exc except OSError as exc: if descriptor is not None: os.close(descriptor) + descriptor = None if published: try: target.unlink() @@ -1082,6 +1326,8 @@ def write_live_proof_receipt(path: Path, receipt: LiveProofReceipt) -> None: _fsync_directory(target.parent) except OSError: pass + if isinstance(exc, FileExistsError): + raise LiveProofError("live-proof receipt output already exists") from exc raise LiveProofError("live-proof receipt output could not be written") from exc diff --git a/tests/unit/cli/test_maintenance_live_proof_cli.py b/tests/unit/cli/test_maintenance_live_proof_cli.py index f56938023c..e205473c45 100644 --- a/tests/unit/cli/test_maintenance_live_proof_cli.py +++ b/tests/unit/cli/test_maintenance_live_proof_cli.py @@ -4,6 +4,7 @@ import json import os +import subprocess from pathlib import Path import pytest @@ -13,6 +14,20 @@ from polylogue.maintenance import live_proof +@pytest.fixture(autouse=True) +def clean_git_identity(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + live_proof, + "_run_git", + lambda repository, *arguments: subprocess.CompletedProcess( + args=("git", str(repository), *arguments), + returncode=0, + stdout="" if arguments[0] == "status" else "a" * 40, + stderr="", + ), + ) + + def test_live_proof_cli_dispatches_registered_read_only_proof( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/maintenance/test_live_proof.py b/tests/unit/maintenance/test_live_proof.py index 621d164075..616bd3c151 100644 --- a/tests/unit/maintenance/test_live_proof.py +++ b/tests/unit/maintenance/test_live_proof.py @@ -48,13 +48,25 @@ def archive_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: root = (tmp_path / "private-archive").resolve() initialize_active_archive_root(root) monkeypatch.setenv("POLYLOGUE_CODE_SHA", "a" * 40) + monkeypatch.setattr( + live_proof, + "_run_git", + lambda repository, *arguments: subprocess.CompletedProcess( + args=("git", str(repository), *arguments), + returncode=0, + stdout="" if arguments[0] == "status" else "a" * 40, + stderr="", + ), + ) return root -def _apply_receipt(bindings: LiveProofBindings, *, status: str = "applied") -> dict[str, object]: +def _apply_receipt( + bindings: LiveProofBindings, *, status: str = "applied", operation_id: str = "known-source-remediation" +) -> dict[str, object]: document = { "receipt_schema": EXISTING_APPLY_RECEIPT_SCHEMA, - "operation_id": "known-source-remediation", + "operation_id": operation_id, "bindings": bindings.to_document(), "result": {"status": status, "changed_count": 1}, } @@ -303,7 +315,18 @@ def test_existing_apply_receipt_rejects_unknown_result(archive_root: Path, tmp_p json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), status="unknown")), encoding="utf-8" ) - with pytest.raises(LiveProofError, match="result status is not successful"): + with pytest.raises(LiveProofError, match="result status is not recognized"): + collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) + + +def test_existing_apply_receipt_requires_registered_operation(archive_root: Path, tmp_path: Path) -> None: + apply_path = tmp_path / "unregistered-apply-receipt.json" + apply_path.write_text( + json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root), operation_id="invented-operation")), + encoding="utf-8", + ) + + with pytest.raises(LiveProofError, match="operation binding is invalid"): collect_live_proof(LiveProofId.EXISTING_APPLY_RECEIPT.value, archive_root, apply_receipt_path=apply_path) @@ -427,6 +450,27 @@ def test_receipt_rejects_residues_mismatched_to_check_evidence(archive_root: Pat validate_live_proof_receipt(_rehash(mutated), archive_root) +def test_receipt_rejects_incomplete_archive_check_evidence(archive_root: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + mutated = receipt.to_document() + result = mutated["result"] + assert isinstance(result, dict) + verification = result["archive_verification"] + assert isinstance(verification, dict) + profiles = verification["profiles"] + assert isinstance(profiles, dict) + profile = profiles["active-archive"] + assert isinstance(profile, dict) + checks = profile["checks"] + assert isinstance(checks, list) and checks + check = checks[0] + assert isinstance(check, dict) + profile["checks"] = [{"name": check["name"], "status": "ok"}, *checks[1:]] + + with pytest.raises(LiveProofError, match="archive verification evidence is malformed"): + validate_live_proof_receipt(_rehash(mutated), archive_root) + + def test_aggregate_rejects_a_self_hashed_failed_proof_result(archive_root: Path) -> None: bindings = capture_live_proof_bindings(archive_root) document = _apply_receipt(bindings, status="failed") @@ -482,13 +526,42 @@ def capture_once(root: Path, candidate_id: str | None) -> LiveProofBindings: assert calls == 1 +def test_aggregate_preserves_candidate_receipt_after_promotion(archive_root: Path, tmp_path: Path) -> None: + generation_id = _candidate(archive_root) + candidate_receipt = collect_live_proof( + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION.value, + archive_root, + candidate_generation_id=generation_id, + ) + store = IndexGenerationStore(ArchiveLocation.resolve(archive_root)) + store.promote(store.load(generation_id)) + + apply_path = tmp_path / "post-promotion-apply.json" + apply_path.write_text(json.dumps(_apply_receipt(capture_live_proof_bindings(archive_root))), encoding="utf-8") + active_receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + apply_receipt = collect_live_proof( + LiveProofId.EXISTING_APPLY_RECEIPT.value, + archive_root, + apply_receipt_path=apply_path, + ) + + validated = live_proof._validate_aggregate( + (active_receipt.to_document(), candidate_receipt.to_document(), apply_receipt.to_document()), archive_root + ) + assert [receipt.proof_id for receipt in validated] == [ + LiveProofId.ARCHIVE_VERIFICATION, + LiveProofId.CANDIDATE_ARCHIVE_VERIFICATION, + LiveProofId.EXISTING_APPLY_RECEIPT, + ] + + def test_readonly_uri_encodes_sqlite_metacharacters(tmp_path: Path) -> None: - database = tmp_path / "archive?name#fragment.db" + database = tmp_path / "archive%2Fname?name#fragment.db" sqlite3.connect(database).close() uri = live_proof._readonly_uri(database) - assert "%3F" in uri and "%23" in uri + assert "%25" in uri and "%3F" in uri and "%23" in uri with sqlite3.connect(uri, uri=True) as connection: assert connection.execute("PRAGMA user_version").fetchone() == (0,) @@ -516,6 +589,18 @@ def test_git_fallback_rejects_dirty_worktree(monkeypatch: pytest.MonkeyPatch) -> live_proof._code_sha() +def test_git_sha_override_still_rejects_dirty_worktree(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("POLYLOGUE_CODE_SHA", "a" * 40) + monkeypatch.setattr( + live_proof, + "_run_git", + lambda *_args: subprocess.CompletedProcess(args=(), returncode=0, stdout=" M live_proof.py\n", stderr=""), + ) + + with pytest.raises(LiveProofError, match="clean git worktree"): + live_proof._code_sha() + + def test_installed_code_identity_uses_version_info_commit(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("POLYLOGUE_CODE_SHA", raising=False) monkeypatch.setattr(VERSION_INFO, "commit", "B" * 40) @@ -558,3 +643,14 @@ def fail_after_publish(_path: Path) -> None: write_live_proof_receipt(target, receipt) assert not target.exists() + + +def test_receipt_write_cleans_temporary_file_when_output_exists(archive_root: Path, tmp_path: Path) -> None: + receipt = collect_live_proof(LiveProofId.ARCHIVE_VERIFICATION.value, archive_root) + target = tmp_path / "existing-receipt.json" + write_live_proof_receipt(target, receipt) + + with pytest.raises(LiveProofError, match="output already exists"): + write_live_proof_receipt(target, receipt) + + assert list(tmp_path.glob(f".{target.name}.*")) == [] From 02e3ac986884b7123aebf5b40af635fc62f1421a Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 7 Aug 2026 17:01:35 +0200 Subject: [PATCH 12/12] docs(maintenance): name the live proof apply route Problem The live-proof protocol now rejects unregistered apply operation ids, but the maintenance guide described only a generic registered route. What changed Name the current source-remediation operation id in the route documentation so operators can construct an accepted apply receipt without guessing. Compatibility/migration Apply receipts using other operation ids remain rejected until a route is added to the closed registry. Ref polylogue-x97cf. Co-Authored-By: Claude --- docs/maintenance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index c6073112f6..26141aba65 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -490,7 +490,7 @@ polylogue ops maintenance live-proof \ --output /path/to/new/live-proof.json ``` -The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt` and a registered source-remediation operation id; every other combination is rejected. An arbitrary nonempty operation id is not accepted. +The registry currently has exactly three routes: `archive-verification` for a fixed read-only archive-check profile, `candidate-archive-verification` for that profile against one named inactive generation, and `existing-apply-receipt` for a pre-existing `polylogue.apply-receipt.v1` input. Candidate mode requires `--candidate-generation`; existing-apply mode requires `--apply-receipt` and the registered `known-source-remediation` operation id; every other combination is rejected. An arbitrary nonempty operation id is not accepted. Each `polylogue.live-proof-receipt.v1` binds the proof and Bead id, exact code SHA, archive identity, source snapshot, all six active archive-tier schema versions, parser and lowering fingerprints, the active SQLite file set, and the candidate generation, schema, and SQLite file set where applicable. SQLite bindings include the database and WAL/journal sidecars and refuse a file set that changes while it is captured. Candidate metadata must name the canonical inactive generation and the same source snapshot. Archives whose configured paths contain SQLite URI query characters are rejected before proof dependencies open them. Private local paths are represented only as a SHA-256 digest plus basename.