From 71123bcf7777687be1770445543ca4d019116b8b Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 06:30:48 +0200 Subject: [PATCH 01/28] fix(maintenance): adopt missing audit tier safely Problem: established production archives that predate audit.db cannot use the\nfresh-root-only initializer, leaving current runtimes unable to start.\n\nWhat changed: add an explicit backup-bound audit adoption route that retains\nthe existing atomic publisher, records an immutable durable-change-train\nreceipt, and validates that receipt during startup reconciliation.\n\nCompatibility: the existing --initialize-missing route remains restricted\nto unadopted archive roots. --- .../cli/commands/maintenance/_migrate_tier.py | 32 ++- polylogue/operations/durable_change_train.py | 184 +++++++++++++++++- .../storage/sqlite/durable_change_train.py | 5 +- polylogue/storage/sqlite/migration_runner.py | 81 ++++++++ .../unit/cli/test_archive_maintenance_cli.py | 184 ++++++++++++++++++ .../unit/storage/test_durable_change_train.py | 36 ++++ 6 files changed, 516 insertions(+), 6 deletions(-) diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 88eaadb28e..c6baa869a5 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -26,6 +26,7 @@ ArchiveOwnershipError, DurablePublicationError, acquire_durable_archive_ownership, + adopt_missing_audit_tier, execute_durable_change_train, initialize_missing_durable_tier, ) @@ -65,11 +66,20 @@ def _require_stopped_daemon(root: Path) -> str: is_flag=True, help="Initialize this durable tier only when its database file is absent; never replaces an existing file.", ) +@click.option( + "--adopt-established-audit", + is_flag=True, + help=( + "Create missing audit.db for an established archive only with a freshly verified full_evidence backup; " + "writes an immutable adoption receipt." + ), +) @click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) def migrate_tier_command( tier: str, backup_manifest: Path | None, initialize_missing: bool, + adopt_established_audit: bool, output_format: str, ) -> None: """Apply additive migrations for one durable archive tier. @@ -85,10 +95,26 @@ def migrate_tier_command( stopped_daemon_evidence_ref: str | None = None initialized = False initialized_version: int | None = None + adoption_receipt: Path | None = None try: with acquire_durable_archive_ownership(path.parent, owner_id=f"migrate-tier:{os.getpid()}") as archive_owner: stopped_daemon_evidence_ref = _require_stopped_daemon(path.parent) - if initialize_missing: + if initialize_missing and adopt_established_audit: + raise MigrationError("choose either --initialize-missing or --adopt-established-audit") + if adopt_established_audit: + if archive_tier is not ArchiveTier.AUDIT: + raise MigrationError("--adopt-established-audit is only valid for the audit tier") + if backup_manifest is None: + raise MigrationError("--adopt-established-audit requires --backup-manifest") + initialized_version, adoption_receipt = adopt_missing_audit_tier( + path, + backup_manifest=backup_manifest, + directory_fd=archive_owner.directory_fd, + stopped_daemon_check=lambda: _require_stopped_daemon(path.parent), + ) + initialized = True + execution = None + elif initialize_missing: initialized_version = initialize_missing_durable_tier( path, archive_tier, @@ -142,6 +168,7 @@ def migrate_tier_command( "tier": tier, "path": str(path), "initialized": initialized, + "adoption_receipt": str(adoption_receipt) if adoption_receipt is not None else None, "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, "train_manifest": ( @@ -172,6 +199,9 @@ def migrate_tier_command( click.echo(json.dumps(payload, indent=2, sort_keys=True)) return + if adoption_receipt is not None: + click.echo(f"Adopted missing audit tier at schema version {initialized_version}; receipt: {adoption_receipt}.") + return if initialized: click.echo(f"Initialized missing {tier} tier at schema version {initialized_version}.") return diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index d85164114b..57828d0ffa 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import os import sqlite3 import stat @@ -22,7 +23,14 @@ from polylogue.storage.sqlite.durable_change_train import ( reconcile_durable_change_train_startup as _reconcile_durable_change_train_startup, ) -from polylogue.storage.sqlite.migration_runner import DurableRuntimeConsumerResult, MigrationError +from polylogue.storage.sqlite.migration_runner import ( + DurableRuntimeConsumerResult, + MigrationError, + validate_full_evidence_backup_for_audit_adoption, +) + +_AUDIT_ADOPTION_RECEIPT_FORMAT = "polylogue.audit-tier-adoption.v1" +_AUDIT_ADOPTION_RECEIPT_NAME = "audit-adoption.json" @dataclass(frozen=True, slots=True) @@ -62,7 +70,14 @@ def acquire_durable_archive_ownership(root: Path, *, owner_id: str) -> OwnedArch return OwnedArchiveLocation.acquire(location, owner_id=owner_id) -def initialize_missing_durable_tier(path: Path, tier: ArchiveTier, *, directory_fd: int | None = None) -> int: +def initialize_missing_durable_tier( + path: Path, + tier: ArchiveTier, + *, + directory_fd: int | None = None, + permit_established_archive: bool = False, + pre_publish_check: Callable[[], None] | None = None, +) -> int: """Initialize one absent durable tier while the caller owns the archive. This is deliberately separate from migration. A missing tier has no @@ -226,7 +241,7 @@ def assert_no_adoption_evidence(*, check_target: bool = True) -> None: has_retained_evidence = bool(directory_entries(evidence_relative, evidence_metadata, description)) if has_retained_evidence: adoption_markers.append(archive_root / evidence_relative) - if existing_siblings or adoption_markers: + if not permit_established_archive and (existing_siblings or adoption_markers): details = ", ".join(str(item) for item in (*existing_siblings, *adoption_markers)) raise MigrationError( f"cannot initialize missing {tier.value} tier in an established archive; " @@ -339,7 +354,10 @@ def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: # ``link`` is the atomic no-replacement check for the target itself; # re-census only evidence whose appearance would otherwise make this # empty tier an unsafe adoption. - assert_no_adoption_evidence(check_target=False) + if pre_publish_check is not None: + pre_publish_check() + else: + assert_no_adoption_evidence(check_target=False) try: os.link( f"/proc/self/fd/{descriptor}", @@ -415,6 +433,161 @@ def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: return ARCHIVE_VERSION_BY_TIER[tier] +def audit_adoption_receipt_path(archive_root: Path) -> Path: + """Return the durable-change-train ledger location for audit adoption.""" + return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_RECEIPT_NAME + + +def _canonical_json_sha256(payload: object) -> str: + import json + + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _audit_schema_inventory_sha256() -> str: + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier + from polylogue.storage.sqlite.migration_runner import capture_durable_schema_inventory + + with sqlite3.connect(":memory:") as connection: + initialize_archive_tier(connection, ArchiveTier.AUDIT) + return capture_durable_schema_inventory(connection).sha256 + + +def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, object]) -> None: + """Publish one pre-publication receipt without replacement and fsync it.""" + import json + + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + unsigned = dict(payload) + unsigned.pop("receipt_sha256", None) + payload = {**unsigned, "receipt_sha256": _canonical_json_sha256(unsigned)} + encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + descriptor: int | None = None + directory_descriptor: int | None = None + try: + descriptor = os.open( + path, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + os.write(descriptor, encoded) + os.fsync(descriptor) + os.close(descriptor) + descriptor = None + directory_descriptor = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + os.fsync(directory_descriptor) + except FileExistsError as exc: + raise MigrationError(f"audit adoption receipt already exists and is immutable: {path}") from exc + except OSError as exc: + raise MigrationError(f"cannot publish immutable audit adoption receipt: {path}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + if directory_descriptor is not None: + os.close(directory_descriptor) + + +def validate_audit_adoption_receipt(archive_root: Path) -> Path | None: + """Validate a present adoption receipt before startup consumes its audit tier.""" + import json + + archive_root = archive_root.resolve() + receipt_path = audit_adoption_receipt_path(archive_root) + if not receipt_path.exists(): + return None + try: + payload = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit adoption receipt: {receipt_path}") from exc + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_RECEIPT_FORMAT: + raise MigrationError(f"audit adoption receipt format mismatch: {receipt_path}") + digest = payload.get("receipt_sha256") + unsigned = dict(payload) + unsigned.pop("receipt_sha256", None) + if not isinstance(digest, str) or digest != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit adoption receipt checksum mismatch: {receipt_path}") + from polylogue.storage.archive_identity import ArchiveIdentity + + if payload.get("archive_identity_digest") != ArchiveIdentity.resolve(archive_root).authority_identity_digest: + raise MigrationError("audit adoption receipt archive identity mismatch") + if payload.get("audit_schema_inventory_sha256") != _audit_schema_inventory_sha256(): + raise MigrationError("audit adoption receipt canonical audit DDL mismatch") + audit_path = archive_root / "audit.db" + if not audit_path.is_file(): + raise MigrationError(f"audit adoption receipt has no published audit tier: {audit_path}") + from polylogue.storage.sqlite.migration_runner import capture_durable_schema_inventory + + with sqlite3.connect(f"file:{audit_path}?mode=ro&immutable=1", uri=True) as connection: + version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) + schema_digest = capture_durable_schema_inventory(connection).sha256 + if version != 1 or quick_check != ("ok",) or schema_digest != _audit_schema_inventory_sha256(): + raise MigrationError("audit adoption receipt does not match a canonical audit v1 tier") + return receipt_path + + +def adopt_missing_audit_tier( + path: Path, + *, + backup_manifest: Path, + directory_fd: int, + stopped_daemon_check: Callable[[], str], +) -> tuple[int, Path]: + """Adopt canonical ``audit.db`` into an established, offline archive. + + The receipt is published first, so a crash cannot leave an unproven audit + tier. It names the authenticated full-evidence backup and expected + canonical image; startup validates that immutable intent against the + linked database before accepting it. + """ + if path.name != "audit.db": + raise MigrationError(f"established-archive adoption is only supported for audit.db: {path}") + archive_root = path.parent.resolve() + if path.exists() or path.is_symlink(): + raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") + stopped_evidence = stopped_daemon_check() + manifest_path, verification_receipt = validate_full_evidence_backup_for_audit_adoption( + backup_manifest, + archive_root=archive_root, + ) + from polylogue.storage.archive_identity import ArchiveIdentity + + receipt_path = audit_adoption_receipt_path(archive_root) + payload = { + "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, + "archive_identity_digest": ArchiveIdentity.resolve(archive_root).authority_identity_digest, + "backup_manifest": str(manifest_path.resolve()), + "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + "backup_verification_receipt": str(verification_receipt.resolve()), + "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), + "stopped_daemon_evidence_ref": stopped_evidence, + "single_writer_evidence_ref": "proof:archive-ownership-lock", + "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), + "audit_user_version": 1, + } + _write_immutable_audit_adoption_receipt(receipt_path, payload) + + def revalidate_before_publish() -> None: + if path.exists() or path.is_symlink(): + raise MigrationError(f"audit tier appeared during established-archive adoption: {path}") + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed during audit adoption") + validate_full_evidence_backup_for_audit_adoption(backup_manifest, archive_root=archive_root) + if ArchiveIdentity.resolve(archive_root).authority_identity_digest != payload["archive_identity_digest"]: + raise MigrationError("archive identity changed during audit adoption") + + version = initialize_missing_durable_tier( + path, + ArchiveTier.AUDIT, + directory_fd=directory_fd, + permit_established_archive=True, + pre_publish_check=revalidate_before_publish, + ) + validate_audit_adoption_receipt(archive_root) + return version, receipt_path + + def execute_durable_change_train( archive_root: Path, tier: ArchiveTier, @@ -444,8 +617,11 @@ def reconcile_durable_change_trains_on_startup(root: Path) -> tuple[Path, ...]: __all__ = [ "acquire_durable_archive_ownership", + "adopt_missing_audit_tier", + "audit_adoption_receipt_path", "ArchiveOwnershipError", "execute_durable_change_train", "initialize_missing_durable_tier", "reconcile_durable_change_trains_on_startup", + "validate_audit_adoption_receipt", ] diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 1dda7033ed..0ce054d41b 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -2282,6 +2282,9 @@ def _reconcile_durable_change_train_startup_locked( live_evidence_cache: dict[ArchiveTier, _DurableForwardVersionEvidence] | None = None, ) -> tuple[Path, ...]: """Reconcile persisted trains while the caller holds archive ownership.""" + from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + + validate_audit_adoption_receipt(archive_root) deferred_tiers = _recover_pending_source_continuity_intents(archive_root) manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" reconciled: list[Path] = [] @@ -2291,7 +2294,7 @@ def _reconcile_durable_change_train_startup_locked( canonical_inventory_by_tier: dict[ArchiveTier, _migration_runner.DurableSchemaInventory] = {} manifests_by_tier: dict[ArchiveTier, dict[int, DurableChangeTrain]] = {} validated_tiers: set[ArchiveTier] = set() - manifest_paths = tuple(sorted(manifest_root.glob("*.json"))) + manifest_paths = tuple(path for path in sorted(manifest_root.glob("*.json")) if path.name != "audit-adoption.json") fresh_bootstrap_versions = _fresh_durable_bootstrap_versions(archive_root, manifest_root) def record_reconciled(path: Path) -> None: diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 05c89a9396..dce684b1ac 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -844,6 +844,86 @@ def validate_migration_backup_live_fingerprint( return receipt_path +def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root: Path) -> tuple[Path, Path]: + """Authorize creation of a missing audit tier in an established archive. + + Unlike a normal tier migration there is no live ``audit.db`` connection to + attest. The route therefore requires the complete pre-audit file set, + validates the existing source/user attestations, and compares every + retained tier's recorded source fingerprint with the still-offline live + archive. This is intentionally stricter than ordinary migration backup + validation: an adoption is only safe when the backup is full evidence for + this exact established archive, not merely a restorable subset. + """ + manifest_path = _backup_manifest_path(path) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"audit adoption requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or manifest.get("profile") != "full_evidence": + raise MigrationError("audit adoption requires a verified full_evidence backup") + required_tiers = {"source", "index", "embeddings", "user", "ops"} + included = set(_json_str_list(manifest.get("included_tiers"))) + if included != {f"{tier}.db" for tier in required_tiers}: + raise MigrationError("audit adoption backup must contain exactly the established non-audit tier set") + receipt_path = _receipt_path(manifest_path) + if not receipt_path.exists() and not receipt_path.is_symlink(): + raise MigrationError( + f"audit adoption requires a successful backup verification receipt; missing {receipt_path}" + ) + _require_regular_backup_artifact(receipt_path, backup_root=backup_root, label="backup verification receipt") + receipt = _load_json(receipt_path, label="verification receipt") + if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": + raise MigrationError("audit adoption requires a successful backup verification receipt") + archive_root = archive_root.resolve() + for authority_tier in ("source", "user"): + try: + verify_verification_receipt( + receipt, + tier=authority_tier, + live_tier_path=archive_root / f"{authority_tier}.db", + ) + except BackupAttestationError as exc: + raise MigrationError(f"audit adoption backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("audit adoption backup receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("audit adoption backup receipt does not match manifest bytes") + artifacts = _validated_receipt_artifacts( + backup_root, + manifest, + receipt, + target_tier="audit", + live_tier_path=archive_root / "audit.db", + file_evidence=file_evidence, + ) + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("audit adoption backup receipt does not match the closed artifact inventory") + for tier in required_tiers: + live_path = archive_root / f"{tier}.db" + artifact = artifacts[tier] + fingerprint = artifact.get("source_fingerprint") + if not isinstance(fingerprint, dict): + raise MigrationError(f"audit adoption backup lacks a live source fingerprint for {tier}.db") + if Path(str(fingerprint.get("path") or "")).resolve(strict=False) != live_path.resolve(strict=False): + raise MigrationError(f"audit adoption backup belongs to a different archive tier: {tier}.db") + if not live_path.is_file(): + raise MigrationError(f"audit adoption live tier is missing: {live_path}") + if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + if str(fingerprint.get("sha256")) != _sha256_file(live_path): + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + return manifest_path, receipt_path + + def validate_backup_manifest_covers_derived_tier( path: Path, tier: ArchiveTier, *, connection: sqlite3.Connection ) -> Path: @@ -3452,6 +3532,7 @@ def write_durable_change_train_manifest( "validate_durable_change_train_manifest", "validate_backup_manifest_covers_derived_tier", "validate_migration_backup_live_fingerprint", + "validate_full_evidence_backup_for_audit_adoption", "validate_migration_backup_manifest", "write_durable_change_train_manifest", ] diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 43029d1619..4cbe6d9f01 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -21,6 +21,7 @@ from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import json_document +from polylogue.daemon.backup import backup_archive from polylogue.maintenance.raw_authority_recovery import ( RecoveryOperation, inspect_raw_authority_recovery, @@ -446,6 +447,18 @@ def _stage_uninitialized_archive(cli_workspace: dict[str, Path]) -> None: ) +def _full_evidence_backup_without_audit(root: Path) -> Path: + """Create the real verified backup an established-audit adoption consumes.""" + result = backup_archive( + output_dir=root.parent / "backups", + profile="full_evidence", + verify=True, + ) + assert result.ok, result.error + assert result.output_path is not None + return Path(result.output_path) / "manifest.json" + + def _write_gc_candidate(cli_workspace: dict[str, Path], blob_hash: str) -> Path: blob_root = cli_workspace["archive_root"] / "blob" path = blob_root / blob_hash[:2] / blob_hash[2:] @@ -3289,6 +3302,177 @@ def test_migrate_tier_cli_missing_initialization_refuses_malformed_train_marker( assert not (root / "audit.db").exists() +def test_migrate_tier_cli_adopts_established_audit_from_verified_full_evidence_backup( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production CLI publishes v1 only after the real backup verifier succeeds.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + receipt = Path(str(payload["adoption_receipt"])) + assert payload["initialized"] is True + assert payload["to_version"] == 1 + assert receipt.is_file() + with sqlite3.connect(root / "audit.db") as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (1,) + assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",) + + +@pytest.mark.parametrize("backup_case", ["missing", "stale", "wrong_archive"]) +def test_migrate_tier_cli_adoption_refuses_unbound_backup( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, backup_case: str +) -> None: + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + backup_arguments = ["--backup-manifest", str(manifest)] + if backup_case == "missing": + backup_arguments = [] + elif backup_case == "stale": + source = root / "source.db" + source.write_bytes(source.read_bytes() + b"stale-after-backup") + else: + foreign_root = root.parent / "foreign-archive" + shutil.copytree(root, foreign_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(foreign_root)) + root = foreign_root + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + *backup_arguments, + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert not (root / "audit.db").exists() + error = json.loads(result.stdout)["error"] + if backup_case == "missing": + assert "adopt-established-audit" in error + else: + assert "audit adoption" in error + + +def test_migrate_tier_cli_adoption_refuses_live_writer_before_receipt_or_sql( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + monkeypatch.setattr( + "polylogue.cli.commands.maintenance._migrate_tier._daemon_pidfile_is_live", + lambda _pidfile: True, + ) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "daemon to be stopped" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + assert not (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").exists() + + +@pytest.mark.parametrize("publication_failure", ["race", "interrupted"]) +def test_migrate_tier_cli_adoption_fails_closed_during_publication( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, publication_failure: str +) -> None: + root = cli_workspace["archive_root"] + audit = root / "audit.db" + audit.unlink() + manifest = _full_evidence_backup_without_audit(root) + real_link = os.link + + def fail_or_race( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if publication_failure == "race": + assert dst_dir_fd is not None + fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dst_dir_fd) + try: + os.write(fd, b"foreign audit target") + finally: + os.close(fd) + real_link( + source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks + ) + return + raise OSError("simulated interrupted audit publication") + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.link", fail_or_race) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + if publication_failure == "race": + assert audit.read_bytes() == b"foreign audit target" + else: + assert not audit.exists() + assert (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").is_file() + + def test_rebuild_index_empty_source_still_runs_the_schema_currency_guard( cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 7ff5b19eba..ce9e742980 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -18,6 +18,12 @@ import pytest import polylogue.storage.sqlite.durable_change_train as durable_change_train_module +from polylogue.daemon.backup import backup_archive +from polylogue.operations.durable_change_train import ( + acquire_durable_archive_ownership, + adopt_missing_audit_tier, + audit_adoption_receipt_path, +) from polylogue.storage.sqlite import migration_runner from polylogue.storage.sqlite.archive_tiers import ARCHIVE_DDL_BY_TIER, ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier @@ -1667,6 +1673,36 @@ def test_fresh_archive_bootstrap_receipt_allows_repeat_startup(tmp_path: Path) - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root initialize_active_archive_root(tmp_path) + + +def test_audit_adoption_receipt_survives_startup_preflight(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The storage startup route validates the receipt created by the real adopter.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = tmp_path / "archive" + archive_root.mkdir() + initialize_active_archive_root(archive_root) + (archive_root / "audit.db").unlink() + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(archive_root)) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + backup = backup_archive(output_dir=tmp_path / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: + version, receipt = adopt_missing_audit_tier( + archive_root / "audit.db", + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert version == 1 + assert receipt == audit_adoption_receipt_path(archive_root) + assert reconcile_durable_change_train_startup(archive_root) == () + receipt.write_text("tampered", encoding="utf-8") + with pytest.raises(MigrationError, match="invalid audit adoption receipt"): + reconcile_durable_change_train_startup(archive_root) assert reconcile_durable_change_train_startup(tmp_path) == () initialize_active_archive_root(tmp_path) From 4320d32975309e3445ac104e0a368993a3eda6bc Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 08:34:47 +0200 Subject: [PATCH 02/28] fix(maintenance): bind audit adoption to published image --- polylogue/operations/durable_change_train.py | 79 +++++++++++++------ .../unit/cli/test_archive_maintenance_cli.py | 21 +++-- .../unit/storage/test_durable_change_train.py | 16 ++++ 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 57828d0ffa..753e44dcf2 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -76,7 +76,7 @@ def initialize_missing_durable_tier( *, directory_fd: int | None = None, permit_established_archive: bool = False, - pre_publish_check: Callable[[], None] | None = None, + pre_publish_check: Callable[[bytes], None] | None = None, ) -> int: """Initialize one absent durable tier while the caller owns the archive. @@ -355,7 +355,7 @@ def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: # re-census only evidence whose appearance would otherwise make this # empty tier an unsafe adoption. if pre_publish_check is not None: - pre_publish_check() + pre_publish_check(initialized_image) else: assert_no_adoption_evidence(check_target=False) try: @@ -454,7 +454,7 @@ def _audit_schema_inventory_sha256() -> str: return capture_durable_schema_inventory(connection).sha256 -def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, object]) -> None: +def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, object], *, archive_root: Path) -> None: """Publish one pre-publication receipt without replacement and fsync it.""" import json @@ -471,12 +471,30 @@ def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, objec os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, ) - os.write(descriptor, encoded) + offset = 0 + while offset < len(encoded): + written = os.write(descriptor, encoded[offset:]) + if written <= 0: + raise MigrationError("immutable audit adoption receipt write made no progress") + offset += written os.fsync(descriptor) os.close(descriptor) descriptor = None - directory_descriptor = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - os.fsync(directory_descriptor) + for directory in (path.parent, *path.parent.parents): + try: + directory_descriptor = os.open( + directory, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + os.fsync(directory_descriptor) + finally: + if directory_descriptor is not None: + os.close(directory_descriptor) + directory_descriptor = None + if directory == archive_root: + break + else: + raise MigrationError(f"audit adoption receipt escapes archive root: {path}") except FileExistsError as exc: raise MigrationError(f"audit adoption receipt already exists and is immutable: {path}") from exc except OSError as exc: @@ -516,6 +534,16 @@ def validate_audit_adoption_receipt(archive_root: Path) -> Path | None: audit_path = archive_root / "audit.db" if not audit_path.is_file(): raise MigrationError(f"audit adoption receipt has no published audit tier: {audit_path}") + expected_image_sha256 = payload.get("audit_image_sha256") + expected_image_size = payload.get("audit_image_size") + if not isinstance(expected_image_sha256, str) or not isinstance(expected_image_size, int): + raise MigrationError("audit adoption receipt lacks a canonical audit image binding") + try: + audit_image = audit_path.read_bytes() + except OSError as exc: + raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc + if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: + raise MigrationError("audit adoption receipt does not match the published canonical audit image") from polylogue.storage.sqlite.migration_runner import capture_durable_schema_inventory with sqlite3.connect(f"file:{audit_path}?mode=ro&immutable=1", uri=True) as connection: @@ -553,29 +581,36 @@ def adopt_missing_audit_tier( ) from polylogue.storage.archive_identity import ArchiveIdentity + initial_archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest receipt_path = audit_adoption_receipt_path(archive_root) - payload = { - "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, - "archive_identity_digest": ArchiveIdentity.resolve(archive_root).authority_identity_digest, - "backup_manifest": str(manifest_path.resolve()), - "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), - "backup_verification_receipt": str(verification_receipt.resolve()), - "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), - "stopped_daemon_evidence_ref": stopped_evidence, - "single_writer_evidence_ref": "proof:archive-ownership-lock", - "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), - "audit_user_version": 1, - } - _write_immutable_audit_adoption_receipt(receipt_path, payload) - - def revalidate_before_publish() -> None: + payload: dict[str, object] = {} + + def revalidate_before_publish(initialized_image: bytes) -> None: if path.exists() or path.is_symlink(): raise MigrationError(f"audit tier appeared during established-archive adoption: {path}") if stopped_daemon_check() != stopped_evidence: raise MigrationError("daemon stopped proof changed during audit adoption") validate_full_evidence_backup_for_audit_adoption(backup_manifest, archive_root=archive_root) - if ArchiveIdentity.resolve(archive_root).authority_identity_digest != payload["archive_identity_digest"]: + archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest + if archive_identity_digest != initial_archive_identity_digest: raise MigrationError("archive identity changed during audit adoption") + payload.update( + { + "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, + "archive_identity_digest": initial_archive_identity_digest, + "backup_manifest": str(manifest_path.resolve()), + "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + "backup_verification_receipt": str(verification_receipt.resolve()), + "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), + "stopped_daemon_evidence_ref": stopped_evidence, + "single_writer_evidence_ref": "proof:archive-ownership-lock", + "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), + "audit_user_version": 1, + "audit_image_sha256": hashlib.sha256(initialized_image).hexdigest(), + "audit_image_size": len(initialized_image), + } + ) + _write_immutable_audit_adoption_receipt(receipt_path, payload, archive_root=archive_root) version = initialize_missing_durable_tier( path, diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 4cbe6d9f01..bf7d969d2c 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3436,11 +3436,13 @@ def fail_or_race( ) -> None: if publication_failure == "race": assert dst_dir_fd is not None - fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=dst_dir_fd) - try: - os.write(fd, b"foreign audit target") - finally: - os.close(fd) + # This is a *valid* v1 audit database with a different image, not + # merely malformed bytes. Startup must reject the durable receipt + # after the atomic no-replace link detects the foreign target. + with sqlite3.connect(root / str(destination)) as foreign: + initialize_archive_tier(foreign, ArchiveTier.AUDIT) + foreign.execute("PRAGMA application_id = 41") + foreign.commit() real_link( source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks ) @@ -3467,7 +3469,14 @@ def fail_or_race( assert result.exit_code == 1 if publication_failure == "race": - assert audit.read_bytes() == b"foreign audit target" + with sqlite3.connect(audit) as foreign: + assert foreign.execute("PRAGMA user_version").fetchone() == (1,) + assert foreign.execute("PRAGMA quick_check").fetchone() == ("ok",) + from polylogue.operations.durable_change_train import reconcile_durable_change_trains_on_startup + from polylogue.storage.sqlite.migration_runner import MigrationError + + with pytest.raises(MigrationError, match="canonical audit image"): + reconcile_durable_change_trains_on_startup(root) else: assert not audit.exists() assert (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").is_file() diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index ce9e742980..4723c19f51 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1688,6 +1688,17 @@ def test_audit_adoption_receipt_survives_startup_preflight(tmp_path: Path, monke backup = backup_archive(output_dir=tmp_path / "backup", profile="full_evidence", verify=True) assert backup.ok, backup.error assert backup.output_path is not None + fsynced_paths: set[Path] = set() + real_fsync = os.fsync + + def record_fsync(descriptor: int) -> None: + try: + fsynced_paths.add(Path(os.readlink(f"/proc/self/fd/{descriptor}"))) + except OSError: + pass + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", record_fsync) with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: version, receipt = adopt_missing_audit_tier( @@ -1699,6 +1710,11 @@ def test_audit_adoption_receipt_survives_startup_preflight(tmp_path: Path, monke assert version == 1 assert receipt == audit_adoption_receipt_path(archive_root) + assert { + archive_root, + archive_root / ".maintenance-state", + archive_root / ".maintenance-state" / "durable-change-trains", + }.issubset(fsynced_paths) assert reconcile_durable_change_train_startup(archive_root) == () receipt.write_text("tampered", encoding="utf-8") with pytest.raises(MigrationError, match="invalid audit adoption receipt"): From eceaf2f06f84aa419ae5bd98681b8b88644981f8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 09:33:04 +0200 Subject: [PATCH 03/28] fix(maintenance): recover audit adoption publication Make the audit adoption receipt recoverable after a pre-publication interruption while preserving its initial-image proof. Validate later audit state structurally, tighten full-evidence admission, and stop ordinary bootstrap from bypassing adoption. --- polylogue/operations/durable_change_train.py | 209 +++++++++++++----- .../storage/sqlite/archive_tiers/bootstrap.py | 16 +- .../storage/sqlite/durable_change_train.py | 6 +- polylogue/storage/sqlite/migration_runner.py | 23 +- .../unit/cli/test_archive_maintenance_cli.py | 73 +++++- .../unit/storage/test_durable_change_train.py | 160 +++++++++++++- 6 files changed, 418 insertions(+), 69 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 753e44dcf2..72f5fffab0 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -3,11 +3,14 @@ from __future__ import annotations import hashlib +import json import os +import secrets import sqlite3 import stat import sys from collections.abc import Callable +from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -26,6 +29,8 @@ from polylogue.storage.sqlite.migration_runner import ( DurableRuntimeConsumerResult, MigrationError, + _canonical_json_sha256, + capture_durable_schema_inventory, validate_full_evidence_backup_for_audit_adoption, ) @@ -76,6 +81,7 @@ def initialize_missing_durable_tier( *, directory_fd: int | None = None, permit_established_archive: bool = False, + prepare_initialized_image: Callable[[sqlite3.Connection], None] | None = None, pre_publish_check: Callable[[bytes], None] | None = None, ) -> int: """Initialize one absent durable tier while the caller owns the archive. @@ -260,6 +266,8 @@ def assert_no_adoption_evidence(*, check_target: bool = True) -> None: memory_database = sqlite3.connect(":memory:") try: initialize_archive_tier(memory_database, tier) + if prepare_initialized_image is not None: + prepare_initialized_image(memory_database) initialized_image = memory_database.serialize() finally: memory_database.close() @@ -438,36 +446,43 @@ def audit_adoption_receipt_path(archive_root: Path) -> Path: return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_RECEIPT_NAME -def _canonical_json_sha256(payload: object) -> str: - import json - - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - def _audit_schema_inventory_sha256() -> str: from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier - from polylogue.storage.sqlite.migration_runner import capture_durable_schema_inventory - with sqlite3.connect(":memory:") as connection: + with closing(sqlite3.connect(":memory:")) as connection: initialize_archive_tier(connection, ArchiveTier.AUDIT) return capture_durable_schema_inventory(connection).sha256 +def _fsync_audit_adoption_receipt_directories(path: Path, *, archive_root: Path) -> None: + """Persist receipt directory entries through the owned archive root.""" + for directory in (path.parent, *path.parent.parents): + descriptor = os.open( + directory, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), + ) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + if directory == archive_root: + return + raise MigrationError(f"audit adoption receipt escapes archive root: {path}") + + def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, object], *, archive_root: Path) -> None: """Publish one pre-publication receipt without replacement and fsync it.""" - import json - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) unsigned = dict(payload) unsigned.pop("receipt_sha256", None) payload = {**unsigned, "receipt_sha256": _canonical_json_sha256(unsigned)} encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") descriptor: int | None = None - directory_descriptor: int | None = None + temporary_path = path.with_name(f".{path.name}.{secrets.token_hex(16)}.tmp") + published = False try: descriptor = os.open( - path, + temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, ) @@ -480,21 +495,11 @@ def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, objec os.fsync(descriptor) os.close(descriptor) descriptor = None - for directory in (path.parent, *path.parent.parents): - try: - directory_descriptor = os.open( - directory, - os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), - ) - os.fsync(directory_descriptor) - finally: - if directory_descriptor is not None: - os.close(directory_descriptor) - directory_descriptor = None - if directory == archive_root: - break - else: - raise MigrationError(f"audit adoption receipt escapes archive root: {path}") + os.link(temporary_path, path, follow_symlinks=False) + published = True + _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) + temporary_path.unlink() + _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) except FileExistsError as exc: raise MigrationError(f"audit adoption receipt already exists and is immutable: {path}") from exc except OSError as exc: @@ -502,15 +507,29 @@ def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, objec finally: if descriptor is not None: os.close(descriptor) - if directory_descriptor is not None: - os.close(directory_descriptor) + if not published: + try: + temporary_path.unlink(missing_ok=True) + _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) + except OSError: + pass -def validate_audit_adoption_receipt(archive_root: Path) -> Path | None: - """Validate a present adoption receipt before startup consumes its audit tier.""" - import json +def _audit_adoption_image_binding(payload: dict[str, object]) -> tuple[str, int, int]: + """Return the receipt-bound initial image digest, size, and durable marker.""" + expected_image_sha256 = payload.get("audit_image_sha256") + expected_image_size = payload.get("audit_image_size") + application_id = payload.get("audit_application_id") + if ( + not isinstance(expected_image_sha256, str) + or not isinstance(expected_image_size, int) + or not isinstance(application_id, int) + ): + raise MigrationError("audit adoption receipt lacks a canonical audit image binding") + return expected_image_sha256, expected_image_size, application_id - archive_root = archive_root.resolve() + +def _load_audit_adoption_receipt(archive_root: Path) -> tuple[Path, dict[str, object]] | None: receipt_path = audit_adoption_receipt_path(archive_root) if not receipt_path.exists(): return None @@ -531,26 +550,94 @@ def validate_audit_adoption_receipt(archive_root: Path) -> Path | None: raise MigrationError("audit adoption receipt archive identity mismatch") if payload.get("audit_schema_inventory_sha256") != _audit_schema_inventory_sha256(): raise MigrationError("audit adoption receipt canonical audit DDL mismatch") - audit_path = archive_root / "audit.db" - if not audit_path.is_file(): - raise MigrationError(f"audit adoption receipt has no published audit tier: {audit_path}") - expected_image_sha256 = payload.get("audit_image_sha256") - expected_image_size = payload.get("audit_image_size") - if not isinstance(expected_image_sha256, str) or not isinstance(expected_image_size, int): - raise MigrationError("audit adoption receipt lacks a canonical audit image binding") + _audit_adoption_image_binding(payload) + return receipt_path, payload + + +def _validate_audit_adoption_recovery_evidence(payload: dict[str, object], *, archive_root: Path) -> None: + manifest_value = payload.get("backup_manifest") + receipt_value = payload.get("backup_verification_receipt") + if not isinstance(manifest_value, str) or not isinstance(receipt_value, str): + raise MigrationError("audit adoption receipt lacks backup recovery evidence") + manifest_path = Path(manifest_value) + verification_receipt = Path(receipt_value) try: - audit_image = audit_path.read_bytes() + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() except OSError as exc: - raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc - if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: - raise MigrationError("audit adoption receipt does not match the published canonical audit image") - from polylogue.storage.sqlite.migration_runner import capture_durable_schema_inventory + raise MigrationError("audit adoption recovery evidence is unavailable") from exc + if ( + payload.get("backup_manifest_sha256") != manifest_sha256 + or payload.get("backup_verification_receipt_sha256") != receipt_sha256 + ): + raise MigrationError("audit adoption recovery evidence no longer matches its immutable receipt") + validated_manifest, validated_receipt = validate_full_evidence_backup_for_audit_adoption( + manifest_path, + archive_root=archive_root, + ) + if validated_manifest != manifest_path.resolve() or validated_receipt != verification_receipt.resolve(): + raise MigrationError("audit adoption recovery evidence path changed") + + +def _recover_pending_audit_adoption( + archive_root: Path, + receipt_path: Path, + payload: dict[str, object], +) -> None: + """Complete a missing audit publication from its immutable, verified intent.""" + audit_path = archive_root / "audit.db" + _validate_audit_adoption_recovery_evidence(payload, archive_root=archive_root) + expected_sha256, expected_size, application_id = _audit_adoption_image_binding(payload) + + def prepare_initialized_image(connection: sqlite3.Connection) -> None: + connection.execute(f"PRAGMA application_id = {application_id}") + + def revalidate_before_publish(initialized_image: bytes) -> None: + if hashlib.sha256(initialized_image).hexdigest() != expected_sha256 or len(initialized_image) != expected_size: + raise MigrationError("audit adoption receipt does not match its recoverable canonical audit image") + _validate_audit_adoption_recovery_evidence(payload, archive_root=archive_root) + if receipt_path != audit_adoption_receipt_path(archive_root): + raise MigrationError("audit adoption receipt path changed during recovery") + + initialize_missing_durable_tier( + audit_path, + ArchiveTier.AUDIT, + permit_established_archive=True, + prepare_initialized_image=prepare_initialized_image, + pre_publish_check=revalidate_before_publish, + ) - with sqlite3.connect(f"file:{audit_path}?mode=ro&immutable=1", uri=True) as connection: + +def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image: bool = False) -> Path | None: + """Validate a present adoption receipt before startup consumes its audit tier.""" + archive_root = archive_root.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + if receipt is None: + return None + receipt_path, payload = receipt + expected_image_sha256, expected_image_size, expected_application_id = _audit_adoption_image_binding(payload) + audit_path = archive_root / "audit.db" + if not audit_path.is_file(): + _recover_pending_audit_adoption(archive_root, receipt_path, payload) + require_initial_image = True + if require_initial_image: + try: + audit_image = audit_path.read_bytes() + except OSError as exc: + raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc + if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: + raise MigrationError("audit adoption receipt does not match the published canonical audit image") + with closing(sqlite3.connect(f"file:{audit_path}?mode=ro", uri=True)) as connection: version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) schema_digest = capture_durable_schema_inventory(connection).sha256 - if version != 1 or quick_check != ("ok",) or schema_digest != _audit_schema_inventory_sha256(): + if ( + version != 1 + or application_id != expected_application_id + or quick_check != ("ok",) + or schema_digest != _audit_schema_inventory_sha256() + ): raise MigrationError("audit adoption receipt does not match a canonical audit v1 tier") return receipt_path @@ -574,6 +661,10 @@ def adopt_missing_audit_tier( archive_root = path.parent.resolve() if path.exists() or path.is_symlink(): raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") + receipt_path = audit_adoption_receipt_path(archive_root) + if receipt_path.exists(): + validate_audit_adoption_receipt(archive_root) + return 1, receipt_path stopped_evidence = stopped_daemon_check() manifest_path, verification_receipt = validate_full_evidence_backup_for_audit_adoption( backup_manifest, @@ -582,9 +673,21 @@ def adopt_missing_audit_tier( from polylogue.storage.archive_identity import ArchiveIdentity initial_archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest - receipt_path = audit_adoption_receipt_path(archive_root) + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + verification_receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() + application_id = ( + int.from_bytes( + hashlib.sha256(f"{initial_archive_identity_digest}:{manifest_sha256}".encode()).digest()[:4], "big" + ) + & 0x7FFFFFFF + ) + if application_id == 0: + application_id = 1 payload: dict[str, object] = {} + def prepare_initialized_image(connection: sqlite3.Connection) -> None: + connection.execute(f"PRAGMA application_id = {application_id}") + def revalidate_before_publish(initialized_image: bytes) -> None: if path.exists() or path.is_symlink(): raise MigrationError(f"audit tier appeared during established-archive adoption: {path}") @@ -599,13 +702,14 @@ def revalidate_before_publish(initialized_image: bytes) -> None: "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, "archive_identity_digest": initial_archive_identity_digest, "backup_manifest": str(manifest_path.resolve()), - "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + "backup_manifest_sha256": manifest_sha256, "backup_verification_receipt": str(verification_receipt.resolve()), - "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), + "backup_verification_receipt_sha256": verification_receipt_sha256, "stopped_daemon_evidence_ref": stopped_evidence, "single_writer_evidence_ref": "proof:archive-ownership-lock", "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), "audit_user_version": 1, + "audit_application_id": application_id, "audit_image_sha256": hashlib.sha256(initialized_image).hexdigest(), "audit_image_size": len(initialized_image), } @@ -617,9 +721,10 @@ def revalidate_before_publish(initialized_image: bytes) -> None: ArchiveTier.AUDIT, directory_fd=directory_fd, permit_established_archive=True, + prepare_initialized_image=prepare_initialized_image, pre_publish_check=revalidate_before_publish, ) - validate_audit_adoption_receipt(archive_root) + validate_audit_adoption_receipt(archive_root, require_initial_image=True) return version, receipt_path diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index c085c85866..635507bb31 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -311,6 +311,7 @@ def initialize_archive_database( def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" + from polylogue.operations.durable_change_train import audit_adoption_receipt_path, validate_audit_adoption_receipt from polylogue.storage.archive_identity import ( ArchiveLocation, OwnedArchiveLocation, @@ -345,7 +346,8 @@ def assert_owned_root() -> None: (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS ) manifest_root = root / ".maintenance-state" / "durable-change-trains" - has_durable_train_state = any(manifest_root.glob("*.json")) + pending_audit_adoption = audit_adoption_receipt_path(root).exists() + has_durable_train_state = any(path.name != "audit-adoption.json" for path in manifest_root.glob("*.json")) has_bootstrap_marker = (manifest_root / ".bootstrap").is_file() pending_bootstrap_path = manifest_root / ".bootstrap.pending" has_pending_bootstrap = pending_bootstrap_path.is_file() @@ -376,9 +378,21 @@ def assert_owned_root() -> None: if fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap_intent(root) + if pending_audit_adoption: + assert_owned_root() + validate_audit_adoption_receipt(root) if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: assert_owned_root() reconcile_durable_change_trains_on_startup(root) + if ( + durable_tier_exists + and not recovering_fresh_durable_bootstrap + and not (root / archive_tier_spec(ArchiveTier.AUDIT).filename).is_file() + ): + raise RuntimeError( + "established archive is missing audit.db; use maintenance migrate-tier audit " + "--adopt-established-audit with a verified full_evidence backup" + ) for spec in ARCHIVE_TIER_SPECS.values(): assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 0ce054d41b..8e0e7a3bd2 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -330,7 +330,7 @@ def _record_fresh_durable_bootstrap(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(marker_root.glob("*.json")): + if marker_path.exists() or any(path.name != "audit-adoption.json" for path in marker_root.glob("*.json")): raise DurableChangeTrainError(f"cannot record fresh durable bootstrap over existing train state: {marker_root}") if pending_path.is_file(): _validate_fresh_durable_bootstrap_intent(archive_root) @@ -363,7 +363,7 @@ def _record_fresh_durable_bootstrap_intent(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(marker_root.glob("*.json")): + if marker_path.exists() or any(path.name != "audit-adoption.json" for path in marker_root.glob("*.json")): raise DurableChangeTrainError( f"cannot record fresh durable bootstrap intent over existing train state: {marker_root}" ) @@ -507,7 +507,7 @@ def _adopt_pre_marker_durable_bootstrap(archive_root: Path) -> None: manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" if (manifest_root / _FRESH_DURABLE_BOOTSTRAP_MARKER).is_file(): return - if any(manifest_root.glob("*.json")): + if any(path.name != "audit-adoption.json" for path in manifest_root.glob("*.json")): return for tier in DURABLE_MIGRATION_ADOPTION_FLOORS: tier_path = archive_root / f"{tier.value}.db" diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index dce684b1ac..241191d216 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -864,10 +864,16 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root manifest = _load_json(manifest_path, label="manifest") if manifest.get("format") != "polylogue-backup-v1" or manifest.get("profile") != "full_evidence": raise MigrationError("audit adoption requires a verified full_evidence backup") - required_tiers = {"source", "index", "embeddings", "user", "ops"} included = set(_json_str_list(manifest.get("included_tiers"))) - if included != {f"{tier}.db" for tier in required_tiers}: - raise MigrationError("audit adoption backup must contain exactly the established non-audit tier set") + required_tiers = {"source", "index", "embeddings", "user"} + permitted_tiers = required_tiers | {"ops"} + included_tiers = {name.removesuffix(".db") for name in included} + if ( + not required_tiers.issubset(included_tiers) + or included_tiers - permitted_tiers + or len(included_tiers) != len(included) + ): + raise MigrationError("audit adoption backup must contain every non-optional established tier and no audit tier") receipt_path = _receipt_path(manifest_path) if not receipt_path.exists() and not receipt_path.is_symlink(): raise MigrationError( @@ -905,9 +911,15 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) if receipt.get("artifact_inventory") != artifact_inventory: raise MigrationError("audit adoption backup receipt does not match the closed artifact inventory") - for tier in required_tiers: + for tier in sorted(included_tiers): live_path = archive_root / f"{tier}.db" artifact = artifacts[tier] + artifact_path = backup_root / f"{tier}.db" + try: + if artifact_path.samefile(live_path): + raise MigrationError(f"audit adoption backup tier artifact aliases the live tier: {tier}.db") + except OSError as exc: + raise MigrationError(f"cannot compare audit adoption backup tier with live tier: {tier}.db") from exc fingerprint = artifact.get("source_fingerprint") if not isinstance(fingerprint, dict): raise MigrationError(f"audit adoption backup lacks a live source fingerprint for {tier}.db") @@ -915,6 +927,9 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root raise MigrationError(f"audit adoption backup belongs to a different archive tier: {tier}.db") if not live_path.is_file(): raise MigrationError(f"audit adoption live tier is missing: {live_path}") + wal_path = live_path.with_name(f"{live_path.name}-wal") + if wal_path.exists() and wal_path.stat().st_size: + raise MigrationError(f"audit adoption backup has live WAL divergence for {tier}.db") if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: raise MigrationError(f"audit adoption backup is stale for {tier}.db") if str(fingerprint.get("sha256")) != _sha256_file(live_path): diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index bf7d969d2c..289902505d 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3338,6 +3338,72 @@ def test_migrate_tier_cli_adopts_established_audit_from_verified_full_evidence_b assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",) +def test_migrate_tier_cli_adoption_allows_a_full_evidence_backup_without_ops( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production adoption command accepts full evidence when optional ops.db is absent.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + (root / "ops.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert (root / "audit.db").is_file() + + +def test_migrate_tier_cli_adoption_rejects_live_wal_divergence( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production backup gate rejects logical source changes held only in a live WAL.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + source = root / "source.db" + with sqlite3.connect(source) as connection: + assert connection.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + connection.execute("CREATE TABLE adoption_wal_probe (value TEXT)") + connection.commit() + assert (root / "source.db-wal").stat().st_size > 0 + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "live WAL" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + + @pytest.mark.parametrize("backup_case", ["missing", "stale", "wrong_archive"]) def test_migrate_tier_cli_adoption_refuses_unbound_backup( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, backup_case: str @@ -3434,6 +3500,11 @@ def fail_or_race( dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: + if Path(destination).name != "audit.db": + real_link( + source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks + ) + return if publication_failure == "race": assert dst_dir_fd is not None # This is a *valid* v1 audit database with a different image, not @@ -3475,7 +3546,7 @@ def fail_or_race( from polylogue.operations.durable_change_train import reconcile_durable_change_trains_on_startup from polylogue.storage.sqlite.migration_runner import MigrationError - with pytest.raises(MigrationError, match="canonical audit image"): + with pytest.raises(MigrationError, match="canonical audit v1 tier"): reconcile_durable_change_trains_on_startup(root) else: assert not audit.exists() diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 4723c19f51..82bfbfba25 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -20,6 +20,7 @@ import polylogue.storage.sqlite.durable_change_train as durable_change_train_module from polylogue.daemon.backup import backup_archive from polylogue.operations.durable_change_train import ( + _write_immutable_audit_adoption_receipt, acquire_durable_archive_ownership, adopt_missing_audit_tier, audit_adoption_receipt_path, @@ -1673,19 +1674,20 @@ def test_fresh_archive_bootstrap_receipt_allows_repeat_startup(tmp_path: Path) - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root initialize_active_archive_root(tmp_path) + assert reconcile_durable_change_train_startup(tmp_path) == () + initialize_active_archive_root(tmp_path) -def test_audit_adoption_receipt_survives_startup_preflight(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_audit_adoption_receipt_survives_startup_preflight( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: """The storage startup route validates the receipt created by the real adopter.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - archive_root = tmp_path / "archive" - archive_root.mkdir() + archive_root = workspace_env["archive_root"] initialize_active_archive_root(archive_root) (archive_root / "audit.db").unlink() - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(archive_root)) - monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) - backup = backup_archive(output_dir=tmp_path / "backup", profile="full_evidence", verify=True) + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) assert backup.ok, backup.error assert backup.output_path is not None fsynced_paths: set[Path] = set() @@ -1719,8 +1721,149 @@ def record_fsync(descriptor: int) -> None: receipt.write_text("tampered", encoding="utf-8") with pytest.raises(MigrationError, match="invalid audit adoption receipt"): reconcile_durable_change_train_startup(archive_root) - assert reconcile_durable_change_train_startup(tmp_path) == () - initialize_active_archive_root(tmp_path) + + +def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: dict[str, Path]) -> None: + """Startup accepts an adopted audit tier after normal SQLite journal writes.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-mutable-journal") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with sqlite3.connect(audit_path) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("adopted-audit-journal", 1, 1), + ) + connection.commit() + + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_audit_adoption_receipt_recovers_interrupted_publication_during_bootstrap( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """The normal bootstrap route completes the receipt-backed publication after a crash.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker_root = archive_root / ".maintenance-state" / "durable-change-trains" + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + real_link = os.link + + def fail_audit_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name == "audit.db": + raise OSError("simulated interruption") + real_link( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + with monkeypatch.context() as failed_publication: + failed_publication.setattr("polylogue.operations.durable_change_train.os.link", fail_audit_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-interrupted-publication") as owner: + with pytest.raises(MigrationError): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + initialize_active_archive_root(archive_root) + + assert audit_path.is_file() + assert (marker_root / ".bootstrap").is_file() + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_audit_adoption_receipt_is_excluded_from_pre_marker_train_state(workspace_env: dict[str, Path]) -> None: + """The adoption receipt does not prevent legacy current-schema bootstrap adoption.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker = archive_root / ".maintenance-state" / "durable-change-trains" / ".bootstrap" + marker.unlink() + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-pre-marker") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + initialize_active_archive_root(archive_root) + + assert marker.is_file() + + +def test_adoption_receipt_short_write_is_removed_for_a_safe_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed receipt write leaves no immutable-looking truncated publication behind.""" + archive_root = tmp_path / "archive" + receipt_path = audit_adoption_receipt_path(archive_root) + write_calls = 0 + + def short_then_fail(descriptor: int, data: bytes) -> int: + nonlocal write_calls + write_calls += 1 + if write_calls == 1: + return min(1, len(data)) + raise OSError("simulated receipt write failure") + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.write", short_then_fail) + + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + _write_immutable_audit_adoption_receipt(receipt_path, {"format": "test"}, archive_root=archive_root) + + assert not receipt_path.exists() + + +def test_runtime_bootstrap_refuses_an_established_archive_missing_audit(workspace_env: dict[str, Path]) -> None: + """Ordinary writable startup cannot create audit.db without adoption evidence.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + (archive_root / "audit.db").unlink() + + with pytest.raises(RuntimeError, match="adopt-established-audit"): + initialize_active_archive_root(archive_root) + + assert not (archive_root / "audit.db").exists() def test_fresh_bootstrap_intent_recovers_after_late_tier_failure( @@ -2359,6 +2502,7 @@ def test_bootstrap_reconciles_and_persists_interrupted_train_evidence( conn.execute("CREATE TABLE durable_items (item_id TEXT PRIMARY KEY, payload TEXT NOT NULL) STRICT") conn.execute(f"PRAGMA user_version = {_TARGET_VERSION}") conn.commit() + bootstrap.initialize_archive_database(tmp_path / "audit.db", ArchiveTier.AUDIT) manifest = tmp_path / ".maintenance-state" / "durable-change-trains" / "source-002.json" write_durable_change_train_manifest(manifest, train, expected_revision=-1) From dde32d3526fd5ea6fced8acf2a3889cc09f130dc Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 12:06:29 +0200 Subject: [PATCH 04/28] fix(storage): anchor audit adoption receipts to archive Traverse audit-adoption receipt parents through owned directory descriptors with O_NOFOLLOW, and perform receipt publication and loading relative to the anchored directory. Reject symlinked or non-private intermediate paths before any outside write.\n\nAdd a regression proving a symlinked maintenance parent cannot receive the immutable receipt.\n\nCo-Authored-By: OpenAI Codex --- polylogue/operations/durable_change_train.py | 173 +++++++++++++++--- .../unit/storage/test_durable_change_train.py | 16 ++ 2 files changed, 160 insertions(+), 29 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 72f5fffab0..da3b3f46dc 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -10,7 +10,7 @@ import stat import sys from collections.abc import Callable -from contextlib import closing +from contextlib import closing, suppress from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -454,37 +454,110 @@ def _audit_schema_inventory_sha256() -> str: return capture_durable_schema_inventory(connection).sha256 -def _fsync_audit_adoption_receipt_directories(path: Path, *, archive_root: Path) -> None: - """Persist receipt directory entries through the owned archive root.""" - for directory in (path.parent, *path.parent.parents): - descriptor = os.open( - directory, - os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0), +def _open_audit_adoption_receipt_directory( + path: Path, + *, + archive_root: Path, + create: bool, + archive_directory_fd: int | None = None, +) -> int: + """Open the receipt parent without following any archive path component.""" + archive_root = archive_root.resolve() + expected_path = audit_adoption_receipt_path(archive_root) + if path != expected_path: + raise MigrationError(f"audit adoption receipt path is outside its fixed archive location: {path}") + try: + current_fd = ( + os.dup(archive_directory_fd) + if archive_directory_fd is not None + else os.open( + archive_root, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) ) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - if directory == archive_root: - return - raise MigrationError(f"audit adoption receipt escapes archive root: {path}") + except OSError as exc: + raise MigrationError(f"cannot anchor audit adoption receipt to archive root: {archive_root}") from exc + try: + for component in path.parent.relative_to(archive_root).parts: + try: + next_fd = os.open( + component, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=current_fd, + ) + except FileNotFoundError: + if not create: + raise + with suppress(FileExistsError): + os.mkdir(component, mode=0o700, dir_fd=current_fd) + next_fd = os.open( + component, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=current_fd, + ) + metadata = os.fstat(next_fd) + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + os.close(next_fd) + raise MigrationError( + f"audit adoption receipt parent is not a private owned directory: {archive_root / component}" + ) + os.fsync(current_fd) + os.fsync(next_fd) + os.close(current_fd) + current_fd = next_fd + return current_fd + except BaseException as exc: + os.close(current_fd) + if isinstance(exc, (FileNotFoundError, MigrationError)): + raise + if isinstance(exc, OSError): + raise MigrationError( + f"audit adoption receipt path must not traverse outside archive-owned directories: {path}" + ) from exc + raise -def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, object], *, archive_root: Path) -> None: +def _write_immutable_audit_adoption_receipt( + path: Path, + payload: dict[str, object], + *, + archive_root: Path, + archive_directory_fd: int | None = None, +) -> None: """Publish one pre-publication receipt without replacement and fsync it.""" - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) unsigned = dict(payload) unsigned.pop("receipt_sha256", None) payload = {**unsigned, "receipt_sha256": _canonical_json_sha256(unsigned)} encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") descriptor: int | None = None - temporary_path = path.with_name(f".{path.name}.{secrets.token_hex(16)}.tmp") + receipt_directory_fd: int | None = None + temporary_name = f".{path.name}.{secrets.token_hex(16)}.tmp" published = False try: + receipt_directory_fd = _open_audit_adoption_receipt_directory( + path, + archive_root=archive_root, + create=True, + archive_directory_fd=archive_directory_fd, + ) descriptor = os.open( - temporary_path, + temporary_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, + dir_fd=receipt_directory_fd, ) offset = 0 while offset < len(encoded): @@ -495,11 +568,17 @@ def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, objec os.fsync(descriptor) os.close(descriptor) descriptor = None - os.link(temporary_path, path, follow_symlinks=False) + os.link( + temporary_name, + path.name, + src_dir_fd=receipt_directory_fd, + dst_dir_fd=receipt_directory_fd, + follow_symlinks=False, + ) published = True - _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) - temporary_path.unlink() - _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) + os.fsync(receipt_directory_fd) + os.unlink(temporary_name, dir_fd=receipt_directory_fd) + os.fsync(receipt_directory_fd) except FileExistsError as exc: raise MigrationError(f"audit adoption receipt already exists and is immutable: {path}") from exc except OSError as exc: @@ -507,12 +586,16 @@ def _write_immutable_audit_adoption_receipt(path: Path, payload: dict[str, objec finally: if descriptor is not None: os.close(descriptor) - if not published: + if receipt_directory_fd is not None and not published: try: - temporary_path.unlink(missing_ok=True) - _fsync_audit_adoption_receipt_directories(path, archive_root=archive_root) + os.unlink(temporary_name, dir_fd=receipt_directory_fd) + os.fsync(receipt_directory_fd) + except FileNotFoundError: + pass except OSError: pass + if receipt_directory_fd is not None: + os.close(receipt_directory_fd) def _audit_adoption_image_binding(payload: dict[str, object]) -> tuple[str, int, int]: @@ -531,12 +614,39 @@ def _audit_adoption_image_binding(payload: dict[str, object]) -> tuple[str, int, def _load_audit_adoption_receipt(archive_root: Path) -> tuple[Path, dict[str, object]] | None: receipt_path = audit_adoption_receipt_path(archive_root) - if not receipt_path.exists(): + try: + receipt_directory_fd = _open_audit_adoption_receipt_directory( + receipt_path, + archive_root=archive_root, + create=False, + ) + except FileNotFoundError: return None + receipt_fd: int | None = None try: - payload = json.loads(receipt_path.read_text(encoding="utf-8")) + receipt_fd = os.open( + receipt_path.name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=receipt_directory_fd, + ) + metadata = os.fstat(receipt_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit adoption receipt ownership or mode: {receipt_path}") + with os.fdopen(receipt_fd, "r", encoding="utf-8") as stream: + receipt_fd = None + payload = json.load(stream) + except FileNotFoundError: + return None except (OSError, json.JSONDecodeError) as exc: raise MigrationError(f"invalid audit adoption receipt: {receipt_path}") from exc + finally: + if receipt_fd is not None: + os.close(receipt_fd) + os.close(receipt_directory_fd) if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_RECEIPT_FORMAT: raise MigrationError(f"audit adoption receipt format mismatch: {receipt_path}") digest = payload.get("receipt_sha256") @@ -662,7 +772,7 @@ def adopt_missing_audit_tier( if path.exists() or path.is_symlink(): raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") receipt_path = audit_adoption_receipt_path(archive_root) - if receipt_path.exists(): + if _load_audit_adoption_receipt(archive_root) is not None: validate_audit_adoption_receipt(archive_root) return 1, receipt_path stopped_evidence = stopped_daemon_check() @@ -714,7 +824,12 @@ def revalidate_before_publish(initialized_image: bytes) -> None: "audit_image_size": len(initialized_image), } ) - _write_immutable_audit_adoption_receipt(receipt_path, payload, archive_root=archive_root) + _write_immutable_audit_adoption_receipt( + receipt_path, + payload, + archive_root=archive_root, + archive_directory_fd=directory_fd, + ) version = initialize_missing_durable_tier( path, diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 82bfbfba25..827e2483cd 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1834,6 +1834,7 @@ def test_adoption_receipt_short_write_is_removed_for_a_safe_retry( ) -> None: """A failed receipt write leaves no immutable-looking truncated publication behind.""" archive_root = tmp_path / "archive" + archive_root.mkdir() receipt_path = audit_adoption_receipt_path(archive_root) write_calls = 0 @@ -1852,6 +1853,21 @@ def short_then_fail(descriptor: int, data: bytes) -> int: assert not receipt_path.exists() +def test_adoption_receipt_refuses_a_symlinked_maintenance_parent(tmp_path: Path) -> None: + """Receipt publication and loading stay beneath the owned archive descriptor.""" + archive_root = tmp_path / "archive" + archive_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (archive_root / ".maintenance-state").symlink_to(outside, target_is_directory=True) + receipt_path = audit_adoption_receipt_path(archive_root) + + with pytest.raises(MigrationError, match="must not traverse outside archive-owned directories"): + _write_immutable_audit_adoption_receipt(receipt_path, {"format": "test"}, archive_root=archive_root) + + assert not (outside / "durable-change-trains" / "audit-adoption.json").exists() + + def test_runtime_bootstrap_refuses_an_established_archive_missing_audit(workspace_env: dict[str, Path]) -> None: """Ordinary writable startup cannot create audit.db without adoption evidence.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root From bb963844207a610bff039104d8b4b0957bc9f115 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 12:36:22 +0200 Subject: [PATCH 05/28] fix(storage): preserve audit adoption continuity Bind audit adoption receipts to stable source/user authority and retain the initial v1 image/schema details as publication evidence. Record the published audit file identity separately so startup accepts in-place journal mutation while rejecting stale file replacement.\n\nRecover pending publication before classifying pre-marker archives, and exclude the continuity record from durable-train discovery.\n\nCo-Authored-By: OpenAI Codex --- polylogue/operations/durable_change_train.py | 178 +++++++++++++++--- .../storage/sqlite/archive_tiers/bootstrap.py | 22 ++- .../storage/sqlite/durable_change_train.py | 16 +- .../unit/storage/test_durable_change_train.py | 137 ++++++++++++++ 4 files changed, 318 insertions(+), 35 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index da3b3f46dc..2e4214f4fe 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -36,6 +36,8 @@ _AUDIT_ADOPTION_RECEIPT_FORMAT = "polylogue.audit-tier-adoption.v1" _AUDIT_ADOPTION_RECEIPT_NAME = "audit-adoption.json" +_AUDIT_ADOPTION_CONTINUITY_FORMAT = "polylogue.audit-tier-continuity.v1" +_AUDIT_ADOPTION_CONTINUITY_NAME = "audit-continuity.json" @dataclass(frozen=True, slots=True) @@ -446,6 +448,19 @@ def audit_adoption_receipt_path(archive_root: Path) -> Path: return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_RECEIPT_NAME +def _audit_adoption_continuity_path(archive_root: Path) -> Path: + """Return the immutable identity binding for the published audit file.""" + return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_CONTINUITY_NAME + + +def _audit_adoption_authority_digest(archive_root: Path) -> str: + """Bind adoption to the two irreplaceable archive authority tiers only.""" + from polylogue.storage.archive_identity import ArchiveIdentity + + durable_id = ArchiveIdentity.resolve(archive_root).durable_id + return hashlib.sha256(f"source-user-authority:{durable_id}".encode()).hexdigest() + + def _audit_schema_inventory_sha256() -> str: from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier @@ -463,8 +478,11 @@ def _open_audit_adoption_receipt_directory( ) -> int: """Open the receipt parent without following any archive path component.""" archive_root = archive_root.resolve() - expected_path = audit_adoption_receipt_path(archive_root) - if path != expected_path: + expected_paths = { + audit_adoption_receipt_path(archive_root), + _audit_adoption_continuity_path(archive_root), + } + if path not in expected_paths: raise MigrationError(f"audit adoption receipt path is outside its fixed archive location: {path}") try: current_fd = ( @@ -536,11 +554,12 @@ def _write_immutable_audit_adoption_receipt( *, archive_root: Path, archive_directory_fd: int | None = None, + checksum_key: str = "receipt_sha256", ) -> None: """Publish one pre-publication receipt without replacement and fsync it.""" unsigned = dict(payload) - unsigned.pop("receipt_sha256", None) - payload = {**unsigned, "receipt_sha256": _canonical_json_sha256(unsigned)} + unsigned.pop(checksum_key, None) + payload = {**unsigned, checksum_key: _canonical_json_sha256(unsigned)} encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") descriptor: int | None = None receipt_directory_fd: int | None = None @@ -654,16 +673,114 @@ def _load_audit_adoption_receipt(archive_root: Path) -> tuple[Path, dict[str, ob unsigned.pop("receipt_sha256", None) if not isinstance(digest, str) or digest != _canonical_json_sha256(unsigned): raise MigrationError(f"audit adoption receipt checksum mismatch: {receipt_path}") - from polylogue.storage.archive_identity import ArchiveIdentity - - if payload.get("archive_identity_digest") != ArchiveIdentity.resolve(archive_root).authority_identity_digest: - raise MigrationError("audit adoption receipt archive identity mismatch") - if payload.get("audit_schema_inventory_sha256") != _audit_schema_inventory_sha256(): - raise MigrationError("audit adoption receipt canonical audit DDL mismatch") + if payload.get("source_user_authority_digest") != _audit_adoption_authority_digest(archive_root): + raise MigrationError("audit adoption receipt source/user authority mismatch") _audit_adoption_image_binding(payload) return receipt_path, payload +def _audit_file_identity(path: Path) -> tuple[int, int]: + """Read the audit leaf identity without following a replacement symlink.""" + try: + metadata = path.lstat() + except OSError as exc: + raise MigrationError(f"cannot inspect adopted audit tier: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise MigrationError(f"adopted audit tier is not a regular file: {path}") + return metadata.st_dev, metadata.st_ino + + +def _load_audit_adoption_continuity(archive_root: Path) -> dict[str, object] | None: + """Load the immutable audit-file identity record, if publication reached it.""" + continuity_path = _audit_adoption_continuity_path(archive_root) + try: + continuity_directory_fd = _open_audit_adoption_receipt_directory( + continuity_path, + archive_root=archive_root, + create=False, + ) + except FileNotFoundError: + return None + continuity_fd: int | None = None + try: + continuity_fd = os.open( + continuity_path.name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=continuity_directory_fd, + ) + metadata = os.fstat(continuity_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit adoption continuity ownership or mode: {continuity_path}") + with os.fdopen(continuity_fd, "r", encoding="utf-8") as stream: + continuity_fd = None + payload = json.load(stream) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit adoption continuity record: {continuity_path}") from exc + finally: + if continuity_fd is not None: + os.close(continuity_fd) + os.close(continuity_directory_fd) + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_CONTINUITY_FORMAT: + raise MigrationError(f"audit adoption continuity format mismatch: {continuity_path}") + digest = payload.get("continuity_sha256") + unsigned = dict(payload) + unsigned.pop("continuity_sha256", None) + if not isinstance(digest, str) or digest != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit adoption continuity checksum mismatch: {continuity_path}") + return payload + + +def _write_audit_adoption_continuity( + archive_root: Path, + *, + receipt_payload: dict[str, object], +) -> None: + """Publish the post-link audit identity that later detects stale replacement.""" + audit_path = archive_root / "audit.db" + device, inode = _audit_file_identity(audit_path) + continuity_path = _audit_adoption_continuity_path(archive_root) + payload: dict[str, object] = { + "format": _AUDIT_ADOPTION_CONTINUITY_FORMAT, + "receipt_sha256": receipt_payload["receipt_sha256"], + "source_user_authority_digest": receipt_payload["source_user_authority_digest"], + "audit_device": device, + "audit_inode": inode, + } + unsigned = dict(payload) + payload["continuity_sha256"] = _canonical_json_sha256(unsigned) + _write_immutable_audit_adoption_receipt( + continuity_path, + payload, + archive_root=archive_root, + checksum_key="continuity_sha256", + ) + if _audit_file_identity(audit_path) != (device, inode): + raise MigrationError("audit tier changed while recording adoption continuity") + + +def _validate_audit_adoption_continuity(archive_root: Path, *, receipt_payload: dict[str, object]) -> None: + """Require the published audit path to retain its adopted live identity.""" + continuity = _load_audit_adoption_continuity(archive_root) + if continuity is None: + _write_audit_adoption_continuity(archive_root, receipt_payload=receipt_payload) + continuity = _load_audit_adoption_continuity(archive_root) + assert continuity is not None + expected = (continuity.get("audit_device"), continuity.get("audit_inode")) + if ( + continuity.get("receipt_sha256") != receipt_payload.get("receipt_sha256") + or continuity.get("source_user_authority_digest") != receipt_payload.get("source_user_authority_digest") + or not all(isinstance(value, int) for value in expected) + or _audit_file_identity(archive_root / "audit.db") != expected + ): + raise MigrationError("audit adoption continuity does not match the live audit tier") + + def _validate_audit_adoption_recovery_evidence(payload: dict[str, object], *, archive_root: Path) -> None: manifest_value = payload.get("backup_manifest") receipt_value = payload.get("backup_verification_receipt") @@ -718,6 +835,17 @@ def revalidate_before_publish(initialized_image: bytes) -> None: ) +def recover_pending_audit_adoption(archive_root: Path) -> bool: + """Publish a receipt-backed missing audit file before startup classification.""" + archive_root = archive_root.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + if receipt is None or (archive_root / "audit.db").is_file(): + return False + receipt_path, payload = receipt + _recover_pending_audit_adoption(archive_root, receipt_path, payload) + return True + + def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image: bool = False) -> Path | None: """Validate a present adoption receipt before startup consumes its audit tier.""" archive_root = archive_root.resolve() @@ -726,6 +854,9 @@ def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image return None receipt_path, payload = receipt expected_image_sha256, expected_image_size, expected_application_id = _audit_adoption_image_binding(payload) + expected_initial_version = payload.get("audit_user_version") + if not isinstance(expected_initial_version, int): + raise MigrationError("audit adoption receipt lacks its initial audit schema version") audit_path = archive_root / "audit.db" if not audit_path.is_file(): _recover_pending_audit_adoption(archive_root, receipt_path, payload) @@ -741,14 +872,9 @@ def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) - schema_digest = capture_durable_schema_inventory(connection).sha256 - if ( - version != 1 - or application_id != expected_application_id - or quick_check != ("ok",) - or schema_digest != _audit_schema_inventory_sha256() - ): - raise MigrationError("audit adoption receipt does not match a canonical audit v1 tier") + if version < expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("audit adoption receipt does not match the live audit tier") + _validate_audit_adoption_continuity(archive_root, receipt_payload=payload) return receipt_path @@ -780,15 +906,11 @@ def adopt_missing_audit_tier( backup_manifest, archive_root=archive_root, ) - from polylogue.storage.archive_identity import ArchiveIdentity - - initial_archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest + initial_authority_digest = _audit_adoption_authority_digest(archive_root) manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() verification_receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() application_id = ( - int.from_bytes( - hashlib.sha256(f"{initial_archive_identity_digest}:{manifest_sha256}".encode()).digest()[:4], "big" - ) + int.from_bytes(hashlib.sha256(f"{initial_authority_digest}:{manifest_sha256}".encode()).digest()[:4], "big") & 0x7FFFFFFF ) if application_id == 0: @@ -804,13 +926,12 @@ def revalidate_before_publish(initialized_image: bytes) -> None: if stopped_daemon_check() != stopped_evidence: raise MigrationError("daemon stopped proof changed during audit adoption") validate_full_evidence_backup_for_audit_adoption(backup_manifest, archive_root=archive_root) - archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest - if archive_identity_digest != initial_archive_identity_digest: - raise MigrationError("archive identity changed during audit adoption") + if _audit_adoption_authority_digest(archive_root) != initial_authority_digest: + raise MigrationError("source/user authority changed during audit adoption") payload.update( { "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, - "archive_identity_digest": initial_archive_identity_digest, + "source_user_authority_digest": initial_authority_digest, "backup_manifest": str(manifest_path.resolve()), "backup_manifest_sha256": manifest_sha256, "backup_verification_receipt": str(verification_receipt.resolve()), @@ -878,5 +999,6 @@ def reconcile_durable_change_trains_on_startup(root: Path) -> tuple[Path, ...]: "execute_durable_change_train", "initialize_missing_durable_tier", "reconcile_durable_change_trains_on_startup", + "recover_pending_audit_adoption", "validate_audit_adoption_receipt", ] diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 635507bb31..4efb66194f 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -311,7 +311,7 @@ def initialize_archive_database( def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" - from polylogue.operations.durable_change_train import audit_adoption_receipt_path, validate_audit_adoption_receipt + from polylogue.operations.durable_change_train import audit_adoption_receipt_path, recover_pending_audit_adoption from polylogue.storage.archive_identity import ( ArchiveLocation, OwnedArchiveLocation, @@ -347,7 +347,9 @@ def assert_owned_root() -> None: ) manifest_root = root / ".maintenance-state" / "durable-change-trains" pending_audit_adoption = audit_adoption_receipt_path(root).exists() - has_durable_train_state = any(path.name != "audit-adoption.json" for path in manifest_root.glob("*.json")) + has_durable_train_state = any( + path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in manifest_root.glob("*.json") + ) has_bootstrap_marker = (manifest_root / ".bootstrap").is_file() pending_bootstrap_path = manifest_root / ".bootstrap.pending" has_pending_bootstrap = pending_bootstrap_path.is_file() @@ -380,7 +382,21 @@ def assert_owned_root() -> None: _record_fresh_durable_bootstrap_intent(root) if pending_audit_adoption: assert_owned_root() - validate_audit_adoption_receipt(root) + recover_pending_audit_adoption(root) + # Receipt-backed recovery can add audit.db to a legacy archive. + # Recompute the path-sensitive classification before deciding + # whether startup must create the missing bootstrap marker. + durable_tier_exists = any( + (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS + ) + pre_marker_adoption = ( + (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() + and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) + and manifest_root.is_dir() + and not has_durable_train_state + and not has_bootstrap_marker + and not has_pending_bootstrap + ) if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: assert_owned_root() reconcile_durable_change_trains_on_startup(root) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 8e0e7a3bd2..36c004a8b6 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -330,7 +330,9 @@ def _record_fresh_durable_bootstrap(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(path.name != "audit-adoption.json" for path in marker_root.glob("*.json")): + if marker_path.exists() or any( + path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in marker_root.glob("*.json") + ): raise DurableChangeTrainError(f"cannot record fresh durable bootstrap over existing train state: {marker_root}") if pending_path.is_file(): _validate_fresh_durable_bootstrap_intent(archive_root) @@ -363,7 +365,9 @@ def _record_fresh_durable_bootstrap_intent(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(path.name != "audit-adoption.json" for path in marker_root.glob("*.json")): + if marker_path.exists() or any( + path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in marker_root.glob("*.json") + ): raise DurableChangeTrainError( f"cannot record fresh durable bootstrap intent over existing train state: {marker_root}" ) @@ -507,7 +511,7 @@ def _adopt_pre_marker_durable_bootstrap(archive_root: Path) -> None: manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" if (manifest_root / _FRESH_DURABLE_BOOTSTRAP_MARKER).is_file(): return - if any(path.name != "audit-adoption.json" for path in manifest_root.glob("*.json")): + if any(path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in manifest_root.glob("*.json")): return for tier in DURABLE_MIGRATION_ADOPTION_FLOORS: tier_path = archive_root / f"{tier.value}.db" @@ -2294,7 +2298,11 @@ def _reconcile_durable_change_train_startup_locked( canonical_inventory_by_tier: dict[ArchiveTier, _migration_runner.DurableSchemaInventory] = {} manifests_by_tier: dict[ArchiveTier, dict[int, DurableChangeTrain]] = {} validated_tiers: set[ArchiveTier] = set() - manifest_paths = tuple(path for path in sorted(manifest_root.glob("*.json")) if path.name != "audit-adoption.json") + manifest_paths = tuple( + path + for path in sorted(manifest_root.glob("*.json")) + if path.name not in {"audit-adoption.json", "audit-continuity.json"} + ) fresh_bootstrap_versions = _fresh_durable_bootstrap_versions(archive_root, manifest_root) def record_reconciled(path: Path) -> None: diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 827e2483cd..a248b551b8 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1751,6 +1751,133 @@ def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: di assert reconcile_durable_change_train_startup(archive_root) == () +def test_audit_adoption_binds_only_the_source_user_authority(workspace_env: dict[str, Path]) -> None: + """Routine replacement of rebuildable or disposable tiers leaves adoption valid.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-stable-authority") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + (archive_root / "index.db").unlink() + (archive_root / "ops.db").unlink() + initialize_active_archive_root(archive_root) + + assert (archive_root / "index.db").is_file() + assert (archive_root / "ops.db").is_file() + + +def test_audit_adoption_receipt_keeps_initial_schema_evidence_after_upgrade( + workspace_env: dict[str, Path], +) -> None: + """Receipt validation permits later audit schema versions for normal train handling.""" + from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-schema-evidence") as owner: + _version, receipt = adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + initial_schema_digest = json.loads(receipt.read_text(encoding="utf-8"))["audit_schema_inventory_sha256"] + + with sqlite3.connect(audit_path) as connection: + connection.execute("CREATE TABLE future_audit_schema (value TEXT)") + connection.execute("PRAGMA user_version = 2") + connection.commit() + + assert validate_audit_adoption_receipt(archive_root) == receipt + assert json.loads(receipt.read_text(encoding="utf-8"))["audit_schema_inventory_sha256"] == initial_schema_digest + + +def test_audit_adoption_rejects_a_stale_audit_file_clone(workspace_env: dict[str, Path]) -> None: + """The continuity record distinguishes in-place writes from a stale file replacement.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-stale-clone") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + stale_clone = archive_root / "stale-audit.db" + shutil.copy2(audit_path, stale_clone) + with sqlite3.connect(audit_path) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("live-audit-after-clone", 2, 1), + ) + connection.commit() + os.replace(stale_clone, audit_path) + + with pytest.raises(MigrationError, match="continuity"): + reconcile_durable_change_train_startup(archive_root) + + +def test_adopted_audit_startup_runs_one_receipt_integrity_check( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Bootstrap delegates adopted-tier validation to startup reconciliation once.""" + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-single-quick-check") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + calls = 0 + real_validate = operations_durable_change_train.validate_audit_adoption_receipt + + def count_validate(root: Path, *, require_initial_image: bool = False) -> Path | None: + nonlocal calls + calls += 1 + return real_validate(root, require_initial_image=require_initial_image) + + monkeypatch.setattr(operations_durable_change_train, "validate_audit_adoption_receipt", count_validate) + initialize_active_archive_root(archive_root) + + assert calls == 1 + + def test_audit_adoption_receipt_recovers_interrupted_publication_during_bootstrap( workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1765,6 +1892,16 @@ def test_audit_adoption_receipt_recovers_interrupted_publication_during_bootstra backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) assert backup.ok, backup.error assert backup.output_path is not None + monkeypatch.setitem( + durable_change_train_module.DURABLE_MIGRATION_ADOPTION_FLOORS, + ArchiveTier.SOURCE, + ARCHIVE_VERSION_BY_TIER[ArchiveTier.SOURCE] - 1, + ) + monkeypatch.setitem( + durable_change_train_module.DURABLE_MIGRATION_ADOPTION_FLOORS, + ArchiveTier.USER, + ARCHIVE_VERSION_BY_TIER[ArchiveTier.USER] - 1, + ) real_link = os.link def fail_audit_link( From a49d0d2b82c2e67e3af01f2d1adc89634c29a351 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 12:59:27 +0200 Subject: [PATCH 06/28] fix(storage): fail closed in audit adoption recovery Problem: a crash after linking audit.db could record continuity for a stale replacement, and a later missing audit.db could be recreated despite existing continuity.\n\nWhat changed: authenticate the receipt-bound initial image before continuity creation, bind the identity write to that verified file, and refuse recovery once continuity proves the original publication completed.\n\nCompatibility: existing continuity still validates later in-place audit schema migrations. --- polylogue/operations/durable_change_train.py | 93 +++++++++++++--- .../unit/storage/test_durable_change_train.py | 101 ++++++++++++++++++ 2 files changed, 177 insertions(+), 17 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 2e4214f4fe..e88f0c0353 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -690,6 +690,37 @@ def _audit_file_identity(path: Path) -> tuple[int, int]: return metadata.st_dev, metadata.st_ino +def _audit_live_metadata(audit_path: Path) -> tuple[int, int, tuple[str, ...]]: + """Read the durable markers that remain valid after an in-place migration.""" + with closing(sqlite3.connect(f"file:{audit_path}?mode=ro", uri=True)) as connection: + version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) + quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) + return version, application_id, quick_check + + +def _validate_initial_audit_image( + audit_path: Path, + *, + expected_image_sha256: str, + expected_image_size: int, + expected_application_id: int, + expected_initial_version: int, +) -> tuple[int, int]: + """Authenticate the receipt-bound initial image before binding its identity.""" + file_identity = _audit_file_identity(audit_path) + try: + audit_image = audit_path.read_bytes() + except OSError as exc: + raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc + if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: + raise MigrationError("audit adoption receipt does not match the published canonical audit image") + version, application_id, quick_check = _audit_live_metadata(audit_path) + if version != expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("audit adoption receipt does not match the published canonical audit image") + return file_identity + + def _load_audit_adoption_continuity(archive_root: Path) -> dict[str, object] | None: """Load the immutable audit-file identity record, if publication reached it.""" continuity_path = _audit_adoption_continuity_path(archive_root) @@ -740,10 +771,13 @@ def _write_audit_adoption_continuity( archive_root: Path, *, receipt_payload: dict[str, object], + expected_initial_file_identity: tuple[int, int], ) -> None: """Publish the post-link audit identity that later detects stale replacement.""" audit_path = archive_root / "audit.db" device, inode = _audit_file_identity(audit_path) + if (device, inode) != expected_initial_file_identity: + raise MigrationError("audit tier changed before recording adoption continuity") continuity_path = _audit_adoption_continuity_path(archive_root) payload: dict[str, object] = { "format": _AUDIT_ADOPTION_CONTINUITY_FORMAT, @@ -764,11 +798,22 @@ def _write_audit_adoption_continuity( raise MigrationError("audit tier changed while recording adoption continuity") -def _validate_audit_adoption_continuity(archive_root: Path, *, receipt_payload: dict[str, object]) -> None: +def _validate_audit_adoption_continuity( + archive_root: Path, + *, + receipt_payload: dict[str, object], + expected_initial_file_identity: tuple[int, int] | None, +) -> None: """Require the published audit path to retain its adopted live identity.""" continuity = _load_audit_adoption_continuity(archive_root) if continuity is None: - _write_audit_adoption_continuity(archive_root, receipt_payload=receipt_payload) + if expected_initial_file_identity is None: + raise MigrationError("audit adoption continuity is missing without an authenticated initial image") + _write_audit_adoption_continuity( + archive_root, + receipt_payload=receipt_payload, + expected_initial_file_identity=expected_initial_file_identity, + ) continuity = _load_audit_adoption_continuity(archive_root) assert continuity is not None expected = (continuity.get("audit_device"), continuity.get("audit_inode")) @@ -839,8 +884,13 @@ def recover_pending_audit_adoption(archive_root: Path) -> bool: """Publish a receipt-backed missing audit file before startup classification.""" archive_root = archive_root.resolve() receipt = _load_audit_adoption_receipt(archive_root) - if receipt is None or (archive_root / "audit.db").is_file(): + audit_path = archive_root / "audit.db" + if receipt is None or audit_path.is_file(): return False + if _load_audit_adoption_continuity(archive_root) is not None: + raise MigrationError( + "adopted audit tier is missing after continuity was recorded; restore audit.db from backup" + ) receipt_path, payload = receipt _recover_pending_audit_adoption(archive_root, receipt_path, payload) return True @@ -858,23 +908,32 @@ def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image if not isinstance(expected_initial_version, int): raise MigrationError("audit adoption receipt lacks its initial audit schema version") audit_path = archive_root / "audit.db" + continuity = _load_audit_adoption_continuity(archive_root) if not audit_path.is_file(): + if continuity is not None: + raise MigrationError( + "adopted audit tier is missing after continuity was recorded; restore audit.db from backup" + ) _recover_pending_audit_adoption(archive_root, receipt_path, payload) require_initial_image = True - if require_initial_image: - try: - audit_image = audit_path.read_bytes() - except OSError as exc: - raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc - if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: - raise MigrationError("audit adoption receipt does not match the published canonical audit image") - with closing(sqlite3.connect(f"file:{audit_path}?mode=ro", uri=True)) as connection: - version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) - application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) - quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) - if version < expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): - raise MigrationError("audit adoption receipt does not match the live audit tier") - _validate_audit_adoption_continuity(archive_root, receipt_payload=payload) + initial_file_identity: tuple[int, int] | None = None + if continuity is None or require_initial_image: + initial_file_identity = _validate_initial_audit_image( + audit_path, + expected_image_sha256=expected_image_sha256, + expected_image_size=expected_image_size, + expected_application_id=expected_application_id, + expected_initial_version=expected_initial_version, + ) + else: + version, application_id, quick_check = _audit_live_metadata(audit_path) + if version < expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("audit adoption receipt does not match the live audit tier") + _validate_audit_adoption_continuity( + archive_root, + receipt_payload=payload, + expected_initial_file_identity=initial_file_identity, + ) return receipt_path diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index a248b551b8..f4d94b3931 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1940,6 +1940,107 @@ def fail_audit_link( assert reconcile_durable_change_train_startup(archive_root) == () +def test_audit_adoption_bootstrap_rejects_stale_replacement_before_recording_continuity( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Startup does not bless a replaced audit image in the post-link crash window.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker_root = archive_root / ".maintenance-state" / "durable-change-trains" + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + real_link = os.link + + def interrupt_continuity_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name == "audit-continuity.json": + raise OSError("simulated crash after audit link") + real_link( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + with monkeypatch.context() as interrupted_publication: + interrupted_publication.setattr("polylogue.operations.durable_change_train.os.link", interrupt_continuity_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-continuity-crash") as owner: + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + stale_clone = archive_root / "stale-audit.db" + with sqlite3.connect(audit_path) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("audit-before-stale-clone", 1, 1), + ) + connection.commit() + shutil.copy2(audit_path, stale_clone) + with sqlite3.connect(audit_path) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("audit-after-stale-clone", 2, 1), + ) + connection.commit() + os.replace(stale_clone, audit_path) + stale_image = audit_path.read_bytes() + + with pytest.raises(MigrationError, match="published canonical audit image"): + initialize_active_archive_root(archive_root) + + assert audit_path.read_bytes() == stale_image + assert not (marker_root / "audit-continuity.json").exists() + + +def test_audit_adoption_recovery_preserves_missing_tier_after_continuity( + workspace_env: dict[str, Path], +) -> None: + """Recovery requires restore, without recreating audit.db after completed adoption.""" + from polylogue.operations.durable_change_train import recover_pending_audit_adoption + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-missing-after-continuity") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + audit_path.unlink() + before = {path.relative_to(archive_root): path.read_bytes() for path in archive_root.rglob("*") if path.is_file()} + + with pytest.raises(MigrationError, match="missing after continuity"): + recover_pending_audit_adoption(archive_root) + + after = {path.relative_to(archive_root): path.read_bytes() for path in archive_root.rglob("*") if path.is_file()} + assert after == before + assert not audit_path.exists() + + def test_audit_adoption_receipt_is_excluded_from_pre_marker_train_state(workspace_env: dict[str, Path]) -> None: """The adoption receipt does not prevent legacy current-schema bootstrap adoption.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root From cbf7ca7078e56642c6fa11ab136347abe04d74ec Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 13:35:09 +0200 Subject: [PATCH 07/28] fix(maintenance): restore adopted audit tiers Problem: adopted audit continuity only accepted the original inode, so a legitimate backup restore left startup permanently blocked. Verified full-evidence receipts also omitted audit authority. What changed: sign audit-bearing backups with the audit path authority, validate a current full-evidence audit artifact against source, user, and audit authority, and expose offline restore through migrate-tier. Restore publishes a prepared and committed append-only continuity generation, with a same-backup resume route after an interrupted commit. Compatibility: normal in-place audit SQLite mutations remain valid; missing or altered adopted audit files fail closed until the authorized restore command completes. Co-Authored-By: Codex --- docs/maintenance.md | 2 +- .../cli/commands/maintenance/_migrate_tier.py | 30 +- polylogue/daemon/backup.py | 2 +- polylogue/operations/durable_change_train.py | 358 +++++++++++++++++- .../storage/sqlite/archive_tiers/bootstrap.py | 4 +- .../storage/sqlite/durable_change_train.py | 12 +- polylogue/storage/sqlite/migration_runner.py | 80 +++- .../unit/cli/test_archive_maintenance_cli.py | 53 +++ tests/unit/daemon/test_backup.py | 2 +- .../unit/storage/test_durable_change_train.py | 147 +++++++ 10 files changed, 669 insertions(+), 21 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index ecde62ad50..462234311a 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -728,7 +728,7 @@ polylogue ops diagnostics workload --json | jq .fts_trigger_state.all_present If FTS remains non-ready after daemon convergence, the underlying issue is structural (missing columns, corrupted index file, or a broken write path). -Stop the daemon, restore from backup or rebuild the affected index tier, and +Stop the daemon, restore an adopted audit tier with `polylogue ops maintenance migrate-tier audit --restore-adopted-audit --backup-manifest /manifest.json`, restore another durable tier from its applicable backup procedure, or rebuild the affected index tier, and open an issue with the probe output attached. ### Inspecting a raw-authority census diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index c6baa869a5..ed90e6b05a 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -29,6 +29,7 @@ adopt_missing_audit_tier, execute_durable_change_train, initialize_missing_durable_tier, + restore_adopted_audit_tier, ) from polylogue.paths import archive_root from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier @@ -74,12 +75,18 @@ def _require_stopped_daemon(root: Path) -> str: "writes an immutable adoption receipt." ), ) +@click.option( + "--restore-adopted-audit", + is_flag=True, + help="Atomically restore adopted audit.db from a scratch-verified full_evidence backup and append continuity.", +) @click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) def migrate_tier_command( tier: str, backup_manifest: Path | None, initialize_missing: bool, adopt_established_audit: bool, + restore_adopted_audit: bool, output_format: str, ) -> None: """Apply additive migrations for one durable archive tier. @@ -96,11 +103,14 @@ def migrate_tier_command( initialized = False initialized_version: int | None = None adoption_receipt: Path | None = None + restore_receipt: Path | None = None try: with acquire_durable_archive_ownership(path.parent, owner_id=f"migrate-tier:{os.getpid()}") as archive_owner: stopped_daemon_evidence_ref = _require_stopped_daemon(path.parent) - if initialize_missing and adopt_established_audit: - raise MigrationError("choose either --initialize-missing or --adopt-established-audit") + if sum((initialize_missing, adopt_established_audit, restore_adopted_audit)) > 1: + raise MigrationError( + "choose only one of --initialize-missing, --adopt-established-audit, or --restore-adopted-audit" + ) if adopt_established_audit: if archive_tier is not ArchiveTier.AUDIT: raise MigrationError("--adopt-established-audit is only valid for the audit tier") @@ -114,6 +124,18 @@ def migrate_tier_command( ) initialized = True execution = None + elif restore_adopted_audit: + if archive_tier is not ArchiveTier.AUDIT: + raise MigrationError("--restore-adopted-audit is only valid for the audit tier") + if backup_manifest is None: + raise MigrationError("--restore-adopted-audit requires --backup-manifest") + restore_receipt = restore_adopted_audit_tier( + path, + backup_manifest=backup_manifest, + directory_fd=archive_owner.directory_fd, + stopped_daemon_check=lambda: _require_stopped_daemon(path.parent), + ) + execution = None elif initialize_missing: initialized_version = initialize_missing_durable_tier( path, @@ -169,6 +191,7 @@ def migrate_tier_command( "path": str(path), "initialized": initialized, "adoption_receipt": str(adoption_receipt) if adoption_receipt is not None else None, + "restore_receipt": str(restore_receipt) if restore_receipt is not None else None, "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, "train_manifest": ( @@ -202,6 +225,9 @@ def migrate_tier_command( if adoption_receipt is not None: click.echo(f"Adopted missing audit tier at schema version {initialized_version}; receipt: {adoption_receipt}.") return + if restore_receipt is not None: + click.echo(f"Restored adopted audit tier; continuity receipt: {restore_receipt}.") + return if initialized: click.echo(f"Initialized missing {tier} tier at schema version {initialized_version}.") return diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index 8687ad0c55..d84be55f16 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -966,7 +966,7 @@ def _write_successful_verification_receipt(backup_root: Path, verification: dict artifacts = verified_evidence.get("tier_artifacts") if isinstance(artifacts, list): for artifact in artifacts: - if not isinstance(artifact, dict) or artifact.get("tier") not in {"source", "user"}: + if not isinstance(artifact, dict) or artifact.get("tier") not in {"source", "user", "audit"}: continue fingerprint = artifact.get("source_fingerprint") source_path = fingerprint.get("path") if isinstance(fingerprint, dict) else None diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index e88f0c0353..cc588302e5 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -5,6 +5,7 @@ import hashlib import json import os +import re import secrets import sqlite3 import stat @@ -31,6 +32,7 @@ MigrationError, _canonical_json_sha256, capture_durable_schema_inventory, + validate_full_evidence_backup_for_adopted_audit_restore, validate_full_evidence_backup_for_audit_adoption, ) @@ -38,6 +40,10 @@ _AUDIT_ADOPTION_RECEIPT_NAME = "audit-adoption.json" _AUDIT_ADOPTION_CONTINUITY_FORMAT = "polylogue.audit-tier-continuity.v1" _AUDIT_ADOPTION_CONTINUITY_NAME = "audit-continuity.json" +_AUDIT_ADOPTION_RESTORE_FORMAT = "polylogue.audit-tier-restore.v1" +_AUDIT_ADOPTION_RESTORE_NAME = re.compile( + r"^audit-restore\.(?P[1-9][0-9]*)\.(?P[0-9a-f]{32})\.(?Pprepared|committed)\.json$" +) @dataclass(frozen=True, slots=True) @@ -478,11 +484,17 @@ def _open_audit_adoption_receipt_directory( ) -> int: """Open the receipt parent without following any archive path component.""" archive_root = archive_root.resolve() - expected_paths = { - audit_adoption_receipt_path(archive_root), - _audit_adoption_continuity_path(archive_root), - } - if path not in expected_paths: + expected_paths = {audit_adoption_receipt_path(archive_root), _audit_adoption_continuity_path(archive_root)} + try: + relative = path.relative_to(archive_root) + except ValueError: + relative = Path() + is_restore_record = ( + len(relative.parts) == 3 + and relative.parts[:2] == (".maintenance-state", "durable-change-trains") + and _AUDIT_ADOPTION_RESTORE_NAME.fullmatch(relative.name) is not None + ) + if path not in expected_paths and not is_restore_record: raise MigrationError(f"audit adoption receipt path is outside its fixed archive location: {path}") try: current_fd = ( @@ -767,6 +779,108 @@ def _load_audit_adoption_continuity(archive_root: Path) -> dict[str, object] | N return payload +def _audit_restore_records(archive_root: Path) -> list[tuple[Path, dict[str, object]]]: + """Read restore state through the fixed, no-follow archive ledger path.""" + marker_path = _audit_adoption_continuity_path(archive_root) + try: + directory_fd = _open_audit_adoption_receipt_directory(marker_path, archive_root=archive_root, create=False) + except FileNotFoundError: + return [] + records: list[tuple[Path, dict[str, object]]] = [] + try: + for name in os.listdir(directory_fd): + match = _AUDIT_ADOPTION_RESTORE_NAME.fullmatch(name) + if match is None: + continue + path = marker_path.with_name(name) + fd: int | None = None + try: + fd = os.open( + name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=directory_fd + ) + metadata = os.fstat(fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit restore record ownership or mode: {path}") + with os.fdopen(fd, "r", encoding="utf-8") as stream: + fd = None + payload = json.load(stream) + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit restore record: {path}") from exc + finally: + if fd is not None: + os.close(fd) + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_RESTORE_FORMAT: + raise MigrationError(f"audit restore record format mismatch: {path}") + checksum_key = "restore_sha256" if match["state"] == "prepared" else "continuity_sha256" + checksum = payload.get(checksum_key) + unsigned = dict(payload) + unsigned.pop(checksum_key, None) + if not isinstance(checksum, str) or checksum != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit restore record checksum mismatch: {path}") + if payload.get("state") != match["state"] or payload.get("operation_id") != match["operation"]: + raise MigrationError(f"audit restore record filename does not match its payload: {path}") + if payload.get("generation") != int(match["generation"]): + raise MigrationError(f"audit restore record generation mismatch: {path}") + records.append((path, payload)) + finally: + os.close(directory_fd) + return records + + +def _latest_audit_adoption_continuity( + archive_root: Path, *, allow_incomplete_restore: bool = False +) -> dict[str, object] | None: + """Follow the immutable restore chain and expose its current generation.""" + continuity = _load_audit_adoption_continuity(archive_root) + if continuity is None: + return None + current_digest = continuity.get("continuity_sha256") + if not isinstance(current_digest, str): + raise MigrationError("audit adoption continuity lacks its immutable checksum") + records_by_generation: dict[int, dict[str, dict[str, object]]] = {} + for _path, payload in _audit_restore_records(archive_root): + generation = payload["generation"] + state = payload["state"] + assert isinstance(generation, int) + assert isinstance(state, str) + states = records_by_generation.setdefault(generation, {}) + if state in states: + raise MigrationError("audit restore records contain duplicate generation state") + states[state] = payload + for expected_generation in range(1, len(records_by_generation) + 1): + if expected_generation not in records_by_generation: + raise MigrationError("audit restore continuity generations are not contiguous") + states = records_by_generation[expected_generation] + prepared = states.get("prepared") + committed = states.get("committed") + if prepared is None or committed is None: + if allow_incomplete_restore and prepared is not None and committed is None: + return continuity + raise MigrationError( + "adopted audit restore is prepared but incomplete; rerun maintenance migrate-tier audit " + "--restore-adopted-audit with the same verified full_evidence backup" + ) + if ( + prepared.get("previous_continuity_sha256") != current_digest + or committed.get("previous_continuity_sha256") != current_digest + or committed.get("prepared_restore_sha256") != prepared.get("restore_sha256") + or committed.get("receipt_sha256") != continuity.get("receipt_sha256") + or committed.get("source_user_authority_digest") != continuity.get("source_user_authority_digest") + ): + raise MigrationError("audit restore continuity chain does not match the adopted archive") + next_digest = committed.get("continuity_sha256") + if not isinstance(next_digest, str): + raise MigrationError("committed audit restore record lacks its continuity checksum") + continuity = committed + current_digest = next_digest + return continuity + + def _write_audit_adoption_continuity( archive_root: Path, *, @@ -805,7 +919,7 @@ def _validate_audit_adoption_continuity( expected_initial_file_identity: tuple[int, int] | None, ) -> None: """Require the published audit path to retain its adopted live identity.""" - continuity = _load_audit_adoption_continuity(archive_root) + continuity = _latest_audit_adoption_continuity(archive_root) if continuity is None: if expected_initial_file_identity is None: raise MigrationError("audit adoption continuity is missing without an authenticated initial image") @@ -814,7 +928,7 @@ def _validate_audit_adoption_continuity( receipt_payload=receipt_payload, expected_initial_file_identity=expected_initial_file_identity, ) - continuity = _load_audit_adoption_continuity(archive_root) + continuity = _latest_audit_adoption_continuity(archive_root) assert continuity is not None expected = (continuity.get("audit_device"), continuity.get("audit_inode")) if ( @@ -887,9 +1001,10 @@ def recover_pending_audit_adoption(archive_root: Path) -> bool: audit_path = archive_root / "audit.db" if receipt is None or audit_path.is_file(): return False - if _load_audit_adoption_continuity(archive_root) is not None: + if _latest_audit_adoption_continuity(archive_root) is not None: raise MigrationError( - "adopted audit tier is missing after continuity was recorded; restore audit.db from backup" + "adopted audit tier is missing after continuity was recorded; run maintenance migrate-tier audit " + "--restore-adopted-audit --backup-manifest /manifest.json" ) receipt_path, payload = receipt _recover_pending_audit_adoption(archive_root, receipt_path, payload) @@ -908,11 +1023,12 @@ def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image if not isinstance(expected_initial_version, int): raise MigrationError("audit adoption receipt lacks its initial audit schema version") audit_path = archive_root / "audit.db" - continuity = _load_audit_adoption_continuity(archive_root) + continuity = _latest_audit_adoption_continuity(archive_root) if not audit_path.is_file(): if continuity is not None: raise MigrationError( - "adopted audit tier is missing after continuity was recorded; restore audit.db from backup" + "adopted audit tier is missing after continuity was recorded; run maintenance migrate-tier audit " + "--restore-adopted-audit --backup-manifest /manifest.json" ) _recover_pending_audit_adoption(archive_root, receipt_path, payload) require_initial_image = True @@ -1023,6 +1139,225 @@ def revalidate_before_publish(initialized_image: bytes) -> None: return version, receipt_path +def _audit_restore_artifact_binding(receipt_path: Path) -> tuple[str, int, int]: + """Read the audit artifact facts after receipt authentication succeeded.""" + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError("cannot read adopted-audit restore verification receipt") from exc + artifacts = receipt.get("tier_artifacts") if isinstance(receipt, dict) else None + audit = ( + next((item for item in artifacts if isinstance(item, dict) and item.get("tier") == "audit"), None) + if isinstance(artifacts, list) + else None + ) + if not isinstance(audit, dict): + raise MigrationError("adopted-audit restore receipt lacks audit artifact evidence") + sha256, size, version = audit.get("sha256"), audit.get("size_bytes"), audit.get("user_version") + if not isinstance(sha256, str) or not isinstance(size, int) or not isinstance(version, int): + raise MigrationError("adopted-audit restore receipt has invalid audit artifact evidence") + return sha256, size, version + + +def _copy_restore_artifact(source: Path, *, directory_fd: int, temporary_name: str, sha256: str, size: int) -> None: + """Copy an exact no-follow, unlinked backup artifact into the owned root.""" + source_fd: int | None = None + target_fd: int | None = None + try: + source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)) + source_metadata = os.fstat(source_fd) + if not stat.S_ISREG(source_metadata.st_mode) or source_metadata.st_nlink != 1: + raise MigrationError("adopted-audit restore artifact is not an unlinked regular file") + target_fd = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=directory_fd, + ) + digest = hashlib.sha256() + copied = 0 + while chunk := os.read(source_fd, 1024 * 1024): + digest.update(chunk) + copied += len(chunk) + offset = 0 + while offset < len(chunk): + written = os.write(target_fd, chunk[offset:]) + if written <= 0: + raise MigrationError("adopted-audit restore artifact copy made no progress") + offset += written + if copied != size or digest.hexdigest() != sha256: + raise MigrationError("adopted-audit restore artifact changed while it was copied") + os.fsync(target_fd) + finally: + if target_fd is not None: + os.close(target_fd) + if source_fd is not None: + os.close(source_fd) + + +def _audit_file_matches_artifact(path: Path, *, sha256: str, size: int) -> bool: + """Check whether an interrupted restore already published the intended image.""" + try: + _audit_file_identity(path) + if path.stat().st_size == size: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() == sha256 + except OSError: + return False + return False + + +def restore_adopted_audit_tier( + path: Path, + *, + backup_manifest: Path, + directory_fd: int, + stopped_daemon_check: Callable[[], str], +) -> Path: + """Restore adopted ``audit.db`` and append its new continuity generation.""" + if path.name != "audit.db": + raise MigrationError(f"adopted-audit restore is only supported for audit.db: {path}") + archive_root = path.parent.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + if receipt is None: + raise MigrationError("adopted-audit restore requires an existing audit adoption receipt") + _receipt_path, adoption = receipt + continuity = _latest_audit_adoption_continuity(archive_root, allow_incomplete_restore=True) + if continuity is None or continuity.get("receipt_sha256") != adoption.get("receipt_sha256"): + raise MigrationError("adopted-audit restore requires completed continuity for this adoption receipt") + stopped_evidence = stopped_daemon_check() + manifest_path, verification_receipt = validate_full_evidence_backup_for_adopted_audit_restore( + backup_manifest, archive_root=archive_root + ) + artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) + previous_continuity_sha256 = continuity.get("continuity_sha256") + if not isinstance(previous_continuity_sha256, str): + raise MigrationError("adopted-audit restore continuity lacks its checksum") + restore_records = _audit_restore_records(archive_root) + committed_generations: list[int] = [] + pending_records: list[tuple[Path, dict[str, object]]] = [] + committed_operations: set[tuple[int, str]] = set() + for record_path, payload in restore_records: + generation_value = payload.get("generation") + if payload.get("state") == "committed" and isinstance(generation_value, int): + committed_generations.append(generation_value) + operation_value = payload.get("operation_id") + if isinstance(operation_value, str): + committed_operations.add((generation_value, operation_value)) + elif payload.get("state") == "prepared": + pending_records.append((record_path, payload)) + unresolved_records: list[tuple[Path, dict[str, object]]] = [] + for record_path, payload in pending_records: + generation_value = payload.get("generation") + operation_value = payload.get("operation_id") + if not isinstance(generation_value, int) or not isinstance(operation_value, str): + raise MigrationError("incomplete adopted-audit restore has invalid identity fields") + if (generation_value, operation_value) not in committed_operations: + unresolved_records.append((record_path, payload)) + pending_records = unresolved_records + if len(pending_records) > 1: + raise MigrationError("adopted-audit restore has multiple incomplete continuity records") + if pending_records: + _pending_path, pending_payload = pending_records[0] + generation_value = pending_payload.get("generation") + operation_value = pending_payload.get("operation_id") + assert isinstance(generation_value, int) + assert isinstance(operation_value, str) + generation = generation_value + operation_id = operation_value + else: + generation = 1 + max(committed_generations, default=0) + operation_id = secrets.token_hex(16) + base_payload: dict[str, object] = { + "format": _AUDIT_ADOPTION_RESTORE_FORMAT, + "generation": generation, + "operation_id": operation_id, + "previous_continuity_sha256": previous_continuity_sha256, + "receipt_sha256": adoption["receipt_sha256"], + "source_user_authority_digest": adoption["source_user_authority_digest"], + "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), + "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), + "audit_artifact_sha256": artifact_sha256, + "audit_artifact_size": artifact_size, + "audit_artifact_user_version": artifact_version, + "stopped_daemon_evidence_ref": stopped_evidence, + "single_writer_evidence_ref": "proof:archive-ownership-lock", + } + if pending_records: + prepared_path, prepared = pending_records[0] + expected_prepared = {**base_payload, "state": "prepared"} + if any(prepared.get(key) != value for key, value in expected_prepared.items()): + raise MigrationError("incomplete adopted-audit restore does not match the supplied verified backup") + else: + prepared_path = _audit_adoption_continuity_path(archive_root).with_name( + f"audit-restore.{generation}.{operation_id}.prepared.json" + ) + prepared = {**base_payload, "state": "prepared"} + _write_immutable_audit_adoption_receipt( + prepared_path, + prepared, + archive_root=archive_root, + archive_directory_fd=directory_fd, + checksum_key="restore_sha256", + ) + temporary_name = f".audit.db.restore-{operation_id}.tmp" + published = False + try: + if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): + published = True + else: + _copy_restore_artifact( + manifest_path.parent / "audit.db", + directory_fd=directory_fd, + temporary_name=temporary_name, + sha256=artifact_sha256, + size=artifact_size, + ) + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed during adopted-audit restore") + validate_full_evidence_backup_for_adopted_audit_restore(backup_manifest, archive_root=archive_root) + if _audit_adoption_authority_digest(archive_root) != adoption.get("source_user_authority_digest"): + raise MigrationError("source/user authority changed during adopted-audit restore") + if not published: + os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + published = True + identity = _audit_file_identity(path) + version, _application_id, quick_check = _audit_live_metadata(path) + if version != artifact_version or quick_check != ("ok",): + raise MigrationError("adopted-audit restore published artifact is not the verified SQLite image") + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") + validate_full_evidence_backup_for_adopted_audit_restore(backup_manifest, archive_root=archive_root) + committed_path = prepared_path.with_name(prepared_path.name.replace(".prepared.json", ".committed.json")) + prepared_restore_sha256 = prepared.get("restore_sha256") + if not isinstance(prepared_restore_sha256, str): + prepared_restore_sha256 = _canonical_json_sha256(prepared) + committed = { + **base_payload, + "state": "committed", + "prepared_restore_sha256": prepared_restore_sha256, + "audit_device": identity[0], + "audit_inode": identity[1], + } + _write_immutable_audit_adoption_receipt( + committed_path, + committed, + archive_root=archive_root, + archive_directory_fd=directory_fd, + checksum_key="continuity_sha256", + ) + return committed_path + finally: + if not published: + with suppress(FileNotFoundError): + os.unlink(temporary_name, dir_fd=directory_fd) + os.fsync(directory_fd) + + def execute_durable_change_train( archive_root: Path, tier: ArchiveTier, @@ -1059,5 +1394,6 @@ def reconcile_durable_change_trains_on_startup(root: Path) -> tuple[Path, ...]: "initialize_missing_durable_tier", "reconcile_durable_change_trains_on_startup", "recover_pending_audit_adoption", + "restore_adopted_audit_tier", "validate_audit_adoption_receipt", ] diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 4efb66194f..d91e98304c 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -348,7 +348,9 @@ def assert_owned_root() -> None: manifest_root = root / ".maintenance-state" / "durable-change-trains" pending_audit_adoption = audit_adoption_receipt_path(root).exists() has_durable_train_state = any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in manifest_root.glob("*.json") + path.name not in {"audit-adoption.json", "audit-continuity.json"} + and not path.name.startswith("audit-restore.") + for path in manifest_root.glob("*.json") ) has_bootstrap_marker = (manifest_root / ".bootstrap").is_file() pending_bootstrap_path = manifest_root / ".bootstrap.pending" diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 36c004a8b6..de3d7b5e0a 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -331,7 +331,8 @@ def _record_fresh_durable_bootstrap(archive_root: Path) -> None: marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER if marker_path.exists() or any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in marker_root.glob("*.json") + path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") + for path in marker_root.glob("*.json") ): raise DurableChangeTrainError(f"cannot record fresh durable bootstrap over existing train state: {marker_root}") if pending_path.is_file(): @@ -366,7 +367,8 @@ def _record_fresh_durable_bootstrap_intent(archive_root: Path) -> None: marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER if marker_path.exists() or any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in marker_root.glob("*.json") + path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") + for path in marker_root.glob("*.json") ): raise DurableChangeTrainError( f"cannot record fresh durable bootstrap intent over existing train state: {marker_root}" @@ -511,7 +513,10 @@ def _adopt_pre_marker_durable_bootstrap(archive_root: Path) -> None: manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" if (manifest_root / _FRESH_DURABLE_BOOTSTRAP_MARKER).is_file(): return - if any(path.name not in {"audit-adoption.json", "audit-continuity.json"} for path in manifest_root.glob("*.json")): + if any( + path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") + for path in manifest_root.glob("*.json") + ): return for tier in DURABLE_MIGRATION_ADOPTION_FLOORS: tier_path = archive_root / f"{tier.value}.db" @@ -2302,6 +2307,7 @@ def _reconcile_durable_change_train_startup_locked( path for path in sorted(manifest_root.glob("*.json")) if path.name not in {"audit-adoption.json", "audit-continuity.json"} + and not path.name.startswith("audit-restore.") ) fresh_bootstrap_versions = _fresh_durable_bootstrap_versions(archive_root, manifest_root) diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 241191d216..06e55f0f11 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -539,7 +539,7 @@ def _validated_receipt_artifacts( receipt: dict[str, object], *, target_tier: str, - live_tier_path: Path, + live_tier_path: Path | None, file_evidence: dict[str, dict[str, object]], ) -> dict[str, dict[str, object]]: included = _json_str_list(manifest.get("included_tiers")) @@ -939,6 +939,83 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root return manifest_path, receipt_path +def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archive_root: Path) -> tuple[Path, Path]: + """Authorize replacing adopted ``audit.db`` from one exact backup. + + The audit file may be absent or unreadable, so its stable path authority is + verified without opening it. Every other captured tier must still match + the scratch-verified full-evidence snapshot byte for byte. + """ + manifest_path = _backup_manifest_path(path) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"adopted-audit restore requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or manifest.get("profile") != "full_evidence": + raise MigrationError("adopted-audit restore requires a verified full_evidence backup") + included = set(_json_str_list(manifest.get("included_tiers"))) + required_tiers = {"source", "index", "embeddings", "user", "audit"} + permitted_tiers = required_tiers | {"ops"} + included_tiers = {name.removesuffix(".db") for name in included} + if ( + not required_tiers.issubset(included_tiers) + or included_tiers - permitted_tiers + or len(included_tiers) != len(included) + ): + raise MigrationError("adopted-audit restore backup must contain every non-optional tier including audit") + receipt_path = _receipt_path(manifest_path) + if not receipt_path.exists() and not receipt_path.is_symlink(): + raise MigrationError( + f"adopted-audit restore requires a successful backup verification receipt; missing {receipt_path}" + ) + _require_regular_backup_artifact(receipt_path, backup_root=backup_root, label="backup verification receipt") + receipt = _load_json(receipt_path, label="verification receipt") + if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": + raise MigrationError("adopted-audit restore requires a successful backup verification receipt") + archive_root = archive_root.resolve() + for authority_tier in ("source", "user", "audit"): + try: + verify_verification_receipt( + receipt, tier=authority_tier, live_tier_path=archive_root / f"{authority_tier}.db" + ) + except BackupAttestationError as exc: + raise MigrationError(f"adopted-audit restore backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("adopted-audit restore receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("adopted-audit restore receipt does not match manifest bytes") + artifacts = _validated_receipt_artifacts( + backup_root, manifest, receipt, target_tier="audit", live_tier_path=None, file_evidence=file_evidence + ) + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("adopted-audit restore receipt does not match the closed artifact inventory") + for tier in sorted(included_tiers - {"audit"}): + live_path = archive_root / f"{tier}.db" + fingerprint = artifacts[tier].get("source_fingerprint") + if not isinstance(fingerprint, dict): + raise MigrationError(f"adopted-audit restore backup lacks a live source fingerprint for {tier}.db") + if Path(str(fingerprint.get("path") or "")).resolve(strict=False) != live_path.resolve(strict=False): + raise MigrationError(f"adopted-audit restore backup belongs to a different archive tier: {tier}.db") + if not live_path.is_file(): + raise MigrationError(f"adopted-audit restore live tier is missing: {live_path}") + wal_path = live_path.with_name(f"{live_path.name}-wal") + if wal_path.exists() and wal_path.stat().st_size: + raise MigrationError(f"adopted-audit restore has live WAL divergence for {tier}.db") + if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + if str(fingerprint.get("sha256")) != _sha256_file(live_path): + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + return manifest_path, receipt_path + + def validate_backup_manifest_covers_derived_tier( path: Path, tier: ArchiveTier, *, connection: sqlite3.Connection ) -> Path: @@ -3548,6 +3625,7 @@ def write_durable_change_train_manifest( "validate_backup_manifest_covers_derived_tier", "validate_migration_backup_live_fingerprint", "validate_full_evidence_backup_for_audit_adoption", + "validate_full_evidence_backup_for_adopted_audit_restore", "validate_migration_backup_manifest", "write_durable_change_train_manifest", ] diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 289902505d..7648c84b95 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3482,6 +3482,59 @@ def test_migrate_tier_cli_adoption_refuses_live_writer_before_receipt_or_sql( assert not (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").exists() +def test_migrate_tier_cli_restores_adopted_audit_from_verified_full_evidence( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The operator-facing command rebinds a corrupted adopted tier instead of leaving startup wedged.""" + root = cli_workspace["archive_root"] + audit_path = root / "audit.db" + audit_path.unlink() + pre_adoption = _full_evidence_backup_without_audit(root) + adopted = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(pre_adoption), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + assert adopted.exit_code == 0, adopted.output + verified = backup_archive(output_dir=root.parent / "adopted-audit-restore", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + expected_bytes = (Path(verified.output_path) / "audit.db").read_bytes() + audit_path.write_bytes(b"corrupt") + + restored = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--restore-adopted-audit", + "--backup-manifest", + str(Path(verified.output_path) / "manifest.json"), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert restored.exit_code == 0, restored.output + payload = json.loads(restored.stdout) + assert payload["restore_receipt"].endswith(".committed.json") + assert audit_path.read_bytes() == expected_bytes + + @pytest.mark.parametrize("publication_failure", ["race", "interrupted"]) def test_migrate_tier_cli_adoption_fails_closed_during_publication( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, publication_failure: str diff --git a/tests/unit/daemon/test_backup.py b/tests/unit/daemon/test_backup.py index d3798c680d..10082bd32c 100644 --- a/tests/unit/daemon/test_backup.py +++ b/tests/unit/daemon/test_backup.py @@ -310,7 +310,7 @@ def test_backup_archive_copies_precious_tiers_and_referenced_blobs( receipt = json.loads(receipt_path.read_text(encoding="utf-8")) assert receipt["format"] == "polylogue-backup-verification-receipt-v2" attestations = {item["tier"]: item for item in receipt["attestations"]} - assert set(attestations) == {"source", "user"} + assert set(attestations) == {"audit", "source", "user"} assert attestations["user"]["algorithm"] == "hmac-sha256" assert len(attestations["user"]["mac"]) == 64 key_path = attestation_key_path(workspace_env["archive_root"] / "user.db") diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index f4d94b3931..bc3e57f7da 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -24,6 +24,7 @@ acquire_durable_archive_ownership, adopt_missing_audit_tier, audit_adoption_receipt_path, + restore_adopted_audit_tier, ) from polylogue.storage.sqlite import migration_runner from polylogue.storage.sqlite.archive_tiers import ARCHIVE_DDL_BY_TIER, ARCHIVE_VERSION_BY_TIER @@ -1751,6 +1752,152 @@ def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: di assert reconcile_durable_change_train_startup(archive_root) == () +def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace_env: dict[str, Path]) -> None: + """The real offline restore publishes a new immutable continuity generation.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "pre-adoption", profile="full_evidence", verify=True) + assert pre_adoption.ok, pre_adoption.error + assert pre_adoption.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-restore-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "post-adoption", profile="full_evidence", verify=True) + assert verified.ok, verified.error + assert verified.output_path is not None + expected_bytes = (Path(verified.output_path) / "audit.db").read_bytes() + old_identity = (audit_path.stat().st_dev, audit_path.stat().st_ino) + audit_path.write_bytes(b"corrupted audit image") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-restore") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == expected_bytes + assert (audit_path.stat().st_dev, audit_path.stat().st_ino) != old_identity + assert receipt.name.endswith(".committed.json") + assert receipt.with_name(receipt.name.replace(".committed.json", ".prepared.json")).is_file() + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_adopted_audit_restore_resumes_an_interrupted_continuity_commit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A prepared record blocks startup but the same verified backup can complete it.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "resume-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "resume-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + real_link = os.link + + def fail_committed_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if str(destination).endswith(".committed.json"): + raise OSError("simulated continuity commit interruption") + real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.link", fail_committed_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-interrupt") as owner: + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with pytest.raises(MigrationError, match="prepared but incomplete"): + reconcile_durable_change_train_startup(archive_root) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert receipt.name.endswith(".committed.json") + assert reconcile_durable_change_train_startup(archive_root) == () + + +@pytest.mark.parametrize("tamper", ["artifact", "receipt", "stale-source"]) +def test_adopted_audit_restore_rejects_untrusted_or_stale_backup(workspace_env: dict[str, Path], tamper: str) -> None: + """Mutation: bypassing receipt, artifact, or current-authority checks reaches the real restore call.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / f"pre-{tamper}", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id=f"test:audit-restore-adopt-{tamper}") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / f"post-{tamper}", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + if tamper == "artifact": + (backup_root / "audit.db").write_bytes(b"altered backup audit") + elif tamper == "receipt": + (backup_root / "verification-receipt.json").write_text("{}", encoding="utf-8") + else: + with sqlite3.connect(archive_root / "source.db") as connection: + connection.execute("PRAGMA user_version = 999") + connection.commit() + audit_path.write_bytes(b"corrupted audit image") + + with acquire_durable_archive_ownership(archive_root, owner_id=f"test:audit-restore-reject-{tamper}") as owner: + with pytest.raises(MigrationError, match="(adopted-audit restore|migration backup tier artifact)"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == b"corrupted audit image" + + def test_audit_adoption_binds_only_the_source_user_authority(workspace_env: dict[str, Path]) -> None: """Routine replacement of rebuildable or disposable tiers leaves adoption valid.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root From f8686e485f67118535cb2aafcc275b6228e75fc0 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 14:19:16 +0200 Subject: [PATCH 08/28] fix(storage): harden adopted audit restoration --- polylogue/operations/durable_change_train.py | 46 +++- .../storage/sqlite/archive_tiers/bootstrap.py | 6 +- polylogue/storage/sqlite/migration_runner.py | 27 ++- .../unit/cli/test_archive_maintenance_cli.py | 3 +- .../unit/storage/test_durable_change_train.py | 202 ++++++++++++++++-- 5 files changed, 254 insertions(+), 30 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index cc588302e5..ab7e66ef8d 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -17,6 +17,7 @@ from typing import Literal from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DurableChangeTrainExecution, @@ -704,7 +705,8 @@ def _audit_file_identity(path: Path) -> tuple[int, int]: def _audit_live_metadata(audit_path: Path) -> tuple[int, int, tuple[str, ...]]: """Read the durable markers that remain valid after an in-place migration.""" - with closing(sqlite3.connect(f"file:{audit_path}?mode=ro", uri=True)) as connection: + uri = f"{audit_path.resolve(strict=False).as_uri()}?mode=ro" + with closing(sqlite3.connect(uri, uri=True)) as connection: version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) @@ -801,7 +803,6 @@ def _audit_restore_records(archive_root: Path) -> list[tuple[Path, dict[str, obj metadata = os.fstat(fd) if ( not stat.S_ISREG(metadata.st_mode) - or metadata.st_nlink != 1 or metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) & 0o022 ): @@ -1114,7 +1115,7 @@ def revalidate_before_publish(initialized_image: bytes) -> None: "stopped_daemon_evidence_ref": stopped_evidence, "single_writer_evidence_ref": "proof:archive-ownership-lock", "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), - "audit_user_version": 1, + "audit_user_version": ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT], "audit_application_id": application_id, "audit_image_sha256": hashlib.sha256(initialized_image).hexdigest(), "audit_image_size": len(initialized_image), @@ -1233,6 +1234,33 @@ def restore_adopted_audit_tier( backup_manifest, archive_root=archive_root ) artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) + expected_application_id = adoption.get("audit_application_id") + expected_initial_version = adoption.get("audit_user_version") + if not isinstance(expected_application_id, int) or not isinstance(expected_initial_version, int): + raise MigrationError("audit adoption receipt lacks its durable SQLite markers") + backup_version, backup_application_id, backup_quick_check = _audit_live_metadata(manifest_path.parent / "audit.db") + if ( + backup_version != artifact_version + or backup_version < expected_initial_version + or backup_application_id != expected_application_id + or backup_quick_check != ("ok",) + ): + raise MigrationError("adopted-audit restore artifact does not belong to this audit adoption") + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + verification_receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() + + def revalidate_exact_backup() -> None: + current_manifest, current_receipt = validate_full_evidence_backup_for_adopted_audit_restore( + backup_manifest, archive_root=archive_root + ) + if ( + current_manifest.resolve() != manifest_path.resolve() + or current_receipt.resolve() != verification_receipt.resolve() + or hashlib.sha256(current_manifest.read_bytes()).hexdigest() != manifest_sha256 + or hashlib.sha256(current_receipt.read_bytes()).hexdigest() != verification_receipt_sha256 + ): + raise MigrationError("adopted-audit restore backup changed during the operation") + previous_continuity_sha256 = continuity.get("continuity_sha256") if not isinstance(previous_continuity_sha256, str): raise MigrationError("adopted-audit restore continuity lacks its checksum") @@ -1278,8 +1306,8 @@ def restore_adopted_audit_tier( "previous_continuity_sha256": previous_continuity_sha256, "receipt_sha256": adoption["receipt_sha256"], "source_user_authority_digest": adoption["source_user_authority_digest"], - "backup_manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(), - "backup_verification_receipt_sha256": hashlib.sha256(verification_receipt.read_bytes()).hexdigest(), + "backup_manifest_sha256": manifest_sha256, + "backup_verification_receipt_sha256": verification_receipt_sha256, "audit_artifact_sha256": artifact_sha256, "audit_artifact_size": artifact_size, "audit_artifact_user_version": artifact_version, @@ -1318,7 +1346,7 @@ def restore_adopted_audit_tier( ) if stopped_daemon_check() != stopped_evidence: raise MigrationError("daemon stopped proof changed during adopted-audit restore") - validate_full_evidence_backup_for_adopted_audit_restore(backup_manifest, archive_root=archive_root) + revalidate_exact_backup() if _audit_adoption_authority_digest(archive_root) != adoption.get("source_user_authority_digest"): raise MigrationError("source/user authority changed during adopted-audit restore") if not published: @@ -1326,12 +1354,12 @@ def restore_adopted_audit_tier( os.fsync(directory_fd) published = True identity = _audit_file_identity(path) - version, _application_id, quick_check = _audit_live_metadata(path) - if version != artifact_version or quick_check != ("ok",): + version, application_id, quick_check = _audit_live_metadata(path) + if version != artifact_version or application_id != expected_application_id or quick_check != ("ok",): raise MigrationError("adopted-audit restore published artifact is not the verified SQLite image") if stopped_daemon_check() != stopped_evidence: raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") - validate_full_evidence_backup_for_adopted_audit_restore(backup_manifest, archive_root=archive_root) + revalidate_exact_backup() committed_path = prepared_path.with_name(prepared_path.name.replace(".prepared.json", ".committed.json")) prepared_restore_sha256 = prepared.get("restore_sha256") if not isinstance(prepared_restore_sha256, str): diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index d91e98304c..9b09cf353d 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -399,9 +399,6 @@ def assert_owned_root() -> None: and not has_bootstrap_marker and not has_pending_bootstrap ) - if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: - assert_owned_root() - reconcile_durable_change_trains_on_startup(root) if ( durable_tier_exists and not recovering_fresh_durable_bootstrap @@ -411,6 +408,9 @@ def assert_owned_root() -> None: "established archive is missing audit.db; use maintenance migrate-tier audit " "--adopt-established-audit with a verified full_evidence backup" ) + if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: + assert_owned_root() + reconcile_durable_change_trains_on_startup(root) for spec in ARCHIVE_TIER_SPECS.values(): assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 06e55f0f11..183b4442e0 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -884,6 +884,11 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": raise MigrationError("audit adoption requires a successful backup verification receipt") archive_root = archive_root.resolve() + try: + if backup_root.samefile(archive_root): + raise MigrationError("audit adoption backup root aliases the live archive root") + except OSError as exc: + raise MigrationError("cannot compare audit adoption backup root with the live archive") from exc for authority_tier in ("source", "user"): try: verify_verification_receipt( @@ -915,6 +920,8 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root live_path = archive_root / f"{tier}.db" artifact = artifacts[tier] artifact_path = backup_root / f"{tier}.db" + if not live_path.is_file(): + raise MigrationError(f"audit adoption live tier is missing: {live_path}") try: if artifact_path.samefile(live_path): raise MigrationError(f"audit adoption backup tier artifact aliases the live tier: {tier}.db") @@ -925,8 +932,6 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root raise MigrationError(f"audit adoption backup lacks a live source fingerprint for {tier}.db") if Path(str(fingerprint.get("path") or "")).resolve(strict=False) != live_path.resolve(strict=False): raise MigrationError(f"audit adoption backup belongs to a different archive tier: {tier}.db") - if not live_path.is_file(): - raise MigrationError(f"audit adoption live tier is missing: {live_path}") wal_path = live_path.with_name(f"{live_path.name}-wal") if wal_path.exists() and wal_path.stat().st_size: raise MigrationError(f"audit adoption backup has live WAL divergence for {tier}.db") @@ -975,6 +980,11 @@ def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archi if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": raise MigrationError("adopted-audit restore requires a successful backup verification receipt") archive_root = archive_root.resolve() + try: + if backup_root.samefile(archive_root): + raise MigrationError("adopted-audit restore backup root aliases the live archive root") + except OSError as exc: + raise MigrationError("cannot compare adopted-audit restore backup root with the live archive") from exc for authority_tier in ("source", "user", "audit"): try: verify_verification_receipt( @@ -995,8 +1005,19 @@ def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archi _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) if receipt.get("artifact_inventory") != artifact_inventory: raise MigrationError("adopted-audit restore receipt does not match the closed artifact inventory") - for tier in sorted(included_tiers - {"audit"}): + for tier in sorted(included_tiers): live_path = archive_root / f"{tier}.db" + artifact_path = backup_root / f"{tier}.db" + if live_path.is_file(): + try: + if artifact_path.samefile(live_path): + raise MigrationError(f"adopted-audit restore backup tier artifact aliases the live tier: {tier}.db") + except OSError as exc: + raise MigrationError( + f"cannot compare adopted-audit restore backup tier with live tier: {tier}.db" + ) from exc + if tier not in {"source", "user"}: + continue fingerprint = artifacts[tier].get("source_fingerprint") if not isinstance(fingerprint, dict): raise MigrationError(f"adopted-audit restore backup lacks a live source fingerprint for {tier}.db") diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 7648c84b95..7b19a0ea2e 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3563,7 +3563,8 @@ def fail_or_race( # This is a *valid* v1 audit database with a different image, not # merely malformed bytes. Startup must reject the durable receipt # after the atomic no-replace link detects the foreign target. - with sqlite3.connect(root / str(destination)) as foreign: + target_root = Path(os.readlink(f"/proc/self/fd/{dst_dir_fd}")) + with sqlite3.connect(target_root / Path(destination).name) as foreign: initialize_archive_tier(foreign, ArchiveTier.AUDIT) foreign.execute("PRAGMA application_id = 41") foreign.commit() diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index bc3e57f7da..79756e0448 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -9,7 +9,7 @@ import sqlite3 import sys from collections.abc import Callable, Iterator -from contextlib import contextmanager +from contextlib import closing, contextmanager from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -20,6 +20,7 @@ import polylogue.storage.sqlite.durable_change_train as durable_change_train_module from polylogue.daemon.backup import backup_archive from polylogue.operations.durable_change_train import ( + _audit_live_metadata, _write_immutable_audit_adoption_receipt, acquire_durable_archive_ownership, adopt_missing_audit_tier, @@ -1742,7 +1743,7 @@ def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: di directory_fd=owner.directory_fd, stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - with sqlite3.connect(audit_path) as connection: + with closing(sqlite3.connect(audit_path)) as connection: connection.execute( "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", ("adopted-audit-journal", 1, 1), @@ -1752,6 +1753,23 @@ def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: di assert reconcile_durable_change_train_startup(archive_root) == () +def test_audit_metadata_read_is_read_only_for_uri_metacharacter_paths(tmp_path: Path) -> None: + """Archive path punctuation cannot consume SQLite's read-only URI parameter.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = tmp_path / "archive?#uri" + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + + version, application_id, quick_check = _audit_live_metadata(audit_path) + + assert version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + assert application_id == 0 + assert quick_check == ("ok",) + assert not audit_path.with_name("audit.db-wal").exists() + assert not audit_path.with_name("audit.db-shm").exists() + + def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace_env: dict[str, Path]) -> None: """The real offline restore publishes a new immutable continuity generation.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -1774,6 +1792,10 @@ def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace assert verified.ok, verified.error assert verified.output_path is not None expected_bytes = (Path(verified.output_path) / "audit.db").read_bytes() + with closing(sqlite3.connect(archive_root / "index.db")) as connection: + current_index_version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + connection.execute(f"PRAGMA user_version = {current_index_version + 1}") + connection.commit() old_identity = (audit_path.stat().st_dev, audit_path.stat().st_ino) audit_path.write_bytes(b"corrupted audit image") @@ -1853,8 +1875,53 @@ def fail_committed_link( assert reconcile_durable_change_train_startup(archive_root) == () -@pytest.mark.parametrize("tamper", ["artifact", "receipt", "stale-source"]) -def test_adopted_audit_restore_rejects_untrusted_or_stale_backup(workspace_env: dict[str, Path], tamper: str) -> None: +def test_adopted_audit_restore_record_survives_publication_temp_hardlink( + workspace_env: dict[str, Path], +) -> None: + """A crash after immutable publication may leave the valid record with two names.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "hardlink-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-hardlink-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "hardlink-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-hardlink-restore") as owner: + committed = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + leftover = committed.with_name(f".{committed.name}.publication.tmp") + os.link(committed, leftover) + + assert committed.stat().st_nlink == 2 + assert reconcile_durable_change_train_startup(archive_root) == () + + +@pytest.mark.parametrize( + ("tamper", "expected_error"), + [ + ("artifact", "migration backup tier artifact"), + ("receipt", "adopted-audit restore"), + ("stale-source", "adopted-audit restore backup is stale for source.db"), + ], +) +def test_adopted_audit_restore_rejects_untrusted_or_stale_backup( + workspace_env: dict[str, Path], tamper: str, expected_error: str +) -> None: """Mutation: bypassing receipt, artifact, or current-authority checks reaches the real restore call.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -1881,13 +1948,13 @@ def test_adopted_audit_restore_rejects_untrusted_or_stale_backup(workspace_env: elif tamper == "receipt": (backup_root / "verification-receipt.json").write_text("{}", encoding="utf-8") else: - with sqlite3.connect(archive_root / "source.db") as connection: + with closing(sqlite3.connect(archive_root / "source.db")) as connection: connection.execute("PRAGMA user_version = 999") connection.commit() audit_path.write_bytes(b"corrupted audit image") with acquire_durable_archive_ownership(archive_root, owner_id=f"test:audit-restore-reject-{tamper}") as owner: - with pytest.raises(MigrationError, match="(adopted-audit restore|migration backup tier artifact)"): + with pytest.raises(MigrationError, match=expected_error): restore_adopted_audit_tier( audit_path, backup_manifest=backup_root / "manifest.json", @@ -1898,6 +1965,102 @@ def test_adopted_audit_restore_rejects_untrusted_or_stale_backup(workspace_env: assert audit_path.read_bytes() == b"corrupted audit image" +def test_adopted_audit_restore_rejects_wrong_archive_application_id( + workspace_env: dict[str, Path], +) -> None: + """A valid backup from different audit authority cannot replace the adopted journal.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "app-id-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-app-id-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with closing(sqlite3.connect(audit_path)) as connection: + adopted_application_id = int(connection.execute("PRAGMA application_id").fetchone()[0]) + connection.execute(f"PRAGMA application_id = {adopted_application_id + 1}") + connection.commit() + wrong_authority = backup_archive( + output_dir=archive_root.parent / "app-id-wrong", profile="full_evidence", verify=True + ) + assert wrong_authority.ok and wrong_authority.output_path is not None, wrong_authority.error + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute(f"PRAGMA application_id = {adopted_application_id}") + connection.commit() + original = audit_path.read_bytes() + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-app-id-restore") as owner: + with pytest.raises(MigrationError, match="does not belong to this audit adoption"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(wrong_authority.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == original + + +def test_adopted_audit_restore_rejects_backup_swap_after_validation( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """The exact manifest and verification receipt stay fixed through publication.""" + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "swap-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-swap-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "swap-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + audit_path.write_bytes(b"corrupt-before-swap-test") + real_validate = operations_durable_change_train.validate_full_evidence_backup_for_adopted_audit_restore + calls = 0 + + def swap_after_validation(path: Path, *, archive_root: Path) -> tuple[Path, Path]: + nonlocal calls + calls += 1 + manifest, receipt = real_validate(path, archive_root=archive_root) + if calls == 2: + receipt.write_bytes(receipt.read_bytes() + b"\n") + return manifest, receipt + + monkeypatch.setattr( + operations_durable_change_train, + "validate_full_evidence_backup_for_adopted_audit_restore", + swap_after_validation, + ) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-swap-restore") as owner: + with pytest.raises(MigrationError, match="backup changed during the operation"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == b"corrupt-before-swap-test" + + def test_audit_adoption_binds_only_the_source_user_authority(workspace_env: dict[str, Path]) -> None: """Routine replacement of rebuildable or disposable tiers leaves adoption valid.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -1948,7 +2111,7 @@ def test_audit_adoption_receipt_keeps_initial_schema_evidence_after_upgrade( ) initial_schema_digest = json.loads(receipt.read_text(encoding="utf-8"))["audit_schema_inventory_sha256"] - with sqlite3.connect(audit_path) as connection: + with closing(sqlite3.connect(audit_path)) as connection: connection.execute("CREATE TABLE future_audit_schema (value TEXT)") connection.execute("PRAGMA user_version = 2") connection.commit() @@ -1977,7 +2140,7 @@ def test_audit_adoption_rejects_a_stale_audit_file_clone(workspace_env: dict[str ) stale_clone = archive_root / "stale-audit.db" shutil.copy2(audit_path, stale_clone) - with sqlite3.connect(audit_path) as connection: + with closing(sqlite3.connect(audit_path)) as connection: connection.execute( "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", ("live-audit-after-clone", 2, 1), @@ -2133,14 +2296,14 @@ def interrupt_continuity_link( ) stale_clone = archive_root / "stale-audit.db" - with sqlite3.connect(audit_path) as connection: + with closing(sqlite3.connect(audit_path)) as connection: connection.execute( "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", ("audit-before-stale-clone", 1, 1), ) connection.commit() shutil.copy2(audit_path, stale_clone) - with sqlite3.connect(audit_path) as connection: + with closing(sqlite3.connect(audit_path)) as connection: connection.execute( "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", ("audit-after-stale-clone", 2, 1), @@ -2253,17 +2416,28 @@ def test_adoption_receipt_refuses_a_symlinked_maintenance_parent(tmp_path: Path) assert not (outside / "durable-change-trains" / "audit-adoption.json").exists() -def test_runtime_bootstrap_refuses_an_established_archive_missing_audit(workspace_env: dict[str, Path]) -> None: +def test_runtime_bootstrap_refuses_an_established_archive_missing_audit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: """Ordinary writable startup cannot create audit.db without adoption evidence.""" - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.archive_tiers import bootstrap archive_root = workspace_env["archive_root"] - initialize_active_archive_root(archive_root) + bootstrap.initialize_active_archive_root(archive_root) (archive_root / "audit.db").unlink() + reconciled = False + + def observe_reconciliation(_root: Path) -> tuple[Path, ...]: + nonlocal reconciled + reconciled = True + return () + + monkeypatch.setattr(bootstrap, "reconcile_durable_change_trains_on_startup", observe_reconciliation) with pytest.raises(RuntimeError, match="adopt-established-audit"): - initialize_active_archive_root(archive_root) + bootstrap.initialize_active_archive_root(archive_root) + assert not reconciled assert not (archive_root / "audit.db").exists() From 55f84c6302a2571d0f9dd386ad0d22f84687035c Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 14:38:42 +0200 Subject: [PATCH 09/28] feat(storage): add replayable audit continuity Problem: audit adoption continuity trusted filesystem identity, so a valid stale audit image copied over the same inode could evade rollback detection. Production mutation executors also ran without an audit repository.\n\nWhat changed: add source-backed typed audit mutation commands, audit/source continuity heads, additive durable migrations and train sidecars, startup reconciliation, and archive-root executor composition. Adoption and verified restore rebind the machine continuity head.\n\nCompatibility/migration: source.db advances to v32 and audit.db to v2 through verified durable change trains.\n\nVerification: staged diff was checked with git diff --cached --check. Focused tests and quick verification are deferred while the active shared seed lease runs in polylogue-fix-tmpfs-budget. --- polylogue/annotations/importer.py | 2 +- polylogue/api/archive.py | 4 +- polylogue/api/ingest.py | 2 +- polylogue/cli/archive_query.py | 2 +- polylogue/cli/commands/excise.py | 2 +- .../cli/commands/maintenance/_raw_identity.py | 2 +- polylogue/cli/commands/reset.py | 2 +- .../maintenance/raw_authority_recovery.py | 2 +- polylogue/operations/audit.py | 342 ++++++++++++++++-- polylogue/operations/durable_change_train.py | 26 ++ polylogue/operations/mutation_transaction.py | 17 + .../storage/sqlite/archive_tiers/audit.py | 16 +- .../storage/sqlite/archive_tiers/bootstrap.py | 5 + .../storage/sqlite/archive_tiers/source.py | 26 +- polylogue/storage/sqlite/audit_continuity.py | 296 +++++++++++++++ .../storage/sqlite/durable_change_train.py | 1 + .../sqlite/migrations/audit/002.train.json | 56 +++ .../audit/002_audit_continuity_head.sql | 10 + .../sqlite/migrations/source/032.train.json | 57 +++ .../source/032_audit_continuity_control.sql | 18 + tests/unit/operations/test_operation_audit.py | 70 +++- tests/unit/storage/test_audit_continuity.py | 107 ++++++ .../unit/storage/test_durable_change_train.py | 10 + 23 files changed, 1032 insertions(+), 43 deletions(-) create mode 100644 polylogue/storage/sqlite/audit_continuity.py create mode 100644 polylogue/storage/sqlite/migrations/audit/002.train.json create mode 100644 polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql create mode 100644 polylogue/storage/sqlite/migrations/source/032.train.json create mode 100644 polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql create mode 100644 tests/unit/storage/test_audit_continuity.py diff --git a/polylogue/annotations/importer.py b/polylogue/annotations/importer.py index 4fa5dc2713..bacfa1ff57 100644 --- a/polylogue/annotations/importer.py +++ b/polylogue/annotations/importer.py @@ -468,7 +468,7 @@ async def default_resolver(ref: str) -> bool: abstained_count=abstained_count, created_at_ms=created_at_ms, ) - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(user_db_path.parent) actuator = AnnotationBatchImportActuator() plan = executor.prepare(actuator, args) authorization = executor.authorize( diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index c8c587a214..92b795cf7c 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -2692,7 +2692,7 @@ def _execute_facade_mutation( with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: args = build_args(archive) - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) plan = executor.prepare(actuator, args) authorization = executor.authorize( actuator, @@ -6589,7 +6589,7 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") detail="session_not_found", ) actuator = SessionDeleteActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) args = SessionDeleteArgs(archive=archive, session_ids=(resolved,)) plan = executor.prepare(actuator, args) authorization = executor.authorize( diff --git a/polylogue/api/ingest.py b/polylogue/api/ingest.py index b67a070a5f..efed353c1b 100644 --- a/polylogue/api/ingest.py +++ b/polylogue/api/ingest.py @@ -67,7 +67,7 @@ async def rebuild_index(self) -> bool: with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: actuator = IndexRebuildActuator() args = IndexRebuildArgs(archive=archive) - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) plan = executor.prepare(actuator, args) authorization = executor.authorize( actuator, diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 506c344209..0bb2552842 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -2149,7 +2149,7 @@ def _emit_delete( count = len(session_ids) actuator = SessionDeleteActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(archive.archive_root) prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) plan = executor.prepare(actuator, prepare_args) diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index 163e49be89..f334698484 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -200,7 +200,7 @@ def excise_command( from polylogue.security.excision import plan_session_excision actuator = SessionExcisionActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(root) excision_args = SessionExcisionArgs( archive_root=root, session_id=session_id, diff --git a/polylogue/cli/commands/maintenance/_raw_identity.py b/polylogue/cli/commands/maintenance/_raw_identity.py index 971a2b716c..fb126c6e6f 100644 --- a/polylogue/cli/commands/maintenance/_raw_identity.py +++ b/polylogue/cli/commands/maintenance/_raw_identity.py @@ -219,7 +219,7 @@ def raw_authority_blocker_resolve_command( ) actuator = BlockerResolveActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(env.config.archive_root) args = BlockerResolveArgs( archive_root=env.config.archive_root, blocker_id=blocker_id, diff --git a/polylogue/cli/commands/reset.py b/polylogue/cli/commands/reset.py index 46b3cf322e..d978f43232 100644 --- a/polylogue/cli/commands/reset.py +++ b/polylogue/cli/commands/reset.py @@ -216,7 +216,7 @@ def _apply_identity_reset(session_ids: list[str], *, reason: str) -> tuple[int, if not session_ids: return 0, 0 actuator = IdentityResetActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(_archive_root()) args = IdentityResetArgs(archive_root=_archive_root(), session_ids=tuple(session_ids), reason=reason) plan = executor.prepare(actuator, args) authorization = executor.authorize( diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 8dec53c0e7..891be14c74 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -1522,7 +1522,7 @@ def apply_raw_authority_recovery( if operation is RecoveryOperation.RESET_CENSUS else PruneOrphanedIndexRevisionSeedsActuator() ) - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(root) try: location = ArchiveLocation.resolve(root) # A final receipt may be missing after a process crash or I/O failure. diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index d9fb4b51d2..b03d9e2742 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -9,8 +9,9 @@ import time from collections.abc import Iterator, Mapping from contextlib import contextmanager +from functools import wraps from pathlib import Path -from typing import Literal +from typing import Any, Literal, cast from polylogue.operations.mutation_transaction import ( MutationAuthorization, @@ -18,8 +19,9 @@ MutationPreview, MutationPrincipal, MutationReceipt, + MutationTarget, ) -from polylogue.storage.sqlite.archive_tiers.audit import AUDIT_DDL, AUDIT_SCHEMA_VERSION +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation AuditTargetState = Literal[ "pending", @@ -37,29 +39,313 @@ def token_sha256(token: str) -> str: """Return the only representation of a bearer token accepted for storage.""" + if token.startswith("sha256:") and len(token) == len("sha256:") + 64: + return token.removeprefix("sha256:") return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _continuity_mutation(kind: str): + """Route one audit repository state transition through the source WAL.""" + + def decorate(method): + @wraps(method) + def wrapped(self: AuditRepository, *args: object, **kwargs: object) -> object: + mutation = AuditMutation( + kind=kind, + mutation_id=f"audit-mutation:{secrets.token_urlsafe(18)}", + created_at_ms=int(time.time() * 1000), + payload=self._continuity_payload(kind, args, kwargs), + ) + + def apply(conn: sqlite3.Connection, _mutation: AuditMutation) -> object: + self._coordinated_connection = conn + self._coordinated_mutation = _mutation + try: + return method(self, *args, **kwargs) + finally: + self._coordinated_mutation = None + self._coordinated_connection = None + + return self._continuity.execute(mutation, apply) + + return wrapped + + return decorate + + +def _target_from_payload(raw: object) -> MutationTarget: + value = cast(dict[str, object], raw) + return MutationTarget( + kind=cast(str, value["kind"]), + ref=cast(str, value["ref"]), + policy_key=cast(str, value["policy_key"]), + identity_digest=cast(str, value["identity_digest"]), + effect_identity=cast(str, value["effect_identity"]), + durability=cast(Any, value["durability"]), + recovery=cast(Any, value["recovery"]), + ) + + +def _plan_from_payload(raw: object) -> MutationPlan: + value = cast(dict[str, object], raw) + return MutationPlan( + operation=cast(str, value["operation"]), + destructive_class=cast(Any, value["destructive_class"]), + target_refs=tuple(cast(list[str], value["target_refs"])), + affected_tiers=tuple(cast(list[str], value["affected_tiers"])), + reversible=cast(bool, value["reversible"]), + prepared_at=cast(str, value["prepared_at"]), + plan_hash=cast(str, value["plan_hash"]), + context=cast(dict[str, object], value["context"]), + operation_version=cast(int, value["operation_version"]), + archive_instance_id=cast(str, value["archive_instance_id"]), + archive_identity_digest=cast(str, value["archive_identity_digest"]), + required_capabilities=tuple(cast(list[str], value["required_capabilities"])), + required_confirmation=cast(Any, value["required_confirmation"]), + targets=tuple(_target_from_payload(item) for item in cast(list[object], value["targets"])), + parameter_digest=cast(str, value["parameter_digest"]), + target_digest=cast(str, value["target_digest"]), + prepared_at_ms=cast(int, value["prepared_at_ms"]), + expires_at_ms=cast(int, value["expires_at_ms"]), + ) + + +def _principal_payload(principal: MutationPrincipal) -> dict[str, object]: + return { + "actor_ref": principal.actor_ref, + "capabilities": sorted(principal.capabilities), + "surface": principal.surface, + "role_label": principal.role_label, + } + + +def _principal_from_payload(raw: object) -> MutationPrincipal: + value = cast(dict[str, object], raw) + return MutationPrincipal( + cast(str, value["actor_ref"]), + frozenset(cast(list[str], value["capabilities"])), + cast(Any, value["surface"]), + cast(str | None, value.get("role_label")), + ) + + +def _preview_payload(preview: MutationPreview) -> dict[str, object]: + return {"preview_ref": preview.preview_ref, "plan": preview.plan.to_dict()} + + +def _preview_from_payload(raw: object) -> MutationPreview: + value = cast(dict[str, object], raw) + return MutationPreview(preview_ref=cast(str, value["preview_ref"]), plan=_plan_from_payload(value["plan"])) + + +def _authorization_payload(authorization: MutationAuthorization) -> dict[str, object]: + return { + **authorization.to_dict(), + "token_sha256": None if authorization.token is None else token_sha256(authorization.token), + } + + +def _authorization_from_payload(raw: object) -> MutationAuthorization: + value = cast(dict[str, object], raw) + token_digest = cast(str | None, value.get("token_sha256")) + return MutationAuthorization( + plan_hash=cast(str, value["plan_hash"]), + actor=cast(str, value["actor"]), + role=cast(str, value["role"]), + capability=cast(str, value["capability"]), + confirmation_strength=cast(Any, value["confirmation_strength"]), + authorized_at=cast(str, value["authorized_at"]), + preview_ref=cast(str | None, value.get("preview_ref")), + authorization_id=cast(str | None, value.get("authorization_id")), + token=None if token_digest is None else f"sha256:{token_digest}", + expires_at_ms=cast(int | None, value.get("expires_at_ms")), + capabilities=tuple(cast(list[str], value["capabilities"])), + surface=cast(Any, value.get("surface")), + ) + + +def _receipt_payload(receipt: MutationReceipt) -> dict[str, object]: + return receipt.to_dict() + + +def _receipt_from_payload(raw: object) -> MutationReceipt: + value = cast(dict[str, object], raw) + return MutationReceipt( + operation=cast(str, value["operation"]), + plan_hash=cast(str, value["plan_hash"]), + status=cast(Any, value["status"]), + target_refs=tuple(cast(list[str], value["target_refs"])), + affected_count=cast(int, value["affected_count"]), + detail=cast(str | None, value.get("detail")), + receipt_ref=cast(str | None, value.get("receipt_ref")), + applied_at=cast(str, value["applied_at"]), + domain_receipt=cast(dict[str, object], value["domain_receipt"]), + operation_id=cast(str | None, value.get("operation_id")), + ) + + class AuditRepository: """Small synchronous repository whose methods make audit transactions explicit.""" def __init__(self, path: Path) -> None: self.path = path - self.path.parent.mkdir(parents=True, exist_ok=True) + self._continuity = AuditContinuityCoordinator(path.parent) + self._coordinated_connection: sqlite3.Connection | None = None + self._coordinated_mutation: AuditMutation | None = None + + @classmethod + def for_archive_root(cls, archive_root: Path) -> AuditRepository: + """Build the repository for an already-initialized archive root.""" + + return cls(archive_root / "audit.db") + + def reconcile_continuity(self) -> None: + """Reject audit bytes that cannot prove the source control head.""" + + self._continuity.reconcile(self._replay_pending_mutation) @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: - conn = sqlite3.connect(self.path) + if self._coordinated_connection is not None: + yield self._coordinated_connection + return + if not self.path.is_file(): + raise RuntimeError(f"audit tier is missing or uninitialized: {self.path}") + conn = sqlite3.connect(f"{self.path.resolve(strict=True).as_uri()}?mode=rw", uri=True) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") - conn.executescript(AUDIT_DDL) - conn.execute(f"PRAGMA user_version = {AUDIT_SCHEMA_VERSION}") - conn.commit() try: yield conn finally: conn.close() + def _continuity_payload( + self, kind: str, args: tuple[object, ...], kwargs: Mapping[str, object] + ) -> dict[str, object]: + """Encode exact typed replay inputs before source.db prepares a command.""" + + values = dict(kwargs) + if kind == "ensure_archive_authority": + return { + "now_ms": cast(int, values["now_ms"]), + "archive_instance_id": values.get("archive_instance_id") or f"archive:{secrets.token_hex(16)}", + } + if kind == "create_preview": + plan, principal = cast(MutationPlan, args[0]), cast(MutationPrincipal, args[1]) + return { + "preview_id": f"preview:{secrets.token_urlsafe(18)}", + "plan": plan.to_dict(), + "principal": _principal_payload(principal), + } + if kind == "issue_authorization": + preview, principal, authorization = ( + cast(MutationPreview, args[0]), + cast(MutationPrincipal, args[1]), + cast(MutationAuthorization, args[2]), + ) + return { + "authorization_id": f"authorization:{secrets.token_urlsafe(18)}", + "issued_at_ms": int(time.time() * 1000), + "preview": _preview_payload(preview), + "principal": _principal_payload(principal), + "authorization": _authorization_payload(authorization), + } + if kind == "consume_authorization_and_start": + preview, authorization = cast(MutationPreview, args[0]), cast(MutationAuthorization, args[1]) + return { + "operation_id": f"operation:{secrets.token_urlsafe(18)}", + "attempt_id": f"attempt:{secrets.token_urlsafe(18)}", + "now_ms": int(time.time() * 1000), + "preview": _preview_payload(preview), + "authorization": _authorization_payload(authorization), + } + if kind == "finalize_attempt": + operation_id = cast(str, args[0]) + return { + "operation_id": operation_id, + "status": cast(str, values["status"]), + "receipt": None + if values.get("receipt") is None + else _receipt_payload(cast(MutationReceipt, values["receipt"])), + "error_summary": values.get("error_summary"), + "unknown_reason": values.get("unknown_reason"), + "now_ms": int(time.time() * 1000), + } + if kind == "reconcile_attempt": + operation_id = cast(str, args[0]) + return { + "operation_id": operation_id, + "outcome": cast(str, values["outcome"]), + "domain_receipt_ref": values.get("domain_receipt_ref"), + "reason": values.get("reason"), + "now_ms": int(time.time() * 1000), + } + raise RuntimeError(f"unregistered audit continuity mutation {kind!r}") + + def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMutation) -> object: + """Replay the stored typed command without allocating fresh ids or clocks.""" + + payload = mutation.payload + self._coordinated_connection = conn + self._coordinated_mutation = mutation + try: + if mutation.kind == "ensure_archive_authority": + return self.ensure_archive_authority.__wrapped__( + self, + now_ms=cast(int, payload["now_ms"]), + archive_instance_id=cast(str, payload["archive_instance_id"]), + ) + if mutation.kind == "create_preview": + return self.create_preview.__wrapped__( + self, _plan_from_payload(payload["plan"]), _principal_from_payload(payload["principal"]) + ) + if mutation.kind == "issue_authorization": + return self.issue_authorization.__wrapped__( + self, + _preview_from_payload(payload["preview"]), + _principal_from_payload(payload["principal"]), + _authorization_from_payload(payload["authorization"]), + ) + if mutation.kind == "consume_authorization_and_start": + return self.consume_authorization_and_start.__wrapped__( + self, + _preview_from_payload(payload["preview"]), + _authorization_from_payload(payload["authorization"]), + ) + if mutation.kind == "finalize_attempt": + return self.finalize_attempt.__wrapped__( + self, + cast(str, payload["operation_id"]), + status=cast(str, payload["status"]), + receipt=None if payload["receipt"] is None else _receipt_from_payload(payload["receipt"]), + error_summary=cast(str | None, payload.get("error_summary")), + unknown_reason=cast(str | None, payload.get("unknown_reason")), + ) + if mutation.kind == "reconcile_attempt": + return self.reconcile_attempt.__wrapped__( + self, + cast(str, payload["operation_id"]), + outcome=cast(Literal["applied", "absent", "unknown"], payload["outcome"]), + domain_receipt_ref=cast(str | None, payload.get("domain_receipt_ref")), + reason=cast(str | None, payload.get("reason")), + ) + raise RuntimeError(f"unregistered audit continuity mutation {mutation.kind!r}") + finally: + self._coordinated_mutation = None + self._coordinated_connection = None + + def _command_value(self, key: str, fallback: object) -> object: + if self._coordinated_mutation is None: + return fallback + return self._coordinated_mutation.payload.get(key, fallback) + + def _begin(self, conn: sqlite3.Connection) -> None: + """Start a standalone audit transaction, or reuse the coordinator's one.""" + + if self._coordinated_connection is None: + conn.execute("BEGIN IMMEDIATE") + + @_continuity_mutation("ensure_archive_authority") def ensure_archive_authority(self, *, now_ms: int, archive_instance_id: str | None = None) -> str: """Create or return the immutable archive lineage id.""" @@ -70,20 +356,20 @@ def ensure_archive_authority(self, *, now_ms: int, archive_instance_id: str | No if archive_instance_id is not None and archive_instance_id != existing: raise ValueError("audit archive instance identity changed") return existing - instance_id = archive_instance_id or f"archive:{secrets.token_hex(16)}" + instance_id = cast(str, archive_instance_id or self._command_value("archive_instance_id", "")) conn.execute( "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", (instance_id, now_ms), ) - conn.commit() return instance_id + @_continuity_mutation("create_preview") def create_preview(self, plan: MutationPlan, principal: MutationPrincipal) -> str: """Persist a bounded preview and its normalized target/capability rows.""" - preview_id = f"preview:{secrets.token_urlsafe(18)}" + preview_id = cast(str, self._command_value("preview_id", f"preview:{secrets.token_urlsafe(18)}")) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) conn.execute( """ INSERT INTO operation_previews ( @@ -153,9 +439,9 @@ def create_preview(self, plan: MutationPlan, principal: MutationPrincipal) -> st "INSERT INTO operation_preview_capabilities(preview_id, capability) VALUES (?, ?)", (preview_id, capability), ) - conn.commit() return preview_id + @_continuity_mutation("issue_authorization") def issue_authorization( self, preview: MutationPreview, @@ -166,10 +452,12 @@ def issue_authorization( if authorization.token is None: raise ValueError("bound authorization requires a token") - authorization_id = f"authorization:{secrets.token_urlsafe(18)}" - issued_at_ms = int(time.time() * 1000) + authorization_id = cast( + str, self._command_value("authorization_id", f"authorization:{secrets.token_urlsafe(18)}") + ) + issued_at_ms = cast(int, self._command_value("issued_at_ms", int(time.time() * 1000))) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) preview_row = conn.execute( "SELECT plan_hash, expires_at_ms, state, principal_actor_ref FROM operation_previews WHERE preview_id = ?", (preview.preview_ref,), @@ -207,19 +495,19 @@ def issue_authorization( "INSERT INTO operation_authorization_capabilities(authorization_id, capability) VALUES (?, ?)", (authorization_id, capability), ) - conn.commit() return authorization_id + @_continuity_mutation("consume_authorization_and_start") def consume_authorization_and_start(self, preview: MutationPreview, authorization: MutationAuthorization) -> str: """Consume a token and create run, targets, and initial attempt atomically.""" if authorization.token is None: raise ValueError("authorization token is missing") - operation_id = f"operation:{secrets.token_urlsafe(18)}" - attempt_id = f"attempt:{secrets.token_urlsafe(18)}" - now_ms = int(time.time() * 1000) + operation_id = cast(str, self._command_value("operation_id", f"operation:{secrets.token_urlsafe(18)}")) + attempt_id = cast(str, self._command_value("attempt_id", f"attempt:{secrets.token_urlsafe(18)}")) + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) row = conn.execute( """ SELECT a.authorization_id, a.preview_id, a.actor_ref, a.surface, @@ -239,7 +527,6 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio "UPDATE operation_authorizations SET state = 'expired' WHERE authorization_id = ?", (str(row[0]),), ) - conn.commit() raise RuntimeError("authorization token is expired") if str(row[2]) != authorization.actor or str(row[3]) != (authorization.surface or ""): raise ValueError("authorization principal mismatch") @@ -324,9 +611,9 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio occurred_at_ms=now_ms, detail={"target_count": preview.plan.target_count}, ) - conn.commit() return operation_id + @_continuity_mutation("finalize_attempt") def finalize_attempt( self, operation_id: str, @@ -338,13 +625,13 @@ def finalize_attempt( ) -> None: """Finalize one running attempt and parent run in one audit transaction.""" - now_ms = int(time.time() * 1000) + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) target_state: AuditTargetState = ( "unknown" if status == "unknown" else "failed" if status == "failed" else "applied" ) attempt_state = "unknown" if target_state == "unknown" else target_state with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) run = conn.execute( "SELECT actor_ref FROM operation_runs WHERE operation_id = ?", (operation_id,) ).fetchone() @@ -430,8 +717,8 @@ def finalize_attempt( occurred_at_ms=now_ms, detail={"status": status, "reason": (unknown_reason or error_summary or "")[:512]}, ) - conn.commit() + @_continuity_mutation("reconcile_attempt") def reconcile_attempt( self, operation_id: str, @@ -442,9 +729,9 @@ def reconcile_attempt( ) -> None: """Persist an explicit applied/absent/unknown reconciliation decision.""" - now_ms = int(time.time() * 1000) + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) target = conn.execute( "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'unknown' ORDER BY ordinal LIMIT 1", (operation_id,), @@ -492,7 +779,6 @@ def reconcile_attempt( occurred_at_ms=now_ms, detail={"reason": (reason or "")[:512]}, ) - conn.commit() def get_operation(self, operation_id: str) -> dict[str, object] | None: with self._connection() as conn: diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index ab7e66ef8d..22aba59fe1 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -10,6 +10,7 @@ import sqlite3 import stat import sys +import time from collections.abc import Callable from contextlib import closing, suppress from dataclasses import dataclass @@ -19,6 +20,7 @@ from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator from polylogue.storage.sqlite.durable_change_train import ( DurableChangeTrainExecution, ) @@ -909,6 +911,21 @@ def _write_audit_adoption_continuity( archive_root=archive_root, checksum_key="continuity_sha256", ) + # The immutable receipt remains operator evidence. The machine authority + # is the cross-tier head, seeded with the authenticated initial image so a + # byte-for-byte stale copy on the same inode cannot be blessed later. + receipt_sha256 = receipt_payload.get("receipt_sha256") + if not isinstance(receipt_sha256, str): + raise MigrationError("audit adoption receipt lacks its checksum") + AuditContinuityCoordinator(archive_root).seed_or_rebind( + mutation_id=f"audit-adoption:{receipt_sha256}", + now_ms=int(time.time() * 1000), + evidence={ + "kind": "adoption", + "receipt_sha256": receipt_sha256, + "audit_image_sha256": hashlib.sha256(audit_path.read_bytes()).hexdigest(), + }, + ) if _audit_file_identity(audit_path) != (device, inode): raise MigrationError("audit tier changed while recording adoption continuity") @@ -1378,6 +1395,15 @@ def revalidate_exact_backup() -> None: archive_directory_fd=directory_fd, checksum_key="continuity_sha256", ) + AuditContinuityCoordinator(archive_root).seed_or_rebind( + mutation_id=f"audit-restore:{operation_id}", + now_ms=int(time.time() * 1000), + evidence={ + "kind": "verified_restore", + "restore_continuity_sha256": committed["continuity_sha256"], + "audit_artifact_sha256": artifact_sha256, + }, + ) return committed_path finally: if not published: diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index 202bdf4911..85e9273565 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -44,6 +44,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field, replace from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, runtime_checkable if TYPE_CHECKING: @@ -551,6 +552,22 @@ def __init__( self._now_ms = now_ms or (lambda: int(datetime.now(UTC).timestamp() * 1000)) self._token_factory = token_factory or (lambda: secrets.token_urlsafe(32)) + @classmethod + def for_archive_root( + cls, + archive_root: Path, + *, + now_ms: Callable[[], int] | None = None, + token_factory: Callable[[], str] | None = None, + ) -> OperationExecutor: + """Compose production mutation execution with the archive's audit tier.""" + + from polylogue.operations.audit import AuditRepository + + audit = AuditRepository.for_archive_root(archive_root) + audit.reconcile_continuity() + return cls(audit=audit, now_ms=now_ms, token_factory=token_factory) + def prepare(self, actuator: MutationActuator[ArgsT], args: ArgsT) -> MutationPlan: """PREPARE: resolve exact targets from live state. Never mutates.""" diff --git a/polylogue/storage/sqlite/archive_tiers/audit.py b/polylogue/storage/sqlite/archive_tiers/audit.py index 01f43118de..152f0dc3e5 100644 --- a/polylogue/storage/sqlite/archive_tiers/audit.py +++ b/polylogue/storage/sqlite/archive_tiers/audit.py @@ -6,7 +6,7 @@ from __future__ import annotations -AUDIT_SCHEMA_VERSION = 1 +AUDIT_SCHEMA_VERSION = 2 AUDIT_DDL = """ CREATE TABLE IF NOT EXISTS archive_authority ( @@ -214,6 +214,20 @@ ) STRICT; CREATE INDEX IF NOT EXISTS idx_operation_events_type_time ON operation_events(event_type, occurred_at_ms); + +-- The audit head is deliberately independent of filesystem identity. source.db +-- records the authoritative committed generation; every audit mutation advances +-- this row in the same audit transaction as its domain rows. +CREATE TABLE IF NOT EXISTS audit_continuity_head ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + generation INTEGER NOT NULL CHECK(generation >= 0), + head_sha256 TEXT NOT NULL CHECK(length(head_sha256) = 64), + mutation_id TEXT, + advanced_at_ms INTEGER NOT NULL CHECK(advanced_at_ms >= 0) +) STRICT; +INSERT OR IGNORE INTO audit_continuity_head( + singleton, generation, head_sha256, mutation_id, advanced_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, 0); """ __all__ = ["AUDIT_DDL", "AUDIT_SCHEMA_VERSION"] diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 9b09cf353d..657c3c1622 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -414,6 +414,11 @@ def assert_owned_root() -> None: for spec in ARCHIVE_TIER_SPECS.values(): assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) + # Runtime mutation composition must observe a reconciled source/audit + # head before it can open any tier for writes. + from polylogue.operations.audit import AuditRepository + + AuditRepository.for_archive_root(root).reconcile_continuity() if recovering_fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap(root) diff --git a/polylogue/storage/sqlite/archive_tiers/source.py b/polylogue/storage/sqlite/archive_tiers/source.py index 1e0cd3750f..f0b0ce4692 100644 --- a/polylogue/storage/sqlite/archive_tiers/source.py +++ b/polylogue/storage/sqlite/archive_tiers/source.py @@ -21,7 +21,7 @@ from polylogue.storage.sqlite.archive_tiers.common import check, literal_check, nullable_check from polylogue.storage.sqlite.archive_tiers.types import ProvenRevisionAuthority -SOURCE_SCHEMA_VERSION = 31 +SOURCE_SCHEMA_VERSION = 32 SOURCE_DDL = f""" CREATE TABLE IF NOT EXISTS raw_sessions ( @@ -857,6 +857,30 @@ PRIMARY KEY(blob_hash) ) STRICT; +-- The source tier is the durable cross-tier write-ahead command log for +-- audit.db. A singleton row gives exactly one committed head and at most one +-- canonical, replayable pending mutation. A pending command is never prose: +-- its JSON is the typed operation input used to complete an interrupted audit +-- write on startup. +CREATE TABLE IF NOT EXISTS audit_continuity_control ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + committed_generation INTEGER NOT NULL CHECK(committed_generation >= 0), + committed_head_sha256 TEXT NOT NULL CHECK(length(committed_head_sha256) = 64), + pending_mutation_id TEXT UNIQUE, + pending_payload_json TEXT, + pending_payload_sha256 TEXT CHECK(pending_payload_sha256 IS NULL OR length(pending_payload_sha256) = 64), + prepared_at_ms INTEGER, + CHECK( + (pending_mutation_id IS NULL AND pending_payload_json IS NULL AND pending_payload_sha256 IS NULL AND prepared_at_ms IS NULL) + OR + (pending_mutation_id IS NOT NULL AND pending_payload_json IS NOT NULL AND pending_payload_sha256 IS NOT NULL AND prepared_at_ms IS NOT NULL AND prepared_at_ms >= 0) + ) +) STRICT; +INSERT OR IGNORE INTO audit_continuity_control( + singleton, committed_generation, committed_head_sha256, + pending_mutation_id, pending_payload_json, pending_payload_sha256, prepared_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, NULL, NULL, NULL); + """ __all__ = ["SOURCE_DDL", "SOURCE_SCHEMA_VERSION"] diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py new file mode 100644 index 0000000000..46e4b74d7f --- /dev/null +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -0,0 +1,296 @@ +"""Replayable cross-tier write-ahead control for durable ``audit.db`` writes. + +SQLite cannot atomically commit transactions spanning source.db and audit.db. +The source control row is therefore the authoritative write-ahead command: +prepare it in source.db, commit the audit mutation plus its head, then promote +the source head. Startup can complete the first two crash windows because the +pending row contains the exact typed command, and it rejects an audit image +whose head regressed after source promotion. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from collections.abc import Callable, Mapping +from contextlib import closing +from dataclasses import dataclass +from pathlib import Path +from typing import TypeVar + +_FORMAT = "polylogue.audit-continuity-command.v1" +_T = TypeVar("_T") + + +class AuditContinuityError(RuntimeError): + """Audit and source durable control state cannot prove one continuity head.""" + + +@dataclass(frozen=True, slots=True) +class AuditMutation: + """One typed audit command with generated identity and replay inputs.""" + + kind: str + mutation_id: str + created_at_ms: int + payload: Mapping[str, object] + + def command(self) -> dict[str, object]: + return { + "kind": self.kind, + "mutation_id": self.mutation_id, + "created_at_ms": self.created_at_ms, + "payload": dict(self.payload), + } + + @classmethod + def from_command(cls, raw: object) -> AuditMutation: + if not isinstance(raw, dict): + raise AuditContinuityError("pending audit continuity command is not an object") + kind = raw.get("kind") + mutation_id = raw.get("mutation_id") + created_at_ms = raw.get("created_at_ms") + payload = raw.get("payload") + if ( + not isinstance(kind, str) + or not kind + or not isinstance(mutation_id, str) + or not mutation_id + or not isinstance(created_at_ms, int) + or created_at_ms < 0 + or not isinstance(payload, dict) + ): + raise AuditContinuityError("pending audit continuity command is malformed") + return cls(kind=kind, mutation_id=mutation_id, created_at_ms=created_at_ms, payload=payload) + + +def _canonical_json(payload: object) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _sha256(payload: object) -> str: + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +class AuditContinuityCoordinator: + """Coordinate typed audit commands through source.db's durable WAL row.""" + + def __init__( + self, + archive_root: Path, + *, + phase_hook: Callable[[str, AuditMutation], None] | None = None, + ) -> None: + self.archive_root = archive_root.resolve() + self.source_path = self.archive_root / "source.db" + self.audit_path = self.archive_root / "audit.db" + self._phase_hook = phase_hook + + def execute(self, mutation: AuditMutation, apply: Callable[[sqlite3.Connection, AuditMutation], _T]) -> _T: + """Prepare one command, commit audit bytes, then promote source control.""" + + self._phase("before_source_prepare", mutation) + prepared = self._prepare(mutation) + self._phase("after_source_prepare", mutation) + result = self._apply_prepared(prepared, apply) + self._phase("after_audit_commit", mutation) + self._promote(prepared) + self._phase("after_source_promotion", mutation) + return result + + def reconcile(self, apply: Callable[[sqlite3.Connection, AuditMutation], object]) -> None: + """Deterministically complete a pending command or reject a stale audit image.""" + + prepared = self._pending() + if prepared is None: + self._assert_committed_head_matches_audit() + return + self._apply_prepared(prepared, apply) + self._promote(prepared) + + def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str, object]) -> None: + """Advance continuity after an authenticated adoption or verified restore. + + This is intentionally a typed WAL command too. The caller has already + authenticated the external publication; this method only binds that + exact evidence to the new audit image without trusting inode identity. + """ + + mutation = AuditMutation("rebind", mutation_id, now_ms, dict(evidence)) + + def apply(conn: sqlite3.Connection, _mutation: AuditMutation) -> None: + # A verified restored image can contain an older audit head. Its + # authenticated restore evidence is the authority to rebind it. + return None + + prepared = self._prepare(mutation) + self._apply_prepared(prepared, apply, allow_rebind=True) + self._promote(prepared) + + def _phase(self, name: str, mutation: AuditMutation) -> None: + if self._phase_hook is not None: + self._phase_hook(name, mutation) + + def _prepare(self, mutation: AuditMutation) -> dict[str, object]: + self._require_paths() + with closing(sqlite3.connect(self.source_path)) as conn, conn: + conn.row_factory = sqlite3.Row + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT committed_generation, committed_head_sha256, pending_mutation_id FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + if row[2] is not None: + raise AuditContinuityError("another audit continuity mutation is already pending") + generation = int(row[0]) + previous_head = str(row[1]) + command = mutation.command() + command_sha256 = _sha256(command) + prepared = { + "format": _FORMAT, + "prior_generation": generation, + "prior_head_sha256": previous_head, + "next_generation": generation + 1, + "command": command, + "command_sha256": command_sha256, + "next_head_sha256": _sha256({"previous_head_sha256": previous_head, "command_sha256": command_sha256}), + } + payload_json = _canonical_json(prepared) + conn.execute( + """ + UPDATE audit_continuity_control + SET pending_mutation_id = ?, pending_payload_json = ?, pending_payload_sha256 = ?, prepared_at_ms = ? + WHERE singleton = 1 AND pending_mutation_id IS NULL + """, + (mutation.mutation_id, payload_json, _sha256(prepared), mutation.created_at_ms), + ) + conn.commit() + return prepared + + def _pending(self) -> dict[str, object] | None: + self._require_paths() + with closing(sqlite3.connect(self.source_path)) as conn: + row = conn.execute( + "SELECT committed_generation, committed_head_sha256, pending_payload_json, pending_payload_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + pending_json = row[2] + if pending_json is None: + return None + if not isinstance(pending_json, str): + raise AuditContinuityError("source audit continuity pending command is malformed") + try: + prepared = json.loads(pending_json) + except json.JSONDecodeError as exc: + raise AuditContinuityError("source audit continuity pending command is invalid JSON") from exc + if not isinstance(prepared, dict) or _sha256(prepared) != row[3]: + raise AuditContinuityError("source audit continuity pending command checksum mismatch") + if ( + prepared.get("format") != _FORMAT + or prepared.get("prior_generation") != row[0] + or prepared.get("prior_head_sha256") != row[1] + ): + raise AuditContinuityError("source audit continuity pending command does not bind its committed head") + self._validate_prepared(prepared) + return prepared + + def _apply_prepared( + self, + prepared: dict[str, object], + apply: Callable[[sqlite3.Connection, AuditMutation], _T], + *, + allow_rebind: bool = False, + ) -> _T: + self._validate_prepared(prepared) + mutation = AuditMutation.from_command(prepared["command"]) + with closing(sqlite3.connect(self.audit_path)) as conn, conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing") + current = (int(row[0]), str(row[1]), row[2]) + prior = (int(prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (int(prepared["next_generation"]), str(prepared["next_head_sha256"])) + if current[:2] == target and current[2] == mutation.mutation_id: + conn.commit() + return None # type: ignore[return-value] + if current[:2] != prior: + if allow_rebind and mutation.kind == "rebind": + pass + else: + raise AuditContinuityError("audit continuity head does not match the prepared source command") + result = apply(conn, mutation) + conn.execute( + "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? WHERE singleton = 1", + (*target, mutation.mutation_id, mutation.created_at_ms), + ) + conn.commit() + return result + + def _promote(self, prepared: Mapping[str, object]) -> None: + mutation = AuditMutation.from_command(prepared["command"]) + with closing(sqlite3.connect(self.source_path)) as conn, conn: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.execute( + """ + UPDATE audit_continuity_control + SET committed_generation = ?, committed_head_sha256 = ?, + pending_mutation_id = NULL, pending_payload_json = NULL, + pending_payload_sha256 = NULL, prepared_at_ms = NULL + WHERE singleton = 1 AND pending_mutation_id = ? AND pending_payload_sha256 = ? + """, + ( + prepared["next_generation"], + prepared["next_head_sha256"], + mutation.mutation_id, + _sha256(dict(prepared)), + ), + ) + if cursor.rowcount != 1: + raise AuditContinuityError("source audit continuity promotion lost its prepared command") + conn.commit() + + def _assert_committed_head_matches_audit(self) -> None: + with closing(sqlite3.connect(self.source_path)) as source, closing(sqlite3.connect(self.audit_path)) as audit: + source_row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_row = audit.execute( + "SELECT generation, head_sha256 FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if source_row is None or audit_row is None: + raise AuditContinuityError("audit continuity control row is missing") + if (int(source_row[0]), str(source_row[1])) != (int(audit_row[0]), str(audit_row[1])): + raise AuditContinuityError("audit continuity head regressed or was replaced after source promotion") + + def _validate_prepared(self, prepared: Mapping[str, object]) -> None: + command = prepared.get("command") + if not isinstance(command, dict) or prepared.get("format") != _FORMAT: + raise AuditContinuityError("audit continuity command format mismatch") + required_ints = ("prior_generation", "next_generation") + if any(not isinstance(prepared.get(key), int) for key in required_ints): + raise AuditContinuityError("audit continuity command generations are malformed") + if prepared["next_generation"] != prepared["prior_generation"] + 1: + raise AuditContinuityError("audit continuity command generation is non-monotonic") + command_sha256 = _sha256(command) + if prepared.get("command_sha256") != command_sha256: + raise AuditContinuityError("audit continuity command payload checksum mismatch") + expected_head = _sha256( + {"previous_head_sha256": prepared.get("prior_head_sha256"), "command_sha256": command_sha256} + ) + if prepared.get("next_head_sha256") != expected_head: + raise AuditContinuityError("audit continuity command head checksum mismatch") + + def _require_paths(self) -> None: + if not self.source_path.is_file() or not self.audit_path.is_file(): + raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") + + +__all__ = ["AuditContinuityCoordinator", "AuditContinuityError", "AuditMutation"] diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index de3d7b5e0a..f1639a7d3b 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -69,6 +69,7 @@ DURABLE_MIGRATION_ADOPTION_FLOORS: Final[dict[ArchiveTier, int]] = { ArchiveTier.SOURCE: 26, ArchiveTier.USER: 10, + ArchiveTier.AUDIT: 1, } _SIDECAR_NAME_RE = re.compile(r"^(?P\d{3,})\.train\.json$") _MIGRATION_NAME_RE = re.compile(r"^(?P\d{3,})_[a-z0-9_]+\.sql$") diff --git a/polylogue/storage/sqlite/migrations/audit/002.train.json b/polylogue/storage/sqlite/migrations/audit/002.train.json new file mode 100644 index 0000000000..49e51ca99c --- /dev/null +++ b/polylogue/storage/sqlite/migrations/audit/002.train.json @@ -0,0 +1,56 @@ +{ + "manifest_format": "polylogue.durable-change-train.v1", + "train_id": "train:audit:v2", + "tier": "audit", + "current_version": 1, + "target_version": 2, + "slot": 2, + "owner_ref": "feature/fix/audit-continuity", + "migration": { + "tier": "audit", + "target_version": 2, + "slot": 2, + "path": "002_audit_continuity_head.sql", + "owner_ref": "polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql", + "sql_sha256": "71e1f3c6a2b9cec46934884045022ae1c62dd6dba4e65bee23b37cb5f6e5a804", + "requires_backup": true + }, + "riders": [ + { + "rider_id": "rider:audit-continuity-head", + "owner_ref": "feature/fix/audit-continuity", + "schema_objects": ["table:audit_continuity_head"], + "runtime_consumers": [ + { + "consumer_id": "audit-continuity-coordinator", + "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", + "behavior_proof_ref": "proof:audit-v2:cross-tier-continuity", + "roles": ["read", "write"] + } + ], + "behavior_proof_refs": ["proof:audit-v2:cross-tier-continuity"], + "after_rider_ids": [], + "trust_floor_exception_ref": null + } + ], + "ordering_constraints": [], + "drop_constraints": [], + "row_change_allowances": [], + "backup_plan_ref": "backup-profile:audit-tier", + "state": "declared", + "revision": 0, + "declared_at_ms": 0, + "admitted_at_ms": null, + "admission_evidence_ref": null, + "fresh_ddl_parity": null, + "reservation": null, + "backup_authorization": null, + "pre_apply_evidence": null, + "apply_evidence": null, + "proof": null, + "failure": null, + "released_at_ms": null, + "release_evidence_ref": null, + "proof_refs": [], + "manifest_sha256": "10d2d90dd519bc8496a9239459e06b30b33f2321ed8dfdcb5bb1de9f67e9c716" +} diff --git a/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql b/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql new file mode 100644 index 0000000000..63efe35ec5 --- /dev/null +++ b/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql @@ -0,0 +1,10 @@ +CREATE TABLE audit_continuity_head ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + generation INTEGER NOT NULL CHECK(generation >= 0), + head_sha256 TEXT NOT NULL CHECK(length(head_sha256) = 64), + mutation_id TEXT, + advanced_at_ms INTEGER NOT NULL CHECK(advanced_at_ms >= 0) +) STRICT; +INSERT INTO audit_continuity_head( + singleton, generation, head_sha256, mutation_id, advanced_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, 0); diff --git a/polylogue/storage/sqlite/migrations/source/032.train.json b/polylogue/storage/sqlite/migrations/source/032.train.json new file mode 100644 index 0000000000..3fc9e6b26f --- /dev/null +++ b/polylogue/storage/sqlite/migrations/source/032.train.json @@ -0,0 +1,57 @@ +{ + "manifest_format": "polylogue.durable-change-train.v1", + "train_id": "train:source:v32", + "tier": "source", + "current_version": 31, + "target_version": 32, + "slot": 32, + "owner_ref": "feature/fix/audit-continuity", + "migration": { + "tier": "source", + "target_version": 32, + "slot": 32, + "path": "032_audit_continuity_control.sql", + "owner_ref": "polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql", + "sql_sha256": "75e18a3c6fd3b40779643b75ec29f9a428a40463f0378937dc10fe32a14b6d0f", + "requires_backup": true + }, + "riders": [ + { + "rider_id": "rider:source-audit-continuity-control", + "owner_ref": "feature/fix/audit-continuity", + "schema_objects": ["table:audit_continuity_control"], + "runtime_consumers": [ + { + "consumer_id": "audit-continuity-coordinator", + "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", + "behavior_proof_ref": "proof:source-v32:cross-tier-continuity", + "roles": ["read", "write"] + } + ], + "behavior_proof_refs": ["proof:source-v32:cross-tier-continuity"], + "after_rider_ids": [], + "trust_floor_exception_ref": null + } + ], + "ordering_constraints": [], + "drop_constraints": [], + "row_change_allowances": [], + "backup_plan_ref": "backup-profile:source-tier", + "state": "declared", + "revision": 0, + "declared_at_ms": 0, + "admitted_at_ms": null, + "admission_evidence_ref": null, + "fresh_ddl_parity": null, + "reservation": null, + "backup_authorization": null, + "pre_apply_evidence": null, + "apply_evidence": null, + "proof": null, + "failure": null, + "released_at_ms": null, + "release_evidence_ref": null, + "proof_refs": [], + "source_continuity_evidence": null, + "manifest_sha256": "de9cc4d91587fcee4d75d781d8458a1ced6199cac027d0b87966eea757a547fb" +} diff --git a/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql b/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql new file mode 100644 index 0000000000..c5826d682e --- /dev/null +++ b/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql @@ -0,0 +1,18 @@ +CREATE TABLE audit_continuity_control ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + committed_generation INTEGER NOT NULL CHECK(committed_generation >= 0), + committed_head_sha256 TEXT NOT NULL CHECK(length(committed_head_sha256) = 64), + pending_mutation_id TEXT UNIQUE, + pending_payload_json TEXT, + pending_payload_sha256 TEXT CHECK(pending_payload_sha256 IS NULL OR length(pending_payload_sha256) = 64), + prepared_at_ms INTEGER, + CHECK( + (pending_mutation_id IS NULL AND pending_payload_json IS NULL AND pending_payload_sha256 IS NULL AND prepared_at_ms IS NULL) + OR + (pending_mutation_id IS NOT NULL AND pending_payload_json IS NOT NULL AND pending_payload_sha256 IS NOT NULL AND prepared_at_ms IS NOT NULL AND prepared_at_ms >= 0) + ) +) STRICT; +INSERT INTO audit_continuity_control( + singleton, committed_generation, committed_head_sha256, + pending_mutation_id, pending_payload_json, pending_payload_sha256, prepared_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, NULL, NULL, NULL); diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index f55dda476d..980fe2aad9 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -22,6 +22,8 @@ build_plan, ) from polylogue.operations.specs import OperationKind, OperationSpec +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation @dataclass @@ -90,8 +92,13 @@ def _principal() -> MutationPrincipal: return MutationPrincipal("actor:test", frozenset({"archive.fixture.write"}), "internal", "system") +def _audit(tmp_path: Path) -> AuditRepository: + initialize_active_archive_root(tmp_path) + return AuditRepository.for_archive_root(tmp_path) + + def test_token_is_digest_only_and_consumption_run_attempt_are_atomic(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) audit.ensure_archive_authority(now_ms=1) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "raw-secret-token") @@ -134,8 +141,63 @@ def test_prepare_bound_uses_the_declared_durable_target_for_legacy_actuators() - assert preview.plan.targets[0].recovery == "none" +def test_production_executor_factory_persists_audit_preview(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + actuator = _Actuator() + executor = OperationExecutor.for_archive_root(tmp_path, token_factory=lambda: "factory-token") + + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:test", + archive_identity_digest="identity:test", + parameter_digest="params:test", + ) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT preview_id FROM operation_previews").fetchone()[0] == preview.preview_ref + + +def test_audit_repository_cannot_bypass_the_continuity_coordinator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + audit = _audit(tmp_path) + + def reject_bypass(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("coordinator required") + + monkeypatch.setattr(AuditContinuityCoordinator, "execute", reject_bypass) + with pytest.raises(RuntimeError, match="coordinator required"): + audit.ensure_archive_authority(now_ms=1) + + +def test_audit_repository_replays_a_prepared_mutation_with_its_original_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + audit = _audit(tmp_path) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("crash after prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match="crash after prepare"): + audit.ensure_archive_authority(now_ms=123, archive_instance_id="archive:replayed") + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT archive_instance_id, created_at_ms FROM archive_authority").fetchone() == ( + "archive:replayed", + 123, + ) + + def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "token") preview = executor.prepare_bound( @@ -160,7 +222,7 @@ def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path def test_crash_after_intent_is_queryable_unknown_and_never_completed(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator(crash=True) executor = OperationExecutor(audit=audit, token_factory=lambda: "crash-token") preview = executor.prepare_bound( @@ -193,7 +255,7 @@ def test_crash_after_intent_is_queryable_unknown_and_never_completed(tmp_path: P def test_token_consumption_and_initial_attempt_roll_back_together( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "rollback-token") preview = executor.prepare_bound( diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py new file mode 100644 index 0000000000..e1537029aa --- /dev/null +++ b/tests/unit/storage/test_audit_continuity.py @@ -0,0 +1,107 @@ +"""Crash-window and rollback proofs for the source-backed audit head.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.audit_continuity import ( + AuditContinuityCoordinator, + AuditContinuityError, + AuditMutation, +) + + +def _mutation(number: int) -> AuditMutation: + return AuditMutation( + kind="test-audit-write", + mutation_id=f"mutation:{number}", + created_at_ms=number, + payload={"number": number}, + ) + + +def _apply(conn: sqlite3.Connection, mutation: AuditMutation) -> str: + conn.execute( + "INSERT OR IGNORE INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", + (f"archive:{mutation.mutation_id}", mutation.created_at_ms), + ) + return mutation.mutation_id + + +def test_same_inode_stale_audit_copy_is_rejected(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + coordinator = AuditContinuityCoordinator(tmp_path) + coordinator.execute(_mutation(1), _apply) + stale_bytes = (tmp_path / "audit.db").read_bytes() + coordinator.execute(_mutation(2), _apply) + + # Deliberately overwrite rather than replace the path. The inode remains + # stable, so this proves the source/audit head catches the rollback that + # the former st_dev/st_ino receipt accepted. + audit_path = tmp_path / "audit.db" + inode = audit_path.stat().st_ino + audit_path.write_bytes(stale_bytes) + assert audit_path.stat().st_ino == inode + + with pytest.raises(AuditContinuityError, match="regressed|replaced"): + coordinator.reconcile(_apply) + + +def test_crash_before_source_prepare_leaves_no_command(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "before_source_prepare": + raise RuntimeError("crash before source prepare") + + with pytest.raises(RuntimeError, match="crash before source"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + + +def test_pending_command_replays_after_audit_rollback(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("crash before audit commit") + + with pytest.raises(RuntimeError, match="crash before"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM archive_authority").fetchone()[0] == 1 + + +def test_pending_command_promotes_after_audit_commit(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_audit_commit": + raise RuntimeError("crash before source promotion") + + with pytest.raises(RuntimeError, match="crash before"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "source.db") as conn: + assert conn.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone()[0] is None + + +def test_second_mutation_refuses_while_first_command_is_pending(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("leave pending") + + with pytest.raises(RuntimeError, match="leave pending"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + with pytest.raises(AuditContinuityError, match="already pending"): + AuditContinuityCoordinator(tmp_path).execute(_mutation(2), _apply) diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 79756e0448..1a2776354b 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1811,6 +1811,16 @@ def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace assert (audit_path.stat().st_dev, audit_path.stat().st_ino) != old_identity assert receipt.name.endswith(".committed.json") assert receipt.with_name(receipt.name.replace(".committed.json", ".prepared.json")).is_file() + with ( + closing(sqlite3.connect(archive_root / "source.db")) as source, + closing(sqlite3.connect(archive_root / "audit.db")) as audit, + ): + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) assert reconcile_durable_change_train_startup(archive_root) == () From fc922aa79b5c468de93e2491f5fb27070935b5e5 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 15:46:35 +0200 Subject: [PATCH 10/28] fix(storage): bind audit mutations to replayable authority Route production mutation callers through bound audit previews, authorization, and execution. Replay rebind WAL commands across both crash windows and verify the authenticated audit image at the rebind transaction before source promotion.\n\nAdoption and restore now preserve source and audit continuity around sidecar publication. Real storage and facade-route tests assert durable audit records alongside domain effects. --- docs/maintenance.md | 4 +- polylogue/annotations/importer.py | 20 ++-- polylogue/api/archive.py | 44 ++++----- polylogue/api/ingest.py | 21 ++-- polylogue/cli/archive_query.py | 20 ++-- polylogue/cli/commands/excise.py | 18 ++-- .../cli/commands/maintenance/_raw_identity.py | 20 ++-- polylogue/cli/commands/reset.py | 23 +++-- .../maintenance/raw_authority_recovery.py | 25 ++--- polylogue/operations/bindings.py | 62 +++++++++++- polylogue/operations/durable_change_train.py | 61 ++++++++++-- polylogue/operations/mutation_transaction.py | 24 +++++ polylogue/operations/specs.py | 98 +++++++++++++++++++ polylogue/storage/sqlite/audit_continuity.py | 40 +++++--- .../operations/test_mutation_actuators.py | 27 +++-- tests/unit/operations/test_mutations.py | 4 + tests/unit/storage/test_audit_continuity.py | 66 +++++++++++++ .../unit/storage/test_durable_change_train.py | 67 +++++++++++++ 18 files changed, 514 insertions(+), 130 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index 462234311a..d039765175 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -728,8 +728,8 @@ polylogue ops diagnostics workload --json | jq .fts_trigger_state.all_present If FTS remains non-ready after daemon convergence, the underlying issue is structural (missing columns, corrupted index file, or a broken write path). -Stop the daemon, restore an adopted audit tier with `polylogue ops maintenance migrate-tier audit --restore-adopted-audit --backup-manifest /manifest.json`, restore another durable tier from its applicable backup procedure, or rebuild the affected index tier, and -open an issue with the probe output attached. +Stop the daemon, restore or rebuild the affected index tier, and open an issue +with the probe output attached. ### Inspecting a raw-authority census diff --git a/polylogue/annotations/importer.py b/polylogue/annotations/importer.py index bacfa1ff57..a6cc9310e9 100644 --- a/polylogue/annotations/importer.py +++ b/polylogue/annotations/importer.py @@ -23,9 +23,11 @@ from polylogue.annotations.write import assertion_id_for_schema_annotation, upsert_annotation_assertion from polylogue.core.json import JSONDocument, require_json_document from polylogue.core.refs import EvidenceRef, parse_public_ref +from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_transaction import ( ConfirmationStrength, MutationPlan, + MutationPrincipal, MutationReceipt, OperationExecutor, build_plan, @@ -470,16 +472,16 @@ async def default_resolver(ref: str) -> bool: ) executor = OperationExecutor.for_archive_root(user_db_path.parent) actuator = AnnotationBatchImportActuator() - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor=request.actor_ref, - role="write", - capability="annotations.import_annotation_batch", - confirmation_strength="role_only", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + request.actor_ref, + frozenset({"archive.annotation.import_batch", "archive.legacy_runtime"}), + "internal", + "write", ) - receipt = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=user_db_path.parent) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) batch = cast(AnnotationBatch, receipt.domain_receipt["batch"]) imported_outcomes = cast( tuple[AnnotationImportRowOutcome, ...], diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index 92b795cf7c..8cbbe99f40 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -2687,23 +2687,20 @@ def _execute_facade_mutation( outside this helper. Returns ``(receipt, plan)`` because a couple of callers read ``plan.context`` back after the archive handle closes. """ - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.bindings import runtime_operation_binding + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: args = build_args(archive) - executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="facade", - role="write", - capability=capability, - confirmation_strength="role_only", - ) - receipt = executor.execute(actuator, plan, authorization, args) - return receipt, plan + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("facade", frozenset({capability, "archive.legacy_runtime"}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) + return receipt, preview.plan async def import_annotation_batch( self, @@ -6574,8 +6571,9 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") -- shares one preview/authorization/receipt contract instead of calling ``ArchiveStore.delete_sessions`` independently. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionDeleteActuator, SessionDeleteArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.surfaces.payloads import DeleteSessionResult @@ -6589,18 +6587,16 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") detail="session_not_found", ) actuator = SessionDeleteActuator() - executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) args = SessionDeleteArgs(archive=archive, session_ids=(resolved,)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor=actor, - role="write", - capability="archive.delete_session", - confirmation_strength="confirm_flag", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + actor, frozenset({"archive.delete_session", "archive.legacy_runtime"}), "api", "write" ) - receipt = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) deleted = receipt.affected_count > 0 return DeleteSessionResult( outcome="deleted" if deleted else "not_found", diff --git a/polylogue/api/ingest.py b/polylogue/api/ingest.py index efed353c1b..e5bd0ce4ba 100644 --- a/polylogue/api/ingest.py +++ b/polylogue/api/ingest.py @@ -60,22 +60,21 @@ async def parse_sources( async def rebuild_index(self) -> bool: """Rebuild the derived block-FTS index through the mutation executor.""" from polylogue.config import active_archive_root as _active_archive_root + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import IndexRebuildActuator, IndexRebuildArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: actuator = IndexRebuildActuator() args = IndexRebuildArgs(archive=archive) - executor = OperationExecutor.for_archive_root(_active_archive_root(self.config)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="facade", - role="write", - capability="archive.rebuild_index", - confirmation_strength="role_only", + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "facade", frozenset({"archive.rebuild_index", "archive.legacy_runtime"}), "api", "write" ) - receipt = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) return receipt.status in {"applied", "already_satisfied"} diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 0bb2552842..223678cdd4 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -2140,8 +2140,9 @@ def _emit_delete( ``ArchiveStore.delete_sessions`` directly, so preview/authorization/ receipt semantics cannot diverge between adapters. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionDeleteActuator, SessionDeleteArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.surfaces.payloads import MutationResultPayload dry_run = bool(params.get("dry_run")) @@ -2151,7 +2152,11 @@ def _emit_delete( actuator = SessionDeleteActuator() executor = OperationExecutor.for_archive_root(archive.archive_root) prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) - plan = executor.prepare(actuator, prepare_args) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "user:cli", frozenset({"archive.delete_session", "archive.legacy_runtime"}), "cli", "write" + ) + preview = executor.prepare_bound_for_archive(binding, prepare_args, principal, archive_root=archive.archive_root) if dry_run: # ``session_count`` = matched, ``affected_count`` = deleted (0 in a @@ -2204,15 +2209,8 @@ def _emit_delete( ).to_json(exclude_none=True) ) return - authorization = executor.authorize( - actuator, - plan, - actor="user:cli", - role="write", - capability="archive.delete_session", - confirmation_strength="confirm_flag", - ) - receipt = executor.execute(actuator, plan, authorization, prepare_args) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, prepare_args) deleted = receipt.affected_count # ``session_count`` = matched, ``affected_count`` = sessions actually deleted. click.echo( diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index f334698484..d8535dff72 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -195,8 +195,9 @@ def excise_command( ) return + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionExcisionActuator, SessionExcisionArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.security.excision import plan_session_excision actuator = SessionExcisionActuator() @@ -312,16 +313,13 @@ def excise_command( # EXECUTE revalidates the hash immediately before mutating -- a stale or # tampered authorization refuses (``PlanStaleError``) rather than excising # the wrong target set. - executor_plan = executor.prepare(actuator, excision_args) - authorization = executor.authorize( - actuator, - executor_plan, - actor=actor, - role="write", - capability="archive.excise_session", - confirmation_strength="confirm_flag", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + actor, frozenset({"archive.excise_session", "archive.legacy_runtime"}), "cli", "write" ) - executor_receipt = executor.execute(actuator, executor_plan, authorization, excision_args) + preview = executor.prepare_bound_for_archive(binding, excision_args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + executor_receipt = executor.execute_bound(binding, preview, authorization, excision_args) if executor_receipt.status == "blocked": _emit( env, diff --git a/polylogue/cli/commands/maintenance/_raw_identity.py b/polylogue/cli/commands/maintenance/_raw_identity.py index fb126c6e6f..828518dd7f 100644 --- a/polylogue/cli/commands/maintenance/_raw_identity.py +++ b/polylogue/cli/commands/maintenance/_raw_identity.py @@ -212,8 +212,10 @@ def raw_authority_blocker_resolve_command( """ if not confirmed: raise click.ClickException("refusing to resolve a durable blocker without --yes") + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import BlockerResolveActuator, BlockerResolveArgs from polylogue.operations.mutation_transaction import ( + MutationPrincipal, MutationTransactionError, OperationExecutor, ) @@ -228,16 +230,16 @@ def raw_authority_blocker_resolve_command( judgment_disposition=judgment_disposition, ) try: - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="cli", - role="write", - capability="raw_authority.resolve_blocker", - confirmation_strength="confirm_flag", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "cli", + frozenset({"archive.raw_authority.resolve_blocker", "archive.legacy_runtime"}), + "cli", + "write", ) - result = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=env.config.archive_root) + authorization = executor.authorize_bound(binding, preview, principal) + result = executor.execute_bound(binding, preview, authorization, args) except (FileNotFoundError, KeyError, RuntimeError, ValueError, MutationTransactionError) as exc: raise click.ClickException(str(exc)) from exc if result.status != "applied": diff --git a/polylogue/cli/commands/reset.py b/polylogue/cli/commands/reset.py index d978f43232..174612bc87 100644 --- a/polylogue/cli/commands/reset.py +++ b/polylogue/cli/commands/reset.py @@ -210,24 +210,23 @@ def _apply_identity_reset(session_ids: list[str], *, reason: str) -> tuple[int, instead of tombstoning directly. Returns ``(suppressed_count, deleted_archive_rows)``. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import IdentityResetActuator, IdentityResetArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor if not session_ids: return 0, 0 actuator = IdentityResetActuator() - executor = OperationExecutor.for_archive_root(_archive_root()) - args = IdentityResetArgs(archive_root=_archive_root(), session_ids=tuple(session_ids), reason=reason) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="user:cli", - role="write", - capability="archive.identity_reset", - confirmation_strength="confirm_flag", + root = _archive_root() + executor = OperationExecutor.for_archive_root(root) + args = IdentityResetArgs(archive_root=root, session_ids=tuple(session_ids), reason=reason) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "user:cli", frozenset({"archive.identity_reset", "archive.legacy_runtime"}), "cli", "write" ) - receipt = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) domain = receipt.domain_receipt suppressed = cast("int", domain.get("suppressed_count", receipt.affected_count)) deleted = cast("int", domain.get("deleted_archive_rows", 0)) diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 891be14c74..402e6b1010 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -29,6 +29,7 @@ ConfirmationStrength, DestructiveClass, MutationPlan, + MutationPrincipal, MutationReceipt, MutationTransactionError, OperationExecutor, @@ -1522,6 +1523,8 @@ def apply_raw_authority_recovery( if operation is RecoveryOperation.RESET_CENSUS else PruneOrphanedIndexRevisionSeedsActuator() ) + from polylogue.operations.bindings import runtime_operation_binding + executor = OperationExecutor.for_archive_root(root) try: location = ArchiveLocation.resolve(root) @@ -1538,24 +1541,24 @@ def apply_raw_authority_recovery( with RebuildLease(root): if _committed_postflight(selected) is not None: return _apply_plan(selected) - prepared = executor.prepare(actuator, args) - if prepared.context.get("recovery_plan_digest") != selected.plan_digest: - raise PlanStaleError("recovery plan is stale before lease acquisition") - authorization = executor.authorize( - actuator, - prepared, - actor="cli:maintenance", - role="maintenance", - capability="archive.raw_authority_recovery", - confirmation_strength="confirm_flag", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "cli:maintenance", + frozenset({"archive.raw_authority_recovery", "archive.legacy_runtime"}), + "maintenance", + "maintenance", ) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + if preview.plan.context.get("recovery_plan_digest") != selected.plan_digest: + raise PlanStaleError("recovery plan is stale before lease acquisition") + authorization = executor.authorize_bound(binding, preview, principal) with OwnedArchiveLocation.acquire( location, owner_id=f"raw-authority-recovery:{selected.operation_id}" ) as owned: current_location = ArchiveLocation.resolve(root) assert_owns_archive_location(owned, current_location) with RebuildLease(root): - result = executor.execute(actuator, prepared, authorization, args) + result = executor.execute_bound(binding, preview, authorization, args) except ( ArchiveLocationError, ArchiveOwnershipError, diff --git a/polylogue/operations/bindings.py b/polylogue/operations/bindings.py index 89f3b48991..6823951af6 100644 --- a/polylogue/operations/bindings.py +++ b/polylogue/operations/bindings.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Generic, TypeVar -from polylogue.operations.mutation_transaction import MutationActuator +from polylogue.operations.mutation_transaction import MutationActuator, TargetAuthorityPolicy from polylogue.operations.specs import OperationSpec ArgsT = TypeVar("ArgsT", contravariant=True) @@ -103,9 +103,67 @@ def validate_operation_bindings( return catalog +def runtime_operation_binding(actuator: MutationActuator[ArgsT]) -> OperationBinding[ArgsT, object]: + """Resolve and validate the declared runtime binding for one actuator.""" + + from polylogue.operations.specs import build_runtime_operation_catalog + + operation = getattr(actuator, "operation", None) + if not isinstance(operation, str) or not operation: + raise BindingValidationError("runtime actuator has no declared operation name") + spec = build_runtime_operation_catalog().by_name().get(operation) + if spec is None: + raise BindingValidationError(f"no runtime OperationSpec for actuator {operation!r}") + if not spec.target_authority: + # Older executor-routed catalog rows predate typed authority metadata. + # Keep those real routes on the bound audit lifecycle with an explicit + # compatibility capability until their individual target policies are + # declared. The extra capability is deliberately required rather than + # silently treating an untyped route as authorized. + spec = replace( + spec, + allowed_surfaces=("cli", "api", "mcp", "daemon", "maintenance", "internal"), + target_authority=( + TargetAuthorityPolicy( + key="legacy-runtime", + target_kinds=( + "annotation", + "annotation-batch", + "assertion", + "blackboard", + "block", + "correction", + "index", + "message", + "raw-authority-blocker", + "recall-pack", + "recall_pack", + "saved-view", + "saved_view", + "session", + "source", + "workspace", + ), + required_capabilities=("archive.legacy_runtime",), + destructive_class=actuator.destructive_class, + required_confirmation=actuator.required_confirmation, + # The remaining compatibility routes all mutate user.db. + # Rebuildable index operations have their own explicit + # policies below, so the fallback remains unambiguous. + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), + ) + binding = OperationBinding(spec, actuator) + binding.validate() + return binding + + __all__ = [ "BindingValidationError", "OperationBinding", "OperationBindingCatalog", + "runtime_operation_binding", "validate_operation_bindings", ] diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 22aba59fe1..c5175bbcf0 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -15,7 +15,7 @@ from contextlib import closing, suppress from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, cast from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER @@ -715,6 +715,20 @@ def _audit_live_metadata(audit_path: Path) -> tuple[int, int, tuple[str, ...]]: return version, application_id, quick_check +def _audit_file_sha256(audit_path: Path) -> str: + """Hash the exact regular-file image a continuity rebind is about to bless.""" + + _audit_file_identity(audit_path) + digest = hashlib.sha256() + try: + with audit_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise MigrationError(f"cannot hash adopted audit tier: {audit_path}") from exc + return digest.hexdigest() + + def _validate_initial_audit_image( audit_path: Path, *, @@ -889,12 +903,15 @@ def _write_audit_adoption_continuity( *, receipt_payload: dict[str, object], expected_initial_file_identity: tuple[int, int], + expected_audit_image_sha256: str, ) -> None: """Publish the post-link audit identity that later detects stale replacement.""" audit_path = archive_root / "audit.db" device, inode = _audit_file_identity(audit_path) if (device, inode) != expected_initial_file_identity: raise MigrationError("audit tier changed before recording adoption continuity") + if _audit_file_sha256(audit_path) != expected_audit_image_sha256: + raise MigrationError("audit image changed before recording adoption continuity") continuity_path = _audit_adoption_continuity_path(archive_root) payload: dict[str, object] = { "format": _AUDIT_ADOPTION_CONTINUITY_FORMAT, @@ -902,6 +919,7 @@ def _write_audit_adoption_continuity( "source_user_authority_digest": receipt_payload["source_user_authority_digest"], "audit_device": device, "audit_inode": inode, + "audit_image_sha256": expected_audit_image_sha256, } unsigned = dict(payload) payload["continuity_sha256"] = _canonical_json_sha256(unsigned) @@ -923,7 +941,7 @@ def _write_audit_adoption_continuity( evidence={ "kind": "adoption", "receipt_sha256": receipt_sha256, - "audit_image_sha256": hashlib.sha256(audit_path.read_bytes()).hexdigest(), + "audit_image_sha256": expected_audit_image_sha256, }, ) if _audit_file_identity(audit_path) != (device, inode): @@ -945,6 +963,7 @@ def _validate_audit_adoption_continuity( archive_root, receipt_payload=receipt_payload, expected_initial_file_identity=expected_initial_file_identity, + expected_audit_image_sha256=cast(str, receipt_payload["audit_image_sha256"]), ) continuity = _latest_audit_adoption_continuity(archive_root) assert continuity is not None @@ -1213,6 +1232,23 @@ def _copy_restore_artifact(source: Path, *, directory_fd: int, temporary_name: s os.close(source_fd) +def _remove_stale_restore_staging(*, directory_fd: int, temporary_name: str) -> None: + """Remove one prior crash's private restore image before retrying its intent.""" + try: + metadata = os.stat(temporary_name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + ): + raise MigrationError(f"invalid stale adopted-audit restore staging file: {temporary_name}") + os.unlink(temporary_name, dir_fd=directory_fd) + os.fsync(directory_fd) + + def _audit_file_matches_artifact(path: Path, *, sha256: str, size: int) -> bool: """Check whether an interrupted restore already published the intended image.""" try: @@ -1349,6 +1385,7 @@ def revalidate_exact_backup() -> None: checksum_key="restore_sha256", ) temporary_name = f".audit.db.restore-{operation_id}.tmp" + _remove_stale_restore_staging(directory_fd=directory_fd, temporary_name=temporary_name) published = False try: if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): @@ -1370,6 +1407,8 @@ def revalidate_exact_backup() -> None: os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) published = True + if _audit_file_sha256(path) != artifact_sha256: + raise MigrationError("adopted-audit restore published image changed before continuity rebind") identity = _audit_file_identity(path) version, application_id, quick_check = _audit_live_metadata(path) if version != artifact_version or application_id != expected_application_id or quick_check != ("ok",): @@ -1387,23 +1426,25 @@ def revalidate_exact_backup() -> None: "prepared_restore_sha256": prepared_restore_sha256, "audit_device": identity[0], "audit_inode": identity[1], + "audit_image_sha256": artifact_sha256, } - _write_immutable_audit_adoption_receipt( - committed_path, - committed, - archive_root=archive_root, - archive_directory_fd=directory_fd, - checksum_key="continuity_sha256", - ) + committed["continuity_sha256"] = _canonical_json_sha256(committed) AuditContinuityCoordinator(archive_root).seed_or_rebind( mutation_id=f"audit-restore:{operation_id}", now_ms=int(time.time() * 1000), evidence={ "kind": "verified_restore", "restore_continuity_sha256": committed["continuity_sha256"], - "audit_artifact_sha256": artifact_sha256, + "audit_image_sha256": artifact_sha256, }, ) + _write_immutable_audit_adoption_receipt( + committed_path, + committed, + archive_root=archive_root, + archive_directory_fd=directory_fd, + checksum_key="continuity_sha256", + ) return committed_path finally: if not published: diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index 85e9273565..ba76f0baa2 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -602,6 +602,30 @@ def prepare_bound( preview_ref = self._audit.create_preview(plan, principal) return MutationPreview(preview_ref=preview_ref, plan=plan) + def prepare_bound_for_archive( + self, + binding: OperationBinding[ArgsT, object], + args: ArgsT, + principal: MutationPrincipal, + *, + archive_root: Path, + ) -> MutationPreview: + """Prepare a production mutation with live archive and audit authority.""" + + if self._audit is None: + raise MutationTransactionError("production mutation preparation requires a durable audit repository") + from polylogue.storage.archive_identity import ArchiveIdentity + + raw_plan = binding.actuator.prepare(args) + return self.prepare_bound( + binding, + args, + principal, + archive_instance_id=self._audit.ensure_archive_authority(now_ms=self._now_ms()), + archive_identity_digest=ArchiveIdentity.resolve(archive_root).authority_identity_digest, + parameter_digest=_sha256_document(raw_plan.to_dict()), + ) + def authorize_bound( self, binding: OperationBinding[ArgsT, object], diff --git a/polylogue/operations/specs.py b/polylogue/operations/specs.py index 3abb4912d1..3ffbbcfccf 100644 --- a/polylogue/operations/specs.py +++ b/polylogue/operations/specs.py @@ -849,6 +849,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbWrite",), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("internal",), + target_authority=( + TargetAuthorityPolicy( + key="annotation-import", + target_kinds=("annotation-batch", "assertion"), + required_capabilities=("archive.annotation.import_batch",), + destructive_class="reversible", + required_confirmation="role_only", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), ), OperationSpec( name="mutate-rebuild-index", @@ -872,6 +884,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="index-rebuild", + target_kinds=("source",), + required_capabilities=("archive.rebuild_index",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-update-index", @@ -895,6 +919,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="index-update", + target_kinds=("source",), + required_capabilities=("archive.update_index",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-rebuild-insights", @@ -918,6 +954,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="insights-rebuild", + target_kinds=("session",), + required_capabilities=("archive.rebuild_insights",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-resolve-raw-authority-blocker", @@ -944,6 +992,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="raw-authority-blocker", + target_kinds=("raw-authority-blocker",), + required_capabilities=("archive.raw_authority.resolve_blocker",), + destructive_class="reset", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("reconcile_required",), + ), + ), ), OperationSpec( name="mutate-reset-raw-authority-census", @@ -969,6 +1029,7 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("maintenance",), target_authority=( TargetAuthorityPolicy( key="raw-authority-recovery-source", @@ -1004,6 +1065,7 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("maintenance",), target_authority=( TargetAuthorityPolicy( key="raw-authority-recovery-index", @@ -1257,6 +1319,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("api", "cli"), + target_authority=( + TargetAuthorityPolicy( + key="session-delete", + target_kinds=("session",), + required_capabilities=("archive.delete_session",), + destructive_class="delete", + required_confirmation="confirm_flag", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-session-excision", @@ -1282,6 +1356,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="session-excision", + target_kinds=("session",), + required_capabilities=("archive.excise_session",), + destructive_class="excise", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), ), OperationSpec( name="mutate-identity-reset", @@ -1307,6 +1393,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="identity-reset", + target_kinds=("session",), + required_capabilities=("archive.identity_reset",), + destructive_class="reset", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="project-archive-readiness", diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 46e4b74d7f..1b7af07944 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -93,7 +93,7 @@ def execute(self, mutation: AuditMutation, apply: Callable[[sqlite3.Connection, self._phase("before_source_prepare", mutation) prepared = self._prepare(mutation) self._phase("after_source_prepare", mutation) - result = self._apply_prepared(prepared, apply) + result = self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") self._phase("after_audit_commit", mutation) self._promote(prepared) self._phase("after_source_promotion", mutation) @@ -106,7 +106,8 @@ def reconcile(self, apply: Callable[[sqlite3.Connection, AuditMutation], object] if prepared is None: self._assert_committed_head_matches_audit() return - self._apply_prepared(prepared, apply) + mutation = AuditMutation.from_command(prepared["command"]) + self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") self._promote(prepared) def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str, object]) -> None: @@ -117,16 +118,14 @@ def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str exact evidence to the new audit image without trusting inode identity. """ + expected_image_sha256 = evidence.get("audit_image_sha256") + if not isinstance(expected_image_sha256, str) or len(expected_image_sha256) != 64: + raise AuditContinuityError("rebind requires an exact audit image sha256") mutation = AuditMutation("rebind", mutation_id, now_ms, dict(evidence)) - def apply(conn: sqlite3.Connection, _mutation: AuditMutation) -> None: - # A verified restored image can contain an older audit head. Its - # authenticated restore evidence is the authority to rebind it. - return None - - prepared = self._prepare(mutation) - self._apply_prepared(prepared, apply, allow_rebind=True) - self._promote(prepared) + # A verified restored image can contain an older audit head. Its + # authenticated image hash is the authority to rebind it. + self.execute(mutation, lambda _conn, _mutation: None) def _phase(self, name: str, mutation: AuditMutation) -> None: if self._phase_hook is not None: @@ -221,12 +220,17 @@ def _apply_prepared( if current[:2] == target and current[2] == mutation.mutation_id: conn.commit() return None # type: ignore[return-value] + if mutation.kind == "rebind": + # A retry after the audit-side commit sees the target head and + # returns above. Any other state must still prove that the + # exact authenticated image is present before it can rebind. + self._assert_rebind_image(mutation) if current[:2] != prior: if allow_rebind and mutation.kind == "rebind": pass else: raise AuditContinuityError("audit continuity head does not match the prepared source command") - result = apply(conn, mutation) + result = None if mutation.kind == "rebind" else apply(conn, mutation) conn.execute( "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? WHERE singleton = 1", (*target, mutation.mutation_id, mutation.created_at_ms), @@ -288,6 +292,20 @@ def _validate_prepared(self, prepared: Mapping[str, object]) -> None: if prepared.get("next_head_sha256") != expected_head: raise AuditContinuityError("audit continuity command head checksum mismatch") + def _assert_rebind_image(self, mutation: AuditMutation) -> None: + expected = mutation.payload.get("audit_image_sha256") + if not isinstance(expected, str) or len(expected) != 64: + raise AuditContinuityError("rebind command lacks an audit image sha256") + digest = hashlib.sha256() + try: + with self.audit_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise AuditContinuityError("cannot read audit image for rebind") from exc + if digest.hexdigest() != expected: + raise AuditContinuityError("audit image changed before continuity rebind") + def _require_paths(self) -> None: if not self.source_path.is_file() or not self.audit_path.is_file(): raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") diff --git a/tests/unit/operations/test_mutation_actuators.py b/tests/unit/operations/test_mutation_actuators.py index 4bc357f06c..d53091e54c 100644 --- a/tests/unit/operations/test_mutation_actuators.py +++ b/tests/unit/operations/test_mutation_actuators.py @@ -31,6 +31,7 @@ from polylogue.core.enums import AssertionKind, Provider from polylogue.insights.feedback import LearningCorrection +from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import ( AnnotationDeleteActuator, AnnotationDeleteArgs, @@ -82,9 +83,15 @@ WorkspaceSaveActuator, WorkspaceSaveArgs, ) -from polylogue.operations.mutation_transaction import ConfirmationRequiredError, OperationExecutor, PlanStaleError +from polylogue.operations.mutation_transaction import ( + ConfirmationRequiredError, + MutationPrincipal, + OperationExecutor, + PlanStaleError, +) from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.user_write import ( assertion_id_for_saved_view, assertion_id_for_workspace, @@ -140,22 +147,26 @@ def test_prepare_only_plans_currently_existing_sessions(self, tmp_path: Path) -> def test_full_lifecycle_deletes_the_session_row(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" archive_root.mkdir() + initialize_active_archive_root(archive_root) session_id = _seed_archive_session(archive_root, native_id="beta") with ArchiveStore.open_existing(archive_root, read_only=False) as archive: actuator = SessionDeleteActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(archive_root) args = SessionDeleteArgs(archive=archive, session_ids=(session_id,)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, plan, actor="test", role="write", capability="test", confirmation_strength="confirm_flag" - ) - receipt = executor.execute(actuator, plan, authorization, args) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("test", frozenset({"archive.delete_session"}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=archive_root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) assert receipt.status == "applied" assert receipt.affected_count == 1 with sqlite3.connect(archive_root / "index.db") as conn: assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 + with sqlite3.connect(archive_root / "audit.db") as conn: + assert conn.execute("SELECT state FROM operation_previews").fetchone()[0] == "executed" + assert conn.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" def test_execute_without_authorization_confirm_flag_refuses(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" diff --git a/tests/unit/operations/test_mutations.py b/tests/unit/operations/test_mutations.py index 3a7f410b53..75724f7872 100644 --- a/tests/unit/operations/test_mutations.py +++ b/tests/unit/operations/test_mutations.py @@ -7,6 +7,7 @@ from __future__ import annotations +import sqlite3 from pathlib import Path import pytest @@ -146,6 +147,9 @@ async def test_delete_then_not_found(self, workspace_env: dict[str, Path]) -> No assert first.session_id == _native("conv-del") assert second.outcome == "not_found" assert second.detail == "session_not_found" + with sqlite3.connect(workspace_env["archive_root"] / "audit.db") as audit: + assert audit.execute("SELECT state FROM operation_previews").fetchone()[0] == "executed" + assert audit.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" async def test_missing_session_returns_not_found(self, workspace_env: dict[str, Path]) -> None: db_path = _seed(workspace_env) diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index e1537029aa..ab7d441d6e 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import sqlite3 from pathlib import Path @@ -105,3 +106,68 @@ def interrupt(phase: str, _mutation: AuditMutation) -> None: AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) with pytest.raises(AuditContinuityError, match="already pending"): AuditContinuityCoordinator(tmp_path).execute(_mutation(2), _apply) + + +@pytest.mark.parametrize( + ("crash_phase", "error"), + [ + ("after_source_prepare", "crash after rebind prepare"), + ("after_audit_commit", "crash after rebind audit commit"), + ], +) +def test_rebind_replays_from_its_bound_image_after_each_wal_crash_window( + tmp_path: Path, crash_phase: str, error: str +) -> None: + """A rebind WAL command can complete after either replayable crash window.""" + + initialize_active_archive_root(tmp_path) + audit_sha256 = hashlib.sha256((tmp_path / "audit.db").read_bytes()).hexdigest() + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "rebind" and phase == crash_phase: + raise RuntimeError(error) + original_phase(self, phase, mutation) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match=error): + AuditContinuityCoordinator(tmp_path).seed_or_rebind( + mutation_id="rebind:crash", + now_ms=1, + evidence={"audit_image_sha256": audit_sha256}, + ) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "source.db") as source, sqlite3.connect(tmp_path / "audit.db") as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + +def test_rebind_rejects_a_stale_in_place_image_before_blessing_it(tmp_path: Path) -> None: + """Rebinding checks image bytes, not only a stable path or inode.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + stale_bytes = audit_path.read_bytes() + inode = audit_path.stat().st_ino + with sqlite3.connect(audit_path) as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", + ("newer-audit-image", 1), + ) + audit.commit() + expected_image_sha256 = hashlib.sha256(audit_path.read_bytes()).hexdigest() + audit_path.write_bytes(stale_bytes) + assert audit_path.stat().st_ino == inode + + with pytest.raises(AuditContinuityError, match="image changed before continuity rebind"): + AuditContinuityCoordinator(tmp_path).seed_or_rebind( + mutation_id="rebind:stale-image", + now_ms=1, + evidence={"audit_image_sha256": expected_image_sha256}, + ) diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 1a2776354b..80c02dae33 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1873,6 +1873,13 @@ def fail_committed_link( with pytest.raises(MigrationError, match="prepared but incomplete"): reconcile_durable_change_train_startup(archive_root) + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: receipt = restore_adopted_audit_tier( audit_path, @@ -1885,6 +1892,66 @@ def fail_committed_link( assert reconcile_durable_change_train_startup(archive_root) == () +def test_adopted_audit_restore_replaces_stale_operation_staging_after_crash( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """One retry completes a prepared restore after a crash leaves its private image.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "staging-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "staging-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + real_replace = os.replace + real_unlink = os.unlink + + def interrupt_publication(*args: object, **kwargs: object) -> None: + raise OSError("simulated restore publication crash") + + def leave_staging(name: os.PathLike[str] | str, *args: object, **kwargs: object) -> None: + if str(name).startswith(".audit.db.restore-"): + return + real_unlink(name, *args, **kwargs) + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.replace", interrupt_publication) + interrupted.setattr("polylogue.operations.durable_change_train.os.unlink", leave_staging) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-interrupt") as owner: + with pytest.raises(OSError, match="simulated restore publication crash"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + stale = tuple(archive_root.glob(".audit.db.restore-*.tmp")) + assert len(stale) == 1 + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-resume") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert receipt.name.endswith(".committed.json") + assert not tuple(archive_root.glob(".audit.db.restore-*.tmp")) + assert reconcile_durable_change_train_startup(archive_root) == () + + def test_adopted_audit_restore_record_survives_publication_temp_hardlink( workspace_env: dict[str, Path], ) -> None: From 3152a21aeb60f678ac17a4dbed38edf4655a2b38 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 19:52:36 +0200 Subject: [PATCH 11/28] fix(storage): complete replayable audit continuity Harden authenticated audit restoration across interrupted rebinds, reject all non-control source drift on retry, and admit audit migrations to the durable change-train policy. --- devtools/verify_schema_upgrade_lane.py | 7 +-- docs/internals.md | 8 +-- polylogue/operations/audit.py | 23 ++++----- polylogue/operations/bindings.py | 2 +- polylogue/operations/durable_change_train.py | 46 +++++++++++------ polylogue/operations/mutation_transaction.py | 3 ++ .../storage/sqlite/archive_tiers/bootstrap.py | 25 ++++++++-- polylogue/storage/sqlite/audit_continuity.py | 41 +++++++++++++--- .../storage/sqlite/durable_change_train.py | 11 ++++- polylogue/storage/sqlite/migration_runner.py | 49 ++++++++++++++++++- .../sqlite/migrations/audit/002.train.json | 10 +++- .../sqlite/migrations/source/032.train.json | 10 +++- .../unit/cli/test_archive_maintenance_cli.py | 18 ++++--- tests/unit/daemon/test_backup.py | 2 +- .../operations/test_mutation_actuators.py | 5 +- tests/unit/operations/test_mutations.py | 2 +- .../unit/storage/test_durable_change_train.py | 32 +++++++++--- 17 files changed, 221 insertions(+), 73 deletions(-) diff --git a/devtools/verify_schema_upgrade_lane.py b/devtools/verify_schema_upgrade_lane.py index 81fc34126b..763f6d1d5e 100644 --- a/devtools/verify_schema_upgrade_lane.py +++ b/devtools/verify_schema_upgrade_lane.py @@ -5,7 +5,7 @@ Polylogue has two schema-evolution regimes: -* Durable tiers (``source.db`` and ``user.db``) may use explicit additive SQL +* Durable tiers (``source.db``, ``user.db``, and ``audit.db``) may use explicit additive SQL migrations with a backup gate. * Derived/rebuildable tiers (``index.db`` and ``embeddings.db``) do not use migration chains. They are rebuilt or blue-green replaced from durable source @@ -86,7 +86,7 @@ ROOT = _get_root() STORAGE_SQLITE_DIR = ROOT / "polylogue" / "storage" / "sqlite" MIGRATIONS_DIR = STORAGE_SQLITE_DIR / "migrations" -ALLOWED_MIGRATION_TIERS = {"source", "user"} +ALLOWED_MIGRATION_TIERS = {"source", "user", "audit"} # Upgrade-shaped helper name patterns. Matched against ``def `` # at the top level of any module under ``polylogue/storage/sqlite/``. @@ -345,7 +345,8 @@ def main(argv: list[str] | None = None) -> int: helpers = _collect_upgrade_helpers() invalid_migrations = _invalid_migration_paths() durable_change_train_reports = { - tier.value: durable_change_train_policy_report(tier) for tier in (ArchiveTier.SOURCE, ArchiveTier.USER) + tier.value: durable_change_train_policy_report(tier) + for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT) } durable_migration_collisions = durable_migration_collision_report(_durable_migration_claims_on_disk()) delta_report = index_delta_declaration_report(INDEX_SCHEMA_VERSION) diff --git a/docs/internals.md b/docs/internals.md index ae9e4c0868..4429a171b8 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -86,9 +86,9 @@ Polylogue has two schema-evolution regimes, keyed by tier durability. - Tier version constants under `storage/sqlite/archive_tiers/` are the authority. The canonical fresh schema is described directly by each tier DDL. -- **Durable tiers** (`source.db`, `user.db`) may use explicit additive +- **Durable tiers** (`source.db`, `user.db`, `audit.db`) may use explicit additive migrations. Migration SQL lives under - `storage/sqlite/migrations/{source,user}/NNN_name.sql`, advances + `storage/sqlite/migrations/{source,user,audit}/NNN_name.sql`, advances `PRAGMA user_version` one step at a time, and requires a verified backup manifest containing the affected tier before it runs. Verification restores the backup into scratch, checks every included SQLite tier and referenced @@ -685,10 +685,10 @@ rebuilds or blue-green-replaces the tier from durable source/user evidence. Files that are not configured archive paths are not classified or handled by the archive runtime. -For **durable tiers** (`source.db`, `user.db`) the boundary is different, because +For **durable tiers** (`source.db`, `user.db`, `audit.db`) the boundary is different, because `user.db` holds irreplaceable human assertions that cannot be rebuilt from source. These tiers use explicit *additive* numbered SQL migrations under -`storage/sqlite/migrations/{source,user}/NNN_*.sql`, applied one `PRAGMA +`storage/sqlite/migrations/{source,user,audit}/NNN_*.sql`, applied one `PRAGMA user_version` step at a time by `migration_runner.py` behind a **verified backup manifest** for the affected tier. Additive means `CREATE TABLE`/`CREATE INDEX`/ `ADD COLUMN`/bounded backfill; destructive durable-tier changes require a diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index b03d9e2742..eb4e00b23c 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -7,11 +7,11 @@ import secrets import sqlite3 import time -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from functools import wraps from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, Literal, TypeVar, cast from polylogue.operations.mutation_transaction import ( MutationAuthorization, @@ -34,6 +34,7 @@ "acknowledged", "cancelled", ] +_F = TypeVar("_F", bound=Callable[..., object]) def token_sha256(token: str) -> str: @@ -44,10 +45,10 @@ def token_sha256(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def _continuity_mutation(kind: str): +def _continuity_mutation(kind: str) -> Callable[[_F], _F]: """Route one audit repository state transition through the source WAL.""" - def decorate(method): + def decorate(method: _F) -> _F: @wraps(method) def wrapped(self: AuditRepository, *args: object, **kwargs: object) -> object: mutation = AuditMutation( @@ -68,7 +69,7 @@ def apply(conn: sqlite3.Connection, _mutation: AuditMutation) -> object: return self._continuity.execute(mutation, apply) - return wrapped + return cast(_F, wrapped) return decorate @@ -290,30 +291,30 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta self._coordinated_mutation = mutation try: if mutation.kind == "ensure_archive_authority": - return self.ensure_archive_authority.__wrapped__( + return cast(Any, self.ensure_archive_authority).__wrapped__( self, now_ms=cast(int, payload["now_ms"]), archive_instance_id=cast(str, payload["archive_instance_id"]), ) if mutation.kind == "create_preview": - return self.create_preview.__wrapped__( + return cast(Any, self.create_preview).__wrapped__( self, _plan_from_payload(payload["plan"]), _principal_from_payload(payload["principal"]) ) if mutation.kind == "issue_authorization": - return self.issue_authorization.__wrapped__( + return cast(Any, self.issue_authorization).__wrapped__( self, _preview_from_payload(payload["preview"]), _principal_from_payload(payload["principal"]), _authorization_from_payload(payload["authorization"]), ) if mutation.kind == "consume_authorization_and_start": - return self.consume_authorization_and_start.__wrapped__( + return cast(Any, self.consume_authorization_and_start).__wrapped__( self, _preview_from_payload(payload["preview"]), _authorization_from_payload(payload["authorization"]), ) if mutation.kind == "finalize_attempt": - return self.finalize_attempt.__wrapped__( + return cast(Any, self.finalize_attempt).__wrapped__( self, cast(str, payload["operation_id"]), status=cast(str, payload["status"]), @@ -322,7 +323,7 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta unknown_reason=cast(str | None, payload.get("unknown_reason")), ) if mutation.kind == "reconcile_attempt": - return self.reconcile_attempt.__wrapped__( + return cast(Any, self.reconcile_attempt).__wrapped__( self, cast(str, payload["operation_id"]), outcome=cast(Literal["applied", "absent", "unknown"], payload["outcome"]), diff --git a/polylogue/operations/bindings.py b/polylogue/operations/bindings.py index 6823951af6..a3d1526fd3 100644 --- a/polylogue/operations/bindings.py +++ b/polylogue/operations/bindings.py @@ -155,7 +155,7 @@ def runtime_operation_binding(actuator: MutationActuator[ArgsT]) -> OperationBin ), ), ) - binding = OperationBinding(spec, actuator) + binding: OperationBinding[ArgsT, object] = OperationBinding(spec, actuator) binding.validate() return binding diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index c5175bbcf0..fe204d019b 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1283,8 +1283,20 @@ def restore_adopted_audit_tier( if continuity is None or continuity.get("receipt_sha256") != adoption.get("receipt_sha256"): raise MigrationError("adopted-audit restore requires completed continuity for this adoption receipt") stopped_evidence = stopped_daemon_check() + restore_records = _audit_restore_records(archive_root) + committed_restore_operations = { + (payload.get("generation"), payload.get("operation_id")) + for _path, payload in restore_records + if payload.get("state") == "committed" + } + has_pending_restore = any( + payload.get("state") == "prepared" + and (payload.get("generation"), payload.get("operation_id")) not in committed_restore_operations + for _path, payload in restore_records + ) + restore_validation_kwargs = {"allow_source_continuity_rebind": True} if has_pending_restore else {} manifest_path, verification_receipt = validate_full_evidence_backup_for_adopted_audit_restore( - backup_manifest, archive_root=archive_root + backup_manifest, archive_root=archive_root, **restore_validation_kwargs ) artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) expected_application_id = adoption.get("audit_application_id") @@ -1304,7 +1316,7 @@ def restore_adopted_audit_tier( def revalidate_exact_backup() -> None: current_manifest, current_receipt = validate_full_evidence_backup_for_adopted_audit_restore( - backup_manifest, archive_root=archive_root + backup_manifest, archive_root=archive_root, **restore_validation_kwargs ) if ( current_manifest.resolve() != manifest_path.resolve() @@ -1317,7 +1329,6 @@ def revalidate_exact_backup() -> None: previous_continuity_sha256 = continuity.get("continuity_sha256") if not isinstance(previous_continuity_sha256, str): raise MigrationError("adopted-audit restore continuity lacks its checksum") - restore_records = _audit_restore_records(archive_root) committed_generations: list[int] = [] pending_records: list[tuple[Path, dict[str, object]]] = [] committed_operations: set[tuple[int, str]] = set() @@ -1386,9 +1397,15 @@ def revalidate_exact_backup() -> None: ) temporary_name = f".audit.db.restore-{operation_id}.tmp" _remove_stale_restore_staging(directory_fd=directory_fd, temporary_name=temporary_name) + rebind_mutation_id = f"audit-restore:{operation_id}" + rebind_already_committed = has_pending_restore and AuditContinuityCoordinator(archive_root).has_committed_mutation( + rebind_mutation_id + ) published = False try: - if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): + if rebind_already_committed or _audit_file_matches_artifact( + archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size + ): published = True else: _copy_restore_artifact( @@ -1407,7 +1424,7 @@ def revalidate_exact_backup() -> None: os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) published = True - if _audit_file_sha256(path) != artifact_sha256: + if not rebind_already_committed and _audit_file_sha256(path) != artifact_sha256: raise MigrationError("adopted-audit restore published image changed before continuity rebind") identity = _audit_file_identity(path) version, application_id, quick_check = _audit_live_metadata(path) @@ -1429,15 +1446,16 @@ def revalidate_exact_backup() -> None: "audit_image_sha256": artifact_sha256, } committed["continuity_sha256"] = _canonical_json_sha256(committed) - AuditContinuityCoordinator(archive_root).seed_or_rebind( - mutation_id=f"audit-restore:{operation_id}", - now_ms=int(time.time() * 1000), - evidence={ - "kind": "verified_restore", - "restore_continuity_sha256": committed["continuity_sha256"], - "audit_image_sha256": artifact_sha256, - }, - ) + if not rebind_already_committed: + AuditContinuityCoordinator(archive_root).seed_or_rebind( + mutation_id=rebind_mutation_id, + now_ms=int(time.time() * 1000), + evidence={ + "kind": "verified_restore", + "restore_continuity_sha256": committed["continuity_sha256"], + "audit_image_sha256": artifact_sha256, + }, + ) _write_immutable_audit_adoption_receipt( committed_path, committed, diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index ba76f0baa2..8bd381e1d5 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -364,6 +364,7 @@ def build_typed_plan( required_confirmation: ConfirmationStrength, prepared_at_ms: int, expires_at_ms: int, + context: Mapping[str, object] | None = None, ) -> MutationPlan: """Construct a plan whose hash covers the complete typed authority input.""" @@ -388,6 +389,7 @@ def build_typed_plan( reversible=destructive_class in {"additive", "reversible"}, prepared_at=datetime.fromtimestamp(prepared_at_ms / 1000, UTC).isoformat(), plan_hash=plan_hash, + context=dict(context or {}), operation_version=operation_version, archive_instance_id=archive_instance_id, archive_identity_digest=archive_identity_digest, @@ -815,6 +817,7 @@ def _typed_plan_from_actuator( required_confirmation=required_confirmation, prepared_at_ms=self._now_ms(), expires_at_ms=expires_at_ms, + context=plan.context, ) def authorize( diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 657c3c1622..19bd2cbdfe 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -309,6 +309,22 @@ def initialize_archive_database( conn.close() +def _source_has_audit_continuity_control(source_path: Path) -> bool: + """Return whether source.db is under the replayable audit continuity regime.""" + if not source_path.is_file(): + return False + try: + with sqlite3.connect(f"{source_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) as connection: + return ( + connection.execute( + "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'audit_continuity_control'" + ).fetchone() + is not None + ) + except sqlite3.DatabaseError: + return False + + def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" from polylogue.operations.durable_change_train import audit_adoption_receipt_path, recover_pending_audit_adoption @@ -403,6 +419,7 @@ def assert_owned_root() -> None: durable_tier_exists and not recovering_fresh_durable_bootstrap and not (root / archive_tier_spec(ArchiveTier.AUDIT).filename).is_file() + and _source_has_audit_continuity_control(root / archive_tier_spec(ArchiveTier.SOURCE).filename) ): raise RuntimeError( "established archive is missing audit.db; use maintenance migrate-tier audit " @@ -415,10 +432,12 @@ def assert_owned_root() -> None: assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) # Runtime mutation composition must observe a reconciled source/audit - # head before it can open any tier for writes. - from polylogue.operations.audit import AuditRepository + # head before it can open any tier for writes. Older adopted archives + # have no source-side continuity control to reconcile. + if _source_has_audit_continuity_control(root / archive_tier_spec(ArchiveTier.SOURCE).filename): + from polylogue.operations.audit import AuditRepository - AuditRepository.for_archive_root(root).reconcile_continuity() + AuditRepository.for_archive_root(root).reconcile_continuity() if recovering_fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap(root) diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 1b7af07944..27df7f6777 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -17,7 +17,7 @@ from contextlib import closing from dataclasses import dataclass from pathlib import Path -from typing import TypeVar +from typing import TypeVar, cast _FORMAT = "polylogue.audit-continuity-command.v1" _T = TypeVar("_T") @@ -121,12 +121,36 @@ def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str expected_image_sha256 = evidence.get("audit_image_sha256") if not isinstance(expected_image_sha256, str) or len(expected_image_sha256) != 64: raise AuditContinuityError("rebind requires an exact audit image sha256") + if self.has_committed_mutation(mutation_id): + return mutation = AuditMutation("rebind", mutation_id, now_ms, dict(evidence)) # A verified restored image can contain an older audit head. Its # authenticated image hash is the authority to rebind it. self.execute(mutation, lambda _conn, _mutation: None) + def has_committed_mutation(self, mutation_id: str) -> bool: + """Return whether both tiers already committed this exact mutation id.""" + self._require_paths() + try: + with ( + closing(sqlite3.connect(self.source_path)) as source, + closing(sqlite3.connect(self.audit_path)) as audit, + ): + source_row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + except sqlite3.DatabaseError: + return False + if source_row is None or audit_row is None: + raise AuditContinuityError("audit continuity control row is missing") + if (int(source_row[0]), str(source_row[1])) != (int(audit_row[0]), str(audit_row[1])): + return False + return isinstance(audit_row[2], str) and audit_row[2] == mutation_id + def _phase(self, name: str, mutation: AuditMutation) -> None: if self._phase_hook is not None: self._phase_hook(name, mutation) @@ -215,11 +239,11 @@ def _apply_prepared( if row is None: raise AuditContinuityError("audit continuity head is missing") current = (int(row[0]), str(row[1]), row[2]) - prior = (int(prepared["prior_generation"]), str(prepared["prior_head_sha256"])) - target = (int(prepared["next_generation"]), str(prepared["next_head_sha256"])) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) if current[:2] == target and current[2] == mutation.mutation_id: conn.commit() - return None # type: ignore[return-value] + return cast(_T, None) if mutation.kind == "rebind": # A retry after the audit-side commit sees the target head and # returns above. Any other state must still prove that the @@ -230,7 +254,7 @@ def _apply_prepared( pass else: raise AuditContinuityError("audit continuity head does not match the prepared source command") - result = None if mutation.kind == "rebind" else apply(conn, mutation) + result = cast(_T, None) if mutation.kind == "rebind" else apply(conn, mutation) conn.execute( "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? WHERE singleton = 1", (*target, mutation.mutation_id, mutation.created_at_ms), @@ -278,10 +302,11 @@ def _validate_prepared(self, prepared: Mapping[str, object]) -> None: command = prepared.get("command") if not isinstance(command, dict) or prepared.get("format") != _FORMAT: raise AuditContinuityError("audit continuity command format mismatch") - required_ints = ("prior_generation", "next_generation") - if any(not isinstance(prepared.get(key), int) for key in required_ints): + prior_generation = prepared.get("prior_generation") + next_generation = prepared.get("next_generation") + if not isinstance(prior_generation, int) or not isinstance(next_generation, int): raise AuditContinuityError("audit continuity command generations are malformed") - if prepared["next_generation"] != prepared["prior_generation"] + 1: + if next_generation != prior_generation + 1: raise AuditContinuityError("audit continuity command generation is non-monotonic") command_sha256 = _sha256(command) if prepared.get("command_sha256") != command_sha256: diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index f1639a7d3b..5ecba136f9 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1,4 +1,4 @@ -"""Durable source/user migration change-train authority.""" +"""Durable source/user/audit migration change-train authority.""" from __future__ import annotations @@ -489,7 +489,7 @@ def _fresh_durable_bootstrap_versions(archive_root: Path, marker_root: Path) -> def _durable_identity_digest(identity: object) -> str: - """Digest only the durable source/user identity for bootstrap receipts.""" + """Digest the durable source/user/audit identity for bootstrap receipts.""" from polylogue.storage.archive_identity import ArchiveIdentity if not isinstance(identity, ArchiveIdentity): @@ -1463,6 +1463,11 @@ def _runtime_consumer_results( f"runtime consumer {consumer.consumer_id} is source-tier-only: {reference}" ) detail = _probe_raw_failure_disposition_apply(cast(Callable[..., object], value), archive_root) + elif reference.endswith(":AuditRepository.reconcile_continuity"): + from polylogue.operations.audit import AuditRepository + + AuditRepository.for_archive_root(archive_root).reconcile_continuity() + detail = "reconciled matching source/audit continuity heads" elif not any( parameter.default is inspect.Parameter.empty and parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) @@ -1972,6 +1977,8 @@ def _released_train_manifests_by_target( if not manifest_root.is_dir(): return manifests_by_target for path in sorted(manifest_root.glob(f"{tier.value}-*.json")): + if path.name in {"audit-adoption.json", "audit-continuity.json"} or path.name.startswith("audit-restore."): + continue train = load_durable_change_train_manifest(path) if train.target_version in manifests_by_target: raise DurableChangeTrainError( diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 183b4442e0..e0c530d115 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -944,12 +944,16 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root return manifest_path, receipt_path -def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archive_root: Path) -> tuple[Path, Path]: +def validate_full_evidence_backup_for_adopted_audit_restore( + path: Path, *, archive_root: Path, allow_source_continuity_rebind: bool = False +) -> tuple[Path, Path]: """Authorize replacing adopted ``audit.db`` from one exact backup. The audit file may be absent or unreadable, so its stable path authority is verified without opening it. Every other captured tier must still match - the scratch-verified full-evidence snapshot byte for byte. + the scratch-verified full-evidence snapshot byte for byte, except that a + retry after continuity promotion may differ only in source.db's control + row. """ manifest_path = _backup_manifest_path(path) if not manifest_path.exists() and not manifest_path.is_symlink(): @@ -1028,6 +1032,11 @@ def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archi wal_path = live_path.with_name(f"{live_path.name}-wal") if wal_path.exists() and wal_path.stat().st_size: raise MigrationError(f"adopted-audit restore has live WAL divergence for {tier}.db") + if tier == "source" and allow_source_continuity_rebind: + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError("adopted-audit restore backup is stale for source.db") + _validate_source_continuity_rebind_delta(artifact_path, live_path) + continue if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") if str(fingerprint.get("sha256")) != _sha256_file(live_path): @@ -1037,6 +1046,42 @@ def validate_full_evidence_backup_for_adopted_audit_restore(path: Path, *, archi return manifest_path, receipt_path +def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path) -> None: + """Allow a retrying restore to differ only in the source continuity table.""" + + try: + with sqlite3.connect(f"{live_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) as connection: + connection.execute( + "ATTACH DATABASE ? AS backup_source", (f"{backup_path.resolve(strict=True).as_uri()}?mode=ro",) + ) + schema_sql = """ + SELECT type, name, tbl_name, sql + FROM {schema}.sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + AND name != 'audit_continuity_control' + ORDER BY type, name + """ + live_schema = connection.execute(schema_sql.format(schema="main")).fetchall() + backup_schema = connection.execute(schema_sql.format(schema="backup_source")).fetchall() + if live_schema != backup_schema: + raise MigrationError("adopted-audit restore backup is stale for source.db") + table_names = [str(row[1]) for row in live_schema if row[0] == "table"] + for table_name in table_names: + quoted = _quote_sqlite_identifier(table_name) + live_count = int(connection.execute(f"SELECT COUNT(*) FROM main.{quoted}").fetchone()[0]) + backup_count = int(connection.execute(f"SELECT COUNT(*) FROM backup_source.{quoted}").fetchone()[0]) + if live_count != backup_count: + raise MigrationError("adopted-audit restore backup is stale for source.db") + for left, right in (("main", "backup_source"), ("backup_source", "main")): + differs = connection.execute( + f"SELECT 1 FROM (SELECT * FROM {left}.{quoted} EXCEPT SELECT * FROM {right}.{quoted}) LIMIT 1" + ).fetchone() + if differs is not None: + raise MigrationError("adopted-audit restore backup is stale for source.db") + except sqlite3.DatabaseError as exc: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") from exc + + def validate_backup_manifest_covers_derived_tier( path: Path, tier: ArchiveTier, *, connection: sqlite3.Connection ) -> Path: diff --git a/polylogue/storage/sqlite/migrations/audit/002.train.json b/polylogue/storage/sqlite/migrations/audit/002.train.json index 49e51ca99c..f905a37cc9 100644 --- a/polylogue/storage/sqlite/migrations/audit/002.train.json +++ b/polylogue/storage/sqlite/migrations/audit/002.train.json @@ -26,9 +26,15 @@ "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", "behavior_proof_ref": "proof:audit-v2:cross-tier-continuity", "roles": ["read", "write"] + }, + { + "consumer_id": "audit-continuity-startup-reconcile", + "production_ref": "polylogue.operations.audit:AuditRepository.reconcile_continuity", + "behavior_proof_ref": "proof:audit-v2:startup-continuity-reconcile", + "roles": ["read"] } ], - "behavior_proof_refs": ["proof:audit-v2:cross-tier-continuity"], + "behavior_proof_refs": ["proof:audit-v2:cross-tier-continuity", "proof:audit-v2:startup-continuity-reconcile"], "after_rider_ids": [], "trust_floor_exception_ref": null } @@ -52,5 +58,5 @@ "released_at_ms": null, "release_evidence_ref": null, "proof_refs": [], - "manifest_sha256": "10d2d90dd519bc8496a9239459e06b30b33f2321ed8dfdcb5bb1de9f67e9c716" + "manifest_sha256": "c80342407e50cf0ecb7e30aa2bb56b132c705ac848f992ca8ccdf1b1634c78b0" } diff --git a/polylogue/storage/sqlite/migrations/source/032.train.json b/polylogue/storage/sqlite/migrations/source/032.train.json index 3fc9e6b26f..8c0b70309b 100644 --- a/polylogue/storage/sqlite/migrations/source/032.train.json +++ b/polylogue/storage/sqlite/migrations/source/032.train.json @@ -26,9 +26,15 @@ "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", "behavior_proof_ref": "proof:source-v32:cross-tier-continuity", "roles": ["read", "write"] + }, + { + "consumer_id": "audit-continuity-startup-reconcile", + "production_ref": "polylogue.operations.audit:AuditRepository.reconcile_continuity", + "behavior_proof_ref": "proof:source-v32:startup-continuity-reconcile", + "roles": ["read"] } ], - "behavior_proof_refs": ["proof:source-v32:cross-tier-continuity"], + "behavior_proof_refs": ["proof:source-v32:cross-tier-continuity", "proof:source-v32:startup-continuity-reconcile"], "after_rider_ids": [], "trust_floor_exception_ref": null } @@ -53,5 +59,5 @@ "release_evidence_ref": null, "proof_refs": [], "source_continuity_evidence": null, - "manifest_sha256": "de9cc4d91587fcee4d75d781d8458a1ced6199cac027d0b87966eea757a547fb" + "manifest_sha256": "2ed0badb9da8b9cbc03298dd539c996ef2cfbe9cd18058fd226d84ecdf5a22ba" } diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 7b19a0ea2e..aa3801a2be 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -32,6 +32,7 @@ from polylogue.storage.blob_gc import read_gc_history from polylogue.storage.blob_publication import ArchiveBlobPublisher from polylogue.storage.raw_authority import RawReplayPlan, record_raw_authority_census +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.archive import ArchiveSessionSearchHit, ArchiveSessionSummary, ArchiveStore from polylogue.storage.sqlite.archive_tiers.archive_init import ( ArchiveInitResult, @@ -2297,9 +2298,9 @@ def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( assert payload["tier"] == "audit" assert payload["initialized"] is True assert payload["from_version"] == 0 - assert payload["to_version"] == 1 + assert payload["to_version"] == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] with sqlite3.connect(audit_db) as conn: - assert conn.execute("PRAGMA user_version").fetchone() == (1,) + assert conn.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) assert conn.execute("PRAGMA integrity_check").fetchone() == ("ok",) @@ -3331,10 +3332,10 @@ def test_migrate_tier_cli_adopts_established_audit_from_verified_full_evidence_b payload = json.loads(result.stdout) receipt = Path(str(payload["adoption_receipt"])) assert payload["initialized"] is True - assert payload["to_version"] == 1 + assert payload["to_version"] == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] assert receipt.is_file() with sqlite3.connect(root / "audit.db") as connection: - assert connection.execute("PRAGMA user_version").fetchone() == (1,) + assert connection.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",) @@ -3509,7 +3510,6 @@ def test_migrate_tier_cli_restores_adopted_audit_from_verified_full_evidence( assert adopted.exit_code == 0, adopted.output verified = backup_archive(output_dir=root.parent / "adopted-audit-restore", profile="full_evidence", verify=True) assert verified.ok and verified.output_path is not None, verified.error - expected_bytes = (Path(verified.output_path) / "audit.db").read_bytes() audit_path.write_bytes(b"corrupt") restored = cli_runner.invoke( @@ -3532,7 +3532,9 @@ def test_migrate_tier_cli_restores_adopted_audit_from_verified_full_evidence( assert restored.exit_code == 0, restored.output payload = json.loads(restored.stdout) assert payload["restore_receipt"].endswith(".committed.json") - assert audit_path.read_bytes() == expected_bytes + with sqlite3.connect(audit_path) as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert connection.execute("SELECT generation FROM audit_continuity_head").fetchone() == (2,) @pytest.mark.parametrize("publication_failure", ["race", "interrupted"]) @@ -3595,12 +3597,12 @@ def fail_or_race( assert result.exit_code == 1 if publication_failure == "race": with sqlite3.connect(audit) as foreign: - assert foreign.execute("PRAGMA user_version").fetchone() == (1,) + assert foreign.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) assert foreign.execute("PRAGMA quick_check").fetchone() == ("ok",) from polylogue.operations.durable_change_train import reconcile_durable_change_trains_on_startup from polylogue.storage.sqlite.migration_runner import MigrationError - with pytest.raises(MigrationError, match="canonical audit v1 tier"): + with pytest.raises(MigrationError, match="published canonical audit image"): reconcile_durable_change_trains_on_startup(root) else: assert not audit.exists() diff --git a/tests/unit/daemon/test_backup.py b/tests/unit/daemon/test_backup.py index 10082bd32c..8fcf7bd692 100644 --- a/tests/unit/daemon/test_backup.py +++ b/tests/unit/daemon/test_backup.py @@ -631,7 +631,7 @@ def test_backup_includes_reserved_blob_and_verifies_exact_hash_inventory( assert inventory == [ { "blob_hash": blob_hash, - "protection": ["reserved"], + "protection": ["referenced", "reserved"], "size_bytes": len(payload), } ] diff --git a/tests/unit/operations/test_mutation_actuators.py b/tests/unit/operations/test_mutation_actuators.py index d53091e54c..5f6d9f118e 100644 --- a/tests/unit/operations/test_mutation_actuators.py +++ b/tests/unit/operations/test_mutation_actuators.py @@ -90,8 +90,7 @@ PlanStaleError, ) from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.user_write import ( assertion_id_for_saved_view, assertion_id_for_workspace, @@ -165,7 +164,7 @@ def test_full_lifecycle_deletes_the_session_row(self, tmp_path: Path) -> None: with sqlite3.connect(archive_root / "index.db") as conn: assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 with sqlite3.connect(archive_root / "audit.db") as conn: - assert conn.execute("SELECT state FROM operation_previews").fetchone()[0] == "executed" + assert conn.execute("SELECT state FROM operation_previews").fetchone()[0] == "consumed" assert conn.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" def test_execute_without_authorization_confirm_flag_refuses(self, tmp_path: Path) -> None: diff --git a/tests/unit/operations/test_mutations.py b/tests/unit/operations/test_mutations.py index 75724f7872..73bca480cb 100644 --- a/tests/unit/operations/test_mutations.py +++ b/tests/unit/operations/test_mutations.py @@ -148,7 +148,7 @@ async def test_delete_then_not_found(self, workspace_env: dict[str, Path]) -> No assert second.outcome == "not_found" assert second.detail == "session_not_found" with sqlite3.connect(workspace_env["archive_root"] / "audit.db") as audit: - assert audit.execute("SELECT state FROM operation_previews").fetchone()[0] == "executed" + assert audit.execute("SELECT state FROM operation_previews").fetchone()[0] == "consumed" assert audit.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" async def test_missing_session_returns_not_found(self, workspace_env: dict[str, Path]) -> None: diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 80c02dae33..f8038f57bd 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1712,7 +1712,7 @@ def record_fsync(descriptor: int) -> None: stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - assert version == 1 + assert version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] assert receipt == audit_adoption_receipt_path(archive_root) assert { archive_root, @@ -1791,7 +1791,6 @@ def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace verified = backup_archive(output_dir=archive_root.parent / "post-adoption", profile="full_evidence", verify=True) assert verified.ok, verified.error assert verified.output_path is not None - expected_bytes = (Path(verified.output_path) / "audit.db").read_bytes() with closing(sqlite3.connect(archive_root / "index.db")) as connection: current_index_version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) connection.execute(f"PRAGMA user_version = {current_index_version + 1}") @@ -1807,7 +1806,9 @@ def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - assert audit_path.read_bytes() == expected_bytes + with closing(sqlite3.connect(audit_path)) as audit: + assert audit.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert audit.execute("SELECT generation FROM audit_continuity_head").fetchone() == (2,) assert (audit_path.stat().st_dev, audit_path.stat().st_ino) != old_identity assert receipt.name.endswith(".committed.json") assert receipt.with_name(receipt.name.replace(".committed.json", ".prepared.json")).is_file() @@ -1880,6 +1881,20 @@ def fail_committed_link( ).fetchone() == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() ) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("CREATE TABLE restore_retry_tamper (value TEXT NOT NULL) STRICT") + source.commit() + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-tamper") as owner: + with pytest.raises(MigrationError, match="backup is stale for source.db"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE restore_retry_tamper") + source.commit() with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: receipt = restore_adopted_audit_tier( audit_path, @@ -1914,16 +1929,15 @@ def test_adopted_audit_restore_replaces_stale_operation_staging_after_crash( verified = backup_archive(output_dir=archive_root.parent / "staging-post", profile="full_evidence", verify=True) assert verified.ok and verified.output_path is not None, verified.error audit_path.write_bytes(b"corrupt") - real_replace = os.replace real_unlink = os.unlink def interrupt_publication(*args: object, **kwargs: object) -> None: raise OSError("simulated restore publication crash") - def leave_staging(name: os.PathLike[str] | str, *args: object, **kwargs: object) -> None: + def leave_staging(name: os.PathLike[str] | str, *, dir_fd: int | None = None) -> None: if str(name).startswith(".audit.db.restore-"): return - real_unlink(name, *args, **kwargs) + real_unlink(name, dir_fd=dir_fd) with monkeypatch.context() as interrupted: interrupted.setattr("polylogue.operations.durable_change_train.os.replace", interrupt_publication) @@ -2110,7 +2124,9 @@ def test_adopted_audit_restore_rejects_backup_swap_after_validation( assert verified.ok and verified.output_path is not None, verified.error backup_root = Path(verified.output_path) audit_path.write_bytes(b"corrupt-before-swap-test") - real_validate = operations_durable_change_train.validate_full_evidence_backup_for_adopted_audit_restore + from polylogue.storage.sqlite.migration_runner import validate_full_evidence_backup_for_adopted_audit_restore + + real_validate = validate_full_evidence_backup_for_adopted_audit_restore calls = 0 def swap_after_validation(path: Path, *, archive_root: Path) -> tuple[Path, Path]: @@ -3154,7 +3170,6 @@ def test_bootstrap_reconciles_and_persists_interrupted_train_evidence( conn.execute("CREATE TABLE durable_items (item_id TEXT PRIMARY KEY, payload TEXT NOT NULL) STRICT") conn.execute(f"PRAGMA user_version = {_TARGET_VERSION}") conn.commit() - bootstrap.initialize_archive_database(tmp_path / "audit.db", ArchiveTier.AUDIT) manifest = tmp_path / ".maintenance-state" / "durable-change-trains" / "source-002.json" write_durable_change_train_manifest(manifest, train, expected_revision=-1) @@ -3232,6 +3247,7 @@ def test_startup_proves_durable_continuity_before_initialization_or_release( db_path = tmp_path / f"{tier.value}.db" _create_current_database(db_path) + bootstrap.initialize_archive_database(tmp_path / "audit.db", ArchiveTier.AUDIT) _install_synthetic_migration(tmp_path, monkeypatch, tier) train = _admitted(tier, rider=_production_rider()) with sqlite3.connect(db_path) as conn: From 2567637e716f2b3cb688f6ed232f39dc26669396 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 20:30:35 +0200 Subject: [PATCH 12/28] fix(storage): recover audit continuity failures Problem: prepared audit continuity commands could wedge later writes after a rejected audit transaction, and replay/migration compatibility gaps left restore and rollout paths incomplete. What changed: normalize typed receipt payloads, preserve optional authority input on replay, abort proven rollbacks, reconcile operation-owned restore rebinds, and add a compatibility-aware runtime probe. Focused crash, replay, and migration-order tests cover the production routes. --- polylogue/operations/audit.py | 70 +++++++++++- polylogue/operations/durable_change_train.py | 16 ++- polylogue/storage/sqlite/audit_continuity.py | 103 +++++++++++++++++- .../storage/sqlite/durable_change_train.py | 4 + tests/unit/operations/test_operation_audit.py | 93 +++++++++++++++- tests/unit/storage/test_audit_continuity.py | 22 ++++ .../unit/storage/test_durable_change_train.py | 74 ++++++++++--- 7 files changed, 355 insertions(+), 27 deletions(-) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index eb4e00b23c..91627888b2 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -4,11 +4,14 @@ import hashlib import json +import math import secrets import sqlite3 import time from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager +from dataclasses import fields, is_dataclass +from enum import Enum from functools import wraps from pathlib import Path from typing import Any, Literal, TypeVar, cast @@ -51,6 +54,12 @@ def _continuity_mutation(kind: str) -> Callable[[_F], _F]: def decorate(method: _F) -> _F: @wraps(method) def wrapped(self: AuditRepository, *args: object, **kwargs: object) -> object: + # The audit tier can be upgraded before source.db installs its + # matching WAL table. Keep that release window operational; the + # coordinator becomes mandatory as soon as both schema halves are + # present. + if not self._continuity.is_available(): + return method(self, *args, **kwargs) mutation = AuditMutation( kind=kind, mutation_id=f"audit-mutation:{secrets.token_urlsafe(18)}", @@ -165,8 +174,43 @@ def _authorization_from_payload(raw: object) -> MutationAuthorization: ) +def _json_primitive(value: object) -> object: + """Project typed receipt values into finite, replayable JSON primitives.""" + + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise TypeError("continuity receipt contains a non-finite float") + return value + if isinstance(value, Enum): + return _json_primitive(value.value) + if isinstance(value, Mapping): + normalized: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError("continuity receipt object keys must be strings") + normalized[key] = _json_primitive(item) + return normalized + if isinstance(value, (list, tuple)): + return [_json_primitive(item) for item in value] + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _json_primitive(model_dump(mode="json")) + if is_dataclass(value) and not isinstance(value, type): + # Dataclass receipt values can carry private derived caches (for + # example AnnotationBatch's canonical byte payload). Persist only the + # constructor fields that define the replayable public value. + return _json_primitive({field.name: getattr(value, field.name) for field in fields(value) if field.init}) + if isinstance(value, Path): + return str(value) + raise TypeError(f"continuity receipt cannot encode {type(value).__qualname__}") + + def _receipt_payload(receipt: MutationReceipt) -> dict[str, object]: - return receipt.to_dict() + payload = _json_primitive(receipt.to_dict()) + assert isinstance(payload, dict) + return payload def _receipt_from_payload(raw: object) -> MutationReceipt: @@ -227,9 +271,18 @@ def _continuity_payload( values = dict(kwargs) if kind == "ensure_archive_authority": + archive_instance_id = cast(str | None, values.get("archive_instance_id")) return { "now_ms": cast(int, values["now_ms"]), - "archive_instance_id": values.get("archive_instance_id") or f"archive:{secrets.token_hex(16)}", + # Keep caller intent separate from the deterministic value used + # only if this command has to create the authority row. A live + # call with ``None`` accepts an existing id; replay must retain + # that same optional semantic rather than treating a generated + # value as an asserted authority id. + "archive_instance_id": archive_instance_id, + "generated_archive_instance_id": ( + None if archive_instance_id is not None else f"archive:{secrets.token_hex(16)}" + ), } if kind == "create_preview": plan, principal = cast(MutationPlan, args[0]), cast(MutationPrincipal, args[1]) @@ -294,7 +347,7 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta return cast(Any, self.ensure_archive_authority).__wrapped__( self, now_ms=cast(int, payload["now_ms"]), - archive_instance_id=cast(str, payload["archive_instance_id"]), + archive_instance_id=cast(str | None, payload.get("archive_instance_id")), ) if mutation.kind == "create_preview": return cast(Any, self.create_preview).__wrapped__( @@ -357,7 +410,16 @@ def ensure_archive_authority(self, *, now_ms: int, archive_instance_id: str | No if archive_instance_id is not None and archive_instance_id != existing: raise ValueError("audit archive instance identity changed") return existing - instance_id = cast(str, archive_instance_id or self._command_value("archive_instance_id", "")) + instance_id = cast( + str, + archive_instance_id + or self._command_value( + "generated_archive_instance_id", + self._command_value("archive_instance_id", ""), + ), + ) + if not instance_id: + raise RuntimeError("audit archive authority command lacks an instance identity") conn.execute( "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", (instance_id, now_ms), diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index fe204d019b..92296d4bfe 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1398,9 +1398,17 @@ def revalidate_exact_backup() -> None: temporary_name = f".audit.db.restore-{operation_id}.tmp" _remove_stale_restore_staging(directory_fd=directory_fd, temporary_name=temporary_name) rebind_mutation_id = f"audit-restore:{operation_id}" - rebind_already_committed = has_pending_restore and AuditContinuityCoordinator(archive_root).has_committed_mutation( - rebind_mutation_id - ) + coordinator = AuditContinuityCoordinator(archive_root) + rebind_already_committed = False + if has_pending_restore: + # A restore can stop after its audit-side rebind commit while the source + # WAL still awaits promotion. Reconcile that exact operation before a + # retry tries to prepare a second command. + rebind_already_committed = ( + coordinator.reconcile_pending_rebind(rebind_mutation_id) + if coordinator.has_pending_rebind(rebind_mutation_id) + else coordinator.has_committed_mutation(rebind_mutation_id) + ) published = False try: if rebind_already_committed or _audit_file_matches_artifact( @@ -1447,7 +1455,7 @@ def revalidate_exact_backup() -> None: } committed["continuity_sha256"] = _canonical_json_sha256(committed) if not rebind_already_committed: - AuditContinuityCoordinator(archive_root).seed_or_rebind( + coordinator.seed_or_rebind( mutation_id=rebind_mutation_id, now_ms=int(time.time() * 1000), evidence={ diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 27df7f6777..f0750c5fec 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -93,7 +93,15 @@ def execute(self, mutation: AuditMutation, apply: Callable[[sqlite3.Connection, self._phase("before_source_prepare", mutation) prepared = self._prepare(mutation) self._phase("after_source_prepare", mutation) - result = self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") + try: + result = self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") + except Exception: + # _apply_prepared has exited its audit transaction before this + # handler runs. Clear this exact source WAL entry only when the + # audit head still proves no commit happened, so validation rejects + # cannot wedge every later audit mutation. + self._abort_prepared(prepared) + raise self._phase("after_audit_commit", mutation) self._promote(prepared) self._phase("after_source_promotion", mutation) @@ -102,6 +110,8 @@ def execute(self, mutation: AuditMutation, apply: Callable[[sqlite3.Connection, def reconcile(self, apply: Callable[[sqlite3.Connection, AuditMutation], object]) -> None: """Deterministically complete a pending command or reject a stale audit image.""" + if not self.is_available(): + return prepared = self._pending() if prepared is None: self._assert_committed_head_matches_audit() @@ -110,6 +120,62 @@ def reconcile(self, apply: Callable[[sqlite3.Connection, AuditMutation], object] self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") self._promote(prepared) + def reconcile_pending_rebind(self, mutation_id: str) -> bool: + """Complete only the named operation-owned rebind command, if pending.""" + + prepared = self._pending() + if prepared is None: + return self.has_committed_mutation(mutation_id) + mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind != "rebind" or mutation.mutation_id != mutation_id: + raise AuditContinuityError("pending audit continuity command does not belong to this restore rebind") + if not self.is_available(): + raise AuditContinuityError("pending restore rebind lacks a readable audit continuity head") + self._apply_prepared(prepared, lambda _conn, _mutation: None, allow_rebind=True) + self._promote(prepared) + return True + + def has_pending_rebind(self, mutation_id: str) -> bool: + """Return whether source.db has the named restore-owned rebind prepared.""" + + prepared = self._pending() + if prepared is None: + return False + mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind != "rebind" or mutation.mutation_id != mutation_id: + raise AuditContinuityError("pending audit continuity command does not belong to this restore rebind") + return True + + def is_available(self) -> bool: + """Return whether both schema halves needed for coordinated writes exist.""" + + if not self.source_path.is_file() or not self.audit_path.is_file(): + return False + try: + with ( + closing(sqlite3.connect(self.source_path)) as source, + closing(sqlite3.connect(self.audit_path)) as audit, + ): + source.execute("SELECT 1 FROM audit_continuity_control WHERE singleton = 1").fetchone() + audit.execute("SELECT 1 FROM audit_continuity_head WHERE singleton = 1").fetchone() + except sqlite3.OperationalError as exc: + if "no such table" in str(exc).lower(): + return False + raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc + except sqlite3.DatabaseError as exc: + raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc + return True + + def runtime_probe(self) -> str: + """Exercise the coordinator's released-schema or compatibility state.""" + + if not self.is_available(): + return "standby until source.db and audit.db both install continuity control" + if self._pending() is not None: + raise AuditContinuityError("runtime probe found an unreconciled audit continuity command") + self._assert_committed_head_matches_audit() + return "reconciled matching source/audit continuity heads" + def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str, object]) -> None: """Advance continuity after an authenticated adoption or verified restore. @@ -285,6 +351,41 @@ def _promote(self, prepared: Mapping[str, object]) -> None: raise AuditContinuityError("source audit continuity promotion lost its prepared command") conn.commit() + def _abort_prepared(self, prepared: Mapping[str, object]) -> None: + """Discard a rejected WAL command after proving its audit transaction rolled back.""" + + mutation = AuditMutation.from_command(prepared["command"]) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + with closing(sqlite3.connect(self.audit_path)) as audit: + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing while aborting a prepared command") + current = (int(row[0]), str(row[1]), row[2]) + if current[:2] == target and current[2] == mutation.mutation_id: + # The audit commit did land. Keep the WAL command for normal + # promotion instead of mistaking an ambiguous failure for rollback. + return + if current[:2] != prior: + raise AuditContinuityError("cannot abort prepared command after an unrelated audit head change") + with closing(sqlite3.connect(self.source_path)) as source, source: + source.execute("BEGIN IMMEDIATE") + cursor = source.execute( + """ + UPDATE audit_continuity_control + SET pending_mutation_id = NULL, pending_payload_json = NULL, + pending_payload_sha256 = NULL, prepared_at_ms = NULL + WHERE singleton = 1 AND committed_generation = ? AND committed_head_sha256 = ? + AND pending_mutation_id = ? AND pending_payload_sha256 = ? + """, + (prior[0], prior[1], mutation.mutation_id, _sha256(dict(prepared))), + ) + if cursor.rowcount != 1: + raise AuditContinuityError("source audit continuity abort lost its prepared command") + source.commit() + def _assert_committed_head_matches_audit(self) -> None: with closing(sqlite3.connect(self.source_path)) as source, closing(sqlite3.connect(self.audit_path)) as audit: source_row = source.execute( diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 5ecba136f9..1d0e4864bc 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1468,6 +1468,10 @@ def _runtime_consumer_results( AuditRepository.for_archive_root(archive_root).reconcile_continuity() detail = "reconciled matching source/audit continuity heads" + elif reference.endswith(":AuditContinuityCoordinator"): + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + detail = AuditContinuityCoordinator(archive_root).runtime_probe() elif not any( parameter.default is inspect.Parameter.empty and parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 980fe2aad9..3ff6b46d24 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -1,14 +1,16 @@ from __future__ import annotations import sqlite3 -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from pathlib import Path import pytest +from pydantic import BaseModel from polylogue.operations.audit import AuditRepository from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import ( + AuditFinalizationError, CapabilityDeniedError, ConfirmationStrength, DestructiveClass, @@ -62,6 +64,31 @@ def apply(self, plan: MutationPlan, _args: object) -> MutationReceipt: ) +@dataclass(frozen=True) +class _TypedDomainBatch: + batch_ref: str + rows: tuple[str, ...] + _cached_bytes: bytes = field(init=False, repr=False, default=b"private-cache") + + +class _TypedDomainOutcome(BaseModel): + row_ref: str + status: str + + +@dataclass +class _TypedReceiptActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + receipt = super().apply(plan, args) + return replace( + receipt, + domain_receipt={ + "batch": _TypedDomainBatch("annotation-batch:typed", ("assertion:typed",)), + "outcomes": (_TypedDomainOutcome(row_ref="assertion:typed", status="imported"),), + }, + ) + + def _binding( actuator: _Actuator, *, target_durability: TargetDurability = "derived" ) -> OperationBinding[object, object]: @@ -196,6 +223,70 @@ def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutati ) +def test_optional_archive_authority_id_replays_without_changing_existing_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An omitted authority id remains omitted across a pre-commit crash.""" + + audit = _audit(tmp_path) + assert audit.ensure_archive_authority(now_ms=1, archive_instance_id="archive:existing") == "archive:existing" + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "ensure_archive_authority" and phase == "after_source_prepare": + raise RuntimeError("crash after optional authority prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match="optional authority prepare"): + audit.ensure_archive_authority(now_ms=2) + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT archive_instance_id, created_at_ms FROM archive_authority").fetchone() == ( + "archive:existing", + 1, + ) + + +def test_typed_domain_receipt_replays_after_source_prepare_crash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real executor route persists and replays typed receipt values as JSON.""" + + audit = _audit(tmp_path) + actuator = _TypedReceiptActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "typed-receipt-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:typed-receipt", + archive_identity_digest="identity:typed-receipt", + parameter_digest="params:typed-receipt", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_finalize(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "finalize_attempt" and phase == "after_source_prepare": + raise RuntimeError("crash after typed receipt prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_finalize) + with pytest.raises(AuditFinalizationError, match="not reported completed"): + executor.execute_bound(_binding(actuator), preview, authorization, object()) + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs").fetchone() == ("completed",) + with sqlite3.connect(tmp_path / "source.db") as source: + command = source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0] + assert command is None + + def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path) -> None: audit = _audit(tmp_path) actuator = _Actuator() diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index ab7d441d6e..943930b637 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -108,6 +108,28 @@ def interrupt(phase: str, _mutation: AuditMutation) -> None: AuditContinuityCoordinator(tmp_path).execute(_mutation(2), _apply) +def test_rejected_audit_transaction_aborts_its_prepared_command(tmp_path: Path) -> None: + """A deterministic reject cannot leave the source WAL blocking later work.""" + + initialize_active_archive_root(tmp_path) + + def reject(_conn: sqlite3.Connection, _mutation: AuditMutation) -> object: + raise ValueError("already consumed") + + coordinator = AuditContinuityCoordinator(tmp_path) + with pytest.raises(ValueError, match="already consumed"): + coordinator.execute(_mutation(1), reject) + + with sqlite3.connect(tmp_path / "source.db") as source: + assert source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone() == (None,) + assert coordinator.execute(_mutation(2), _apply) == "mutation:2" + with sqlite3.connect(tmp_path / "audit.db") as audit: + assert audit.execute("SELECT generation, mutation_id FROM audit_continuity_head").fetchone() == ( + 1, + "mutation:2", + ) + + @pytest.mark.parametrize( ("crash_phase", "error"), [ diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index f8038f57bd..bee6f865fc 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -68,6 +68,7 @@ durable_migration_claim_for_sql, durable_migration_collision_report, load_durable_change_train_manifest, + migrate_archive_tier, prove_durable_change_train, prove_durable_fresh_ddl_parity, reconcile_interrupted_durable_change_train, @@ -1847,24 +1848,19 @@ def test_adopted_audit_restore_resumes_an_interrupted_continuity_commit( verified = backup_archive(output_dir=archive_root.parent / "resume-post", profile="full_evidence", verify=True) assert verified.ok and verified.output_path is not None, verified.error audit_path.write_bytes(b"corrupt") - real_link = os.link + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator - def fail_committed_link( - source: os.PathLike[str] | str, - destination: os.PathLike[str] | str, - *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, - follow_symlinks: bool = True, - ) -> None: - if str(destination).endswith(".committed.json"): - raise OSError("simulated continuity commit interruption") - real_link(source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_rebind_commit(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + if phase == "after_audit_commit" and getattr(mutation, "mutation_id", "").startswith("audit-restore:"): + raise RuntimeError("simulated continuity promotion interruption") + original_phase(self, phase, mutation) # type: ignore[arg-type] with monkeypatch.context() as interrupted: - interrupted.setattr("polylogue.operations.durable_change_train.os.link", fail_committed_link) + interrupted.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_rebind_commit) with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-interrupt") as owner: - with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + with pytest.raises(RuntimeError, match="continuity promotion interruption"): restore_adopted_audit_tier( audit_path, backup_manifest=Path(verified.output_path) / "manifest.json", @@ -1872,14 +1868,17 @@ def fail_committed_link( stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - with pytest.raises(MigrationError, match="prepared but incomplete"): - reconcile_durable_change_train_startup(archive_root) with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert ( + source.execute("SELECT pending_mutation_id FROM audit_continuity_control") + .fetchone()[0] + .startswith("audit-restore:") + ) assert ( source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" ).fetchone() - == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + != audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() ) with sqlite3.connect(archive_root / "source.db") as source: source.execute("CREATE TABLE restore_retry_tamper (value TEXT NOT NULL) STRICT") @@ -1907,6 +1906,47 @@ def fail_committed_link( assert reconcile_durable_change_train_startup(archive_root) == () +@pytest.mark.parametrize("order", ((ArchiveTier.AUDIT, ArchiveTier.SOURCE), (ArchiveTier.SOURCE, ArchiveTier.AUDIT))) +def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window( + workspace_env: dict[str, Path], order: tuple[ArchiveTier, ArchiveTier] +) -> None: + """Each numbered migration can ship first; coordination activates only after both.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + with sqlite3.connect(archive_root / "audit.db") as audit: + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.commit() + backup = backup_archive( + output_dir=archive_root.parent / f"continuity-{order[0].value}-first", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + + for position, tier in enumerate(order): + with sqlite3.connect(archive_root / f"{tier.value}.db") as connection: + result = migrate_archive_tier(connection, tier, backup_manifest=manifest) + assert result.applied_versions == (ARCHIVE_VERSION_BY_TIER[tier],) + train = durable_migration_sidecar_for_slot(tier, ARCHIVE_VERSION_BY_TIER[tier]).train + results = _runtime_consumer_results(train, archive_root) + assert {result.consumer_id for result in results} == { + consumer.consumer_id for rider in train.riders for consumer in rider.runtime_consumers + } + probe = AuditContinuityCoordinator(archive_root) + if position == 0: + assert probe.runtime_probe().startswith("standby") + else: + assert probe.runtime_probe() == "reconciled matching source/audit continuity heads" + + def test_adopted_audit_restore_replaces_stale_operation_staging_after_crash( workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch ) -> None: From 9ca0a2f1ca3edc70253e24eb9961a2a75678e4f9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 20:33:23 +0200 Subject: [PATCH 13/28] test(storage): type migration probe lookup Assert the declared sidecar exists before exercising its runtime consumers in the cross-tier migration-order regression. --- tests/unit/storage/test_durable_change_train.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index bee6f865fc..d4cd0664aa 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1935,7 +1935,9 @@ def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window with sqlite3.connect(archive_root / f"{tier.value}.db") as connection: result = migrate_archive_tier(connection, tier, backup_manifest=manifest) assert result.applied_versions == (ARCHIVE_VERSION_BY_TIER[tier],) - train = durable_migration_sidecar_for_slot(tier, ARCHIVE_VERSION_BY_TIER[tier]).train + sidecar = durable_migration_sidecar_for_slot(tier, ARCHIVE_VERSION_BY_TIER[tier]) + assert sidecar is not None + train = sidecar.train results = _runtime_consumer_results(train, archive_root) assert {result.consumer_id for result in results} == { consumer.consumer_id for rider in train.riders for consumer in rider.runtime_consumers From 7cf3db38bd6f5090f18052c2088a18327f660066 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 22:04:38 +0200 Subject: [PATCH 14/28] fix: harden audit continuity recovery Problem: replayed authorization digests were accepted as bearer tokens, and recovery could persist continuity state before offline ownership was proven.\n\nWhat changed: keep stored authorization digests on a private replay path, move recovery reconciliation and executor persistence inside the ownership boundary, normalize adopted-audit continuity errors at the CLI, and retarget executor tests to bound dispatch. --- .../cli/commands/maintenance/_migrate_tier.py | 3 +- .../maintenance/raw_authority_recovery.py | 69 +++++++++---------- polylogue/operations/audit.py | 60 +++++++++++++--- polylogue/operations/durable_change_train.py | 3 +- tests/unit/annotations/test_importer.py | 19 +++-- tests/unit/api/test_facade_contracts.py | 23 ++++--- .../api/test_operation_executor_routes.py | 22 +++--- .../unit/cli/test_archive_maintenance_cli.py | 40 +++++++++++ .../maintenance/test_raw_authority_reset.py | 44 ++++++++++++ tests/unit/operations/test_operation_audit.py | 5 +- 10 files changed, 211 insertions(+), 77 deletions(-) diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index ed90e6b05a..33cea1462e 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -24,6 +24,7 @@ from polylogue.operations.durable_change_train import ( ArchiveOwnershipError, + AuditContinuityError, DurablePublicationError, acquire_durable_archive_ownership, adopt_missing_audit_tier, @@ -153,7 +154,7 @@ def migrate_tier_command( single_writer_evidence_ref="proof:archive-ownership-lock", release_archive_ownership=archive_owner.release, ) - except (sqlite3.Error, MigrationError, ArchiveOwnershipError) as exc: + except (sqlite3.Error, MigrationError, ArchiveOwnershipError, AuditContinuityError) as exc: if output_format == "json": click.echo( json.dumps( diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 402e6b1010..49accf6f55 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -1493,19 +1493,6 @@ def apply_raw_authority_recovery( or str(backup_manifest.resolve(strict=False)) != selected.backup_authority.get("manifest_path") ): raise RawAuthorityRecoveryError("apply backup manifest does not match the plan authority") - existing = _receipt_for_plan(selected) - if existing is not None: - _require_apply_preconditions(Path(selected.archive_root)) - _validate_existing_receipt(selected, existing) - _refresh_source_train_continuity(selected) - return RawAuthorityRecoveryReport( - plan=selected, - applied=False, - status="already_satisfied", - receipt_path=Path(selected.receipt_path), - after_counts=cast(dict[str, int], existing.get("after_counts")), - postflight=cast(dict[str, object], existing.get("postflight")), - ) if selected.backup_authority is None: raise RawAuthorityRecoveryError("apply requires a dry-run plan with verified backup authority") operation = RecoveryOperation(selected.operation) @@ -1525,39 +1512,45 @@ def apply_raw_authority_recovery( ) from polylogue.operations.bindings import runtime_operation_binding - executor = OperationExecutor.for_archive_root(root) try: location = ArchiveLocation.resolve(root) - # A final receipt may be missing after a process crash or I/O failure. - # Only exact committed postflight evidence can skip a fresh executor - # authorization. An uncommitted intent is evidence of interruption, - # not authority to perform the destructive mutation. - if _intent_for_plan(selected) is not None: - with OwnedArchiveLocation.acquire( - location, owner_id=f"raw-authority-recovery:{selected.operation_id}" - ) as owned: - current_location = ArchiveLocation.resolve(root) - assert_owns_archive_location(owned, current_location) - with RebuildLease(root): - if _committed_postflight(selected) is not None: - return _apply_plan(selected) - binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - "cli:maintenance", - frozenset({"archive.raw_authority_recovery", "archive.legacy_runtime"}), - "maintenance", - "maintenance", - ) - preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) - if preview.plan.context.get("recovery_plan_digest") != selected.plan_digest: - raise PlanStaleError("recovery plan is stale before lease acquisition") - authorization = executor.authorize_bound(binding, preview, principal) with OwnedArchiveLocation.acquire( location, owner_id=f"raw-authority-recovery:{selected.operation_id}" ) as owned: current_location = ArchiveLocation.resolve(root) assert_owns_archive_location(owned, current_location) + _require_apply_preconditions(root) with RebuildLease(root): + existing = _receipt_for_plan(selected) + if existing is not None: + _validate_existing_receipt(selected, existing) + _refresh_source_train_continuity(selected) + return RawAuthorityRecoveryReport( + plan=selected, + applied=False, + status="already_satisfied", + receipt_path=Path(selected.receipt_path), + after_counts=cast(dict[str, int], existing.get("after_counts")), + postflight=cast(dict[str, object], existing.get("postflight")), + ) + # A final receipt may be missing after a process crash or I/O failure. + # Only exact committed postflight evidence can skip a fresh executor + # authorization. An uncommitted intent is evidence of interruption, + # not authority to perform the destructive mutation. + if _intent_for_plan(selected) is not None and _committed_postflight(selected) is not None: + return _apply_plan(selected) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "cli:maintenance", + frozenset({"archive.raw_authority_recovery", "archive.legacy_runtime"}), + "maintenance", + "maintenance", + ) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + if preview.plan.context.get("recovery_plan_digest") != selected.plan_digest: + raise PlanStaleError("recovery plan is stale after ownership acquisition") + authorization = executor.authorize_bound(binding, preview, principal) result = executor.execute_bound(binding, preview, authorization, args) except ( ArchiveLocationError, diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index 91627888b2..eb380cc229 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -10,7 +10,7 @@ import time from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager -from dataclasses import fields, is_dataclass +from dataclasses import dataclass, fields, is_dataclass from enum import Enum from functools import wraps from pathlib import Path @@ -43,11 +43,16 @@ def token_sha256(token: str) -> str: """Return the only representation of a bearer token accepted for storage.""" - if token.startswith("sha256:") and len(token) == len("sha256:") + 64: - return token.removeprefix("sha256:") return hashlib.sha256(token.encode("utf-8")).hexdigest() +@dataclass(frozen=True, slots=True) +class _StoredAuthorizationDigest: + """A persisted digest available only while replaying a continuity command.""" + + value: str + + def _continuity_mutation(kind: str) -> Callable[[_F], _F]: """Route one audit repository state transition through the source WAL.""" @@ -157,7 +162,6 @@ def _authorization_payload(authorization: MutationAuthorization) -> dict[str, ob def _authorization_from_payload(raw: object) -> MutationAuthorization: value = cast(dict[str, object], raw) - token_digest = cast(str | None, value.get("token_sha256")) return MutationAuthorization( plan_hash=cast(str, value["plan_hash"]), actor=cast(str, value["actor"]), @@ -167,13 +171,21 @@ def _authorization_from_payload(raw: object) -> MutationAuthorization: authorized_at=cast(str, value["authorized_at"]), preview_ref=cast(str | None, value.get("preview_ref")), authorization_id=cast(str | None, value.get("authorization_id")), - token=None if token_digest is None else f"sha256:{token_digest}", + token=None, expires_at_ms=cast(int | None, value.get("expires_at_ms")), capabilities=tuple(cast(list[str], value["capabilities"])), surface=cast(Any, value.get("surface")), ) +def _stored_authorization_digest(raw: object) -> _StoredAuthorizationDigest: + value = cast(dict[str, object], raw) + digest = value.get("token_sha256") + if not isinstance(digest, str) or len(digest) != 64: + raise ValueError("replayed bound authorization lacks a token digest") + return _StoredAuthorizationDigest(digest) + + def _json_primitive(value: object) -> object: """Project typed receipt values into finite, replayable JSON primitives.""" @@ -354,15 +366,15 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta self, _plan_from_payload(payload["plan"]), _principal_from_payload(payload["principal"]) ) if mutation.kind == "issue_authorization": - return cast(Any, self.issue_authorization).__wrapped__( - self, + return self._persist_authorization( + _stored_authorization_digest(payload["authorization"]), _preview_from_payload(payload["preview"]), _principal_from_payload(payload["principal"]), _authorization_from_payload(payload["authorization"]), ) if mutation.kind == "consume_authorization_and_start": - return cast(Any, self.consume_authorization_and_start).__wrapped__( - self, + return self._consume_authorization( + _stored_authorization_digest(payload["authorization"]), _preview_from_payload(payload["preview"]), _authorization_from_payload(payload["authorization"]), ) @@ -515,6 +527,20 @@ def issue_authorization( if authorization.token is None: raise ValueError("bound authorization requires a token") + return self._persist_authorization( + _StoredAuthorizationDigest(token_sha256(authorization.token)), + preview, + principal, + authorization, + ) + + def _persist_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + principal: MutationPrincipal, + authorization: MutationAuthorization, + ) -> str: authorization_id = cast( str, self._command_value("authorization_id", f"authorization:{secrets.token_urlsafe(18)}") ) @@ -548,7 +574,7 @@ def issue_authorization( principal.surface, principal.role_label, authorization.confirmation_strength, - token_sha256(authorization.token), + token_digest.value, issued_at_ms, authorization.expires_at_ms or issued_at_ms, ), @@ -566,6 +592,18 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio if authorization.token is None: raise ValueError("authorization token is missing") + return self._consume_authorization( + _StoredAuthorizationDigest(token_sha256(authorization.token)), + preview, + authorization, + ) + + def _consume_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + authorization: MutationAuthorization, + ) -> str: operation_id = cast(str, self._command_value("operation_id", f"operation:{secrets.token_urlsafe(18)}")) attempt_id = cast(str, self._command_value("attempt_id", f"attempt:{secrets.token_urlsafe(18)}")) now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) @@ -579,7 +617,7 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio JOIN operation_previews AS p ON p.preview_id = a.preview_id WHERE a.token_sha256 = ? """, - (token_sha256(authorization.token),), + (token_digest.value,), ).fetchone() if row is None or str(row[1]) != preview.preview_ref: raise ValueError("authorization token does not match preview") diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 92296d4bfe..82224d7063 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -20,7 +20,7 @@ from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditContinuityError from polylogue.storage.sqlite.durable_change_train import ( DurableChangeTrainExecution, ) @@ -1510,6 +1510,7 @@ def reconcile_durable_change_trains_on_startup(root: Path) -> tuple[Path, ...]: "acquire_durable_archive_ownership", "adopt_missing_audit_tier", "audit_adoption_receipt_path", + "AuditContinuityError", "ArchiveOwnershipError", "execute_durable_change_train", "initialize_missing_durable_tier", diff --git a/tests/unit/annotations/test_importer.py b/tests/unit/annotations/test_importer.py index 7f79d84ccc..44866eff97 100644 --- a/tests/unit/annotations/test_importer.py +++ b/tests/unit/annotations/test_importer.py @@ -20,6 +20,7 @@ from polylogue.archive.message.roles import Role from polylogue.archive.query.expression import parse_unit_source_expression from polylogue.core.enums import AssertionKind, BlockType, BranchType, Provider +from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import OperationExecutor from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore @@ -119,19 +120,25 @@ async def test_import_roundtrip_keeps_failures_candidates_and_independent_batche """The registered import route writes real user-tier provenance and candidates. Anti-vacuity: ``import_annotation_batch`` must dispatch the real - ``AnnotationBatchImportActuator`` through ``OperationExecutor`` before the + ``AnnotationBatchImportActuator`` through ``OperationExecutor.execute_bound`` before the transaction reaches ``user.db``. Removing that executor dispatch or restoring a direct persistence call leaves ``executed`` empty even though a toy persistence stub could still appear green. """ executed: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def spy(self: OperationExecutor, actuator: object, plan: object, authorization: object, args: object) -> object: - executed.append(type(actuator).__name__) - return original_execute(self, actuator, plan, authorization, args) # type: ignore[arg-type] + def spy( + self: OperationExecutor, + binding: OperationBinding[object, object], + preview: object, + authorization: object, + args: object, + ) -> object: + executed.append(type(binding.actuator).__name__) + return original_execute_bound(self, binding, preview, authorization, args) # type: ignore[arg-type] - monkeypatch.setattr(OperationExecutor, "execute", spy) + monkeypatch.setattr(OperationExecutor, "execute_bound", spy) archive_root = workspace_env["archive_root"] with ArchiveStore(archive_root) as archive: diff --git a/tests/unit/api/test_facade_contracts.py b/tests/unit/api/test_facade_contracts.py index 8f99311b44..8816b992fc 100644 --- a/tests/unit/api/test_facade_contracts.py +++ b/tests/unit/api/test_facade_contracts.py @@ -49,6 +49,7 @@ delegation_edge_object_id, delegation_subtree_object_id, ) +from polylogue.operations.bindings import OperationBinding from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession from polylogue.storage.block_anchor import format_block_anchor from polylogue.storage.runtime.store_constants import SESSION_INSIGHT_MATERIALIZER_VERSION @@ -356,7 +357,7 @@ async def test_facade_capture_candidate_dispatches_executor_and_persists_user_ro """The real facade route cannot bypass the executor and still pass. Production dependency: ``PolylogueArchiveMixin.capture_assertion_candidate`` - calls ``OperationExecutor.execute`` and the actuator writes ``user.db``. + calls ``OperationExecutor.execute_bound`` and the actuator writes ``user.db``. Removing that dispatch, or restoring the former direct helper call, makes the captured actuator list empty or leaves no candidate row. """ @@ -365,13 +366,19 @@ async def test_facade_capture_candidate_dispatches_executor_and_persists_user_ro archive = _archive(tmp_path) captured: list[str] = [] - original_execute = OperationExecutor.execute - - def spy(self: OperationExecutor, actuator: object, plan: object, authorization: object, args: object) -> object: - captured.append(type(actuator).__name__) - return original_execute(self, actuator, plan, authorization, args) # type: ignore[arg-type] - - monkeypatch.setattr(OperationExecutor, "execute", spy) + original_execute_bound = OperationExecutor.execute_bound + + def spy( + self: OperationExecutor, + binding: OperationBinding[object, object], + preview: object, + authorization: object, + args: object, + ) -> object: + captured.append(type(binding.actuator).__name__) + return original_execute_bound(self, binding, preview, authorization, args) # type: ignore[arg-type] + + monkeypatch.setattr(OperationExecutor, "execute_bound", spy) try: result = await archive.capture_assertion_candidate( body_text="facade candidate", diff --git a/tests/unit/api/test_operation_executor_routes.py b/tests/unit/api/test_operation_executor_routes.py index 4a5358b6da..7dbf3f0711 100644 --- a/tests/unit/api/test_operation_executor_routes.py +++ b/tests/unit/api/test_operation_executor_routes.py @@ -18,9 +18,9 @@ def _seed_archive(archive_root: Path, *, native_id: str) -> str: + initialize_active_archive_root(archive_root) source_db = archive_root / "source.db" index_db = archive_root / "index.db" - initialize_active_archive_root(archive_root) raw_id = f"raw-{native_id}" session_id = f"codex-session:{native_id}" with sqlite3.connect(source_db) as conn: @@ -53,13 +53,13 @@ async def test_facade_rebuild_and_update_index_use_executor_and_real_routes( session_id = _seed_archive(archive_root, native_id="route-index") archive = Polylogue(archive_root=archive_root, db_path=archive_root / "index.db") calls: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def record_execute(self: OperationExecutor, actuator, plan, authorization, args): # type: ignore[no-untyped-def] - calls.append(actuator.operation) - return original_execute(self, actuator, plan, authorization, args) + def record_execute_bound(self: OperationExecutor, binding, preview, authorization, args): # type: ignore[no-untyped-def] + calls.append(binding.actuator.operation) + return original_execute_bound(self, binding, preview, authorization, args) - monkeypatch.setattr(OperationExecutor, "execute", record_execute) + monkeypatch.setattr(OperationExecutor, "execute_bound", record_execute_bound) try: assert await archive.rebuild_index() is True assert await archive.update_index([session_id]) is True @@ -78,13 +78,13 @@ async def test_facade_rebuild_insights_uses_executor_and_real_materializer( session_id = _seed_archive(archive_root, native_id="route-insights") archive = Polylogue(archive_root=archive_root, db_path=archive_root / "index.db") calls: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def record_execute(self: OperationExecutor, actuator, plan, authorization, args): # type: ignore[no-untyped-def] - calls.append(actuator.operation) - return original_execute(self, actuator, plan, authorization, args) + def record_execute_bound(self: OperationExecutor, binding, preview, authorization, args): # type: ignore[no-untyped-def] + calls.append(binding.actuator.operation) + return original_execute_bound(self, binding, preview, authorization, args) - monkeypatch.setattr(OperationExecutor, "execute", record_execute) + monkeypatch.setattr(OperationExecutor, "execute_bound", record_execute_bound) try: counts = await archive.rebuild_insights(session_ids=[session_id]) finally: diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index aa3801a2be..07abfb890d 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3537,6 +3537,46 @@ def test_migrate_tier_cli_restores_adopted_audit_from_verified_full_evidence( assert connection.execute("SELECT generation FROM audit_continuity_head").fetchone() == (2,) +@pytest.mark.parametrize("output_format", ["json", "plain"]) +def test_migrate_tier_cli_reports_adopted_audit_continuity_failures( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, output_format: str +) -> None: + """Continuity refusal stays within the command's declared error contract.""" + + from polylogue.cli.commands.maintenance import _migrate_tier + from polylogue.storage.sqlite.audit_continuity import AuditContinuityError + + manifest = cli_workspace["archive_root"] / "backup-manifest.json" + manifest.write_text("manifest", encoding="utf-8") + + def refuse_restore(*_args: object, **_kwargs: object) -> Path: + raise AuditContinuityError("inconsistent audit continuity head") + + monkeypatch.setattr(_migrate_tier, "restore_adopted_audit_tier", refuse_restore) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--restore-adopted-audit", + "--backup-manifest", + str(manifest), + "--output-format", + output_format, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + if output_format == "json": + assert json.loads(result.stdout)["error"] == "inconsistent audit continuity head" + else: + assert "Migration blocked for audit: inconsistent audit continuity head" in result.stderr + + @pytest.mark.parametrize("publication_failure", ["race", "interrupted"]) def test_migrate_tier_cli_adoption_fails_closed_during_publication( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, publication_failure: str diff --git a/tests/unit/maintenance/test_raw_authority_reset.py b/tests/unit/maintenance/test_raw_authority_reset.py index d9be33c63e..d60dbb8cfd 100644 --- a/tests/unit/maintenance/test_raw_authority_reset.py +++ b/tests/unit/maintenance/test_raw_authority_reset.py @@ -12,6 +12,7 @@ import pytest +from polylogue.maintenance import raw_authority_recovery from polylogue.maintenance.raw_authority_recovery import ( PruneOrphanedIndexRevisionSeedsActuator, RawAuthorityRecoveryError, @@ -26,6 +27,7 @@ write_recovery_plan, ) from polylogue.operations.mutation_transaction import OperationExecutor +from polylogue.storage.archive_identity import ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import write_source_continuity_pending_intent @@ -887,6 +889,48 @@ def test_census_reset_refuses_a_competing_source_continuity_intent( assert conn.execute("SELECT COUNT(*) FROM raw_authority_censuses").fetchone() == (1,) +@pytest.mark.parametrize("refusal", ["daemon", "owner"]) +def test_recovery_refusal_precedes_continuity_reconciliation_and_preview( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, refusal: str +) -> None: + """Offline refusal leaves source/audit bytes and their continuity rows unchanged.""" + + initialize_active_archive_root(tmp_path) + _seed_ledger(tmp_path / "source.db") + _seed_raw(tmp_path / "source.db", "r-keep") + backup = _backup_authority(tmp_path, monkeypatch, tier="source") + plan = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.RESET_CENSUS, backup_manifest=backup) + source_db = tmp_path / "source.db" + audit_db = tmp_path / "audit.db" + before_source = source_db.read_bytes() + before_audit = audit_db.read_bytes() + with sqlite3.connect(source_db) as source: + before_control = source.execute("SELECT * FROM audit_continuity_control").fetchall() + with sqlite3.connect(audit_db) as audit: + before_head = audit.execute("SELECT * FROM audit_continuity_head").fetchall() + + if refusal == "daemon": + monkeypatch.setattr(raw_authority_recovery, "running_daemon_pid", lambda _config: 123) + error = "polylogued is running" + else: + + def reject_owner(*_args: object, **_kwargs: object) -> object: + raise ArchiveOwnershipError("competing archive owner") + + monkeypatch.setattr(OwnedArchiveLocation, "acquire", reject_owner) + error = "competing archive owner" + + with pytest.raises(RawAuthorityRecoveryError, match=error): + apply_raw_authority_recovery(plan) + + assert source_db.read_bytes() == before_source + assert audit_db.read_bytes() == before_audit + with sqlite3.connect(source_db) as source: + assert source.execute("SELECT * FROM audit_continuity_control").fetchall() == before_control + with sqlite3.connect(audit_db) as audit: + assert audit.execute("SELECT * FROM audit_continuity_head").fetchall() == before_head + + def test_uncommitted_index_prune_intent_reauthorizes_before_deleting_candidates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 3ff6b46d24..deee2d6a57 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -7,7 +7,7 @@ import pytest from pydantic import BaseModel -from polylogue.operations.audit import AuditRepository +from polylogue.operations.audit import AuditRepository, token_sha256 from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import ( AuditFinalizationError, @@ -139,6 +139,9 @@ def test_token_is_digest_only_and_consumption_run_attempt_are_atomic(tmp_path: P ) authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) assert "raw-secret-token" not in (tmp_path / "audit.db").read_bytes().decode("utf-8", errors="ignore") + digest_as_bearer = replace(authorization, token=f"sha256:{token_sha256('raw-secret-token')}") + with pytest.raises(ValueError, match="does not match preview"): + audit.consume_authorization_and_start(preview, digest_as_bearer) receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) assert receipt.operation_id is not None operation = audit.get_operation(receipt.operation_id) From 4e2024a3b085fcb431ce98c6598a6f257ecddfa4 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 22:50:57 +0200 Subject: [PATCH 15/28] fix: close audit operation recovery gaps Finalize every bound target atomically, preserve rejected effects, and recover abandoned attempts through the continuity-backed unknown path. Revalidate archive identity and full plan context before consumption, preserve surface confirmation evidence, and keep migration and audit recovery validation fail closed. --- CLAUDE.md | 7 +- docs/internals.md | 5 +- polylogue/api/archive.py | 6 +- polylogue/cli/archive_query.py | 17 ++- polylogue/cli/commands/excise.py | 6 +- .../cli/commands/maintenance/_migrate_tier.py | 24 ++-- .../cli/commands/maintenance/_raw_identity.py | 13 +- polylogue/cli/commands/reset.py | 6 +- polylogue/operations/audit.py | 117 ++++++++++++++---- polylogue/operations/durable_change_train.py | 20 +-- polylogue/operations/mutation_transaction.py | 33 ++++- .../storage/sqlite/archive_tiers/audit.py | 6 +- .../storage/sqlite/archive_tiers/bootstrap.py | 39 +++--- .../storage/sqlite/archive_tiers/source.py | 3 +- polylogue/storage/sqlite/audit_continuity.py | 44 ++++--- .../storage/sqlite/durable_change_train.py | 34 +++-- polylogue/storage/sqlite/migration_runner.py | 10 +- tests/unit/operations/test_operation_audit.py | 10 ++ tests/unit/storage/test_audit_continuity.py | 9 +- .../unit/storage/test_durable_change_train.py | 4 +- 20 files changed, 267 insertions(+), 146 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43b0bb8cfb..7321576791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ caller; it exists only as test infrastructure (`polylogue-enium`). `TopologyEdgeStatus` = unresolved/resolved/repaired/**quarantined** (cycle-break). -### The five tiers (durability is the axis) +### The six tiers (durability is the axis) | Tier | durability | holds | | --- | --- | --- | @@ -119,6 +119,7 @@ caller; it exists only as test infrastructure (`polylogue-enium`). | `index.db` | **rebuildable** | the whole parsed tree, FTS, `session_links`, cost tables, and all materialized insights | | `embeddings.db` | rebuildable | `vec0` virtual table (Voyage 1024-dim), meta, status | | `user.db` | **durable, irreplaceable** | unified `assertions`, settings/context receipts, immutable annotation schemas + batch provenance | +| `audit.db` | **durable, append-only authority** | mutation previews, authorizations, attempts, receipts, and continuity heads | | `ops.db` | disposable | ingest cursors, attempts, `convergence_debt`, cursor-lag samples, daemon events, embed catch-up runs | `user.db` is a **single unified `assertions` table** keyed by a closed @@ -186,8 +187,8 @@ snapshot reference check) to bridge the acquire-blob → commit-row window. Two evolution regimes, enforced by `devtools lab policy schema-versioning`: -- **Durable tiers** (`source.db`, `user.db`): explicit **additive** numbered SQL - migrations under `storage/sqlite/migrations/{source,user}/NNN_*.sql`, one +- **Durable tiers** (`source.db`, `user.db`, `audit.db`): explicit **additive** numbered SQL + migrations under `storage/sqlite/migrations/{source,user,audit}/NNN_*.sql`, one `PRAGMA user_version` step at a time, behind a **verified backup manifest**. Destructive durable changes need a copy-forward design + explicit consent. - **Derived tiers** (`index.db`, `embeddings.db`): no migration *chain*, but not diff --git a/docs/internals.md b/docs/internals.md index 4429a171b8..534d667129 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -686,8 +686,9 @@ Files that are not configured archive paths are not classified or handled by the archive runtime. For **durable tiers** (`source.db`, `user.db`, `audit.db`) the boundary is different, because -`user.db` holds irreplaceable human assertions that cannot be rebuilt from -source. These tiers use explicit *additive* numbered SQL migrations under +`user.db` holds irreplaceable human assertions and `audit.db` holds immutable +mutation authority and receipt evidence; neither can be rebuilt from source. +These tiers use explicit *additive* numbered SQL migrations under `storage/sqlite/migrations/{source,user,audit}/NNN_*.sql`, applied one `PRAGMA user_version` step at a time by `migration_runner.py` behind a **verified backup manifest** for the affected tier. Additive means `CREATE TABLE`/`CREATE INDEX`/ diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index 8cbbe99f40..fae8444bd1 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -6591,11 +6591,9 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") executor = OperationExecutor.for_archive_root(root) args = SessionDeleteArgs(archive=archive, session_ids=(resolved,)) binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - actor, frozenset({"archive.delete_session", "archive.legacy_runtime"}), "api", "write" - ) + principal = MutationPrincipal(actor, frozenset({"archive.delete_session"}), "api", "write") preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) - authorization = executor.authorize_bound(binding, preview, principal) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") receipt = executor.execute_bound(binding, preview, authorization, args) deleted = receipt.affected_count > 0 return DeleteSessionResult( diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 223678cdd4..f6db297840 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -2149,15 +2149,6 @@ def _emit_delete( force = bool(params.get("force")) count = len(session_ids) - actuator = SessionDeleteActuator() - executor = OperationExecutor.for_archive_root(archive.archive_root) - prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) - binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - "user:cli", frozenset({"archive.delete_session", "archive.legacy_runtime"}), "cli", "write" - ) - preview = executor.prepare_bound_for_archive(binding, prepare_args, principal, archive_root=archive.archive_root) - if dry_run: # ``session_count`` = matched, ``affected_count`` = deleted (0 in a # preview); ``session_ids`` enumerates the sessions that would be deleted. @@ -2209,7 +2200,13 @@ def _emit_delete( ).to_json(exclude_none=True) ) return - authorization = executor.authorize_bound(binding, preview, principal) + actuator = SessionDeleteActuator() + executor = OperationExecutor.for_archive_root(archive.archive_root) + prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("user:cli", frozenset({"archive.delete_session"}), "cli", "write") + preview = executor.prepare_bound_for_archive(binding, prepare_args, principal, archive_root=archive.archive_root) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") receipt = executor.execute_bound(binding, preview, authorization, prepare_args) deleted = receipt.affected_count # ``session_count`` = matched, ``affected_count`` = sessions actually deleted. diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index d8535dff72..c2ed19bee4 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -314,11 +314,9 @@ def excise_command( # tampered authorization refuses (``PlanStaleError``) rather than excising # the wrong target set. binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - actor, frozenset({"archive.excise_session", "archive.legacy_runtime"}), "cli", "write" - ) + principal = MutationPrincipal(actor, frozenset({"archive.excise_session"}), "cli", "write") preview = executor.prepare_bound_for_archive(binding, excision_args, principal, archive_root=root) - authorization = executor.authorize_bound(binding, preview, principal) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") executor_receipt = executor.execute_bound(binding, preview, authorization, excision_args) if executor_receipt.status == "blocked": _emit( diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 33cea1462e..9617240272 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -106,17 +106,20 @@ def migrate_tier_command( adoption_receipt: Path | None = None restore_receipt: Path | None = None try: + if sum((initialize_missing, adopt_established_audit, restore_adopted_audit)) > 1: + raise MigrationError( + "choose only one of --initialize-missing, --adopt-established-audit, or --restore-adopted-audit" + ) + if (adopt_established_audit or restore_adopted_audit) and archive_tier is not ArchiveTier.AUDIT: + option = "--adopt-established-audit" if adopt_established_audit else "--restore-adopted-audit" + raise MigrationError(f"{option} is only valid for the audit tier") + if (adopt_established_audit or restore_adopted_audit) and backup_manifest is None: + option = "--adopt-established-audit" if adopt_established_audit else "--restore-adopted-audit" + raise MigrationError(f"{option} requires --backup-manifest") with acquire_durable_archive_ownership(path.parent, owner_id=f"migrate-tier:{os.getpid()}") as archive_owner: stopped_daemon_evidence_ref = _require_stopped_daemon(path.parent) - if sum((initialize_missing, adopt_established_audit, restore_adopted_audit)) > 1: - raise MigrationError( - "choose only one of --initialize-missing, --adopt-established-audit, or --restore-adopted-audit" - ) if adopt_established_audit: - if archive_tier is not ArchiveTier.AUDIT: - raise MigrationError("--adopt-established-audit is only valid for the audit tier") - if backup_manifest is None: - raise MigrationError("--adopt-established-audit requires --backup-manifest") + assert backup_manifest is not None initialized_version, adoption_receipt = adopt_missing_audit_tier( path, backup_manifest=backup_manifest, @@ -126,10 +129,7 @@ def migrate_tier_command( initialized = True execution = None elif restore_adopted_audit: - if archive_tier is not ArchiveTier.AUDIT: - raise MigrationError("--restore-adopted-audit is only valid for the audit tier") - if backup_manifest is None: - raise MigrationError("--restore-adopted-audit requires --backup-manifest") + assert backup_manifest is not None restore_receipt = restore_adopted_audit_tier( path, backup_manifest=backup_manifest, diff --git a/polylogue/cli/commands/maintenance/_raw_identity.py b/polylogue/cli/commands/maintenance/_raw_identity.py index 828518dd7f..7cbc3da44c 100644 --- a/polylogue/cli/commands/maintenance/_raw_identity.py +++ b/polylogue/cli/commands/maintenance/_raw_identity.py @@ -212,7 +212,7 @@ def raw_authority_blocker_resolve_command( """ if not confirmed: raise click.ClickException("refusing to resolve a durable blocker without --yes") - from polylogue.operations.bindings import runtime_operation_binding + from polylogue.operations.bindings import BindingValidationError, runtime_operation_binding from polylogue.operations.mutation_actuators import BlockerResolveActuator, BlockerResolveArgs from polylogue.operations.mutation_transaction import ( MutationPrincipal, @@ -233,14 +233,21 @@ def raw_authority_blocker_resolve_command( binding = runtime_operation_binding(actuator) principal = MutationPrincipal( "cli", - frozenset({"archive.raw_authority.resolve_blocker", "archive.legacy_runtime"}), + frozenset({"archive.raw_authority.resolve_blocker"}), "cli", "write", ) preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=env.config.archive_root) authorization = executor.authorize_bound(binding, preview, principal) result = executor.execute_bound(binding, preview, authorization, args) - except (FileNotFoundError, KeyError, RuntimeError, ValueError, MutationTransactionError) as exc: + except ( + BindingValidationError, + FileNotFoundError, + KeyError, + RuntimeError, + ValueError, + MutationTransactionError, + ) as exc: raise click.ClickException(str(exc)) from exc if result.status != "applied": raise click.ClickException(f"blocker {blocker_id!r} not found or already resolved") diff --git a/polylogue/cli/commands/reset.py b/polylogue/cli/commands/reset.py index 174612bc87..ed22da465b 100644 --- a/polylogue/cli/commands/reset.py +++ b/polylogue/cli/commands/reset.py @@ -221,11 +221,9 @@ def _apply_identity_reset(session_ids: list[str], *, reason: str) -> tuple[int, executor = OperationExecutor.for_archive_root(root) args = IdentityResetArgs(archive_root=root, session_ids=tuple(session_ids), reason=reason) binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - "user:cli", frozenset({"archive.identity_reset", "archive.legacy_runtime"}), "cli", "write" - ) + principal = MutationPrincipal("user:cli", frozenset({"archive.identity_reset"}), "cli", "write") preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) - authorization = executor.authorize_bound(binding, preview, principal) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") receipt = executor.execute_bound(binding, preview, authorization, args) domain = receipt.domain_receipt suppressed = cast("int", domain.get("suppressed_count", receipt.affected_count)) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index eb380cc229..c9914fed93 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -273,6 +273,11 @@ def _connection(self) -> Iterator[sqlite3.Connection]: conn.execute("PRAGMA foreign_keys = ON") try: yield conn + except BaseException: + conn.rollback() + raise + else: + conn.commit() finally: conn.close() @@ -346,6 +351,8 @@ def _continuity_payload( "reason": values.get("reason"), "now_ms": int(time.time() * 1000), } + if kind == "recover_abandoned_attempts": + return {"now_ms": int(time.time() * 1000)} raise RuntimeError(f"unregistered audit continuity mutation {kind!r}") def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMutation) -> object: @@ -395,6 +402,8 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta domain_receipt_ref=cast(str | None, payload.get("domain_receipt_ref")), reason=cast(str | None, payload.get("reason")), ) + if mutation.kind == "recover_abandoned_attempts": + return cast(Any, self._recover_abandoned_attempts).__wrapped__(self) raise RuntimeError(f"unregistered audit continuity mutation {mutation.kind!r}") finally: self._coordinated_mutation = None @@ -727,10 +736,17 @@ def finalize_attempt( """Finalize one running attempt and parent run in one audit transaction.""" now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) - target_state: AuditTargetState = ( - "unknown" if status == "unknown" else "failed" if status == "failed" else "applied" + target_state = cast( + AuditTargetState, + { + "unknown": "unknown", + "failed": "failed", + "blocked": "rejected", + }.get(status, "applied"), + ) + attempt_state = ( + "unknown" if target_state == "unknown" else "failed" if target_state == "rejected" else target_state ) - attempt_state = "unknown" if target_state == "unknown" else target_state with self._connection() as conn: self._begin(conn) run = conn.execute( @@ -739,7 +755,7 @@ def finalize_attempt( if run is None: raise ValueError(f"unknown operation {operation_id!r}") target = conn.execute( - "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'running' ORDER BY ordinal LIMIT 1", + "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state IN ('running', 'pending') ORDER BY ordinal LIMIT 1", (operation_id,), ).fetchone() ordinal = int(target[0]) if target is not None else None @@ -751,25 +767,23 @@ def finalize_attempt( """, (attempt_state, now_ms, error_summary, unknown_reason, operation_id), ) - if ordinal is not None: - conn.execute( - """ - UPDATE operation_targets - SET state = ?, completed_at_ms = ?, error_summary = ?, unknown_reason = ?, - domain_receipt_ref = ?, domain_receipt_kind = ? - WHERE operation_id = ? AND ordinal = ? - """, - ( - target_state, - now_ms, - error_summary, - unknown_reason, - None if receipt is None else receipt.receipt_ref, - None if receipt is None else "mutation-receipt", - operation_id, - ordinal, - ), - ) + conn.execute( + """ + UPDATE operation_targets + SET state = ?, completed_at_ms = ?, error_summary = ?, unknown_reason = ?, + domain_receipt_ref = ?, domain_receipt_kind = ? + WHERE operation_id = ? AND state IN ('running', 'pending') + """, + ( + target_state, + now_ms, + error_summary, + unknown_reason, + None if receipt is None else receipt.receipt_ref, + None if receipt is None else "mutation-receipt", + operation_id, + ), + ) states = [ str(row[0]) for row in conn.execute("SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,)) @@ -816,9 +830,64 @@ def finalize_attempt( to_state=run_status, actor_ref=str(run[0]), occurred_at_ms=now_ms, - detail={"status": status, "reason": (unknown_reason or error_summary or "")[:512]}, + detail={ + "status": status, + "reason": (unknown_reason or error_summary or "")[:512], + "domain_receipt": {} if receipt is None else receipt.domain_receipt, + }, ) + def recover_abandoned_attempts(self) -> tuple[str, ...]: + """Recover only work that a prior process actually left running.""" + + with self._connection() as conn: + has_running = conn.execute("SELECT 1 FROM operation_attempts WHERE state = 'running' LIMIT 1").fetchone() + if has_running is None: + return () + return self._recover_abandoned_attempts() + + @_continuity_mutation("recover_abandoned_attempts") + def _recover_abandoned_attempts(self) -> tuple[str, ...]: + """Mark persisted in-flight work unknown before a fresh executor can act. + + A running attempt has no durable worker lease or resumable process + handle. Seeing it during a new executor construction therefore proves + only that the previous process stopped before it finalized the effect. + Preserve that uncertainty instead of leaving an unreconcilable running + operation forever. + """ + + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) + with self._connection() as conn: + self._begin(conn) + rows = conn.execute( + "SELECT DISTINCT operation_id FROM operation_attempts WHERE state = 'running' ORDER BY operation_id" + ).fetchall() + operation_ids = tuple(str(row[0]) for row in rows) + for operation_id in operation_ids: + conn.execute( + "UPDATE operation_attempts SET state = 'unknown', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'running'", + (now_ms, "process ended before audit finalization", operation_id), + ) + conn.execute( + "UPDATE operation_targets SET state = 'unknown', unknown_reason = ? WHERE operation_id = ? AND state IN ('running', 'pending')", + ("process ended before audit finalization", operation_id), + ) + conn.execute( + "UPDATE operation_runs SET status = 'interrupted', terminal_reason = 'unknown_effect', updated_at_ms = ?, completed_at_ms = ?, unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), unknown_reason = ? WHERE operation_id = ?", + (now_ms, now_ms, operation_id, "process ended before audit finalization", operation_id), + ) + self._append_event( + conn, + operation_id=operation_id, + event_type="attempt_unknown", + from_state="running", + to_state="interrupted", + occurred_at_ms=now_ms, + detail={"reason": "process ended before audit finalization"}, + ) + return operation_ids + @_continuity_mutation("reconcile_attempt") def reconcile_attempt( self, diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 82224d7063..cf6c989158 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1404,16 +1404,18 @@ def revalidate_exact_backup() -> None: # A restore can stop after its audit-side rebind commit while the source # WAL still awaits promotion. Reconcile that exact operation before a # retry tries to prepare a second command. - rebind_already_committed = ( - coordinator.reconcile_pending_rebind(rebind_mutation_id) - if coordinator.has_pending_rebind(rebind_mutation_id) - else coordinator.has_committed_mutation(rebind_mutation_id) - ) + try: + rebind_already_committed = ( + coordinator.reconcile_pending_rebind(rebind_mutation_id) + if coordinator.has_pending_rebind(rebind_mutation_id) + else coordinator.has_committed_mutation(rebind_mutation_id) + ) + except AuditContinuityError as exc: + if "cannot read audit continuity commit state" not in str(exc): + raise published = False try: - if rebind_already_committed or _audit_file_matches_artifact( - archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size - ): + if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): published = True else: _copy_restore_artifact( @@ -1432,7 +1434,7 @@ def revalidate_exact_backup() -> None: os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) published = True - if not rebind_already_committed and _audit_file_sha256(path) != artifact_sha256: + if _audit_file_sha256(path) != artifact_sha256: raise MigrationError("adopted-audit restore published image changed before continuity rebind") identity = _audit_file_identity(path) version, application_id, quick_check = _audit_live_metadata(path) diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index 8bd381e1d5..51dcb71303 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -226,6 +226,19 @@ def compute_target_digest(targets: tuple[MutationTarget, ...]) -> str: return _sha256_document([target.canonical_dict() for target in targets]) +def _parameter_digest(raw_plan: MutationPlan) -> str: + """Hash stable caller intent without the clock-bound preview envelope.""" + + return _sha256_document( + { + "operation": raw_plan.operation, + "destructive_class": raw_plan.destructive_class, + "affected_tiers": list(raw_plan.affected_tiers), + "context": {key: raw_plan.context[key] for key in sorted(raw_plan.context)}, + } + ) + + def compute_typed_plan_hash( *, operation: str, @@ -238,6 +251,7 @@ def compute_typed_plan_hash( destructive_class: DestructiveClass, required_confirmation: ConfirmationStrength, affected_tiers: tuple[str, ...], + context: Mapping[str, object], ) -> str: """Hash every authority-relevant field of a typed mutation plan.""" @@ -253,6 +267,7 @@ def compute_typed_plan_hash( "destructive_class": destructive_class, "required_confirmation": required_confirmation, "affected_tiers": list(affected_tiers), + "context": {key: context[key] for key in sorted(context)}, } ) @@ -380,6 +395,7 @@ def build_typed_plan( destructive_class=destructive_class, required_confirmation=required_confirmation, affected_tiers=affected_tiers, + context=context or {}, ) return MutationPlan( operation=operation, @@ -549,10 +565,12 @@ def __init__( audit: AuditRepository | None = None, now_ms: Callable[[], int] | None = None, token_factory: Callable[[], str] | None = None, + archive_root: Path | None = None, ) -> None: self._audit = audit self._now_ms = now_ms or (lambda: int(datetime.now(UTC).timestamp() * 1000)) self._token_factory = token_factory or (lambda: secrets.token_urlsafe(32)) + self._archive_root = archive_root @classmethod def for_archive_root( @@ -568,7 +586,8 @@ def for_archive_root( audit = AuditRepository.for_archive_root(archive_root) audit.reconcile_continuity() - return cls(audit=audit, now_ms=now_ms, token_factory=token_factory) + audit.recover_abandoned_attempts() + return cls(audit=audit, now_ms=now_ms, token_factory=token_factory, archive_root=archive_root) def prepare(self, actuator: MutationActuator[ArgsT], args: ArgsT) -> MutationPlan: """PREPARE: resolve exact targets from live state. Never mutates.""" @@ -585,6 +604,7 @@ def prepare_bound( archive_identity_digest: str, parameter_digest: str, expires_at_ms: int | None = None, + raw_plan: MutationPlan | None = None, ) -> MutationPreview: """Prepare and durably record a versioned, capability-bound preview.""" @@ -593,7 +613,7 @@ def prepare_bound( raise SurfaceDeniedError(f"{binding.spec.name!r} is not allowed on {principal.surface!r}") plan = self._typed_plan_from_actuator( binding, - binding.actuator.prepare(args), + raw_plan or binding.actuator.prepare(args), archive_instance_id=archive_instance_id, archive_identity_digest=archive_identity_digest, parameter_digest=parameter_digest, @@ -625,7 +645,8 @@ def prepare_bound_for_archive( principal, archive_instance_id=self._audit.ensure_archive_authority(now_ms=self._now_ms()), archive_identity_digest=ArchiveIdentity.resolve(archive_root).authority_identity_digest, - parameter_digest=_sha256_document(raw_plan.to_dict()), + parameter_digest=_parameter_digest(raw_plan), + raw_plan=raw_plan, ) def authorize_bound( @@ -684,6 +705,12 @@ def execute_bound( raise AuthorizationMismatchError("authorization is not bound to this preview") if authorization.expires_at_ms is not None and self._now_ms() >= authorization.expires_at_ms: raise TokenExpiredError("authorization token is expired") + if self._archive_root is not None: + from polylogue.storage.archive_identity import ArchiveIdentity + + live_identity = ArchiveIdentity.resolve(self._archive_root).authority_identity_digest + if live_identity != preview.plan.archive_identity_digest: + raise PlanStaleError("archive identity changed after the bound preview was prepared") fresh_plan = self._typed_plan_from_actuator( binding, binding.actuator.prepare(args), diff --git a/polylogue/storage/sqlite/archive_tiers/audit.py b/polylogue/storage/sqlite/archive_tiers/audit.py index 152f0dc3e5..c4de2a8f75 100644 --- a/polylogue/storage/sqlite/archive_tiers/audit.py +++ b/polylogue/storage/sqlite/archive_tiers/audit.py @@ -6,6 +6,8 @@ from __future__ import annotations +from polylogue.storage.sqlite.audit_continuity import AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 + AUDIT_SCHEMA_VERSION = 2 AUDIT_DDL = """ @@ -227,7 +229,9 @@ ) STRICT; INSERT OR IGNORE INTO audit_continuity_head( singleton, generation, head_sha256, mutation_id, advanced_at_ms -) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, 0); +) VALUES (1, 0, '__AUDIT_CONTINUITY_GENESIS_HEAD__', NULL, 0); """ +AUDIT_DDL = AUDIT_DDL.replace("__AUDIT_CONTINUITY_GENESIS_HEAD__", AUDIT_CONTINUITY_GENESIS_HEAD_SHA256) + __all__ = ["AUDIT_DDL", "AUDIT_SCHEMA_VERSION"] diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 19bd2cbdfe..101905dd5b 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -4,6 +4,7 @@ import os import sqlite3 +from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -314,14 +315,14 @@ def _source_has_audit_continuity_control(source_path: Path) -> bool: if not source_path.is_file(): return False try: - with sqlite3.connect(f"{source_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) as connection: + with closing(sqlite3.connect(f"{source_path.resolve(strict=True).as_uri()}?mode=ro", uri=True)) as connection: return ( connection.execute( "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'audit_continuity_control'" ).fetchone() is not None ) - except sqlite3.DatabaseError: + except (OSError, sqlite3.DatabaseError): return False @@ -371,6 +372,20 @@ def assert_owned_root() -> None: has_bootstrap_marker = (manifest_root / ".bootstrap").is_file() pending_bootstrap_path = manifest_root / ".bootstrap.pending" has_pending_bootstrap = pending_bootstrap_path.is_file() + + def classify_paths() -> tuple[bool, bool]: + durable_exists = any((root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS) + adoption = ( + (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() + and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) + and manifest_root.is_dir() + and not has_durable_train_state + and not has_bootstrap_marker + and not has_pending_bootstrap + ) + return durable_exists, adoption + + durable_tier_exists, pre_marker_adoption = classify_paths() if has_pending_bootstrap: _validate_fresh_durable_bootstrap_intent(root) if has_durable_train_state: @@ -387,14 +402,6 @@ def assert_owned_root() -> None: recovering_fresh_durable_bootstrap = fresh_durable_bootstrap or ( has_pending_bootstrap and not has_bootstrap_marker ) - pre_marker_adoption = ( - (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() - and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) - and manifest_root.is_dir() - and not has_durable_train_state - and not has_bootstrap_marker - and not has_pending_bootstrap - ) if fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap_intent(root) @@ -404,17 +411,7 @@ def assert_owned_root() -> None: # Receipt-backed recovery can add audit.db to a legacy archive. # Recompute the path-sensitive classification before deciding # whether startup must create the missing bootstrap marker. - durable_tier_exists = any( - (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS - ) - pre_marker_adoption = ( - (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() - and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) - and manifest_root.is_dir() - and not has_durable_train_state - and not has_bootstrap_marker - and not has_pending_bootstrap - ) + durable_tier_exists, pre_marker_adoption = classify_paths() if ( durable_tier_exists and not recovering_fresh_durable_bootstrap diff --git a/polylogue/storage/sqlite/archive_tiers/source.py b/polylogue/storage/sqlite/archive_tiers/source.py index f0b0ce4692..344f567149 100644 --- a/polylogue/storage/sqlite/archive_tiers/source.py +++ b/polylogue/storage/sqlite/archive_tiers/source.py @@ -20,6 +20,7 @@ ) from polylogue.storage.sqlite.archive_tiers.common import check, literal_check, nullable_check from polylogue.storage.sqlite.archive_tiers.types import ProvenRevisionAuthority +from polylogue.storage.sqlite.audit_continuity import AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 SOURCE_SCHEMA_VERSION = 32 @@ -879,7 +880,7 @@ INSERT OR IGNORE INTO audit_continuity_control( singleton, committed_generation, committed_head_sha256, pending_mutation_id, pending_payload_json, pending_payload_sha256, prepared_at_ms -) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, NULL, NULL, NULL); +) VALUES (1, 0, '{AUDIT_CONTINUITY_GENESIS_HEAD_SHA256}', NULL, NULL, NULL, NULL); """ diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index f0750c5fec..1899c9123a 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -20,6 +20,7 @@ from typing import TypeVar, cast _FORMAT = "polylogue.audit-continuity-command.v1" +AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 = "3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f" _T = TypeVar("_T") @@ -209,8 +210,10 @@ def has_committed_mutation(self, mutation_id: str) -> bool: audit_row = audit.execute( "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" ).fetchone() - except sqlite3.DatabaseError: - return False + except sqlite3.DatabaseError as exc: + if "no such table" in str(exc).lower(): + return False + raise AuditContinuityError("cannot read audit continuity commit state") from exc if source_row is None or audit_row is None: raise AuditContinuityError("audit continuity control row is missing") if (int(source_row[0]), str(source_row[1])) != (int(audit_row[0]), str(audit_row[1])): @@ -358,33 +361,34 @@ def _abort_prepared(self, prepared: Mapping[str, object]) -> None: prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) with closing(sqlite3.connect(self.audit_path)) as audit: + audit.execute("BEGIN IMMEDIATE") row = audit.execute( "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" ).fetchone() - if row is None: - raise AuditContinuityError("audit continuity head is missing while aborting a prepared command") - current = (int(row[0]), str(row[1]), row[2]) - if current[:2] == target and current[2] == mutation.mutation_id: - # The audit commit did land. Keep the WAL command for normal - # promotion instead of mistaking an ambiguous failure for rollback. - return - if current[:2] != prior: - raise AuditContinuityError("cannot abort prepared command after an unrelated audit head change") - with closing(sqlite3.connect(self.source_path)) as source, source: - source.execute("BEGIN IMMEDIATE") - cursor = source.execute( - """ + if row is None: + raise AuditContinuityError("audit continuity head is missing while aborting a prepared command") + current = (int(row[0]), str(row[1]), row[2]) + if current[:2] == target and current[2] == mutation.mutation_id: + # The audit commit did land. Keep the WAL command for normal + # promotion instead of mistaking an ambiguous failure for rollback. + return + if current[:2] != prior: + raise AuditContinuityError("cannot abort prepared command after an unrelated audit head change") + with closing(sqlite3.connect(self.source_path)) as source, source: + source.execute("BEGIN IMMEDIATE") + cursor = source.execute( + """ UPDATE audit_continuity_control SET pending_mutation_id = NULL, pending_payload_json = NULL, pending_payload_sha256 = NULL, prepared_at_ms = NULL WHERE singleton = 1 AND committed_generation = ? AND committed_head_sha256 = ? AND pending_mutation_id = ? AND pending_payload_sha256 = ? """, - (prior[0], prior[1], mutation.mutation_id, _sha256(dict(prepared))), - ) - if cursor.rowcount != 1: - raise AuditContinuityError("source audit continuity abort lost its prepared command") - source.commit() + (prior[0], prior[1], mutation.mutation_id, _sha256(dict(prepared))), + ) + if cursor.rowcount != 1: + raise AuditContinuityError("source audit continuity abort lost its prepared command") + source.commit() def _assert_committed_head_matches_audit(self) -> None: with closing(sqlite3.connect(self.source_path)) as source, closing(sqlite3.connect(self.audit_path)) as audit: diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 1d0e4864bc..7b5b6bb50d 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -78,6 +78,14 @@ _SourceContinuityMutationKind = Literal["blob_ref_liveness", "raw_authority_recovery"] _FRESH_DURABLE_BOOTSTRAP_FORMAT = "polylogue.durable-bootstrap.v1" _FRESH_DURABLE_BOOTSTRAP_MARKER = ".bootstrap" + + +def _is_audit_continuity_receipt(path: Path) -> bool: + """Return whether a maintenance receipt is not a durable train manifest.""" + + return path.name in {"audit-adoption.json", "audit-continuity.json"} or path.name.startswith("audit-restore.") + + _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER = ".bootstrap.pending" @@ -331,10 +339,7 @@ def _record_fresh_durable_bootstrap(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") - for path in marker_root.glob("*.json") - ): + if marker_path.exists() or any(not _is_audit_continuity_receipt(path) for path in marker_root.glob("*.json")): raise DurableChangeTrainError(f"cannot record fresh durable bootstrap over existing train state: {marker_root}") if pending_path.is_file(): _validate_fresh_durable_bootstrap_intent(archive_root) @@ -367,10 +372,7 @@ def _record_fresh_durable_bootstrap_intent(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") - for path in marker_root.glob("*.json") - ): + if marker_path.exists() or any(not _is_audit_continuity_receipt(path) for path in marker_root.glob("*.json")): raise DurableChangeTrainError( f"cannot record fresh durable bootstrap intent over existing train state: {marker_root}" ) @@ -514,10 +516,7 @@ def _adopt_pre_marker_durable_bootstrap(archive_root: Path) -> None: manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" if (manifest_root / _FRESH_DURABLE_BOOTSTRAP_MARKER).is_file(): return - if any( - path.name not in {"audit-adoption.json", "audit-continuity.json"} and not path.name.startswith("audit-restore.") - for path in manifest_root.glob("*.json") - ): + if any(not _is_audit_continuity_receipt(path) for path in manifest_root.glob("*.json")): return for tier in DURABLE_MIGRATION_ADOPTION_FLOORS: tier_path = archive_root / f"{tier.value}.db" @@ -1981,7 +1980,7 @@ def _released_train_manifests_by_target( if not manifest_root.is_dir(): return manifests_by_target for path in sorted(manifest_root.glob(f"{tier.value}-*.json")): - if path.name in {"audit-adoption.json", "audit-continuity.json"} or path.name.startswith("audit-restore."): + if _is_audit_continuity_receipt(path): continue train = load_durable_change_train_manifest(path) if train.target_version in manifests_by_target: @@ -2316,10 +2315,7 @@ def _reconcile_durable_change_train_startup_locked( manifests_by_tier: dict[ArchiveTier, dict[int, DurableChangeTrain]] = {} validated_tiers: set[ArchiveTier] = set() manifest_paths = tuple( - path - for path in sorted(manifest_root.glob("*.json")) - if path.name not in {"audit-adoption.json", "audit-continuity.json"} - and not path.name.startswith("audit-restore.") + path for path in sorted(manifest_root.glob("*.json")) if not _is_audit_continuity_receipt(path) ) fresh_bootstrap_versions = _fresh_durable_bootstrap_versions(archive_root, manifest_root) @@ -2386,7 +2382,9 @@ def record_reconciled(path: Path) -> None: if current_version <= adoption_floor: continue manifests_by_tier[tier] = _released_train_manifests_by_target(manifest_root, tier) - tier_manifest_paths = tuple(manifest_root.glob(f"{tier.value}-*.json")) + tier_manifest_paths = tuple( + path for path in manifest_root.glob(f"{tier.value}-*.json") if not _is_audit_continuity_receipt(path) + ) bootstrap_version = fresh_bootstrap_versions.get(tier) if bootstrap_version is not None and current_version < bootstrap_version: raise DurableChangeTrainError( diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index e0c530d115..969601434c 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -1050,7 +1050,7 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path) """Allow a retrying restore to differ only in the source continuity table.""" try: - with sqlite3.connect(f"{live_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) as connection: + with closing(sqlite3.connect(f"{live_path.resolve(strict=True).as_uri()}?mode=ro", uri=True)) as connection: connection.execute( "ATTACH DATABASE ? AS backup_source", (f"{backup_path.resolve(strict=True).as_uri()}?mode=ro",) ) @@ -1072,9 +1072,15 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path) backup_count = int(connection.execute(f"SELECT COUNT(*) FROM backup_source.{quoted}").fetchone()[0]) if live_count != backup_count: raise MigrationError("adopted-audit restore backup is stale for source.db") + columns = [str(row[1]) for row in connection.execute(f"PRAGMA main.table_info({quoted})")] + if not columns: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + grouped_columns = ", ".join(_quote_sqlite_identifier(column) for column in columns) for left, right in (("main", "backup_source"), ("backup_source", "main")): differs = connection.execute( - f"SELECT 1 FROM (SELECT * FROM {left}.{quoted} EXCEPT SELECT * FROM {right}.{quoted}) LIMIT 1" + f"SELECT 1 FROM (SELECT {grouped_columns}, COUNT(*) AS multiplicity FROM {left}.{quoted} " + f"GROUP BY {grouped_columns} EXCEPT SELECT {grouped_columns}, COUNT(*) AS multiplicity " + f"FROM {right}.{quoted} GROUP BY {grouped_columns}) LIMIT 1" ).fetchone() if differs is not None: raise MigrationError("adopted-audit restore backup is stale for source.db") diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index deee2d6a57..5fdf2a9a0f 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import sqlite3 from dataclasses import dataclass, field, replace from pathlib import Path @@ -285,6 +286,15 @@ def interrupt_finalize(self: AuditContinuityCoordinator, phase: str, mutation: A AuditRepository.for_archive_root(tmp_path).reconcile_continuity() with sqlite3.connect(tmp_path / "audit.db") as conn: assert conn.execute("SELECT status FROM operation_runs").fetchone() == ("completed",) + receipt_json = str( + conn.execute("SELECT detail_json FROM operation_events WHERE event_type = 'attempt_finalized'").fetchone()[ + 0 + ] + ) + assert "private-cache" not in receipt_json + assert json.loads(receipt_json)["domain_receipt"]["outcomes"] == [ + {"row_ref": "assertion:typed", "status": "imported"} + ] with sqlite3.connect(tmp_path / "source.db") as source: command = source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0] assert command is None diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index 943930b637..bb56f52016 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -4,6 +4,7 @@ import hashlib import sqlite3 +from contextlib import closing from pathlib import Path import pytest @@ -63,6 +64,8 @@ def interrupt(phase: str, _mutation: AuditMutation) -> None: AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with closing(sqlite3.connect(tmp_path / "source.db")) as source: + assert source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone() == (None,) def test_pending_command_replays_after_audit_rollback(tmp_path: Path) -> None: @@ -120,10 +123,10 @@ def reject(_conn: sqlite3.Connection, _mutation: AuditMutation) -> object: with pytest.raises(ValueError, match="already consumed"): coordinator.execute(_mutation(1), reject) - with sqlite3.connect(tmp_path / "source.db") as source: + with closing(sqlite3.connect(tmp_path / "source.db")) as source: assert source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone() == (None,) assert coordinator.execute(_mutation(2), _apply) == "mutation:2" - with sqlite3.connect(tmp_path / "audit.db") as audit: + with closing(sqlite3.connect(tmp_path / "audit.db")) as audit: assert audit.execute("SELECT generation, mutation_id FROM audit_continuity_head").fetchone() == ( 1, "mutation:2", @@ -177,7 +180,7 @@ def test_rebind_rejects_a_stale_in_place_image_before_blessing_it(tmp_path: Path audit_path = tmp_path / "audit.db" stale_bytes = audit_path.read_bytes() inode = audit_path.stat().st_ino - with sqlite3.connect(audit_path) as audit: + with closing(sqlite3.connect(audit_path)) as audit: audit.execute( "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", ("newer-audit-image", 1), diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index d4cd0664aa..3bf5f75973 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -2171,10 +2171,10 @@ def test_adopted_audit_restore_rejects_backup_swap_after_validation( real_validate = validate_full_evidence_backup_for_adopted_audit_restore calls = 0 - def swap_after_validation(path: Path, *, archive_root: Path) -> tuple[Path, Path]: + def swap_after_validation(path: Path, *, archive_root: Path, **kwargs: object) -> tuple[Path, Path]: nonlocal calls calls += 1 - manifest, receipt = real_validate(path, archive_root=archive_root) + manifest, receipt = real_validate(path, archive_root=archive_root, **kwargs) # type: ignore[arg-type] if calls == 2: receipt.write_bytes(receipt.read_bytes() + b"\n") return manifest, receipt From 2b74cd1a4ce3fa4e91b6620e0f94aee402f7e3cd Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 23:58:37 +0200 Subject: [PATCH 16/28] fix(audit): close continuity recovery gaps Repair multi-target finalization and reconciliation, preserve terminal rejection accounting, remove legacy authority bypasses, and keep user-authored receipt content out of audit events. Also make adopted audit rebind recovery occur only after durable publication and publish the typed migrate-tier result schema. --- devtools/render_cli_output_schemas.py | 11 ++ docs/cli-reference.md | 1 + docs/schemas/cli-output/README.md | 1 + .../migrate-tier-result.schema.json | 165 ++++++++++++++++++ polylogue/annotations/importer.py | 2 +- polylogue/api/archive.py | 2 +- polylogue/api/ingest.py | 4 +- .../cli/commands/maintenance/_migrate_tier.py | 61 ++++--- .../maintenance/raw_authority_recovery.py | 2 +- polylogue/operations/audit.py | 83 ++++++--- polylogue/operations/bindings.py | 45 +---- polylogue/operations/durable_change_train.py | 21 +-- polylogue/operations/specs.py | 77 +++++++- .../storage/sqlite/archive_tiers/bootstrap.py | 10 +- .../unit/cli/test_archive_maintenance_cli.py | 2 + tests/unit/operations/test_operation_audit.py | 96 +++++++++- .../operations/test_operation_bindings.py | 17 +- tests/unit/storage/test_audit_continuity.py | 14 ++ 18 files changed, 491 insertions(+), 123 deletions(-) create mode 100644 docs/schemas/cli-output/migrate-tier-result.schema.json diff --git a/devtools/render_cli_output_schemas.py b/devtools/render_cli_output_schemas.py index b8a656f320..f5d23acc09 100644 --- a/devtools/render_cli_output_schemas.py +++ b/devtools/render_cli_output_schemas.py @@ -24,6 +24,7 @@ from devtools.command_catalog import control_plane_command from devtools.render_support import write_if_changed from polylogue.archive.query.metadata import terminal_query_cli_surfaces, terminal_query_source_list +from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload from polylogue.operations.action_contracts import ActionAffordanceListPayload from polylogue.surfaces.payloads import ( ArchiveDebtListPayload, @@ -259,6 +260,16 @@ class CliOutputSchema: "MCP action_affordances", ), ), + CliOutputSchema( + name="migrate-tier-result", + title="Migrate Tier Result", + description=( + "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable " + "adoption and restore receipt references." + ), + model=MigrateTierResultPayload, + surfaces=("polylogue ops maintenance migrate-tier --output-format json",), + ), CliOutputSchema( name="machine-error", title="Machine Error Envelope", diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2202e2a1b4..89b36c80b4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -801,6 +801,7 @@ The schema files live under `docs/schemas/cli-output/`. | `session-neighbor-candidate` | `SessionNeighborCandidatePayload` | `polylogue read --view neighbors --format json` | | `mutation-result` | `MutationResultPayload` | `polylogue find then delete --dry-run`
`polylogue find then delete --yes`
`MCP mutation tools`
`daemon mutation endpoints` | | `action-affordance-list` | `ActionAffordanceListPayload` | `polylogue config action-affordances`
`GET /api/action-affordances`
`MCP action_affordances` | +| `migrate-tier-result` | `MigrateTierResultPayload` | `polylogue ops maintenance migrate-tier --output-format json` | | `machine-error` | `MachineErrorPayload` | `polylogue * --machine (error path)` | | `machine-success` | `MachineSuccessPayload` | `polylogue * --machine (success path)` | | `query-error` | `QueryErrorPayload` | `GET /api/sessions?query=... (error path)`
`daemon query/read error responses`
`MCP query/read error responses` | diff --git a/docs/schemas/cli-output/README.md b/docs/schemas/cli-output/README.md index a79694e19a..58301b8477 100644 --- a/docs/schemas/cli-output/README.md +++ b/docs/schemas/cli-output/README.md @@ -30,6 +30,7 @@ devtools render cli-output-schemas --check # CI sync check | [`session-neighbor-candidate.schema.json`](./session-neighbor-candidate.schema.json) | `polylogue read --view neighbors --format json` | `SessionNeighborCandidatePayload` | | [`mutation-result.schema.json`](./mutation-result.schema.json) | `polylogue find then delete --dry-run`
`polylogue find then delete --yes`
`MCP mutation tools`
`daemon mutation endpoints` | `MutationResultPayload` | | [`action-affordance-list.schema.json`](./action-affordance-list.schema.json) | `polylogue config action-affordances`
`GET /api/action-affordances`
`MCP action_affordances` | `ActionAffordanceListPayload` | +| [`migrate-tier-result.schema.json`](./migrate-tier-result.schema.json) | `polylogue ops maintenance migrate-tier --output-format json` | `MigrateTierResultPayload` | | [`machine-error.schema.json`](./machine-error.schema.json) | `polylogue * --machine (error path)` | `MachineErrorPayload` | | [`machine-success.schema.json`](./machine-success.schema.json) | `polylogue * --machine (success path)` | `MachineSuccessPayload` | | [`query-error.schema.json`](./query-error.schema.json) | `GET /api/sessions?query=... (error path)`
`daemon query/read error responses`
`MCP query/read error responses` | `QueryErrorPayload` | diff --git a/docs/schemas/cli-output/migrate-tier-result.schema.json b/docs/schemas/cli-output/migrate-tier-result.schema.json new file mode 100644 index 0000000000..01af5d9635 --- /dev/null +++ b/docs/schemas/cli-output/migrate-tier-result.schema.json @@ -0,0 +1,165 @@ +{ + "$id": "https://polylogue.dev/schemas/cli-output/migrate-tier-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable adoption and restore receipt references.\n\nGenerated from `polylogue.cli.commands.maintenance._migrate_tier.MigrateTierResultPayload` by `devtools render cli-output-schemas`. Do not edit by hand.", + "properties": { + "adoption_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Adoption Receipt" + }, + "applied_versions": { + "items": { + "type": "integer" + }, + "title": "Applied Versions", + "type": "array" + }, + "backup_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Manifest" + }, + "backup_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Receipt" + }, + "forward_version_receipt": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Forward Version Receipt" + }, + "from_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "From Version" + }, + "initialized": { + "title": "Initialized", + "type": "boolean" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "path": { + "title": "Path", + "type": "string" + }, + "restore_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Restore Receipt" + }, + "stopped_daemon_evidence_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopped Daemon Evidence Ref" + }, + "tier": { + "title": "Tier", + "type": "string" + }, + "to_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "To Version" + }, + "train_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train Manifest" + }, + "train_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train State" + } + }, + "required": [ + "ok", + "tier", + "path", + "initialized", + "adoption_receipt", + "restore_receipt", + "backup_manifest", + "stopped_daemon_evidence_ref", + "train_manifest", + "train_state", + "backup_receipt", + "from_version", + "to_version", + "applied_versions", + "forward_version_receipt" + ], + "title": "Migrate Tier Result", + "type": "object", + "x-polylogue-cli-surfaces": [ + "polylogue ops maintenance migrate-tier --output-format json" + ], + "x-polylogue-source-model": "MigrateTierResultPayload" +} diff --git a/polylogue/annotations/importer.py b/polylogue/annotations/importer.py index a6cc9310e9..5647bf0b91 100644 --- a/polylogue/annotations/importer.py +++ b/polylogue/annotations/importer.py @@ -475,7 +475,7 @@ async def default_resolver(ref: str) -> bool: binding = runtime_operation_binding(actuator) principal = MutationPrincipal( request.actor_ref, - frozenset({"archive.annotation.import_batch", "archive.legacy_runtime"}), + frozenset({"archive.annotation.import_batch"}), "internal", "write", ) diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index fae8444bd1..23ff20dbdf 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -2696,7 +2696,7 @@ def _execute_facade_mutation( root = _active_archive_root(self.config) executor = OperationExecutor.for_archive_root(root) binding = runtime_operation_binding(actuator) - principal = MutationPrincipal("facade", frozenset({capability, "archive.legacy_runtime"}), "api", "write") + principal = MutationPrincipal("facade", frozenset({capability}), "api", "write") preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) authorization = executor.authorize_bound(binding, preview, principal) receipt = executor.execute_bound(binding, preview, authorization, args) diff --git a/polylogue/api/ingest.py b/polylogue/api/ingest.py index e5bd0ce4ba..a65e52ccd3 100644 --- a/polylogue/api/ingest.py +++ b/polylogue/api/ingest.py @@ -71,9 +71,7 @@ async def rebuild_index(self) -> bool: root = _active_archive_root(self.config) executor = OperationExecutor.for_archive_root(root) binding = runtime_operation_binding(actuator) - principal = MutationPrincipal( - "facade", frozenset({"archive.rebuild_index", "archive.legacy_runtime"}), "api", "write" - ) + principal = MutationPrincipal("facade", frozenset({"archive.rebuild_index"}), "api", "write") preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) authorization = executor.authorize_bound(binding, preview, principal) receipt = executor.execute_bound(binding, preview, authorization, args) diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 9617240272..3663893803 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -21,6 +21,7 @@ from pathlib import Path import click +from pydantic import BaseModel, ConfigDict from polylogue.operations.durable_change_train import ( ArchiveOwnershipError, @@ -37,6 +38,28 @@ from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS, MigrationError +class MigrateTierResultPayload(BaseModel): + """Stable machine-readable result for one durable-tier migration route.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: bool + tier: str + path: str + initialized: bool + adoption_receipt: str | None + restore_receipt: str | None + backup_manifest: str | None + stopped_daemon_evidence_ref: str | None + train_manifest: str | None + train_state: str | None + backup_receipt: str | None + from_version: int | None + to_version: int | None + applied_versions: list[int] + forward_version_receipt: dict[str, object] | None + + def _daemon_pidfile_is_live(pidfile: Path) -> bool: """Return whether the archive pidfile names a live polylogued process.""" try: @@ -186,26 +209,24 @@ def migrate_tier_command( result = execution.migration_result if execution is not None else None receipt = execution.forward_version_receipt if execution is not None else None - payload = { - "ok": True, - "tier": tier, - "path": str(path), - "initialized": initialized, - "adoption_receipt": str(adoption_receipt) if adoption_receipt is not None else None, - "restore_receipt": str(restore_receipt) if restore_receipt is not None else None, - "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, - "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, - "train_manifest": ( + payload = MigrateTierResultPayload( + ok=True, + tier=tier, + path=str(path), + initialized=initialized, + adoption_receipt=str(adoption_receipt) if adoption_receipt is not None else None, + restore_receipt=str(restore_receipt) if restore_receipt is not None else None, + backup_manifest=str(backup_manifest) if backup_manifest is not None else None, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + train_manifest=( str(execution.manifest_path) if execution is not None and execution.manifest_path is not None else None ), - "train_state": execution.train.state.value if execution is not None and execution.train is not None else None, - "backup_receipt": str(result.backup_receipt) - if result is not None and result.backup_receipt is not None - else None, - "from_version": result.from_version if result is not None else 0 if initialized else None, - "to_version": result.to_version if result is not None else initialized_version, - "applied_versions": list(result.applied_versions) if result is not None else [], - "forward_version_receipt": ( + train_state=execution.train.state.value if execution is not None and execution.train is not None else None, + backup_receipt=str(result.backup_receipt) if result is not None and result.backup_receipt is not None else None, + from_version=result.from_version if result is not None else 0 if initialized else None, + to_version=result.to_version if result is not None else initialized_version, + applied_versions=list(result.applied_versions) if result is not None else [], + forward_version_receipt=( { "tier": receipt.tier.value, "historical_train_id": receipt.historical_train_id, @@ -218,9 +239,9 @@ def migrate_tier_command( if receipt is not None else None ), - } + ) if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) + click.echo(json.dumps(payload.model_dump(mode="json"), indent=2, sort_keys=True)) return if adoption_receipt is not None: diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 49accf6f55..95dc6a77d3 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -1543,7 +1543,7 @@ def apply_raw_authority_recovery( binding = runtime_operation_binding(actuator) principal = MutationPrincipal( "cli:maintenance", - frozenset({"archive.raw_authority_recovery", "archive.legacy_runtime"}), + frozenset({"archive.raw_authority_recovery"}), "maintenance", "maintenance", ) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index c9914fed93..4cb8145174 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -40,6 +40,32 @@ _F = TypeVar("_F", bound=Callable[..., object]) +def _run_state_for_targets(states: list[str]) -> tuple[str, str | None]: + """Derive the parent lifecycle state from the complete target set.""" + + if "unknown" in states: + return "interrupted", "unknown_effect" + if "rejected" in states: + return "failed", "target_rejected" + if "failed" in states: + return "failed", "domain_failure" + if states and all(state in {"applied", "already_satisfied"} for state in states): + return "completed", None + return "running", None + + +def _receipt_event_detail(receipt: MutationReceipt | None, *, status: str, reason: str | None) -> dict[str, object]: + """Return bounded audit evidence without copying user-authored domain payloads.""" + + return { + "status": status, + "reason": (reason or "")[:512], + "receipt_ref": None if receipt is None else receipt.receipt_ref, + "target_count": 0 if receipt is None else len(receipt.target_refs), + "affected_count": 0 if receipt is None else receipt.affected_count, + } + + def token_sha256(token: str) -> str: """Return the only representation of a bearer token accepted for storage.""" @@ -788,19 +814,13 @@ def finalize_attempt( str(row[0]) for row in conn.execute("SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,)) ] - if "unknown" in states: - run_status, terminal_reason = "interrupted", "unknown_effect" - elif "failed" in states: - run_status, terminal_reason = "failed", "domain_failure" - elif states and all(state in {"applied", "already_satisfied"} for state in states): - run_status, terminal_reason = "completed", None - else: - run_status, terminal_reason = "running", None + run_status, terminal_reason = _run_state_for_targets(states) conn.execute( """ UPDATE operation_runs SET status = ?, terminal_reason = ?, updated_at_ms = ?, completed_at_ms = CASE WHEN ? IN ('completed', 'failed', 'interrupted') THEN ? ELSE completed_at_ms END, + rejected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'rejected'), failed_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'failed'), unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state IN ('applied', 'already_satisfied')), @@ -816,6 +836,7 @@ def finalize_attempt( operation_id, operation_id, operation_id, + operation_id, error_summary, unknown_reason, operation_id, @@ -830,11 +851,7 @@ def finalize_attempt( to_state=run_status, actor_ref=str(run[0]), occurred_at_ms=now_ms, - detail={ - "status": status, - "reason": (unknown_reason or error_summary or "")[:512], - "domain_receipt": {} if receipt is None else receipt.domain_receipt, - }, + detail=_receipt_event_detail(receipt, status=status, reason=unknown_reason or error_summary), ) def recover_abandoned_attempts(self) -> tuple[str, ...]: @@ -902,41 +919,55 @@ def reconcile_attempt( now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: self._begin(conn) - target = conn.execute( - "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'unknown' ORDER BY ordinal LIMIT 1", + rows = conn.execute( + "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'unknown' ORDER BY ordinal", (operation_id,), - ).fetchone() - ordinal = int(target[0]) if target is not None else None - if ordinal is None: + ).fetchall() + if not rows: raise ValueError(f"operation {operation_id!r} has no unknown target to reconcile") + ordinal = int(rows[0][0]) target_state = "applied" if outcome == "applied" else "pending" if outcome == "absent" else "unknown" - run_state = ( - "completed" if target_state == "applied" else "running" if target_state == "pending" else "interrupted" - ) conn.execute( "UPDATE operation_attempts SET state = 'reconciled', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'unknown'", (now_ms, reason, operation_id), ) conn.execute( - "UPDATE operation_targets SET state = ?, domain_receipt_ref = ?, domain_receipt_kind = ?, completed_at_ms = ? WHERE operation_id = ? AND ordinal = ?", + "UPDATE operation_targets SET state = ?, domain_receipt_ref = ?, domain_receipt_kind = ?, completed_at_ms = ? WHERE operation_id = ? AND state = 'unknown'", ( target_state, domain_receipt_ref, "domain" if domain_receipt_ref else None, now_ms if target_state == "applied" else None, operation_id, - ordinal, ), ) + states = [ + str(row[0]) + for row in conn.execute("SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,)) + ] + run_state, terminal_reason = _run_state_for_targets(states) conn.execute( - "UPDATE operation_runs SET status = ?, terminal_reason = ?, updated_at_ms = ?, completed_at_ms = CASE WHEN ? = 'completed' THEN ? ELSE completed_at_ms END WHERE operation_id = ?", + """ + UPDATE operation_runs + SET status = ?, terminal_reason = ?, updated_at_ms = ?, + completed_at_ms = CASE WHEN ? IN ('completed', 'failed', 'interrupted') THEN ? ELSE completed_at_ms END, + rejected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'rejected'), + failed_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'failed'), + unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), + affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state IN ('applied', 'already_satisfied')) + WHERE operation_id = ? + """, ( run_state, - None if run_state == "completed" else "reconciliation_unknown", + terminal_reason, now_ms, run_state, now_ms, operation_id, + operation_id, + operation_id, + operation_id, + operation_id, ), ) self._append_event( @@ -947,7 +978,7 @@ def reconcile_attempt( from_state="unknown", to_state=target_state, occurred_at_ms=now_ms, - detail={"reason": (reason or "")[:512]}, + detail={"reason": (reason or "")[:512], "target_count": len(rows)}, ) def get_operation(self, operation_id: str) -> dict[str, object] | None: diff --git a/polylogue/operations/bindings.py b/polylogue/operations/bindings.py index a3d1526fd3..c2881976eb 100644 --- a/polylogue/operations/bindings.py +++ b/polylogue/operations/bindings.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Generic, TypeVar -from polylogue.operations.mutation_transaction import MutationActuator, TargetAuthorityPolicy +from polylogue.operations.mutation_transaction import MutationActuator from polylogue.operations.specs import OperationSpec ArgsT = TypeVar("ArgsT", contravariant=True) @@ -114,47 +114,6 @@ def runtime_operation_binding(actuator: MutationActuator[ArgsT]) -> OperationBin spec = build_runtime_operation_catalog().by_name().get(operation) if spec is None: raise BindingValidationError(f"no runtime OperationSpec for actuator {operation!r}") - if not spec.target_authority: - # Older executor-routed catalog rows predate typed authority metadata. - # Keep those real routes on the bound audit lifecycle with an explicit - # compatibility capability until their individual target policies are - # declared. The extra capability is deliberately required rather than - # silently treating an untyped route as authorized. - spec = replace( - spec, - allowed_surfaces=("cli", "api", "mcp", "daemon", "maintenance", "internal"), - target_authority=( - TargetAuthorityPolicy( - key="legacy-runtime", - target_kinds=( - "annotation", - "annotation-batch", - "assertion", - "blackboard", - "block", - "correction", - "index", - "message", - "raw-authority-blocker", - "recall-pack", - "recall_pack", - "saved-view", - "saved_view", - "session", - "source", - "workspace", - ), - required_capabilities=("archive.legacy_runtime",), - destructive_class=actuator.destructive_class, - required_confirmation=actuator.required_confirmation, - # The remaining compatibility routes all mutate user.db. - # Rebuildable index operations have their own explicit - # policies below, so the fallback remains unambiguous. - allowed_durabilities=("durable",), - allowed_recovery=("none",), - ), - ), - ) binding: OperationBinding[ArgsT, object] = OperationBinding(spec, actuator) binding.validate() return binding diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index cf6c989158..49befa0953 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1400,19 +1400,6 @@ def revalidate_exact_backup() -> None: rebind_mutation_id = f"audit-restore:{operation_id}" coordinator = AuditContinuityCoordinator(archive_root) rebind_already_committed = False - if has_pending_restore: - # A restore can stop after its audit-side rebind commit while the source - # WAL still awaits promotion. Reconcile that exact operation before a - # retry tries to prepare a second command. - try: - rebind_already_committed = ( - coordinator.reconcile_pending_rebind(rebind_mutation_id) - if coordinator.has_pending_rebind(rebind_mutation_id) - else coordinator.has_committed_mutation(rebind_mutation_id) - ) - except AuditContinuityError as exc: - if "cannot read audit continuity commit state" not in str(exc): - raise published = False try: if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): @@ -1440,6 +1427,14 @@ def revalidate_exact_backup() -> None: version, application_id, quick_check = _audit_live_metadata(path) if version != artifact_version or application_id != expected_application_id or quick_check != ("ok",): raise MigrationError("adopted-audit restore published artifact is not the verified SQLite image") + if has_pending_restore: + # The pending source-side rebind can only be inspected after this + # retry has restored a readable, verified audit authority image. + rebind_already_committed = ( + coordinator.reconcile_pending_rebind(rebind_mutation_id) + if coordinator.has_pending_rebind(rebind_mutation_id) + else coordinator.has_committed_mutation(rebind_mutation_id) + ) if stopped_daemon_check() != stopped_evidence: raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") revalidate_exact_backup() diff --git a/polylogue/operations/specs.py b/polylogue/operations/specs.py index 3ffbbcfccf..c75cb61ee8 100644 --- a/polylogue/operations/specs.py +++ b/polylogue/operations/specs.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum from functools import lru_cache from typing import Literal @@ -1619,6 +1619,81 @@ def to_dict(self) -> JSONDocumentList: ) +_USER_MUTATION_TARGET_KINDS = ( + "annotation", + "assertion", + "blackboard", + "block", + "correction", + "message", + "recall_pack", + "saved_view", + "session", + "workspace", +) + +_LEGACY_EXECUTOR_CAPABILITIES: dict[str, str] = { + "mutate-add-tag": "archive.add_tag", + "mutate-remove-tag": "archive.remove_tag", + "mutate-bulk-tag-sessions": "archive.bulk_tag_sessions", + "mutate-set-metadata": "archive.set_metadata", + "mutate-delete-metadata": "archive.delete_metadata", + "mutate-add-mark": "archive.add_mark", + "mutate-remove-mark": "archive.remove_mark", + "mutate-save-annotation": "archive.save_annotation", + "mutate-delete-annotation": "archive.delete_annotation", + "mutate-blackboard-post": "archive.post_blackboard_note", + "mutate-capture-assertion-candidate": "archive.capture_assertion_candidate", + "mutate-save-saved-view": "archive.save_view", + "mutate-delete-saved-view": "archive.delete_view", + "mutate-save-recall-pack": "archive.create_recall_pack", + "mutate-delete-recall-pack": "archive.delete_recall_pack", + "mutate-save-workspace": "archive.save_workspace", + "mutate-delete-workspace": "archive.delete_workspace", + "mutate-record-correction": "archive.record_correction", + "mutate-delete-correction": "archive.delete_correction", + "mutate-clear-corrections": "archive.clear_corrections", +} + + +def _declare_executor_authority(specs: tuple[OperationSpec, ...]) -> tuple[OperationSpec, ...]: + """Give every executor route a specific capability and surface boundary.""" + + declared: list[OperationSpec] = [] + for spec in specs: + if spec.executor_status != "executor-routed" or spec.target_authority: + declared.append(spec) + continue + capability = _LEGACY_EXECUTOR_CAPABILITIES.get(spec.name) + if capability is None: + raise ValueError(f"executor-routed operation lacks target authority: {spec.name}") + declared.append( + replace( + spec, + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key=spec.name.removeprefix("mutate-"), + target_kinds=_USER_MUTATION_TARGET_KINDS, + required_capabilities=(capability,), + destructive_class="reversible", + required_confirmation="role_only", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), + ) + ) + return tuple(declared) + + +RUNTIME_OPERATION_SPECS = _declare_executor_authority(RUNTIME_OPERATION_SPECS) +DECLARED_OPERATION_SPECS = ( + *RUNTIME_OPERATION_SPECS, + *DECLARED_CONTROL_PLANE_OPERATION_SPECS, +) + + def _validate_executor_status() -> None: """t46.9 AC1: every mutating spec must declare an executor_status. diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 101905dd5b..8d412e8c05 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -428,13 +428,9 @@ def classify_paths() -> tuple[bool, bool]: for spec in ARCHIVE_TIER_SPECS.values(): assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) - # Runtime mutation composition must observe a reconciled source/audit - # head before it can open any tier for writes. Older adopted archives - # have no source-side continuity control to reconcile. - if _source_has_audit_continuity_control(root / archive_tier_spec(ArchiveTier.SOURCE).filename): - from polylogue.operations.audit import AuditRepository - - AuditRepository.for_archive_root(root).reconcile_continuity() + # Mutation composition performs source/audit reconciliation immediately + # before it consumes authority. Ordinary archive opens stay read-only + # with respect to continuity, including their steady-state path. if recovering_fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap(root) diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 07abfb890d..b838d9667f 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -18,6 +18,7 @@ from polylogue.cli.click_app import cli from polylogue.cli.commands.maintenance import _rebuild_index as maintenance_rebuild_index +from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import json_document @@ -2294,6 +2295,7 @@ def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( assert result.exit_code == 0, result.output payload = json.loads(result.stdout) + assert MigrateTierResultPayload.model_validate(payload).initialized is True assert payload["ok"] is True assert payload["tier"] == "audit" assert payload["initialized"] is True diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 5fdf2a9a0f..ea2ccd7dbb 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -36,15 +36,16 @@ class _Actuator: changed: bool = False calls: int = 0 crash: bool = False + target_refs: tuple[str, ...] = ("session:fixture",) destructive_class: DestructiveClass = "reversible" required_confirmation: ConfirmationStrength = "role_only" def prepare(self, _args: object) -> MutationPlan: - target = "session:changed" if self.changed else "session:fixture" + targets = ("session:changed",) if self.changed else self.target_refs return build_plan( operation=self.operation, destructive_class="reversible", - target_refs=(target,), + target_refs=targets, affected_tiers=("user",), reversible=True, ) @@ -58,7 +59,7 @@ def apply(self, plan: MutationPlan, _args: object) -> MutationReceipt: plan_hash=plan.plan_hash, status="applied", target_refs=plan.target_refs, - affected_count=1, + affected_count=len(plan.target_refs), detail=None, receipt_ref=None, applied_at="now", @@ -292,14 +293,97 @@ def interrupt_finalize(self: AuditContinuityCoordinator, phase: str, mutation: A ] ) assert "private-cache" not in receipt_json - assert json.loads(receipt_json)["domain_receipt"]["outcomes"] == [ - {"row_ref": "assertion:typed", "status": "imported"} - ] + detail = json.loads(receipt_json) + assert detail["affected_count"] == 1 + assert "domain_receipt" not in detail + assert "annotation-batch:typed" not in receipt_json with sqlite3.connect(tmp_path / "source.db") as source: command = source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0] assert command is None +def test_atomic_batch_finalization_marks_every_target_and_terminates_run(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=("session:first", "session:second")) + executor = OperationExecutor(audit=audit, token_factory=lambda: "batch-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:batch", + archive_identity_digest="identity:batch", + parameter_digest="params:batch", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, affected_count, unknown_count FROM operation_runs WHERE operation_id = ?", + (receipt.operation_id,), + ).fetchone() == ("completed", 2, 0) + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ? ORDER BY ordinal", + (receipt.operation_id,), + ).fetchall() == [("applied",), ("applied",)] + + +def test_blocked_finalization_rejects_targets_and_fails_parent_run(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "blocked-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:blocked", + archive_identity_digest="identity:blocked", + parameter_digest="params:blocked", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + + audit.finalize_attempt(operation_id, status="blocked") + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, terminal_reason, rejected_count FROM operation_runs WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("failed", "target_rejected", 1) + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("rejected",) + + +def test_reconciliation_resolves_the_full_unknown_atomic_batch(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=("session:first", "session:second")) + executor = OperationExecutor(audit=audit, token_factory=lambda: "reconcile-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:reconcile", + archive_identity_digest="identity:reconcile", + parameter_digest="params:reconcile", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + audit.recover_abandoned_attempts() + + audit.reconcile_attempt(operation_id, outcome="applied", domain_receipt_ref="receipt:reconciled") + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, affected_count, unknown_count FROM operation_runs WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("completed", 2, 0) + assert conn.execute( + "SELECT state, domain_receipt_ref FROM operation_targets WHERE operation_id = ? ORDER BY ordinal", + (operation_id,), + ).fetchall() == [("applied", "receipt:reconciled"), ("applied", "receipt:reconciled")] + + def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path) -> None: audit = _audit(tmp_path) actuator = _Actuator() diff --git a/tests/unit/operations/test_operation_bindings.py b/tests/unit/operations/test_operation_bindings.py index 903b870eb3..7b14a233c4 100644 --- a/tests/unit/operations/test_operation_bindings.py +++ b/tests/unit/operations/test_operation_bindings.py @@ -18,7 +18,7 @@ TargetAuthorityPolicy, build_plan, ) -from polylogue.operations.specs import OperationKind, OperationSpec +from polylogue.operations.specs import OperationKind, OperationSpec, build_runtime_operation_catalog @dataclass @@ -119,3 +119,18 @@ def test_catalog_requires_every_executor_routed_spec_and_resolves_only_registere def test_catalog_rejects_missing_binding() -> None: with pytest.raises(BindingValidationError, match="missing"): validate_operation_bindings((_spec(),), ()) + + +def test_runtime_executor_routes_have_specific_capabilities_and_surfaces() -> None: + specs = build_runtime_operation_catalog().by_name().values() + routed = [spec for spec in specs if spec.executor_status == "executor-routed"] + + assert routed + assert all(spec.target_authority for spec in routed) + assert all(spec.allowed_surfaces for spec in routed) + assert all( + capability != "archive.legacy_runtime" + for spec in routed + for policy in spec.target_authority + for capability in policy.required_capabilities + ) diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index bb56f52016..6291b8c3e8 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -9,14 +9,28 @@ import pytest +from polylogue.storage.sqlite.archive_tiers.audit import AUDIT_DDL from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.source import SOURCE_DDL from polylogue.storage.sqlite.audit_continuity import ( + AUDIT_CONTINUITY_GENESIS_HEAD_SHA256, AuditContinuityCoordinator, AuditContinuityError, AuditMutation, ) +def test_genesis_head_is_shared_by_fresh_ddl_and_additive_migrations() -> None: + migration_paths = ( + Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql"), + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql"), + ) + + assert AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in AUDIT_DDL + assert AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in SOURCE_DDL + assert all(AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in path.read_text(encoding="utf-8") for path in migration_paths) + + def _mutation(number: int) -> AuditMutation: return AuditMutation( kind="test-audit-write", From 20d787c09e85d7d3d5a81d4e2e50730a1bc82d32 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 00:53:52 +0200 Subject: [PATCH 17/28] fix(audit): close durable continuity crash windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repair adoption and restore ordering so immutable continuity evidence follows a verified cross-tier machine head, while preserving same-inode stale-image detection. Keep pending source continuity payloads replay-only, accept only operation-owned restore WAL state, remove audit-owned sidecars before publication, and terminalize successful zero-target operations.\n\nVerification:\n- direnv exec . devtools test … (156 passed)\n- direnv exec . devtools verify --quick --json (25 checks passed) --- polylogue/operations/audit.py | 69 ++++++- polylogue/operations/durable_change_train.py | 127 ++++++++++--- polylogue/storage/sqlite/audit_continuity.py | 20 +- polylogue/storage/sqlite/migration_runner.py | 77 +++++++- tests/unit/operations/test_operation_audit.py | 31 ++++ .../unit/storage/test_durable_change_train.py | 173 ++++++++++++++---- 6 files changed, 418 insertions(+), 79 deletions(-) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index 4cb8145174..ea630e5da0 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -43,6 +43,8 @@ def _run_state_for_targets(states: list[str]) -> tuple[str, str | None]: """Derive the parent lifecycle state from the complete target set.""" + if not states: + return "completed", None if "unknown" in states: return "interrupted", "unknown_effect" if "rejected" in states: @@ -127,8 +129,52 @@ def _target_from_payload(raw: object) -> MutationTarget: ) +def _context_sha256(context: Mapping[str, object]) -> str: + """Bind omitted authored context without retaining it in source.db.""" + + encoded = json.dumps(context, sort_keys=True, default=str, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _replay_plan_payload(plan: MutationPlan) -> dict[str, object]: + """Persist only the plan fields the audit replay path consumes.""" + + return { + "operation": plan.operation, + "destructive_class": plan.destructive_class, + "target_refs": list(plan.target_refs), + "affected_tiers": list(plan.affected_tiers), + "reversible": plan.reversible, + "prepared_at": plan.prepared_at, + "plan_hash": plan.plan_hash, + "context_sha256": _context_sha256(plan.context), + "operation_version": plan.operation_version, + "archive_instance_id": plan.archive_instance_id, + "archive_identity_digest": plan.archive_identity_digest, + "required_capabilities": list(plan.required_capabilities), + "required_confirmation": plan.required_confirmation, + "targets": [target.canonical_dict() for target in plan.targets], + "parameter_digest": plan.parameter_digest, + "target_digest": plan.target_digest, + "prepared_at_ms": plan.prepared_at_ms, + "expires_at_ms": plan.expires_at_ms, + } + + def _plan_from_payload(raw: object) -> MutationPlan: value = cast(dict[str, object], raw) + raw_context = value.get("context") + if raw_context is None: + context_digest = value.get("context_sha256") + if not isinstance(context_digest, str) or len(context_digest) != 64: + raise ValueError("replayed plan lacks an authored-context digest") + context: Mapping[str, object] = {} + elif isinstance(raw_context, Mapping): + # Compatibility for pending commands written before this replay-only + # format. New commands never write authored context to source.db. + context = cast(Mapping[str, object], raw_context) + else: + raise ValueError("replayed plan context is malformed") return MutationPlan( operation=cast(str, value["operation"]), destructive_class=cast(Any, value["destructive_class"]), @@ -137,7 +183,7 @@ def _plan_from_payload(raw: object) -> MutationPlan: reversible=cast(bool, value["reversible"]), prepared_at=cast(str, value["prepared_at"]), plan_hash=cast(str, value["plan_hash"]), - context=cast(dict[str, object], value["context"]), + context=context, operation_version=cast(int, value["operation_version"]), archive_instance_id=cast(str, value["archive_instance_id"]), archive_identity_digest=cast(str, value["archive_identity_digest"]), @@ -171,7 +217,7 @@ def _principal_from_payload(raw: object) -> MutationPrincipal: def _preview_payload(preview: MutationPreview) -> dict[str, object]: - return {"preview_ref": preview.preview_ref, "plan": preview.plan.to_dict()} + return {"preview_ref": preview.preview_ref, "plan": _replay_plan_payload(preview.plan)} def _preview_from_payload(raw: object) -> MutationPreview: @@ -246,9 +292,18 @@ def _json_primitive(value: object) -> object: def _receipt_payload(receipt: MutationReceipt) -> dict[str, object]: - payload = _json_primitive(receipt.to_dict()) - assert isinstance(payload, dict) - return payload + """Persist only finalization data consumed by the audit state transition.""" + + return { + "operation": receipt.operation, + "plan_hash": receipt.plan_hash, + "status": receipt.status, + "target_refs": list(receipt.target_refs), + "affected_count": receipt.affected_count, + "receipt_ref": receipt.receipt_ref, + "applied_at": receipt.applied_at, + "operation_id": receipt.operation_id, + } def _receipt_from_payload(raw: object) -> MutationReceipt: @@ -262,7 +317,7 @@ def _receipt_from_payload(raw: object) -> MutationReceipt: detail=cast(str | None, value.get("detail")), receipt_ref=cast(str | None, value.get("receipt_ref")), applied_at=cast(str, value["applied_at"]), - domain_receipt=cast(dict[str, object], value["domain_receipt"]), + domain_receipt=cast(dict[str, object], value.get("domain_receipt", {})), operation_id=cast(str | None, value.get("operation_id")), ) @@ -331,7 +386,7 @@ def _continuity_payload( plan, principal = cast(MutationPlan, args[0]), cast(MutationPrincipal, args[1]) return { "preview_id": f"preview:{secrets.token_urlsafe(18)}", - "plan": plan.to_dict(), + "plan": _replay_plan_payload(plan), "principal": _principal_payload(principal), } if kind == "issue_authorization": diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index 49befa0953..ce6ae65d17 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -20,7 +20,11 @@ from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditContinuityError +from polylogue.storage.sqlite.audit_continuity import ( + AuditContinuityCoordinator, + AuditContinuityError, + audit_semantic_sha256, +) from polylogue.storage.sqlite.durable_change_train import ( DurableChangeTrainExecution, ) @@ -478,6 +482,19 @@ def _audit_schema_inventory_sha256() -> str: return capture_durable_schema_inventory(connection).sha256 +def _initial_audit_semantic_sha256(application_id: int) -> str: + """Rebuild the adopted audit image's stable content outside its head row.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier + + with closing(sqlite3.connect(":memory:")) as connection: + initialize_archive_tier(connection, ArchiveTier.AUDIT) + connection.execute(f"PRAGMA application_id = {application_id}") + connection.commit() + lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + def _open_audit_adoption_receipt_directory( path: Path, *, @@ -902,17 +919,31 @@ def _write_audit_adoption_continuity( archive_root: Path, *, receipt_payload: dict[str, object], - expected_initial_file_identity: tuple[int, int], + expected_initial_file_identity: tuple[int, int] | None, expected_audit_image_sha256: str, ) -> None: """Publish the post-link audit identity that later detects stale replacement.""" audit_path = archive_root / "audit.db" device, inode = _audit_file_identity(audit_path) - if (device, inode) != expected_initial_file_identity: + if expected_initial_file_identity is not None and (device, inode) != expected_initial_file_identity: raise MigrationError("audit tier changed before recording adoption continuity") - if _audit_file_sha256(audit_path) != expected_audit_image_sha256: + receipt_sha256 = receipt_payload.get("receipt_sha256") + if not isinstance(receipt_sha256, str): + raise MigrationError("audit adoption receipt lacks its checksum") + mutation_id = f"audit-adoption:{receipt_sha256}" + coordinator = AuditContinuityCoordinator(archive_root) + machine_head_started = coordinator.has_committed_mutation(mutation_id) or coordinator.has_pending_rebind( + mutation_id + ) + if expected_initial_file_identity is None and not machine_head_started: + raise MigrationError("audit adoption continuity is missing without an authenticated initial image") + if not machine_head_started and _audit_file_sha256(audit_path) != expected_audit_image_sha256: raise MigrationError("audit image changed before recording adoption continuity") - continuity_path = _audit_adoption_continuity_path(archive_root) + application_id = receipt_payload.get("audit_application_id") + if not isinstance(application_id, int) or audit_semantic_sha256(audit_path) != _initial_audit_semantic_sha256( + application_id + ): + raise MigrationError("audit tier changed before recording adoption continuity") payload: dict[str, object] = { "format": _AUDIT_ADOPTION_CONTINUITY_FORMAT, "receipt_sha256": receipt_payload["receipt_sha256"], @@ -923,20 +954,10 @@ def _write_audit_adoption_continuity( } unsigned = dict(payload) payload["continuity_sha256"] = _canonical_json_sha256(unsigned) - _write_immutable_audit_adoption_receipt( - continuity_path, - payload, - archive_root=archive_root, - checksum_key="continuity_sha256", - ) - # The immutable receipt remains operator evidence. The machine authority - # is the cross-tier head, seeded with the authenticated initial image so a - # byte-for-byte stale copy on the same inode cannot be blessed later. - receipt_sha256 = receipt_payload.get("receipt_sha256") - if not isinstance(receipt_sha256, str): - raise MigrationError("audit adoption receipt lacks its checksum") - AuditContinuityCoordinator(archive_root).seed_or_rebind( - mutation_id=f"audit-adoption:{receipt_sha256}", + # Advance the machine head before its immutable receipt says adoption is + # complete. A publication-first crash would leave both heads at genesis. + coordinator.seed_or_rebind( + mutation_id=mutation_id, now_ms=int(time.time() * 1000), evidence={ "kind": "adoption", @@ -946,6 +967,12 @@ def _write_audit_adoption_continuity( ) if _audit_file_identity(audit_path) != (device, inode): raise MigrationError("audit tier changed while recording adoption continuity") + _write_immutable_audit_adoption_receipt( + _audit_adoption_continuity_path(archive_root), + payload, + archive_root=archive_root, + checksum_key="continuity_sha256", + ) def _validate_audit_adoption_continuity( @@ -957,8 +984,6 @@ def _validate_audit_adoption_continuity( """Require the published audit path to retain its adopted live identity.""" continuity = _latest_audit_adoption_continuity(archive_root) if continuity is None: - if expected_initial_file_identity is None: - raise MigrationError("audit adoption continuity is missing without an authenticated initial image") _write_audit_adoption_continuity( archive_root, receipt_payload=receipt_payload, @@ -1070,7 +1095,18 @@ def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image _recover_pending_audit_adoption(archive_root, receipt_path, payload) require_initial_image = True initial_file_identity: tuple[int, int] | None = None - if continuity is None or require_initial_image: + seeded_adoption_head = False + if continuity is None: + receipt_sha256 = payload.get("receipt_sha256") + if not isinstance(receipt_sha256, str): + raise MigrationError("audit adoption receipt lacks its checksum") + coordinator = AuditContinuityCoordinator(archive_root) + if coordinator.is_available(): + mutation_id = f"audit-adoption:{receipt_sha256}" + seeded_adoption_head = coordinator.has_committed_mutation(mutation_id) or coordinator.has_pending_rebind( + mutation_id + ) + if (continuity is None and not seeded_adoption_head) or require_initial_image: initial_file_identity = _validate_initial_audit_image( audit_path, expected_image_sha256=expected_image_sha256, @@ -1249,6 +1285,22 @@ def _remove_stale_restore_staging(*, directory_fd: int, temporary_name: str) -> os.fsync(directory_fd) +def _remove_owned_audit_sidecars(*, directory_fd: int) -> None: + """Remove only audit.db's SQLite sidecars at the restore publication boundary.""" + + removed = False + for suffix in ("-wal", "-shm", "-journal"): + try: + os.unlink(f"audit.db{suffix}", dir_fd=directory_fd) + except FileNotFoundError: + continue + except OSError as exc: + raise MigrationError(f"cannot remove owned audit restore sidecar: audit.db{suffix}") from exc + removed = True + if removed: + os.fsync(directory_fd) + + def _audit_file_matches_artifact(path: Path, *, sha256: str, size: int) -> bool: """Check whether an interrupted restore already published the intended image.""" try: @@ -1289,14 +1341,27 @@ def restore_adopted_audit_tier( for _path, payload in restore_records if payload.get("state") == "committed" } - has_pending_restore = any( - payload.get("state") == "prepared" - and (payload.get("generation"), payload.get("operation_id")) not in committed_restore_operations - for _path, payload in restore_records + pending_restore_operation_ids: list[str] = [] + for _path, payload in restore_records: + if payload.get("state") != "prepared": + continue + operation_id = payload.get("operation_id") + if (payload.get("generation"), operation_id) in committed_restore_operations: + continue + if not isinstance(operation_id, str): + raise MigrationError("adopted-audit restore has an invalid incomplete continuity record") + pending_restore_operation_ids.append(operation_id) + if len(pending_restore_operation_ids) > 1: + raise MigrationError("adopted-audit restore has multiple or invalid incomplete continuity records") + has_pending_restore = bool(pending_restore_operation_ids) + source_continuity_rebind_mutation_id = ( + f"audit-restore:{pending_restore_operation_ids[0]}" if has_pending_restore else None ) - restore_validation_kwargs = {"allow_source_continuity_rebind": True} if has_pending_restore else {} manifest_path, verification_receipt = validate_full_evidence_backup_for_adopted_audit_restore( - backup_manifest, archive_root=archive_root, **restore_validation_kwargs + backup_manifest, + archive_root=archive_root, + allow_source_continuity_rebind=has_pending_restore, + source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, ) artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) expected_application_id = adoption.get("audit_application_id") @@ -1316,7 +1381,10 @@ def restore_adopted_audit_tier( def revalidate_exact_backup() -> None: current_manifest, current_receipt = validate_full_evidence_backup_for_adopted_audit_restore( - backup_manifest, archive_root=archive_root, **restore_validation_kwargs + backup_manifest, + archive_root=archive_root, + allow_source_continuity_rebind=has_pending_restore, + source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, ) if ( current_manifest.resolve() != manifest_path.resolve() @@ -1417,6 +1485,7 @@ def revalidate_exact_backup() -> None: revalidate_exact_backup() if _audit_adoption_authority_digest(archive_root) != adoption.get("source_user_authority_digest"): raise MigrationError("source/user authority changed during adopted-audit restore") + _remove_owned_audit_sidecars(directory_fd=directory_fd) if not published: os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 1899c9123a..3056e55db9 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -74,6 +74,18 @@ def _sha256(payload: object) -> str: return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() +def audit_semantic_sha256(path: Path) -> str: + """Hash audit content while excluding the self-mutating continuity head.""" + + try: + uri = f"{path.resolve(strict=True).as_uri()}?mode=ro" + with closing(sqlite3.connect(uri, uri=True)) as connection: + lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + except sqlite3.DatabaseError as exc: + raise AuditContinuityError("cannot hash audit content for continuity validation") from exc + + class AuditContinuityCoordinator: """Coordinate typed audit commands through source.db's durable WAL row.""" @@ -190,6 +202,12 @@ def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str raise AuditContinuityError("rebind requires an exact audit image sha256") if self.has_committed_mutation(mutation_id): return + # Adoption and restore publish their immutable evidence only after + # this machine head advances. Resume this exact source-WAL command on + # retry instead of treating it as an unrelated competing mutation. + if self.has_pending_rebind(mutation_id): + self.reconcile_pending_rebind(mutation_id) + return mutation = AuditMutation("rebind", mutation_id, now_ms, dict(evidence)) # A verified restored image can contain an older audit head. Its @@ -441,4 +459,4 @@ def _require_paths(self) -> None: raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") -__all__ = ["AuditContinuityCoordinator", "AuditContinuityError", "AuditMutation"] +__all__ = ["AuditContinuityCoordinator", "AuditContinuityError", "AuditMutation", "audit_semantic_sha256"] diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 969601434c..80fda61783 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -945,7 +945,11 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root def validate_full_evidence_backup_for_adopted_audit_restore( - path: Path, *, archive_root: Path, allow_source_continuity_rebind: bool = False + path: Path, + *, + archive_root: Path, + allow_source_continuity_rebind: bool = False, + source_continuity_rebind_mutation_id: str | None = None, ) -> tuple[Path, Path]: """Authorize replacing adopted ``audit.db`` from one exact backup. @@ -1029,14 +1033,22 @@ def validate_full_evidence_backup_for_adopted_audit_restore( raise MigrationError(f"adopted-audit restore backup belongs to a different archive tier: {tier}.db") if not live_path.is_file(): raise MigrationError(f"adopted-audit restore live tier is missing: {live_path}") - wal_path = live_path.with_name(f"{live_path.name}-wal") - if wal_path.exists() and wal_path.stat().st_size: - raise MigrationError(f"adopted-audit restore has live WAL divergence for {tier}.db") if tier == "source" and allow_source_continuity_rebind: + if not source_continuity_rebind_mutation_id: + raise MigrationError("adopted-audit restore lacks an operation-owned source continuity rebind") if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): raise MigrationError("adopted-audit restore backup is stale for source.db") - _validate_source_continuity_rebind_delta(artifact_path, live_path) + # A retry can retain this operation's committed source WAL after + # a crash. Validate SQLite's logical WAL view, not only its file. + _validate_source_continuity_rebind_delta( + artifact_path, + live_path, + expected_mutation_id=source_continuity_rebind_mutation_id, + ) continue + wal_path = live_path.with_name(f"{live_path.name}-wal") + if wal_path.exists() and wal_path.stat().st_size: + raise MigrationError(f"adopted-audit restore has live WAL divergence for {tier}.db") if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") if str(fingerprint.get("sha256")) != _sha256_file(live_path): @@ -1046,7 +1058,7 @@ def validate_full_evidence_backup_for_adopted_audit_restore( return manifest_path, receipt_path -def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path) -> None: +def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, *, expected_mutation_id: str) -> None: """Allow a retrying restore to differ only in the source continuity table.""" try: @@ -1054,6 +1066,59 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path) connection.execute( "ATTACH DATABASE ? AS backup_source", (f"{backup_path.resolve(strict=True).as_uri()}?mode=ro",) ) + control = connection.execute( + "SELECT pending_mutation_id, pending_payload_json, pending_payload_sha256 " + "FROM main.audit_continuity_control WHERE singleton = 1" + ).fetchone() + if control is None: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + pending_mutation_id, pending_payload_json, pending_payload_sha256 = control + if pending_mutation_id is not None: + if ( + pending_mutation_id != expected_mutation_id + or not isinstance(pending_payload_json, str) + or not isinstance(pending_payload_sha256, str) + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + try: + prepared = json.loads(pending_payload_json) + except json.JSONDecodeError as exc: + raise MigrationError("adopted-audit restore source continuity rebind is malformed") from exc + if ( + not isinstance(prepared, dict) + or hashlib.sha256( + json.dumps(prepared, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() + != pending_payload_sha256 + or not isinstance(prepared.get("command"), dict) + or prepared["command"].get("kind") != "rebind" + or prepared["command"].get("mutation_id") != expected_mutation_id + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + else: + source_head = connection.execute( + "SELECT committed_generation, committed_head_sha256 " + "FROM main.audit_continuity_control WHERE singleton = 1" + ).fetchone() + backup_head = connection.execute( + "SELECT committed_generation, committed_head_sha256 " + "FROM backup_source.audit_continuity_control WHERE singleton = 1" + ).fetchone() + if source_head != backup_head: + audit_path = live_path.parent / "audit.db" + with closing( + sqlite3.connect(f"{audit_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) + ) as audit: + audit_head = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if ( + source_head is None + or audit_head is None + or (int(source_head[0]), str(source_head[1])) != (int(audit_head[0]), str(audit_head[1])) + or audit_head[2] != expected_mutation_id + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") schema_sql = """ SELECT type, name, tbl_name, sql FROM {schema}.sqlite_schema diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index ea2ccd7dbb..27558764f8 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -282,6 +282,11 @@ def interrupt_finalize(self: AuditContinuityCoordinator, phase: str, mutation: A monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_finalize) with pytest.raises(AuditFinalizationError, match="not reported completed"): executor.execute_bound(_binding(actuator), preview, authorization, object()) + with sqlite3.connect(tmp_path / "source.db") as source: + pending_payload = str(source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0]) + assert "private-cache" not in pending_payload + assert "annotation-batch:typed" not in pending_payload + assert '"domain_receipt"' not in pending_payload monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) AuditRepository.for_archive_root(tmp_path).reconcile_continuity() @@ -330,6 +335,32 @@ def test_atomic_batch_finalization_marks_every_target_and_terminates_run(tmp_pat ).fetchall() == [("applied",), ("applied",)] +def test_zero_target_finalization_completes_a_successful_noop(tmp_path: Path) -> None: + """The real start/finalize route terminalizes a successful empty target set.""" + + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=()) + executor = OperationExecutor(audit=audit, token_factory=lambda: "zero-target-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:zero-target", + archive_identity_digest="identity:zero-target", + parameter_digest="params:zero-target", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, terminal_reason, affected_count FROM operation_runs WHERE operation_id = ?", + (receipt.operation_id,), + ).fetchone() == ("completed", None, 0) + + def test_blocked_finalization_rejects_targets_and_fails_parent_run(tmp_path: Path) -> None: audit = _audit(tmp_path) actuator = _Actuator() diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 3bf5f75973..a0be86a76c 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1848,59 +1848,74 @@ def test_adopted_audit_restore_resumes_an_interrupted_continuity_commit( verified = backup_archive(output_dir=archive_root.parent / "resume-post", profile="full_evidence", verify=True) assert verified.ok and verified.output_path is not None, verified.error audit_path.write_bytes(b"corrupt") + source_wal_reader: sqlite3.Connection | None = None from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator original_phase = AuditContinuityCoordinator._phase def interrupt_after_rebind_commit(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: - if phase == "after_audit_commit" and getattr(mutation, "mutation_id", "").startswith("audit-restore:"): - raise RuntimeError("simulated continuity promotion interruption") + nonlocal source_wal_reader + if getattr(mutation, "mutation_id", "").startswith("audit-restore:"): + if phase == "before_source_prepare": + with sqlite3.connect(archive_root / "source.db") as source: + assert source.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + source_wal_reader = sqlite3.connect(archive_root / "source.db") + source_wal_reader.execute("BEGIN") + source_wal_reader.execute("SELECT * FROM audit_continuity_control").fetchone() + if phase == "after_source_prepare": + raise RuntimeError("simulated continuity prepare interruption") original_phase(self, phase, mutation) # type: ignore[arg-type] - with monkeypatch.context() as interrupted: - interrupted.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_rebind_commit) - with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-interrupt") as owner: - with pytest.raises(RuntimeError, match="continuity promotion interruption"): + try: + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_rebind_commit) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-interrupt") as owner: + with pytest.raises(RuntimeError, match="continuity prepare interruption"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + # The production source-prepare transition is durable only in a WAL; + # retry must validate this operation-owned pending command before it + # can republish/rebind the audit image. + assert (archive_root / "source.db-wal").stat().st_size > 0 + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert str( + source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone()[0] + ).startswith("audit-restore:") + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("CREATE TABLE restore_retry_tamper (value TEXT NOT NULL) STRICT") + source.commit() + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-tamper") as owner: + with pytest.raises(MigrationError, match="backup is stale for source.db"): restore_adopted_audit_tier( audit_path, backup_manifest=Path(verified.output_path) / "manifest.json", directory_fd=owner.directory_fd, stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - - with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: - assert ( - source.execute("SELECT pending_mutation_id FROM audit_continuity_control") - .fetchone()[0] - .startswith("audit-restore:") - ) - assert ( - source.execute( - "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" - ).fetchone() - != audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() - ) - with sqlite3.connect(archive_root / "source.db") as source: - source.execute("CREATE TABLE restore_retry_tamper (value TEXT NOT NULL) STRICT") - source.commit() - with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-tamper") as owner: - with pytest.raises(MigrationError, match="backup is stale for source.db"): - restore_adopted_audit_tier( + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE restore_retry_tamper") + source.commit() + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: + receipt = restore_adopted_audit_tier( audit_path, backup_manifest=Path(verified.output_path) / "manifest.json", directory_fd=owner.directory_fd, stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - with sqlite3.connect(archive_root / "source.db") as source: - source.execute("DROP TABLE restore_retry_tamper") - source.commit() - with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: - receipt = restore_adopted_audit_tier( - audit_path, - backup_manifest=Path(verified.output_path) / "manifest.json", - directory_fd=owner.directory_fd, - stopped_daemon_check=lambda: "proof:test-daemon-stopped", - ) + finally: + if source_wal_reader is not None: + source_wal_reader.close() assert receipt.name.endswith(".committed.json") assert reconcile_durable_change_train_startup(archive_root) == () @@ -2430,6 +2445,14 @@ def interrupt_continuity_link( stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + source_head = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + audit_head = audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + assert source_head == audit_head + assert source_head[0] == 1 + stale_clone = archive_root / "stale-audit.db" with closing(sqlite3.connect(audit_path)) as connection: connection.execute( @@ -2447,13 +2470,91 @@ def interrupt_continuity_link( os.replace(stale_clone, audit_path) stale_image = audit_path.read_bytes() - with pytest.raises(MigrationError, match="published canonical audit image"): + with pytest.raises(MigrationError, match="audit tier changed before recording adoption continuity"): initialize_active_archive_root(archive_root) assert audit_path.read_bytes() == stale_image assert not (marker_root / "audit-continuity.json").exists() +def test_audit_adoption_retries_seeded_machine_head_before_continuity_publication( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A continuity-receipt crash resumes the already-seeded adoption head.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive( + output_dir=archive_root.parent / "seed-before-publish", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + real_link = os.link + + def interrupt_continuity_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + **kwargs: object, + ) -> None: + if Path(destination).name == "audit-continuity.json": + raise OSError("simulated crash after machine-head seed") + real_link(source, destination, **kwargs) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.link", interrupt_continuity_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-seed-before-publish") as owner: + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert source.execute("SELECT committed_generation FROM audit_continuity_control").fetchone() == (1,) + assert audit.execute("SELECT generation FROM audit_continuity_head").fetchone() == (1,) + initialize_active_archive_root(archive_root) + assert (archive_root / ".maintenance-state" / "durable-change-trains" / "audit-continuity.json").is_file() + + +def test_adopted_audit_restore_removes_owned_sidecars_before_publication(workspace_env: dict[str, Path]) -> None: + """The real restore cannot replay stale audit WAL, SHM, or rollback bytes.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "sidecar-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-sidecar-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "sidecar-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt-audit-main") + for suffix in ("-wal", "-shm", "-journal"): + audit_path.with_name(f"audit.db{suffix}").write_bytes(b"stale-owned-sidecar") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-sidecar-restore") as owner: + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert not any(audit_path.with_name(f"audit.db{suffix}").exists() for suffix in ("-wal", "-shm", "-journal")) + assert _audit_live_metadata(audit_path)[2] == ("ok",) + + def test_audit_adoption_recovery_preserves_missing_tier_after_continuity( workspace_env: dict[str, Path], ) -> None: From 283e39d9220ec7498f331e30a9f7d1c7e37a16d5 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 01:26:32 +0200 Subject: [PATCH 18/28] fix(audit): close continuity recovery review findings Problem: established archives could recreate a missing audit tier before\nsource v32, migrate-tier JSON errors violated their published schema, and\nexecutor construction could abandon live work.\n\nWhat changed: fail closed from durable bootstrap evidence, publish a typed\nsuccess/error result union, report the recovered audit version, track live\nprocess ownership on attempts, and defer excise executor construction until\nconfirmation.\n\nCompatibility: existing excise JSON continues to expose its durable assertion\nreceipt while the audited operation receipt remains available in plain output. --- .../migrate-tier-result.schema.json | 398 ++++++++++++------ polylogue/cli/commands/excise.py | 4 +- .../cli/commands/maintenance/_migrate_tier.py | 72 +++- polylogue/operations/audit.py | 72 +++- polylogue/operations/durable_change_train.py | 2 +- polylogue/operations/mutation_transaction.py | 5 +- .../storage/sqlite/archive_tiers/bootstrap.py | 23 +- .../unit/cli/test_archive_maintenance_cli.py | 15 +- tests/unit/cli/test_cli_output_schemas.py | 21 + tests/unit/cli/test_excise.py | 37 +- tests/unit/operations/test_operation_audit.py | 57 +++ .../unit/storage/test_durable_change_train.py | 74 ++++ 12 files changed, 580 insertions(+), 200 deletions(-) diff --git a/docs/schemas/cli-output/migrate-tier-result.schema.json b/docs/schemas/cli-output/migrate-tier-result.schema.json index 01af5d9635..7c054623f5 100644 --- a/docs/schemas/cli-output/migrate-tier-result.schema.json +++ b/docs/schemas/cli-output/migrate-tier-result.schema.json @@ -1,163 +1,295 @@ { - "$id": "https://polylogue.dev/schemas/cli-output/migrate-tier-result.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "description": "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable adoption and restore receipt references.\n\nGenerated from `polylogue.cli.commands.maintenance._migrate_tier.MigrateTierResultPayload` by `devtools render cli-output-schemas`. Do not edit by hand.", - "properties": { - "adoption_receipt": { - "anyOf": [ - { - "type": "string" + "$defs": { + "DurableRecoveryPayload": { + "additionalProperties": false, + "description": "Typed recovery evidence for a blocked durable publication.", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" }, - { - "type": "null" - } - ], - "title": "Adoption Receipt" - }, - "applied_versions": { - "items": { - "type": "integer" - }, - "title": "Applied Versions", - "type": "array" - }, - "backup_manifest": { - "anyOf": [ - { - "type": "string" + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" }, - { - "type": "null" - } - ], - "title": "Backup Manifest" - }, - "backup_receipt": { - "anyOf": [ - { + "state": { + "title": "State", "type": "string" }, - { - "type": "null" - } - ], - "title": "Backup Receipt" - }, - "forward_version_receipt": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" + "target": { + "title": "Target", + "type": "string" } + }, + "required": [ + "state", + "code", + "target", + "detail" ], - "title": "Forward Version Receipt" + "title": "DurableRecoveryPayload", + "type": "object" }, - "from_version": { - "anyOf": [ - { - "type": "integer" + "MigrateTierErrorPayload": { + "additionalProperties": false, + "description": "Blocked result for one durable-tier migration route.", + "properties": { + "backup_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Manifest" }, - { - "type": "null" - } - ], - "title": "From Version" - }, - "initialized": { - "title": "Initialized", - "type": "boolean" - }, - "ok": { - "title": "Ok", - "type": "boolean" - }, - "path": { - "title": "Path", - "type": "string" - }, - "restore_receipt": { - "anyOf": [ - { + "durable_recovery": { + "anyOf": [ + { + "$ref": "#/$defs/DurableRecoveryPayload" + }, + { + "type": "null" + } + ] + }, + "error": { + "title": "Error", "type": "string" }, - { - "type": "null" - } - ], - "title": "Restore Receipt" - }, - "stopped_daemon_evidence_ref": { - "anyOf": [ - { + "ok": { + "const": false, + "title": "Ok", + "type": "boolean" + }, + "path": { + "title": "Path", "type": "string" }, - { - "type": "null" - } - ], - "title": "Stopped Daemon Evidence Ref" - }, - "tier": { - "title": "Tier", - "type": "string" - }, - "to_version": { - "anyOf": [ - { - "type": "integer" + "stopped_daemon_evidence_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopped Daemon Evidence Ref" }, - { - "type": "null" + "tier": { + "title": "Tier", + "type": "string" } + }, + "required": [ + "ok", + "tier", + "path", + "backup_manifest", + "stopped_daemon_evidence_ref", + "error", + "durable_recovery" ], - "title": "To Version" + "title": "MigrateTierErrorPayload", + "type": "object" }, - "train_manifest": { - "anyOf": [ - { + "MigrateTierSuccessPayload": { + "additionalProperties": false, + "description": "Successful result for one durable-tier migration route.", + "properties": { + "adoption_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Adoption Receipt" + }, + "applied_versions": { + "items": { + "type": "integer" + }, + "title": "Applied Versions", + "type": "array" + }, + "backup_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Manifest" + }, + "backup_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Receipt" + }, + "forward_version_receipt": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Forward Version Receipt" + }, + "from_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "From Version" + }, + "initialized": { + "title": "Initialized", + "type": "boolean" + }, + "ok": { + "const": true, + "title": "Ok", + "type": "boolean" + }, + "path": { + "title": "Path", "type": "string" }, - { - "type": "null" - } - ], - "title": "Train Manifest" - }, - "train_state": { - "anyOf": [ - { + "restore_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Restore Receipt" + }, + "stopped_daemon_evidence_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopped Daemon Evidence Ref" + }, + "tier": { + "title": "Tier", "type": "string" }, - { - "type": "null" + "to_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "To Version" + }, + "train_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train Manifest" + }, + "train_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train State" } + }, + "required": [ + "ok", + "tier", + "path", + "initialized", + "adoption_receipt", + "restore_receipt", + "backup_manifest", + "stopped_daemon_evidence_ref", + "train_manifest", + "train_state", + "backup_receipt", + "from_version", + "to_version", + "applied_versions", + "forward_version_receipt" ], - "title": "Train State" + "title": "MigrateTierSuccessPayload", + "type": "object" } }, - "required": [ - "ok", - "tier", - "path", - "initialized", - "adoption_receipt", - "restore_receipt", - "backup_manifest", - "stopped_daemon_evidence_ref", - "train_manifest", - "train_state", - "backup_receipt", - "from_version", - "to_version", - "applied_versions", - "forward_version_receipt" + "$id": "https://polylogue.dev/schemas/cli-output/migrate-tier-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable adoption and restore receipt references.\n\nGenerated from `polylogue.cli.commands.maintenance._migrate_tier.MigrateTierResultPayload` by `devtools render cli-output-schemas`. Do not edit by hand.", + "discriminator": { + "mapping": { + "False": "#/$defs/MigrateTierErrorPayload", + "True": "#/$defs/MigrateTierSuccessPayload" + }, + "propertyName": "ok" + }, + "oneOf": [ + { + "$ref": "#/$defs/MigrateTierSuccessPayload" + }, + { + "$ref": "#/$defs/MigrateTierErrorPayload" + } ], "title": "Migrate Tier Result", - "type": "object", "x-polylogue-cli-surfaces": [ "polylogue ops maintenance migrate-tier --output-format json" ], diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index c2ed19bee4..d4894149af 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -201,7 +201,6 @@ def excise_command( from polylogue.security.excision import plan_session_excision actuator = SessionExcisionActuator() - executor = OperationExecutor.for_archive_root(root) excision_args = SessionExcisionArgs( archive_root=root, session_id=session_id, @@ -313,6 +312,7 @@ def excise_command( # EXECUTE revalidates the hash immediately before mutating -- a stale or # tampered authorization refuses (``PlanStaleError``) rather than excising # the wrong target set. + executor = OperationExecutor.for_archive_root(root) binding = runtime_operation_binding(actuator) principal = MutationPrincipal(actor, frozenset({"archive.excise_session"}), "cli", "write") preview = executor.prepare_bound_for_archive(binding, excision_args, principal, archive_root=root) @@ -345,7 +345,7 @@ def excise_command( affected_count=executor_receipt.affected_count, output_format=output_format, plain_message=detail_message, - detail=executor_receipt.receipt_ref, + detail=cast(str | None, domain_receipt.get("receipt_assertion_id")) or executor_receipt.receipt_ref, ) diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 3663893803..af45999206 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -19,9 +19,10 @@ import os import sqlite3 from pathlib import Path +from typing import Annotated, Literal import click -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, RootModel from polylogue.operations.durable_change_train import ( ArchiveOwnershipError, @@ -38,12 +39,23 @@ from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS, MigrationError -class MigrateTierResultPayload(BaseModel): - """Stable machine-readable result for one durable-tier migration route.""" +class DurableRecoveryPayload(BaseModel): + """Typed recovery evidence for a blocked durable publication.""" model_config = ConfigDict(extra="forbid", frozen=True) - ok: bool + state: str + code: str | None + target: str + detail: str | None + + +class MigrateTierSuccessPayload(BaseModel): + """Successful result for one durable-tier migration route.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[True] tier: str path: str initialized: bool @@ -60,6 +72,26 @@ class MigrateTierResultPayload(BaseModel): forward_version_receipt: dict[str, object] | None +class MigrateTierErrorPayload(BaseModel): + """Blocked result for one durable-tier migration route.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[False] + tier: str + path: str + backup_manifest: str | None + stopped_daemon_evidence_ref: str | None + error: str + durable_recovery: DurableRecoveryPayload | None + + +class MigrateTierResultPayload( + RootModel[Annotated[MigrateTierSuccessPayload | MigrateTierErrorPayload, Field(discriminator="ok")]] +): + """Published success/error union for the migrate-tier JSON surface.""" + + def _daemon_pidfile_is_live(pidfile: Path) -> bool: """Return whether the archive pidfile names a live polylogued process.""" try: @@ -179,23 +211,19 @@ def migrate_tier_command( ) except (sqlite3.Error, MigrationError, ArchiveOwnershipError, AuditContinuityError) as exc: if output_format == "json": - click.echo( - json.dumps( - { - "ok": False, - "tier": tier, - "path": str(path), - "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, - "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, - "error": str(exc), - "durable_recovery": ( - exc.cleanup.as_dict() if isinstance(exc, DurablePublicationError) and exc.cleanup else None - ), - }, - indent=2, - sort_keys=True, - ) + cleanup = exc.cleanup if isinstance(exc, DurablePublicationError) else None + error_payload = MigrateTierErrorPayload( + ok=False, + tier=tier, + path=str(path), + backup_manifest=str(backup_manifest) if backup_manifest is not None else None, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + error=str(exc), + durable_recovery=( + DurableRecoveryPayload.model_validate(cleanup.as_dict()) if cleanup is not None else None + ), ) + click.echo(json.dumps(error_payload.model_dump(mode="json"), indent=2, sort_keys=True)) else: click.echo(f"Migration blocked for {tier}: {exc}", err=True) if isinstance(exc, DurablePublicationError) and exc.cleanup is not None: @@ -209,7 +237,7 @@ def migrate_tier_command( result = execution.migration_result if execution is not None else None receipt = execution.forward_version_receipt if execution is not None else None - payload = MigrateTierResultPayload( + success_payload = MigrateTierSuccessPayload( ok=True, tier=tier, path=str(path), @@ -241,7 +269,7 @@ def migrate_tier_command( ), ) if output_format == "json": - click.echo(json.dumps(payload.model_dump(mode="json"), indent=2, sort_keys=True)) + click.echo(json.dumps(success_payload.model_dump(mode="json"), indent=2, sort_keys=True)) return if adoption_receipt is not None: diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index ea630e5da0..d4519a9d68 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -5,6 +5,7 @@ import hashlib import json import math +import os import secrets import sqlite3 import time @@ -74,6 +75,38 @@ def token_sha256(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _current_process_attempt_owner() -> str: + """Return a local process identity that rejects PID reuse when available.""" + + pid = os.getpid() + try: + start_ticks = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()[21] + except (IndexError, OSError): + return f"pid:{pid}" + return f"pid:{pid}:{start_ticks}" + + +def _attempt_owner_is_live(owner_id: str | None) -> bool: + """Return whether an attempt's recorded local process is still its owner.""" + + if owner_id is None: + return False + parts = owner_id.split(":") + if len(parts) not in {2, 3} or parts[0] != "pid": + return False + try: + pid = int(parts[1]) + os.kill(pid, 0) + except (OSError, ValueError): + return False + if len(parts) == 2: + return True + try: + return Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()[21] == parts[2] + except (IndexError, OSError): + return False + + @dataclass(frozen=True, slots=True) class _StoredAuthorizationDigest: """A persisted digest available only while replaying a continuity command.""" @@ -325,17 +358,24 @@ def _receipt_from_payload(raw: object) -> MutationReceipt: class AuditRepository: """Small synchronous repository whose methods make audit transactions explicit.""" - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, *, attempt_owner_id: str | None = None) -> None: self.path = path + self._attempt_owner_id = attempt_owner_id self._continuity = AuditContinuityCoordinator(path.parent) self._coordinated_connection: sqlite3.Connection | None = None self._coordinated_mutation: AuditMutation | None = None @classmethod - def for_archive_root(cls, archive_root: Path) -> AuditRepository: + def for_archive_root(cls, archive_root: Path, *, attempt_owner_id: str | None = None) -> AuditRepository: """Build the repository for an already-initialized archive root.""" - return cls(archive_root / "audit.db") + return cls(archive_root / "audit.db", attempt_owner_id=attempt_owner_id) + + @staticmethod + def current_process_attempt_owner() -> str: + """Return the process identity assigned to production mutation attempts.""" + + return _current_process_attempt_owner() def reconcile_continuity(self) -> None: """Reject audit bytes that cannot prove the source control head.""" @@ -788,10 +828,17 @@ def _consume_authorization( """ INSERT INTO operation_attempts( attempt_id, operation_id, target_ordinal, authorization_id, - state, started_at_ms - ) VALUES (?, ?, ?, ?, 'running', ?) + worker_id, state, started_at_ms + ) VALUES (?, ?, ?, ?, ?, 'running', ?) """, - (attempt_id, operation_id, 0 if preview.plan.targets else None, str(row[0]), now_ms), + ( + attempt_id, + operation_id, + 0 if preview.plan.targets else None, + str(row[0]), + self._attempt_owner_id, + now_ms, + ), ) self._append_event( conn, @@ -920,22 +967,15 @@ def recover_abandoned_attempts(self) -> tuple[str, ...]: @_continuity_mutation("recover_abandoned_attempts") def _recover_abandoned_attempts(self) -> tuple[str, ...]: - """Mark persisted in-flight work unknown before a fresh executor can act. - - A running attempt has no durable worker lease or resumable process - handle. Seeing it during a new executor construction therefore proves - only that the previous process stopped before it finalized the effect. - Preserve that uncertainty instead of leaving an unreconcilable running - operation forever. - """ + """Mark only attempts whose recorded owner is no longer live as unknown.""" now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: self._begin(conn) rows = conn.execute( - "SELECT DISTINCT operation_id FROM operation_attempts WHERE state = 'running' ORDER BY operation_id" + "SELECT operation_id, worker_id FROM operation_attempts WHERE state = 'running' ORDER BY operation_id" ).fetchall() - operation_ids = tuple(str(row[0]) for row in rows) + operation_ids = tuple(str(row[0]) for row in rows if not _attempt_owner_is_live(cast(str | None, row[1]))) for operation_id in operation_ids: conn.execute( "UPDATE operation_attempts SET state = 'unknown', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'running'", diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index ce6ae65d17..af7b8c496d 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1148,7 +1148,7 @@ def adopt_missing_audit_tier( receipt_path = audit_adoption_receipt_path(archive_root) if _load_audit_adoption_receipt(archive_root) is not None: validate_audit_adoption_receipt(archive_root) - return 1, receipt_path + return _audit_live_metadata(path)[0], receipt_path stopped_evidence = stopped_daemon_check() manifest_path, verification_receipt = validate_full_evidence_backup_for_audit_adoption( backup_manifest, diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index 51dcb71303..fbf6a7595c 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -584,7 +584,10 @@ def for_archive_root( from polylogue.operations.audit import AuditRepository - audit = AuditRepository.for_archive_root(archive_root) + audit = AuditRepository.for_archive_root( + archive_root, + attempt_owner_id=AuditRepository.current_process_attempt_owner(), + ) audit.reconcile_continuity() audit.recover_abandoned_attempts() return cls(audit=audit, now_ms=now_ms, token_factory=token_factory, archive_root=archive_root) diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index 8d412e8c05..f143520439 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -4,7 +4,6 @@ import os import sqlite3 -from contextlib import closing from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -310,22 +309,6 @@ def initialize_archive_database( conn.close() -def _source_has_audit_continuity_control(source_path: Path) -> bool: - """Return whether source.db is under the replayable audit continuity regime.""" - if not source_path.is_file(): - return False - try: - with closing(sqlite3.connect(f"{source_path.resolve(strict=True).as_uri()}?mode=ro", uri=True)) as connection: - return ( - connection.execute( - "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'audit_continuity_control'" - ).fetchone() - is not None - ) - except (OSError, sqlite3.DatabaseError): - return False - - def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" from polylogue.operations.durable_change_train import audit_adoption_receipt_path, recover_pending_audit_adoption @@ -412,11 +395,15 @@ def classify_paths() -> tuple[bool, bool]: # Recompute the path-sensitive classification before deciding # whether startup must create the missing bootstrap marker. durable_tier_exists, pre_marker_adoption = classify_paths() + established_archive = has_bootstrap_marker or ( + (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() + and (root / archive_tier_spec(ArchiveTier.USER).filename).is_file() + ) if ( durable_tier_exists and not recovering_fresh_durable_bootstrap + and established_archive and not (root / archive_tier_spec(ArchiveTier.AUDIT).filename).is_file() - and _source_has_audit_continuity_control(root / archive_tier_spec(ArchiveTier.SOURCE).filename) ): raise RuntimeError( "established archive is missing audit.db; use maintenance migrate-tier audit " diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index b838d9667f..2a160b1ad2 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -18,7 +18,11 @@ from polylogue.cli.click_app import cli from polylogue.cli.commands.maintenance import _rebuild_index as maintenance_rebuild_index -from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload +from polylogue.cli.commands.maintenance._migrate_tier import ( + MigrateTierErrorPayload, + MigrateTierResultPayload, + MigrateTierSuccessPayload, +) from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import json_document @@ -2295,7 +2299,9 @@ def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( assert result.exit_code == 0, result.output payload = json.loads(result.stdout) - assert MigrateTierResultPayload.model_validate(payload).initialized is True + result_payload = MigrateTierResultPayload.model_validate(payload).root + assert isinstance(result_payload, MigrateTierSuccessPayload) + assert result_payload.initialized is True assert payload["ok"] is True assert payload["tier"] == "audit" assert payload["initialized"] is True @@ -3574,7 +3580,10 @@ def refuse_restore(*_args: object, **_kwargs: object) -> Path: assert result.exit_code == 1 if output_format == "json": - assert json.loads(result.stdout)["error"] == "inconsistent audit continuity head" + payload = json.loads(result.stdout) + result_payload = MigrateTierResultPayload.model_validate(payload).root + assert isinstance(result_payload, MigrateTierErrorPayload) + assert result_payload.error == "inconsistent audit continuity head" else: assert "Migration blocked for audit: inconsistent audit continuity head" in result.stderr diff --git a/tests/unit/cli/test_cli_output_schemas.py b/tests/unit/cli/test_cli_output_schemas.py index 6f94d7c370..a402c10bef 100644 --- a/tests/unit/cli/test_cli_output_schemas.py +++ b/tests/unit/cli/test_cli_output_schemas.py @@ -121,6 +121,27 @@ def test_machine_success_payload_validates_against_schema() -> None: jsonschema.validate(instance=instance, schema=schema) +def test_migrate_tier_error_payload_validates_against_schema() -> None: + """A blocked migrate-tier result remains valid against its published union.""" + import jsonschema + + from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload + + schema = _load_published_schema("migrate-tier-result") + payload = MigrateTierResultPayload.model_validate( + { + "ok": False, + "tier": "audit", + "path": "/archive/audit.db", + "backup_manifest": None, + "stopped_daemon_evidence_ref": None, + "error": "missing audit tier", + "durable_recovery": None, + } + ) + jsonschema.validate(instance=payload.model_dump(mode="json"), schema=schema) + + def test_mutation_result_payload_validates_against_schema() -> None: """A real MutationResultPayload must validate against the published schema.""" import jsonschema diff --git a/tests/unit/cli/test_excise.py b/tests/unit/cli/test_excise.py index 110b6f0998..bed62c9ce8 100644 --- a/tests/unit/cli/test_excise.py +++ b/tests/unit/cli/test_excise.py @@ -13,17 +13,15 @@ from click.testing import CliRunner from polylogue.cli import cli -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier def _seed_session(archive_root: Path, *, native_id: str) -> str: archive_root.mkdir(parents=True, exist_ok=True) + initialize_active_archive_root(archive_root) source_db = archive_root / "source.db" index_db = archive_root / "index.db" - initialize_archive_database(source_db, ArchiveTier.SOURCE) - initialize_archive_database(index_db, ArchiveTier.INDEX) source_conn = sqlite3.connect(source_db) source_conn.execute("PRAGMA foreign_keys = ON") @@ -131,6 +129,37 @@ def test_dry_run_reports_plan_without_mutating(self, tmp_path: Path) -> None: index_conn.close() assert count == 1 + def test_dry_run_does_not_construct_a_mutating_audit_executor(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="dry-run-no-executor") + with ( + patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root), + patch("polylogue.operations.mutation_transaction.OperationExecutor.for_archive_root") as factory, + ): + result = CliRunner().invoke( + cli, + ["ops", "excise", "--session", session_id, "--reason", "r", "--dry-run", "--json"], + ) + + assert result.exit_code == 0, result.output + factory.assert_not_called() + + def test_declined_confirmation_does_not_construct_a_mutating_audit_executor(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="declined-no-executor") + with ( + patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root), + patch("polylogue.operations.mutation_transaction.OperationExecutor.for_archive_root") as factory, + ): + result = CliRunner().invoke( + cli, + ["ops", "excise", "--session", session_id, "--reason", "r"], + input="n\n", + ) + + assert result.exit_code == 0, result.output + factory.assert_not_called() + def test_without_yes_aborts_in_json_mode(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" session_id = _seed_session(archive_root, native_id="no-yes-1") diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 27558764f8..d2fc3dc498 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -191,6 +191,63 @@ def test_production_executor_factory_persists_audit_preview(tmp_path: Path) -> N assert conn.execute("SELECT preview_id FROM operation_previews").fetchone()[0] == preview.preview_ref +def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: + """A second composition-root call recognizes the first executor's owner.""" + initialize_active_archive_root(tmp_path) + actuator = _Actuator() + first = OperationExecutor.for_archive_root(tmp_path, token_factory=lambda: "first-owner-token") + preview = first.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:live-owner", + archive_identity_digest="identity:live-owner", + parameter_digest="params:live-owner", + ) + authorization = first.authorize_bound(_binding(actuator), preview, _principal()) + assert first._audit is not None + operation_id = first._audit.consume_authorization_and_start(preview, authorization) + + OperationExecutor.for_archive_root(tmp_path) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert ( + conn.execute( + "SELECT state, worker_id FROM operation_attempts WHERE operation_id = ?", (operation_id,) + ).fetchone()[0] + == "running" + ) + + +def test_recovery_marks_a_dead_process_owned_attempt_unknown(tmp_path: Path) -> None: + """Restart recovery remains active when the recorded owner no longer exists.""" + initialize_active_archive_root(tmp_path) + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "dead-owner-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:dead-owner", + archive_identity_digest="identity:dead-owner", + parameter_digest="params:dead-owner", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute( + "UPDATE operation_attempts SET worker_id = 'pid:999999999:0' WHERE operation_id = ?", (operation_id,) + ) + conn.commit() + + assert audit.recover_abandoned_attempts() == (operation_id,) + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() == ( + "interrupted", + ) + + def test_audit_repository_cannot_bypass_the_continuity_coordinator( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index a0be86a76c..472f6bd620 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -2400,6 +2400,62 @@ def fail_audit_link( assert reconcile_durable_change_train_startup(archive_root) == () +def test_audit_adoption_retry_reports_recovered_audit_schema_version( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A receipt-backed retry reports the live audit schema, not a sentinel.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + real_link = os.link + + def interrupt_audit_publication( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name == "audit.db": + raise OSError("simulated publication interruption") + real_link( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.link", interrupt_audit_publication) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: + with pytest.raises(MigrationError, match="anonymous durable publication failed"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=manifest, + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + assert not audit_path.exists() + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption-retry") as owner: + recovered_version, _receipt = adopt_missing_audit_tier( + audit_path, + backup_manifest=manifest, + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert recovered_version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + + def test_audit_adoption_bootstrap_rejects_stale_replacement_before_recording_continuity( workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2677,6 +2733,24 @@ def observe_reconciliation(_root: Path) -> tuple[Path, ...]: assert not (archive_root / "audit.db").exists() +def test_runtime_bootstrap_refuses_source_v31_archive_missing_audit(workspace_env: dict[str, Path]) -> None: + """Bootstrap evidence remains authoritative before source v32 exists.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + (archive_root / "audit.db").unlink() + + with pytest.raises(RuntimeError, match="adopt-established-audit"): + initialize_active_archive_root(archive_root) + + assert not (archive_root / "audit.db").exists() + + def test_fresh_bootstrap_intent_recovers_after_late_tier_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From cf44ef5f90f8ff9c971a530bc92befb85029319b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 01:37:33 +0200 Subject: [PATCH 19/28] fix(audit): parse spaced process identities Parse Linux proc stat records after their parenthesized command field so PID reuse checks compare the actual start time. Cover a reused PID whose process name contains spaces. --- polylogue/operations/audit.py | 22 ++++++++++----- tests/unit/operations/test_operation_audit.py | 28 ++++++++++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index d4519a9d68..8efcd4b255 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -75,13 +75,24 @@ def token_sha256(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _linux_process_start_ticks(pid: int) -> str | None: + """Return Linux /proc start ticks without misparsing a spaced process name.""" + + try: + _prefix, delimiter, suffix = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").rpartition(")") + if not delimiter: + return None + return suffix.split()[19] + except (IndexError, OSError): + return None + + def _current_process_attempt_owner() -> str: """Return a local process identity that rejects PID reuse when available.""" pid = os.getpid() - try: - start_ticks = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()[21] - except (IndexError, OSError): + start_ticks = _linux_process_start_ticks(pid) + if start_ticks is None: return f"pid:{pid}" return f"pid:{pid}:{start_ticks}" @@ -101,10 +112,7 @@ def _attempt_owner_is_live(owner_id: str | None) -> bool: return False if len(parts) == 2: return True - try: - return Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()[21] == parts[2] - except (IndexError, OSError): - return False + return _linux_process_start_ticks(pid) == parts[2] @dataclass(frozen=True, slots=True) diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index d2fc3dc498..6af09a0a33 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -8,7 +8,12 @@ import pytest from pydantic import BaseModel -from polylogue.operations.audit import AuditRepository, token_sha256 +from polylogue.operations.audit import ( + AuditRepository, + _attempt_owner_is_live, + _current_process_attempt_owner, + token_sha256, +) from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import ( AuditFinalizationError, @@ -248,6 +253,27 @@ def test_recovery_marks_a_dead_process_owned_attempt_unknown(tmp_path: Path) -> ) +def test_process_owner_uses_proc_start_ticks_after_a_spaced_process_name(monkeypatch: pytest.MonkeyPatch) -> None: + """PID reuse remains detectable when /proc's parenthesized comm has spaces.""" + + state = {"stat": "321 (worker process) S " + " ".join(["0"] * 17 + ["stable", "old", "0"])} + + def read_text(self: Path, *, encoding: str) -> str: + assert self == Path("/proc/321/stat") + assert encoding == "utf-8" + return state["stat"] + + monkeypatch.setattr("polylogue.operations.audit.os.getpid", lambda: 321) + monkeypatch.setattr("polylogue.operations.audit.os.kill", lambda _pid, _signal: None) + monkeypatch.setattr(Path, "read_text", read_text) + + owner = _current_process_attempt_owner() + assert owner == "pid:321:old" + + state["stat"] = "321 (worker process) S " + " ".join(["0"] * 17 + ["stable", "new", "0"]) + assert not _attempt_owner_is_live(owner) + + def test_audit_repository_cannot_bypass_the_continuity_coordinator( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 245e7dd380e18e75ff033e315feda72a5c60cba2 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 01:52:09 +0200 Subject: [PATCH 20/28] fix(audit): preserve unproven recovery ownership Problem: recovery treated missing or unverifiable ownership as proof of process death, and a pre-publication migrate-tier failure could not serialize its nullable cleanup target.\n\nWhat changed: classify attempt ownership as live, dead, or unknown and recover only confirmed-dead owners; declare the recovery target nullable and validate the real CLI error against its rendered schema.\n\nCompatibility: legacy and externally-owned running attempts remain recoverable until an operator can establish their outcome. --- .../migrate-tier-result.schema.json | 11 ++++- .../cli/commands/maintenance/_migrate_tier.py | 2 +- polylogue/operations/audit.py | 41 +++++++++++------ .../unit/cli/test_archive_maintenance_cli.py | 46 +++++++++++++++++++ tests/unit/operations/test_operation_audit.py | 43 +++++++++++++++++ 5 files changed, 126 insertions(+), 17 deletions(-) diff --git a/docs/schemas/cli-output/migrate-tier-result.schema.json b/docs/schemas/cli-output/migrate-tier-result.schema.json index 7c054623f5..2f8752d845 100644 --- a/docs/schemas/cli-output/migrate-tier-result.schema.json +++ b/docs/schemas/cli-output/migrate-tier-result.schema.json @@ -31,8 +31,15 @@ "type": "string" }, "target": { - "title": "Target", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target" } }, "required": [ diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index af45999206..c135e955a0 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -46,7 +46,7 @@ class DurableRecoveryPayload(BaseModel): state: str code: str | None - target: str + target: str | None detail: str | None diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index 8efcd4b255..71fe530d8c 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -87,6 +87,29 @@ def _linux_process_start_ticks(pid: int) -> str | None: return None +def _attempt_owner_liveness(owner_id: str | None) -> Literal["live", "dead", "unknown"]: + """Classify an owner without mistaking unavailable liveness evidence for death.""" + + if owner_id is None: + return "unknown" + parts = owner_id.split(":") + if len(parts) not in {2, 3} or parts[0] != "pid": + return "unknown" + try: + pid = int(parts[1]) + os.kill(pid, 0) + except ProcessLookupError: + return "dead" + except (OSError, ValueError): + return "unknown" + if len(parts) == 2: + return "live" + start_ticks = _linux_process_start_ticks(pid) + if start_ticks is None: + return "unknown" + return "live" if start_ticks == parts[2] else "dead" + + def _current_process_attempt_owner() -> str: """Return a local process identity that rejects PID reuse when available.""" @@ -100,19 +123,7 @@ def _current_process_attempt_owner() -> str: def _attempt_owner_is_live(owner_id: str | None) -> bool: """Return whether an attempt's recorded local process is still its owner.""" - if owner_id is None: - return False - parts = owner_id.split(":") - if len(parts) not in {2, 3} or parts[0] != "pid": - return False - try: - pid = int(parts[1]) - os.kill(pid, 0) - except (OSError, ValueError): - return False - if len(parts) == 2: - return True - return _linux_process_start_ticks(pid) == parts[2] + return _attempt_owner_liveness(owner_id) == "live" @dataclass(frozen=True, slots=True) @@ -983,7 +994,9 @@ def _recover_abandoned_attempts(self) -> tuple[str, ...]: rows = conn.execute( "SELECT operation_id, worker_id FROM operation_attempts WHERE state = 'running' ORDER BY operation_id" ).fetchall() - operation_ids = tuple(str(row[0]) for row in rows if not _attempt_owner_is_live(cast(str | None, row[1]))) + operation_ids = tuple( + str(row[0]) for row in rows if _attempt_owner_liveness(cast(str | None, row[1])) == "dead" + ) for operation_id in operation_ids: conn.execute( "UPDATE operation_attempts SET state = 'unknown', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'running'", diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 2a160b1ad2..f87e2fcaf9 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -2775,6 +2775,52 @@ def fail_after_publish(descriptor: int) -> None: } +def test_migrate_tier_cli_serializes_a_prepublication_failure_against_its_schema( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed pre-link publication emits the published nullable recovery target.""" + import jsonschema + + from polylogue.cli.commands.maintenance import _migrate_tier + from polylogue.operations.durable_change_train import DurableCleanupOutcome, DurablePublicationError + + _stage_uninitialized_archive(cli_workspace) + + def fail_prepublication(*_args: object, **_kwargs: object) -> int: + raise DurablePublicationError("pre-publication write failed", cleanup=DurableCleanupOutcome("not_attempted")) + + monkeypatch.setattr(_migrate_tier, "initialize_missing_durable_tier", fail_prepublication) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["durable_recovery"] == { + "code": None, + "detail": None, + "state": "not_attempted", + "target": None, + } + schema = json.loads( + (Path(__file__).parents[3] / "docs/schemas/cli-output/migrate-tier-result.schema.json").read_text( + encoding="utf-8" + ) + ) + jsonschema.validate(instance=payload, schema=schema) + + def test_migrate_tier_cli_preserves_replacement_during_checked_leaf_cleanup( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 6af09a0a33..eef00f91ff 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -11,6 +11,7 @@ from polylogue.operations.audit import ( AuditRepository, _attempt_owner_is_live, + _attempt_owner_liveness, _current_process_attempt_owner, token_sha256, ) @@ -253,6 +254,43 @@ def test_recovery_marks_a_dead_process_owned_attempt_unknown(tmp_path: Path) -> ) +@pytest.mark.parametrize("owner_id", [None, "external:unverifiable"]) +def test_recovery_preserves_attempts_with_unproven_owners(tmp_path: Path, owner_id: str | None) -> None: + """Legacy or externally-owned attempts stay running until their owner is proven dead.""" + + initialize_active_archive_root(tmp_path) + audit = _audit(tmp_path) + executor = OperationExecutor(audit=audit, token_factory=lambda: "unproven-owner-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:unproven-owner", + archive_identity_digest="identity:unproven-owner", + parameter_digest="params:unproven-owner", + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute("UPDATE operation_attempts SET worker_id = ? WHERE operation_id = ?", (owner_id, operation_id)) + conn.commit() + + assert audit.recover_abandoned_attempts() == () + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() == ( + "running", + ) + + +def test_process_owner_liveness_is_unknown_when_start_ticks_cannot_be_read(monkeypatch: pytest.MonkeyPatch) -> None: + """A live PID with unreadable identity evidence is not proof that its owner died.""" + + monkeypatch.setattr("polylogue.operations.audit.os.kill", lambda _pid, _signal: None) + monkeypatch.setattr(Path, "read_text", lambda _self, *, encoding: (_ for _ in ()).throw(OSError("denied"))) + + assert _attempt_owner_liveness("pid:321:known-start") == "unknown" + + def test_process_owner_uses_proc_start_ticks_after_a_spaced_process_name(monkeypatch: pytest.MonkeyPatch) -> None: """PID reuse remains detectable when /proc's parenthesized comm has spaces.""" @@ -484,6 +522,11 @@ def test_reconciliation_resolves_the_full_unknown_atomic_batch(tmp_path: Path) - ) authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute( + "UPDATE operation_attempts SET worker_id = 'pid:999999999:0' WHERE operation_id = ?", (operation_id,) + ) + conn.commit() audit.recover_abandoned_attempts() audit.reconcile_attempt(operation_id, outcome="applied", domain_receipt_ref="receipt:reconciled") From 1ef97f861f1a796540dd1092ff8257a9c44e5e0b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 02:07:24 +0200 Subject: [PATCH 21/28] fix(audit): reject redirected authority paths Problem: a symlinked audit tier could redirect authority outside its archive root, and confirmed lifecycle requests could bypass the established-archive audit gate.\n\nWhat changed: require a regular audit leaf before bootstrap or audit access, and bootstrap the complete archive before a confirmed mirror or primary lifecycle request.\n\nCompatibility: confirmed mirror and primary requests now fail closed when an established archive lacks audit authority. --- polylogue/cli/commands/excise.py | 5 ++-- polylogue/operations/audit.py | 17 +++++++++-- .../storage/sqlite/archive_tiers/bootstrap.py | 15 ++++++++++ tests/unit/cli/test_excise.py | 28 +++++++++++++++++++ tests/unit/operations/test_operation_audit.py | 19 +++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index d4894149af..5c740c3c0d 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -163,11 +163,10 @@ def excise_command( import sqlite3 - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database - from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root user_db = root / "user.db" - initialize_archive_database(user_db, ArchiveTier.USER) + initialize_active_archive_root(root) conn = sqlite3.connect(user_db) try: with conn: diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index 71fe530d8c..d3ab258767 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -8,6 +8,7 @@ import os import secrets import sqlite3 +import stat import time from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager @@ -399,15 +400,27 @@ def current_process_attempt_owner() -> str: def reconcile_continuity(self) -> None: """Reject audit bytes that cannot prove the source control head.""" + self._assert_regular_audit_leaf() self._continuity.reconcile(self._replay_pending_mutation) + def _assert_regular_audit_leaf(self) -> None: + """Refuse an audit pathname that redirects authority outside the archive root.""" + + try: + metadata = self.path.lstat() + except FileNotFoundError as exc: + raise RuntimeError(f"audit tier is missing or uninitialized: {self.path}") from exc + except OSError as exc: + raise RuntimeError(f"cannot inspect audit tier leaf: {self.path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise RuntimeError(f"audit tier must be an archive-owned regular file: {self.path}") + @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: + self._assert_regular_audit_leaf() if self._coordinated_connection is not None: yield self._coordinated_connection return - if not self.path.is_file(): - raise RuntimeError(f"audit tier is missing or uninitialized: {self.path}") conn = sqlite3.connect(f"{self.path.resolve(strict=True).as_uri()}?mode=rw", uri=True) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index f143520439..d473c5fec7 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -4,6 +4,7 @@ import os import sqlite3 +import stat from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -335,6 +336,19 @@ def initialize_active_archive_root(root: Path) -> None: allow_reentrant=True, ) as owned: + def assert_regular_audit_leaf() -> None: + """Reject an audit pathname that could redirect durable authority outside this root.""" + + audit_path = root / archive_tier_spec(ArchiveTier.AUDIT).filename + try: + metadata = audit_path.lstat() + except FileNotFoundError: + return + except OSError as exc: + raise RuntimeError(f"cannot inspect audit tier leaf: {audit_path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise RuntimeError(f"audit tier must be an archive-owned regular file: {audit_path}") + def assert_owned_root() -> None: """Refuse pathname writes after the owned root has been replaced.""" assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) @@ -342,6 +356,7 @@ def assert_owned_root() -> None: # Classify the archive after acquiring ownership. Another process may # publish a marker or durable train while the probe is in flight. assert_owned_root() + assert_regular_audit_leaf() durable_tier_exists = any( (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS ) diff --git a/tests/unit/cli/test_excise.py b/tests/unit/cli/test_excise.py index bed62c9ce8..d534383b2a 100644 --- a/tests/unit/cli/test_excise.py +++ b/tests/unit/cli/test_excise.py @@ -286,6 +286,34 @@ def test_primary_yes_creates_pending_request_without_touching_local_content(self index_conn.close() assert count == 1 + def test_primary_refuses_missing_audit_without_writing_a_lifecycle_request(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-missing-audit") + (archive_root / "audit.db").unlink() + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + result = CliRunner().invoke( + cli, + [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "missing audit.db" in str(result.exception) + with sqlite3.connect(archive_root / "user.db") as connection: + assert connection.execute("SELECT COUNT(*) FROM assertions WHERE kind = 'excision_request'").fetchone() == ( + 0, + ) + class TestExciseLineageSafety: """CLI coverage for the polylogue-27m fix-round lineage-safety guard.""" diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index eef00f91ff..b556b82232 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -197,6 +197,25 @@ def test_production_executor_factory_persists_audit_preview(tmp_path: Path) -> N assert conn.execute("SELECT preview_id FROM operation_previews").fetchone()[0] == preview.preview_ref +def test_audit_authority_rejects_a_symlinked_audit_leaf_without_touching_its_target(tmp_path: Path) -> None: + """Bootstrap and direct audit access never follow an audit path outside its archive root.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + external_audit = tmp_path.parent / "external-audit.db" + external_audit.write_bytes(audit_path.read_bytes()) + audit_path.unlink() + audit_path.symlink_to(external_audit) + before = external_audit.read_bytes() + + with pytest.raises(RuntimeError, match="archive-owned regular file"): + initialize_active_archive_root(tmp_path) + with pytest.raises(RuntimeError, match="archive-owned regular file"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert external_audit.read_bytes() == before + + def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: """A second composition-root call recognizes the first executor's owner.""" initialize_active_archive_root(tmp_path) From 52b4e04832fd9d6e2134cc5b09e458f26dcd629e Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 02:37:16 +0200 Subject: [PATCH 22/28] fix(audit): harden authority continuity routes --- devtools/validation_lane_catalog_contracts.py | 1 + docs/plans/mutation-census.yaml | 8 ++ docs/test-quality-workflows.md | 6 +- polylogue/cli/commands/excise.py | 42 +++--- .../maintenance/raw_authority_recovery.py | 61 +++++++-- polylogue/operations/audit.py | 103 +++++++++----- polylogue/operations/durable_change_train.py | 6 +- polylogue/operations/mutation_actuators.py | 64 +++++++++ polylogue/operations/mutation_transaction.py | 25 +++- polylogue/operations/specs.py | 43 +++++- .../storage/sqlite/archive_tiers/bootstrap.py | 10 +- polylogue/storage/sqlite/audit_continuity.py | 30 ++-- polylogue/storage/sqlite/audit_leaf.py | 129 ++++++++++++++++++ tests/unit/cli/test_excise.py | 35 +++++ .../maintenance/test_raw_authority_reset.py | 13 +- tests/unit/operations/test_operation_audit.py | 117 ++++++++++++++++ tests/unit/operations/test_specs.py | 13 ++ .../unit/storage/test_durable_change_train.py | 89 +++++++++--- 18 files changed, 682 insertions(+), 113 deletions(-) create mode 100644 polylogue/storage/sqlite/audit_leaf.py diff --git a/devtools/validation_lane_catalog_contracts.py b/devtools/validation_lane_catalog_contracts.py index 1897f627da..4ee6223395 100644 --- a/devtools/validation_lane_catalog_contracts.py +++ b/devtools/validation_lane_catalog_contracts.py @@ -281,6 +281,7 @@ "mutate-clear-corrections", "mutate-delete-session", "mutate-session-excision", + "mutate-session-lifecycle-request", "mutate-identity-reset", ), tags=("contract", "mutation", "operation-executor"), diff --git a/docs/plans/mutation-census.yaml b/docs/plans/mutation-census.yaml index 60425b3d70..6aa47e68b2 100644 --- a/docs/plans/mutation-census.yaml +++ b/docs/plans/mutation-census.yaml @@ -61,6 +61,14 @@ rows: adapters: - polylogue.cli.commands.excise.excise_command + - operation: mutate-session-lifecycle-request + spec_name: mutate-session-lifecycle-request + status: executor-routed + actuator: polylogue.operations.mutation_actuators.SessionLifecycleRequestActuator + surfaces: [cli] + adapters: + - polylogue.cli.commands.excise.excise_command (--mode mirror/primary) + - operation: mutate-identity-reset spec_name: mutate-identity-reset status: executor-routed diff --git a/docs/test-quality-workflows.md b/docs/test-quality-workflows.md index 0140b1efb5..da802b2df3 100644 --- a/docs/test-quality-workflows.md +++ b/docs/test-quality-workflows.md @@ -26,9 +26,9 @@ Current registry snapshot: - covered runtime paths: `38` - covered runtime artifacts: `62` -- covered runtime operations: `57` +- covered runtime operations: `58` - covered maintenance targets: `5` -- covered declared operation targets: `79` +- covered declared operation targets: `80` - uncovered runtime paths: — - uncovered runtime artifacts: — - uncovered runtime operations: — @@ -399,7 +399,7 @@ These projections explain which executable lanes, inferred fixture scenarios, or | `validation-lane` | `maintenance-workflows` | — | — | — | — | — | — | `contract`
`maintenance` | Health, maintenance selection, cache/live provenance, and machine output | | `validation-lane` | `memory-budget` | `session-query-loop` | `message_fts`
`session_query_results` | — | — | `query-sessions` | — | `live`
`retrieval`
`readiness` | Live archive grouped retrieval command under an explicit RSS budget | | `validation-lane` | `mixed-consumer-contracts` | — | — | — | — | — | — | — | CLI, facade, and readiness surfaces consuming the same evidence/inference insight model | -| `validation-lane` | `mutation-routes` | `tag-mutation-loop`
`metadata-mutation-loop`
`mark-mutation-loop`
`annotation-mutation-loop`
`blackboard-post-loop`
`assertion-candidate-capture-loop`
`raw-authority-blocker-resolution-loop`
`raw-authority-recovery-loop`
`saved-view-mutation-loop`
`recall-pack-mutation-loop`
`workspace-mutation-loop`
`correction-mutation-loop`
`session-delete-loop`
`session-excision-loop`
`identity-reset-loop`
`message-fts-readiness-loop`
`session-insight-repair-loop` | `sessions`
`assertions`
`archive_deleted_session`
`raw_sessions`
`blob_refs`
`excision_receipt`
`suppression_rows`
`raw_authority_plans`
`raw_authority_blockers`
`raw_authority_blocker_resolution`
`raw_authority_census_ledger`
`raw_authority_census_recovery_receipt`
`raw_revision_heads`
`raw_revision_applications`
`raw_authority_index_seed_recovery_receipt` | — | — | `mutate-add-tag`
`mutate-remove-tag`
`mutate-bulk-tag-sessions`
`mutate-set-metadata`
`mutate-delete-metadata`
`mutate-add-mark`
`mutate-remove-mark`
`mutate-save-annotation`
`mutate-delete-annotation`
`mutate-blackboard-post`
`mutate-capture-assertion-candidate`
`mutate-import-annotation-batch`
`mutate-rebuild-index`
`mutate-update-index`
`mutate-rebuild-insights`
`mutate-resolve-raw-authority-blocker`
`mutate-reset-raw-authority-census`
`mutate-prune-orphaned-index-revision-seeds`
`mutate-save-saved-view`
`mutate-delete-saved-view`
`mutate-save-recall-pack`
`mutate-delete-recall-pack`
`mutate-save-workspace`
`mutate-delete-workspace`
`mutate-record-correction`
`mutate-delete-correction`
`mutate-clear-corrections`
`mutate-delete-session`
`mutate-session-excision`
`mutate-identity-reset` | — | `contract`
`mutation`
`operation-executor` | Executor-routed mutation actuators and transaction receipts over their declared runtime closures | +| `validation-lane` | `mutation-routes` | `tag-mutation-loop`
`metadata-mutation-loop`
`mark-mutation-loop`
`annotation-mutation-loop`
`blackboard-post-loop`
`assertion-candidate-capture-loop`
`raw-authority-blocker-resolution-loop`
`raw-authority-recovery-loop`
`saved-view-mutation-loop`
`recall-pack-mutation-loop`
`workspace-mutation-loop`
`correction-mutation-loop`
`session-delete-loop`
`session-excision-loop`
`identity-reset-loop`
`message-fts-readiness-loop`
`session-insight-repair-loop` | `sessions`
`assertions`
`archive_deleted_session`
`raw_sessions`
`blob_refs`
`excision_receipt`
`suppression_rows`
`raw_authority_plans`
`raw_authority_blockers`
`raw_authority_blocker_resolution`
`raw_authority_census_ledger`
`raw_authority_census_recovery_receipt`
`raw_revision_heads`
`raw_revision_applications`
`raw_authority_index_seed_recovery_receipt` | — | — | `mutate-add-tag`
`mutate-remove-tag`
`mutate-bulk-tag-sessions`
`mutate-set-metadata`
`mutate-delete-metadata`
`mutate-add-mark`
`mutate-remove-mark`
`mutate-save-annotation`
`mutate-delete-annotation`
`mutate-blackboard-post`
`mutate-capture-assertion-candidate`
`mutate-import-annotation-batch`
`mutate-rebuild-index`
`mutate-update-index`
`mutate-rebuild-insights`
`mutate-resolve-raw-authority-blocker`
`mutate-reset-raw-authority-census`
`mutate-prune-orphaned-index-revision-seeds`
`mutate-save-saved-view`
`mutate-delete-saved-view`
`mutate-save-recall-pack`
`mutate-delete-recall-pack`
`mutate-save-workspace`
`mutate-delete-workspace`
`mutate-record-correction`
`mutate-delete-correction`
`mutate-clear-corrections`
`mutate-delete-session`
`mutate-session-excision`
`mutate-session-lifecycle-request`
`mutate-identity-reset` | — | `contract`
`mutation`
`operation-executor` | Executor-routed mutation actuators and transaction receipts over their declared runtime closures | | `validation-lane` | `pipeline-probe-chatgpt` | `source-acquisition-loop`
`raw-reparse-loop`
`raw-archive-ingest-loop` | `configured_sources`
`source_payload_stream`
`raw_validation_state`
`artifact_observation_rows`
`validation_backlog`
`parse_backlog`
`parse_quarantine`
`archive_session_rows` | — | — | `acquire-raw-sessions`
`plan-validation-backlog`
`plan-parse-backlog`
`ingest-archive-runtime` | — | — | Synthetic ChatGPT parse-stage pipeline probe under explicit runtime and RSS budgets | | `validation-lane` | `probabilistic-enrichment-cleanup-live` | `archive-debt-query-loop`
`message-fts-readiness-loop`
`retrieval-band-readiness-loop` | `archive_readiness`
`embedding_status_results`
`message_fts`
`archive_debt_results`
`session_insight_readiness`
`retrieval_band_readiness` | — | — | `query-archive-debt`
`cli.json-contract`
`project-archive-readiness` | — | `insights`
`debt`
`live`
`maintenance`
`preview` | Bounded live archive lane for cleanup/debt preview and maintenance budgets | | `validation-lane` | `probabilistic-enrichment-contracts` | — | — | — | — | — | — | — | Session-enrichment contracts across CLI, facade, storage, and retrieval-band status | diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index 5c740c3c0d..1779b82876 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -125,8 +125,6 @@ def excise_command( root = archive_root() if mode != "standalone": - from polylogue.security.lifecycle import submit_lifecycle_request - target_ref = f"session:{session_id}" if dry_run: _emit( @@ -161,25 +159,33 @@ def excise_command( env.ui.console.print("Aborted.") return - import sqlite3 - + from polylogue.operations.bindings import runtime_operation_binding + from polylogue.operations.mutation_actuators import SessionLifecycleRequestActuator, SessionLifecycleRequestArgs + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor + from polylogue.security.lifecycle import LifecycleMode from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - user_db = root / "user.db" initialize_active_archive_root(root) - conn = sqlite3.connect(user_db) - try: - with conn: - assertion_id = submit_lifecycle_request( - conn, - target_ref=target_ref, - mode=mode, # type: ignore[arg-type] - reason=reason, - actor=actor, - now_ms=_now_ms(), - ) - finally: - conn.close() + lifecycle_actuator = SessionLifecycleRequestActuator() + lifecycle_args = SessionLifecycleRequestArgs( + archive_root=root, + session_id=session_id, + mode=cast(LifecycleMode, mode), + reason=reason, + actor=actor, + now_ms=_now_ms(), + ) + executor = OperationExecutor.for_archive_root(root) + lifecycle_binding = runtime_operation_binding(lifecycle_actuator) + lifecycle_principal = MutationPrincipal(actor, frozenset({"archive.request_session_lifecycle"}), "cli", "write") + lifecycle_preview = executor.prepare_bound_for_archive( + lifecycle_binding, lifecycle_args, lifecycle_principal, archive_root=root + ) + lifecycle_authorization = executor.authorize_bound( + lifecycle_binding, lifecycle_preview, lifecycle_principal, confirmation_strength="confirm_flag" + ) + receipt = executor.execute_bound(lifecycle_binding, lifecycle_preview, lifecycle_authorization, lifecycle_args) + assertion_id = cast(str, receipt.domain_receipt["assertion_id"]) _emit( env, status="ok", diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 95dc6a77d3..0c025f77dd 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -35,6 +35,7 @@ OperationExecutor, PlanStaleError, build_plan, + compute_parameter_digest, make_target_ref, ) from polylogue.paths import render_root @@ -117,18 +118,31 @@ def _file_fingerprint(path: Path) -> dict[str, object]: raise RawAuthorityRecoveryError(f"recovery tier is not readable: {path}") from exc digest = hashlib.sha256() try: - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as exc: + if path.name == "source.db": + # Audit continuity is an executor-owned authority side effect, not + # a raw-authority recovery input. Hash the logical source image + # while excluding that mutable WAL control row so PREPARE's own + # audit writes cannot invalidate its bound recovery plan. + with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as connection: + for line in connection.iterdump(): + if "audit_continuity_control" not in line: + digest.update(line.encode("utf-8")) + digest.update(b"\n") + else: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except (OSError, sqlite3.Error) as exc: raise RawAuthorityRecoveryError(f"could not fingerprint recovery tier: {path}") from exc - return { + fingerprint: dict[str, object] = { "path": str(path.resolve(strict=False)), - "size_bytes": stat.st_size, "sha256": digest.hexdigest(), "device": stat.st_dev, "inode": stat.st_ino, } + if path.name != "source.db": + fingerprint["size_bytes"] = stat.st_size + return fingerprint def _pointer_fingerprint(root: Path) -> dict[str, object]: @@ -186,10 +200,15 @@ def update(value: object) -> None: def _protected_digest(conn: sqlite3.Connection, *, excluded: tuple[str, ...]) -> str: + # OperationExecutor journals its own authorization transitions in this + # source-tier control table. Those transitions are verified by audit + # continuity itself and cannot make the recovery target set safe or + # unsafe, so they must not self-invalidate a bound recovery plan. + volatile_control_tables = {"audit_continuity_control"} tables = sorted( str(row[0]) for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") - if str(row[0]) not in excluded + if str(row[0]) not in excluded and str(row[0]) not in volatile_control_tables ) return _digest({name: _table_digest(conn, name) for name in tables}) @@ -1538,7 +1557,33 @@ def apply_raw_authority_recovery( # authorization. An uncommitted intent is evidence of interruption, # not authority to perform the destructive mutation. if _intent_for_plan(selected) is not None and _committed_postflight(selected) is not None: - return _apply_plan(selected) + executor = OperationExecutor.for_archive_root(root) + recovered = _apply_plan(selected) + raw_plan = build_plan( + operation=actuator.operation, + destructive_class=actuator.destructive_class, + target_refs=( + make_target_ref("source", operation.value) + if operation is RecoveryOperation.RESET_CENSUS + else make_target_ref("index", operation.value), + ), + affected_tiers=("source",) if operation is RecoveryOperation.RESET_CENSUS else ("index",), + reversible=False, + context={"recovery_plan_digest": selected.plan_digest, "operation_id": selected.operation_id}, + ) + operation_id = executor.find_interrupted_operation( + operation_name=actuator.operation, + parameter_digest=compute_parameter_digest(raw_plan), + ) + if operation_id is None: + raise RawAuthorityRecoveryError("committed recovery has no matching interrupted audit attempt") + executor.reconcile_operation( + operation_id, + outcome="applied", + domain_receipt_ref=str(recovered.receipt_path) if recovered.receipt_path is not None else None, + reason="exact raw-authority postflight proved the prior domain commit", + ) + return recovered executor = OperationExecutor.for_archive_root(root) binding = runtime_operation_binding(actuator) principal = MutationPrincipal( diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index d3ab258767..90d34e5b7c 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -8,7 +8,6 @@ import os import secrets import sqlite3 -import stat import time from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager @@ -25,8 +24,14 @@ MutationPrincipal, MutationReceipt, MutationTarget, + TokenExpiredError, ) from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + assert_verified_audit_leaf, + open_verified_audit_connection, +) AuditTargetState = Literal[ "pending", @@ -407,13 +412,9 @@ def _assert_regular_audit_leaf(self) -> None: """Refuse an audit pathname that redirects authority outside the archive root.""" try: - metadata = self.path.lstat() - except FileNotFoundError as exc: - raise RuntimeError(f"audit tier is missing or uninitialized: {self.path}") from exc - except OSError as exc: - raise RuntimeError(f"cannot inspect audit tier leaf: {self.path}") from exc - if not stat.S_ISREG(metadata.st_mode): - raise RuntimeError(f"audit tier must be an archive-owned regular file: {self.path}") + assert_verified_audit_leaf(self.path) + except AuditLeafError as exc: + raise RuntimeError(str(exc)) from exc @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: @@ -421,18 +422,16 @@ def _connection(self) -> Iterator[sqlite3.Connection]: if self._coordinated_connection is not None: yield self._coordinated_connection return - conn = sqlite3.connect(f"{self.path.resolve(strict=True).as_uri()}?mode=rw", uri=True) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - try: - yield conn - except BaseException: - conn.rollback() - raise - else: - conn.commit() - finally: - conn.close() + with open_verified_audit_connection(self.path) as conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + except BaseException: + conn.rollback() + raise + else: + conn.commit() def _continuity_payload( self, kind: str, args: tuple[object, ...], kwargs: Mapping[str, object] @@ -469,13 +468,16 @@ def _continuity_payload( ) return { "authorization_id": f"authorization:{secrets.token_urlsafe(18)}", - "issued_at_ms": int(time.time() * 1000), + "issued_at_ms": cast(int, values.get("issued_at_ms", int(time.time() * 1000))), "preview": _preview_payload(preview), "principal": _principal_payload(principal), "authorization": _authorization_payload(authorization), } if kind == "consume_authorization_and_start": - preview, authorization = cast(MutationPreview, args[0]), cast(MutationAuthorization, args[1]) + if isinstance(args[0], _StoredAuthorizationDigest): + preview, authorization = cast(MutationPreview, args[1]), cast(MutationAuthorization, args[2]) + else: + preview, authorization = cast(MutationPreview, args[0]), cast(MutationAuthorization, args[1]) return { "operation_id": f"operation:{secrets.token_urlsafe(18)}", "attempt_id": f"attempt:{secrets.token_urlsafe(18)}", @@ -684,6 +686,8 @@ def issue_authorization( preview: MutationPreview, principal: MutationPrincipal, authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, ) -> str: """Persist token digest and exact proved capabilities, never token material.""" @@ -694,6 +698,7 @@ def issue_authorization( preview, principal, authorization, + issued_at_ms=issued_at_ms, ) def _persist_authorization( @@ -702,11 +707,16 @@ def _persist_authorization( preview: MutationPreview, principal: MutationPrincipal, authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, ) -> str: authorization_id = cast( str, self._command_value("authorization_id", f"authorization:{secrets.token_urlsafe(18)}") ) - issued_at_ms = cast(int, self._command_value("issued_at_ms", int(time.time() * 1000))) + effective_issued_at_ms = cast( + int, + self._command_value("issued_at_ms", issued_at_ms if issued_at_ms is not None else int(time.time() * 1000)), + ) with self._connection() as conn: self._begin(conn) preview_row = conn.execute( @@ -737,8 +747,8 @@ def _persist_authorization( principal.role_label, authorization.confirmation_strength, token_digest.value, - issued_at_ms, - authorization.expires_at_ms or issued_at_ms, + effective_issued_at_ms, + authorization.expires_at_ms or effective_issued_at_ms, ), ) for capability in authorization.capabilities: @@ -748,24 +758,37 @@ def _persist_authorization( ) return authorization_id - @_continuity_mutation("consume_authorization_and_start") def consume_authorization_and_start(self, preview: MutationPreview, authorization: MutationAuthorization) -> str: """Consume a token and create run, targets, and initial attempt atomically.""" if authorization.token is None: raise ValueError("authorization token is missing") - return self._consume_authorization( + operation_id = self._consume_authorization_and_start( _StoredAuthorizationDigest(token_sha256(authorization.token)), preview, authorization, ) + if operation_id is None: + raise TokenExpiredError("authorization token is expired") + return operation_id + + @_continuity_mutation("consume_authorization_and_start") + def _consume_authorization_and_start( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + authorization: MutationAuthorization, + ) -> str | None: + """Commit an expired-token transition before reporting it to the caller.""" + + return self._consume_authorization(token_digest, preview, authorization) def _consume_authorization( self, token_digest: _StoredAuthorizationDigest, preview: MutationPreview, authorization: MutationAuthorization, - ) -> str: + ) -> str | None: operation_id = cast(str, self._command_value("operation_id", f"operation:{secrets.token_urlsafe(18)}")) attempt_id = cast(str, self._command_value("attempt_id", f"attempt:{secrets.token_urlsafe(18)}")) now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) @@ -790,7 +813,7 @@ def _consume_authorization( "UPDATE operation_authorizations SET state = 'expired' WHERE authorization_id = ?", (str(row[0]),), ) - raise RuntimeError("authorization token is expired") + return None if str(row[2]) != authorization.actor or str(row[3]) != (authorization.surface or ""): raise ValueError("authorization principal mismatch") if str(row[6]) != preview.plan.plan_hash or authorization.plan_hash != preview.plan.plan_hash: @@ -902,10 +925,11 @@ def finalize_attempt( "unknown": "unknown", "failed": "failed", "blocked": "rejected", + "already_satisfied": "already_satisfied", }.get(status, "applied"), ) attempt_state = ( - "unknown" if target_state == "unknown" else "failed" if target_state == "rejected" else target_state + "unknown" if target_state == "unknown" else "failed" if target_state == "rejected" else "applied" ) with self._connection() as conn: self._begin(conn) @@ -957,7 +981,7 @@ def finalize_attempt( rejected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'rejected'), failed_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'failed'), unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), - affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state IN ('applied', 'already_satisfied')), + affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'applied'), error_summary = ?, unknown_reason = ? WHERE operation_id = ? """, @@ -1115,6 +1139,23 @@ def get_operation(self, operation_id: str) -> dict[str, object] | None: row = conn.execute("SELECT * FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() return dict(row) if row is not None else None + def find_interrupted_operation(self, *, operation_name: str, parameter_digest: str) -> str | None: + """Return the one interrupted operation bound to an exact durable parameter digest.""" + + with self._connection() as conn: + rows = conn.execute( + """ + SELECT operation_id + FROM operation_runs + WHERE operation_name = ? AND parameter_digest = ? AND status = 'interrupted' + ORDER BY started_at_ms, operation_id + """, + (operation_name, parameter_digest), + ).fetchall() + if len(rows) > 1: + raise RuntimeError("multiple interrupted operations share the same durable parameter digest") + return None if not rows else str(rows[0][0]) + def list_events(self, operation_id: str) -> tuple[dict[str, object], ...]: with self._connection() as conn: rows = conn.execute( diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index af7b8c496d..fac460973e 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -1143,12 +1143,12 @@ def adopt_missing_audit_tier( if path.name != "audit.db": raise MigrationError(f"established-archive adoption is only supported for audit.db: {path}") archive_root = path.parent.resolve() - if path.exists() or path.is_symlink(): - raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") receipt_path = audit_adoption_receipt_path(archive_root) if _load_audit_adoption_receipt(archive_root) is not None: validate_audit_adoption_receipt(archive_root) return _audit_live_metadata(path)[0], receipt_path + if path.exists() or path.is_symlink(): + raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") stopped_evidence = stopped_daemon_check() manifest_path, verification_receipt = validate_full_evidence_backup_for_audit_adoption( backup_manifest, @@ -1371,7 +1371,7 @@ def restore_adopted_audit_tier( backup_version, backup_application_id, backup_quick_check = _audit_live_metadata(manifest_path.parent / "audit.db") if ( backup_version != artifact_version - or backup_version < expected_initial_version + or backup_version != ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] or backup_application_id != expected_application_id or backup_quick_check != ("ok",) ): diff --git a/polylogue/operations/mutation_actuators.py b/polylogue/operations/mutation_actuators.py index 424def4726..5cff49f030 100644 --- a/polylogue/operations/mutation_actuators.py +++ b/polylogue/operations/mutation_actuators.py @@ -39,6 +39,7 @@ build_plan, make_target_ref, ) +from polylogue.security.lifecycle import LifecycleMode from polylogue.storage.sqlite.connection_profile import open_connection if TYPE_CHECKING: @@ -214,6 +215,67 @@ def apply(self, plan: MutationPlan, args: SessionExcisionArgs) -> MutationReceip ) +# --------------------------------------------------------------------------- +# Lifecycle request (mutate-session-lifecycle-request) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class SessionLifecycleRequestArgs: + """Arguments for the durable mirror/primary excision-request outbox row.""" + + archive_root: Path + session_id: str + mode: LifecycleMode + reason: str + actor: str + now_ms: int + + +@dataclass(frozen=True, slots=True) +class SessionLifecycleRequestActuator: + """Create the local lifecycle request through the audit-backed executor.""" + + operation: str = "mutate-session-lifecycle-request" + destructive_class: DestructiveClass = "additive" + required_confirmation: ConfirmationStrength = "confirm_flag" + + def prepare(self, args: SessionLifecycleRequestArgs) -> MutationPlan: + return build_plan( + operation=self.operation, + destructive_class=self.destructive_class, + target_refs=(make_target_ref("session", args.session_id),), + affected_tiers=("user",), + reversible=True, + context={"mode": args.mode, "reason": args.reason}, + ) + + def apply(self, plan: MutationPlan, args: SessionLifecycleRequestArgs) -> MutationReceipt: + from polylogue.security.lifecycle import submit_lifecycle_request + + user_db = args.archive_root / "user.db" + with sqlite3.connect(user_db) as connection: + assertion_id = submit_lifecycle_request( + connection, + target_ref=make_target_ref("session", args.session_id), + mode=args.mode, + reason=args.reason, + actor=args.actor, + now_ms=args.now_ms, + ) + return MutationReceipt( + operation=self.operation, + plan_hash=plan.plan_hash, + status="applied", + target_refs=plan.target_refs, + affected_count=1, + detail=None, + receipt_ref=assertion_id, + applied_at=plan.prepared_at, + domain_receipt={"assertion_id": assertion_id, "mode": args.mode}, + ) + + # --------------------------------------------------------------------------- # Derived reset / identity tombstone (mutate-identity-reset) # --------------------------------------------------------------------------- @@ -2026,6 +2088,8 @@ def _resolve_session_id(archive: ArchiveStore, session_id: str) -> tuple[str, .. "SessionDeleteArgs", "SessionExcisionActuator", "SessionExcisionArgs", + "SessionLifecycleRequestActuator", + "SessionLifecycleRequestArgs", "TagAddActuator", "TagAddArgs", "TagRemoveActuator", diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index fbf6a7595c..e7e340a839 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -226,7 +226,7 @@ def compute_target_digest(targets: tuple[MutationTarget, ...]) -> str: return _sha256_document([target.canonical_dict() for target in targets]) -def _parameter_digest(raw_plan: MutationPlan) -> str: +def compute_parameter_digest(raw_plan: MutationPlan) -> str: """Hash stable caller intent without the clock-bound preview envelope.""" return _sha256_document( @@ -648,7 +648,7 @@ def prepare_bound_for_archive( principal, archive_instance_id=self._audit.ensure_archive_authority(now_ms=self._now_ms()), archive_identity_digest=ArchiveIdentity.resolve(archive_root).authority_identity_digest, - parameter_digest=_parameter_digest(raw_plan), + parameter_digest=compute_parameter_digest(raw_plan), raw_plan=raw_plan, ) @@ -690,7 +690,9 @@ def authorize_bound( surface=principal.surface, ) if self._audit is not None: - authorization_id = self._audit.issue_authorization(preview, principal, authorization) + authorization_id = self._audit.issue_authorization( + preview, principal, authorization, issued_at_ms=self._now_ms() + ) authorization = replace(authorization, authorization_id=authorization_id) return authorization @@ -706,7 +708,11 @@ def execute_bound( binding.validate() if authorization.preview_ref != preview.preview_ref or authorization.token is None: raise AuthorizationMismatchError("authorization is not bound to this preview") - if authorization.expires_at_ms is not None and self._now_ms() >= authorization.expires_at_ms: + if ( + self._audit is None + and authorization.expires_at_ms is not None + and self._now_ms() >= authorization.expires_at_ms + ): raise TokenExpiredError("authorization token is expired") if self._archive_root is not None: from polylogue.storage.archive_identity import ArchiveIdentity @@ -775,6 +781,16 @@ def reconcile_operation( reason=reason, ) + def find_interrupted_operation(self, *, operation_name: str, parameter_digest: str) -> str | None: + """Find the uniquely identified interrupted durable attempt for a recovery route.""" + + if self._audit is None: + raise MutationTransactionError("interrupted-operation lookup requires a durable audit repository") + return self._audit.find_interrupted_operation( + operation_name=operation_name, + parameter_digest=parameter_digest, + ) + def _typed_plan_from_actuator( self, binding: OperationBinding[ArgsT, object], @@ -944,6 +960,7 @@ def make_target_ref(kind: Literal["session", "message", "block", "source", "inde "TokenExpiredError", "build_plan", "build_typed_plan", + "compute_parameter_digest", "compute_plan_hash", "compute_target_digest", "compute_typed_plan_hash", diff --git a/polylogue/operations/specs.py b/polylogue/operations/specs.py index c75cb61ee8..0b56d1a38d 100644 --- a/polylogue/operations/specs.py +++ b/polylogue/operations/specs.py @@ -8,6 +8,7 @@ from typing import Literal from polylogue.core.json import JSONDocument, JSONDocumentList, json_document +from polylogue.core.user_state_targets import TARGET_KIND_NAMES from polylogue.operations.mutation_transaction import ( IdempotencyPolicy, Surface, @@ -1369,6 +1370,42 @@ def to_dict(self) -> JSONDocumentList: ), ), ), + OperationSpec( + name="mutate-session-lifecycle-request", + kind=OperationKind.MAINTENANCE, + description=( + "Create one durable mirror/primary session lifecycle-request outbox row through " + "OperationExecutor so its intent, authorization, and receipt share audit continuity authority." + ), + consumes=("archive_session_rows",), + produces=("excision_receipt",), + path_targets=("session-excision-loop",), + code_refs=( + "polylogue.cli.commands.excise.excise_command", + "polylogue.security.lifecycle.submit_lifecycle_request", + "polylogue.operations.mutation_actuators.SessionLifecycleRequestActuator", + ), + surfaces=("cli",), + mutates_state=True, + previewable=True, + idempotent=True, + effects=("DbRead", "DbWrite", "Destructive"), + safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), + executor_status="executor-routed", + allowed_surfaces=("cli",), + affected_tiers=("user", "audit"), + target_authority=( + TargetAuthorityPolicy( + key="session-lifecycle-request", + target_kinds=("session",), + required_capabilities=("archive.request_session_lifecycle",), + destructive_class="additive", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("retry_convergent",), + ), + ), + ), OperationSpec( name="mutate-identity-reset", kind=OperationKind.MAINTENANCE, @@ -1402,7 +1439,7 @@ def to_dict(self) -> JSONDocumentList: destructive_class="reset", required_confirmation="confirm_flag", allowed_durabilities=("durable",), - allowed_recovery=("rebuild",), + allowed_recovery=("reconcile_required",), ), ), ), @@ -1623,13 +1660,11 @@ def to_dict(self) -> JSONDocumentList: "annotation", "assertion", "blackboard", - "block", "correction", - "message", "recall_pack", "saved_view", - "session", "workspace", + *TARGET_KIND_NAMES, ) _LEGACY_EXECUTOR_CAPABILITIES: dict[str, str] = { diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index d473c5fec7..e124c0ff10 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -4,7 +4,6 @@ import os import sqlite3 -import stat from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -12,6 +11,7 @@ from polylogue.storage.sqlite.archive_tiers import ARCHIVE_DDL_BY_TIER, ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.index_convergence import apply_index_benign_ddl_convergence from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.audit_leaf import AuditLeafError, assert_verified_audit_leaf from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec @@ -341,13 +341,15 @@ def assert_regular_audit_leaf() -> None: audit_path = root / archive_tier_spec(ArchiveTier.AUDIT).filename try: - metadata = audit_path.lstat() + audit_path.lstat() except FileNotFoundError: return except OSError as exc: raise RuntimeError(f"cannot inspect audit tier leaf: {audit_path}") from exc - if not stat.S_ISREG(metadata.st_mode): - raise RuntimeError(f"audit tier must be an archive-owned regular file: {audit_path}") + try: + assert_verified_audit_leaf(audit_path) + except AuditLeafError as exc: + raise RuntimeError(str(exc)) from exc def assert_owned_root() -> None: """Refuse pathname writes after the owned root has been replaced.""" diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 3056e55db9..6cbc2f125b 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -19,6 +19,8 @@ from pathlib import Path from typing import TypeVar, cast +from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf, open_verified_audit_connection + _FORMAT = "polylogue.audit-continuity-command.v1" AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 = "3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f" _T = TypeVar("_T") @@ -78,11 +80,10 @@ def audit_semantic_sha256(path: Path) -> str: """Hash audit content while excluding the self-mutating continuity head.""" try: - uri = f"{path.resolve(strict=True).as_uri()}?mode=ro" - with closing(sqlite3.connect(uri, uri=True)) as connection: + with open_verified_audit_connection(path) as connection: lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() - except sqlite3.DatabaseError as exc: + except (AuditLeafError, sqlite3.DatabaseError) as exc: raise AuditContinuityError("cannot hash audit content for continuity validation") from exc @@ -167,7 +168,7 @@ def is_available(self) -> bool: try: with ( closing(sqlite3.connect(self.source_path)) as source, - closing(sqlite3.connect(self.audit_path)) as audit, + open_verified_audit_connection(self.audit_path) as audit, ): source.execute("SELECT 1 FROM audit_continuity_control WHERE singleton = 1").fetchone() audit.execute("SELECT 1 FROM audit_continuity_head WHERE singleton = 1").fetchone() @@ -220,7 +221,7 @@ def has_committed_mutation(self, mutation_id: str) -> bool: try: with ( closing(sqlite3.connect(self.source_path)) as source, - closing(sqlite3.connect(self.audit_path)) as audit, + open_verified_audit_connection(self.audit_path) as audit, ): source_row = source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" @@ -316,7 +317,7 @@ def _apply_prepared( ) -> _T: self._validate_prepared(prepared) mutation = AuditMutation.from_command(prepared["command"]) - with closing(sqlite3.connect(self.audit_path)) as conn, conn: + with open_verified_audit_connection(self.audit_path) as conn, conn: conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") conn.execute("BEGIN IMMEDIATE") @@ -378,7 +379,7 @@ def _abort_prepared(self, prepared: Mapping[str, object]) -> None: mutation = AuditMutation.from_command(prepared["command"]) prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) - with closing(sqlite3.connect(self.audit_path)) as audit: + with open_verified_audit_connection(self.audit_path) as audit: audit.execute("BEGIN IMMEDIATE") row = audit.execute( "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" @@ -409,7 +410,10 @@ def _abort_prepared(self, prepared: Mapping[str, object]) -> None: source.commit() def _assert_committed_head_matches_audit(self) -> None: - with closing(sqlite3.connect(self.source_path)) as source, closing(sqlite3.connect(self.audit_path)) as audit: + with ( + closing(sqlite3.connect(self.source_path)) as source, + open_verified_audit_connection(self.audit_path) as audit, + ): source_row = source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" ).fetchone() @@ -446,10 +450,12 @@ def _assert_rebind_image(self, mutation: AuditMutation) -> None: raise AuditContinuityError("rebind command lacks an audit image sha256") digest = hashlib.sha256() try: - with self.audit_path.open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as exc: + with VerifiedAuditLeaf(self.audit_path.parent, filename=self.audit_path.name) as leaf: + with leaf.anchored_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + leaf.assert_unchanged() + except (AuditLeafError, OSError) as exc: raise AuditContinuityError("cannot read audit image for rebind") from exc if digest.hexdigest() != expected: raise AuditContinuityError("audit image changed before continuity rebind") diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py new file mode 100644 index 0000000000..bbeb764d0b --- /dev/null +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -0,0 +1,129 @@ +"""Descriptor-anchored access to the archive-owned ``audit.db`` leaf.""" + +from __future__ import annotations + +import os +import sqlite3 +import stat +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + + +class AuditLeafError(RuntimeError): + """The audit pathname cannot prove it is one archive-owned database file.""" + + +@dataclass(frozen=True, slots=True) +class _AuditLeafIdentity: + device: int + inode: int + + +class VerifiedAuditLeaf: + """Keep one archive directory descriptor and verify its ``audit.db`` leaf. + + SQLite accepts a URI below ``/proc/self/fd/``. That binds + all database and sidecar opens to the directory we inspected, rather than + re-resolving the caller's mutable pathname. The leaf is checked before + and immediately after SQLite opens it, so a replace between those steps + is rejected before any caller receives a connection. + """ + + def __init__(self, archive_root: Path, *, filename: str = "audit.db") -> None: + self._archive_root = archive_root + self._filename = filename + self._directory_fd: int | None = None + self._identity: _AuditLeafIdentity | None = None + + def __enter__(self) -> VerifiedAuditLeaf: + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC + nofollow = getattr(os, "O_NOFOLLOW", 0) + try: + self._directory_fd = os.open(self._archive_root, directory_flags | nofollow) + self._validate(self._lstat_leaf_metadata()) + metadata = self._open_leaf_metadata() + except OSError as exc: + self.close() + raise AuditLeafError(f"cannot safely open audit tier leaf: {self._archive_root / self._filename}") from exc + self._identity = self._validate(metadata) + return self + + def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: + self.close() + + @property + def sqlite_uri(self) -> str: + return f"{self.anchored_path.as_uri()}?mode=rw" + + @property + def anchored_path(self) -> Path: + """Return the descriptor-anchored path SQLite and byte readers may open.""" + + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + return Path(f"/proc/self/fd/{self._directory_fd}/{self._filename}") + + def assert_unchanged(self) -> None: + """Require the current directory entry to retain the inspected inode.""" + + if self._identity is None: + raise RuntimeError("audit leaf descriptor is closed") + try: + current = self._validate(self._open_leaf_metadata()) + except OSError as exc: + raise AuditLeafError(f"cannot revalidate audit tier leaf: {self._archive_root / self._filename}") from exc + if current != self._identity: + raise AuditLeafError(f"audit tier leaf changed during SQLite open: {self._archive_root / self._filename}") + + def close(self) -> None: + if self._directory_fd is not None: + os.close(self._directory_fd) + self._directory_fd = None + self._identity = None + + def _open_leaf_metadata(self) -> os.stat_result: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(self._filename, flags, dir_fd=self._directory_fd) + try: + return os.fstat(descriptor) + finally: + os.close(descriptor) + + def _lstat_leaf_metadata(self) -> os.stat_result: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + return os.stat(self._filename, dir_fd=self._directory_fd, follow_symlinks=False) + + def _validate(self, metadata: os.stat_result) -> _AuditLeafIdentity: + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise AuditLeafError( + f"audit tier must be an archive-owned regular file with one link: {self._archive_root / self._filename}" + ) + return _AuditLeafIdentity(metadata.st_dev, metadata.st_ino) + + +@contextmanager +def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open one writable audit connection pinned to an owned leaf descriptor.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri, uri=True) + try: + leaf.assert_unchanged() + yield connection + finally: + connection.close() + + +def assert_verified_audit_leaf(path: Path) -> None: + """Check an existing audit leaf without exposing its descriptor to callers.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name): + return + + +__all__ = ["AuditLeafError", "VerifiedAuditLeaf", "assert_verified_audit_leaf", "open_verified_audit_connection"] diff --git a/tests/unit/cli/test_excise.py b/tests/unit/cli/test_excise.py index d534383b2a..febf73a869 100644 --- a/tests/unit/cli/test_excise.py +++ b/tests/unit/cli/test_excise.py @@ -275,6 +275,10 @@ def test_primary_yes_creates_pending_request_without_touching_local_content(self assert row is not None assert row[0] == "excision_request" assert row[1] == f"session:{session_id}" + with sqlite3.connect(archive_root / "audit.db") as audit_connection: + assert audit_connection.execute( + "SELECT status FROM operation_runs WHERE operation_name = 'mutate-session-lifecycle-request'" + ).fetchone() == ("completed",) # Local content is untouched by mirror/primary mode. index_conn = sqlite3.connect(archive_root / "index.db") @@ -314,6 +318,37 @@ def test_primary_refuses_missing_audit_without_writing_a_lifecycle_request(self, 0, ) + def test_primary_refuses_broken_audit_continuity_without_writing_a_lifecycle_request(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-broken-continuity") + with sqlite3.connect(archive_root / "audit.db") as connection: + connection.execute("UPDATE audit_continuity_head SET head_sha256 = ? WHERE singleton = 1", ("0" * 64,)) + connection.commit() + + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + result = CliRunner().invoke( + cli, + [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "continuity head regressed" in str(result.exception) + with sqlite3.connect(archive_root / "user.db") as connection: + assert connection.execute("SELECT COUNT(*) FROM assertions WHERE kind = 'excision_request'").fetchone() == ( + 0, + ) + class TestExciseLineageSafety: """CLI coverage for the polylogue-27m fix-round lineage-safety guard.""" diff --git a/tests/unit/maintenance/test_raw_authority_reset.py b/tests/unit/maintenance/test_raw_authority_reset.py index d60dbb8cfd..465e802fdf 100644 --- a/tests/unit/maintenance/test_raw_authority_reset.py +++ b/tests/unit/maintenance/test_raw_authority_reset.py @@ -217,7 +217,7 @@ def test_census_reset_refuses_wal_visible_ledger_drift(tmp_path: Path, monkeypat refreshed = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.RESET_CENSUS, backup_manifest=backup) assert refreshed.ledger_digest != plan.ledger_digest assert refreshed.plan_digest != plan.plan_digest - with pytest.raises(RawAuthorityRecoveryError, match="stale before lease acquisition"): + with pytest.raises(RawAuthorityRecoveryError, match="stale after ownership acquisition"): apply_raw_authority_recovery(plan) with sqlite3.connect(source_db) as conn: assert conn.execute("SELECT residual_json FROM raw_authority_censuses WHERE census_id = 'c1'").fetchone() == ( @@ -278,6 +278,9 @@ def fail_final_receipt(root: Path, path: Path, payload: dict[str, object], *, di receipt = json.loads(receipt_path.read_text(encoding="utf-8")) assert receipt["operation_id"] == operation_id assert receipt["plan_digest"] == recovered.plan.plan_digest + with sqlite3.connect(tmp_path / "audit.db") as audit: + assert audit.execute("SELECT status FROM operation_runs").fetchone() == ("completed",) + assert audit.execute("SELECT state FROM operation_targets").fetchone() == ("applied",) def test_persisted_recovery_plan_ignores_process_scoped_archive_metadata( @@ -526,7 +529,7 @@ def test_uncommitted_recovery_intent_reauthorizes_through_executor( def require_authorization(*_args: object, **_kwargs: object) -> Never: raise RuntimeError("executor authorization was required") - monkeypatch.setattr(OperationExecutor, "authorize", require_authorization) + monkeypatch.setattr(OperationExecutor, "authorize_bound", require_authorization) with pytest.raises(RuntimeError, match="executor authorization was required"): apply_raw_authority_recovery(plan) with sqlite3.connect(tmp_path / "source.db") as conn: @@ -942,12 +945,12 @@ def test_uncommitted_index_prune_intent_reauthorizes_before_deleting_candidates( plan = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.PRUNE_INDEX_SEEDS, backup_manifest=backup) _write_recovery_intent(plan) - original_authorize = OperationExecutor.authorize + original_authorize = OperationExecutor.authorize_bound def require_authorization(*_args: object, **_kwargs: object) -> Never: raise RuntimeError("executor authorization was required") - monkeypatch.setattr(OperationExecutor, "authorize", require_authorization) + monkeypatch.setattr(OperationExecutor, "authorize_bound", require_authorization) with pytest.raises(RuntimeError, match="executor authorization was required"): resume_raw_authority_recovery( tmp_path, @@ -958,7 +961,7 @@ def require_authorization(*_args: object, **_kwargs: object) -> Never: assert conn.execute("SELECT COUNT(*) FROM raw_revision_heads").fetchone() == (2,) assert conn.execute("SELECT COUNT(*) FROM raw_revision_applications").fetchone() == (2,) - monkeypatch.setattr(OperationExecutor, "authorize", original_authorize) + monkeypatch.setattr(OperationExecutor, "authorize_bound", original_authorize) resumed = resume_raw_authority_recovery( tmp_path, RecoveryOperation.PRUNE_INDEX_SEEDS, diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index b556b82232..1b657db65d 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -2,8 +2,10 @@ import json import sqlite3 +from collections.abc import Callable from dataclasses import dataclass, field, replace from pathlib import Path +from typing import cast import pytest from pydantic import BaseModel @@ -28,6 +30,7 @@ PlanStaleError, TargetAuthorityPolicy, TargetDurability, + TokenExpiredError, build_plan, ) from polylogue.operations.specs import OperationKind, OperationSpec @@ -97,6 +100,12 @@ def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: ) +@dataclass +class _AlreadySatisfiedActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + return replace(super().apply(plan, args), status="already_satisfied", affected_count=0) + + def _binding( actuator: _Actuator, *, target_durability: TargetDurability = "derived" ) -> OperationBinding[object, object]: @@ -216,6 +225,56 @@ def test_audit_authority_rejects_a_symlinked_audit_leaf_without_touching_its_tar assert external_audit.read_bytes() == before +def test_audit_authority_rejects_a_hardlinked_audit_leaf_without_touching_its_target(tmp_path: Path) -> None: + """A regular-looking audit leaf must still have exactly one archive-owned link.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + external_audit = tmp_path.parent / "external-hardlinked-audit.db" + external_audit.write_bytes(audit_path.read_bytes()) + audit_path.unlink() + audit_path.hardlink_to(external_audit) + before = external_audit.read_bytes() + + with pytest.raises(RuntimeError, match="one link"): + initialize_active_archive_root(tmp_path) + with pytest.raises(RuntimeError, match="one link"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert external_audit.read_bytes() == before + + +def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """SQLite never yields a connection after the descriptor-checked leaf changes.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + replacement = tmp_path / "replacement-audit.db" + replacement.write_bytes(audit_path.read_bytes()) + before = replacement.read_bytes() + original_connect = cast(Callable[..., sqlite3.Connection], sqlite3.connect) + swapped = False + + def replace_after_open(database: object, *args: object, **kwargs: object) -> sqlite3.Connection: + nonlocal swapped + connection = original_connect(database, *args, **kwargs) + if not swapped and "/proc/self/fd/" in str(database): + swapped = True + audit_path.unlink() + replacement.replace(audit_path) + return connection + + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.sqlite3.connect", replace_after_open) + + with pytest.raises(RuntimeError, match="changed during SQLite open"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert swapped + assert audit_path.read_bytes() == before + + def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: """A second composition-root call recognizes the first executor's owner.""" initialize_active_archive_root(tmp_path) @@ -501,6 +560,64 @@ def test_zero_target_finalization_completes_a_successful_noop(tmp_path: Path) -> ).fetchone() == ("completed", None, 0) +def test_expired_authorization_is_durably_marked_before_execute_refuses( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The bound execution route commits expiry instead of rolling it back with the refusal.""" + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "expired-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:expired", + archive_identity_digest="identity:expired", + parameter_digest="params:expired", + expires_at_ms=61_000, + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + clock[0] = 61_000 + monkeypatch.setattr("polylogue.operations.audit.time.time", lambda: 61.0) + + with pytest.raises(TokenExpiredError): + executor.execute_bound(_binding(_Actuator()), preview, authorization, object()) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("expired",) + + +def test_already_satisfied_receipt_preserves_target_state_and_zero_affected_count(tmp_path: Path) -> None: + """A nonempty idempotent success remains distinct from an applied domain effect.""" + + audit = _audit(tmp_path) + actuator = _AlreadySatisfiedActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "already-satisfied-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:already-satisfied", + archive_identity_digest="identity:already-satisfied", + parameter_digest="params:already-satisfied", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("already_satisfied",) + assert conn.execute( + "SELECT status, affected_count FROM operation_runs WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ( + "completed", + 0, + ) + + def test_blocked_finalization_rejects_targets_and_fails_parent_run(tmp_path: Path) -> None: audit = _audit(tmp_path) actuator = _Actuator() diff --git a/tests/unit/operations/test_specs.py b/tests/unit/operations/test_specs.py index 79e1909b74..e663d3704f 100644 --- a/tests/unit/operations/test_specs.py +++ b/tests/unit/operations/test_specs.py @@ -1,5 +1,6 @@ from __future__ import annotations +from polylogue.core.user_state_targets import TARGET_KIND_NAMES from polylogue.operations import ( OperationKind, build_declared_operation_catalog, @@ -68,6 +69,7 @@ def test_runtime_operation_catalog_covers_the_current_runtime_paths() -> None: "mutate-delete-session", "mutate-bulk-tag-sessions", "mutate-session-excision", + "mutate-session-lifecycle-request", "mutate-identity-reset", } assert specs["acquire-raw-sessions"].kind is OperationKind.MATERIALIZATION @@ -152,6 +154,17 @@ def test_raw_authority_recovery_specs_declare_their_exact_target_kinds() -> None ] +def test_user_mutation_policy_tracks_the_supported_target_registry_and_identity_recovery() -> None: + """Bound facade writes accept every user-state target the core registry admits.""" + + specs = build_runtime_operation_catalog().by_name() + add_mark_policy = specs["mutate-add-mark"].target_authority[0] + identity_reset_policy = specs["mutate-identity-reset"].target_authority[0] + + assert set(TARGET_KIND_NAMES).issubset(add_mark_policy.target_kinds) + assert identity_reset_policy.allowed_recovery == ("reconcile_required",) + + def test_declared_operation_catalog_contains_runtime_and_control_plane_operations() -> None: catalog = build_declared_operation_catalog() diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 472f6bd620..05a8439a0c 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -2113,6 +2113,62 @@ def test_adopted_audit_restore_rejects_untrusted_or_stale_backup( assert audit_path.read_bytes() == b"corrupted audit image" +def test_adopted_audit_restore_rejects_an_authenticated_legacy_audit_schema( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Restore admission rejects a staged audit image that predates the continuity schema it needs.""" + + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "legacy-schema-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:legacy-schema-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive( + output_dir=archive_root.parent / "legacy-schema-post", profile="full_evidence", verify=True + ) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + with sqlite3.connect(backup_root / "audit.db") as staged: + staged.execute("DROP TABLE audit_continuity_head") + staged.execute("PRAGMA user_version = 1") + receipt = archive_root.parent / "legacy-schema-receipt.json" + receipt.write_text( + json.dumps({"tier_artifacts": [{"tier": "audit", "sha256": "0" * 64, "size_bytes": 1, "user_version": 1}]}), + encoding="utf-8", + ) + monkeypatch.setattr( + operations_durable_change_train, + "validate_full_evidence_backup_for_adopted_audit_restore", + lambda *_args, **_kwargs: (backup_root / "manifest.json", receipt), + ) + + with acquire_durable_archive_ownership(archive_root, owner_id="test:legacy-schema-restore") as owner: + with pytest.raises(MigrationError, match="does not belong"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(audit_path) as audit: + assert audit.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert audit.execute("SELECT 1 FROM audit_continuity_head").fetchone() == (1,) + + def test_adopted_audit_restore_rejects_wrong_archive_application_id( workspace_env: dict[str, Path], ) -> None: @@ -2413,37 +2469,28 @@ def test_audit_adoption_retry_reports_recovered_audit_schema_version( backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) assert backup.ok and backup.output_path is not None, backup.error manifest = Path(backup.output_path) / "manifest.json" - real_link = os.link + from polylogue.operations import durable_change_train as operations_durable_change_train - def interrupt_audit_publication( - source: os.PathLike[str] | str, - destination: os.PathLike[str] | str, - *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, - follow_symlinks: bool = True, - ) -> None: - if Path(destination).name == "audit.db": - raise OSError("simulated publication interruption") - real_link( - source, - destination, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, - follow_symlinks=follow_symlinks, - ) + real_validate = operations_durable_change_train.validate_audit_adoption_receipt + + def interrupt_after_publication(root: Path, *, require_initial_image: bool = False) -> Path | None: + if require_initial_image: + raise RuntimeError("simulated post-publication interruption") + return real_validate(root, require_initial_image=require_initial_image) with monkeypatch.context() as interrupted: - interrupted.setattr("polylogue.operations.durable_change_train.os.link", interrupt_audit_publication) + interrupted.setattr( + operations_durable_change_train, "validate_audit_adoption_receipt", interrupt_after_publication + ) with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: - with pytest.raises(MigrationError, match="anonymous durable publication failed"): + with pytest.raises(RuntimeError, match="post-publication interruption"): adopt_missing_audit_tier( audit_path, backup_manifest=manifest, directory_fd=owner.directory_fd, stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) - assert not audit_path.exists() + assert audit_path.is_file() with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption-retry") as owner: recovered_version, _receipt = adopt_missing_audit_tier( From e749f078f848174abddadf7e82b2b93aa9de123a Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 03:09:31 +0200 Subject: [PATCH 23/28] fix(audit): preserve exact audit execution evidence Problem: lifecycle replays, failed receipts, mutable preview payloads, and\nplatform-specific audit leaf access could make durable audit evidence diverge\nfrom the real domain action.\n\nWhat changed: record lifecycle replays as already satisfied, preserve failed\nattempt states, validate typed preview data before consumption, and harden\nportable descriptor-bound audit leaf ownership and cleanup. --- polylogue/operations/audit.py | 8 +- polylogue/operations/mutation_actuators.py | 12 +- polylogue/operations/mutation_transaction.py | 25 ++++ polylogue/security/lifecycle.py | 34 ++++- polylogue/storage/sqlite/audit_leaf.py | 24 +++- .../storage/sqlite/connection_profile.py | 18 ++- tests/unit/cli/test_excise.py | 36 ++++++ tests/unit/operations/test_operation_audit.py | 118 +++++++++++++++++- 8 files changed, 254 insertions(+), 21 deletions(-) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index 90d34e5b7c..b66c3ee8f2 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -25,6 +25,7 @@ MutationReceipt, MutationTarget, TokenExpiredError, + validate_mutation_plan_integrity, ) from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation from polylogue.storage.sqlite.audit_leaf import ( @@ -789,6 +790,7 @@ def _consume_authorization( preview: MutationPreview, authorization: MutationAuthorization, ) -> str | None: + validate_mutation_plan_integrity(preview.plan) operation_id = cast(str, self._command_value("operation_id", f"operation:{secrets.token_urlsafe(18)}")) attempt_id = cast(str, self._command_value("attempt_id", f"attempt:{secrets.token_urlsafe(18)}")) now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) @@ -929,7 +931,11 @@ def finalize_attempt( }.get(status, "applied"), ) attempt_state = ( - "unknown" if target_state == "unknown" else "failed" if target_state == "rejected" else "applied" + "unknown" + if target_state == "unknown" + else "failed" + if target_state in {"rejected", "failed"} + else "applied" ) with self._connection() as conn: self._begin(conn) diff --git a/polylogue/operations/mutation_actuators.py b/polylogue/operations/mutation_actuators.py index 5cff49f030..7248d0972c 100644 --- a/polylogue/operations/mutation_actuators.py +++ b/polylogue/operations/mutation_actuators.py @@ -251,11 +251,11 @@ def prepare(self, args: SessionLifecycleRequestArgs) -> MutationPlan: ) def apply(self, plan: MutationPlan, args: SessionLifecycleRequestArgs) -> MutationReceipt: - from polylogue.security.lifecycle import submit_lifecycle_request + from polylogue.security.lifecycle import submit_lifecycle_request_with_outcome user_db = args.archive_root / "user.db" with sqlite3.connect(user_db) as connection: - assertion_id = submit_lifecycle_request( + submission = submit_lifecycle_request_with_outcome( connection, target_ref=make_target_ref("session", args.session_id), mode=args.mode, @@ -266,13 +266,13 @@ def apply(self, plan: MutationPlan, args: SessionLifecycleRequestArgs) -> Mutati return MutationReceipt( operation=self.operation, plan_hash=plan.plan_hash, - status="applied", + status="applied" if submission.created else "already_satisfied", target_refs=plan.target_refs, - affected_count=1, + affected_count=1 if submission.created else 0, detail=None, - receipt_ref=assertion_id, + receipt_ref=submission.assertion_id, applied_at=plan.prepared_at, - domain_receipt={"assertion_id": assertion_id, "mode": args.mode}, + domain_receipt={"assertion_id": submission.assertion_id, "mode": args.mode}, ) diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index e7e340a839..c9a1267971 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -419,6 +419,28 @@ def build_typed_plan( ) +def validate_mutation_plan_integrity(plan: MutationPlan) -> None: + """Reject a reconstructed preview whose typed authority fields were changed.""" + + target_refs = tuple(target.ref for target in plan.targets) + target_digest = compute_target_digest(plan.targets) + plan_hash = compute_typed_plan_hash( + operation=plan.operation, + operation_version=plan.operation_version, + archive_instance_id=plan.archive_instance_id, + archive_identity_digest=plan.archive_identity_digest, + parameter_digest=plan.parameter_digest, + target_digest=target_digest, + required_capabilities=plan.required_capabilities, + destructive_class=plan.destructive_class, + required_confirmation=plan.required_confirmation, + affected_tiers=plan.affected_tiers, + context=plan.context, + ) + if plan.target_refs != target_refs or plan.target_digest != target_digest or plan.plan_hash != plan_hash: + raise AuthorizationMismatchError("preview plan payload does not match its authority hash") + + def build_plan( *, operation: str, @@ -663,6 +685,7 @@ def authorize_bound( """Issue a random one-time token bound to the persisted preview.""" binding.validate() + validate_mutation_plan_integrity(preview.plan) plan = preview.plan required = set(plan.required_capabilities) if not required.issubset(principal.capabilities): @@ -706,6 +729,7 @@ def execute_bound( """Consume a bound token, journal intent, apply, and finalize honestly.""" binding.validate() + validate_mutation_plan_integrity(preview.plan) if authorization.preview_ref != preview.preview_ref or authorization.token is None: raise AuthorizationMismatchError("authorization is not bound to this preview") if ( @@ -965,4 +989,5 @@ def make_target_ref(kind: Literal["session", "message", "block", "source", "inde "compute_target_digest", "compute_typed_plan_hash", "make_target_ref", + "validate_mutation_plan_integrity", ] diff --git a/polylogue/security/lifecycle.py b/polylogue/security/lifecycle.py index d61c246901..7fb4265bdb 100644 --- a/polylogue/security/lifecycle.py +++ b/polylogue/security/lifecycle.py @@ -146,6 +146,14 @@ class LifecycleRequestRow: history: tuple[Mapping[str, JSONValue], ...] +@dataclass(frozen=True, slots=True) +class LifecycleRequestSubmission: + """The durable lifecycle assertion and whether this call created it.""" + + assertion_id: str + created: bool + + def _request_assertion_id(target_ref: str, mode: str) -> str: digest = hashlib.sha256() for part in ("excision-request", target_ref, mode): @@ -169,11 +177,31 @@ def submit_lifecycle_request( target returns the same ``assertion_id`` and does not reset its state -- only :func:`drive_lifecycle_request` advances it. """ + return submit_lifecycle_request_with_outcome( + conn, + target_ref=target_ref, + mode=mode, + reason=reason, + actor=actor, + now_ms=now_ms, + ).assertion_id + + +def submit_lifecycle_request_with_outcome( + conn: sqlite3.Connection, + *, + target_ref: str, + mode: LifecycleMode, + reason: str, + actor: str = "user:local", + now_ms: int, +) -> LifecycleRequestSubmission: + """Create a request or report that its exact durable assertion already exists.""" from polylogue.storage.sqlite.archive_tiers.user_write import read_assertion_envelope, upsert_assertion assertion_id = _request_assertion_id(target_ref, mode) if read_assertion_envelope(conn, assertion_id) is not None: - return assertion_id + return LifecycleRequestSubmission(assertion_id=assertion_id, created=False) upsert_assertion( conn, assertion_id=assertion_id, @@ -195,7 +223,7 @@ def submit_lifecycle_request( context_policy={"inject": False}, now_ms=now_ms, ) - return assertion_id + return LifecycleRequestSubmission(assertion_id=assertion_id, created=True) def read_lifecycle_request(conn: sqlite3.Connection, assertion_id: str) -> LifecycleRequestRow | None: @@ -388,6 +416,7 @@ def apply_primary_invalidation_if_confirmed( "ExcisionLifecycleContract", "LifecycleInvalidationOutcome", "LifecycleRequestRow", + "LifecycleRequestSubmission", "SinexContractFake", "apply_primary_invalidation_if_confirmed", "drive_lifecycle_request", @@ -395,4 +424,5 @@ def apply_primary_invalidation_if_confirmed( "primary_may_invalidate_locally", "read_lifecycle_request", "submit_lifecycle_request", + "submit_lifecycle_request_with_outcome", ] diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py index bbeb764d0b..0cd713ceef 100644 --- a/polylogue/storage/sqlite/audit_leaf.py +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -10,6 +10,8 @@ from dataclasses import dataclass from pathlib import Path +from polylogue.storage.sqlite.connection_profile import descriptor_alias_path + class AuditLeafError(RuntimeError): """The audit pathname cannot prove it is one archive-owned database file.""" @@ -24,11 +26,11 @@ class _AuditLeafIdentity: class VerifiedAuditLeaf: """Keep one archive directory descriptor and verify its ``audit.db`` leaf. - SQLite accepts a URI below ``/proc/self/fd/``. That binds - all database and sidecar opens to the directory we inspected, rather than - re-resolving the caller's mutable pathname. The leaf is checked before - and immediately after SQLite opens it, so a replace between those steps - is rejected before any caller receives a connection. + SQLite accepts a URI below a validated ``/dev/fd`` or ``/proc/self/fd`` + alias. That binds all database and sidecar opens to the directory we + inspected, rather than re-resolving the caller's mutable pathname. The + leaf is checked before and immediately after SQLite opens it, so a replace + between those steps is rejected before any caller receives a connection. """ def __init__(self, archive_root: Path, *, filename: str = "audit.db") -> None: @@ -44,6 +46,9 @@ def __enter__(self) -> VerifiedAuditLeaf: self._directory_fd = os.open(self._archive_root, directory_flags | nofollow) self._validate(self._lstat_leaf_metadata()) metadata = self._open_leaf_metadata() + except AuditLeafError: + self.close() + raise except OSError as exc: self.close() raise AuditLeafError(f"cannot safely open audit tier leaf: {self._archive_root / self._filename}") from exc @@ -63,7 +68,10 @@ def anchored_path(self) -> Path: if self._directory_fd is None: raise RuntimeError("audit leaf descriptor is closed") - return Path(f"/proc/self/fd/{self._directory_fd}/{self._filename}") + alias = descriptor_alias_path(self._directory_fd) + if alias is None: + raise AuditLeafError(f"cannot access audit tier through a verified descriptor: {self._archive_root}") + return alias / self._filename def assert_unchanged(self) -> None: """Require the current directory entry to retain the inspected inode.""" @@ -103,6 +111,10 @@ def _validate(self, metadata: os.stat_result) -> _AuditLeafIdentity: raise AuditLeafError( f"audit tier must be an archive-owned regular file with one link: {self._archive_root / self._filename}" ) + if metadata.st_uid != os.geteuid(): + raise AuditLeafError( + f"audit tier must be owned by the current effective user: {self._archive_root / self._filename}" + ) return _AuditLeafIdentity(metadata.st_dev, metadata.st_ino) diff --git a/polylogue/storage/sqlite/connection_profile.py b/polylogue/storage/sqlite/connection_profile.py index a928b25bcb..1f6133f246 100644 --- a/polylogue/storage/sqlite/connection_profile.py +++ b/polylogue/storage/sqlite/connection_profile.py @@ -540,11 +540,12 @@ def open_daemon_connection( return conn -def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: - """Return a validated descriptor URI on platforms that expose one.""" - descriptor_metadata = os.fstat(opened_main_fd) +def descriptor_alias_path(opened_fd: int) -> Path | None: + """Return a validated portable pathname alias for an opened descriptor.""" + + descriptor_metadata = os.fstat(opened_fd) for directory in ("/dev/fd", "/proc/self/fd"): - candidate = f"{directory}/{opened_main_fd}" + candidate = Path(directory) / str(opened_fd) try: alias_metadata = os.stat(candidate) except OSError: @@ -553,10 +554,16 @@ def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: descriptor_metadata.st_dev, descriptor_metadata.st_ino, ): - return f"file:{candidate}{suffix}" + return candidate return None +def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: + """Return a validated descriptor URI on platforms that expose one.""" + alias = descriptor_alias_path(opened_main_fd) + return None if alias is None else f"file:{alias}{suffix}" + + def open_readonly_connection( path: str | Path, *, @@ -649,6 +656,7 @@ def connection_context(path: str | Path, *, timeout: float = DB_TIMEOUT) -> Iter "WRITE_MMAP_SIZE_BYTES", "check_mapped_bytes_budget_against_cgroup_limit", "connection_context", + "descriptor_alias_path", "log_mapped_bytes_budget_check", "mapped_bytes_budget", "open_daemon_connection", diff --git a/tests/unit/cli/test_excise.py b/tests/unit/cli/test_excise.py index febf73a869..0bd9a540bf 100644 --- a/tests/unit/cli/test_excise.py +++ b/tests/unit/cli/test_excise.py @@ -290,6 +290,42 @@ def test_primary_yes_creates_pending_request_without_touching_local_content(self index_conn.close() assert count == 1 + def test_replayed_primary_request_is_an_audited_noop(self, tmp_path: Path) -> None: + """The CLI route records the existing lifecycle assertion as idempotent. + + Anti-vacuity: unconditionally applied actuator receipts make the + second completed audit run report one affected target instead of zero. + """ + + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-replay") + command = [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ] + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + runner = CliRunner() + first = runner.invoke(cli, command) + replay = runner.invoke(cli, command) + + assert first.exit_code == replay.exit_code == 0 + with sqlite3.connect(archive_root / "audit.db") as connection: + assert connection.execute("SELECT state FROM operation_targets ORDER BY rowid").fetchall() == [ + ("applied",), + ("already_satisfied",), + ] + assert connection.execute( + "SELECT affected_count FROM operation_runs ORDER BY requested_at_ms, operation_id" + ).fetchall() == [(1,), (0,)] + def test_primary_refuses_missing_audit_without_writing_a_lifecycle_request(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" session_id = _seed_session(archive_root, native_id="primary-missing-audit") diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 1b657db65d..7377eb9848 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -20,6 +20,7 @@ from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import ( AuditFinalizationError, + AuthorizationMismatchError, CapabilityDeniedError, ConfirmationStrength, DestructiveClass, @@ -36,6 +37,7 @@ from polylogue.operations.specs import OperationKind, OperationSpec from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation +from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf @dataclass @@ -106,6 +108,12 @@ def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: return replace(super().apply(plan, args), status="already_satisfied", affected_count=0) +@dataclass +class _FailedReceiptActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + return replace(super().apply(plan, args), status="failed", affected_count=0) + + def _binding( actuator: _Actuator, *, target_durability: TargetDurability = "derived" ) -> OperationBinding[object, object]: @@ -244,6 +252,54 @@ def test_audit_authority_rejects_a_hardlinked_audit_leaf_without_touching_its_ta assert external_audit.read_bytes() == before +def test_audit_authority_rejects_a_foreign_owned_audit_leaf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The authority leaf must belong to this effective archive owner. + + Anti-vacuity: removing the uid comparison accepts the otherwise-valid + single-linked regular file and allows the authority check to proceed. + """ + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + before = audit_path.read_bytes() + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.os.geteuid", lambda: audit_path.stat().st_uid + 1) + + with pytest.raises(RuntimeError, match="current effective user"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert audit_path.read_bytes() == before + + +def test_audit_leaf_uses_dev_fd_alias_without_proc(tmp_path: Path) -> None: + """Descriptor-bound audit access remains available on macOS-style hosts. + + Anti-vacuity: restoring the Linux-only alias makes this descriptor-bound + route expose ``/proc/self/fd`` instead of the portable ``/dev/fd`` path. + """ + + initialize_active_archive_root(tmp_path) + with VerifiedAuditLeaf(tmp_path) as leaf: + assert str(leaf.anchored_path).startswith("/dev/fd/") + + +def test_audit_leaf_closes_its_directory_descriptor_after_validation_failure(tmp_path: Path) -> None: + """Rejected leaves do not retain one descriptor per failed authority request. + + Anti-vacuity: the old OSError-only cleanup leaves ``_directory_fd`` set + after this symlink validation error. + """ + + target = tmp_path.parent / "external-audit-leaf.db" + target.write_bytes(b"external") + (tmp_path / "audit.db").symlink_to(target) + leaf = VerifiedAuditLeaf(tmp_path) + + with pytest.raises(AuditLeafError): + leaf.__enter__() + + assert leaf._directory_fd is None + + def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -260,7 +316,7 @@ def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( def replace_after_open(database: object, *args: object, **kwargs: object) -> sqlite3.Connection: nonlocal swapped connection = original_connect(database, *args, **kwargs) - if not swapped and "/proc/self/fd/" in str(database): + if not swapped and ("/dev/fd/" in str(database) or "/proc/self/fd/" in str(database)): swapped = True audit_path.unlink() replacement.replace(audit_path) @@ -618,6 +674,66 @@ def test_already_satisfied_receipt_preserves_target_state_and_zero_affected_coun ) +def test_failed_receipt_marks_the_audit_attempt_failed(tmp_path: Path) -> None: + """A domain-declared failure cannot leave an applied attempt receipt. + + Anti-vacuity: restoring the rejected-only attempt mapping makes the target + failed while this attempt row incorrectly returns applied. + """ + + audit = _audit(tmp_path) + actuator = _FailedReceiptActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "failed-receipt-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:failed-receipt", + archive_identity_digest="identity:failed-receipt", + parameter_digest="params:failed-receipt", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT state FROM operation_attempts WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("failed",) + assert conn.execute( + "SELECT status FROM operation_runs WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("failed",) + + +def test_tampered_preview_payload_refuses_before_audit_intent(tmp_path: Path) -> None: + """Execution cannot journal targets substituted into a reconstructed preview. + + Anti-vacuity: deleting the typed hash validation consumes the token and + creates an audit run whose target set comes from the altered preview. + """ + + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "tampered-preview-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:tampered-preview", + archive_identity_digest="identity:tampered-preview", + parameter_digest="params:tampered-preview", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + tampered_preview = replace(preview, plan=replace(preview.plan, targets=())) + + with pytest.raises(AuthorizationMismatchError, match="authority hash"): + executor.execute_bound(_binding(actuator), tampered_preview, authorization, object()) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM operation_runs").fetchone() == (0,) + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("active",) + + def test_blocked_finalization_rejects_targets_and_fails_parent_run(tmp_path: Path) -> None: audit = _audit(tmp_path) actuator = _Actuator() From 1a871a4e3b38cbd8e1a66f61cf63774e8dd52a5b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 04:36:19 +0200 Subject: [PATCH 24/28] fix(audit): close durable continuity authority gaps Preserve original attempt ownership during crash replay, fail closed on damaged current continuity schemas, authenticate populated legacy journals before binding them, and restore adopted audit images before consulting their heads. Harden descriptor-pinned main and sidecar access while keeping partial durable-train images explicitly non-applicable. --- polylogue/operations/audit.py | 135 +++++++-- polylogue/storage/sqlite/audit_continuity.py | 137 ++++++++- polylogue/storage/sqlite/audit_leaf.py | 228 ++++++++++++--- polylogue/storage/sqlite/migration_runner.py | 123 +++++++- tests/unit/operations/test_operation_audit.py | 262 +++++++++++++++++- tests/unit/storage/test_audit_continuity.py | 116 ++++++++ tests/unit/storage/test_audit_tier.py | 5 +- .../unit/storage/test_durable_change_train.py | 107 +++++++ 8 files changed, 1028 insertions(+), 85 deletions(-) diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index b66c3ee8f2..a02de6e392 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -46,6 +46,7 @@ "cancelled", ] _F = TypeVar("_F", bound=Callable[..., object]) +_CONFIRMATION_STRENGTH_ORDER = {"role_only": 0, "confirm_flag": 1, "bound_token": 2} def _run_state_for_targets(states: list[str]) -> tuple[str, str | None]: @@ -462,11 +463,18 @@ def _continuity_payload( "principal": _principal_payload(principal), } if kind == "issue_authorization": - preview, principal, authorization = ( - cast(MutationPreview, args[0]), - cast(MutationPrincipal, args[1]), - cast(MutationAuthorization, args[2]), - ) + if isinstance(args[0], _StoredAuthorizationDigest): + preview, principal, authorization = ( + cast(MutationPreview, args[1]), + cast(MutationPrincipal, args[2]), + cast(MutationAuthorization, args[3]), + ) + else: + preview, principal, authorization = ( + cast(MutationPreview, args[0]), + cast(MutationPrincipal, args[1]), + cast(MutationAuthorization, args[2]), + ) return { "authorization_id": f"authorization:{secrets.token_urlsafe(18)}", "issued_at_ms": cast(int, values.get("issued_at_ms", int(time.time() * 1000))), @@ -482,6 +490,10 @@ def _continuity_payload( return { "operation_id": f"operation:{secrets.token_urlsafe(18)}", "attempt_id": f"attempt:{secrets.token_urlsafe(18)}", + # The command can be replayed by a fresh repository process. + # Keep the original actuator owner, rather than accidentally + # assigning its pre-effect attempt to the recovery process. + "attempt_owner_id": self._attempt_owner_id, "now_ms": int(time.time() * 1000), "preview": _preview_payload(preview), "authorization": _authorization_payload(authorization), @@ -534,6 +546,7 @@ def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMuta _preview_from_payload(payload["preview"]), _principal_from_payload(payload["principal"]), _authorization_from_payload(payload["authorization"]), + issued_at_ms=cast(int, payload["issued_at_ms"]), ) if mutation.kind == "consume_authorization_and_start": return self._consume_authorization( @@ -681,7 +694,6 @@ def create_preview(self, plan: MutationPlan, principal: MutationPrincipal) -> st ) return preview_id - @_continuity_mutation("issue_authorization") def issue_authorization( self, preview: MutationPreview, @@ -694,13 +706,36 @@ def issue_authorization( if authorization.token is None: raise ValueError("bound authorization requires a token") - return self._persist_authorization( + authorization_id = self._issue_authorization( _StoredAuthorizationDigest(token_sha256(authorization.token)), preview, principal, authorization, issued_at_ms=issued_at_ms, ) + if authorization_id is None: + raise TokenExpiredError("cannot authorize an expired preview") + return authorization_id + + @_continuity_mutation("issue_authorization") + def _issue_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + principal: MutationPrincipal, + authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, + ) -> str | None: + """Persist one token from the durable preview's exact authorization evidence.""" + + return self._persist_authorization( + token_digest, + preview, + principal, + authorization, + issued_at_ms=issued_at_ms, + ) def _persist_authorization( self, @@ -710,7 +745,7 @@ def _persist_authorization( authorization: MutationAuthorization, *, issued_at_ms: int | None = None, - ) -> str: + ) -> str | None: authorization_id = cast( str, self._command_value("authorization_id", f"authorization:{secrets.token_urlsafe(18)}") ) @@ -721,7 +756,11 @@ def _persist_authorization( with self._connection() as conn: self._begin(conn) preview_row = conn.execute( - "SELECT plan_hash, expires_at_ms, state, principal_actor_ref FROM operation_previews WHERE preview_id = ?", + """ + SELECT plan_hash, expires_at_ms, state, principal_actor_ref, + principal_surface, role_label, required_confirmation + FROM operation_previews WHERE preview_id = ? + """, (preview.preview_ref,), ).fetchone() if preview_row is None: @@ -730,8 +769,43 @@ def _persist_authorization( raise ValueError("preview plan hash does not match its durable row") if str(preview_row[2]) != "prepared": raise ValueError("preview is not authorizable") - if principal.actor_ref != str(preview_row[3]): + durable_expires_at_ms = int(preview_row[1]) + durable_capabilities = tuple( + str(row[0]) + for row in conn.execute( + "SELECT capability FROM operation_preview_capabilities WHERE preview_id = ? ORDER BY capability", + (preview.preview_ref,), + ) + ) + if principal.actor_ref != str(preview_row[3]) or principal.surface != str(preview_row[4]): raise ValueError("authorization principal differs from preview principal") + if principal.role_label != cast(str | None, preview_row[5]): + raise ValueError("authorization role differs from preview principal") + if not set(durable_capabilities).issubset(principal.capabilities): + raise ValueError("authorization principal lacks the preview's required capabilities") + if ( + _CONFIRMATION_STRENGTH_ORDER.get(authorization.confirmation_strength, -1) + < _CONFIRMATION_STRENGTH_ORDER[str(preview_row[6])] + ): + raise ValueError("authorization confirmation is weaker than the durable preview") + if ( + authorization.preview_ref != preview.preview_ref + or authorization.plan_hash != str(preview_row[0]) + or authorization.actor != str(preview_row[3]) + or authorization.surface != str(preview_row[4]) + or authorization.role != (cast(str | None, preview_row[5]) or "") + or authorization.expires_at_ms != durable_expires_at_ms + or authorization.capabilities != durable_capabilities + or (durable_capabilities and authorization.capability not in durable_capabilities) + or (not durable_capabilities and authorization.capability != "") + ): + raise ValueError("authorization evidence differs from its durable preview") + if effective_issued_at_ms >= durable_expires_at_ms: + conn.execute( + "UPDATE operation_previews SET state = 'expired' WHERE preview_id = ? AND state = 'prepared'", + (preview.preview_ref,), + ) + return None conn.execute( """ INSERT INTO operation_authorizations( @@ -749,10 +823,10 @@ def _persist_authorization( authorization.confirmation_strength, token_digest.value, effective_issued_at_ms, - authorization.expires_at_ms or effective_issued_at_ms, + durable_expires_at_ms, ), ) - for capability in authorization.capabilities: + for capability in durable_capabilities: conn.execute( "INSERT INTO operation_authorization_capabilities(authorization_id, capability) VALUES (?, ?)", (authorization_id, capability), @@ -799,7 +873,8 @@ def _consume_authorization( row = conn.execute( """ SELECT a.authorization_id, a.preview_id, a.actor_ref, a.surface, - a.state, a.expires_at_ms, p.plan_hash + a.role_label, a.confirmation_strength, a.state, a.expires_at_ms, + p.plan_hash FROM operation_authorizations AS a JOIN operation_previews AS p ON p.preview_id = a.preview_id WHERE a.token_sha256 = ? @@ -808,17 +883,33 @@ def _consume_authorization( ).fetchone() if row is None or str(row[1]) != preview.preview_ref: raise ValueError("authorization token does not match preview") - if str(row[4]) != "active": + if str(row[6]) != "active": raise RuntimeError("authorization token is already consumed or revoked") - if int(row[5]) <= now_ms: + if int(row[7]) <= now_ms: conn.execute( "UPDATE operation_authorizations SET state = 'expired' WHERE authorization_id = ?", (str(row[0]),), ) return None - if str(row[2]) != authorization.actor or str(row[3]) != (authorization.surface or ""): + durable_capabilities = tuple( + str(capability_row[0]) + for capability_row in conn.execute( + "SELECT capability FROM operation_authorization_capabilities WHERE authorization_id = ? ORDER BY capability", + (str(row[0]),), + ) + ) + if ( + str(row[2]) != authorization.actor + or str(row[3]) != (authorization.surface or "") + or (cast(str | None, row[4]) or "") != authorization.role + or str(row[5]) != authorization.confirmation_strength + or int(row[7]) != authorization.expires_at_ms + or durable_capabilities != authorization.capabilities + or (durable_capabilities and authorization.capability not in durable_capabilities) + or (not durable_capabilities and authorization.capability != "") + ): raise ValueError("authorization principal mismatch") - if str(row[6]) != preview.plan.plan_hash or authorization.plan_hash != preview.plan.plan_hash: + if str(row[8]) != preview.plan.plan_hash or authorization.plan_hash != preview.plan.plan_hash: raise ValueError("authorization plan mismatch") conn.execute( "UPDATE operation_authorizations SET state = 'consumed', consumed_at_ms = ? WHERE authorization_id = ?", @@ -850,15 +941,15 @@ def _consume_authorization( preview.plan.parameter_digest, preview.plan.target_digest or preview.plan.plan_hash, preview.plan.target_count, - authorization.actor, - authorization.surface, - authorization.role, + str(row[2]), + str(row[3]), + cast(str | None, row[4]), now_ms, now_ms, now_ms, ), ) - for capability in authorization.capabilities: + for capability in durable_capabilities: conn.execute( "INSERT INTO operation_run_capabilities(operation_id, capability) VALUES (?, ?)", (operation_id, capability), @@ -893,7 +984,7 @@ def _consume_authorization( operation_id, 0 if preview.plan.targets else None, str(row[0]), - self._attempt_owner_id, + cast(str | None, self._command_value("attempt_owner_id", self._attempt_owner_id)), now_ms, ), ) diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 6cbc2f125b..859f517fd0 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -23,6 +23,8 @@ _FORMAT = "polylogue.audit-continuity-command.v1" AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 = "3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f" +_SOURCE_CONTINUITY_SCHEMA_VERSION = 32 +_AUDIT_CONTINUITY_SCHEMA_VERSION = 2 _T = TypeVar("_T") @@ -81,12 +83,18 @@ def audit_semantic_sha256(path: Path) -> str: try: with open_verified_audit_connection(path) as connection: - lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) - return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + return _audit_semantic_sha256_connection(connection) except (AuditLeafError, sqlite3.DatabaseError) as exc: raise AuditContinuityError("cannot hash audit content for continuity validation") from exc +def _audit_semantic_sha256_connection(connection: sqlite3.Connection) -> str: + """Return the continuity-independent semantic digest for one open audit DB.""" + + lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + class AuditContinuityCoordinator: """Coordinate typed audit commands through source.db's durable WAL row.""" @@ -170,16 +178,92 @@ def is_available(self) -> bool: closing(sqlite3.connect(self.source_path)) as source, open_verified_audit_connection(self.audit_path) as audit, ): + source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) + audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) + source_has_control = self._has_table(source, "audit_continuity_control") + audit_has_head = self._has_table(audit, "audit_continuity_head") + if not source_has_control and source_version >= _SOURCE_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current source schema is missing audit continuity control") + if not audit_has_head and audit_version >= _AUDIT_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current audit schema is missing audit continuity head") + if not source_has_control or not audit_has_head: + return False source.execute("SELECT 1 FROM audit_continuity_control WHERE singleton = 1").fetchone() audit.execute("SELECT 1 FROM audit_continuity_head WHERE singleton = 1").fetchone() + if self._is_unbound_populated_precontinuity_audit(source, audit): + raise AuditContinuityError( + "populated pre-continuity audit journal requires authenticated post-migration binding" + ) + except AuditLeafError as exc: + raise AuditContinuityError(str(exc)) from exc except sqlite3.OperationalError as exc: - if "no such table" in str(exc).lower(): - return False raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc except sqlite3.DatabaseError as exc: raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc return True + def needs_precontinuity_binding(self) -> bool: + """Return whether a migrated populated audit journal still has only genesis heads.""" + + self._require_paths() + try: + with ( + closing(sqlite3.connect(self.source_path)) as source, + open_verified_audit_connection(self.audit_path) as audit, + ): + source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) + audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) + source_has_control = self._has_table(source, "audit_continuity_control") + audit_has_head = self._has_table(audit, "audit_continuity_head") + if not source_has_control and source_version >= _SOURCE_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current source schema is missing audit continuity control") + if not audit_has_head and audit_version >= _AUDIT_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current audit schema is missing audit continuity head") + return ( + source_has_control + and audit_has_head + and self._is_unbound_populated_precontinuity_audit(source, audit) + ) + except AuditLeafError as exc: + raise AuditContinuityError("cannot inspect pre-continuity audit binding state") from exc + except sqlite3.DatabaseError as exc: + raise AuditContinuityError("cannot inspect pre-continuity audit binding state") from exc + + def bind_precontinuity_audit(self, *, mutation_id: str, now_ms: int, audit_semantic_sha256: str) -> None: + """Bind a populated v1 audit journal through its first source-backed head. + + Published v2/v32 migrations seeded matching genesis rows for both fresh + and upgraded archives. A populated upgraded journal needs this explicit + command, whose head commits the authenticated pre-migration semantic + digest, before ordinary coordinated mutations are allowed. + """ + + if len(audit_semantic_sha256) != 64: + raise AuditContinuityError("pre-continuity binding requires an audit semantic sha256") + if self.has_committed_mutation(mutation_id): + return + prepared = self._pending() + if prepared is not None: + pending = AuditMutation.from_command(prepared["command"]) + if pending.kind != "bind_precontinuity_audit" or pending.mutation_id != mutation_id: + raise AuditContinuityError( + "pending audit continuity command does not belong to this pre-continuity binding" + ) + self._apply_prepared(prepared, lambda _conn, _mutation: None) + self._promote(prepared) + return + if not self.needs_precontinuity_binding(): + raise AuditContinuityError("pre-continuity audit binding no longer has matching unbound genesis heads") + self.execute( + AuditMutation( + "bind_precontinuity_audit", + mutation_id, + now_ms, + {"audit_semantic_sha256": audit_semantic_sha256}, + ), + lambda _conn, _mutation: None, + ) + def runtime_probe(self) -> str: """Exercise the coordinator's released-schema or compatibility state.""" @@ -337,12 +421,16 @@ def _apply_prepared( # returns above. Any other state must still prove that the # exact authenticated image is present before it can rebind. self._assert_rebind_image(mutation) + elif mutation.kind == "bind_precontinuity_audit": + self._assert_precontinuity_audit_semantics(conn, mutation) if current[:2] != prior: if allow_rebind and mutation.kind == "rebind": pass else: raise AuditContinuityError("audit continuity head does not match the prepared source command") - result = cast(_T, None) if mutation.kind == "rebind" else apply(conn, mutation) + result = ( + cast(_T, None) if mutation.kind in {"rebind", "bind_precontinuity_audit"} else apply(conn, mutation) + ) conn.execute( "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? WHERE singleton = 1", (*target, mutation.mutation_id, mutation.created_at_ms), @@ -460,6 +548,45 @@ def _assert_rebind_image(self, mutation: AuditMutation) -> None: if digest.hexdigest() != expected: raise AuditContinuityError("audit image changed before continuity rebind") + @staticmethod + def _has_table(connection: sqlite3.Connection, name: str) -> bool: + return ( + connection.execute("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", (name,)).fetchone() + is not None + ) + + def _is_unbound_populated_precontinuity_audit(self, source: sqlite3.Connection, audit: sqlite3.Connection) -> bool: + source_head = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_head = audit.execute( + "SELECT generation, head_sha256 FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if source_head != (0, AUDIT_CONTINUITY_GENESIS_HEAD_SHA256) or audit_head != ( + 0, + AUDIT_CONTINUITY_GENESIS_HEAD_SHA256, + ): + return False + tables = tuple( + str(row[0]) + for row in audit.execute( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' " + "AND name != 'audit_continuity_head' ORDER BY name" + ) + ) + for name in tables: + quoted_name = name.replace('"', '""') + if audit.execute(f'SELECT 1 FROM "{quoted_name}" LIMIT 1').fetchone() is not None: + return True + return False + + def _assert_precontinuity_audit_semantics(self, connection: sqlite3.Connection, mutation: AuditMutation) -> None: + expected = mutation.payload.get("audit_semantic_sha256") + if not isinstance(expected, str) or len(expected) != 64: + raise AuditContinuityError("pre-continuity binding lacks an audit semantic sha256") + if _audit_semantic_sha256_connection(connection) != expected: + raise AuditContinuityError("pre-continuity audit journal differs from its authenticated migration evidence") + def _require_paths(self) -> None: if not self.source_path.is_file() or not self.audit_path.is_file(): raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py index 0cd713ceef..b13f593691 100644 --- a/polylogue/storage/sqlite/audit_leaf.py +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -2,6 +2,7 @@ from __future__ import annotations +import fcntl import os import sqlite3 import stat @@ -10,7 +11,7 @@ from dataclasses import dataclass from pathlib import Path -from polylogue.storage.sqlite.connection_profile import descriptor_alias_path +_SQLITE_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal") class AuditLeafError(RuntimeError): @@ -26,33 +27,47 @@ class _AuditLeafIdentity: class VerifiedAuditLeaf: """Keep one archive directory descriptor and verify its ``audit.db`` leaf. - SQLite accepts a URI below a validated ``/dev/fd`` or ``/proc/self/fd`` - alias. That binds all database and sidecar opens to the directory we - inspected, rather than re-resolving the caller's mutable pathname. The - leaf is checked before and immediately after SQLite opens it, so a replace - between those steps is rejected before any caller receives a connection. + A writer holds the verified main leaf while SQLite opens a child path that + is proven to resolve back to that descriptor's directory. The main leaf + and any SQLite sidecar are checked before and after opening, so a + replacement or redirected sidecar is rejected before a caller receives a + connection. """ - def __init__(self, archive_root: Path, *, filename: str = "audit.db") -> None: + def __init__(self, archive_root: Path, *, filename: str = "audit.db", lock_writer: bool = False) -> None: self._archive_root = archive_root self._filename = filename + self._lock_writer = lock_writer self._directory_fd: int | None = None + self._leaf_fd: int | None = None + self._directory_identity: _AuditLeafIdentity | None = None self._identity: _AuditLeafIdentity | None = None + self._anchored_path: Path | None = None + self._writer_lock_held = False def __enter__(self) -> VerifiedAuditLeaf: directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC nofollow = getattr(os, "O_NOFOLLOW", 0) try: self._directory_fd = os.open(self._archive_root, directory_flags | nofollow) - self._validate(self._lstat_leaf_metadata()) - metadata = self._open_leaf_metadata() - except AuditLeafError: - self.close() - raise - except OSError as exc: - self.close() + directory_metadata = os.fstat(self._directory_fd) + self._validate_directory(directory_metadata) + self._directory_identity = _AuditLeafIdentity(directory_metadata.st_dev, directory_metadata.st_ino) + expected = self._validate(self._lstat_leaf_metadata()) + self._leaf_fd = self._open_leaf() + metadata = os.fstat(self._leaf_fd) + self._identity = self._validate(metadata) + if self._identity != expected: + raise AuditLeafError(f"audit tier leaf changed while opening: {self._archive_root / self._filename}") + if self._lock_writer: + self._acquire_writer_lock() + self._anchored_path = self._resolve_portable_child_path() + self._assert_sidecar_namespace() + except BaseException as exc: + self._close_after_failed_enter() + if isinstance(exc, AuditLeafError): + raise raise AuditLeafError(f"cannot safely open audit tier leaf: {self._archive_root / self._filename}") from exc - self._identity = self._validate(metadata) return self def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: @@ -66,36 +81,82 @@ def sqlite_uri(self) -> str: def anchored_path(self) -> Path: """Return the descriptor-anchored path SQLite and byte readers may open.""" - if self._directory_fd is None: + if self._anchored_path is None: raise RuntimeError("audit leaf descriptor is closed") - alias = descriptor_alias_path(self._directory_fd) - if alias is None: - raise AuditLeafError(f"cannot access audit tier through a verified descriptor: {self._archive_root}") - return alias / self._filename + return self._anchored_path def assert_unchanged(self) -> None: """Require the current directory entry to retain the inspected inode.""" - if self._identity is None: + if self._identity is None or self._directory_identity is None: raise RuntimeError("audit leaf descriptor is closed") try: current = self._validate(self._open_leaf_metadata()) + anchored = self._stat_path(self.anchored_path) + anchored_directory = self._stat_path(self.anchored_path.parent) + self._assert_sidecar_namespace() except OSError as exc: raise AuditLeafError(f"cannot revalidate audit tier leaf: {self._archive_root / self._filename}") from exc - if current != self._identity: + if ( + current != self._identity + or _AuditLeafIdentity(anchored.st_dev, anchored.st_ino) != self._identity + or _AuditLeafIdentity(anchored_directory.st_dev, anchored_directory.st_ino) != self._directory_identity + ): raise AuditLeafError(f"audit tier leaf changed during SQLite open: {self._archive_root / self._filename}") def close(self) -> None: - if self._directory_fd is not None: - os.close(self._directory_fd) - self._directory_fd = None + directory_fd, leaf_fd = self._directory_fd, self._leaf_fd + writer_lock_held = self._writer_lock_held + self._directory_fd = None + self._leaf_fd = None + self._directory_identity = None self._identity = None + self._anchored_path = None + self._writer_lock_held = False + errors: list[OSError] = [] + if leaf_fd is not None: + if writer_lock_held: + try: + fcntl.flock(leaf_fd, fcntl.LOCK_UN) + except OSError as exc: + errors.append(exc) + try: + os.close(leaf_fd) + except OSError as exc: + errors.append(exc) + if directory_fd is not None: + try: + os.close(directory_fd) + except OSError as exc: + errors.append(exc) + if errors: + raise errors[0] - def _open_leaf_metadata(self) -> os.stat_result: + def _close_after_failed_enter(self) -> None: + try: + self.close() + except OSError: + return + + def _acquire_writer_lock(self) -> None: + if self._leaf_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + try: + fcntl.flock(self._leaf_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise AuditLeafError( + f"audit tier already has an active writer: {self._archive_root / self._filename}" + ) from exc + self._writer_lock_held = True + + def _open_leaf(self) -> int: if self._directory_fd is None: raise RuntimeError("audit leaf descriptor is closed") flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) - descriptor = os.open(self._filename, flags, dir_fd=self._directory_fd) + return os.open(self._filename, flags, dir_fd=self._directory_fd) + + def _open_leaf_metadata(self) -> os.stat_result: + descriptor = self._open_leaf() try: return os.fstat(descriptor) finally: @@ -106,29 +167,128 @@ def _lstat_leaf_metadata(self) -> os.stat_result: raise RuntimeError("audit leaf descriptor is closed") return os.stat(self._filename, dir_fd=self._directory_fd, follow_symlinks=False) - def _validate(self, metadata: os.stat_result) -> _AuditLeafIdentity: - if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: - raise AuditLeafError( - f"audit tier must be an archive-owned regular file with one link: {self._archive_root / self._filename}" + def _resolve_portable_child_path(self) -> Path: + if self._directory_fd is None or self._identity is None: + raise RuntimeError("audit leaf descriptor is closed") + directory = self._native_directory_path() + if directory is not None: + candidate = directory / self._filename + if self._matches_identity(candidate, self._identity): + return candidate + descriptor_child = self._descriptor_child_path() + if descriptor_child is not None: + return descriptor_child + raise AuditLeafError(f"cannot access audit tier through a verified descriptor: {self._archive_root}") + + def _descriptor_child_path(self) -> Path | None: + """Return a descriptor-directory child only where the host proves it works.""" + + if self._directory_fd is None or self._identity is None: + raise RuntimeError("audit leaf descriptor is closed") + for directory in (Path("/proc/self/fd"), Path("/dev/fd")): + candidate = directory / str(self._directory_fd) / self._filename + if self._matches_identity(candidate, self._identity): + return candidate + return None + + def _native_directory_path(self) -> Path | None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + request = getattr(fcntl, "F_GETPATH", None) + if not isinstance(request, int): + return None + try: + raw = fcntl.fcntl(self._directory_fd, request, b"\0" * 1024) + except OSError: + return None + if not isinstance(raw, bytes): + return None + encoded = raw.split(b"\0", 1)[0] + if not encoded: + return None + try: + candidate = Path(os.fsdecode(encoded)) + directory = os.fstat(self._directory_fd) + metadata = self._stat_path(candidate) + except OSError: + return None + if (metadata.st_dev, metadata.st_ino) != (directory.st_dev, directory.st_ino): + return None + return candidate + + def _assert_sidecar_namespace(self) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for suffix in _SQLITE_SIDECAR_SUFFIXES: + filename = f"{self._filename}{suffix}" + try: + expected = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + except FileNotFoundError: + continue + descriptor = os.open( + filename, + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + dir_fd=self._directory_fd, ) + try: + actual = self._validate(os.fstat(descriptor), description="audit tier sidecar", filename=filename) + finally: + os.close(descriptor) + if actual != expected: + raise AuditLeafError(f"audit tier sidecar changed while opening: {self._archive_root / filename}") + + @staticmethod + def _stat_path(path: Path) -> os.stat_result: + return os.stat(path) + + def _matches_identity(self, path: Path, identity: _AuditLeafIdentity) -> bool: + try: + metadata = self._stat_path(path) + except OSError: + return False + return (metadata.st_dev, metadata.st_ino) == (identity.device, identity.inode) + + def _validate( + self, + metadata: os.stat_result, + *, + description: str = "audit tier", + filename: str | None = None, + ) -> _AuditLeafIdentity: + path = self._archive_root / (filename or self._filename) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise AuditLeafError(f"{description} must be an archive-owned regular file with one link: {path}") + if metadata.st_uid != os.geteuid(): + raise AuditLeafError(f"{description} must be owned by the current effective user: {path}") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise AuditLeafError(f"{description} must not be writable by group or other: {path}") + return _AuditLeafIdentity(metadata.st_dev, metadata.st_ino) + + def _validate_directory(self, metadata: os.stat_result) -> None: if metadata.st_uid != os.geteuid(): raise AuditLeafError( - f"audit tier must be owned by the current effective user: {self._archive_root / self._filename}" + f"audit tier directory must be owned by the current effective user: {self._archive_root}" ) - return _AuditLeafIdentity(metadata.st_dev, metadata.st_ino) + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise AuditLeafError(f"audit tier directory must not be writable by group or other: {self._archive_root}") @contextmanager def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: """Open one writable audit connection pinned to an owned leaf descriptor.""" - with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + with VerifiedAuditLeaf(path.parent, filename=path.name, lock_writer=True) as leaf: connection = sqlite3.connect(leaf.sqlite_uri, uri=True) try: leaf.assert_unchanged() yield connection finally: connection.close() + leaf.assert_unchanged() def assert_verified_audit_leaf(path: Path) -> None: diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 80fda61783..dd92c1cf79 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -1058,6 +1058,97 @@ def validate_full_evidence_backup_for_adopted_audit_restore( return manifest_path, receipt_path +def _authenticated_backup_audit_semantic_sha256(backup_manifest: Path, *, audit_path: Path) -> str: + """Read the pre-migration audit digest from a receipt-authenticated backup image.""" + + manifest_path = _backup_manifest_path(backup_manifest) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"pre-continuity binding requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or "audit.db" not in _json_str_list( + manifest.get("included_tiers") + ): + raise MigrationError("pre-continuity binding requires a backup containing audit.db") + receipt_path = _receipt_path(manifest_path) + _require_regular_backup_artifact(receipt_path, backup_root=backup_root, label="backup verification receipt") + receipt = _load_json(receipt_path, label="verification receipt") + if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": + raise MigrationError("pre-continuity binding requires a successful backup verification receipt") + try: + verify_verification_receipt(receipt, tier="audit", live_tier_path=audit_path) + except BackupAttestationError as exc: + raise MigrationError(f"pre-continuity binding backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("pre-continuity binding receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("pre-continuity binding receipt does not match manifest bytes") + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("pre-continuity binding receipt does not match the closed artifact inventory") + artifacts = _validated_receipt_artifacts( + backup_root, + manifest, + receipt, + target_tier="audit", + live_tier_path=None, + file_evidence=file_evidence, + ) + if "audit" not in artifacts: + raise MigrationError("pre-continuity binding backup does not contain an audit artifact") + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + from polylogue.storage.sqlite.audit_continuity import audit_semantic_sha256 + + return audit_semantic_sha256(backup_root / "audit.db") + + +def _bind_populated_precontinuity_audit(conn: sqlite3.Connection, *, backup_manifest: Path | None) -> None: + """Bind a legacy populated audit journal once both published schema halves exist.""" + + archive_root = _connection_main_path(conn).parent + # Durable tier migrations also run against deliberately partial archives: + # source-only repair images, user-tier fixtures, and train scratch roots. + # Pre-continuity binding is meaningful only after both halves exist. A + # present-but-invalid pair still reaches the verified coordinator below + # and fails closed; absence is a legitimate non-applicable state here. + if not (archive_root / "source.db").is_file() or not (archive_root / "audit.db").is_file(): + return + from polylogue.storage.sqlite.audit_continuity import ( + AuditContinuityCoordinator, + AuditContinuityError, + audit_semantic_sha256, + ) + + coordinator = AuditContinuityCoordinator(archive_root) + try: + if not coordinator.needs_precontinuity_binding(): + return + except AuditContinuityError as exc: + raise MigrationError("cannot inspect pre-continuity audit binding state") from exc + if backup_manifest is None: + raise MigrationError("populated pre-continuity audit journal requires a verified backup for continuity binding") + audit_path = archive_root / "audit.db" + expected = _authenticated_backup_audit_semantic_sha256(backup_manifest, audit_path=audit_path) + try: + actual = audit_semantic_sha256(audit_path) + except AuditContinuityError as exc: + raise MigrationError("cannot hash populated audit journal for continuity binding") from exc + if actual != expected: + raise MigrationError("populated audit journal differs from its authenticated pre-migration backup") + try: + coordinator.bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", + now_ms=int(time.time() * 1000), + audit_semantic_sha256=expected, + ) + except AuditContinuityError as exc: + raise MigrationError("cannot bind populated pre-continuity audit journal") from exc + + def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, *, expected_mutation_id: str) -> None: """Allow a retrying restore to differ only in the source continuity table.""" @@ -1104,21 +1195,21 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, "SELECT committed_generation, committed_head_sha256 " "FROM backup_source.audit_continuity_control WHERE singleton = 1" ).fetchone() - if source_head != backup_head: - audit_path = live_path.parent / "audit.db" - with closing( - sqlite3.connect(f"{audit_path.resolve(strict=True).as_uri()}?mode=ro", uri=True) - ) as audit: - audit_head = audit.execute( - "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" - ).fetchone() - if ( - source_head is None - or audit_head is None - or (int(source_head[0]), str(source_head[1])) != (int(audit_head[0]), str(audit_head[1])) - or audit_head[2] != expected_mutation_id - ): - raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + # A crash after source promotion may leave audit.db absent or + # unreadable. The verified image is republished before its + # head is consulted by the restore coordinator, which then + # authenticates the exact mutation id. Here we can only admit + # the source control-row delta while proving all other source + # rows remain byte-for-byte equivalent below. + if source_head != backup_head and ( + source_head is None + or backup_head is None + or not isinstance(source_head[0], int) + or not isinstance(source_head[1], str) + or len(str(source_head[1])) != 64 + or int(source_head[0]) <= int(backup_head[0]) + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") schema_sql = """ SELECT type, name, tbl_name, sql FROM {schema}.sqlite_schema @@ -1236,6 +1327,7 @@ def migrate_archive_tier( # computed here is re-derived from a fresh read once the lock is held. precheck_version = int(conn.execute("PRAGMA user_version").fetchone()[0] or 0) if precheck_version == target_version: + _bind_populated_precontinuity_audit(conn, backup_manifest=backup_manifest) return MigrationResult( tier=tier, from_version=precheck_version, @@ -1389,6 +1481,7 @@ def migrate_archive_tier( conn.commit() if foreign_keys_were_on: conn.execute("PRAGMA foreign_keys = ON") + _bind_populated_precontinuity_audit(conn, backup_manifest=backup_manifest) return MigrationResult( tier=tier, from_version=start_version, diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index 7377eb9848..ae580bbf36 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +import os import sqlite3 from collections.abc import Callable from dataclasses import dataclass, field, replace from pathlib import Path -from typing import cast +from typing import Any, cast import pytest from pydantic import BaseModel @@ -37,7 +38,7 @@ from polylogue.operations.specs import OperationKind, OperationSpec from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation -from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf +from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf, open_verified_audit_connection @dataclass @@ -270,16 +271,25 @@ def test_audit_authority_rejects_a_foreign_owned_audit_leaf(tmp_path: Path, monk assert audit_path.read_bytes() == before -def test_audit_leaf_uses_dev_fd_alias_without_proc(tmp_path: Path) -> None: - """Descriptor-bound audit access remains available on macOS-style hosts. +def test_audit_leaf_uses_the_verified_native_directory_when_descriptor_children_are_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A portable native descriptor path is used before pseudo-filesystem traversal. - Anti-vacuity: restoring the Linux-only alias makes this descriptor-bound - route expose ``/proc/self/fd`` instead of the portable ``/dev/fd`` path. + Anti-vacuity: removing the F_GETPATH-style route makes this macOS-shaped + host fail closed because neither pseudo-filesystem child is available. """ initialize_active_archive_root(tmp_path) + + def native_path_from_descriptor(_fd: int, _request: int, _buffer: bytes) -> bytes: + return os.fsencode(tmp_path) + b"\0" + + monkeypatch.setattr(VerifiedAuditLeaf, "_descriptor_child_path", lambda _self: None) + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.fcntl.F_GETPATH", 50, raising=False) + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.fcntl.fcntl", native_path_from_descriptor) with VerifiedAuditLeaf(tmp_path) as leaf: - assert str(leaf.anchored_path).startswith("/dev/fd/") + assert leaf.anchored_path == tmp_path / "audit.db" def test_audit_leaf_closes_its_directory_descriptor_after_validation_failure(tmp_path: Path) -> None: @@ -298,6 +308,88 @@ def test_audit_leaf_closes_its_directory_descriptor_after_validation_failure(tmp leaf.__enter__() assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_a_foreign_sidecar_without_leaking_descriptors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The SQLite namespace is validated with the main leaf before any writer opens. + + Anti-vacuity: checking only audit.db accepts this foreign-owned WAL leaf + and lets SQLite consume attacker-controlled sidecar bytes. + """ + + initialize_active_archive_root(tmp_path) + sidecar = tmp_path / "audit.db-wal" + sidecar.write_bytes(b"not a sqlite wal") + leaf = VerifiedAuditLeaf(tmp_path) + real_stat = sidecar.stat() + real_os_stat = os.stat + + def foreign_sidecar_metadata( + path: str | bytes | os.PathLike[str] | os.PathLike[bytes], *args: Any, **kwargs: Any + ) -> os.stat_result: + metadata = real_os_stat(path, *args, **kwargs) + if path == "audit.db-wal": + values = list(metadata) + values[4] = real_stat.st_uid + 1 + return os.stat_result(values) + return metadata + + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.os.stat", foreign_sidecar_metadata) + + with pytest.raises(AuditLeafError, match="sidecar.*current effective user"): + leaf.__enter__() + + assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_group_writable_archive_directory(tmp_path: Path) -> None: + """A second Unix principal cannot plant an SQLite sidecar in the authority namespace.""" + + initialize_active_archive_root(tmp_path) + tmp_path.chmod(0o770) + leaf = VerifiedAuditLeaf(tmp_path) + + with pytest.raises(AuditLeafError, match="directory must not be writable by group or other"): + leaf.__enter__() + + assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_group_writable_main_and_sidecar_files(tmp_path: Path) -> None: + """UID equality alone cannot grant exclusive write authority over SQLite files.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + audit_path.chmod(0o660) + with pytest.raises(AuditLeafError, match="audit tier must not be writable by group or other"): + VerifiedAuditLeaf(tmp_path).__enter__() + + audit_path.chmod(0o600) + sidecar = tmp_path / "audit.db-wal" + sidecar.write_bytes(b"not a sqlite wal") + sidecar.chmod(0o660) + with pytest.raises(AuditLeafError, match="sidecar must not be writable by group or other"): + VerifiedAuditLeaf(tmp_path).__enter__() + + +def test_audit_leaf_serializes_writers_across_the_main_and_sidecar_namespace(tmp_path: Path) -> None: + """A second writer cannot validate then race the first SQLite namespace owner. + + Anti-vacuity: without the nonblocking main-leaf lock, both contexts open + and can independently create or replace the audit sidecar namespace. + """ + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + with open_verified_audit_connection(audit_path): + with pytest.raises(AuditLeafError, match="active writer"): + with open_verified_audit_connection(audit_path): + pass def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( @@ -483,6 +575,46 @@ def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutati ) +def test_replayed_start_keeps_the_crashed_owner_recoverable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Recovery never adopts an actuator-less pre-effect attempt into its own process.""" + + initialize_active_archive_root(tmp_path) + crashed_owner = "pid:999999999:0" + first = AuditRepository.for_archive_root(tmp_path, attempt_owner_id=crashed_owner) + executor = OperationExecutor(audit=first, token_factory=lambda: "replayed-owner-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:replayed-owner", + archive_identity_digest="identity:replayed-owner", + parameter_digest="params:replayed-owner", + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_start(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "consume_authorization_and_start" and phase == "after_source_prepare": + raise RuntimeError("crash after start prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_start) + with pytest.raises(RuntimeError, match="crash after start prepare"): + first.consume_authorization_and_start(preview, authorization) + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + recovery = AuditRepository.for_archive_root(tmp_path, attempt_owner_id="pid:12345:recovery") + recovery.reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + operation_id = str(conn.execute("SELECT operation_id FROM operation_runs").fetchone()[0]) + assert conn.execute( + "SELECT worker_id FROM operation_attempts WHERE operation_id = ?", (operation_id,) + ).fetchone() == (crashed_owner,) + + assert recovery.recover_abandoned_attempts() == (operation_id,) + assert recovery.get_operation(operation_id)["status"] == "interrupted" # type: ignore[index] + + def test_optional_archive_authority_id_replays_without_changing_existing_identity( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -644,6 +776,122 @@ def test_expired_authorization_is_durably_marked_before_execute_refuses( assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("expired",) +def test_authorization_expiry_is_canonicalized_from_the_durable_preview(tmp_path: Path) -> None: + """A caller cannot issue a longer-lived bearer than the preview authorizes. + + Anti-vacuity: storing ``authorization.expires_at_ms`` accepts the forged + expiry and leaves an authorization row that outlives its durable preview. + """ + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "canonical-expiry") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:canonical-expiry", + archive_identity_digest="identity:canonical-expiry", + parameter_digest="params:canonical-expiry", + expires_at_ms=2_000, + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + + with pytest.raises(ValueError, match="evidence differs"): + audit.issue_authorization( + preview, + _principal(), + replace(authorization, token="forged-expiry", expires_at_ms=3_000), + issued_at_ms=1_100, + ) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT expires_at_ms FROM operation_previews").fetchone() == (2_000,) + assert conn.execute("SELECT expires_at_ms FROM operation_authorizations").fetchone() == (2_000,) + + +def test_authorization_consumption_uses_durable_actor_and_capability_evidence(tmp_path: Path) -> None: + """Execution refuses reconstructed authority that differs from durable rows. + + Anti-vacuity: persisting run fields from the caller object records this + substituted actor/capability instead of the issued authorization evidence. + """ + + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "durable-evidence") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:durable-evidence", + archive_identity_digest="identity:durable-evidence", + parameter_digest="params:durable-evidence", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + forged = replace( + authorization, + actor="actor:substituted", + role="administrator", + capability="archive.substituted.write", + capabilities=("archive.substituted.write",), + ) + + with pytest.raises(ValueError, match="principal mismatch"): + audit.consume_authorization_and_start(preview, forged) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM operation_runs").fetchone() == (0,) + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("active",) + assert conn.execute("SELECT actor_ref FROM operation_authorizations").fetchone() == ("actor:test",) + assert conn.execute("SELECT capability FROM operation_authorization_capabilities").fetchone() == ( + "archive.fixture.write", + ) + + +def test_authorization_replay_preserves_the_prepared_expiry_and_issue_clock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A retried WAL authorization reuses its prepared durable evidence verbatim. + + Anti-vacuity: omitting ``issued_at_ms`` during replay silently substitutes + the recovery clock and can make an authorization valid longer than the + original prepared command proved. + """ + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "replayed-expiry") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:replayed-expiry", + archive_identity_digest="identity:replayed-expiry", + parameter_digest="params:replayed-expiry", + expires_at_ms=2_000, + ) + original_abort = AuditContinuityCoordinator._abort_prepared + + def interrupt_issue(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "issue_authorization" and phase == "after_source_prepare": + raise RuntimeError("crash after authorization prepare") + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_issue) + monkeypatch.setattr(AuditContinuityCoordinator, "_abort_prepared", lambda _self, _prepared: None) + with pytest.raises(RuntimeError, match="authorization prepare"): + executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + + monkeypatch.setattr(AuditContinuityCoordinator, "_abort_prepared", original_abort) + audit.reconcile_continuity() + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT issued_at_ms, expires_at_ms FROM operation_authorizations").fetchone() == ( + 1_000, + 2_000, + ) + + def test_already_satisfied_receipt_preserves_target_state_and_zero_affected_count(tmp_path: Path) -> None: """A nonempty idempotent success remains distinct from an applied domain effect.""" diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index 6291b8c3e8..3a403473c3 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -17,6 +17,7 @@ AuditContinuityCoordinator, AuditContinuityError, AuditMutation, + audit_semantic_sha256, ) @@ -112,6 +113,48 @@ def interrupt(phase: str, _mutation: AuditMutation) -> None: assert conn.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone()[0] is None +@pytest.mark.parametrize( + ("dropped_table", "error"), + [ + ("audit_continuity_control", "current source schema"), + ("audit_continuity_head", "current audit schema"), + ], +) +def test_current_schema_missing_a_continuity_table_is_damage(tmp_path: Path, dropped_table: str, error: str) -> None: + initialize_active_archive_root(tmp_path) + path = tmp_path / ("source.db" if dropped_table.endswith("control") else "audit.db") + with sqlite3.connect(path) as connection: + connection.execute(f"DROP TABLE {dropped_table}") + connection.commit() + + with pytest.raises(AuditContinuityError, match=error): + AuditContinuityCoordinator(tmp_path).is_available() + + +@pytest.mark.parametrize( + ("path_name", "table", "legacy_version"), + [("source.db", "audit_continuity_control", 31), ("audit.db", "audit_continuity_head", 1)], +) +def test_legitimate_one_sided_precontinuity_schema_window_stays_in_standby( + tmp_path: Path, path_name: str, table: str, legacy_version: int +) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / path_name) as connection: + connection.execute(f"DROP TABLE {table}") + connection.execute(f"PRAGMA user_version = {legacy_version}") + connection.commit() + + assert not AuditContinuityCoordinator(tmp_path).is_available() + + +def test_empty_fresh_archive_can_use_the_genesis_continuity_head(tmp_path: Path) -> None: + """Genesis is valid only when it describes an empty freshly-created audit journal.""" + + initialize_active_archive_root(tmp_path) + + assert AuditContinuityCoordinator(tmp_path).is_available() + + def test_second_mutation_refuses_while_first_command_is_pending(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) @@ -210,3 +253,76 @@ def test_rebind_rejects_a_stale_in_place_image_before_blessing_it(tmp_path: Path now_ms=1, evidence={"audit_image_sha256": expected_image_sha256}, ) + + +def test_populated_precontinuity_audit_is_bound_before_normal_coordination(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.executescript(Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql").read_text()) + audit.execute("PRAGMA user_version = 2") + audit.commit() + with sqlite3.connect(tmp_path / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.executescript( + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql").read_text() + ) + source.execute("PRAGMA user_version = 32") + source.commit() + expected = audit_semantic_sha256(tmp_path / "audit.db") + coordinator = AuditContinuityCoordinator(tmp_path) + + with pytest.raises(AuditContinuityError, match="post-migration binding"): + coordinator.is_available() + coordinator.bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", now_ms=1, audit_semantic_sha256=expected + ) + + assert coordinator.is_available() + with sqlite3.connect(tmp_path / "source.db") as source, sqlite3.connect(tmp_path / "audit.db") as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + +def test_precontinuity_binding_rejects_a_substituted_genesis_audit_image(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.executescript(Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql").read_text()) + audit.execute("PRAGMA user_version = 2") + audit.commit() + expected = audit_semantic_sha256(tmp_path / "audit.db") + with sqlite3.connect(tmp_path / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.executescript( + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql").read_text() + ) + source.execute("PRAGMA user_version = 32") + source.commit() + replacement_root = tmp_path / "replacement" + initialize_active_archive_root(replacement_root) + with sqlite3.connect(replacement_root / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('substituted:archive', 1, 1)" + ) + audit.commit() + (replacement_root / "audit.db").replace(tmp_path / "audit.db") + + with pytest.raises(AuditContinuityError, match="differs from its authenticated migration evidence"): + AuditContinuityCoordinator(tmp_path).bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", now_ms=1, audit_semantic_sha256=expected + ) diff --git a/tests/unit/storage/test_audit_tier.py b/tests/unit/storage/test_audit_tier.py index b67c4a3117..070e269310 100644 --- a/tests/unit/storage/test_audit_tier.py +++ b/tests/unit/storage/test_audit_tier.py @@ -11,12 +11,12 @@ from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS -def test_audit_tier_bootstrap_has_v1_authority_tables_and_is_durable(tmp_path: Path) -> None: +def test_audit_tier_bootstrap_has_current_authority_tables_and_is_durable(tmp_path: Path) -> None: path = tmp_path / "audit.db" initialize_archive_database(path, ArchiveTier.AUDIT) conn = sqlite3.connect(path) try: - assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + assert conn.execute("PRAGMA user_version").fetchone()[0] == ARCHIVE_TIER_SPECS[ArchiveTier.AUDIT].version tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} finally: conn.close() @@ -26,6 +26,7 @@ def test_audit_tier_bootstrap_has_v1_authority_tables_and_is_durable(tmp_path: P "operation_authorizations", "operation_runs", "operation_events", + "audit_continuity_head", } <= tables assert ArchiveTier.AUDIT in DURABLE_MIGRATION_TIERS assert ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] == ARCHIVE_TIER_SPECS[ArchiveTier.AUDIT].version diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 05a8439a0c..d0a0aed01e 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1921,6 +1921,74 @@ def interrupt_after_rebind_commit(self: AuditContinuityCoordinator, phase: str, assert reconcile_durable_change_train_startup(archive_root) == () +def test_adopted_audit_restore_republishes_before_reading_a_promoted_unreadable_audit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A retry restores its verified image before authenticating a promoted rebind head.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "promoted-missing-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive( + output_dir=archive_root.parent / "promoted-missing-post", profile="full_evidence", verify=True + ) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt before promoted crash") + original_phase = AuditContinuityCoordinator._phase + + def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + if getattr(mutation, "mutation_id", "").startswith("audit-restore:") and phase == "after_source_promotion": + raise RuntimeError("crash after restore source promotion") + original_phase(self, phase, mutation) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", crash_after_source_promotion) + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-crash") as owner: + with pytest.raises(RuntimeError, match="crash after restore source promotion"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + # The source promotion survived while the live authority image became + # unreadable. The retry must publish the verified backup before reading + # its continuity head. + audit_path.write_bytes(b"unreadable after promoted restore crash") + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-retry") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert receipt.name.endswith(".committed.json") + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + @pytest.mark.parametrize("order", ((ArchiveTier.AUDIT, ArchiveTier.SOURCE), (ArchiveTier.SOURCE, ArchiveTier.AUDIT))) def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window( workspace_env: dict[str, Path], order: tuple[ArchiveTier, ArchiveTier] @@ -1964,6 +2032,45 @@ def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window assert probe.runtime_probe() == "reconciled matching source/audit continuity heads" +def test_populated_precontinuity_audit_upgrade_binds_authenticated_existing_content( + workspace_env: dict[str, Path], +) -> None: + """The real two-tier upgrade advances past genesis with the legacy journal's digest.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.commit() + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + backup = backup_archive( + output_dir=archive_root.parent / "populated-precontinuity", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + + with sqlite3.connect(archive_root / "audit.db") as audit: + assert migrate_archive_tier(audit, ArchiveTier.AUDIT, backup_manifest=manifest).applied_versions == (2,) + with sqlite3.connect(archive_root / "source.db") as source: + assert migrate_archive_tier(source, ArchiveTier.SOURCE, backup_manifest=manifest).applied_versions == (32,) + + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(archive_root / "audit.db") as audit: + assert source.execute("SELECT committed_generation FROM audit_continuity_control").fetchone() == (1,) + assert audit.execute("SELECT generation, mutation_id FROM audit_continuity_head").fetchone()[0] == 1 + assert str(audit.execute("SELECT mutation_id FROM audit_continuity_head").fetchone()[0]).startswith( + "precontinuity-audit:" + ) + + def test_adopted_audit_restore_replaces_stale_operation_staging_after_crash( workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch ) -> None: From 4724fd5c6312d72c5af0fa951590c1277d7fb1ad Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 05:09:05 +0200 Subject: [PATCH 25/28] fix(audit): authenticate restore rebind retries Problem: a promoted restore retry admitted unrelated higher source heads, and audit sidecars could change between validation and the first writer transaction.\n\nWhat changed: derive restore rebind targets from immutable prepared evidence, repair only the matching promoted audit head, and pin verified WAL/SHM sidecars before a writer is exposed. Read-only continuity inspection no longer creates sidecars, and invalid pre-continuity entries fail closed. --- polylogue/operations/durable_change_train.py | 62 +++++-- polylogue/storage/sqlite/audit_continuity.py | 150 +++++++++++++--- polylogue/storage/sqlite/audit_leaf.py | 161 +++++++++++++++++- polylogue/storage/sqlite/migration_runner.py | 83 +++++++-- tests/unit/operations/test_operation_audit.py | 15 ++ tests/unit/storage/test_audit_continuity.py | 12 ++ .../unit/storage/test_durable_change_train.py | 109 ++++++++++++ 7 files changed, 529 insertions(+), 63 deletions(-) diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index fac460973e..d8bb537dee 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -23,6 +23,7 @@ from polylogue.storage.sqlite.audit_continuity import ( AuditContinuityCoordinator, AuditContinuityError, + AuditMutation, audit_semantic_sha256, ) from polylogue.storage.sqlite.durable_change_train import ( @@ -1342,6 +1343,7 @@ def restore_adopted_audit_tier( if payload.get("state") == "committed" } pending_restore_operation_ids: list[str] = [] + pending_restore: dict[str, object] | None = None for _path, payload in restore_records: if payload.get("state") != "prepared": continue @@ -1351,6 +1353,7 @@ def restore_adopted_audit_tier( if not isinstance(operation_id, str): raise MigrationError("adopted-audit restore has an invalid incomplete continuity record") pending_restore_operation_ids.append(operation_id) + pending_restore = payload if len(pending_restore_operation_ids) > 1: raise MigrationError("adopted-audit restore has multiple or invalid incomplete continuity records") has_pending_restore = bool(pending_restore_operation_ids) @@ -1362,6 +1365,7 @@ def restore_adopted_audit_tier( archive_root=archive_root, allow_source_continuity_rebind=has_pending_restore, source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, + source_continuity_rebind_prepared_restore=pending_restore, ) artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) expected_application_id = adoption.get("audit_application_id") @@ -1385,6 +1389,7 @@ def revalidate_exact_backup() -> None: archive_root=archive_root, allow_source_continuity_rebind=has_pending_restore, source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, + source_continuity_rebind_prepared_restore=pending_restore, ) if ( current_manifest.resolve() != manifest_path.resolve() @@ -1431,6 +1436,12 @@ def revalidate_exact_backup() -> None: else: generation = 1 + max(committed_generations, default=0) operation_id = secrets.token_hex(16) + existing_rebind_created_at_ms = pending_restore.get("rebind_created_at_ms") if pending_restore is not None else None + if existing_rebind_created_at_ms is not None and not isinstance(existing_rebind_created_at_ms, int): + raise MigrationError("incomplete adopted-audit restore lacks deterministic rebind timing evidence") + rebind_created_at_ms = ( + existing_rebind_created_at_ms if isinstance(existing_rebind_created_at_ms, int) else int(time.time() * 1000) + ) base_payload: dict[str, object] = { "format": _AUDIT_ADOPTION_RESTORE_FORMAT, "generation": generation, @@ -1443,6 +1454,7 @@ def revalidate_exact_backup() -> None: "audit_artifact_sha256": artifact_sha256, "audit_artifact_size": artifact_size, "audit_artifact_user_version": artifact_version, + "rebind_created_at_ms": rebind_created_at_ms, "stopped_daemon_evidence_ref": stopped_evidence, "single_writer_evidence_ref": "proof:archive-ownership-lock", } @@ -1496,21 +1508,41 @@ def revalidate_exact_backup() -> None: version, application_id, quick_check = _audit_live_metadata(path) if version != artifact_version or application_id != expected_application_id or quick_check != ("ok",): raise MigrationError("adopted-audit restore published artifact is not the verified SQLite image") - if has_pending_restore: - # The pending source-side rebind can only be inspected after this - # retry has restored a readable, verified audit authority image. - rebind_already_committed = ( - coordinator.reconcile_pending_rebind(rebind_mutation_id) - if coordinator.has_pending_rebind(rebind_mutation_id) - else coordinator.has_committed_mutation(rebind_mutation_id) - ) - if stopped_daemon_check() != stopped_evidence: - raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") - revalidate_exact_backup() committed_path = prepared_path.with_name(prepared_path.name.replace(".prepared.json", ".committed.json")) prepared_restore_sha256 = prepared.get("restore_sha256") if not isinstance(prepared_restore_sha256, str): prepared_restore_sha256 = _canonical_json_sha256(prepared) + prepared_rebind_created_at_ms = prepared.get("rebind_created_at_ms") + if not isinstance(prepared_rebind_created_at_ms, int): + raise MigrationError("adopted-audit restore lacks deterministic rebind timing evidence") + rebind_mutation = AuditMutation( + "rebind", + rebind_mutation_id, + prepared_rebind_created_at_ms, + { + "kind": "verified_restore", + "prepared_restore_sha256": prepared_restore_sha256, + "audit_image_sha256": artifact_sha256, + }, + ) + if has_pending_restore: + with closing(sqlite3.connect(manifest_path.parent / "source.db")) as backup_source: + backup_head = backup_source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if backup_head is None: + raise MigrationError("adopted-audit restore backup lacks source continuity control") + try: + rebind_already_committed = coordinator.reconcile_restore_rebind( + rebind_mutation, + prior_generation=int(backup_head[0]), + prior_head_sha256=str(backup_head[1]), + ) + except AuditContinuityError as exc: + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") from exc + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") + revalidate_exact_backup() committed = { **base_payload, "state": "committed", @@ -1523,12 +1555,8 @@ def revalidate_exact_backup() -> None: if not rebind_already_committed: coordinator.seed_or_rebind( mutation_id=rebind_mutation_id, - now_ms=int(time.time() * 1000), - evidence={ - "kind": "verified_restore", - "restore_continuity_sha256": committed["continuity_sha256"], - "audit_image_sha256": artifact_sha256, - }, + now_ms=rebind_mutation.created_at_ms, + evidence=rebind_mutation.payload, ) _write_immutable_audit_adoption_receipt( committed_path, diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index 859f517fd0..a6216879ba 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -19,7 +19,12 @@ from pathlib import Path from typing import TypeVar, cast -from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf, open_verified_audit_connection +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + VerifiedAuditLeaf, + open_verified_audit_connection, + open_verified_audit_read_connection, +) _FORMAT = "polylogue.audit-continuity-command.v1" AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 = "3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f" @@ -78,11 +83,29 @@ def _sha256(payload: object) -> str: return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() +def prepared_audit_continuity_command( + mutation: AuditMutation, *, prior_generation: int, prior_head_sha256: str +) -> dict[str, object]: + """Derive the sole source-WAL command and target for one mutation.""" + + command = mutation.command() + command_sha256 = _sha256(command) + return { + "format": _FORMAT, + "prior_generation": prior_generation, + "prior_head_sha256": prior_head_sha256, + "next_generation": prior_generation + 1, + "command": command, + "command_sha256": command_sha256, + "next_head_sha256": _sha256({"previous_head_sha256": prior_head_sha256, "command_sha256": command_sha256}), + } + + def audit_semantic_sha256(path: Path) -> str: """Hash audit content while excluding the self-mutating continuity head.""" try: - with open_verified_audit_connection(path) as connection: + with open_verified_audit_read_connection(path) as connection: return _audit_semantic_sha256_connection(connection) except (AuditLeafError, sqlite3.DatabaseError) as exc: raise AuditContinuityError("cannot hash audit content for continuity validation") from exc @@ -176,7 +199,7 @@ def is_available(self) -> bool: try: with ( closing(sqlite3.connect(self.source_path)) as source, - open_verified_audit_connection(self.audit_path) as audit, + open_verified_audit_read_connection(self.audit_path) as audit, ): source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) @@ -209,7 +232,7 @@ def needs_precontinuity_binding(self) -> bool: try: with ( closing(sqlite3.connect(self.source_path)) as source, - open_verified_audit_connection(self.audit_path) as audit, + open_verified_audit_read_connection(self.audit_path) as audit, ): source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) @@ -305,7 +328,7 @@ def has_committed_mutation(self, mutation_id: str) -> bool: try: with ( closing(sqlite3.connect(self.source_path)) as source, - open_verified_audit_connection(self.audit_path) as audit, + open_verified_audit_read_connection(self.audit_path) as audit, ): source_row = source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" @@ -323,6 +346,47 @@ def has_committed_mutation(self, mutation_id: str) -> bool: return False return isinstance(audit_row[2], str) and audit_row[2] == mutation_id + def reconcile_restore_rebind( + self, + mutation: AuditMutation, + *, + prior_generation: int, + prior_head_sha256: str, + ) -> bool: + """Resume one exact restore rebind without minting a second source head.""" + + if mutation.kind != "rebind": + raise AuditContinuityError("restore continuity reconciliation requires a rebind mutation") + expected = prepared_audit_continuity_command( + mutation, prior_generation=prior_generation, prior_head_sha256=prior_head_sha256 + ) + pending = self._pending() + if pending is not None: + if pending != expected: + raise AuditContinuityError("pending restore rebind does not match its immutable prepared evidence") + self._apply_prepared(pending, lambda _conn, _mutation: None, allow_rebind=True) + self._promote(pending) + return True + with closing(sqlite3.connect(self.source_path)) as source: + row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + prior = (prior_generation, prior_head_sha256) + target_generation = expected["next_generation"] + target_head = expected["next_head_sha256"] + if not isinstance(target_generation, int) or not isinstance(target_head, str): + raise AuditContinuityError("restore rebind target is malformed") + target = (target_generation, target_head) + current = (int(row[0]), str(row[1])) + if current == prior: + return False + if current != target: + raise AuditContinuityError("promoted restore rebind does not match its immutable prepared evidence") + self._repair_promoted_rebind(expected) + return True + def _phase(self, name: str, mutation: AuditMutation) -> None: if self._phase_hook is not None: self._phase_hook(name, mutation) @@ -339,19 +403,9 @@ def _prepare(self, mutation: AuditMutation) -> dict[str, object]: raise AuditContinuityError("source audit continuity control is missing") if row[2] is not None: raise AuditContinuityError("another audit continuity mutation is already pending") - generation = int(row[0]) - previous_head = str(row[1]) - command = mutation.command() - command_sha256 = _sha256(command) - prepared = { - "format": _FORMAT, - "prior_generation": generation, - "prior_head_sha256": previous_head, - "next_generation": generation + 1, - "command": command, - "command_sha256": command_sha256, - "next_head_sha256": _sha256({"previous_head_sha256": previous_head, "command_sha256": command_sha256}), - } + prepared = prepared_audit_continuity_command( + mutation, prior_generation=int(row[0]), prior_head_sha256=str(row[1]) + ) payload_json = _canonical_json(prepared) conn.execute( """ @@ -401,6 +455,11 @@ def _apply_prepared( ) -> _T: self._validate_prepared(prepared) mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind == "rebind" and not self._audit_has_prepared_target(prepared, mutation): + # Writer setup persists WAL mode in the main header. Authenticate a + # restored image before opening that mutating connection, but do + # not re-hash an audit side that already committed this target. + self._assert_rebind_image(mutation) with open_verified_audit_connection(self.audit_path) as conn, conn: conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") @@ -416,12 +475,7 @@ def _apply_prepared( if current[:2] == target and current[2] == mutation.mutation_id: conn.commit() return cast(_T, None) - if mutation.kind == "rebind": - # A retry after the audit-side commit sees the target head and - # returns above. Any other state must still prove that the - # exact authenticated image is present before it can rebind. - self._assert_rebind_image(mutation) - elif mutation.kind == "bind_precontinuity_audit": + if mutation.kind == "bind_precontinuity_audit": self._assert_precontinuity_audit_semantics(conn, mutation) if current[:2] != prior: if allow_rebind and mutation.kind == "rebind": @@ -497,10 +551,48 @@ def _abort_prepared(self, prepared: Mapping[str, object]) -> None: raise AuditContinuityError("source audit continuity abort lost its prepared command") source.commit() + def _repair_promoted_rebind(self, prepared: Mapping[str, object]) -> None: + """Advance a restored audit head to an already-promoted exact target.""" + + mutation = AuditMutation.from_command(prepared["command"]) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + self._assert_rebind_image(mutation) + with open_verified_audit_connection(self.audit_path) as audit, audit: + audit.execute("BEGIN IMMEDIATE") + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing while repairing promoted rebind") + current = (int(row[0]), str(row[1]), row[2]) + if current[:2] == target and current[2] == mutation.mutation_id: + audit.commit() + return + if current[:2] != prior: + raise AuditContinuityError("restored audit head does not match the exact promoted rebind prior") + audit.execute( + "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? " + "WHERE singleton = 1", + (*target, mutation.mutation_id, mutation.created_at_ms), + ) + audit.commit() + + def _audit_has_prepared_target(self, prepared: Mapping[str, object], mutation: AuditMutation) -> bool: + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + try: + with open_verified_audit_read_connection(self.audit_path) as audit: + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + except (AuditLeafError, sqlite3.DatabaseError) as exc: + raise AuditContinuityError("cannot inspect audit continuity head before rebind") from exc + return row is not None and (int(row[0]), str(row[1])) == target and row[2] == mutation.mutation_id + def _assert_committed_head_matches_audit(self) -> None: with ( closing(sqlite3.connect(self.source_path)) as source, - open_verified_audit_connection(self.audit_path) as audit, + open_verified_audit_read_connection(self.audit_path) as audit, ): source_row = source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" @@ -592,4 +684,10 @@ def _require_paths(self) -> None: raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") -__all__ = ["AuditContinuityCoordinator", "AuditContinuityError", "AuditMutation", "audit_semantic_sha256"] +__all__ = [ + "AuditContinuityCoordinator", + "AuditContinuityError", + "AuditMutation", + "audit_semantic_sha256", + "prepared_audit_continuity_command", +] diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py index b13f593691..c3479f7d66 100644 --- a/polylogue/storage/sqlite/audit_leaf.py +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -44,6 +44,9 @@ def __init__(self, archive_root: Path, *, filename: str = "audit.db", lock_write self._identity: _AuditLeafIdentity | None = None self._anchored_path: Path | None = None self._writer_lock_held = False + self._sidecar_fds: dict[str, int] = {} + self._sidecar_identities: dict[str, _AuditLeafIdentity] = {} + self._first_transaction_guard_armed = False def __enter__(self) -> VerifiedAuditLeaf: directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC @@ -73,8 +76,9 @@ def __enter__(self) -> VerifiedAuditLeaf: def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: self.close() - @property - def sqlite_uri(self) -> str: + def sqlite_uri(self, *, readonly: bool = False) -> str: + if readonly: + return f"{self.anchored_path.as_uri()}?mode=ro&immutable=1" return f"{self.anchored_path.as_uri()}?mode=rw" @property @@ -113,7 +117,15 @@ def close(self) -> None: self._identity = None self._anchored_path = None self._writer_lock_held = False + sidecar_fds, self._sidecar_fds = self._sidecar_fds, {} + self._sidecar_identities = {} + self._first_transaction_guard_armed = False errors: list[OSError] = [] + for descriptor in sidecar_fds.values(): + try: + os.close(descriptor) + except OSError as exc: + errors.append(exc) if leaf_fd is not None: if writer_lock_held: try: @@ -240,6 +252,117 @@ def _assert_sidecar_namespace(self) -> None: os.close(descriptor) if actual != expected: raise AuditLeafError(f"audit tier sidecar changed while opening: {self._archive_root / filename}") + self._assert_pinned_sidecars() + + def prepare_writable_sqlite(self, connection: sqlite3.Connection) -> None: + """Create and pin SQLite's WAL namespace before exposing a writer. + + Opening ``audit.db`` alone does not create WAL/SHM. Force that setup + while the verified main-leaf lock is held, then retain descriptors for + both files so a later pathname replacement is detectable before an + application transaction is authorized. + """ + + if not self._lock_writer: + raise RuntimeError("audit leaf is not a writer") + try: + journal_mode = connection.execute("PRAGMA journal_mode = WAL").fetchone() + if journal_mode is None or str(journal_mode[0]).lower() != "wal": + raise AuditLeafError("audit tier must use WAL before writable access") + connection.execute("BEGIN IMMEDIATE") + connection.commit() + self._pin_writable_sidecars() + self.assert_unchanged() + self._first_transaction_guard_armed = True + except sqlite3.DatabaseError as exc: + raise AuditLeafError("cannot establish the audit SQLite WAL namespace") from exc + + def install_transaction_guard(self, connection: sqlite3.Connection) -> None: + """Reject a sidecar replacement before SQLite starts an application tx.""" + + def authorize( + action: int, argument1: str | None, _argument2: str | None, _database: str | None, _trigger: str | None + ) -> int: + if action == sqlite3.SQLITE_TRANSACTION and argument1 == "BEGIN" and self._first_transaction_guard_armed: + self._assert_pinned_sidecars(allow_absent=False) + self._first_transaction_guard_armed = False + return sqlite3.SQLITE_OK + + connection.set_authorizer(authorize) + + def _pin_writable_sidecars(self) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for suffix in ("-wal", "-shm"): + filename = f"{self._filename}{suffix}" + try: + expected = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + descriptor = os.open( + filename, + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + dir_fd=self._directory_fd, + ) + except FileNotFoundError as exc: + raise AuditLeafError( + f"audit tier did not create required WAL sidecar: {self._archive_root / filename}" + ) from exc + try: + actual = self._validate(os.fstat(descriptor), description="audit tier sidecar", filename=filename) + except BaseException: + os.close(descriptor) + raise + if actual != expected: + os.close(descriptor) + raise AuditLeafError(f"audit tier sidecar changed while pinning: {self._archive_root / filename}") + self._sidecar_fds[filename] = descriptor + self._sidecar_identities[filename] = actual + + def _assert_pinned_sidecars(self, *, allow_absent: bool = True) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for filename, identity in self._sidecar_identities.items(): + try: + current = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + pinned = self._validate( + os.fstat(self._sidecar_fds[filename]), description="audit tier sidecar", filename=filename + ) + except FileNotFoundError as exc: + if allow_absent: + continue + raise AuditLeafError( + f"audit tier sidecar disappeared during SQLite access: {self._archive_root / filename}" + ) from exc + except OSError as exc: + raise AuditLeafError(f"cannot inspect audit tier sidecar: {self._archive_root / filename}") from exc + if current != identity or pinned != identity: + raise AuditLeafError( + f"audit tier sidecar changed during SQLite access: {self._archive_root / filename}" + ) + + def cleanup_pinned_writable_sidecars(self) -> None: + """Remove only the still-verified WAL/SHM pair created for this writer.""" + + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + self._assert_pinned_sidecars(allow_absent=True) + for filename in self._sidecar_identities: + try: + os.unlink(filename, dir_fd=self._directory_fd) + except FileNotFoundError: + continue + except OSError as exc: + raise AuditLeafError( + f"cannot clean verified audit tier sidecar: {self._archive_root / filename}" + ) from exc + os.fsync(self._directory_fd) @staticmethod def _stat_path(path: Path) -> os.stat_result: @@ -282,13 +405,35 @@ def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: """Open one writable audit connection pinned to an owned leaf descriptor.""" with VerifiedAuditLeaf(path.parent, filename=path.name, lock_writer=True) as leaf: - connection = sqlite3.connect(leaf.sqlite_uri, uri=True) + connection = sqlite3.connect(leaf.sqlite_uri(), uri=True) + clean_sidecars = False try: - leaf.assert_unchanged() + leaf.prepare_writable_sqlite(connection) + leaf.install_transaction_guard(connection) yield connection + leaf.assert_unchanged() + clean_sidecars = True finally: + if clean_sidecars: + connection.rollback() + connection.execute("PRAGMA journal_mode = DELETE") connection.close() + if clean_sidecars: + leaf.cleanup_pinned_writable_sidecars() + + +@contextmanager +def open_verified_audit_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open one read-only audit connection without creating SQLite sidecars.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri(readonly=True), uri=True) + try: + leaf.assert_unchanged() + yield connection leaf.assert_unchanged() + finally: + connection.close() def assert_verified_audit_leaf(path: Path) -> None: @@ -298,4 +443,10 @@ def assert_verified_audit_leaf(path: Path) -> None: return -__all__ = ["AuditLeafError", "VerifiedAuditLeaf", "assert_verified_audit_leaf", "open_verified_audit_connection"] +__all__ = [ + "AuditLeafError", + "VerifiedAuditLeaf", + "assert_verified_audit_leaf", + "open_verified_audit_connection", + "open_verified_audit_read_connection", +] diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index dd92c1cf79..f367ad6f85 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -950,6 +950,7 @@ def validate_full_evidence_backup_for_adopted_audit_restore( archive_root: Path, allow_source_continuity_rebind: bool = False, source_continuity_rebind_mutation_id: str | None = None, + source_continuity_rebind_prepared_restore: Mapping[str, object] | None = None, ) -> tuple[Path, Path]: """Authorize replacing adopted ``audit.db`` from one exact backup. @@ -1044,6 +1045,7 @@ def validate_full_evidence_backup_for_adopted_audit_restore( artifact_path, live_path, expected_mutation_id=source_continuity_rebind_mutation_id, + prepared_restore=source_continuity_rebind_prepared_restore, ) continue wal_path = live_path.with_name(f"{live_path.name}-wal") @@ -1115,7 +1117,20 @@ def _bind_populated_precontinuity_audit(conn: sqlite3.Connection, *, backup_mani # Pre-continuity binding is meaningful only after both halves exist. A # present-but-invalid pair still reaches the verified coordinator below # and fails closed; absence is a legitimate non-applicable state here. - if not (archive_root / "source.db").is_file() or not (archive_root / "audit.db").is_file(): + entries: dict[str, os.stat_result | None] = {} + for name in ("source.db", "audit.db"): + path = archive_root / name + try: + metadata = path.lstat() + except FileNotFoundError: + entries[name] = None + continue + except OSError as exc: + raise MigrationError(f"cannot inspect pre-continuity {name}: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise MigrationError(f"invalid pre-continuity {name} entry: {path}") + entries[name] = metadata + if entries["source.db"] is None or entries["audit.db"] is None: return from polylogue.storage.sqlite.audit_continuity import ( AuditContinuityCoordinator, @@ -1149,7 +1164,13 @@ def _bind_populated_precontinuity_audit(conn: sqlite3.Connection, *, backup_mani raise MigrationError("cannot bind populated pre-continuity audit journal") from exc -def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, *, expected_mutation_id: str) -> None: +def _validate_source_continuity_rebind_delta( + backup_path: Path, + live_path: Path, + *, + expected_mutation_id: str, + prepared_restore: Mapping[str, object] | None, +) -> None: """Allow a retrying restore to differ only in the source continuity table.""" try: @@ -1163,6 +1184,42 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, ).fetchone() if control is None: raise MigrationError("cannot compare adopted-audit restore source continuity delta") + backup_head = connection.execute( + "SELECT committed_generation, committed_head_sha256 " + "FROM backup_source.audit_continuity_control WHERE singleton = 1" + ).fetchone() + if backup_head is None or not isinstance(backup_head[0], int) or not isinstance(backup_head[1], str): + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + expected_prepared: dict[str, object] | None = None + if prepared_restore is not None: + from polylogue.storage.sqlite.audit_continuity import AuditMutation, prepared_audit_continuity_command + + operation_id = prepared_restore.get("operation_id") + created_at_ms = prepared_restore.get("rebind_created_at_ms") + restore_sha256 = prepared_restore.get("restore_sha256") + audit_image_sha256 = prepared_restore.get("audit_artifact_sha256") + if ( + not isinstance(operation_id, str) + or not isinstance(created_at_ms, int) + or created_at_ms < 0 + or not isinstance(restore_sha256, str) + or not isinstance(audit_image_sha256, str) + ): + raise MigrationError("adopted-audit restore rebind lacks immutable prepared evidence") + expected_prepared = prepared_audit_continuity_command( + AuditMutation( + "rebind", + f"audit-restore:{operation_id}", + created_at_ms, + { + "kind": "verified_restore", + "prepared_restore_sha256": restore_sha256, + "audit_image_sha256": audit_image_sha256, + }, + ), + prior_generation=int(backup_head[0]), + prior_head_sha256=str(backup_head[1]), + ) pending_mutation_id, pending_payload_json, pending_payload_sha256 = control if pending_mutation_id is not None: if ( @@ -1181,9 +1238,8 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, json.dumps(prepared, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") ).hexdigest() != pending_payload_sha256 - or not isinstance(prepared.get("command"), dict) - or prepared["command"].get("kind") != "rebind" - or prepared["command"].get("mutation_id") != expected_mutation_id + or expected_prepared is None + or prepared != expected_prepared ): raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") else: @@ -1191,10 +1247,12 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, "SELECT committed_generation, committed_head_sha256 " "FROM main.audit_continuity_control WHERE singleton = 1" ).fetchone() - backup_head = connection.execute( - "SELECT committed_generation, committed_head_sha256 " - "FROM backup_source.audit_continuity_control WHERE singleton = 1" - ).fetchone() + expected_target: tuple[int, str] | None = None + if expected_prepared is not None: + next_generation = expected_prepared["next_generation"] + next_head = expected_prepared["next_head_sha256"] + if isinstance(next_generation, int) and isinstance(next_head, str): + expected_target = (next_generation, next_head) # A crash after source promotion may leave audit.db absent or # unreadable. The verified image is republished before its # head is consulted by the restore coordinator, which then @@ -1202,12 +1260,7 @@ def _validate_source_continuity_rebind_delta(backup_path: Path, live_path: Path, # the source control-row delta while proving all other source # rows remain byte-for-byte equivalent below. if source_head != backup_head and ( - source_head is None - or backup_head is None - or not isinstance(source_head[0], int) - or not isinstance(source_head[1], str) - or len(str(source_head[1])) != 64 - or int(source_head[0]) <= int(backup_head[0]) + source_head is None or expected_target is None or source_head != expected_target ): raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") schema_sql = """ diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index ae580bbf36..c930302938 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -423,6 +423,21 @@ def replace_after_open(database: object, *args: object, **kwargs: object) -> sql assert audit_path.read_bytes() == before +def test_verified_audit_writer_rejects_a_wal_replacement_before_first_application_begin(tmp_path: Path) -> None: + """The production writer pins WAL/SHM before a caller can start its transaction.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + wal_path = audit_path.with_name("audit.db-wal") + + with pytest.raises(sqlite3.DatabaseError, match="not authorized"): + with open_verified_audit_connection(audit_path) as connection: + replacement = tmp_path / "replacement-audit.db-wal" + replacement.write_bytes(wal_path.read_bytes()) + replacement.replace(wal_path) + connection.execute("BEGIN IMMEDIATE") + + def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: """A second composition-root call recognizes the first executor's owner.""" initialize_active_archive_root(tmp_path) diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index 3a403473c3..e826e5c3f6 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -155,6 +155,18 @@ def test_empty_fresh_archive_can_use_the_genesis_continuity_head(tmp_path: Path) assert AuditContinuityCoordinator(tmp_path).is_available() +def test_semantic_hash_reads_audit_without_creating_backup_sidecars(tmp_path: Path) -> None: + """Read-only continuity validation never changes an immutable backup image.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + for suffix in ("-wal", "-shm", "-journal"): + audit_path.with_name(f"audit.db{suffix}").unlink(missing_ok=True) + + assert len(audit_semantic_sha256(audit_path)) == 64 + assert not any(audit_path.with_name(f"audit.db{suffix}").exists() for suffix in ("-wal", "-shm", "-journal")) + + def test_second_mutation_refuses_while_first_command_is_pending(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index d0a0aed01e..900ab0f02b 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -1967,6 +1967,11 @@ def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, m stopped_daemon_check=lambda: "proof:test-daemon-stopped", ) + with sqlite3.connect(archive_root / "source.db") as source: + promoted_source_tuple = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + # The source promotion survived while the live authority image became # unreadable. The retry must publish the verified backup before reading # its continuity head. @@ -1981,6 +1986,12 @@ def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, m assert receipt.name.endswith(".committed.json") with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == promoted_source_tuple + ) assert ( source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" @@ -1989,6 +2000,69 @@ def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, m ) +def test_adopted_audit_restore_rejects_an_unrelated_higher_promoted_source_head( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A cleared pending row cannot authorize an arbitrary higher source generation.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "higher-head-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "higher-head-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt before forged higher promoted head") + original_phase = AuditContinuityCoordinator._phase + + def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + if getattr(mutation, "mutation_id", "").startswith("audit-restore:") and phase == "after_source_promotion": + raise RuntimeError("crash after restore source promotion") + original_phase(self, phase, mutation) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", crash_after_source_promotion) + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-crash") as owner: + with pytest.raises(RuntimeError, match="crash after restore source promotion"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source: + source.execute( + "UPDATE audit_continuity_control SET committed_generation = committed_generation + 7, " + "committed_head_sha256 = ? WHERE singleton = 1", + ("f" * 64,), + ) + source.commit() + audit_path.write_bytes(b"unreadable after forged higher promoted head") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-retry") as owner: + with pytest.raises(MigrationError, match="rebind is not operation-owned"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + @pytest.mark.parametrize("order", ((ArchiveTier.AUDIT, ArchiveTier.SOURCE), (ArchiveTier.SOURCE, ArchiveTier.AUDIT))) def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window( workspace_env: dict[str, Path], order: tuple[ArchiveTier, ArchiveTier] @@ -2032,6 +2106,41 @@ def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window assert probe.runtime_probe() == "reconciled matching source/audit continuity heads" +@pytest.mark.parametrize( + ("entry_name", "entry_kind"), + ( + ("audit.db", "directory"), + ("audit.db", "dangling_symlink"), + ("source.db", "symlink"), + ), +) +def test_precontinuity_binding_rejects_invalid_present_archive_entries( + tmp_path: Path, entry_name: str, entry_kind: str +) -> None: + """Only truly absent durable entries can leave pre-continuity binding in standby.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + initialize_active_archive_root(tmp_path) + source_path = tmp_path / "source.db" + entry_path = tmp_path / entry_name + with closing(sqlite3.connect(source_path)) as source: + if entry_kind == "directory": + entry_path.unlink() + entry_path.mkdir() + elif entry_kind == "dangling_symlink": + entry_path.unlink() + entry_path.symlink_to(tmp_path / "missing-audit.db") + else: + external = tmp_path.parent / "external-source.db" + external.write_bytes(entry_path.read_bytes()) + entry_path.unlink() + entry_path.symlink_to(external) + + with pytest.raises(MigrationError, match="invalid pre-continuity"): + migration_runner._bind_populated_precontinuity_audit(source, backup_manifest=None) + + def test_populated_precontinuity_audit_upgrade_binds_authenticated_existing_content( workspace_env: dict[str, Path], ) -> None: From 622aabf19125b0bf700a6ca73c7610c3e55eb7af Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 05:24:12 +0200 Subject: [PATCH 26/28] fix(audit): preserve live WAL authority reads Use locking-aware read-only SQLite connections so continuity validation sees committed WAL state. Route source continuity access through no-follow descriptor-anchored opens and treat only literal absence as standby. --- polylogue/storage/sqlite/audit_continuity.py | 66 +++++++++++++++---- polylogue/storage/sqlite/audit_leaf.py | 34 +++++++++- tests/unit/operations/test_operation_audit.py | 28 +++++++- tests/unit/storage/test_audit_continuity.py | 29 ++++++++ 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index a6216879ba..f8fc28bf91 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -13,8 +13,9 @@ import hashlib import json import sqlite3 -from collections.abc import Callable, Mapping -from contextlib import closing +import stat +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import TypeVar, cast @@ -24,6 +25,8 @@ VerifiedAuditLeaf, open_verified_audit_connection, open_verified_audit_read_connection, + open_verified_sqlite_read_connection, + open_verified_sqlite_write_connection, ) _FORMAT = "polylogue.audit-continuity-command.v1" @@ -111,6 +114,34 @@ def audit_semantic_sha256(path: Path) -> str: raise AuditContinuityError("cannot hash audit content for continuity validation") from exc +@contextmanager +def _open_source_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + try: + with open_verified_sqlite_read_connection(path) as connection: + yield connection + except AuditLeafError as exc: + raise AuditContinuityError(f"cannot safely read source continuity tier: {path}") from exc + + +@contextmanager +def _open_source_write_connection(path: Path) -> Iterator[sqlite3.Connection]: + try: + with open_verified_sqlite_write_connection(path) as connection: + yield connection + except AuditLeafError as exc: + raise AuditContinuityError(f"cannot safely write source continuity tier: {path}") from exc + + +def _entry_is_absent(path: Path) -> bool: + try: + path.lstat() + except FileNotFoundError: + return True + except OSError as exc: + raise AuditContinuityError(f"cannot inspect audit continuity tier entry: {path}") from exc + return False + + def _audit_semantic_sha256_connection(connection: sqlite3.Connection) -> str: """Return the continuity-independent semantic digest for one open audit DB.""" @@ -194,11 +225,11 @@ def has_pending_rebind(self, mutation_id: str) -> bool: def is_available(self) -> bool: """Return whether both schema halves needed for coordinated writes exist.""" - if not self.source_path.is_file() or not self.audit_path.is_file(): + if _entry_is_absent(self.source_path) or _entry_is_absent(self.audit_path): return False try: with ( - closing(sqlite3.connect(self.source_path)) as source, + _open_source_read_connection(self.source_path) as source, open_verified_audit_read_connection(self.audit_path) as audit, ): source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) @@ -231,7 +262,7 @@ def needs_precontinuity_binding(self) -> bool: self._require_paths() try: with ( - closing(sqlite3.connect(self.source_path)) as source, + _open_source_read_connection(self.source_path) as source, open_verified_audit_read_connection(self.audit_path) as audit, ): source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) @@ -327,7 +358,7 @@ def has_committed_mutation(self, mutation_id: str) -> bool: self._require_paths() try: with ( - closing(sqlite3.connect(self.source_path)) as source, + _open_source_read_connection(self.source_path) as source, open_verified_audit_read_connection(self.audit_path) as audit, ): source_row = source.execute( @@ -367,7 +398,7 @@ def reconcile_restore_rebind( self._apply_prepared(pending, lambda _conn, _mutation: None, allow_rebind=True) self._promote(pending) return True - with closing(sqlite3.connect(self.source_path)) as source: + with _open_source_read_connection(self.source_path) as source: row = source.execute( "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" ).fetchone() @@ -393,7 +424,7 @@ def _phase(self, name: str, mutation: AuditMutation) -> None: def _prepare(self, mutation: AuditMutation) -> dict[str, object]: self._require_paths() - with closing(sqlite3.connect(self.source_path)) as conn, conn: + with _open_source_write_connection(self.source_path) as conn, conn: conn.row_factory = sqlite3.Row conn.execute("BEGIN IMMEDIATE") row = conn.execute( @@ -420,7 +451,7 @@ def _prepare(self, mutation: AuditMutation) -> dict[str, object]: def _pending(self) -> dict[str, object] | None: self._require_paths() - with closing(sqlite3.connect(self.source_path)) as conn: + with _open_source_read_connection(self.source_path) as conn: row = conn.execute( "SELECT committed_generation, committed_head_sha256, pending_payload_json, pending_payload_sha256 FROM audit_continuity_control WHERE singleton = 1" ).fetchone() @@ -494,7 +525,7 @@ def _apply_prepared( def _promote(self, prepared: Mapping[str, object]) -> None: mutation = AuditMutation.from_command(prepared["command"]) - with closing(sqlite3.connect(self.source_path)) as conn, conn: + with _open_source_write_connection(self.source_path) as conn, conn: conn.execute("BEGIN IMMEDIATE") cursor = conn.execute( """ @@ -535,7 +566,7 @@ def _abort_prepared(self, prepared: Mapping[str, object]) -> None: return if current[:2] != prior: raise AuditContinuityError("cannot abort prepared command after an unrelated audit head change") - with closing(sqlite3.connect(self.source_path)) as source, source: + with _open_source_write_connection(self.source_path) as source, source: source.execute("BEGIN IMMEDIATE") cursor = source.execute( """ @@ -591,7 +622,7 @@ def _audit_has_prepared_target(self, prepared: Mapping[str, object], mutation: A def _assert_committed_head_matches_audit(self) -> None: with ( - closing(sqlite3.connect(self.source_path)) as source, + _open_source_read_connection(self.source_path) as source, open_verified_audit_read_connection(self.audit_path) as audit, ): source_row = source.execute( @@ -680,8 +711,15 @@ def _assert_precontinuity_audit_semantics(self, connection: sqlite3.Connection, raise AuditContinuityError("pre-continuity audit journal differs from its authenticated migration evidence") def _require_paths(self) -> None: - if not self.source_path.is_file() or not self.audit_path.is_file(): - raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") + for path in (self.source_path, self.audit_path): + try: + metadata = path.lstat() + except FileNotFoundError as exc: + raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") from exc + except OSError as exc: + raise AuditContinuityError(f"cannot inspect audit continuity tier entry: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise AuditContinuityError(f"audit continuity tier entry is not an owned regular file: {path}") __all__ = [ diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py index c3479f7d66..3feb2b5b1a 100644 --- a/polylogue/storage/sqlite/audit_leaf.py +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -78,7 +78,11 @@ def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: def sqlite_uri(self, *, readonly: bool = False) -> str: if readonly: - return f"{self.anchored_path.as_uri()}?mode=ro&immutable=1" + # ``immutable=1`` is unsafe for the live authority database: a + # committed head may still reside in WAL, and immutable readers + # deliberately ignore locking and change detection. ``mode=ro`` + # preserves WAL visibility without granting write access. + return f"{self.anchored_path.as_uri()}?mode=ro" return f"{self.anchored_path.as_uri()}?mode=rw" @property @@ -423,8 +427,8 @@ def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: @contextmanager -def open_verified_audit_read_connection(path: Path) -> Iterator[sqlite3.Connection]: - """Open one read-only audit connection without creating SQLite sidecars.""" +def open_verified_sqlite_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open a read-only SQLite leaf through a no-follow directory descriptor.""" with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: connection = sqlite3.connect(leaf.sqlite_uri(readonly=True), uri=True) @@ -436,6 +440,28 @@ def open_verified_audit_read_connection(path: Path) -> Iterator[sqlite3.Connecti connection.close() +@contextmanager +def open_verified_sqlite_write_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open an existing writable SQLite leaf through a no-follow descriptor.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri(), uri=True) + try: + leaf.assert_unchanged() + yield connection + leaf.assert_unchanged() + finally: + connection.close() + + +@contextmanager +def open_verified_audit_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open one live-WAL-aware, read-only audit connection.""" + + with open_verified_sqlite_read_connection(path) as connection: + yield connection + + def assert_verified_audit_leaf(path: Path) -> None: """Check an existing audit leaf without exposing its descriptor to callers.""" @@ -449,4 +475,6 @@ def assert_verified_audit_leaf(path: Path) -> None: "assert_verified_audit_leaf", "open_verified_audit_connection", "open_verified_audit_read_connection", + "open_verified_sqlite_read_connection", + "open_verified_sqlite_write_connection", ] diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index c930302938..d3ed3fe11d 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -38,7 +38,12 @@ from polylogue.operations.specs import OperationKind, OperationSpec from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation -from polylogue.storage.sqlite.audit_leaf import AuditLeafError, VerifiedAuditLeaf, open_verified_audit_connection +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + VerifiedAuditLeaf, + open_verified_audit_connection, + open_verified_audit_read_connection, +) @dataclass @@ -438,6 +443,27 @@ def test_verified_audit_writer_rejects_a_wal_replacement_before_first_applicatio connection.execute("BEGIN IMMEDIATE") +def test_verified_audit_reader_observes_a_committed_live_wal_head(tmp_path: Path) -> None: + """Read-only authority checks include commits still resident in the live WAL.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + archive_id = "archive:live-wal-read" + + with open_verified_audit_connection(audit_path) as writer: + writer.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, 1, 1)", + (archive_id,), + ) + writer.commit() + assert audit_path.with_name("audit.db-wal").exists() + + with open_verified_audit_read_connection(audit_path) as reader: + assert reader.execute( + "SELECT archive_instance_id FROM archive_authority WHERE archive_instance_id = ?", (archive_id,) + ).fetchone() == (archive_id,) + + def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: """A second composition-root call recognizes the first executor's owner.""" initialize_active_archive_root(tmp_path) diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py index e826e5c3f6..48aef3eb41 100644 --- a/tests/unit/storage/test_audit_continuity.py +++ b/tests/unit/storage/test_audit_continuity.py @@ -147,6 +147,35 @@ def test_legitimate_one_sided_precontinuity_schema_window_stays_in_standby( assert not AuditContinuityCoordinator(tmp_path).is_available() +@pytest.mark.parametrize( + ("path_name", "entry_kind"), + [ + ("source.db", "external_symlink"), + ("audit.db", "directory"), + ("audit.db", "dangling_symlink"), + ], +) +def test_invalid_present_continuity_tier_never_enters_standby(tmp_path: Path, path_name: str, entry_kind: str) -> None: + """Only literal absence can disable continuity; invalid entries fail closed.""" + + initialize_active_archive_root(tmp_path) + path = tmp_path / path_name + if entry_kind == "external_symlink": + external = tmp_path.parent / "external-source.db" + external.write_bytes(path.read_bytes()) + path.unlink() + path.symlink_to(external) + elif entry_kind == "directory": + path.unlink() + path.mkdir() + else: + path.unlink() + path.symlink_to(tmp_path / "missing-audit.db") + + with pytest.raises(AuditContinuityError, match="regular file|safely"): + AuditContinuityCoordinator(tmp_path).is_available() + + def test_empty_fresh_archive_can_use_the_genesis_continuity_head(tmp_path: Path) -> None: """Genesis is valid only when it describes an empty freshly-created audit journal.""" From 18924916d40952a91fdd1b86f0d44c6b5d9c22b2 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 05:41:52 +0200 Subject: [PATCH 27/28] fix(audit): preserve verified leaf diagnostics --- polylogue/storage/sqlite/audit_continuity.py | 4 ++-- tests/unit/operations/test_operation_audit.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py index f8fc28bf91..109b530d60 100644 --- a/polylogue/storage/sqlite/audit_continuity.py +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -120,7 +120,7 @@ def _open_source_read_connection(path: Path) -> Iterator[sqlite3.Connection]: with open_verified_sqlite_read_connection(path) as connection: yield connection except AuditLeafError as exc: - raise AuditContinuityError(f"cannot safely read source continuity tier: {path}") from exc + raise AuditContinuityError(f"cannot safely read source continuity tier: {path}: {exc}") from exc @contextmanager @@ -129,7 +129,7 @@ def _open_source_write_connection(path: Path) -> Iterator[sqlite3.Connection]: with open_verified_sqlite_write_connection(path) as connection: yield connection except AuditLeafError as exc: - raise AuditContinuityError(f"cannot safely write source continuity tier: {path}") from exc + raise AuditContinuityError(f"cannot safely write source continuity tier: {path}: {exc}") from exc def _entry_is_absent(path: Path) -> bool: diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index d3ed3fe11d..d08bc82765 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -413,7 +413,12 @@ def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( def replace_after_open(database: object, *args: object, **kwargs: object) -> sqlite3.Connection: nonlocal swapped connection = original_connect(database, *args, **kwargs) - if not swapped and ("/dev/fd/" in str(database) or "/proc/self/fd/" in str(database)): + database_text = str(database) + if ( + not swapped + and database_text.split("?", 1)[0].endswith("/audit.db") + and ("/dev/fd/" in database_text or "/proc/self/fd/" in database_text) + ): swapped = True audit_path.unlink() replacement.replace(audit_path) From 51262f6e99d30ba14b19029d87badd624076a051 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 05:58:04 +0200 Subject: [PATCH 28/28] fix(audit): keep WAL mode across readers --- polylogue/storage/sqlite/audit_leaf.py | 25 +------------------ tests/unit/operations/test_operation_audit.py | 17 +++++++++++++ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py index 3feb2b5b1a..a91ed78a35 100644 --- a/polylogue/storage/sqlite/audit_leaf.py +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -351,23 +351,6 @@ def _assert_pinned_sidecars(self, *, allow_absent: bool = True) -> None: f"audit tier sidecar changed during SQLite access: {self._archive_root / filename}" ) - def cleanup_pinned_writable_sidecars(self) -> None: - """Remove only the still-verified WAL/SHM pair created for this writer.""" - - if self._directory_fd is None: - raise RuntimeError("audit leaf descriptor is closed") - self._assert_pinned_sidecars(allow_absent=True) - for filename in self._sidecar_identities: - try: - os.unlink(filename, dir_fd=self._directory_fd) - except FileNotFoundError: - continue - except OSError as exc: - raise AuditLeafError( - f"cannot clean verified audit tier sidecar: {self._archive_root / filename}" - ) from exc - os.fsync(self._directory_fd) - @staticmethod def _stat_path(path: Path) -> os.stat_result: return os.stat(path) @@ -410,20 +393,14 @@ def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: with VerifiedAuditLeaf(path.parent, filename=path.name, lock_writer=True) as leaf: connection = sqlite3.connect(leaf.sqlite_uri(), uri=True) - clean_sidecars = False try: leaf.prepare_writable_sqlite(connection) leaf.install_transaction_guard(connection) yield connection leaf.assert_unchanged() - clean_sidecars = True finally: - if clean_sidecars: - connection.rollback() - connection.execute("PRAGMA journal_mode = DELETE") + connection.rollback() connection.close() - if clean_sidecars: - leaf.cleanup_pinned_writable_sidecars() @contextmanager diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index d08bc82765..1186d8da40 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -469,6 +469,23 @@ def test_verified_audit_reader_observes_a_committed_live_wal_head(tmp_path: Path ).fetchone() == (archive_id,) +def test_verified_audit_writer_coexists_with_an_older_read_transaction(tmp_path: Path) -> None: + """Persistent WAL mode lets a later writer proceed while a reader retains its snapshot.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + with open_verified_audit_connection(audit_path) as writer: + assert writer.execute("PRAGMA journal_mode").fetchone() == ("wal",) + + with open_verified_audit_read_connection(audit_path) as reader: + reader.execute("BEGIN") + reader.execute("SELECT generation FROM audit_continuity_head").fetchone() + with open_verified_audit_connection(audit_path) as writer: + writer.execute("BEGIN IMMEDIATE") + writer.execute("UPDATE audit_continuity_head SET advanced_at_ms = advanced_at_ms") + writer.commit() + + def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: """A second composition-root call recognizes the first executor's owner.""" initialize_active_archive_root(tmp_path)