From f301693eefcc5d9bf4f89ea6d1ecabbb91380088 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 19:07:48 +0200 Subject: [PATCH 01/39] feat: add offline archive root relocation Add an explicit, backup-attested relocation route that rebinds released source train identity without mutating archive database data. A prepared relocation receipt blocks daemon startup until the same authorized operation completes. --- devtools/render_cli_output_schemas.py | 8 + docs/archive-backup.md | 23 + docs/maintenance.md | 11 + ...archive-root-relocation-result.schema.json | 56 ++ .../cli/commands/maintenance/__init__.py | 6 + .../maintenance/_archive_root_relocation.py | 90 +++ polylogue/daemon/cli.py | 5 + .../operations/archive_root_relocation.py | 523 ++++++++++++++++++ .../storage/sqlite/durable_change_train.py | 26 + polylogue/storage/sqlite/migration_runner.py | 40 ++ .../unit/cli/test_archive_maintenance_cli.py | 15 + tests/unit/daemon/test_daemon_cli.py | 44 ++ .../storage/test_archive_root_relocation.py | 53 ++ 13 files changed, 900 insertions(+) create mode 100644 docs/schemas/cli-output/archive-root-relocation-result.schema.json create mode 100644 polylogue/cli/commands/maintenance/_archive_root_relocation.py create mode 100644 polylogue/operations/archive_root_relocation.py create mode 100644 tests/unit/storage/test_archive_root_relocation.py diff --git a/devtools/render_cli_output_schemas.py b/devtools/render_cli_output_schemas.py index 875852facd..e7ed6f63cc 100644 --- a/devtools/render_cli_output_schemas.py +++ b/devtools/render_cli_output_schemas.py @@ -26,6 +26,7 @@ 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.operations.archive_root_relocation import ArchiveRootRelocationResult from polylogue.surfaces.payloads import ( ArchiveDebtListPayload, ImportExplainPayload, @@ -270,6 +271,13 @@ class CliOutputSchema: model=MigrateTierResultPayload, surfaces=("polylogue ops maintenance migrate-tier --output-format json",), ), + CliOutputSchema( + name="archive-root-relocation-result", + title="Archive Root Relocation Result", + description=("Result from the offline archive-root relocation apply command."), + model=ArchiveRootRelocationResult, + surfaces=("polylogue ops maintenance archive-root-relocation apply --output-format json",), + ), CliOutputSchema( name="machine-error", title="Machine Error Envelope", diff --git a/docs/archive-backup.md b/docs/archive-backup.md index fe4addc4b2..449b365dfd 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -37,6 +37,29 @@ When SQLite WAL files are present, either stop the daemon or run an explicit checkpoint before copying. Copying only `*.db` while an uncheckpointed `*-wal` contains recent writes creates an incomplete backup. +## Offline archive-root relocation + +An inode-preserving filesystem move is the only supported way to change a configured archive root without restoring or rebuilding it. Stop the daemon, move the complete root without copying its database files, and retain the verified `full_evidence` backup made at the old root. Then point `POLYLOGUE_ARCHIVE_ROOT` at the destination and create the bound plan: + +```bash +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ + polylogue ops maintenance archive-root-relocation plan \ + --old-root /old/archive/root \ + --backup-manifest /path/to/verified-full-evidence/manifest.json \ + --output /safe/operator/location/relocation-plan.json --output-format json +``` + +Apply only the exact self-hash printed in that plan: + +```bash +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ + polylogue ops maintenance archive-root-relocation apply \ + --plan /safe/operator/location/relocation-plan.json \ + --authorize PLAN_SHA256 --output-format json +``` + +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, source-continuity trains, or any non-released source train. It records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. + ## Restore Rules Restore into an isolated archive root first: diff --git a/docs/maintenance.md b/docs/maintenance.md index 83a2559699..860a7483a7 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -39,6 +39,17 @@ Restart health and runtime-consumer convergence are the final lifecycle proof and are recorded by the durable train lifecycle API, not inferred from this command's migration result alone. +## Relocating an archive root + +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. + +```bash +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation apply --plan /safe/relocation-plan.json --authorize PLAN_SHA256 --output-format json +``` + +If apply stops after recording a prepared receipt, daemon startup fails closed and names the exact apply command. Rerun that command with the same plan and authorization after restoring offline ownership. Do not use this operation for a copy, restore, new archive, migration, or live service move. + ### Rebuild deployment-currency preflight Before a managed `rebuild-index`, confirm that the package selected for the diff --git a/docs/schemas/cli-output/archive-root-relocation-result.schema.json b/docs/schemas/cli-output/archive-root-relocation-result.schema.json new file mode 100644 index 0000000000..d35c90b0ec --- /dev/null +++ b/docs/schemas/cli-output/archive-root-relocation-result.schema.json @@ -0,0 +1,56 @@ +{ + "$id": "https://polylogue.dev/schemas/cli-output/archive-root-relocation-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Result from the offline archive-root relocation apply command.\n\nGenerated from `polylogue.operations.archive_root_relocation.ArchiveRootRelocationResult` by `devtools render cli-output-schemas`. Do not edit by hand.", + "properties": { + "changed_manifests": { + "items": { + "type": "string" + }, + "title": "Changed Manifests", + "type": "array" + }, + "ok": { + "const": true, + "default": true, + "title": "Ok", + "type": "boolean" + }, + "plan_sha256": { + "title": "Plan Sha256", + "type": "string" + }, + "receipt_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Receipt Path" + }, + "state": { + "enum": [ + "prepared", + "committed" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "plan_sha256", + "receipt_path", + "changed_manifests" + ], + "title": "Archive Root Relocation Result", + "type": "object", + "x-polylogue-cli-surfaces": [ + "polylogue ops maintenance archive-root-relocation apply --output-format json" + ], + "x-polylogue-source-model": "ArchiveRootRelocationResult" +} diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index e575b802dc..b95d0041fc 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -46,6 +46,12 @@ "migrate_tier_command", "Apply additive migrations for one durable archive tier.", ), + ( + "archive-root-relocation", + "_archive_root_relocation", + "archive_root_relocation_command", + "Plan or apply one offline inode-preserving archive-root relocation.", + ), ( "run-preview", "_run_preview", diff --git a/polylogue/cli/commands/maintenance/_archive_root_relocation.py b/polylogue/cli/commands/maintenance/_archive_root_relocation.py new file mode 100644 index 0000000000..21ec7234a7 --- /dev/null +++ b/polylogue/cli/commands/maintenance/_archive_root_relocation.py @@ -0,0 +1,90 @@ +"""``maintenance archive-root-relocation``: explicit offline root rebinding.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import click + +from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + apply_archive_root_relocation, + load_archive_root_relocation_plan, + prepare_archive_root_relocation, + write_archive_root_relocation_plan, +) +from polylogue.operations.durable_change_train import acquire_durable_archive_ownership +from polylogue.paths import archive_root + + +@click.group("archive-root-relocation") +def archive_root_relocation_command() -> None: + """Move one complete archive root without changing any SQLite rows. + + This accepts only an inode-preserving move with a previously verified + full-evidence backup. It is an offline transition, never startup repair. + """ + + +@archive_root_relocation_command.command("plan") +@click.option("--old-root", required=True, type=click.Path(path_type=Path)) +@click.option("--backup-manifest", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--output", required=True, type=click.Path(path_type=Path)) +@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) +def archive_root_relocation_plan_command( + old_root: Path, + backup_manifest: Path, + output: Path, + output_format: str, +) -> None: + """Record a strict, read-only relocation plan for the configured destination.""" + from polylogue.cli.commands.maintenance._migrate_tier import _require_stopped_daemon + + root = archive_root() + try: + with acquire_durable_archive_ownership(root, owner_id=f"archive-root-relocation-plan:{os.getpid()}"): + stopped = _require_stopped_daemon(root) + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=root, + backup_manifest=backup_manifest, + stopped_daemon_evidence_ref=stopped, + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + write_archive_root_relocation_plan(plan, output) + except (ArchiveRootRelocationError, OSError) as exc: + raise click.ClickException(str(exc)) from exc + if output_format == "json": + click.echo(json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True)) + else: + click.echo(f"Wrote inode-preserving archive-root relocation plan: {output}") + + +@archive_root_relocation_command.command("apply") +@click.option("--plan", "plan_path", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--authorize", required=True) +@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) +def archive_root_relocation_apply_command(plan_path: Path, authorize: str, output_format: str) -> None: + """Apply the plan by CAS-revising released source manifests only.""" + from polylogue.cli.commands.maintenance._migrate_tier import _require_stopped_daemon + + root = archive_root() + try: + plan = load_archive_root_relocation_plan(plan_path) + with acquire_durable_archive_ownership(root, owner_id=f"archive-root-relocation-apply:{os.getpid()}"): + stopped = _require_stopped_daemon(root) + result = apply_archive_root_relocation( + root=root, + plan=plan, + authorization=authorize, + stopped_daemon_evidence_ref=stopped, + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + except (ArchiveRootRelocationError, OSError) as exc: + raise click.ClickException(str(exc)) from exc + if output_format == "json": + click.echo(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True)) + else: + click.echo(f"Archive-root relocation {result.state}: {result.receipt_path}") diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index f849bccac5..02223c82cd 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -2262,6 +2262,11 @@ async def _run_daemon_services_under_active_writer_lease( # validation rather than making fresh service startup depend on a separate # bootstrap invocation. archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) + from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation + + # A prepared relocation is explicit operator work. Check before runtime + # component registration so no daemon surface becomes observable first. + assert_no_prepared_archive_root_relocation(archive_root_path) from polylogue.storage.archive_identity import assert_writable_archive_identity # Identity precedes schema checks, pidfiles, HTTP startup, and every other diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py new file mode 100644 index 0000000000..b1e73b6884 --- /dev/null +++ b/polylogue/operations/archive_root_relocation.py @@ -0,0 +1,523 @@ +"""One explicit offline transition for an inode-preserving archive-root move.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +import stat +import tempfile +from dataclasses import replace +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.durable_change_train import ( + DurableChangeTrainState, + load_durable_change_train_manifest, + rebind_released_source_train_archive_identity, + write_durable_change_train_manifest, +) +from polylogue.storage.sqlite.migration_runner import ( + MigrationError, + capture_durable_database_evidence, + capture_durable_schema_inventory, + validate_full_evidence_backup_for_archive_root_relocation, +) +from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec + +PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v1"] = "polylogue.archive-root-relocation-plan.v1" +RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = "polylogue.archive-root-relocation-receipt.v1" +_TIER_NAMES = tuple(tier.value for tier in ArchiveTier) +_DURABLE_TIER_NAMES = ("source", "user", "audit") +_SIDECARS = ("-wal", "-shm", "-journal") + + +class ArchiveRootRelocationError(RuntimeError): + """The requested root move has no single safe offline transition.""" + + +class RelocationTierEvidence(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + tier: str + configured_path: str + resolved_path: str + device: int + inode: int + size_bytes: int + sha256: str + user_version: int + schema_inventory_sha256: str + content_sha256: str + quick_check: tuple[str, ...] + + +class RelocationSourceTrain(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + path: str + before_revision: int + before_manifest_sha256: str + before_archive_identity_digest: str + after_archive_identity_digest: str + + +class ArchiveRootRelocationPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["polylogue.archive-root-relocation-plan.v1"] = PLAN_FORMAT + old_configured_root: str + old_resolved_root: str + new_configured_root: str + new_resolved_root: str + new_root_device: int + new_root_inode: int + backup_manifest_path: str + backup_manifest_sha256: str + backup_receipt_path: str + backup_receipt_sha256: str + backup_profile: Literal["full_evidence"] + backup_tier_inventory: tuple[str, ...] + tiers: tuple[RelocationTierEvidence, ...] + source_trains: tuple[RelocationSourceTrain, ...] + stopped_daemon_evidence_ref: str + single_writer_evidence_ref: str + bound_confirmation: str + plan_sha256: str + + +class ArchiveRootRelocationReceipt(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["polylogue.archive-root-relocation-receipt.v1"] = RECEIPT_FORMAT + state: Literal["prepared", "committed"] + revision: int + plan_sha256: str + authorization: str + manifest_before_sha256: tuple[str, ...] + manifest_after_sha256: tuple[str, ...] + resume_command: str + receipt_sha256: str + + +class ArchiveRootRelocationResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[True] = True + state: Literal["prepared", "committed"] + plan_sha256: str + receipt_path: str | None + changed_manifests: tuple[str, ...] + + +def _canonical_sha256(payload: object) -> str: + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() + + +def _sealed_plan(**values: object) -> ArchiveRootRelocationPlan: + payload = {"format": PLAN_FORMAT, **values, "plan_sha256": ""} + payload["plan_sha256"] = _canonical_sha256({key: value for key, value in payload.items() if key != "plan_sha256"}) + return ArchiveRootRelocationPlan.model_validate(payload) + + +def _sealed_receipt(**values: object) -> ArchiveRootRelocationReceipt: + payload = {"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""} + payload["receipt_sha256"] = _canonical_sha256( + {key: value for key, value in payload.items() if key != "receipt_sha256"} + ) + return ArchiveRootRelocationReceipt.model_validate(payload) + + +def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: + expected = _canonical_sha256(plan.model_dump(exclude={"plan_sha256"}, mode="json")) + if plan.plan_sha256 != expected: + raise ArchiveRootRelocationError("archive-root relocation plan checksum mismatch") + + +def _verify_receipt(receipt: ArchiveRootRelocationReceipt) -> None: + expected = _canonical_sha256(receipt.model_dump(exclude={"receipt_sha256"}, mode="json")) + if receipt.receipt_sha256 != expected: + raise ArchiveRootRelocationError("archive-root relocation receipt checksum mismatch") + + +def _real_directory(path: Path, *, label: str) -> Path: + try: + metadata = path.lstat() + except OSError as exc: + raise ArchiveRootRelocationError(f"cannot inspect {label}: {path}") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise ArchiveRootRelocationError(f"{label} is not a real directory: {path}") + absolute = Path(os.path.abspath(path)) + resolved = path.resolve(strict=True) + if absolute != resolved: + raise ArchiveRootRelocationError(f"{label} traverses a symbolic link: {path}") + return resolved + + +def _real_file(path: Path, *, label: str) -> os.stat_result: + try: + metadata = path.lstat() + except OSError as exc: + raise ArchiveRootRelocationError(f"cannot inspect {label}: {path}") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ArchiveRootRelocationError(f"{label} is not a real single-linked file: {path}") + return metadata + + +def _sha256_file(path: Path) -> str: + 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() + + +def _reject_sidecars(root: Path) -> None: + for tier in _TIER_NAMES: + for suffix in _SIDECARS: + path = root / f"{tier}.db{suffix}" + if path.exists() or path.is_symlink(): + raise ArchiveRootRelocationError(f"archive-root relocation refuses SQLite sidecar: {path}") + + +def _tier_snapshot(root: Path, tier: ArchiveTier) -> RelocationTierEvidence: + location = ArchiveLocation.resolve(root) + identity = location.active_tier(tier.value) + path = identity.configured_path + resolved_path = identity.resolved_path + if tier is ArchiveTier.INDEX: + # The promoted index route deliberately uses an active-generation + # pointer. Snapshot the resolved generation, never a shadow path. + metadata = _real_file(resolved_path, label="active index tier") + else: + metadata = _real_file(path, label=f"{tier.value} tier") + try: + with sqlite3.connect(f"file:{resolved_path}?mode=ro&immutable=1", uri=True) as connection: + if tier is ArchiveTier.EMBEDDINGS: + loaded, error = try_load_sqlite_vec(connection) + if not loaded: + raise ArchiveRootRelocationError( + "cannot load sqlite-vec for immutable embeddings evidence" + ) from error + schema = capture_durable_schema_inventory(connection) + quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) + user_version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + if tier.value in _DURABLE_TIER_NAMES: + evidence = capture_durable_database_evidence(connection, tier) + content_sha256 = evidence.content_sha256 + else: + content_sha256 = _sha256_file(resolved_path) + except (OSError, sqlite3.Error, MigrationError) as exc: + raise ArchiveRootRelocationError(f"cannot read {tier.value} tier without mutation") from exc + return RelocationTierEvidence( + tier=tier.value, + configured_path=str(path.absolute()), + resolved_path=str(resolved_path), + device=metadata.st_dev, + inode=metadata.st_ino, + size_bytes=metadata.st_size, + sha256=_sha256_file(resolved_path), + user_version=user_version, + schema_inventory_sha256=schema.sha256, + content_sha256=content_sha256, + quick_check=quick_check, + ) + + +def _source_trains( + root: Path, + *, + accepted_before_digests: frozenset[str], + after_identity_digest: str, +) -> tuple[RelocationSourceTrain, ...]: + manifest_root = root / ".maintenance-state" / "durable-change-trains" + _real_directory(root / ".maintenance-state", label="maintenance state") + _real_directory(manifest_root, label="durable change-train state") + if (manifest_root / ".bootstrap").exists() or (manifest_root / ".bootstrap.pending").exists(): + raise ArchiveRootRelocationError("archive-root relocation does not support fresh-bootstrap train authority") + paths = tuple(sorted(manifest_root.glob("source-*.json"))) + if not paths: + raise ArchiveRootRelocationError("archive-root relocation requires released source train evidence") + trains: list[RelocationSourceTrain] = [] + for path in paths: + _real_file(path, label="source train manifest") + train = load_durable_change_train_manifest(path) + if train.state is not DurableChangeTrainState.RELEASED or train.apply_evidence is None: + raise ArchiveRootRelocationError(f"source train is not released: {path}") + if train.source_continuity_evidence is not None: + raise ArchiveRootRelocationError( + "archive-root relocation does not support source-continuity train authority" + ) + trains.append( + RelocationSourceTrain( + path=str(path), + before_revision=train.revision, + before_manifest_sha256=_sha256_file(path), + before_archive_identity_digest=train.apply_evidence.post.archive_identity_digest, + after_archive_identity_digest=after_identity_digest, + ) + ) + if trains[-1].before_archive_identity_digest not in accepted_before_digests: + raise ArchiveRootRelocationError( + f"released source train does not independently prove the relocated source inode: {path}" + ) + return tuple(trains) + + +def _check_backup_against_live( + root: Path, + *, + manifest: dict[str, object], + receipt: dict[str, object], + snapshots: tuple[RelocationTierEvidence, ...], +) -> None: + fingerprints = manifest["tier_source_fingerprints"] + artifacts = receipt["tier_artifacts"] + assert isinstance(fingerprints, dict) + assert isinstance(artifacts, list) + by_tier = {str(item["tier"]): item for item in artifacts if isinstance(item, dict) and "tier" in item} + for snapshot in snapshots: + filename = f"{snapshot.tier}.db" + fingerprint = fingerprints.get(filename) + artifact = by_tier.get(snapshot.tier) + if not isinstance(fingerprint, dict) or not isinstance(artifact, dict): + raise ArchiveRootRelocationError(f"backup lacks {filename} evidence") + fields = {"size_bytes": snapshot.size_bytes, "sha256": snapshot.sha256, "user_version": snapshot.user_version} + if any(fingerprint.get(key) != value for key, value in fields.items()): + raise ArchiveRootRelocationError(f"backup bytes/version differ from relocated {filename}") + artifact_fingerprint = artifact.get("source_fingerprint") + if not isinstance(artifact_fingerprint, dict) or any( + artifact_fingerprint.get(key) != value for key, value in fields.items() + ): + raise ArchiveRootRelocationError(f"backup receipt differs from relocated {filename}") + _reject_sidecars(root) + + +def prepare_archive_root_relocation( + *, + old_root: Path, + new_root: Path, + backup_manifest: Path, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, +) -> ArchiveRootRelocationPlan: + """Capture immutable, read-only evidence for the one root transition.""" + old_configured = old_root.absolute() + old_resolved = old_root.resolve(strict=False) + new_configured = new_root.absolute() + new_resolved = _real_directory(new_root, label="new archive root") + if old_resolved == new_resolved: + raise ArchiveRootRelocationError("archive-root relocation requires distinct old and new roots") + _reject_sidecars(new_resolved) + try: + manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( + backup_manifest, old_archive_root=old_resolved + ) + except MigrationError as exc: + raise ArchiveRootRelocationError(str(exc)) from exc + snapshots = tuple(_tier_snapshot(new_resolved, tier) for tier in ArchiveTier) + _check_backup_against_live(new_resolved, manifest=manifest, receipt=receipt, snapshots=snapshots) + location_identity = ArchiveIdentity.resolve_location(ArchiveLocation.resolve(new_resolved)) + source_identity_digest = hashlib.sha256(location_identity.tier("source").stable_id.encode()).hexdigest() + old_location_identity = replace(location_identity, configured_root=old_resolved) + trains = _source_trains( + new_resolved, + accepted_before_digests=frozenset({source_identity_digest, old_location_identity.authority_identity_digest}), + after_identity_digest=source_identity_digest, + ) + root_metadata = new_resolved.stat() + return _sealed_plan( + old_configured_root=str(old_configured), + old_resolved_root=str(old_resolved), + new_configured_root=str(new_configured), + new_resolved_root=str(new_resolved), + new_root_device=root_metadata.st_dev, + new_root_inode=root_metadata.st_ino, + backup_manifest_path=str(manifest_path), + backup_manifest_sha256=_sha256_file(manifest_path), + backup_receipt_path=str(receipt_path), + backup_receipt_sha256=_sha256_file(receipt_path), + backup_profile="full_evidence", + backup_tier_inventory=tuple(sorted(f"{tier}.db" for tier in _TIER_NAMES)), + tiers=snapshots, + source_trains=trains, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + single_writer_evidence_ref=single_writer_evidence_ref, + bound_confirmation="archive-root-relocation", + ) + + +def write_archive_root_relocation_plan(plan: ArchiveRootRelocationPlan, output: Path) -> None: + _verify_plan(plan) + output.parent.mkdir(parents=True, exist_ok=True) + encoded = (json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + with tempfile.NamedTemporaryFile(dir=output.parent, prefix=f".{output.name}.", delete=False) as stream: + temporary = Path(stream.name) + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + try: + os.replace(temporary, output) + finally: + temporary.unlink(missing_ok=True) + + +def load_archive_root_relocation_plan(path: Path) -> ArchiveRootRelocationPlan: + try: + plan = ArchiveRootRelocationPlan.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ArchiveRootRelocationError(f"invalid archive-root relocation plan: {path}") from exc + _verify_plan(plan) + return plan + + +def _receipt_path(root: Path, plan: ArchiveRootRelocationPlan) -> Path: + return root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json" + + +def _write_receipt(path: Path, receipt: ArchiveRootRelocationReceipt, *, expected: str | None) -> None: + _verify_receipt(receipt) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + _real_directory(path.parent, label="archive-root relocation receipt directory") + if path.exists(): + current = load_archive_root_relocation_receipt(path) + if current.receipt_sha256 != expected: + raise ArchiveRootRelocationError("archive-root relocation receipt CAS state changed") + elif expected is not None: + raise ArchiveRootRelocationError("archive-root relocation receipt disappeared") + encoded = (json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream: + temporary = Path(stream.name) + stream.write(encoded) + stream.flush() + os.fsync(stream.fileno()) + try: + os.replace(temporary, path) + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + +def load_archive_root_relocation_receipt(path: Path) -> ArchiveRootRelocationReceipt: + _real_file(path, label="archive-root relocation receipt") + try: + receipt = ArchiveRootRelocationReceipt.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") from exc + _verify_receipt(receipt) + return receipt + + +def assert_no_prepared_archive_root_relocation(root: Path) -> None: + receipt_root = root / ".maintenance-state" / "archive-root-relocations" + if not receipt_root.exists(): + return + _real_directory(receipt_root, label="archive-root relocation receipt directory") + for path in sorted(receipt_root.glob("*.json")): + receipt = load_archive_root_relocation_receipt(path) + if receipt.state == "prepared": + raise ArchiveRootRelocationError( + "archive-root relocation is prepared but incomplete; rerun " + receipt.resume_command + ) + + +def apply_archive_root_relocation( + *, + root: Path, + plan: ArchiveRootRelocationPlan, + authorization: str, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, +) -> ArchiveRootRelocationResult: + """CAS-rewrite only released source manifests, never SQLite/archive bytes.""" + _verify_plan(plan) + if authorization != plan.plan_sha256 or plan.bound_confirmation != "archive-root-relocation": + raise ArchiveRootRelocationError("archive-root relocation authorization does not bind this plan") + resolved = _real_directory(root, label="configured archive root") + if str(root.absolute()) != plan.new_configured_root or str(resolved) != plan.new_resolved_root: + raise ArchiveRootRelocationError("archive-root relocation plan is bound to a different configured root") + current = prepare_archive_root_relocation( + old_root=Path(plan.old_configured_root), + new_root=root, + backup_manifest=Path(plan.backup_manifest_path), + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + single_writer_evidence_ref=single_writer_evidence_ref, + ) + if current.plan_sha256 != plan.plan_sha256: + raise ArchiveRootRelocationError("archive-root relocation evidence changed after planning") + receipt_path = _receipt_path(resolved, plan) + command = ( + f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance archive-root-relocation " + f"apply --plan --authorize {plan.plan_sha256} --output-format json" + ) + before_hashes = tuple(item.before_manifest_sha256 for item in plan.source_trains) + receipt = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=authorization, + manifest_before_sha256=before_hashes, + manifest_after_sha256=(), + resume_command=command, + ) + if receipt_path.exists(): + receipt = load_archive_root_relocation_receipt(receipt_path) + if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: + raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") + if receipt.state == "committed": + return ArchiveRootRelocationResult( + state="committed", + plan_sha256=plan.plan_sha256, + receipt_path=str(receipt_path), + changed_manifests=tuple(item.path for item in plan.source_trains), + ) + else: + _write_receipt(receipt_path, receipt, expected=None) + after_hashes: list[str] = [] + for item in plan.source_trains: + path = Path(item.path) + train = load_durable_change_train_manifest(path) + actual_hash = _sha256_file(path) + if actual_hash == item.before_manifest_sha256: + updated = rebind_released_source_train_archive_identity( + train, + archive_identity_digest=item.after_archive_identity_digest, + proof_ref=f"proof:archive-root-relocation:{receipt.receipt_sha256}", + ) + write_durable_change_train_manifest(path, updated, expected_revision=item.before_revision) + elif ( + train.revision != item.before_revision + 1 + or train.apply_evidence is None + or train.apply_evidence.post.archive_identity_digest != item.after_archive_identity_digest + ): + raise ArchiveRootRelocationError( + f"archive-root relocation manifest is neither exact before nor after: {path}" + ) + after_hashes.append(_sha256_file(path)) + committed = _sealed_receipt( + state="committed", + revision=1, + plan_sha256=plan.plan_sha256, + authorization=authorization, + manifest_before_sha256=before_hashes, + manifest_after_sha256=tuple(after_hashes), + resume_command=command, + ) + _write_receipt(receipt_path, committed, expected=receipt.receipt_sha256) + return ArchiveRootRelocationResult( + state="committed", + plan_sha256=plan.plan_sha256, + receipt_path=str(receipt_path), + changed_manifests=tuple(item.path for item in plan.source_trains), + ) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 7b5b6bb50d..c73fb98950 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -555,6 +555,30 @@ def _persist_train_transition(path: Path, train: DurableChangeTrain, *, expected return load_durable_change_train_manifest(path) +def rebind_released_source_train_archive_identity( + train: DurableChangeTrain, + *, + archive_identity_digest: str, + proof_ref: str, +) -> DurableChangeTrain: + """Return the one permitted root-relocation revision of a source train.""" + if train.tier is not ArchiveTier.SOURCE or train.state is not DurableChangeTrainState.RELEASED: + raise DurableChangeTrainError("archive-root relocation requires a released source train") + if train.apply_evidence is None or train.source_continuity_evidence is not None: + raise DurableChangeTrainError("archive-root relocation does not support source continuity train shapes") + _migration_runner._validate_sha256(archive_identity_digest, label="relocated archive identity") + post = replace(train.apply_evidence.post, archive_identity_digest=archive_identity_digest) + evidence = replace(train.apply_evidence, post=post) + updated = replace( + train, + revision=train.revision + 1, + apply_evidence=evidence, + proof_refs=_migration_runner._append_proof_refs(train.proof_refs, proof_ref), + ) + validate_durable_change_train_manifest(updated) + return updated + + def write_source_continuity_pending_intent( archive_root: Path, *, @@ -2302,8 +2326,10 @@ 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.archive_root_relocation import assert_no_prepared_archive_root_relocation from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + assert_no_prepared_archive_root_relocation(archive_root) validate_audit_adoption_receipt(archive_root) deferred_tiers = _recover_pending_source_continuity_intents(archive_root) manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index f367ad6f85..fce5c9dc7c 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -844,6 +844,46 @@ def validate_migration_backup_live_fingerprint( return receipt_path +def validate_full_evidence_backup_for_archive_root_relocation( + path: Path, + *, + old_archive_root: Path, +) -> tuple[Path, Path, dict[str, object], dict[str, object]]: + """Authenticate complete old-root backup evidence for a root relocation.""" + manifest_path = _backup_manifest_path(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("archive-root relocation requires a verified full_evidence backup") + expected_tiers = {f"{tier.value}.db" for tier in ArchiveTier} + if set(_json_str_list(manifest.get("included_tiers"))) != expected_tiers or _json_str_list( + manifest.get("omitted_tiers") + ): + raise MigrationError("archive-root relocation backup must contain the exact complete tier set") + 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("archive-root relocation requires a successful verification receipt") + for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT): + try: + verify_verification_receipt(receipt, tier=tier.value, live_tier_path=old_archive_root / f"{tier.value}.db") + except BackupAttestationError as exc: + raise MigrationError(f"archive-root relocation old-root authority failed for {tier.value}: {exc}") from exc + fingerprints = manifest.get("tier_source_fingerprints") + artifacts = receipt.get("tier_artifacts") + if not isinstance(fingerprints, dict) or not isinstance(artifacts, list): + raise MigrationError("archive-root relocation backup lacks complete tier evidence") + artifact_by_tier = { + item.get("tier"): item for item in artifacts if isinstance(item, dict) and isinstance(item.get("tier"), str) + } + if set(fingerprints) != expected_tiers or set(artifact_by_tier) != {tier.value for tier in ArchiveTier}: + raise MigrationError("archive-root relocation backup tier evidence is incomplete") + return manifest_path, receipt_path, manifest, receipt + + 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. diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index dbd62b9b23..fdcc2fc12a 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -91,6 +91,21 @@ def test_raw_authority_census_cli_resolves_receipt_handle( assert payload["census"]["census_id"] == receipt.census_id +def test_archive_root_relocation_cli_help_exposes_only_plan_and_apply( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """Exercise the lazy production maintenance dispatcher for the offline route.""" + result = cli_runner.invoke( + cli, + ["--plain", "ops", "maintenance", "archive-root-relocation", "--help"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "plan" in result.output + assert "apply" in result.output + + def test_raw_authority_cli_bounds_oversized_plan_and_resolves_detail( cli_workspace: dict[str, Path], cli_runner: CliRunner, diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 710f0d26c9..68e5382791 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4104,6 +4104,50 @@ def test_run_daemon_services_checks_archive_identity_before_component_startup(tm configure.assert_not_called() +def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The ordinary daemon startup preflight, not a test-only guard, blocks a prepared relocation.""" + from polylogue.daemon import cli as daemon_cli + from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + _sealed_receipt, + _write_receipt, + ) + + root = tmp_path / "archive" + root.mkdir() + receipt = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256="a" * 64, + authorization="a" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="polylogue ops maintenance archive-root-relocation apply --plan plan.json --authorize " + + "a" * 64, + ) + _write_receipt(root / ".maintenance-state" / "archive-root-relocations" / "prepared.json", receipt, expected=None) + configure = Mock() + monkeypatch.setattr("polylogue.paths.archive_root", lambda: root) + monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", configure) + + with pytest.raises(ArchiveRootRelocationError, match="archive-root-relocation apply"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + + configure.assert_not_called() + + def test_emit_daemon_lifecycle_event_carries_dev_loop_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py new file mode 100644 index 0000000000..ec9891ee40 --- /dev/null +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -0,0 +1,53 @@ +"""Regression coverage for the offline inode-preserving archive-root move.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from polylogue.cli.click_app import cli +from polylogue.daemon.backup import backup_archive +from polylogue.operations.archive_root_relocation import ArchiveRootRelocationError, prepare_archive_root_relocation + + +def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict[str, object]) -> None: + """The production maintenance dispatcher exposes the explicit relocation route.""" + result = CliRunner().invoke( + cli, + ["--plain", "ops", "maintenance", "archive-root-relocation", "--help"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "inode-preserving" in result.output + + +def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( + workspace_env: dict[str, Path], tmp_path: Path +) -> None: + """The plan enters backup attestation and immutable archive inspection, never a write route.""" + old_root = workspace_env["archive_root"] + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "moved-archive" + os.rename(old_root, new_root) + before = { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + + with pytest.raises(ArchiveRootRelocationError, match="fresh-bootstrap"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:test-daemon-stopped", + single_writer_evidence_ref="proof:test-writer-lock", + ) + + after = { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + assert after == before From 2275ab273ee212b8adf89ae4d74a7818ae59529c Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 19:12:21 +0200 Subject: [PATCH 02/39] fix: permit relocation receipt recovery Revalidate immutable plan bindings while accepting only the planned before or after durable-manifest CAS states, so a prepared relocation can resume. --- .../operations/archive_root_relocation.py | 55 +++++++++++++++++-- .../storage/test_archive_root_relocation.py | 55 +++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index b1e73b6884..371d414adb 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -432,6 +432,52 @@ def assert_no_prepared_archive_root_relocation(root: Path) -> None: ) +def _revalidate_plan_live_state( + root: Path, + plan: ArchiveRootRelocationPlan, + *, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, +) -> None: + """Recheck every immutable plan binding while allowing CAS resume states.""" + if stopped_daemon_evidence_ref != plan.stopped_daemon_evidence_ref: + raise ArchiveRootRelocationError("archive-root relocation stopped-daemon evidence changed") + if single_writer_evidence_ref != plan.single_writer_evidence_ref: + raise ArchiveRootRelocationError("archive-root relocation single-writer evidence changed") + root_metadata = root.stat() + if (root_metadata.st_dev, root_metadata.st_ino) != (plan.new_root_device, plan.new_root_inode): + raise ArchiveRootRelocationError("archive-root relocation configured root identity changed") + _reject_sidecars(root) + try: + manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( + Path(plan.backup_manifest_path), old_archive_root=Path(plan.old_resolved_root) + ) + except MigrationError as exc: + raise ArchiveRootRelocationError(str(exc)) from exc + if ( + str(manifest_path) != plan.backup_manifest_path + or str(receipt_path) != plan.backup_receipt_path + or _sha256_file(manifest_path) != plan.backup_manifest_sha256 + or _sha256_file(receipt_path) != plan.backup_receipt_sha256 + ): + raise ArchiveRootRelocationError("archive-root relocation backup authority changed") + snapshots = tuple(_tier_snapshot(root, tier) for tier in ArchiveTier) + if snapshots != plan.tiers: + raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") + _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) + for item in plan.source_trains: + path = Path(item.path) + train = load_durable_change_train_manifest(path) + before = _sha256_file(path) == item.before_manifest_sha256 + after = ( + train.revision == item.before_revision + 1 + and train.apply_evidence is not None + and train.apply_evidence.post.archive_identity_digest == item.after_archive_identity_digest + ) + if not before and not after: + raise ArchiveRootRelocationError(f"archive-root relocation manifest changed: {path}") + + def apply_archive_root_relocation( *, root: Path, @@ -447,15 +493,12 @@ def apply_archive_root_relocation( resolved = _real_directory(root, label="configured archive root") if str(root.absolute()) != plan.new_configured_root or str(resolved) != plan.new_resolved_root: raise ArchiveRootRelocationError("archive-root relocation plan is bound to a different configured root") - current = prepare_archive_root_relocation( - old_root=Path(plan.old_configured_root), - new_root=root, - backup_manifest=Path(plan.backup_manifest_path), + _revalidate_plan_live_state( + resolved, + plan, stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, single_writer_evidence_ref=single_writer_evidence_ref, ) - if current.plan_sha256 != plan.plan_sha256: - raise ArchiveRootRelocationError("archive-root relocation evidence changed after planning") receipt_path = _receipt_path(resolved, plan) command = ( f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance archive-root-relocation " diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index ec9891ee40..6db324e7bc 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -3,6 +3,8 @@ from __future__ import annotations import os +import sqlite3 +from dataclasses import replace from pathlib import Path import pytest @@ -11,6 +13,15 @@ from polylogue.cli.click_app import cli from polylogue.daemon.backup import backup_archive from polylogue.operations.archive_root_relocation import ArchiveRootRelocationError, prepare_archive_root_relocation +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.durable_change_train import rebind_released_source_train_archive_identity +from polylogue.storage.sqlite.migration_runner import ( + apply_durable_change_train, + capture_durable_restart_convergence, + prove_durable_change_train, + record_durable_writer_release, + release_durable_change_train, +) def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict[str, object]) -> None: @@ -51,3 +62,47 @@ def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") } assert after == before + + +def test_rebind_rewrites_only_the_released_source_identity_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Exercise the real durable-train lifecycle, then its relocation revision helper.""" + from tests.unit.storage import test_durable_change_train as trains + + database = tmp_path / "source.db" + trains._create_current_database(database) + trains._install_synthetic_migration(tmp_path, monkeypatch, ArchiveTier.SOURCE) + train = trains._admitted(ArchiveTier.SOURCE) + with sqlite3.connect(database) as connection: + train = trains._reserve_and_authorize(connection, train, archive_root=tmp_path) + train = apply_durable_change_train(connection, train) + train = record_durable_writer_release(train, evidence_ref="proof:release") + with sqlite3.connect(database) as connection: + restart = capture_durable_restart_convergence( + connection, + train, + runtime_consumers=trains._runtime_results(), + evidence_ref="proof:restart", + ) + train = prove_durable_change_train( + train, + fresh_ddl_parity=trains._parity(ArchiveTier.SOURCE), + runtime_consumers=trains._runtime_results(), + restart_convergence=restart, + ) + released = release_durable_change_train(train, evidence_ref="proof:released") + assert released.apply_evidence is not None + before = released + updated = rebind_released_source_train_archive_identity( + before, + archive_identity_digest="a" * 64, + proof_ref="proof:archive-root-relocation:receipt", + ) + + assert updated.revision == before.revision + 1 + assert updated.apply_evidence == replace( + before.apply_evidence, + post=replace(before.apply_evidence.post, archive_identity_digest="a" * 64), + ) + assert updated.proof_refs == (*before.proof_refs, "proof:archive-root-relocation:receipt") From 4d988e33a9c78f4db2d65c798aa9fa50ac5a91db Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 19:13:53 +0200 Subject: [PATCH 03/39] test: type relocation train evidence --- tests/unit/storage/test_archive_root_relocation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 6db324e7bc..b15845cc60 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -94,6 +94,8 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( released = release_durable_change_train(train, evidence_ref="proof:released") assert released.apply_evidence is not None before = released + before_evidence = before.apply_evidence + assert before_evidence is not None updated = rebind_released_source_train_archive_identity( before, archive_identity_digest="a" * 64, @@ -102,7 +104,7 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( assert updated.revision == before.revision + 1 assert updated.apply_evidence == replace( - before.apply_evidence, - post=replace(before.apply_evidence.post, archive_identity_digest="a" * 64), + before_evidence, + post=replace(before_evidence.post, archive_identity_digest="a" * 64), ) assert updated.proof_refs == (*before.proof_refs, "proof:archive-root-relocation:receipt") From ee1f4fa18bc4c405ad273baa4889d73b5407b522 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 19:16:49 +0200 Subject: [PATCH 04/39] docs: point operator examples at relocated archive --- devtools/command_catalog.py | 4 ++-- docs/archive-backup.md | 4 ++-- docs/devtools.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index a4708a83b8..287aa5d557 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -574,9 +574,9 @@ def to_dict(self) -> dict[str, object]: "archive and never writes into polylogue/scenarios/ on its own." ), examples=( - "devtools demo real-slice-screen --archive-root /realm/db/polylogue " + "devtools demo real-slice-screen --archive-root /realm/state/polylogue " "--session claude-code-session:: --out .agent/scratch/real-slice", - "devtools demo real-slice-screen --archive-root /realm/db/polylogue " + "devtools demo real-slice-screen --archive-root /realm/state/polylogue " "--refs-file refs.txt --out .agent/scratch/real-slice", ), ), diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 449b365dfd..0b9a28c150 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -164,8 +164,8 @@ sqlite3 /source.db "PRAGMA user_version; SELECT count(*) FROM raw_sess # Sane-lag comparison against the live archive (restored counts must be <= # live counts, and the gap should track the age of the chosen archive): -sqlite3 /realm/db/polylogue/user.db "SELECT count(*) FROM assertions;" -sqlite3 /realm/db/polylogue/source.db "SELECT count(*) FROM raw_sessions;" +sqlite3 /realm/state/polylogue/user.db "SELECT count(*) FROM assertions;" +sqlite3 /realm/state/polylogue/source.db "SELECT count(*) FROM raw_sessions;" ``` **Negative control (deliberately corrupted restore must fail loudly)** — diff --git a/docs/devtools.md b/docs/devtools.md index 47847f455a..a3c6b34028 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -234,7 +234,7 @@ These are the commands worth remembering during normal repo work: `polylogue ops maintenance cursor-authority-reconcile` is a dry-run-by-default repair route for exactly one proven cursor-ahead source. It reads the fixed -`/realm/db/polylogue` archive root, requires the daemon to be stopped, and +`/realm/state/polylogue` archive root, requires the daemon to be stopped, and writes a plan containing path and raw identifiers only as digests. Apply requires that immutable plan, a freshly verified `full_evidence` backup manifest with blob rollback evidence, and a new receipt path. The apply route From 17f5730f80fcb7bd7030db142a8a869b30bb47cf Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 20:07:04 +0200 Subject: [PATCH 05/39] fix: require source continuity before root relocation --- docs/archive-backup.md | 2 +- docs/maintenance.md | 2 +- .../operations/archive_root_relocation.py | 95 +++++++-- .../storage/sqlite/durable_change_train.py | 8 +- .../storage/test_archive_root_relocation.py | 185 +++++++++++++++++- 5 files changed, 266 insertions(+), 26 deletions(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 0b9a28c150..600b704795 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -58,7 +58,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --authorize PLAN_SHA256 --output-format json ``` -The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, source-continuity trains, or any non-released source train. It records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry the existing receipt-backed source-continuity refresh; relocation authenticates and rebinds that evidence, it does not bypass it. It records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. ## Restore Rules diff --git a/docs/maintenance.md b/docs/maintenance.md index 860a7483a7..5a3dc0275e 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -41,7 +41,7 @@ command's migration result alone. ## Relocating an archive root -Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. A current source train with post-release source content must first have its existing receipt-backed source-continuity refresh; this operation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 371d414adb..192543a0f0 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -8,7 +8,6 @@ import sqlite3 import stat import tempfile -from dataclasses import replace from pathlib import Path from typing import Literal @@ -17,7 +16,12 @@ from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( + DURABLE_MIGRATION_ADOPTION_FLOORS, + DurableChangeTrainError, DurableChangeTrainState, + _released_train_manifests_by_target, + _require_released_train_chain, + _validate_source_continuity_refresh_receipt, load_durable_change_train_manifest, rebind_released_source_train_archive_identity, write_durable_change_train_manifest, @@ -65,6 +69,7 @@ class RelocationSourceTrain(BaseModel): before_manifest_sha256: str before_archive_identity_digest: str after_archive_identity_digest: str + source_continuity_receipt_digests: tuple[str, ...] class ArchiveRootRelocationPlan(BaseModel): @@ -122,17 +127,15 @@ def _canonical_sha256(payload: object) -> str: def _sealed_plan(**values: object) -> ArchiveRootRelocationPlan: - payload = {"format": PLAN_FORMAT, **values, "plan_sha256": ""} - payload["plan_sha256"] = _canonical_sha256({key: value for key, value in payload.items() if key != "plan_sha256"}) - return ArchiveRootRelocationPlan.model_validate(payload) + plan = ArchiveRootRelocationPlan.model_validate({"format": PLAN_FORMAT, **values, "plan_sha256": ""}) + payload = plan.model_dump(mode="json", exclude={"plan_sha256"}) + return plan.model_copy(update={"plan_sha256": _canonical_sha256(payload)}) def _sealed_receipt(**values: object) -> ArchiveRootRelocationReceipt: - payload = {"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""} - payload["receipt_sha256"] = _canonical_sha256( - {key: value for key, value in payload.items() if key != "receipt_sha256"} - ) - return ArchiveRootRelocationReceipt.model_validate(payload) + receipt = ArchiveRootRelocationReceipt.model_validate({"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""}) + payload = receipt.model_dump(mode="json", exclude={"receipt_sha256"}) + return receipt.model_copy(update={"receipt_sha256": _canonical_sha256(payload)}) def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: @@ -234,7 +237,8 @@ def _tier_snapshot(root: Path, tier: ArchiveTier) -> RelocationTierEvidence: def _source_trains( root: Path, *, - accepted_before_digests: frozenset[str], + source_version: int, + source_content_sha256: str, after_identity_digest: str, ) -> tuple[RelocationSourceTrain, ...]: manifest_root = root / ".maintenance-state" / "durable-change-trains" @@ -242,19 +246,47 @@ def _source_trains( _real_directory(manifest_root, label="durable change-train state") if (manifest_root / ".bootstrap").exists() or (manifest_root / ".bootstrap.pending").exists(): raise ArchiveRootRelocationError("archive-root relocation does not support fresh-bootstrap train authority") - paths = tuple(sorted(manifest_root.glob("source-*.json"))) - if not paths: + manifests = _released_train_manifests_by_target(manifest_root, ArchiveTier.SOURCE) + if not manifests: raise ArchiveRootRelocationError("archive-root relocation requires released source train evidence") + try: + _require_released_train_chain( + ArchiveTier.SOURCE, + manifests, + current_version=source_version, + ) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError("archive-root relocation source train chain is not released") from exc + expected_targets = set(range(DURABLE_MIGRATION_ADOPTION_FLOORS[ArchiveTier.SOURCE] + 1, source_version + 1)) + if set(manifests) != expected_targets: + raise ArchiveRootRelocationError("archive-root relocation found an unexpected source train target") trains: list[RelocationSourceTrain] = [] - for path in paths: + for _target, train in sorted(manifests.items()): + path = manifest_root / f"source-{train.slot:03d}.json" _real_file(path, label="source train manifest") - train = load_durable_change_train_manifest(path) if train.state is not DurableChangeTrainState.RELEASED or train.apply_evidence is None: raise ArchiveRootRelocationError(f"source train is not released: {path}") - if train.source_continuity_evidence is not None: + continuity_refs = tuple( + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") + ) + if ( + train.target_version == source_version + and train.source_continuity_evidence is None + and train.apply_evidence.post.content_sha256 != source_content_sha256 + ): raise ArchiveRootRelocationError( - "archive-root relocation does not support source-continuity train authority" + "archive-root relocation requires a typed source-continuity refresh for the live source train; " + "the released source train still carries stale source content authority" ) + if train.source_continuity_evidence is not None: + try: + _validate_source_continuity_refresh_receipt(root, train) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation source continuity authority is invalid" + ) from exc trains.append( RelocationSourceTrain( path=str(path), @@ -262,11 +294,12 @@ def _source_trains( before_manifest_sha256=_sha256_file(path), before_archive_identity_digest=train.apply_evidence.post.archive_identity_digest, after_archive_identity_digest=after_identity_digest, + source_continuity_receipt_digests=continuity_refs, ) ) - if trains[-1].before_archive_identity_digest not in accepted_before_digests: + if trains[-1].before_archive_identity_digest == after_identity_digest: raise ArchiveRootRelocationError( - f"released source train does not independently prove the relocated source inode: {path}" + f"released source train already carries the current archive identity: {path}" ) return tuple(trains) @@ -326,10 +359,12 @@ def prepare_archive_root_relocation( _check_backup_against_live(new_resolved, manifest=manifest, receipt=receipt, snapshots=snapshots) location_identity = ArchiveIdentity.resolve_location(ArchiveLocation.resolve(new_resolved)) source_identity_digest = hashlib.sha256(location_identity.tier("source").stable_id.encode()).hexdigest() - old_location_identity = replace(location_identity, configured_root=old_resolved) + source_version = next(item.user_version for item in snapshots if item.tier == "source") + source_content_sha256 = next(item.content_sha256 for item in snapshots if item.tier == "source") trains = _source_trains( new_resolved, - accepted_before_digests=frozenset({source_identity_digest, old_location_identity.authority_identity_digest}), + source_version=source_version, + source_content_sha256=source_content_sha256, after_identity_digest=source_identity_digest, ) root_metadata = new_resolved.stat() @@ -468,11 +503,29 @@ def _revalidate_plan_live_state( for item in plan.source_trains: path = Path(item.path) train = load_durable_change_train_manifest(path) + continuity_refs = tuple( + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") + ) + if continuity_refs != item.source_continuity_receipt_digests: + raise ArchiveRootRelocationError(f"archive-root relocation continuity receipts changed: {path}") + if train.source_continuity_evidence is not None: + try: + _validate_source_continuity_refresh_receipt(root, train) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + f"archive-root relocation continuity receipt is invalid: {path}" + ) from exc before = _sha256_file(path) == item.before_manifest_sha256 after = ( train.revision == item.before_revision + 1 and train.apply_evidence is not None and train.apply_evidence.post.archive_identity_digest == item.after_archive_identity_digest + and ( + train.source_continuity_evidence is None + or train.source_continuity_evidence.archive_identity_digest == item.after_archive_identity_digest + ) ) if not before and not after: raise ArchiveRootRelocationError(f"archive-root relocation manifest changed: {path}") @@ -519,6 +572,8 @@ def apply_archive_root_relocation( if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") if receipt.state == "committed": + if tuple(_sha256_file(Path(item.path)) for item in plan.source_trains) != receipt.manifest_after_sha256: + raise ArchiveRootRelocationError("archive-root relocation committed receipt does not match manifests") return ArchiveRootRelocationResult( state="committed", plan_sha256=plan.plan_sha256, diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index c73fb98950..d9298affdc 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -564,15 +564,19 @@ def rebind_released_source_train_archive_identity( """Return the one permitted root-relocation revision of a source train.""" if train.tier is not ArchiveTier.SOURCE or train.state is not DurableChangeTrainState.RELEASED: raise DurableChangeTrainError("archive-root relocation requires a released source train") - if train.apply_evidence is None or train.source_continuity_evidence is not None: - raise DurableChangeTrainError("archive-root relocation does not support source continuity train shapes") + if train.apply_evidence is None: + raise DurableChangeTrainError("archive-root relocation requires source train apply evidence") _migration_runner._validate_sha256(archive_identity_digest, label="relocated archive identity") post = replace(train.apply_evidence.post, archive_identity_digest=archive_identity_digest) evidence = replace(train.apply_evidence, post=post) + continuity = train.source_continuity_evidence + if continuity is not None: + continuity = replace(continuity, archive_identity_digest=archive_identity_digest) updated = replace( train, revision=train.revision + 1, apply_evidence=evidence, + source_continuity_evidence=continuity, proof_refs=_migration_runner._append_proof_refs(train.proof_refs, proof_ref), ) validate_durable_change_train_manifest(updated) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index b15845cc60..0154f1d88c 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -12,15 +12,24 @@ from polylogue.cli.click_app import cli from polylogue.daemon.backup import backup_archive -from polylogue.operations.archive_root_relocation import ArchiveRootRelocationError, prepare_archive_root_relocation +from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + apply_archive_root_relocation, + prepare_archive_root_relocation, +) from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.durable_change_train import rebind_released_source_train_archive_identity +from polylogue.storage.sqlite.durable_change_train import ( + DURABLE_MIGRATION_ADOPTION_FLOORS, + load_durable_change_train_manifest, + rebind_released_source_train_archive_identity, +) from polylogue.storage.sqlite.migration_runner import ( apply_durable_change_train, capture_durable_restart_convergence, prove_durable_change_train, record_durable_writer_release, release_durable_change_train, + write_durable_change_train_manifest, ) @@ -108,3 +117,175 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( post=replace(before_evidence.post, archive_identity_digest="a" * 64), ) assert updated.proof_refs == (*before.proof_refs, "proof:archive-root-relocation:receipt") + assert before.released_at_ms is not None + current_authority = replace( + before, + source_continuity_evidence=replace(before_evidence.post, observed_at_ms=before.released_at_ms + 1), + proof_refs=(*before.proof_refs, "proof:source-continuity-refresh:" + "d" * 64), + ) + rebound_current_authority = rebind_released_source_train_archive_identity( + current_authority, + archive_identity_digest="c" * 64, + proof_ref="proof:archive-root-relocation:receipt-current", + ) + assert rebound_current_authority.source_continuity_evidence == replace( + current_authority.source_continuity_evidence, + archive_identity_digest="c" * 64, + ) + + +def _released_moved_source_train(root: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Build a real released source train over a temporary SQLite source tier.""" + from tests.unit.storage import test_durable_change_train as trains + + source = root / "source.db" + source.unlink() + trains._create_current_database(source) + trains._install_synthetic_migration(root.parent, monkeypatch, ArchiveTier.SOURCE) + train = trains._admitted(ArchiveTier.SOURCE) + with sqlite3.connect(source) as connection: + train = trains._reserve_and_authorize(connection, train, archive_root=root) + train = apply_durable_change_train(connection, train) + train = record_durable_writer_release(train, evidence_ref="proof:writer-release") + with sqlite3.connect(source) as connection: + restart = capture_durable_restart_convergence( + connection, + train, + runtime_consumers=trains._runtime_results(), + evidence_ref="proof:restart", + ) + train = prove_durable_change_train( + train, + fresh_ddl_parity=trains._parity(ArchiveTier.SOURCE), + runtime_consumers=trains._runtime_results(), + restart_convergence=restart, + ) + released = release_durable_change_train(train, evidence_ref="proof:released") + assert released.apply_evidence is not None + historical = replace( + released, + apply_evidence=replace( + released.apply_evidence, + post=replace(released.apply_evidence.post, archive_identity_digest="b" * 64), + ), + ) + manifest_root = root / ".maintenance-state" / "durable-change-trains" + (manifest_root / ".bootstrap").unlink() + manifest = manifest_root / "source-002.json" + write_durable_change_train_manifest(manifest, historical, expected_revision=-1) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.SOURCE, 1) + return manifest + + +def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_crash( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Use production backup, train CAS, and ordinary verifier across a moved temporary archive.""" + from polylogue.storage.sqlite import durable_change_train as trains + + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + moved_manifest = new_root / manifest.relative_to(old_root) + with sqlite3.connect(new_root / "source.db") as connection: + with pytest.raises(Exception, match="continuity proof failed"): + trains._verify_released_train_live_tier( + new_root, + connection, + trains.load_durable_change_train_manifest(moved_manifest), + ) + database_before = { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert database_before == { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + with monkeypatch.context() as scoped: + scoped.setattr( + "polylogue.operations.archive_root_relocation.rebind_released_source_train_archive_identity", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("crash")), + ) + with pytest.raises(RuntimeError, match="crash"): + apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + result = apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert result.state == "committed" + assert ( + apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ).state + == "committed" + ) + assert database_before == { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + with sqlite3.connect(new_root / "source.db") as connection: + assert ( + trains._verify_released_train_live_tier( + new_root, + connection, + trains.load_durable_change_train_manifest(moved_manifest), + ) + is None + ) + + +def test_plan_rejects_the_real_stale_source_train_shape_before_receipt_write( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A post-liveness current source needs the existing typed continuity receipt.""" + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + released = load_durable_change_train_manifest(manifest) + assert released.apply_evidence is not None + stale = replace( + released, + revision=released.revision + 1, + apply_evidence=replace( + released.apply_evidence, + post=replace(released.apply_evidence.post, content_sha256="f" * 64), + ), + ) + write_durable_change_train_manifest(manifest, stale, expected_revision=released.revision) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + manifest_before = (new_root / manifest.relative_to(old_root)).read_bytes() + + with pytest.raises(ArchiveRootRelocationError, match="typed source-continuity refresh"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + assert (new_root / manifest.relative_to(old_root)).read_bytes() == manifest_before + assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() From 5fe8c4d91974b22de6b7a89005ec99c888102cb7 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 20:08:51 +0200 Subject: [PATCH 06/39] test: type relocation continuity evidence --- tests/unit/storage/test_archive_root_relocation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 0154f1d88c..76cd3c1e61 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -128,6 +128,7 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( archive_identity_digest="c" * 64, proof_ref="proof:archive-root-relocation:receipt-current", ) + assert current_authority.source_continuity_evidence is not None assert rebound_current_authority.source_continuity_evidence == replace( current_authority.source_continuity_evidence, archive_identity_digest="c" * 64, From 3fc3d5dcb8a00470fa3549ebd49fafbedbbf700a Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 20:13:02 +0200 Subject: [PATCH 07/39] fix: expose relocation subcommands to inventory --- polylogue/cli/commands/maintenance/__init__.py | 5 +++-- tests/unit/cli/test_archive_maintenance_cli.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index b95d0041fc..855d7d6606 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -15,7 +15,7 @@ import click -from polylogue.cli.click_command_registration import _LazyCommand +from polylogue.cli.click_command_registration import _LazyCommand, _LazyGroup # (cli name, submodule, attribute, short_help) _COMMANDS: tuple[tuple[str, str, str, str], ...] = ( @@ -243,8 +243,9 @@ def maintenance_group(ctx: click.Context) -> None: for _cli_name, _submodule, _attr, _short_help in _COMMANDS: + _command_type = _LazyGroup if _cli_name == "archive-root-relocation" else _LazyCommand maintenance_group.add_command( - _LazyCommand( + _command_type( _cli_name, f"polylogue.cli.commands.maintenance.{_submodule}", _attr, diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index fdcc2fc12a..2b77bdc01a 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -17,6 +17,7 @@ from click.testing import CliRunner from polylogue.cli.click_app import cli +from polylogue.cli.command_inventory import iter_command_paths from polylogue.cli.commands.maintenance import _rebuild_index as maintenance_rebuild_index from polylogue.cli.commands.maintenance._migrate_tier import ( MigrateTierErrorPayload, @@ -106,6 +107,14 @@ def test_archive_root_relocation_cli_help_exposes_only_plan_and_apply( assert "apply" in result.output +def test_archive_root_relocation_apply_is_in_the_public_command_inventory() -> None: + """Generated docs must discover the nested apply JSON option.""" + paths = {item.path: item.command for item in iter_command_paths(cli, include_root=False)} + + apply = paths[("ops", "maintenance", "archive-root-relocation", "apply")] + assert "--output-format" in {option for parameter in apply.params for option in parameter.opts} + + def test_raw_authority_cli_bounds_oversized_plan_and_resolves_detail( cli_workspace: dict[str, Path], cli_runner: CliRunner, From b529110db877e7f7be41da48507bb0e4b176ef94 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 21:45:58 +0200 Subject: [PATCH 08/39] feat: recover authenticated historical source continuity --- devtools/render_cli_output_schemas.py | 8 + docs/archive-backup.md | 2 +- docs/maintenance.md | 13 +- ...rce-continuity-recovery-result.schema.json | 46 + polylogue/cli/click_command_registration.py | 5 +- .../cli/commands/maintenance/__init__.py | 10 +- .../_source_continuity_recovery.py | 98 +++ polylogue/daemon/cli.py | 4 + .../operations/archive_root_relocation.py | 12 +- .../historical_source_continuity_recovery.py | 815 ++++++++++++++++++ polylogue/storage/blob_ref_liveness.py | 5 +- .../storage/sqlite/durable_change_train.py | 39 + .../storage/test_archive_root_relocation.py | 174 ++++ tests/unit/storage/test_blob_ref_liveness.py | 12 + 14 files changed, 1234 insertions(+), 9 deletions(-) create mode 100644 docs/schemas/cli-output/historical-source-continuity-recovery-result.schema.json create mode 100644 polylogue/cli/commands/maintenance/_source_continuity_recovery.py create mode 100644 polylogue/operations/historical_source_continuity_recovery.py diff --git a/devtools/render_cli_output_schemas.py b/devtools/render_cli_output_schemas.py index e7ed6f63cc..aa23ff8dfa 100644 --- a/devtools/render_cli_output_schemas.py +++ b/devtools/render_cli_output_schemas.py @@ -27,6 +27,7 @@ from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload from polylogue.operations.action_contracts import ActionAffordanceListPayload from polylogue.operations.archive_root_relocation import ArchiveRootRelocationResult +from polylogue.operations.historical_source_continuity_recovery import HistoricalSourceContinuityRecoveryResult from polylogue.surfaces.payloads import ( ArchiveDebtListPayload, ImportExplainPayload, @@ -278,6 +279,13 @@ class CliOutputSchema: model=ArchiveRootRelocationResult, surfaces=("polylogue ops maintenance archive-root-relocation apply --output-format json",), ), + CliOutputSchema( + name="historical-source-continuity-recovery-result", + title="Historical Source Continuity Recovery Result", + description=("Result from the one-purpose pre-#3868 historical source continuity recovery apply command."), + model=HistoricalSourceContinuityRecoveryResult, + surfaces=("polylogue ops maintenance source-continuity-recovery apply --output-format json",), + ), CliOutputSchema( name="machine-error", title="Machine Error Envelope", diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 600b704795..aae677faac 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -58,7 +58,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --authorize PLAN_SHA256 --output-format json ``` -The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry the existing receipt-backed source-continuity refresh; relocation authenticates and rebinds that evidence, it does not bypass it. It records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census; it is a separate offline transition, not an exception inside relocation. After it commits, make and verify a fresh `full_evidence` backup at the moved root before relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. ## Restore Rules diff --git a/docs/maintenance.md b/docs/maintenance.md index 5a3dc0275e..2f64cc5e59 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -41,7 +41,7 @@ command's migration result alone. ## Relocating an archive root -Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. A current source train with post-release source content must first have its existing receipt-backed source-continuity refresh; this operation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json @@ -50,6 +50,17 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root- If apply stops after recording a prepared receipt, daemon startup fails closed and names the exact apply command. Rerun that command with the same plan and authorization after restoring offline ownership. Do not use this operation for a copy, restore, new archive, migration, or live service move. +### Recovering the one historical liveness receipt shape + +`source-continuity-recovery` is a one-purpose bridge for a committed pre-#3868 blob-reference-liveness receipt that lacks the modern manifest digest and post-orphan fields. It does not make ordinary liveness receipts permissive. It requires an authenticated pre-mutation backup at the old source path, an authenticated post-mutation backup at that same old path, exact pre/post `blob_refs` delta proof, and a fresh zero-orphan census against the configured moved root. It creates no SQLite rows; apply CAS-revises only the current released source train and retained receipts. + +```bash +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-continuity-recovery plan --old-root /old/archive/root --mutation-receipt /safe/liveness.jsonl --pre-backup-manifest /safe/pre/manifest.json --post-backup-manifest /safe/post/manifest.json --output /safe/continuity-plan.json --output-format json +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-continuity-recovery apply --plan /safe/continuity-plan.json --authorize PLAN_SHA256 --output-format json +``` + +After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root before running the separate archive-root-relocation plan/apply transition. A prepared bridge receipt blocks daemon startup and names its exact resume command. + ### Rebuild deployment-currency preflight Before a managed `rebuild-index`, confirm that the package selected for the diff --git a/docs/schemas/cli-output/historical-source-continuity-recovery-result.schema.json b/docs/schemas/cli-output/historical-source-continuity-recovery-result.schema.json new file mode 100644 index 0000000000..4175c9910b --- /dev/null +++ b/docs/schemas/cli-output/historical-source-continuity-recovery-result.schema.json @@ -0,0 +1,46 @@ +{ + "$id": "https://polylogue.dev/schemas/cli-output/historical-source-continuity-recovery-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Result from the one-purpose pre-#3868 historical source continuity recovery apply command.\n\nGenerated from `polylogue.operations.historical_source_continuity_recovery.HistoricalSourceContinuityRecoveryResult` by `devtools render cli-output-schemas`. Do not edit by hand.", + "properties": { + "ok": { + "const": true, + "default": true, + "title": "Ok", + "type": "boolean" + }, + "plan_sha256": { + "title": "Plan Sha256", + "type": "string" + }, + "receipt_path": { + "title": "Receipt Path", + "type": "string" + }, + "refresh_receipt_path": { + "title": "Refresh Receipt Path", + "type": "string" + }, + "state": { + "enum": [ + "prepared", + "committed" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "state", + "plan_sha256", + "receipt_path", + "refresh_receipt_path" + ], + "title": "Historical Source Continuity Recovery Result", + "type": "object", + "x-polylogue-cli-surfaces": [ + "polylogue ops maintenance source-continuity-recovery apply --output-format json" + ], + "x-polylogue-source-model": "HistoricalSourceContinuityRecoveryResult" +} diff --git a/polylogue/cli/click_command_registration.py b/polylogue/cli/click_command_registration.py index 9868e8cc22..2e6b67675d 100644 --- a/polylogue/cli/click_command_registration.py +++ b/polylogue/cli/click_command_registration.py @@ -50,7 +50,10 @@ class _LazyGroup(_LazyCommand, click.Group): """Lazy proxy for Click groups that need nested command dispatch.""" def invoke(self, ctx: click.Context) -> object: - return self._resolve().invoke(ctx) + # Dispatch through this proxy's delegated ``get_command``. Invoking + # the resolved group directly loses Click's child-command context and + # leaves its subcommand options attached to the parent group. + return click.Group.invoke(self, ctx) def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: return click.Group.parse_args(self, ctx, args) diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index 855d7d6606..875ec4a3e2 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -52,6 +52,12 @@ "archive_root_relocation_command", "Plan or apply one offline inode-preserving archive-root relocation.", ), + ( + "source-continuity-recovery", + "_source_continuity_recovery", + "source_continuity_recovery_command", + "Recover one authenticated pre-#3868 source liveness transition offline.", + ), ( "run-preview", "_run_preview", @@ -243,7 +249,9 @@ def maintenance_group(ctx: click.Context) -> None: for _cli_name, _submodule, _attr, _short_help in _COMMANDS: - _command_type = _LazyGroup if _cli_name == "archive-root-relocation" else _LazyCommand + _command_type = ( + _LazyGroup if _cli_name in {"archive-root-relocation", "source-continuity-recovery"} else _LazyCommand + ) maintenance_group.add_command( _command_type( _cli_name, diff --git a/polylogue/cli/commands/maintenance/_source_continuity_recovery.py b/polylogue/cli/commands/maintenance/_source_continuity_recovery.py new file mode 100644 index 0000000000..1dc870f007 --- /dev/null +++ b/polylogue/cli/commands/maintenance/_source_continuity_recovery.py @@ -0,0 +1,98 @@ +"""Offline bridge for one authenticated historical source mutation.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import click + +from polylogue.operations.durable_change_train import acquire_durable_archive_ownership +from polylogue.operations.historical_source_continuity_recovery import ( + HistoricalSourceContinuityRecoveryError, + apply_historical_source_continuity_recovery, + load_historical_source_continuity_recovery_plan, + prepare_historical_source_continuity_recovery, + write_historical_source_continuity_recovery_plan, +) +from polylogue.paths import archive_root + + +@click.group("source-continuity-recovery") +def source_continuity_recovery_command() -> None: + """Recover one pre-#3868 liveness receipt with independently attested evidence.""" + + +@source_continuity_recovery_command.command("plan") +@click.option("--old-root", required=True, type=click.Path(path_type=Path)) +@click.option("--mutation-receipt", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--pre-backup-manifest", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--post-backup-manifest", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--output", required=True, type=click.Path(path_type=Path)) +@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) +def source_continuity_recovery_plan_command( + old_root: Path, + mutation_receipt: Path, + pre_backup_manifest: Path, + post_backup_manifest: Path, + output: Path, + output_format: str, +) -> None: + """Seal a read-only bridge plan; it never writes SQLite or sidecars.""" + from polylogue.cli.commands.maintenance._migrate_tier import _require_stopped_daemon + + root = archive_root() + try: + with acquire_durable_archive_ownership( + root, owner_id=f"historical-source-continuity-recovery-plan:{os.getpid()}" + ): + stopped = _require_stopped_daemon(root) + plan = prepare_historical_source_continuity_recovery( + old_root=old_root, + new_root=root, + mutation_receipt=mutation_receipt, + pre_backup_manifest=pre_backup_manifest, + post_backup_manifest=post_backup_manifest, + stopped_daemon_evidence_ref=stopped, + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + write_historical_source_continuity_recovery_plan(plan, output) + except (HistoricalSourceContinuityRecoveryError, OSError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + if output_format == "json" + else f"Wrote historical source continuity recovery plan: {output}" + ) + + +@source_continuity_recovery_command.command("apply") +@click.option("--plan", "plan_path", required=True, type=click.Path(path_type=Path, exists=True)) +@click.option("--authorize", required=True) +@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) +def source_continuity_recovery_apply_command(plan_path: Path, authorize: str, output_format: str) -> None: + """CAS-revise only the released current source train and retained receipts.""" + from polylogue.cli.commands.maintenance._migrate_tier import _require_stopped_daemon + + root = archive_root() + try: + plan = load_historical_source_continuity_recovery_plan(plan_path) + with acquire_durable_archive_ownership( + root, owner_id=f"historical-source-continuity-recovery-apply:{os.getpid()}" + ): + stopped = _require_stopped_daemon(root) + result = apply_historical_source_continuity_recovery( + root=root, + plan=plan, + authorization=authorize, + stopped_daemon_evidence_ref=stopped, + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + except (HistoricalSourceContinuityRecoveryError, OSError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True) + if output_format == "json" + else f"Historical source continuity recovery {result.state}: {result.receipt_path}" + ) diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 02223c82cd..a2096a6e74 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -2263,10 +2263,14 @@ async def _run_daemon_services_under_active_writer_lease( # bootstrap invocation. archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation + from polylogue.operations.historical_source_continuity_recovery import ( + assert_no_prepared_historical_source_continuity_recovery, + ) # A prepared relocation is explicit operator work. Check before runtime # component registration so no daemon surface becomes observable first. assert_no_prepared_archive_root_relocation(archive_root_path) + assert_no_prepared_historical_source_continuity_recovery(archive_root_path) from polylogue.storage.archive_identity import assert_writable_archive_identity # Identity precedes schema checks, pidfiles, HTTP startup, and every other diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 192543a0f0..262a3ec86b 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -69,6 +69,7 @@ class RelocationSourceTrain(BaseModel): before_manifest_sha256: str before_archive_identity_digest: str after_archive_identity_digest: str + requires_rebind: bool source_continuity_receipt_digests: tuple[str, ...] @@ -294,10 +295,13 @@ def _source_trains( before_manifest_sha256=_sha256_file(path), before_archive_identity_digest=train.apply_evidence.post.archive_identity_digest, after_archive_identity_digest=after_identity_digest, + requires_rebind=train.apply_evidence.post.archive_identity_digest != after_identity_digest, source_continuity_receipt_digests=continuity_refs, ) ) - if trains[-1].before_archive_identity_digest == after_identity_digest: + if trains[-1].before_archive_identity_digest == after_identity_digest and not ( + train.target_version == source_version and train.source_continuity_evidence is not None + ): raise ArchiveRootRelocationError( f"released source train already carries the current archive identity: {path}" ) @@ -519,7 +523,7 @@ def _revalidate_plan_live_state( ) from exc before = _sha256_file(path) == item.before_manifest_sha256 after = ( - train.revision == item.before_revision + 1 + train.revision == item.before_revision + (1 if item.requires_rebind else 0) and train.apply_evidence is not None and train.apply_evidence.post.archive_identity_digest == item.after_archive_identity_digest and ( @@ -587,7 +591,7 @@ def apply_archive_root_relocation( path = Path(item.path) train = load_durable_change_train_manifest(path) actual_hash = _sha256_file(path) - if actual_hash == item.before_manifest_sha256: + if actual_hash == item.before_manifest_sha256 and item.requires_rebind: updated = rebind_released_source_train_archive_identity( train, archive_identity_digest=item.after_archive_identity_digest, @@ -595,7 +599,7 @@ def apply_archive_root_relocation( ) write_durable_change_train_manifest(path, updated, expected_revision=item.before_revision) elif ( - train.revision != item.before_revision + 1 + train.revision != item.before_revision + (1 if item.requires_rebind else 0) or train.apply_evidence is None or train.apply_evidence.post.archive_identity_digest != item.after_archive_identity_digest ): diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py new file mode 100644 index 0000000000..ca66f8ff83 --- /dev/null +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -0,0 +1,815 @@ +"""One-purpose recovery of a pre-#3868 blob-liveness transition. + +This bridge exists because an historical receipt predates the normal receipt +postcondition. It does not relax that normal validator: it reconstructs the +missing authority from the attested pre/post backups and a fresh read-only +liveness census, then records a normal retained source-continuity receipt. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import sqlite3 +import stat +import tempfile +from pathlib import Path +from typing import Literal, cast + +from pydantic import BaseModel, ConfigDict + +from polylogue.maintenance.blob_ref_liveness_reconciliation import census_blob_ref_liveness +from polylogue.storage.backup_attestation import BackupAttestationError, verify_verification_receipt +from polylogue.storage.blob_ref_liveness import ( + BlobRefLivenessCandidate, + BlobRefLivenessCandidateDigest, + classify_blob_ref_liveness, +) +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.durable_change_train import ( + DURABLE_MIGRATION_ADOPTION_FLOORS, + DurableChangeTrain, + DurableChangeTrainError, + DurableChangeTrainState, + _released_train_manifests_by_target, + _require_released_train_chain, + _validate_source_continuity_refresh_receipt, + load_durable_change_train_manifest, + recover_released_source_train_continuity, + write_durable_change_train_manifest, +) +from polylogue.storage.sqlite.migration_runner import ( + DurableDatabaseEvidence, + capture_durable_database_evidence, +) + +PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v1"] = ( + "polylogue.historical-source-continuity-recovery-plan.v1" +) +RECEIPT_FORMAT: Literal["polylogue.historical-source-continuity-recovery-receipt.v1"] = ( + "polylogue.historical-source-continuity-recovery-receipt.v1" +) + + +class HistoricalSourceContinuityRecoveryError(RuntimeError): + """Historical evidence cannot prove this one recovery transition.""" + + +class HistoricalSourceContinuityRecoveryPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["polylogue.historical-source-continuity-recovery-plan.v1"] = PLAN_FORMAT + old_configured_root: str + old_resolved_root: str + new_configured_root: str + new_resolved_root: str + mutation_receipt_path: str + mutation_receipt_sha256: str + legacy_candidate_count: int + legacy_candidate_digest: str + pre_backup_manifest_path: str + pre_backup_manifest_sha256: str + pre_backup_receipt_path: str + pre_backup_receipt_sha256: str + post_backup_manifest_path: str + post_backup_manifest_sha256: str + post_backup_receipt_path: str + post_backup_receipt_sha256: str + source_train_path: str + source_train_revision: int + source_train_sha256: str + source_before: dict[str, object] + source_after: dict[str, object] + census: dict[str, object] + stopped_daemon_evidence_ref: str + single_writer_evidence_ref: str + bound_confirmation: Literal["historical-source-continuity-recovery"] + plan_sha256: str + + +class HistoricalSourceContinuityRecoveryReceipt(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["polylogue.historical-source-continuity-recovery-receipt.v1"] = RECEIPT_FORMAT + state: Literal["prepared", "committed"] + revision: int + plan_sha256: str + authorization: str + train_before_sha256: str + train_after_sha256: str | None + refresh_receipt_sha256: str + resume_command: str + receipt_sha256: str + + +class HistoricalSourceContinuityRecoveryResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[True] = True + state: Literal["prepared", "committed"] + plan_sha256: str + receipt_path: str + refresh_receipt_path: str + + +def _sha256(path: Path) -> str: + 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() + + +def _canonical_json_sha256(payload: object) -> str: + return hashlib.sha256( + json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True).encode("utf-8") + ).hexdigest() + + +def _real_file(path: Path, *, label: str) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise HistoricalSourceContinuityRecoveryError(f"cannot inspect {label}: {path}") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise HistoricalSourceContinuityRecoveryError(f"{label} is not a real single-linked file: {path}") + + +def _real_directory(path: Path, *, label: str) -> Path: + try: + metadata = path.lstat() + except OSError as exc: + raise HistoricalSourceContinuityRecoveryError(f"cannot inspect {label}: {path}") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise HistoricalSourceContinuityRecoveryError(f"{label} is not a real directory: {path}") + absolute = Path(os.path.abspath(path)) + resolved = path.resolve(strict=True) + if absolute != resolved: + raise HistoricalSourceContinuityRecoveryError(f"{label} traverses a symbolic link: {path}") + return resolved + + +def _sealed_plan(**values: object) -> HistoricalSourceContinuityRecoveryPlan: + plan = HistoricalSourceContinuityRecoveryPlan.model_validate({**values, "plan_sha256": ""}) + return plan.model_copy( + update={"plan_sha256": _canonical_json_sha256(plan.model_dump(mode="json", exclude={"plan_sha256"}))} + ) + + +def _sealed_receipt(**values: object) -> HistoricalSourceContinuityRecoveryReceipt: + receipt = HistoricalSourceContinuityRecoveryReceipt.model_validate( + {"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""} + ) + return receipt.model_copy( + update={"receipt_sha256": _canonical_json_sha256(receipt.model_dump(mode="json", exclude={"receipt_sha256"}))} + ) + + +def _verify_plan(plan: HistoricalSourceContinuityRecoveryPlan) -> None: + if plan.plan_sha256 != _canonical_json_sha256(plan.model_dump(mode="json", exclude={"plan_sha256"})): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery plan checksum mismatch") + + +def _verify_receipt(receipt: HistoricalSourceContinuityRecoveryReceipt) -> None: + if receipt.receipt_sha256 != _canonical_json_sha256(receipt.model_dump(mode="json", exclude={"receipt_sha256"})): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt checksum mismatch") + + +def _backup_source_evidence( + manifest_path: Path, *, old_source_path: Path +) -> tuple[Path, dict[str, object], DurableDatabaseEvidence]: + """Authenticate one old-path source backup without assuming it is full-evidence.""" + _real_file(manifest_path, label="historical backup manifest") + backup_root = _real_directory(manifest_path.parent, label="historical backup directory") + receipt_path = backup_root / "verification-receipt.json" + _real_file(receipt_path, label="historical backup verification receipt") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise HistoricalSourceContinuityRecoveryError("historical backup authority is unreadable") from exc + if not isinstance(manifest, dict) or not isinstance(receipt, dict): + raise HistoricalSourceContinuityRecoveryError("historical backup authority is not an object") + if manifest.get("format") != "polylogue-backup-v1" or receipt.get("verdict") != "success": + raise HistoricalSourceContinuityRecoveryError("historical backup is not a successful polylogue backup") + if receipt.get("manifest_sha256") != _sha256(manifest_path): + raise HistoricalSourceContinuityRecoveryError("historical backup receipt does not bind manifest bytes") + try: + verify_verification_receipt(receipt, tier="source", live_tier_path=old_source_path) + except BackupAttestationError as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical backup does not authenticate the old source path" + ) from exc + fingerprints = manifest.get("tier_source_fingerprints") + artifacts = receipt.get("tier_artifacts") + if not isinstance(fingerprints, dict) or not isinstance(artifacts, list): + raise HistoricalSourceContinuityRecoveryError("historical backup lacks source fingerprint authority") + fingerprint = fingerprints.get("source.db") + artifact = next((item for item in artifacts if isinstance(item, dict) and item.get("tier") == "source"), None) + if not isinstance(fingerprint, dict) or not isinstance(artifact, dict): + raise HistoricalSourceContinuityRecoveryError("historical backup lacks source artifact authority") + if fingerprint.get("path") != str(old_source_path) or artifact.get("source_fingerprint") != fingerprint: + raise HistoricalSourceContinuityRecoveryError("historical backup source path authority changed") + backup_source = backup_root / "source.db" + _real_file(backup_source, label="historical backup source.db") + actual = {"sha256": _sha256(backup_source), "size_bytes": backup_source.stat().st_size} + if any(fingerprint.get(key) != value or artifact.get(key) != value for key, value in actual.items()): + raise HistoricalSourceContinuityRecoveryError("historical backup source bytes differ from its receipt") + try: + with sqlite3.connect(f"file:{backup_source}?mode=ro&immutable=1", uri=True) as connection: + evidence = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("historical backup source is unreadable") from exc + if ( + fingerprint.get("user_version") != evidence.user_version + or artifact.get("user_version") != evidence.user_version + ): + raise HistoricalSourceContinuityRecoveryError("historical backup source version differs from its receipt") + return receipt_path, manifest, evidence + + +def _legacy_liveness_receipt(receipt_path: Path, *, old_source_path: Path, pre_manifest: Path) -> tuple[int, str]: + """Validate exactly the historical receipt shape, including every candidate row.""" + _real_file(receipt_path, label="historical liveness receipt") + header: dict[str, object] | None = None + footer: dict[str, object] | None = None + digest = BlobRefLivenessCandidateDigest() + count = 0 + try: + for line in io.BytesIO(receipt_path.read_bytes()): + if not line.strip(): + continue + record = json.loads(line) + if not isinstance(record, dict): + raise HistoricalSourceContinuityRecoveryError("historical liveness receipt contains a non-object") + if header is None: + header = cast(dict[str, object], record) + continue + if footer is not None: + raise HistoricalSourceContinuityRecoveryError("historical liveness receipt has data after its footer") + if record.get("kind") == "candidate": + try: + size = record["size_bytes"] + acquired = record["acquired_at_ms"] + if ( + not isinstance(size, int) + or isinstance(size, bool) + or not isinstance(acquired, int) + or isinstance(acquired, bool) + ): + raise TypeError + digest.update( + BlobRefLivenessCandidate( + blob_hash=str(record["blob_hash"]), + ref_type=str(record["ref_type"]), + ref_id=str(record["ref_id"]), + source_path=str(record["source_path"]) if record.get("source_path") is not None else None, + size_bytes=size, + acquired_at_ms=acquired, + referent_table=str(record["referent_table"]), + referent_column=str(record["referent_column"]), + ) + ) + except (KeyError, TypeError, ValueError) as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical liveness receipt has an invalid candidate" + ) from exc + count += 1 + else: + footer = cast(dict[str, object], record) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HistoricalSourceContinuityRecoveryError("historical liveness receipt is not valid JSONL") from exc + if header is None or footer is None: + raise HistoricalSourceContinuityRecoveryError("historical liveness receipt is incomplete") + if ( + header.get("kind") != "blob_ref_liveness_reconciliation" + or header.get("phase") != "prepared" + or header.get("source_db") != str(old_source_path) + or header.get("backup_manifest") != str(pre_manifest) + or "backup_manifest_sha256" in header + or footer.get("kind") != "blob_ref_liveness_reconciliation" + or footer.get("phase") != "committed" + or "post_orphaned_count" in footer + or header.get("candidate_count") != count + or header.get("candidate_digest") != digest.hexdigest() + or footer.get("deleted_count") != count + ): + raise HistoricalSourceContinuityRecoveryError("historical liveness receipt does not bind the legacy operation") + return count, digest.hexdigest() + + +def _evidence_payload(evidence: DurableDatabaseEvidence) -> dict[str, object]: + return { + "tier": evidence.tier.value, + "user_version": evidence.user_version, + "quick_check": list(evidence.quick_check), + "schema_inventory_sha256": evidence.schema_inventory_sha256, + "row_counts": [[table, count] for table, count in evidence.row_counts], + "archive_identity_digest": evidence.archive_identity_digest, + "content_sha256": evidence.content_sha256, + "observed_at_ms": evidence.observed_at_ms, + } + + +def _assert_pre_train_authority( + train_path: Path, pre: DurableDatabaseEvidence +) -> tuple[DurableChangeTrain, dict[str, object]]: + train = load_durable_change_train_manifest(train_path) + if ( + train.state is not DurableChangeTrainState.RELEASED + or train.tier is not ArchiveTier.SOURCE + or train.apply_evidence is None + ): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery requires a released source train") + expected = train.apply_evidence.post + fields = ("user_version", "schema_inventory_sha256", "content_sha256", "quick_check") + if any(getattr(expected, field) != getattr(pre, field) for field in fields): + raise HistoricalSourceContinuityRecoveryError("pre-mutation backup does not match the released source train") + return train, _evidence_payload(expected) + + +def _current_evidence(root: Path) -> DurableDatabaseEvidence: + for suffix in ("-wal", "-shm", "-journal"): + if (root / f"source.db{suffix}").exists() or (root / f"source.db{suffix}").is_symlink(): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery refuses source SQLite sidecars" + ) + try: + with sqlite3.connect(f"file:{root / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + return capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("cannot read current source evidence") from exc + + +def _blob_ref_rows_digest(connection: sqlite3.Connection, *, excluded: set[tuple[str, str, str]]) -> tuple[int, str]: + """Digest the exact non-candidate blob-ref relation without writing SQLite.""" + digest = hashlib.sha256() + count = 0 + try: + rows = connection.execute( + "SELECT hex(blob_hash), ref_type, ref_id, source_path, size_bytes, acquired_at_ms " + "FROM blob_refs ORDER BY ref_type, ref_id, blob_hash" + ) + for blob_hash, ref_type, ref_id, source_path, size_bytes, acquired_at_ms in rows: + key = (str(blob_hash).lower(), str(ref_type), str(ref_id)) + if key in excluded: + continue + digest.update( + json.dumps( + [ + str(blob_hash).lower(), + str(ref_type), + str(ref_id), + source_path, + int(size_bytes), + int(acquired_at_ms), + ], + separators=(",", ":"), + ensure_ascii=True, + ).encode() + + b"\n" + ) + count += 1 + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("historical backup cannot read blob-ref relation") from exc + return count, digest.hexdigest() + + +def _assert_exact_liveness_delta( + pre_source: Path, post_source: Path, candidates: tuple[BlobRefLivenessCandidate, ...] +) -> None: + """Prove the post backup differs only by deleting the historical candidates.""" + candidate_keys = {(candidate.blob_hash.lower(), candidate.ref_type, candidate.ref_id) for candidate in candidates} + try: + with ( + sqlite3.connect(f"file:{pre_source}?mode=ro&immutable=1", uri=True) as pre, + sqlite3.connect(f"file:{post_source}?mode=ro&immutable=1", uri=True) as post, + ): + pre_has_blob_refs = ( + pre.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'blob_refs'").fetchone() + is not None + ) + post_has_blob_refs = ( + post.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'blob_refs'").fetchone() + is not None + ) + if not pre_has_blob_refs and not post_has_blob_refs: + if candidate_keys: + raise HistoricalSourceContinuityRecoveryError( + "historical receipt names candidates but its backup has no blob-ref relation" + ) + return + if not pre_has_blob_refs or not post_has_blob_refs: + raise HistoricalSourceContinuityRecoveryError("pre/post backups disagree on the blob-ref relation") + pre_count, pre_digest = _blob_ref_rows_digest(pre, excluded=candidate_keys) + post_count, post_digest = _blob_ref_rows_digest(post, excluded=set()) + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("cannot compare pre/post blob-ref authority") from exc + if (pre_count, pre_digest) != (post_count, post_digest): + raise HistoricalSourceContinuityRecoveryError( + "post-mutation backup changed blob refs beyond the historical candidates" + ) + + +def _census(root: Path) -> dict[str, object]: + census = census_blob_ref_liveness(root) + payload = census.to_privacy_safe_dict() + if ( + census.total + or census.schema_unavailable_count + or payload.get("unknown_ref_type_count") + or census.deferred_by_ref_type + ): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery requires a zero-orphan complete liveness census" + ) + return payload + + +def _evidence_matches_plan(actual: DurableDatabaseEvidence, planned: dict[str, object]) -> bool: + """Evidence timestamps are observations, not a mutable archive identity fact.""" + actual_payload = _evidence_payload(actual) + return all(actual_payload.get(key) == value for key, value in planned.items() if key != "observed_at_ms") + + +def _evidence_from_plan(payload: dict[str, object]) -> DurableDatabaseEvidence: + """Decode the sealed evidence payload without accepting an untyped shape.""" + try: + row_counts = payload["row_counts"] + if not isinstance(row_counts, list): + raise TypeError + normalized_counts = tuple( + (str(item[0]), int(item[1])) for item in row_counts if isinstance(item, list) and len(item) == 2 + ) + if len(normalized_counts) != len(row_counts): + raise TypeError + user_version = payload["user_version"] + observed_at_ms = payload["observed_at_ms"] + if not isinstance(user_version, int) or isinstance(user_version, bool): + raise TypeError + if not isinstance(observed_at_ms, int) or isinstance(observed_at_ms, bool): + raise TypeError + return DurableDatabaseEvidence( + tier=ArchiveTier(str(payload["tier"])), + user_version=user_version, + quick_check=tuple(str(item) for item in cast(list[object], payload["quick_check"])), + schema_inventory_sha256=str(payload["schema_inventory_sha256"]), + row_counts=normalized_counts, + archive_identity_digest=str(payload["archive_identity_digest"]), + content_sha256=str(payload["content_sha256"]), + observed_at_ms=observed_at_ms, + ) + except (KeyError, TypeError, ValueError) as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery plan has invalid source evidence" + ) from exc + + +def _receipt_path(root: Path, plan: HistoricalSourceContinuityRecoveryPlan) -> Path: + return root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan.plan_sha256}.json" + + +def _refresh_path(root: Path, digest: str) -> Path: + return root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json" + + +def prepare_historical_source_continuity_recovery( + *, + old_root: Path, + new_root: Path, + mutation_receipt: Path, + pre_backup_manifest: Path, + post_backup_manifest: Path, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, +) -> HistoricalSourceContinuityRecoveryPlan: + """Seal a read-only recovery plan for the one historical liveness receipt.""" + old_configured = old_root.absolute() + old_resolved = old_root.resolve(strict=False) + root = _real_directory(new_root, label="configured archive root") + if old_resolved == root: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery requires distinct old and new roots" + ) + old_source = old_resolved / "source.db" + pre_receipt, _pre_manifest, pre = _backup_source_evidence(pre_backup_manifest, old_source_path=old_source) + post_receipt, _post_manifest, post = _backup_source_evidence(post_backup_manifest, old_source_path=old_source) + candidates, candidate_digest = _legacy_liveness_receipt( + mutation_receipt, old_source_path=old_source, pre_manifest=pre_backup_manifest.absolute() + ) + try: + with sqlite3.connect( + f"file:{pre_backup_manifest.parent / 'source.db'}?mode=ro&immutable=1", uri=True + ) as connection: + prior = classify_blob_ref_liveness(connection) + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("cannot recompute historical liveness candidates") from exc + prior_digest = BlobRefLivenessCandidateDigest() + for candidate in prior.candidates: + prior_digest.update(candidate) + if prior.orphaned_count != candidates or prior_digest.hexdigest() != candidate_digest: + raise HistoricalSourceContinuityRecoveryError("historical backup liveness candidates differ from the receipt") + _assert_exact_liveness_delta( + pre_backup_manifest.parent / "source.db", + post_backup_manifest.parent / "source.db", + prior.candidates, + ) + current = _current_evidence(root) + current_path = root / "source.db" + if ( + current.content_sha256 != post.content_sha256 + or current.user_version != post.user_version + or _sha256(current_path) != _sha256(post_backup_manifest.parent / "source.db") + ): + raise HistoricalSourceContinuityRecoveryError( + "current source bytes do not match the authenticated post-mutation backup" + ) + manifest_root = root / ".maintenance-state" / "durable-change-trains" + manifests = _released_train_manifests_by_target(manifest_root, ArchiveTier.SOURCE) + try: + _require_released_train_chain(ArchiveTier.SOURCE, manifests, current_version=current.user_version) + except DurableChangeTrainError as exc: + raise HistoricalSourceContinuityRecoveryError("released source train chain is not authoritative") from exc + expected_targets = set(range(DURABLE_MIGRATION_ADOPTION_FLOORS[ArchiveTier.SOURCE] + 1, current.user_version + 1)) + if set(manifests) != expected_targets: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery found an unexpected source train set" + ) + train = manifests.get(current.user_version) + if train is None: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery lacks the current released source train" + ) + train_path = manifest_root / f"source-{train.slot:03d}.json" + _real_file(train_path, label="current released source train") + _, source_before = _assert_pre_train_authority(train_path, pre) + if train.source_continuity_evidence is not None: + raise HistoricalSourceContinuityRecoveryError("current released source train already has continuity authority") + census = _census(root) + return _sealed_plan( + old_configured_root=str(old_configured), + old_resolved_root=str(old_resolved), + new_configured_root=str(new_root.absolute()), + new_resolved_root=str(root), + mutation_receipt_path=str(mutation_receipt.absolute()), + mutation_receipt_sha256=_sha256(mutation_receipt), + legacy_candidate_count=candidates, + legacy_candidate_digest=candidate_digest, + pre_backup_manifest_path=str(pre_backup_manifest.absolute()), + pre_backup_manifest_sha256=_sha256(pre_backup_manifest), + pre_backup_receipt_path=str(pre_receipt), + pre_backup_receipt_sha256=_sha256(pre_receipt), + post_backup_manifest_path=str(post_backup_manifest.absolute()), + post_backup_manifest_sha256=_sha256(post_backup_manifest), + post_backup_receipt_path=str(post_receipt), + post_backup_receipt_sha256=_sha256(post_receipt), + source_train_path=str(train_path), + source_train_revision=train.revision, + source_train_sha256=_sha256(train_path), + source_before=source_before, + source_after=_evidence_payload(current), + census=census, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + single_writer_evidence_ref=single_writer_evidence_ref, + bound_confirmation="historical-source-continuity-recovery", + ) + + +def write_historical_source_continuity_recovery_plan( + plan: HistoricalSourceContinuityRecoveryPlan, output: Path +) -> None: + _verify_plan(plan) + output.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(dir=output.parent, prefix=f".{output.name}.", delete=False) as stream: + temporary = Path(stream.name) + stream.write((json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode()) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, output) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def load_historical_source_continuity_recovery_plan(path: Path) -> HistoricalSourceContinuityRecoveryPlan: + try: + plan = HistoricalSourceContinuityRecoveryPlan.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery plan") from exc + _verify_plan(plan) + return plan + + +def _write_receipt(path: Path, receipt: HistoricalSourceContinuityRecoveryReceipt, *, expected: str | None) -> None: + _verify_receipt(receipt) + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + _real_directory(path.parent, label="historical continuity recovery receipt directory") + if path.exists(): + if load_historical_source_continuity_recovery_receipt(path).receipt_sha256 != expected: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt CAS state changed") + elif expected is not None: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt disappeared") + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream: + temporary = Path(stream.name) + stream.write((json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode()) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + temporary = None + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def load_historical_source_continuity_recovery_receipt(path: Path) -> HistoricalSourceContinuityRecoveryReceipt: + _real_file(path, label="historical continuity recovery receipt") + try: + receipt = HistoricalSourceContinuityRecoveryReceipt.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") from exc + _verify_receipt(receipt) + return receipt + + +def assert_no_prepared_historical_source_continuity_recovery(root: Path) -> None: + receipt_root = root / ".maintenance-state" / "historical-source-continuity-recoveries" + if not receipt_root.exists(): + return + _real_directory(receipt_root, label="historical continuity recovery receipt directory") + for path in sorted(receipt_root.glob("*.json")): + receipt = load_historical_source_continuity_recovery_receipt(path) + if receipt.state == "prepared": + raise HistoricalSourceContinuityRecoveryError( + "historical source continuity recovery is prepared but incomplete; rerun " + receipt.resume_command + ) + + +def _revalidate( + root: Path, plan: HistoricalSourceContinuityRecoveryPlan, *, stopped: str, writer: str +) -> DurableDatabaseEvidence: + if stopped != plan.stopped_daemon_evidence_ref or writer != plan.single_writer_evidence_ref: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery writer evidence changed") + if str(root) != plan.new_resolved_root: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery configured root changed") + old_source = Path(plan.old_resolved_root) / "source.db" + pre_receipt, _m, pre = _backup_source_evidence(Path(plan.pre_backup_manifest_path), old_source_path=old_source) + post_receipt, _m2, post = _backup_source_evidence(Path(plan.post_backup_manifest_path), old_source_path=old_source) + bindings = ( + (Path(plan.mutation_receipt_path), plan.mutation_receipt_sha256), + (Path(plan.pre_backup_manifest_path), plan.pre_backup_manifest_sha256), + (pre_receipt, plan.pre_backup_receipt_sha256), + (Path(plan.post_backup_manifest_path), plan.post_backup_manifest_sha256), + (post_receipt, plan.post_backup_receipt_sha256), + ) + if any(_sha256(path) != digest for path, digest in bindings): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery authority bytes changed") + count, digest = _legacy_liveness_receipt( + Path(plan.mutation_receipt_path), old_source_path=old_source, pre_manifest=Path(plan.pre_backup_manifest_path) + ) + if count != plan.legacy_candidate_count or digest != plan.legacy_candidate_digest: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery legacy receipt changed") + current = _current_evidence(root) + if not _evidence_matches_plan(current, plan.source_after) or current.content_sha256 != post.content_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery current source changed") + train = load_durable_change_train_manifest(Path(plan.source_train_path)) + if _sha256(Path(plan.source_train_path)) != plan.source_train_sha256 and train.source_continuity_evidence is None: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source train changed") + if train.source_continuity_evidence is None: + _assert_pre_train_authority(Path(plan.source_train_path), pre) + if _census(root) != plan.census: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery liveness census changed") + return current + + +def apply_historical_source_continuity_recovery( + *, + root: Path, + plan: HistoricalSourceContinuityRecoveryPlan, + authorization: str, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, +) -> HistoricalSourceContinuityRecoveryResult: + _verify_plan(plan) + if authorization != plan.plan_sha256 or plan.bound_confirmation != "historical-source-continuity-recovery": + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery authorization does not bind this plan" + ) + resolved = _real_directory(root, label="configured archive root") + _revalidate(resolved, plan, stopped=stopped_daemon_evidence_ref, writer=single_writer_evidence_ref) + planned_current = _evidence_from_plan(plan.source_after) + refresh_payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": plan.legacy_candidate_digest, + "evidence_ref": "proof:historical-source-continuity-recovery:" + plan.plan_sha256, + "backup_manifest": plan.pre_backup_manifest_path, + "backup_manifest_sha256": plan.pre_backup_manifest_sha256, + "mutation_receipt": plan.mutation_receipt_path, + "mutation_receipt_sha256": plan.mutation_receipt_sha256, + "train_id": load_durable_change_train_manifest(Path(plan.source_train_path)).train_id, + "source_before": plan.source_before, + "source_after": plan.source_after, + "refreshed_at_ms": planned_current.observed_at_ms, + "historical_bridge": { + "pre_backup": plan.pre_backup_manifest_sha256, + "post_backup": plan.post_backup_manifest_sha256, + "legacy_candidate_count": plan.legacy_candidate_count, + "legacy_candidate_digest": plan.legacy_candidate_digest, + "census": plan.census, + }, + } + refresh_digest = _canonical_json_sha256(refresh_payload) + refresh_path = _refresh_path(resolved, refresh_digest) + command = f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance source-continuity-recovery apply --plan --authorize {plan.plan_sha256} --output-format json" + receipt_path = _receipt_path(resolved, plan) + prepared = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=authorization, + train_before_sha256=plan.source_train_sha256, + train_after_sha256=None, + refresh_receipt_sha256=refresh_digest, + resume_command=command, + ) + if receipt_path.exists(): + receipt = load_historical_source_continuity_recovery_receipt(receipt_path) + if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery receipt belongs to another plan" + ) + if receipt.state == "committed": + train = load_durable_change_train_manifest(Path(plan.source_train_path)) + _validate_source_continuity_refresh_receipt(resolved, train) + return HistoricalSourceContinuityRecoveryResult( + state="committed", + plan_sha256=plan.plan_sha256, + receipt_path=str(receipt_path), + refresh_receipt_path=str(refresh_path), + ) + else: + _write_receipt(receipt_path, prepared, expected=None) + receipt = prepared + refresh_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + encoded = {**refresh_payload, "refresh_sha256": refresh_digest} + if refresh_path.exists(): + if json.loads(refresh_path.read_text(encoding="utf-8")) != encoded: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery refresh receipt collision") + else: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + dir=refresh_path.parent, prefix=f".{refresh_path.name}.", delete=False + ) as stream: + temporary = Path(stream.name) + stream.write((json.dumps(encoded, indent=2, sort_keys=True) + "\n").encode()) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, refresh_path) + temporary = None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + path = Path(plan.source_train_path) + train = load_durable_change_train_manifest(path) + if _sha256(path) == plan.source_train_sha256: + updated = recover_released_source_train_continuity( + train, current_evidence=planned_current, proof_ref="proof:source-continuity-refresh:" + refresh_digest + ) + write_durable_change_train_manifest(path, updated, expected_revision=plan.source_train_revision) + else: + _validate_source_continuity_refresh_receipt(resolved, train) + if train.source_continuity_evidence is None or not _evidence_matches_plan( + train.source_continuity_evidence, plan.source_after + ): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery source train is neither exact before nor after" + ) + committed = _sealed_receipt( + state="committed", + revision=1, + plan_sha256=plan.plan_sha256, + authorization=authorization, + train_before_sha256=plan.source_train_sha256, + train_after_sha256=_sha256(path), + refresh_receipt_sha256=refresh_digest, + resume_command=command, + ) + _write_receipt(receipt_path, committed, expected=receipt.receipt_sha256) + return HistoricalSourceContinuityRecoveryResult( + state="committed", + plan_sha256=plan.plan_sha256, + receipt_path=str(receipt_path), + refresh_receipt_path=str(refresh_path), + ) diff --git a/polylogue/storage/blob_ref_liveness.py b/polylogue/storage/blob_ref_liveness.py index 6942a08031..82f4328a00 100644 --- a/polylogue/storage/blob_ref_liveness.py +++ b/polylogue/storage/blob_ref_liveness.py @@ -330,7 +330,10 @@ def classify_blob_ref_liveness(conn: sqlite3.Connection) -> BlobRefLivenessClass """Return the complete candidate projection for read-only callers.""" staged = stage_blob_ref_liveness(conn, sample_limit=0) - candidates = tuple(staged.candidates(conn)) + # ``stage_blob_ref_liveness`` intentionally avoids creating a temp table + # for archives predating ``blob_refs``. That is still a valid empty + # read-only classification, not a reason to query the absent temp table. + candidates = tuple(staged.candidates(conn)) if staged.classification.candidate_count else () return BlobRefLivenessClassification( scanned_count=staged.classification.scanned_count, ref_type_counts=staged.classification.ref_type_counts, diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index d9298affdc..6f6b34b211 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -583,6 +583,41 @@ def rebind_released_source_train_archive_identity( return updated +def recover_released_source_train_continuity( + train: DurableChangeTrain, + *, + current_evidence: DurableDatabaseEvidence, + proof_ref: str, +) -> DurableChangeTrain: + """Bind one released source train to separately authenticated current bytes. + + This is deliberately narrower than the ordinary liveness refresh: callers + must have already authenticated a historical mutation bridge. It never + accepts a legacy receipt itself and only revises the current released + source train's identity/evidence authority. + """ + if train.tier is not ArchiveTier.SOURCE or train.state is not DurableChangeTrainState.RELEASED: + raise DurableChangeTrainError("historical continuity recovery requires a released source train") + if train.apply_evidence is None: + raise DurableChangeTrainError("historical continuity recovery requires source train apply evidence") + if current_evidence.tier is not ArchiveTier.SOURCE or current_evidence.user_version != train.target_version: + raise DurableChangeTrainError("historical continuity recovery has the wrong live source schema") + if current_evidence.quick_check != ("ok",): + raise DurableChangeTrainError("historical continuity recovery requires successful source quick_check") + if current_evidence.schema_inventory_sha256 != train.apply_evidence.post.schema_inventory_sha256: + raise DurableChangeTrainError("historical continuity recovery changed the released source schema") + post = replace(train.apply_evidence.post, archive_identity_digest=current_evidence.archive_identity_digest) + updated = replace( + train, + revision=train.revision + 1, + apply_evidence=replace(train.apply_evidence, post=post), + source_continuity_evidence=current_evidence, + proof_refs=_migration_runner._append_proof_refs(train.proof_refs, proof_ref), + ) + validate_durable_change_train_manifest(updated) + return updated + + def write_source_continuity_pending_intent( archive_root: Path, *, @@ -2332,8 +2367,12 @@ def _reconcile_durable_change_train_startup_locked( """Reconcile persisted trains while the caller holds archive ownership.""" from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + from polylogue.operations.historical_source_continuity_recovery import ( + assert_no_prepared_historical_source_continuity_recovery, + ) assert_no_prepared_archive_root_relocation(archive_root) + assert_no_prepared_historical_source_continuity_recovery(archive_root) validate_audit_adoption_receipt(archive_root) deferred_tiers = _recover_pending_source_continuity_intents(archive_root) manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 76cd3c1e61..8f136b9a73 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import sqlite3 from dataclasses import replace @@ -17,6 +18,11 @@ apply_archive_root_relocation, prepare_archive_root_relocation, ) +from polylogue.operations.historical_source_continuity_recovery import ( + HistoricalSourceContinuityRecoveryError, + apply_historical_source_continuity_recovery, + load_historical_source_continuity_recovery_plan, +) from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, @@ -43,6 +49,13 @@ def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict assert result.exit_code == 0, result.output assert "inode-preserving" in result.output + nested = CliRunner().invoke( + cli, + ["--plain", "ops", "maintenance", "archive-root-relocation", "plan", "--help"], + catch_exceptions=False, + ) + assert nested.exit_code == 0, nested.output + assert "--old-root" in nested.output def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( @@ -178,6 +191,167 @@ def _released_moved_source_train(root: Path, monkeypatch: pytest.MonkeyPatch) -> return manifest +def _legacy_zero_candidate_receipt(path: Path, *, old_root: Path, pre_manifest: Path) -> None: + """Encode the exact pre-#3868 shape: no backup digest or postcondition field.""" + path.write_text( + json.dumps( + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "prepared", + "source_db": str(old_root / "source.db"), + "backup_manifest": str(pre_manifest), + "candidate_count": 0, + "candidate_digest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + } + ) + + "\n" + + json.dumps( + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "committed", + "deleted_count": 0, + } + ) + + "\n", + encoding="utf-8", + ) + + +def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Exercise old-path HMACs, backup bytes, train CAS, census, and ordinary verification. + + This is deliberately file-backed: deleting the recovery identity rewrite, + swapping either backup, or changing the receipt's old path makes the real + plan/apply route fail before the train manifest can be written. + """ + from polylogue.storage.sqlite import durable_change_train as trains + + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + pre_backup = backup_archive(output_dir=tmp_path / "pre", profile="rebuildable_cache_exclude", verify=True) + post_backup = backup_archive(output_dir=tmp_path / "post", profile="rebuildable_cache_exclude", verify=True) + assert pre_backup.ok and pre_backup.output_path is not None + assert post_backup.ok and post_backup.output_path is not None + pre_manifest = Path(pre_backup.output_path) / "manifest.json" + post_manifest = Path(post_backup.output_path) / "manifest.json" + legacy_receipt = tmp_path / "legacy-liveness.jsonl" + _legacy_zero_candidate_receipt(legacy_receipt, old_root=old_root, pre_manifest=pre_manifest) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + moved_manifest = new_root / manifest.relative_to(old_root) + database_before = { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + + plan_path = tmp_path / "continuity-plan.json" + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(new_root)} + plan_result = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(old_root), + "--mutation-receipt", + str(legacy_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert plan_result.exit_code == 0, plan_result.output + plan = load_historical_source_continuity_recovery_plan(plan_path) + assert database_before == { + path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") + } + with sqlite3.connect(new_root / "source.db") as connection: + with pytest.raises(Exception, match="continuity proof failed"): + trains._verify_released_train_live_tier( + new_root, + connection, + trains.load_durable_change_train_manifest(moved_manifest), + ) + + with monkeypatch.context() as scoped: + scoped.setattr( + "polylogue.operations.historical_source_continuity_recovery.recover_released_source_train_continuity", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("crash after prepared receipt")), + ) + with pytest.raises(RuntimeError, match="crash after prepared"): + apply_historical_source_continuity_recovery( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + trains.reconcile_durable_change_train_startup(new_root) + + result = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan.plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["state"] == "committed" + # The second apply is the crash-recovery/idempotency path, not a second revision. + replay = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan.plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert replay.exit_code == 0, replay.output + assert json.loads(replay.output)["state"] == "committed" + with sqlite3.connect(new_root / "source.db") as connection: + assert ( + trains._verify_released_train_live_tier( + new_root, + connection, + trains.load_durable_change_train_manifest(moved_manifest), + ) + is None + ) + + def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_crash( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/storage/test_blob_ref_liveness.py b/tests/unit/storage/test_blob_ref_liveness.py index 0bc86b2b6b..bec8aec487 100644 --- a/tests/unit/storage/test_blob_ref_liveness.py +++ b/tests/unit/storage/test_blob_ref_liveness.py @@ -37,6 +37,18 @@ ) +def test_classify_missing_blob_refs_is_an_empty_read_only_result(tmp_path: Path) -> None: + """The public classifier must support old source schemas with no blob_refs table.""" + database = tmp_path / "old-source.db" + with sqlite3.connect(database) as connection: + connection.execute("CREATE TABLE legacy_items (id TEXT PRIMARY KEY)") + classification = classify_blob_ref_liveness(connection) + + assert classification.scanned_count == 0 + assert classification.orphaned_count == 0 + assert classification.candidates == () + + def _source_archive(tmp_path: Path) -> Path: archive_root = tmp_path / "archive" initialize_active_archive_root(archive_root) From 903a7555035cf32b1b4634df6f05beccc0b01195 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 21:51:00 +0200 Subject: [PATCH 09/39] fix: prove complete historical source delta --- .../historical_source_continuity_recovery.py | 50 +++++++++++++++++ .../storage/test_archive_root_relocation.py | 55 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index ca66f8ff83..2aae572d65 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -43,6 +43,7 @@ from polylogue.storage.sqlite.migration_runner import ( DurableDatabaseEvidence, capture_durable_database_evidence, + capture_durable_schema_inventory, ) PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v1"] = ( @@ -377,6 +378,52 @@ def _blob_ref_rows_digest(connection: sqlite3.Connection, *, excluded: set[tuple return count, digest.hexdigest() +def _table_content_digest(connection: sqlite3.Connection, table: str) -> tuple[int, str]: + """Stream a deterministic typed row digest without materializing a table.""" + quoted = '"' + table.replace('"', '""') + '"' + columns = [str(row[1]) for row in connection.execute(f"PRAGMA table_xinfo({quoted})") if int(row[6]) == 0] + if not columns: + raise HistoricalSourceContinuityRecoveryError(f"source table has no readable columns: {table}") + quoted_columns = ['"' + column.replace('"', '""') + '"' for column in columns] + digest = hashlib.sha256() + count = 0 + try: + for row in connection.execute( + f"SELECT {', '.join(quoted_columns)} FROM {quoted} ORDER BY {', '.join(quoted_columns)}" + ): + encoded = [value.hex() if isinstance(value, bytes) else value for value in row] + digest.update(json.dumps(encoded, separators=(",", ":"), ensure_ascii=True).encode() + b"\n") + count += 1 + except (sqlite3.Error, TypeError) as exc: + raise HistoricalSourceContinuityRecoveryError(f"cannot deterministically read source table: {table}") from exc + return count, digest.hexdigest() + + +def _assert_complete_source_semantic_delta(pre_source: Path, post_source: Path) -> None: + """Require every non-blob-ref schema object and relation to be identical.""" + try: + with ( + sqlite3.connect(f"file:{pre_source}?mode=ro&immutable=1", uri=True) as pre, + sqlite3.connect(f"file:{post_source}?mode=ro&immutable=1", uri=True) as post, + ): + if capture_durable_schema_inventory(pre) != capture_durable_schema_inventory(post): + raise HistoricalSourceContinuityRecoveryError("pre/post backups have source schema or object drift") + tables = [ + str(row[0]) + for row in pre.execute( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + if str(row[0]) != "blob_refs" + ] + for table in tables: + if _table_content_digest(pre, table) != _table_content_digest(post, table): + raise HistoricalSourceContinuityRecoveryError( + f"post-mutation backup changed non-blob-ref source table: {table}" + ) + except sqlite3.Error as exc: + raise HistoricalSourceContinuityRecoveryError("cannot compare complete pre/post source authority") from exc + + def _assert_exact_liveness_delta( pre_source: Path, post_source: Path, candidates: tuple[BlobRefLivenessCandidate, ...] ) -> None: @@ -516,6 +563,9 @@ def prepare_historical_source_continuity_recovery( post_backup_manifest.parent / "source.db", prior.candidates, ) + _assert_complete_source_semantic_delta( + pre_backup_manifest.parent / "source.db", post_backup_manifest.parent / "source.db" + ) current = _current_evidence(root) current_path = root / "source.db" if ( diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 8f136b9a73..6a68189bc4 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -20,9 +20,12 @@ ) from polylogue.operations.historical_source_continuity_recovery import ( HistoricalSourceContinuityRecoveryError, + _assert_complete_source_semantic_delta, + _assert_exact_liveness_delta, apply_historical_source_continuity_recovery, load_historical_source_continuity_recovery_plan, ) +from polylogue.storage.blob_ref_liveness import BlobRefLivenessCandidate from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, @@ -217,6 +220,58 @@ def _legacy_zero_candidate_receipt(path: Path, *, old_root: Path, pre_manifest: ) +def _write_liveness_delta_database(path: Path, *, keep_body: str = "kept", include_candidate: bool = True) -> None: + with sqlite3.connect(path) as connection: + connection.executescript( + """ + CREATE TABLE raw_sessions (raw_id TEXT PRIMARY KEY, body TEXT NOT NULL); + CREATE TABLE unrelated_authority (id TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE blob_refs ( + blob_hash BLOB NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, + source_path TEXT, size_bytes INTEGER NOT NULL, acquired_at_ms INTEGER NOT NULL, + PRIMARY KEY (blob_hash, ref_type, ref_id) + ) STRICT; + """ + ) + connection.execute("INSERT INTO raw_sessions VALUES ('live', ?)", (keep_body,)) + connection.execute("INSERT INTO unrelated_authority VALUES ('stable', 'unchanged')") + connection.execute("INSERT INTO blob_refs VALUES (X'01', 'attachment', 'live', NULL, 1, 1)") + if include_candidate: + connection.execute("INSERT INTO blob_refs VALUES (X'02', 'attachment', 'deleted', NULL, 2, 2)") + + +def test_historical_liveness_delta_requires_exact_deletion_and_no_other_source_mutation(tmp_path: Path) -> None: + """The bridge permits one enumerated orphan deletion, not a broad backup-to-backup rewrite.""" + pre = tmp_path / "pre.db" + post = tmp_path / "post.db" + _write_liveness_delta_database(pre) + _write_liveness_delta_database(post, include_candidate=False) + candidate = BlobRefLivenessCandidate( + blob_hash="02", + ref_type="attachment", + ref_id="deleted", + source_path=None, + size_bytes=2, + acquired_at_ms=2, + referent_table="raw_sessions", + referent_column="raw_id", + ) + _assert_exact_liveness_delta(pre, post, (candidate,)) + _assert_complete_source_semantic_delta(pre, post) + + changed_table = tmp_path / "changed-table.db" + _write_liveness_delta_database(changed_table, keep_body="tampered", include_candidate=False) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="non-blob-ref"): + _assert_complete_source_semantic_delta(pre, changed_table) + + wrong_blob_set = tmp_path / "wrong-blob-set.db" + _write_liveness_delta_database(wrong_blob_set, include_candidate=False) + with sqlite3.connect(wrong_blob_set) as connection: + connection.execute("INSERT INTO blob_refs VALUES (X'03', 'attachment', 'extra', NULL, 3, 3)") + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="beyond the historical candidates"): + _assert_exact_liveness_delta(pre, wrong_blob_set, (candidate,)) + + def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 69715f94d3c50dbd1eefa0d80ee06cda9a0fc586 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 22:32:23 +0200 Subject: [PATCH 10/39] fix: harden historical continuity receipts --- .../historical_source_continuity_recovery.py | 129 +++++++++++++++--- .../storage/test_archive_root_relocation.py | 40 ++++++ 2 files changed, 149 insertions(+), 20 deletions(-) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 2aae572d65..9f5a7769be 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -20,7 +20,12 @@ from pydantic import BaseModel, ConfigDict +from polylogue.config import Config +from polylogue.daemon.write_coordinator import daemon_write_lease_active from polylogue.maintenance.blob_ref_liveness_reconciliation import census_blob_ref_liveness +from polylogue.maintenance.offline_guard import running_daemon_pid +from polylogue.paths import render_root +from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation from polylogue.storage.backup_attestation import BackupAttestationError, verify_verification_receipt from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, @@ -391,7 +396,7 @@ def _table_content_digest(connection: sqlite3.Connection, table: str) -> tuple[i for row in connection.execute( f"SELECT {', '.join(quoted_columns)} FROM {quoted} ORDER BY {', '.join(quoted_columns)}" ): - encoded = [value.hex() if isinstance(value, bytes) else value for value in row] + encoded = [_typed_sqlite_value(value) for value in row] digest.update(json.dumps(encoded, separators=(",", ":"), ensure_ascii=True).encode() + b"\n") count += 1 except (sqlite3.Error, TypeError) as exc: @@ -399,6 +404,25 @@ def _table_content_digest(connection: sqlite3.Connection, table: str) -> tuple[i return count, digest.hexdigest() +def _typed_sqlite_value(value: object) -> list[str]: + """Encode SQLite's storage class as well as its visible value.""" + if value is None: + return ["null", ""] + if isinstance(value, bool): + return ["integer", "1" if value else "0"] + if isinstance(value, int): + return ["integer", str(value)] + if isinstance(value, float): + return ["real", value.hex()] + if isinstance(value, str): + return ["text", value] + if isinstance(value, bytes): + return ["blob", value.hex()] + raise HistoricalSourceContinuityRecoveryError( + f"source table returned unsupported SQLite value type: {type(value)!r}" + ) + + def _assert_complete_source_semantic_delta(pre_source: Path, post_source: Path) -> None: """Require every non-blob-ref schema object and relation to be identical.""" try: @@ -522,6 +546,64 @@ def _refresh_path(root: Path, digest: str) -> Path: return root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json" +def _require_offline_ownership_boundary(root: Path) -> None: + """Make offline authority real for callers that bypass the Click adapter.""" + if daemon_write_lease_active(): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery is offline-only and cannot run under a daemon writer lease" + ) + if (pid := running_daemon_pid(Config(archive_root=root, render_root=render_root(), sources=[]))) is not None: + raise HistoricalSourceContinuityRecoveryError( + f"historical continuity recovery requires the daemon to be stopped; live pidfile PID: {pid}" + ) + + +def _write_refresh_receipt(path: Path, payload: dict[str, object]) -> None: + """Publish a retained receipt beneath a pinned, non-symlink directory.""" + state_root = _real_directory(path.parent.parent, label="maintenance state") + refresh_root = state_root / path.parent.name + if refresh_root.exists() or refresh_root.is_symlink(): + _real_directory(refresh_root, label="source continuity refresh receipt directory") + else: + refresh_root.mkdir(mode=0o700) + descriptor = os.open(state_root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + _real_directory(refresh_root, label="source continuity refresh receipt directory") + receipt_path = refresh_root / path.name + if receipt_path.exists() or receipt_path.is_symlink(): + _real_file(receipt_path, label="source continuity refresh receipt") + try: + if json.loads(receipt_path.read_text(encoding="utf-8")) != payload: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery refresh receipt collision" + ) + except (OSError, json.JSONDecodeError) as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery refresh receipt is unreadable" + ) from exc + return + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(dir=refresh_root, prefix=f".{receipt_path.name}.", delete=False) as stream: + temporary = Path(stream.name) + stream.write((json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, receipt_path) + temporary = None + descriptor = os.open(refresh_root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + def prepare_historical_source_continuity_recovery( *, old_root: Path, @@ -750,6 +832,31 @@ def apply_historical_source_continuity_recovery( authorization: str, stopped_daemon_evidence_ref: str, single_writer_evidence_ref: str, +) -> HistoricalSourceContinuityRecoveryResult: + """Acquire archive ownership before the API can publish receipts or a CAS revision.""" + resolved = _real_directory(root, label="configured archive root") + _require_offline_ownership_boundary(resolved) + with OwnedArchiveLocation.acquire( + ArchiveLocation.resolve(resolved), + owner_id=f"historical-source-continuity-recovery:{os.getpid()}", + allow_reentrant=True, + ): + return _apply_historical_source_continuity_recovery_locked( + root=resolved, + plan=plan, + authorization=authorization, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + single_writer_evidence_ref=single_writer_evidence_ref, + ) + + +def _apply_historical_source_continuity_recovery_locked( + *, + root: Path, + plan: HistoricalSourceContinuityRecoveryPlan, + authorization: str, + stopped_daemon_evidence_ref: str, + single_writer_evidence_ref: str, ) -> HistoricalSourceContinuityRecoveryResult: _verify_plan(plan) if authorization != plan.plan_sha256 or plan.bound_confirmation != "historical-source-continuity-recovery": @@ -811,26 +918,8 @@ def apply_historical_source_continuity_recovery( else: _write_receipt(receipt_path, prepared, expected=None) receipt = prepared - refresh_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) encoded = {**refresh_payload, "refresh_sha256": refresh_digest} - if refresh_path.exists(): - if json.loads(refresh_path.read_text(encoding="utf-8")) != encoded: - raise HistoricalSourceContinuityRecoveryError("historical continuity recovery refresh receipt collision") - else: - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile( - dir=refresh_path.parent, prefix=f".{refresh_path.name}.", delete=False - ) as stream: - temporary = Path(stream.name) - stream.write((json.dumps(encoded, indent=2, sort_keys=True) + "\n").encode()) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, refresh_path) - temporary = None - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) + _write_refresh_receipt(refresh_path, encoded) path = Path(plan.source_train_path) train = load_durable_change_train_manifest(path) if _sha256(path) == plan.source_train_sha256: diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 6a68189bc4..804f04c7bc 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -22,6 +22,8 @@ HistoricalSourceContinuityRecoveryError, _assert_complete_source_semantic_delta, _assert_exact_liveness_delta, + _table_content_digest, + _write_refresh_receipt, apply_historical_source_continuity_recovery, load_historical_source_continuity_recovery_plan, ) @@ -272,6 +274,30 @@ def test_historical_liveness_delta_requires_exact_deletion_and_no_other_source_m _assert_exact_liveness_delta(pre, wrong_blob_set, (candidate,)) +def test_historical_source_delta_tags_sqlite_storage_classes_and_rejects_refresh_symlinks(tmp_path: Path) -> None: + """A non-STRICT BLOB/TEXT swap and a symlinked receipt directory are both unsafe.""" + typed = tmp_path / "typed.db" + with sqlite3.connect(typed) as connection: + connection.execute("CREATE TABLE values_table (value)") + connection.execute("INSERT INTO values_table VALUES (?)", ("01",)) + text_digest = _table_content_digest(connection, "values_table") + connection.execute("UPDATE values_table SET value = X'01'") + blob_digest = _table_content_digest(connection, "values_table") + assert text_digest != blob_digest + + root = tmp_path / "archive" + state = root / ".maintenance-state" + state.mkdir(parents=True) + target = tmp_path / "outside" + target.mkdir() + (state / "source-continuity-refreshes").symlink_to(target, target_is_directory=True) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="not a real directory"): + _write_refresh_receipt( + state / "source-continuity-refreshes" / ("a" * 64 + ".json"), + {"refresh_sha256": "a" * 64}, + ) + + def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -328,6 +354,20 @@ def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( ) assert plan_result.exit_code == 0, plan_result.output plan = load_historical_source_continuity_recovery_plan(plan_path) + with monkeypatch.context() as scoped: + scoped.setattr( + "polylogue.operations.historical_source_continuity_recovery.running_daemon_pid", + lambda _config: 4242, + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="daemon to be stopped"): + apply_historical_source_continuity_recovery( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert not (new_root / ".maintenance-state" / "historical-source-continuity-recoveries").exists() assert database_before == { path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") } From d011f2bba48779434b788509030bbb18adf25af9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 23:35:41 +0200 Subject: [PATCH 11/39] fix: harden archive relocation proof boundaries Authenticate pre-move root and tier inode evidence through verified backup receipts and retain the complete closed-package validator. Make relocation apply acquire its own offline ownership boundary, and publish relocation and continuity receipts through descriptor-pinned maintenance-state directories. --- .../maintenance/_archive_root_relocation.py | 16 +- polylogue/daemon/backup.py | 21 +- polylogue/maintenance/offline_guard.py | 14 +- .../operations/_maintenance_receipt_fs.py | 132 ++++++++++ .../operations/archive_root_relocation.py | 236 ++++++++++++++---- .../historical_source_continuity_recovery.py | 159 ++++++------ polylogue/storage/sqlite/migration_runner.py | 184 ++++++++------ 7 files changed, 540 insertions(+), 222 deletions(-) create mode 100644 polylogue/operations/_maintenance_receipt_fs.py diff --git a/polylogue/cli/commands/maintenance/_archive_root_relocation.py b/polylogue/cli/commands/maintenance/_archive_root_relocation.py index 21ec7234a7..56e3220d4d 100644 --- a/polylogue/cli/commands/maintenance/_archive_root_relocation.py +++ b/polylogue/cli/commands/maintenance/_archive_root_relocation.py @@ -68,20 +68,14 @@ def archive_root_relocation_plan_command( @click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) def archive_root_relocation_apply_command(plan_path: Path, authorize: str, output_format: str) -> None: """Apply the plan by CAS-revising released source manifests only.""" - from polylogue.cli.commands.maintenance._migrate_tier import _require_stopped_daemon - root = archive_root() try: plan = load_archive_root_relocation_plan(plan_path) - with acquire_durable_archive_ownership(root, owner_id=f"archive-root-relocation-apply:{os.getpid()}"): - stopped = _require_stopped_daemon(root) - result = apply_archive_root_relocation( - root=root, - plan=plan, - authorization=authorize, - stopped_daemon_evidence_ref=stopped, - single_writer_evidence_ref="proof:archive-ownership-lock", - ) + result = apply_archive_root_relocation( + root=root, + plan=plan, + authorization=authorize, + ) except (ArchiveRootRelocationError, OSError) as exc: raise click.ClickException(str(exc)) from exc if output_format == "json": diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index d84be55f16..2da3a8aa0d 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -185,14 +185,30 @@ def _sqlite_user_version(path: Path) -> int: def _sqlite_source_fingerprint(path: Path) -> dict[str, object]: + metadata = path.stat() return { "path": str(path), - "size_bytes": path.stat().st_size, + "device": metadata.st_dev, + "inode": metadata.st_ino, + "size_bytes": metadata.st_size, "sha256": _sha256_file(path), "user_version": _sqlite_user_version(path), } +def _archive_root_source_identity(root: Path) -> dict[str, object]: + """Capture the pre-move directory identity later authenticated by the receipt.""" + configured = root.absolute() + resolved = root.resolve(strict=True) + metadata = resolved.stat() + return { + "configured_path": str(configured), + "resolved_path": str(resolved), + "device": metadata.st_dev, + "inode": metadata.st_ino, + } + + def _json_str_list(value: object) -> list[str]: return [str(item) for item in value] if isinstance(value, list) else [] @@ -471,6 +487,7 @@ def _write_manifest( blob_count: int, blob_size: int, warnings: list[str], + archive_root_source_identity: dict[str, object], tier_source_fingerprints: dict[str, dict[str, object]], blob_reference_debt: BlobReferenceDebtReport | None = None, ) -> None: @@ -485,6 +502,7 @@ def _write_manifest( "blob_count": blob_count, "blob_size_bytes": blob_size, "blob_inventory_file": "blob-inventory.json", + "archive_root_source_identity": archive_root_source_identity, "tier_source_fingerprints": tier_source_fingerprints, "warnings": warnings, } @@ -604,6 +622,7 @@ def _backup_archive(*, output_dir: Path, started: float, profile: BackupProfile) blob_count=blob_count, blob_size=blob_size, warnings=warnings, + archive_root_source_identity=_archive_root_source_identity(root), tier_source_fingerprints=tier_source_fingerprints, blob_reference_debt=blob_reference_debt, ) diff --git a/polylogue/maintenance/offline_guard.py b/polylogue/maintenance/offline_guard.py index 174a03b085..d8066612d5 100644 --- a/polylogue/maintenance/offline_guard.py +++ b/polylogue/maintenance/offline_guard.py @@ -26,6 +26,18 @@ def running_daemon_pid(config: Config) -> int | None: return pid if b"polylogued" in cmdline else None +def offline_writer_block_reason(config: Config) -> str | None: + """Return the concrete writer that makes a strictly offline operation unsafe.""" + from polylogue.daemon.write_coordinator import daemon_write_lease_active + + if daemon_write_lease_active(): + return "a daemon writer lease is active" + daemon_pid = running_daemon_pid(config) + if daemon_pid is not None: + return f"live pidfile PID {daemon_pid} is running" + return None + + def offline_maintenance_block_reason( config: Config, *, @@ -51,4 +63,4 @@ def offline_maintenance_block_reason( ) -__all__ = ["offline_maintenance_block_reason", "running_daemon_pid"] +__all__ = ["offline_maintenance_block_reason", "offline_writer_block_reason", "running_daemon_pid"] diff --git a/polylogue/operations/_maintenance_receipt_fs.py b/polylogue/operations/_maintenance_receipt_fs.py new file mode 100644 index 0000000000..fdada387ac --- /dev/null +++ b/polylogue/operations/_maintenance_receipt_fs.py @@ -0,0 +1,132 @@ +"""Descriptor-pinned publication for retained offline-maintenance receipts.""" + +from __future__ import annotations + +import os +import stat +import uuid +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from pathlib import Path + + +class MaintenanceReceiptPathError(RuntimeError): + """A maintenance-state path could not be pinned without following links.""" + + +_DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def _simple_name(value: str, *, label: str) -> str: + if not value or value in {".", ".."} or Path(value).name != value: + raise MaintenanceReceiptPathError(f"{label} is not a canonical single path component: {value!r}") + return value + + +def _open_directory(path: Path, *, label: str) -> int: + try: + descriptor = os.open(path, _DIRECTORY_FLAGS) + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot pin {label} without following links: {path}") from exc + if not stat.S_ISDIR(os.fstat(descriptor).st_mode): + os.close(descriptor) + raise MaintenanceReceiptPathError(f"{label} is not a real directory: {path}") + return descriptor + + +def _open_directory_at(parent_fd: int, name: str, *, label: str) -> int: + try: + descriptor = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot pin {label} without following links: {name}") from exc + if not stat.S_ISDIR(os.fstat(descriptor).st_mode): + os.close(descriptor) + raise MaintenanceReceiptPathError(f"{label} is not a real directory: {name}") + return descriptor + + +@contextmanager +def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> Iterator[int]: + """Yield a pinned child of an existing, non-symlink ``.maintenance-state``.""" + child_name = _simple_name(directory_name, label="maintenance receipt directory name") + root_fd = _open_directory(archive_root, label="archive root") + state_fd = -1 + child_fd = -1 + try: + state_fd = _open_directory_at(root_fd, ".maintenance-state", label="maintenance state") + try: + child_fd = os.open(child_name, _DIRECTORY_FLAGS, dir_fd=state_fd) + except FileNotFoundError: + with suppress(FileExistsError): + os.mkdir(child_name, mode=0o700, dir_fd=state_fd) + os.fsync(state_fd) + child_fd = _open_directory_at(state_fd, child_name, label="maintenance receipt directory") + except OSError as exc: + raise MaintenanceReceiptPathError( + f"cannot pin maintenance receipt directory without following links: {child_name}" + ) from exc + if not stat.S_ISDIR(os.fstat(child_fd).st_mode): + raise MaintenanceReceiptPathError(f"maintenance receipt directory is not real: {child_name}") + yield child_fd + finally: + if child_fd >= 0: + os.close(child_fd) + if state_fd >= 0: + os.close(state_fd) + os.close(root_fd) + + +def read_optional_receipt(directory_fd: int, filename: str) -> bytes | None: + """Read one regular, single-linked receipt relative to a pinned directory.""" + name = _simple_name(filename, label="maintenance receipt filename") + try: + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory_fd) + except FileNotFoundError: + return None + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot open maintenance receipt without following links: {name}") from exc + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise MaintenanceReceiptPathError(f"maintenance receipt is not a regular single-linked file: {name}") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + return stream.read() + finally: + os.close(descriptor) + + +def atomic_replace_receipt(directory_fd: int, filename: str, payload: bytes) -> None: + """Fsync and atomically replace one file within a pinned receipt directory.""" + name = _simple_name(filename, label="maintenance receipt filename") + temporary = f".{name}.{uuid.uuid4().hex}.tmp" + descriptor = -1 + try: + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + dir_fd=directory_fd, + ) + with os.fdopen(descriptor, "wb", closefd=False) as stream: + stream.write(payload) + stream.flush() + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot atomically publish maintenance receipt: {name}") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + with suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory_fd) + + +__all__ = [ + "MaintenanceReceiptPathError", + "atomic_replace_receipt", + "maintenance_receipt_directory", + "read_optional_receipt", +] diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 262a3ec86b..9ffd7c2672 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -13,7 +13,21 @@ from pydantic import BaseModel, ConfigDict -from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation +from polylogue.config import Config +from polylogue.maintenance.offline_guard import offline_writer_block_reason +from polylogue.operations._maintenance_receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + maintenance_receipt_directory, + read_optional_receipt, +) +from polylogue.paths import render_root +from polylogue.storage.archive_identity import ( + ArchiveIdentity, + ArchiveLocation, + ArchiveOwnershipError, + OwnedArchiveLocation, +) from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, @@ -51,6 +65,8 @@ class RelocationTierEvidence(BaseModel): tier: str configured_path: str resolved_path: str + old_device: int + old_inode: int device: int inode: int size_bytes: int @@ -79,6 +95,8 @@ class ArchiveRootRelocationPlan(BaseModel): format: Literal["polylogue.archive-root-relocation-plan.v1"] = PLAN_FORMAT old_configured_root: str old_resolved_root: str + old_root_device: int + old_root_inode: int new_configured_root: str new_resolved_root: str new_root_device: int @@ -191,7 +209,13 @@ def _reject_sidecars(root: Path) -> None: raise ArchiveRootRelocationError(f"archive-root relocation refuses SQLite sidecar: {path}") -def _tier_snapshot(root: Path, tier: ArchiveTier) -> RelocationTierEvidence: +def _tier_snapshot( + root: Path, + tier: ArchiveTier, + *, + old_device: int, + old_inode: int, +) -> RelocationTierEvidence: location = ArchiveLocation.resolve(root) identity = location.active_tier(tier.value) path = identity.configured_path @@ -224,6 +248,8 @@ def _tier_snapshot(root: Path, tier: ArchiveTier) -> RelocationTierEvidence: tier=tier.value, configured_path=str(path.absolute()), resolved_path=str(resolved_path), + old_device=old_device, + old_inode=old_inode, device=metadata.st_dev, inode=metadata.st_ino, size_bytes=metadata.st_size, @@ -308,6 +334,26 @@ def _source_trains( return tuple(trains) +def _authenticated_identity(payload: object, *, label: str) -> tuple[int, int]: + if not isinstance(payload, dict): + raise ArchiveRootRelocationError(f"backup lacks authenticated {label} identity") + device = payload.get("device") + inode = payload.get("inode") + if type(device) is not int or type(inode) is not int: + raise ArchiveRootRelocationError(f"backup lacks authenticated {label} device/inode") + return device, inode + + +def _authenticated_old_tier_identities(manifest: dict[str, object]) -> dict[str, tuple[int, int]]: + fingerprints = manifest.get("tier_source_fingerprints") + if not isinstance(fingerprints, dict): + raise ArchiveRootRelocationError("backup lacks authenticated tier identity inventory") + return { + tier.value: _authenticated_identity(fingerprints.get(f"{tier.value}.db"), label=f"{tier.value} tier") + for tier in ArchiveTier + } + + def _check_backup_against_live( root: Path, *, @@ -326,7 +372,13 @@ def _check_backup_against_live( artifact = by_tier.get(snapshot.tier) if not isinstance(fingerprint, dict) or not isinstance(artifact, dict): raise ArchiveRootRelocationError(f"backup lacks {filename} evidence") - fields = {"size_bytes": snapshot.size_bytes, "sha256": snapshot.sha256, "user_version": snapshot.user_version} + fields = { + "device": snapshot.old_device, + "inode": snapshot.old_inode, + "size_bytes": snapshot.size_bytes, + "sha256": snapshot.sha256, + "user_version": snapshot.user_version, + } if any(fingerprint.get(key) != value for key, value in fields.items()): raise ArchiveRootRelocationError(f"backup bytes/version differ from relocated {filename}") artifact_fingerprint = artifact.get("source_fingerprint") @@ -334,6 +386,10 @@ def _check_backup_against_live( artifact_fingerprint.get(key) != value for key, value in fields.items() ): raise ArchiveRootRelocationError(f"backup receipt differs from relocated {filename}") + if snapshot.old_inode != snapshot.inode: + raise ArchiveRootRelocationError( + f"archive-root relocation requires inode continuity for {filename}; a copied tier is not accepted" + ) _reject_sidecars(root) @@ -355,11 +411,25 @@ def prepare_archive_root_relocation( _reject_sidecars(new_resolved) try: manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( - backup_manifest, old_archive_root=old_resolved + backup_manifest, + old_configured_root=old_configured, + old_archive_root=old_resolved, ) except MigrationError as exc: raise ArchiveRootRelocationError(str(exc)) from exc - snapshots = tuple(_tier_snapshot(new_resolved, tier) for tier in ArchiveTier) + old_root_device, old_root_inode = _authenticated_identity( + manifest.get("archive_root_source_identity"), label="archive root" + ) + old_tier_identities = _authenticated_old_tier_identities(manifest) + snapshots = tuple( + _tier_snapshot( + new_resolved, + tier, + old_device=old_tier_identities[tier.value][0], + old_inode=old_tier_identities[tier.value][1], + ) + for tier in ArchiveTier + ) _check_backup_against_live(new_resolved, manifest=manifest, receipt=receipt, snapshots=snapshots) location_identity = ArchiveIdentity.resolve_location(ArchiveLocation.resolve(new_resolved)) source_identity_digest = hashlib.sha256(location_identity.tier("source").stable_id.encode()).hexdigest() @@ -372,9 +442,15 @@ def prepare_archive_root_relocation( after_identity_digest=source_identity_digest, ) root_metadata = new_resolved.stat() + if old_root_inode != root_metadata.st_ino: + raise ArchiveRootRelocationError( + "archive-root relocation requires root inode continuity; a copied archive root is not accepted" + ) return _sealed_plan( old_configured_root=str(old_configured), old_resolved_root=str(old_resolved), + old_root_device=old_root_device, + old_root_inode=old_root_inode, new_configured_root=str(new_configured), new_resolved_root=str(new_resolved), new_root_device=root_metadata.st_dev, @@ -421,41 +497,57 @@ def _receipt_path(root: Path, plan: ArchiveRootRelocationPlan) -> Path: return root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json" +def _receipt_directory_binding(path: Path) -> tuple[Path, str]: + state_root = path.parent.parent + if state_root.name != ".maintenance-state" or path.suffix != ".json": + raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt path: {path}") + return state_root.parent, path.parent.name + + +def _decode_receipt(encoded: bytes, *, path: Path) -> ArchiveRootRelocationReceipt: + try: + receipt = ArchiveRootRelocationReceipt.model_validate_json(encoded) + except ValueError as exc: + raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") from exc + _verify_receipt(receipt) + return receipt + + +def _load_receipt_for_update(path: Path) -> ArchiveRootRelocationReceipt | None: + root, directory_name = _receipt_directory_binding(path) + try: + with maintenance_receipt_directory(root, directory_name) as directory_fd: + encoded = read_optional_receipt(directory_fd, path.name) + except MaintenanceReceiptPathError as exc: + raise ArchiveRootRelocationError(f"unsafe archive-root relocation receipt path: {path}") from exc + return None if encoded is None else _decode_receipt(encoded, path=path) + + def _write_receipt(path: Path, receipt: ArchiveRootRelocationReceipt, *, expected: str | None) -> None: _verify_receipt(receipt) - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - _real_directory(path.parent, label="archive-root relocation receipt directory") - if path.exists(): - current = load_archive_root_relocation_receipt(path) - if current.receipt_sha256 != expected: - raise ArchiveRootRelocationError("archive-root relocation receipt CAS state changed") - elif expected is not None: - raise ArchiveRootRelocationError("archive-root relocation receipt disappeared") - encoded = (json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() - with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream: - temporary = Path(stream.name) - stream.write(encoded) - stream.flush() - os.fsync(stream.fileno()) + root, directory_name = _receipt_directory_binding(path) try: - os.replace(temporary, path) - descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - finally: - temporary.unlink(missing_ok=True) + with maintenance_receipt_directory(root, directory_name) as directory_fd: + current_bytes = read_optional_receipt(directory_fd, path.name) + if current_bytes is not None: + current = _decode_receipt(current_bytes, path=path) + if current.receipt_sha256 != expected: + raise ArchiveRootRelocationError("archive-root relocation receipt CAS state changed") + elif expected is not None: + raise ArchiveRootRelocationError("archive-root relocation receipt disappeared") + encoded = (json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + atomic_replace_receipt(directory_fd, path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise ArchiveRootRelocationError(f"unsafe archive-root relocation receipt path: {path}") from exc def load_archive_root_relocation_receipt(path: Path) -> ArchiveRootRelocationReceipt: _real_file(path, label="archive-root relocation receipt") try: - receipt = ArchiveRootRelocationReceipt.model_validate_json(path.read_text(encoding="utf-8")) + encoded = path.read_bytes() except (OSError, ValueError) as exc: raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") from exc - _verify_receipt(receipt) - return receipt + return _decode_receipt(encoded, path=path) def assert_no_prepared_archive_root_relocation(root: Path) -> None: @@ -474,22 +566,17 @@ def assert_no_prepared_archive_root_relocation(root: Path) -> None: def _revalidate_plan_live_state( root: Path, plan: ArchiveRootRelocationPlan, - *, - stopped_daemon_evidence_ref: str, - single_writer_evidence_ref: str, ) -> None: """Recheck every immutable plan binding while allowing CAS resume states.""" - if stopped_daemon_evidence_ref != plan.stopped_daemon_evidence_ref: - raise ArchiveRootRelocationError("archive-root relocation stopped-daemon evidence changed") - if single_writer_evidence_ref != plan.single_writer_evidence_ref: - raise ArchiveRootRelocationError("archive-root relocation single-writer evidence changed") root_metadata = root.stat() if (root_metadata.st_dev, root_metadata.st_ino) != (plan.new_root_device, plan.new_root_inode): raise ArchiveRootRelocationError("archive-root relocation configured root identity changed") _reject_sidecars(root) try: manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( - Path(plan.backup_manifest_path), old_archive_root=Path(plan.old_resolved_root) + Path(plan.backup_manifest_path), + old_configured_root=Path(plan.old_configured_root), + old_archive_root=Path(plan.old_resolved_root), ) except MigrationError as exc: raise ArchiveRootRelocationError(str(exc)) from exc @@ -500,7 +587,26 @@ def _revalidate_plan_live_state( or _sha256_file(receipt_path) != plan.backup_receipt_sha256 ): raise ArchiveRootRelocationError("archive-root relocation backup authority changed") - snapshots = tuple(_tier_snapshot(root, tier) for tier in ArchiveTier) + expected_inventory = tuple(sorted(f"{tier}.db" for tier in _TIER_NAMES)) + if plan.backup_tier_inventory != expected_inventory: + raise ArchiveRootRelocationError("archive-root relocation plan tier inventory changed") + if _authenticated_identity(manifest.get("archive_root_source_identity"), label="archive root") != ( + plan.old_root_device, + plan.old_root_inode, + ): + raise ArchiveRootRelocationError("archive-root relocation old root identity authority changed") + old_tiers = {item.tier: (item.old_device, item.old_inode) for item in plan.tiers} + if len(plan.tiers) != len(ArchiveTier) or set(old_tiers) != {tier.value for tier in ArchiveTier}: + raise ArchiveRootRelocationError("archive-root relocation plan tier evidence is incomplete") + snapshots = tuple( + _tier_snapshot( + root, + tier, + old_device=old_tiers[tier.value][0], + old_inode=old_tiers[tier.value][1], + ) + for tier in ArchiveTier + ) if snapshots != plan.tiers: raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) @@ -535,28 +641,51 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError(f"archive-root relocation manifest changed: {path}") +def _require_offline_apply_boundary(root: Path) -> None: + reason = offline_writer_block_reason(Config(archive_root=root, render_root=render_root(), sources=[])) + if reason is not None: + raise ArchiveRootRelocationError(f"archive-root relocation requires the daemon to be stopped; {reason}") + + def apply_archive_root_relocation( *, root: Path, plan: ArchiveRootRelocationPlan, authorization: str, - stopped_daemon_evidence_ref: str, - single_writer_evidence_ref: str, ) -> ArchiveRootRelocationResult: - """CAS-rewrite only released source manifests, never SQLite/archive bytes.""" + """Acquire real offline ownership before any receipt or manifest publication.""" + resolved = _real_directory(root, label="configured archive root") + try: + with OwnedArchiveLocation.acquire( + ArchiveLocation.resolve(resolved), + owner_id=f"archive-root-relocation:{os.getpid()}", + ): + _require_offline_apply_boundary(resolved) + return _apply_archive_root_relocation_locked( + root=resolved, + plan=plan, + authorization=authorization, + ) + except ArchiveOwnershipError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation could not acquire exclusive archive ownership" + ) from exc + + +def _apply_archive_root_relocation_locked( + *, + root: Path, + plan: ArchiveRootRelocationPlan, + authorization: str, +) -> ArchiveRootRelocationResult: + """CAS-rewrite only released source manifests under the owned offline boundary.""" _verify_plan(plan) if authorization != plan.plan_sha256 or plan.bound_confirmation != "archive-root-relocation": raise ArchiveRootRelocationError("archive-root relocation authorization does not bind this plan") - resolved = _real_directory(root, label="configured archive root") - if str(root.absolute()) != plan.new_configured_root or str(resolved) != plan.new_resolved_root: + if str(root.absolute()) != plan.new_configured_root or str(root) != plan.new_resolved_root: raise ArchiveRootRelocationError("archive-root relocation plan is bound to a different configured root") - _revalidate_plan_live_state( - resolved, - plan, - stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, - single_writer_evidence_ref=single_writer_evidence_ref, - ) - receipt_path = _receipt_path(resolved, plan) + _revalidate_plan_live_state(root, plan) + receipt_path = _receipt_path(root, plan) command = ( f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance archive-root-relocation " f"apply --plan --authorize {plan.plan_sha256} --output-format json" @@ -571,8 +700,9 @@ def apply_archive_root_relocation( manifest_after_sha256=(), resume_command=command, ) - if receipt_path.exists(): - receipt = load_archive_root_relocation_receipt(receipt_path) + existing_receipt = _load_receipt_for_update(receipt_path) + if existing_receipt is not None: + receipt = existing_receipt if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") if receipt.state == "committed": diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 9f5a7769be..ea549d01f5 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -21,9 +21,14 @@ from pydantic import BaseModel, ConfigDict from polylogue.config import Config -from polylogue.daemon.write_coordinator import daemon_write_lease_active from polylogue.maintenance.blob_ref_liveness_reconciliation import census_blob_ref_liveness -from polylogue.maintenance.offline_guard import running_daemon_pid +from polylogue.maintenance.offline_guard import offline_writer_block_reason +from polylogue.operations._maintenance_receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + maintenance_receipt_directory, + read_optional_receipt, +) from polylogue.paths import render_root from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation from polylogue.storage.backup_attestation import BackupAttestationError, verify_verification_receipt @@ -548,60 +553,39 @@ def _refresh_path(root: Path, digest: str) -> Path: def _require_offline_ownership_boundary(root: Path) -> None: """Make offline authority real for callers that bypass the Click adapter.""" - if daemon_write_lease_active(): - raise HistoricalSourceContinuityRecoveryError( - "historical continuity recovery is offline-only and cannot run under a daemon writer lease" - ) - if (pid := running_daemon_pid(Config(archive_root=root, render_root=render_root(), sources=[]))) is not None: + reason = offline_writer_block_reason(Config(archive_root=root, render_root=render_root(), sources=[])) + if reason is not None: raise HistoricalSourceContinuityRecoveryError( - f"historical continuity recovery requires the daemon to be stopped; live pidfile PID: {pid}" + f"historical continuity recovery requires the daemon to be stopped; {reason}" ) def _write_refresh_receipt(path: Path, payload: dict[str, object]) -> None: """Publish a retained receipt beneath a pinned, non-symlink directory.""" - state_root = _real_directory(path.parent.parent, label="maintenance state") - refresh_root = state_root / path.parent.name - if refresh_root.exists() or refresh_root.is_symlink(): - _real_directory(refresh_root, label="source continuity refresh receipt directory") - else: - refresh_root.mkdir(mode=0o700) - descriptor = os.open(state_root, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - _real_directory(refresh_root, label="source continuity refresh receipt directory") - receipt_path = refresh_root / path.name - if receipt_path.exists() or receipt_path.is_symlink(): - _real_file(receipt_path, label="source continuity refresh receipt") - try: - if json.loads(receipt_path.read_text(encoding="utf-8")) != payload: - raise HistoricalSourceContinuityRecoveryError( - "historical continuity recovery refresh receipt collision" - ) - except (OSError, json.JSONDecodeError) as exc: - raise HistoricalSourceContinuityRecoveryError( - "historical continuity recovery refresh receipt is unreadable" - ) from exc - return - temporary: Path | None = None + state_root = path.parent.parent + if state_root.name != ".maintenance-state" or path.suffix != ".json": + raise HistoricalSourceContinuityRecoveryError("invalid source continuity refresh receipt path") try: - with tempfile.NamedTemporaryFile(dir=refresh_root, prefix=f".{receipt_path.name}.", delete=False) as stream: - temporary = Path(stream.name) - stream.write((json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, receipt_path) - temporary = None - descriptor = os.open(refresh_root, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) + with maintenance_receipt_directory(state_root.parent, path.parent.name) as directory_fd: + current = read_optional_receipt(directory_fd, path.name) + if current is not None: + try: + current_payload = json.loads(current) + except json.JSONDecodeError as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery refresh receipt is unreadable" + ) from exc + if current_payload != payload: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery refresh receipt collision" + ) + return + encoded = (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode() + atomic_replace_receipt(directory_fd, path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"unsafe historical continuity refresh receipt path: {path}" + ) from exc def prepare_historical_source_continuity_recovery( @@ -737,42 +721,62 @@ def load_historical_source_continuity_recovery_plan(path: Path) -> HistoricalSou return plan +def _recovery_receipt_directory_binding(path: Path) -> tuple[Path, str]: + state_root = path.parent.parent + if state_root.name != ".maintenance-state" or path.suffix != ".json": + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt path") + return state_root.parent, path.parent.name + + +def _decode_recovery_receipt(encoded: bytes) -> HistoricalSourceContinuityRecoveryReceipt: + try: + receipt = HistoricalSourceContinuityRecoveryReceipt.model_validate_json(encoded) + except ValueError as exc: + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") from exc + _verify_receipt(receipt) + return receipt + + +def _load_recovery_receipt_for_update(path: Path) -> HistoricalSourceContinuityRecoveryReceipt | None: + root, directory_name = _recovery_receipt_directory_binding(path) + try: + with maintenance_receipt_directory(root, directory_name) as directory_fd: + encoded = read_optional_receipt(directory_fd, path.name) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"unsafe historical continuity recovery receipt path: {path}" + ) from exc + return None if encoded is None else _decode_recovery_receipt(encoded) + + def _write_receipt(path: Path, receipt: HistoricalSourceContinuityRecoveryReceipt, *, expected: str | None) -> None: _verify_receipt(receipt) - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - _real_directory(path.parent, label="historical continuity recovery receipt directory") - if path.exists(): - if load_historical_source_continuity_recovery_receipt(path).receipt_sha256 != expected: - raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt CAS state changed") - elif expected is not None: - raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt disappeared") - temporary: Path | None = None + root, directory_name = _recovery_receipt_directory_binding(path) try: - with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream: - temporary = Path(stream.name) - stream.write((json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode()) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - temporary = None - descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) + with maintenance_receipt_directory(root, directory_name) as directory_fd: + current_bytes = read_optional_receipt(directory_fd, path.name) + if current_bytes is not None: + if _decode_recovery_receipt(current_bytes).receipt_sha256 != expected: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery receipt CAS state changed" + ) + elif expected is not None: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt disappeared") + encoded = (json.dumps(receipt.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + atomic_replace_receipt(directory_fd, path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"unsafe historical continuity recovery receipt path: {path}" + ) from exc def load_historical_source_continuity_recovery_receipt(path: Path) -> HistoricalSourceContinuityRecoveryReceipt: _real_file(path, label="historical continuity recovery receipt") try: - receipt = HistoricalSourceContinuityRecoveryReceipt.model_validate_json(path.read_text(encoding="utf-8")) + encoded = path.read_bytes() except (OSError, ValueError) as exc: raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") from exc - _verify_receipt(receipt) - return receipt + return _decode_recovery_receipt(encoded) def assert_no_prepared_historical_source_continuity_recovery(root: Path) -> None: @@ -900,8 +904,9 @@ def _apply_historical_source_continuity_recovery_locked( refresh_receipt_sha256=refresh_digest, resume_command=command, ) - if receipt_path.exists(): - receipt = load_historical_source_continuity_recovery_receipt(receipt_path) + existing_receipt = _load_recovery_receipt_for_update(receipt_path) + if existing_receipt is not None: + receipt = existing_receipt if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise HistoricalSourceContinuityRecoveryError( "historical continuity recovery receipt belongs to another plan" diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index fce5c9dc7c..35578aa7a0 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -538,7 +538,7 @@ def _validated_receipt_artifacts( manifest: dict[str, object], receipt: dict[str, object], *, - target_tier: str, + target_tier: str | None, live_tier_path: Path | None, file_evidence: dict[str, dict[str, object]], ) -> dict[str, dict[str, object]]: @@ -564,7 +564,7 @@ def _validated_receipt_artifacts( backup_root, artifact, file_evidence=file_evidence, - live_tier_path=live_tier_path if tier == target_tier else None, + live_tier_path=live_tier_path if target_tier is not None and tier == target_tier else None, ) by_tier[tier] = artifact if set(by_tier) != {name.removesuffix(".db") for name in included}: @@ -701,6 +701,61 @@ def _validate_blob_inventory( raise MigrationError(f"migration backup blob hash mismatch: {blob['path']}") +def _load_verified_backup_package( + path: Path, +) -> tuple[Path, Path, Path, dict[str, object], dict[str, object]]: + """Load the regular manifest/receipt pair shared by every strict validator.""" + manifest_path = _backup_manifest_path(path) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"migration 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": + raise MigrationError(f"migration backup manifest has unsupported format: {manifest_path}") + receipt_path = _receipt_path(manifest_path) + if not receipt_path.exists() and not receipt_path.is_symlink(): + raise MigrationError(f"migration 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: + raise MigrationError(f"migration backup receipt has unsupported format: {receipt_path}") + if receipt.get("verdict") != "success": + raise MigrationError(f"migration backup receipt is not a successful verification: {receipt_path}") + return manifest_path, receipt_path, backup_root, manifest, receipt + + +def _validate_closed_backup_package( + backup_root: Path, + manifest: dict[str, object], + receipt: dict[str, object], + *, + target_tier: str | None, + live_tier_path: Path | None, +) -> dict[str, dict[str, object]]: + """Re-hash the complete closed package bound by a successful receipt.""" + 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("migration backup receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("migration backup receipt does not match manifest bytes") + artifacts = _validated_receipt_artifacts( + backup_root, + manifest, + receipt, + target_tier=target_tier, + live_tier_path=live_tier_path, + file_evidence=file_evidence, + ) + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("migration backup receipt does not match the closed artifact inventory") + return artifacts + + def _validate_backup_manifest_covers_tier( path: Path, tier: ArchiveTier, *, connection: sqlite3.Connection, require_attestation: bool ) -> Path: @@ -718,54 +773,26 @@ def _validate_backup_manifest_covers_tier( recomputed from the current on-disk file -- just not the attestation, which durable-tier migrations (``migrate_archive_tier``) still require. """ - manifest_path = _backup_manifest_path(path) - if not manifest_path.exists() and not manifest_path.is_symlink(): - raise MigrationError(f"migration 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") - payload = _load_json(manifest_path, label="manifest") - if payload.get("format") != "polylogue-backup-v1": - raise MigrationError(f"migration backup manifest has unsupported format: {manifest_path}") + manifest_path, receipt_path, backup_root, payload, receipt = _load_verified_backup_package(path) included = set(_json_str_list(payload.get("included_tiers"))) if f"{tier.value}.db" not in included: raise MigrationError(f"migration backup manifest does not include {tier.value}.db: {manifest_path}") - receipt_path = _receipt_path(manifest_path) - if not receipt_path.exists() and not receipt_path.is_symlink(): - raise MigrationError(f"migration 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: - raise MigrationError(f"migration backup receipt has unsupported format: {receipt_path}") live_tier_path = _connection_main_path(connection).resolve(strict=False) if require_attestation: try: verify_verification_receipt(receipt, tier=tier.value, live_tier_path=live_tier_path) except BackupAttestationError as exc: raise MigrationError(f"migration backup receipt authentication failed: {exc}") from exc - if receipt.get("verdict") != "success": - raise MigrationError(f"migration backup receipt is not a successful verification: {receipt_path}") - 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("migration backup receipt does not match manifest size") - if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): - raise MigrationError("migration backup receipt does not match manifest bytes") - artifacts = _validated_receipt_artifacts( + artifacts = _validate_closed_backup_package( backup_root, payload, receipt, target_tier=tier.value, live_tier_path=live_tier_path, - file_evidence=file_evidence, ) artifact = artifacts.get(tier.value) if artifact is None: raise MigrationError(f"migration backup receipt does not include {tier.value}.db: {receipt_path}") - _validate_blob_inventory(backup_root, payload, receipt, file_evidence=file_evidence) - if receipt.get("artifact_inventory") != artifact_inventory: - raise MigrationError("migration backup receipt does not match the closed artifact inventory") _validate_live_source_fingerprint(connection, artifact) return receipt_path @@ -794,52 +821,24 @@ def validate_migration_backup_live_fingerprint( if tier not in DURABLE_MIGRATION_TIERS: raise MigrationError(f"{tier.value} tier is not a durable migration tier") - manifest_path = _backup_manifest_path(path) - if not manifest_path.exists() and not manifest_path.is_symlink(): - raise MigrationError(f"migration 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": - raise MigrationError(f"migration backup manifest has unsupported format: {manifest_path}") + manifest_path, receipt_path, backup_root, manifest, receipt = _load_verified_backup_package(path) if f"{tier.value}.db" not in _json_str_list(manifest.get("included_tiers")): raise MigrationError(f"migration backup manifest does not include {tier.value}.db: {manifest_path}") - receipt_path = _receipt_path(manifest_path) - if not receipt_path.exists() and not receipt_path.is_symlink(): - raise MigrationError(f"migration 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: - raise MigrationError(f"migration backup receipt has unsupported format: {receipt_path}") live_tier_path = _connection_main_path(connection).resolve(strict=False) try: verify_verification_receipt(receipt, tier=tier.value, live_tier_path=live_tier_path) except BackupAttestationError as exc: raise MigrationError(f"migration backup receipt authentication failed: {exc}") from exc - if receipt.get("verdict") != "success": - raise MigrationError(f"migration backup receipt is not a successful verification: {receipt_path}") - 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("migration backup receipt does not match manifest size") - if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): - raise MigrationError("migration backup receipt does not match manifest bytes") - artifacts = _validated_receipt_artifacts( + artifacts = _validate_closed_backup_package( backup_root, manifest, receipt, target_tier=tier.value, live_tier_path=live_tier_path, - file_evidence=file_evidence, ) artifact = artifacts.get(tier.value) if artifact is None: raise MigrationError(f"migration backup receipt does not include {tier.value}.db: {receipt_path}") - _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) - if receipt.get("artifact_inventory") != artifact_inventory: - raise MigrationError("migration backup receipt does not match the closed artifact inventory") _validate_live_source_fingerprint(connection, artifact) return receipt_path @@ -847,40 +846,67 @@ def validate_migration_backup_live_fingerprint( def validate_full_evidence_backup_for_archive_root_relocation( path: Path, *, + old_configured_root: Path, old_archive_root: Path, ) -> tuple[Path, Path, dict[str, object], dict[str, object]]: """Authenticate complete old-root backup evidence for a root relocation.""" - manifest_path = _backup_manifest_path(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": + manifest_path, receipt_path, backup_root, manifest, receipt = _load_verified_backup_package(path) + if manifest.get("profile") != "full_evidence": raise MigrationError("archive-root relocation requires a verified full_evidence backup") expected_tiers = {f"{tier.value}.db" for tier in ArchiveTier} if set(_json_str_list(manifest.get("included_tiers"))) != expected_tiers or _json_str_list( manifest.get("omitted_tiers") ): raise MigrationError("archive-root relocation backup must contain the exact complete tier set") - 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("archive-root relocation requires a successful verification receipt") for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT): try: verify_verification_receipt(receipt, tier=tier.value, live_tier_path=old_archive_root / f"{tier.value}.db") except BackupAttestationError as exc: raise MigrationError(f"archive-root relocation old-root authority failed for {tier.value}: {exc}") from exc + validated_artifacts = _validate_closed_backup_package( + backup_root, + manifest, + receipt, + target_tier=None, + live_tier_path=None, + ) fingerprints = manifest.get("tier_source_fingerprints") - artifacts = receipt.get("tier_artifacts") - if not isinstance(fingerprints, dict) or not isinstance(artifacts, list): + if not isinstance(fingerprints, dict): raise MigrationError("archive-root relocation backup lacks complete tier evidence") - artifact_by_tier = { - item.get("tier"): item for item in artifacts if isinstance(item, dict) and isinstance(item.get("tier"), str) - } - if set(fingerprints) != expected_tiers or set(artifact_by_tier) != {tier.value for tier in ArchiveTier}: + if set(fingerprints) != expected_tiers or set(validated_artifacts) != {tier.value for tier in ArchiveTier}: raise MigrationError("archive-root relocation backup tier evidence is incomplete") + for filename, fingerprint in fingerprints.items(): + tier = filename.removesuffix(".db") + artifact = validated_artifacts[tier] + if not isinstance(fingerprint, dict) or artifact.get("source_fingerprint") != fingerprint: + raise MigrationError(f"archive-root relocation backup source authority differs for {filename}") + if not all(isinstance(fingerprint.get(field), int) for field in ("device", "inode")): + raise MigrationError(f"archive-root relocation backup lacks authenticated inode authority for {filename}") + recorded_path = fingerprint.get("path") + if not isinstance(recorded_path, str): + raise MigrationError(f"archive-root relocation backup lacks old path authority for {filename}") + recorded = Path(recorded_path).resolve(strict=False) + if tier == ArchiveTier.INDEX.value: + if not recorded.is_relative_to(old_archive_root.resolve(strict=False)): + raise MigrationError("archive-root relocation backup active index is outside the old archive root") + elif recorded != (old_archive_root / filename).resolve(strict=False): + raise MigrationError(f"archive-root relocation backup belongs to a different old tier path: {filename}") + root_identity = manifest.get("archive_root_source_identity") + if not isinstance(root_identity, dict) or not all( + isinstance(root_identity.get(field), int) for field in ("device", "inode") + ): + raise MigrationError("archive-root relocation backup lacks authenticated root inode authority") + recorded_root = root_identity.get("resolved_path") + if not isinstance(recorded_root, str) or Path(recorded_root).resolve(strict=False) != old_archive_root.resolve( + strict=False + ): + raise MigrationError("archive-root relocation backup belongs to a different old archive root") + recorded_configured_root = root_identity.get("configured_path") + if ( + not isinstance(recorded_configured_root, str) + or Path(recorded_configured_root).absolute() != old_configured_root.absolute() + ): + raise MigrationError("archive-root relocation backup belongs to a different configured old archive root") return manifest_path, receipt_path, manifest, receipt From e102d5f023145a6778e9e820b904f67fc5e6c47b Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 13 Aug 2026 23:36:08 +0200 Subject: [PATCH 12/39] test: reject archive relocation proof substitutions Cover copytree inode changes, direct-operation ownership and daemon conflicts, stale authenticated backup evidence, and descriptor-path swaps. Exercise historical continuity through the real CLI with a nonzero orphan deletion and idempotent resume. --- .../storage/test_archive_root_relocation.py | 324 +++++++++++++++--- 1 file changed, 285 insertions(+), 39 deletions(-) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 804f04c7bc..06a15be41f 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -4,6 +4,7 @@ import json import os +import shutil import sqlite3 from dataclasses import replace from pathlib import Path @@ -18,6 +19,12 @@ apply_archive_root_relocation, prepare_archive_root_relocation, ) +from polylogue.operations.archive_root_relocation import ( + _sealed_receipt as _sealed_relocation_receipt, +) +from polylogue.operations.archive_root_relocation import ( + _write_receipt as _write_relocation_receipt, +) from polylogue.operations.historical_source_continuity_recovery import ( HistoricalSourceContinuityRecoveryError, _assert_complete_source_semantic_delta, @@ -27,7 +34,18 @@ apply_historical_source_continuity_recovery, load_historical_source_continuity_recovery_plan, ) -from polylogue.storage.blob_ref_liveness import BlobRefLivenessCandidate +from polylogue.operations.historical_source_continuity_recovery import ( + _sealed_receipt as _sealed_continuity_receipt, +) +from polylogue.operations.historical_source_continuity_recovery import ( + _write_receipt as _write_continuity_receipt, +) +from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation +from polylogue.storage.blob_ref_liveness import ( + BlobRefLivenessCandidate, + BlobRefLivenessCandidateDigest, + classify_blob_ref_liveness, +) from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, @@ -36,6 +54,7 @@ ) from polylogue.storage.sqlite.migration_runner import ( apply_durable_change_train, + capture_durable_database_evidence, capture_durable_restart_convergence, prove_durable_change_train, record_durable_writer_release, @@ -91,6 +110,73 @@ def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( assert after == before +def test_plan_rejects_mutated_manifest_and_stale_authenticated_receipt( + workspace_env: dict[str, Path], tmp_path: Path +) -> None: + """The old-path HMAC cannot bypass manifest-byte or closed-package binding.""" + old_root = workspace_env["archive_root"] + first = backup_archive(output_dir=tmp_path / "first", profile="full_evidence", verify=True) + second = backup_archive(output_dir=tmp_path / "second", profile="full_evidence", verify=True) + assert first.ok and first.output_path is not None + assert second.ok and second.output_path is not None + first_manifest = Path(first.output_path) / "manifest.json" + first_receipt = Path(first.output_path) / "verification-receipt.json" + second_receipt = Path(second.output_path) / "verification-receipt.json" + original_manifest = first_manifest.read_bytes() + original_receipt = first_receipt.read_bytes() + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + + first_manifest.write_bytes(original_manifest + b"\n") + with pytest.raises(ArchiveRootRelocationError, match="does not match manifest"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=first_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + first_manifest.write_bytes(original_manifest) + first_receipt.write_bytes(second_receipt.read_bytes()) + with pytest.raises(ArchiveRootRelocationError, match="does not match manifest"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=first_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + first_receipt.write_bytes(original_receipt) + assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() + + +def test_plan_rejects_byte_identical_copied_archive_with_new_inodes( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Authenticated pre-move inode facts distinguish a move from copytree bytes.""" + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "copied" + shutil.copytree(old_root, new_root, symlinks=True) + assert (old_root / "source.db").read_bytes() == (new_root / "source.db").read_bytes() + assert (old_root / "source.db").stat().st_ino != (new_root / "source.db").stat().st_ino + + with pytest.raises(ArchiveRootRelocationError, match="inode continuity"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() + + def test_rebind_rewrites_only_the_released_source_identity_fields( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -153,7 +239,9 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( ) -def _released_moved_source_train(root: Path, monkeypatch: pytest.MonkeyPatch) -> Path: +def _released_moved_source_train( + root: Path, monkeypatch: pytest.MonkeyPatch, *, include_orphan_blob_ref: bool = False +) -> Path: """Build a real released source train over a temporary SQLite source tier.""" from tests.unit.storage import test_durable_change_train as trains @@ -181,12 +269,48 @@ def _released_moved_source_train(root: Path, monkeypatch: pytest.MonkeyPatch) -> ) released = release_durable_change_train(train, evidence_ref="proof:released") assert released.apply_evidence is not None + assert released.proof is not None + source_post = released.apply_evidence.post + source_proof = released.proof + if include_orphan_blob_ref: + with sqlite3.connect(source) as connection: + connection.executescript( + """ + CREATE TABLE raw_sessions (raw_id TEXT PRIMARY KEY, blob_hash BLOB) STRICT; + CREATE TABLE blob_refs ( + blob_hash BLOB NOT NULL, + ref_type TEXT NOT NULL, + ref_id TEXT NOT NULL, + source_path TEXT, + size_bytes INTEGER NOT NULL, + acquired_at_ms INTEGER NOT NULL, + PRIMARY KEY (blob_hash, ref_type, ref_id) + ) STRICT; + INSERT INTO blob_refs VALUES (X'02', 'attachment', 'deleted', NULL, 2, 2); + """ + ) + source_post = replace( + capture_durable_database_evidence(connection, ArchiveTier.SOURCE), + observed_at_ms=released.apply_evidence.post.observed_at_ms, + ) + source_proof = replace( + released.proof, + fresh_ddl_parity=replace( + released.proof.fresh_ddl_parity, + migrated_inventory_sha256=source_post.schema_inventory_sha256, + ), + restart_convergence=replace( + released.proof.restart_convergence, + observed_schema_inventory_sha256=source_post.schema_inventory_sha256, + ), + ) historical = replace( released, apply_evidence=replace( released.apply_evidence, - post=replace(released.apply_evidence.post, archive_identity_digest="b" * 64), + post=replace(source_post, archive_identity_digest="b" * 64), ), + proof=source_proof, ) manifest_root = root / ".maintenance-state" / "durable-change-trains" (manifest_root / ".bootstrap").unlink() @@ -196,30 +320,34 @@ def _released_moved_source_train(root: Path, monkeypatch: pytest.MonkeyPatch) -> return manifest -def _legacy_zero_candidate_receipt(path: Path, *, old_root: Path, pre_manifest: Path) -> None: +def _legacy_liveness_receipt( + path: Path, + *, + old_root: Path, + pre_manifest: Path, + candidates: tuple[BlobRefLivenessCandidate, ...], +) -> None: """Encode the exact pre-#3868 shape: no backup digest or postcondition field.""" - path.write_text( - json.dumps( - { - "kind": "blob_ref_liveness_reconciliation", - "phase": "prepared", - "source_db": str(old_root / "source.db"), - "backup_manifest": str(pre_manifest), - "candidate_count": 0, - "candidate_digest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - } - ) - + "\n" - + json.dumps( - { - "kind": "blob_ref_liveness_reconciliation", - "phase": "committed", - "deleted_count": 0, - } - ) - + "\n", - encoding="utf-8", - ) + digest = BlobRefLivenessCandidateDigest() + for candidate in candidates: + digest.update(candidate) + records = [ + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "prepared", + "source_db": str(old_root / "source.db"), + "backup_manifest": str(pre_manifest), + "candidate_count": len(candidates), + "candidate_digest": digest.hexdigest(), + }, + *({"kind": "candidate", **candidate.to_dict()} for candidate in candidates), + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "committed", + "deleted_count": len(candidates), + }, + ] + path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") def _write_liveness_delta_database(path: Path, *, keep_body: str = "kept", include_candidate: bool = True) -> None: @@ -291,11 +419,104 @@ def test_historical_source_delta_tags_sqlite_storage_classes_and_rejects_refresh target = tmp_path / "outside" target.mkdir() (state / "source-continuity-refreshes").symlink_to(target, target_is_directory=True) - with pytest.raises(HistoricalSourceContinuityRecoveryError, match="not a real directory"): + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="unsafe"): _write_refresh_receipt( state / "source-continuity-refreshes" / ("a" * 64 + ".json"), {"refresh_sha256": "a" * 64}, ) + assert not tuple(target.iterdir()) + + +def test_receipt_directory_swap_cannot_redirect_either_operation_outside_archive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A child swapped to a symlink after mkdir is rejected before external writes.""" + root = tmp_path / "archive" + state = root / ".maintenance-state" + state.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + real_mkdir = os.mkdir + + def swapped_mkdir(path: str, mode: int = 0o777, *, dir_fd: int | None = None) -> None: + real_mkdir(path, mode=mode, dir_fd=dir_fd) + os.rmdir(path, dir_fd=dir_fd) + os.symlink(outside, path, target_is_directory=True, dir_fd=dir_fd) + + relocation = _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256="a" * 64, + authorization="a" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="resume relocation", + ) + with monkeypatch.context() as scoped: + scoped.setattr(os, "mkdir", swapped_mkdir) + with pytest.raises(ArchiveRootRelocationError, match="unsafe"): + _write_relocation_receipt( + state / "archive-root-relocations" / ("a" * 64 + ".json"), + relocation, + expected=None, + ) + (state / "archive-root-relocations").unlink() + + continuity = _sealed_continuity_receipt( + state="prepared", + revision=0, + plan_sha256="b" * 64, + authorization="b" * 64, + train_before_sha256="c" * 64, + train_after_sha256=None, + refresh_receipt_sha256="d" * 64, + resume_command="resume continuity", + ) + with monkeypatch.context() as scoped: + scoped.setattr(os, "mkdir", swapped_mkdir) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="unsafe"): + _write_continuity_receipt( + state / "historical-source-continuity-recoveries" / ("b" * 64 + ".json"), + continuity, + expected=None, + ) + + assert not tuple(outside.iterdir()) + + +def test_receipt_writers_never_create_through_a_symlinked_maintenance_state(tmp_path: Path) -> None: + """Missing receipt children cannot make ``mkdir`` traverse an external state target.""" + outside = tmp_path / "outside-state" + outside.mkdir() + relocation_root = tmp_path / "relocation-archive" + relocation_root.mkdir() + (relocation_root / ".maintenance-state").symlink_to(outside, target_is_directory=True) + relocation = _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256="e" * 64, + authorization="e" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="resume relocation", + ) + with pytest.raises(ArchiveRootRelocationError, match="unsafe"): + _write_relocation_receipt( + relocation_root / ".maintenance-state" / "archive-root-relocations" / ("e" * 64 + ".json"), + relocation, + expected=None, + ) + + continuity_root = tmp_path / "continuity-archive" + continuity_root.mkdir() + (continuity_root / ".maintenance-state").symlink_to(outside, target_is_directory=True) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="unsafe"): + _write_refresh_receipt( + continuity_root / ".maintenance-state" / "source-continuity-refreshes" / ("f" * 64 + ".json"), + {"refresh_sha256": "f" * 64}, + ) + + assert not tuple(outside.iterdir()) def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( @@ -310,15 +531,28 @@ def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( from polylogue.storage.sqlite import durable_change_train as trains old_root = workspace_env["archive_root"] - manifest = _released_moved_source_train(old_root, monkeypatch) + manifest = _released_moved_source_train(old_root, monkeypatch, include_orphan_blob_ref=True) pre_backup = backup_archive(output_dir=tmp_path / "pre", profile="rebuildable_cache_exclude", verify=True) - post_backup = backup_archive(output_dir=tmp_path / "post", profile="rebuildable_cache_exclude", verify=True) assert pre_backup.ok and pre_backup.output_path is not None - assert post_backup.ok and post_backup.output_path is not None + with sqlite3.connect(f"file:{old_root / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + prior = classify_blob_ref_liveness(connection) + assert prior.orphaned_count == 1 + assert prior.candidates[0].ref_id == "deleted" pre_manifest = Path(pre_backup.output_path) / "manifest.json" - post_manifest = Path(post_backup.output_path) / "manifest.json" legacy_receipt = tmp_path / "legacy-liveness.jsonl" - _legacy_zero_candidate_receipt(legacy_receipt, old_root=old_root, pre_manifest=pre_manifest) + _legacy_liveness_receipt( + legacy_receipt, + old_root=old_root, + pre_manifest=pre_manifest, + candidates=prior.candidates, + ) + with sqlite3.connect(old_root / "source.db") as connection: + connection.execute( + "DELETE FROM blob_refs WHERE blob_hash = X'02' AND ref_type = 'attachment' AND ref_id = 'deleted'" + ) + post_backup = backup_archive(output_dir=tmp_path / "post", profile="rebuildable_cache_exclude", verify=True) + assert post_backup.ok and post_backup.output_path is not None + post_manifest = Path(post_backup.output_path) / "manifest.json" new_root = tmp_path / "moved" os.rename(old_root, new_root) moved_manifest = new_root / manifest.relative_to(old_root) @@ -356,7 +590,7 @@ def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( plan = load_historical_source_continuity_recovery_plan(plan_path) with monkeypatch.context() as scoped: scoped.setattr( - "polylogue.operations.historical_source_continuity_recovery.running_daemon_pid", + "polylogue.maintenance.offline_guard.running_daemon_pid", lambda _config: 4242, ) with pytest.raises(HistoricalSourceContinuityRecoveryError, match="daemon to be stopped"): @@ -477,9 +711,27 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ stopped_daemon_evidence_ref="proof:daemon-stopped", single_writer_evidence_ref="proof:archive-ownership-lock", ) + assert plan.old_root_inode == plan.new_root_inode + assert all(item.old_inode == item.inode for item in plan.tiers) assert database_before == { path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") } + with OwnedArchiveLocation.acquire(ArchiveLocation.resolve(new_root), owner_id="held-by-another-operation"): + with pytest.raises(ArchiveRootRelocationError, match="exclusive archive ownership"): + apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + ) + with monkeypatch.context() as scoped: + scoped.setattr("polylogue.maintenance.offline_guard.running_daemon_pid", lambda _config: 4242) + with pytest.raises(ArchiveRootRelocationError, match="daemon to be stopped"): + apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + ) + assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() with monkeypatch.context() as scoped: scoped.setattr( "polylogue.operations.archive_root_relocation.rebind_released_source_train_archive_identity", @@ -490,15 +742,11 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ root=new_root, plan=plan, authorization=plan.plan_sha256, - stopped_daemon_evidence_ref="proof:daemon-stopped", - single_writer_evidence_ref="proof:archive-ownership-lock", ) result = apply_archive_root_relocation( root=new_root, plan=plan, authorization=plan.plan_sha256, - stopped_daemon_evidence_ref="proof:daemon-stopped", - single_writer_evidence_ref="proof:archive-ownership-lock", ) assert result.state == "committed" assert ( @@ -506,8 +754,6 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ root=new_root, plan=plan, authorization=plan.plan_sha256, - stopped_daemon_evidence_ref="proof:daemon-stopped", - single_writer_evidence_ref="proof:archive-ownership-lock", ).state == "committed" ) From d954b6eef0d6d28915e4aac467b5aeb19581ab35 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 00:20:44 +0200 Subject: [PATCH 13/39] fix: bind archive relocation recovery evidence Require complete device/inode continuity, validate live source leaves, and bind the historical liveness recovery to its immutable offline evidence. Make receipt-directory creation rollback safely on fsync failure and exercise prepared relocation state through the production setup path. --- .../operations/_maintenance_receipt_fs.py | 30 ++- .../operations/archive_root_relocation.py | 29 +- ...-source-continuity-operation-20260807.json | 13 + .../historical_source_continuity_recovery.py | 75 ++++++ tests/unit/daemon/test_daemon_cli.py | 41 +-- .../operations/test_maintenance_receipt_fs.py | 39 +++ .../storage/test_archive_root_relocation.py | 253 ++++++++++-------- 7 files changed, 341 insertions(+), 139 deletions(-) create mode 100644 polylogue/operations/historical-source-continuity-operation-20260807.json create mode 100644 tests/unit/operations/test_maintenance_receipt_fs.py diff --git a/polylogue/operations/_maintenance_receipt_fs.py b/polylogue/operations/_maintenance_receipt_fs.py index fdada387ac..83b0bb17a2 100644 --- a/polylogue/operations/_maintenance_receipt_fs.py +++ b/polylogue/operations/_maintenance_receipt_fs.py @@ -45,6 +45,20 @@ def _open_directory_at(parent_fd: int, name: str, *, label: str) -> int: return descriptor +def _remove_created_empty_child(parent_fd: int, name: str, *, expected: os.stat_result) -> None: + """Remove only the empty child this operation created, through its pinned parent.""" + try: + current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot inspect fresh maintenance receipt directory: {name}") from exc + if not stat.S_ISDIR(current.st_mode) or (current.st_dev, current.st_ino) != (expected.st_dev, expected.st_ino): + raise MaintenanceReceiptPathError(f"fresh maintenance receipt directory changed before cleanup: {name}") + try: + os.rmdir(name, dir_fd=parent_fd) + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot remove fresh maintenance receipt directory: {name}") from exc + + @contextmanager def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> Iterator[int]: """Yield a pinned child of an existing, non-symlink ``.maintenance-state``.""" @@ -57,10 +71,22 @@ def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> It try: child_fd = os.open(child_name, _DIRECTORY_FLAGS, dir_fd=state_fd) except FileNotFoundError: - with suppress(FileExistsError): + created_child = False + try: os.mkdir(child_name, mode=0o700, dir_fd=state_fd) - os.fsync(state_fd) + created_child = True + except FileExistsError: + pass child_fd = _open_directory_at(state_fd, child_name, label="maintenance receipt directory") + if created_child: + child_metadata = os.fstat(child_fd) + try: + os.fsync(state_fd) + except OSError as exc: + _remove_created_empty_child(state_fd, child_name, expected=child_metadata) + raise MaintenanceReceiptPathError( + f"cannot persist maintenance receipt directory: {child_name}" + ) from exc except OSError as exc: raise MaintenanceReceiptPathError( f"cannot pin maintenance receipt directory without following links: {child_name}" diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 9ffd7c2672..c4f5d4d61d 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -354,6 +354,13 @@ def _authenticated_old_tier_identities(manifest: dict[str, object]) -> dict[str, } +def _require_identity_continuity(*, old_device: int, old_inode: int, device: int, inode: int, label: str) -> None: + if (old_device, old_inode) != (device, inode): + raise ArchiveRootRelocationError( + f"archive-root relocation requires {label} device/inode continuity; a copied archive is not accepted" + ) + + def _check_backup_against_live( root: Path, *, @@ -386,10 +393,13 @@ def _check_backup_against_live( artifact_fingerprint.get(key) != value for key, value in fields.items() ): raise ArchiveRootRelocationError(f"backup receipt differs from relocated {filename}") - if snapshot.old_inode != snapshot.inode: - raise ArchiveRootRelocationError( - f"archive-root relocation requires inode continuity for {filename}; a copied tier is not accepted" - ) + _require_identity_continuity( + old_device=snapshot.old_device, + old_inode=snapshot.old_inode, + device=snapshot.device, + inode=snapshot.inode, + label=filename, + ) _reject_sidecars(root) @@ -442,10 +452,13 @@ def prepare_archive_root_relocation( after_identity_digest=source_identity_digest, ) root_metadata = new_resolved.stat() - if old_root_inode != root_metadata.st_ino: - raise ArchiveRootRelocationError( - "archive-root relocation requires root inode continuity; a copied archive root is not accepted" - ) + _require_identity_continuity( + old_device=old_root_device, + old_inode=old_root_inode, + device=root_metadata.st_dev, + inode=root_metadata.st_ino, + label="root", + ) return _sealed_plan( old_configured_root=str(old_configured), old_resolved_root=str(old_resolved), diff --git a/polylogue/operations/historical-source-continuity-operation-20260807.json b/polylogue/operations/historical-source-continuity-operation-20260807.json new file mode 100644 index 0000000000..d94f0a4ce1 --- /dev/null +++ b/polylogue/operations/historical-source-continuity-operation-20260807.json @@ -0,0 +1,13 @@ +{ + "format": "polylogue.historical-source-continuity-operation-evidence.v1", + "operation": "blob-ref-liveness-reconciliation-20260807", + "mutation_receipt_sha256": "66ef98cc8eb21a7df7a5766b5679700bca46d476d3fb3e54016089e8b2523e55", + "candidate_count": 69340, + "candidate_digest": "49df6c4daf45ee549e43b2eb25d9ea4dcb13585a0a105293fa3167e4a27b4445", + "pre_backup_manifest_sha256": "022c2a4dd9a8dce40d4c07a14b1e6e968503212a4b53cc69186f01f55f21a478", + "pre_backup_receipt_sha256": "ec6280ba4eac30392a3b5bd91ecdbd754868b269aeb08a69cb9854937d22e02a", + "pre_source_sha256": "fc68bee389e7b176a63b2e50af4d78c950058413edec9fea24891d7d0d131bbd", + "post_backup_manifest_sha256": "177a52476182d9c9eff8bbd73c3bba30c3bfc7e184cde026d1be43aa0de74a69", + "post_backup_receipt_sha256": "569fb39a7856df3a7ff345a0743b8baa827625f92537cf358d2ed704401ca12e", + "post_source_sha256": "fa44cbe033a0110eb3298584387624d85fd518a84b2841b893d70237b1384309" +} diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index ea549d01f5..7400bf7dd4 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -62,6 +62,7 @@ RECEIPT_FORMAT: Literal["polylogue.historical-source-continuity-recovery-receipt.v1"] = ( "polylogue.historical-source-continuity-recovery-receipt.v1" ) +_HISTORICAL_OPERATION_EVIDENCE = Path(__file__).with_name("historical-source-continuity-operation-20260807.json") class HistoricalSourceContinuityRecoveryError(RuntimeError): @@ -78,6 +79,7 @@ class HistoricalSourceContinuityRecoveryPlan(BaseModel): new_resolved_root: str mutation_receipt_path: str mutation_receipt_sha256: str + historical_evidence_sha256: str legacy_candidate_count: int legacy_candidate_digest: str pre_backup_manifest_path: str @@ -125,6 +127,24 @@ class HistoricalSourceContinuityRecoveryResult(BaseModel): refresh_receipt_path: str +class HistoricalOperationEvidence(BaseModel): + """Digest-only authority for the one historical liveness operation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["polylogue.historical-source-continuity-operation-evidence.v1"] + operation: Literal["blob-ref-liveness-reconciliation-20260807"] + mutation_receipt_sha256: str + candidate_count: int + candidate_digest: str + pre_backup_manifest_sha256: str + pre_backup_receipt_sha256: str + pre_source_sha256: str + post_backup_manifest_sha256: str + post_backup_receipt_sha256: str + post_source_sha256: str + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -162,6 +182,48 @@ def _real_directory(path: Path, *, label: str) -> Path: return resolved +def _historical_operation_evidence() -> HistoricalOperationEvidence: + _real_file(_HISTORICAL_OPERATION_EVIDENCE, label="immutable historical operation evidence") + try: + return HistoricalOperationEvidence.model_validate_json( + _HISTORICAL_OPERATION_EVIDENCE.read_text(encoding="utf-8") + ) + except (OSError, ValueError) as exc: + raise HistoricalSourceContinuityRecoveryError("immutable historical operation evidence is unreadable") from exc + + +def _verify_historical_operation_evidence( + *, + mutation_receipt: Path, + candidates: int, + candidate_digest: str, + pre_manifest: Path, + pre_receipt: Path, + pre_source: Path, + post_manifest: Path, + post_receipt: Path, + post_source: Path, +) -> str: + """Bind recovery to the real 69,340-row operation without retaining private bytes or paths.""" + evidence = _historical_operation_evidence() + actual = { + "mutation_receipt_sha256": _sha256(mutation_receipt), + "candidate_count": candidates, + "candidate_digest": candidate_digest, + "pre_backup_manifest_sha256": _sha256(pre_manifest), + "pre_backup_receipt_sha256": _sha256(pre_receipt), + "pre_source_sha256": _sha256(pre_source), + "post_backup_manifest_sha256": _sha256(post_manifest), + "post_backup_receipt_sha256": _sha256(post_receipt), + "post_source_sha256": _sha256(post_source), + } + if any(getattr(evidence, key) != value for key, value in actual.items()): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery inputs do not match immutable offline evidence" + ) + return _sha256(_HISTORICAL_OPERATION_EVIDENCE) + + def _sealed_plan(**values: object) -> HistoricalSourceContinuityRecoveryPlan: plan = HistoricalSourceContinuityRecoveryPlan.model_validate({**values, "plan_sha256": ""}) return plan.model_copy( @@ -342,6 +404,7 @@ def _assert_pre_train_authority( def _current_evidence(root: Path) -> DurableDatabaseEvidence: + _real_file(root / "source.db", label="live source.db") for suffix in ("-wal", "-shm", "-journal"): if (root / f"source.db{suffix}").exists() or (root / f"source.db{suffix}").is_symlink(): raise HistoricalSourceContinuityRecoveryError( @@ -612,6 +675,17 @@ def prepare_historical_source_continuity_recovery( candidates, candidate_digest = _legacy_liveness_receipt( mutation_receipt, old_source_path=old_source, pre_manifest=pre_backup_manifest.absolute() ) + historical_evidence_sha256 = _verify_historical_operation_evidence( + mutation_receipt=mutation_receipt, + candidates=candidates, + candidate_digest=candidate_digest, + pre_manifest=pre_backup_manifest, + pre_receipt=pre_receipt, + pre_source=pre_backup_manifest.parent / "source.db", + post_manifest=post_backup_manifest, + post_receipt=post_receipt, + post_source=post_backup_manifest.parent / "source.db", + ) try: with sqlite3.connect( f"file:{pre_backup_manifest.parent / 'source.db'}?mode=ro&immutable=1", uri=True @@ -671,6 +745,7 @@ def prepare_historical_source_continuity_recovery( new_resolved_root=str(root), mutation_receipt_path=str(mutation_receipt.absolute()), mutation_receipt_sha256=_sha256(mutation_receipt), + historical_evidence_sha256=historical_evidence_sha256, legacy_candidate_count=candidates, legacy_candidate_digest=candidate_digest, pre_backup_manifest_path=str(pre_backup_manifest.absolute()), diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 68e5382791..af0f9e79df 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4105,29 +4105,38 @@ def test_run_daemon_services_checks_archive_identity_before_component_startup(tm def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The ordinary daemon startup preflight, not a test-only guard, blocks a prepared relocation.""" from polylogue.daemon import cli as daemon_cli + from polylogue.daemon.backup import backup_archive from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, - _sealed_receipt, - _write_receipt, + apply_archive_root_relocation, + prepare_archive_root_relocation, ) - - root = tmp_path / "archive" - root.mkdir() - receipt = _sealed_receipt( - state="prepared", - revision=0, - plan_sha256="a" * 64, - authorization="a" * 64, - manifest_before_sha256=(), - manifest_after_sha256=(), - resume_command="polylogue ops maintenance archive-root-relocation apply --plan plan.json --authorize " - + "a" * 64, + from tests.unit.storage.test_archive_root_relocation import _released_moved_source_train + + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + root = tmp_path / "relocated-archive" + os.rename(old_root, root) + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", ) - _write_receipt(root / ".maintenance-state" / "archive-root-relocations" / "prepared.json", receipt, expected=None) + with monkeypatch.context() as scoped: + scoped.setattr( + "polylogue.operations.archive_root_relocation.rebind_released_source_train_archive_identity", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("leave prepared relocation receipt")), + ) + with pytest.raises(RuntimeError, match="leave prepared relocation receipt"): + apply_archive_root_relocation(root=root, plan=plan, authorization=plan.plan_sha256) configure = Mock() monkeypatch.setattr("polylogue.paths.archive_root", lambda: root) monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", configure) diff --git a/tests/unit/operations/test_maintenance_receipt_fs.py b/tests/unit/operations/test_maintenance_receipt_fs.py new file mode 100644 index 0000000000..23645f8000 --- /dev/null +++ b/tests/unit/operations/test_maintenance_receipt_fs.py @@ -0,0 +1,39 @@ +"""Failure atomicity for descriptor-pinned maintenance receipt directories.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from polylogue.operations._maintenance_receipt_fs import ( + MaintenanceReceiptPathError, + maintenance_receipt_directory, +) + + +def test_fsync_failure_removes_only_the_new_empty_receipt_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed persistence barrier leaves no fresh child residue and preserves an existing child.""" + root = tmp_path / "archive" + state = root / ".maintenance-state" + state.mkdir(parents=True) + existing = state / "existing" + existing.mkdir() + state_inode = state.stat().st_ino + real_fsync = os.fsync + + def fail_state_fsync(descriptor: int) -> None: + if os.fstat(descriptor).st_ino == state_inode: + raise OSError("directory fsync failed") + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations._maintenance_receipt_fs.os.fsync", fail_state_fsync) + with pytest.raises(MaintenanceReceiptPathError, match="maintenance receipt directory"): + with maintenance_receipt_directory(root, "new-child"): + pytest.fail("the failed child directory must not be yielded") + + assert not (state / "new-child").exists() + assert existing.is_dir() diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 06a15be41f..5fc9dcb564 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -16,6 +16,8 @@ from polylogue.daemon.backup import backup_archive from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, + RelocationTierEvidence, + _check_backup_against_live, apply_archive_root_relocation, prepare_archive_root_relocation, ) @@ -29,10 +31,13 @@ HistoricalSourceContinuityRecoveryError, _assert_complete_source_semantic_delta, _assert_exact_liveness_delta, + _current_evidence, _table_content_digest, + _verify_historical_operation_evidence, _write_refresh_receipt, - apply_historical_source_continuity_recovery, - load_historical_source_continuity_recovery_plan, +) +from polylogue.operations.historical_source_continuity_recovery import ( + _legacy_liveness_receipt as _validate_legacy_liveness_receipt, ) from polylogue.operations.historical_source_continuity_recovery import ( _sealed_receipt as _sealed_continuity_receipt, @@ -165,7 +170,7 @@ def test_plan_rejects_byte_identical_copied_archive_with_new_inodes( assert (old_root / "source.db").read_bytes() == (new_root / "source.db").read_bytes() assert (old_root / "source.db").stat().st_ino != (new_root / "source.db").stat().st_ino - with pytest.raises(ArchiveRootRelocationError, match="inode continuity"): + with pytest.raises(ArchiveRootRelocationError, match="device/inode continuity"): prepare_archive_root_relocation( old_root=old_root, new_root=new_root, @@ -177,6 +182,133 @@ def test_plan_rejects_byte_identical_copied_archive_with_new_inodes( assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() +def test_tier_identity_rejects_a_changed_device_with_a_coincident_inode(tmp_path: Path) -> None: + """Tier continuity is the full device/inode pair, not an inode alone.""" + snapshot = RelocationTierEvidence( + tier="source", + configured_path=str(tmp_path / "source.db"), + resolved_path=str(tmp_path / "source.db"), + old_device=41, + old_inode=99, + device=42, + inode=99, + size_bytes=1, + sha256="a" * 64, + user_version=0, + schema_inventory_sha256="b" * 64, + content_sha256="c" * 64, + quick_check=("ok",), + ) + fingerprint = { + "device": snapshot.old_device, + "inode": snapshot.old_inode, + "size_bytes": snapshot.size_bytes, + "sha256": snapshot.sha256, + "user_version": snapshot.user_version, + } + with pytest.raises(ArchiveRootRelocationError, match="device/inode continuity"): + _check_backup_against_live( + tmp_path, + manifest={"tier_source_fingerprints": {"source.db": fingerprint}}, + receipt={"tier_artifacts": [{"tier": "source", "source_fingerprint": fingerprint}]}, + snapshots=(snapshot,), + ) + + +def test_plan_rejects_root_device_change_with_a_coincident_inode( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The full prepare route checks root device/inode continuity from authenticated evidence.""" + from polylogue.operations import archive_root_relocation as relocation + + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + real_authenticated_identity = relocation._authenticated_identity + + def changed_root_device(payload: object, *, label: str) -> tuple[int, int]: + device, inode = real_authenticated_identity(payload, label=label) + return (device + 1, inode) if label == "archive root" else (device, inode) + + monkeypatch.setattr(relocation, "_authenticated_identity", changed_root_device) + with pytest.raises(ArchiveRootRelocationError, match="root device/inode continuity"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + +@pytest.mark.parametrize("leaf_kind", ["symlink", "directory", "hardlink"]) +def test_current_source_evidence_rejects_unverified_live_leaves_before_sqlite_read( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, leaf_kind: str +) -> None: + """Continuity recovery validates the live source leaf before evidence collection.""" + root = tmp_path / leaf_kind + root.mkdir() + source = workspace_env["archive_root"] / "source.db" + target = root / "source.db" + if leaf_kind == "symlink": + target.symlink_to(source) + elif leaf_kind == "directory": + target.mkdir() + else: + os.link(source, target) + monkeypatch.setattr( + "polylogue.operations.historical_source_continuity_recovery.capture_durable_database_evidence", + lambda *_args: pytest.fail("live source evidence was read before leaf validation"), + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="real single-linked file"): + _current_evidence(root) + + +def test_historical_receipt_rejects_a_one_row_substitute_for_the_bound_operation(tmp_path: Path) -> None: + """A small synthetic receipt cannot stand in for the 69,340-row offline operation.""" + receipt = tmp_path / "one-row.jsonl" + old_root = tmp_path / "old" + old_root.mkdir() + pre_manifest = tmp_path / "pre-manifest.json" + pre_manifest.write_text("{}", encoding="utf-8") + candidate = BlobRefLivenessCandidate( + blob_hash="02", + ref_type="attachment", + ref_id="deleted", + source_path=None, + size_bytes=2, + acquired_at_ms=2, + referent_table="raw_sessions", + referent_column="raw_id", + ) + _legacy_liveness_receipt( + receipt, + old_root=old_root, + pre_manifest=pre_manifest, + candidates=(candidate,), + ) + digest = BlobRefLivenessCandidateDigest() + digest.update(candidate) + assert _validate_legacy_liveness_receipt( + receipt, old_source_path=old_root / "source.db", pre_manifest=pre_manifest + ) == (1, digest.hexdigest()) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="immutable offline evidence"): + _verify_historical_operation_evidence( + mutation_receipt=receipt, + candidates=1, + candidate_digest=digest.hexdigest(), + pre_manifest=pre_manifest, + pre_receipt=pre_manifest, + pre_source=pre_manifest, + post_manifest=pre_manifest, + post_receipt=pre_manifest, + post_source=pre_manifest, + ) + + def test_rebind_rewrites_only_the_released_source_identity_fields( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -519,19 +651,13 @@ def test_receipt_writers_never_create_through_a_symlinked_maintenance_state(tmp_ assert not tuple(outside.iterdir()) -def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( +def test_historical_continuity_recovery_cli_rejects_an_unbound_synthetic_operation( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Exercise old-path HMACs, backup bytes, train CAS, census, and ordinary verification. - - This is deliberately file-backed: deleting the recovery identity rewrite, - swapping either backup, or changing the receipt's old path makes the real - plan/apply route fail before the train manifest can be written. - """ - from polylogue.storage.sqlite import durable_change_train as trains + """The production CLI refuses a file-backed substitute for the attested operation.""" old_root = workspace_env["archive_root"] - manifest = _released_moved_source_train(old_root, monkeypatch, include_orphan_blob_ref=True) + _released_moved_source_train(old_root, monkeypatch, include_orphan_blob_ref=True) pre_backup = backup_archive(output_dir=tmp_path / "pre", profile="rebuildable_cache_exclude", verify=True) assert pre_backup.ok and pre_backup.output_path is not None with sqlite3.connect(f"file:{old_root / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: @@ -546,19 +672,11 @@ def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( pre_manifest=pre_manifest, candidates=prior.candidates, ) - with sqlite3.connect(old_root / "source.db") as connection: - connection.execute( - "DELETE FROM blob_refs WHERE blob_hash = X'02' AND ref_type = 'attachment' AND ref_id = 'deleted'" - ) post_backup = backup_archive(output_dir=tmp_path / "post", profile="rebuildable_cache_exclude", verify=True) assert post_backup.ok and post_backup.output_path is not None post_manifest = Path(post_backup.output_path) / "manifest.json" new_root = tmp_path / "moved" os.rename(old_root, new_root) - moved_manifest = new_root / manifest.relative_to(old_root) - database_before = { - path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") - } plan_path = tmp_path / "continuity-plan.json" command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(new_root)} @@ -586,99 +704,8 @@ def test_historical_continuity_recovery_is_a_real_cli_route_and_resumes( env=command_env, catch_exceptions=False, ) - assert plan_result.exit_code == 0, plan_result.output - plan = load_historical_source_continuity_recovery_plan(plan_path) - with monkeypatch.context() as scoped: - scoped.setattr( - "polylogue.maintenance.offline_guard.running_daemon_pid", - lambda _config: 4242, - ) - with pytest.raises(HistoricalSourceContinuityRecoveryError, match="daemon to be stopped"): - apply_historical_source_continuity_recovery( - root=new_root, - plan=plan, - authorization=plan.plan_sha256, - stopped_daemon_evidence_ref="proof:daemon-stopped", - single_writer_evidence_ref="proof:archive-ownership-lock", - ) - assert not (new_root / ".maintenance-state" / "historical-source-continuity-recoveries").exists() - assert database_before == { - path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") - } - with sqlite3.connect(new_root / "source.db") as connection: - with pytest.raises(Exception, match="continuity proof failed"): - trains._verify_released_train_live_tier( - new_root, - connection, - trains.load_durable_change_train_manifest(moved_manifest), - ) - - with monkeypatch.context() as scoped: - scoped.setattr( - "polylogue.operations.historical_source_continuity_recovery.recover_released_source_train_continuity", - lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("crash after prepared receipt")), - ) - with pytest.raises(RuntimeError, match="crash after prepared"): - apply_historical_source_continuity_recovery( - root=new_root, - plan=plan, - authorization=plan.plan_sha256, - stopped_daemon_evidence_ref="proof:daemon-stopped", - single_writer_evidence_ref="proof:archive-ownership-lock", - ) - with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): - trains.reconcile_durable_change_train_startup(new_root) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "source-continuity-recovery", - "apply", - "--plan", - str(plan_path), - "--authorize", - plan.plan_sha256, - "--output-format", - "json", - ], - env=command_env, - catch_exceptions=False, - ) - assert result.exit_code == 0, result.output - assert json.loads(result.output)["state"] == "committed" - # The second apply is the crash-recovery/idempotency path, not a second revision. - replay = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "source-continuity-recovery", - "apply", - "--plan", - str(plan_path), - "--authorize", - plan.plan_sha256, - "--output-format", - "json", - ], - env=command_env, - catch_exceptions=False, - ) - assert replay.exit_code == 0, replay.output - assert json.loads(replay.output)["state"] == "committed" - with sqlite3.connect(new_root / "source.db") as connection: - assert ( - trains._verify_released_train_live_tier( - new_root, - connection, - trains.load_durable_change_train_manifest(moved_manifest), - ) - is None - ) + assert plan_result.exit_code != 0 + assert "immutable offline evidence" in plan_result.output def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_crash( From 062a371dfd6cd15ecd36117afdb42fefd6b87936 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 01:28:43 +0200 Subject: [PATCH 14/39] fix: preserve continuity through archive relocation --- devtools/verify_distribution_surface.py | 13 +- docs/archive-backup.md | 15 +- docs/devtools.md | 4 +- polylogue/cli/click_command_registration.py | 12 +- .../cli/commands/maintenance/__init__.py | 4 +- .../receipt_fs.py} | 78 ++++++- .../operations/archive_root_relocation.py | 80 +++++-- .../historical_source_continuity_recovery.py | 56 +++-- .../storage/sqlite/durable_change_train.py | 164 ++++++++++++++- .../test_verify_distribution_surface.py | 12 ++ .../operations/test_maintenance_receipt_fs.py | 4 +- .../storage/test_archive_root_relocation.py | 199 +++++++++++++++++- 12 files changed, 576 insertions(+), 65 deletions(-) rename polylogue/{operations/_maintenance_receipt_fs.py => maintenance/receipt_fs.py} (64%) diff --git a/devtools/verify_distribution_surface.py b/devtools/verify_distribution_surface.py index 6c44e32308..12def67184 100644 --- a/devtools/verify_distribution_surface.py +++ b/devtools/verify_distribution_surface.py @@ -23,6 +23,7 @@ "polylogue.mcp.cli", "polylogue.archive.query.expression", ) +PACKAGE_RESOURCES = (("polylogue.operations", "historical-source-continuity-operation-20260807.json"),) class DistributionVerificationError(RuntimeError): @@ -98,6 +99,10 @@ def _verify_wheel_surface(wheel: Path) -> None: names = set(archive.namelist()) if "polylogue/_build_info.py" not in names: raise DistributionVerificationError(f"{wheel.name} is missing polylogue/_build_info.py") + for package, resource in PACKAGE_RESOURCES: + resource_path = f"{package.replace('.', '/')}/{resource}" + if resource_path not in names: + raise DistributionVerificationError(f"{wheel.name} is missing package resource {resource_path}") entry_points = _read_entry_points(archive) for script in RUNTIME_SCRIPTS: if f"{script} =" not in entry_points: @@ -135,7 +140,13 @@ def _smoke_installed_wheel(wheel: Path, install_dir: Path) -> None: def _probe_runtime_imports(python: Path, install_dir: Path, env: dict[str, str]) -> None: """Import runtime entrypoint modules from the installed wheel environment.""" modules_literal = repr(RUNTIME_IMPORT_PROBES) - code = f"import importlib\nmodules = {modules_literal}\nfor name in modules:\n importlib.import_module(name)\n" + resources_literal = repr(PACKAGE_RESOURCES) + code = ( + "import importlib\nfrom importlib import resources\n" + f"modules = {modules_literal}\nresources_to_read = {resources_literal}\n" + "for name in modules:\n importlib.import_module(name)\n" + "for package, resource in resources_to_read:\n assert resources.files(package).joinpath(resource).read_text(encoding='utf-8')\n" + ) _run((str(python), "-I", "-c", code), cwd=install_dir, env=env) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index aae677faac..7dd4c3479a 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -60,6 +60,8 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census; it is a separate offline transition, not an exception inside relocation. After it commits, make and verify a fresh `full_evidence` backup at the moved root before relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. +For a deployed archive, run these commands only from the Nix package built from the post-merge commit selected for deployment. Record that merge SHA and the resulting Nix store path in the operator receipt, verify the daemon executable resolves to that exact package, and keep `POLYLOGUE_ARCHIVE_ROOT` set to the configured deployed root. Do not resume a stopped daemon with an older deployed package or a branch checkout: its durable-train vocabulary may predate the relocation transition. + ## Restore Rules Restore into an isolated archive root first: @@ -164,8 +166,9 @@ sqlite3 /source.db "PRAGMA user_version; SELECT count(*) FROM raw_sess # Sane-lag comparison against the live archive (restored counts must be <= # live counts, and the gap should track the age of the chosen archive): -sqlite3 /realm/state/polylogue/user.db "SELECT count(*) FROM assertions;" -sqlite3 /realm/state/polylogue/source.db "SELECT count(*) FROM raw_sessions;" +archive_root="${POLYLOGUE_ARCHIVE_ROOT:?set the configured archive root}" +sqlite3 "$archive_root/user.db" "SELECT count(*) FROM assertions;" +sqlite3 "$archive_root/source.db" "SELECT count(*) FROM raw_sessions;" ``` **Negative control (deliberately corrupted restore must fail loudly)** — @@ -192,10 +195,10 @@ schema `user_version=4`, `source.db` carried 17,839 `raw_sessions` rows at snapshot's 17-day age. The corruption negative control correctly failed with `database disk image is malformed (11)`. -**CRITICAL FINDING — the live durable tier currently has NO Borg coverage.** -`/realm/db/polylogue` (where `source.db`/`user.db` actually live; `/realm/data/captures/polylogue/*.db` -are symlinks to it) was converted to its own nested Btrfs subvolume on -2026-07-06 (`btrfs subvolume list /realm` shows `ID 3862 ... path db/polylogue`). +**CRITICAL FINDING — the durable tier then under review had NO Borg coverage.** +The configured archive root (resolved from `POLYLOGUE_ARCHIVE_ROOT`) was a nested +Btrfs subvolume at the time of the drill. Its location is configuration, not a +fixed runtime path; inspect the resolved root before repeating this evidence. btrbk/Borg snapshot the **parent** `/realm` subvolume only; a nested subvolume shows up as an **empty directory** in every snapshot and archive — confirmed directly: `borg list db/polylogue` returns diff --git a/docs/devtools.md b/docs/devtools.md index a3c6b34028..e7a5566de8 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -233,8 +233,8 @@ These are the commands worth remembering during normal repo work: ## Cursor-authority reconciliation `polylogue ops maintenance cursor-authority-reconcile` is a dry-run-by-default -repair route for exactly one proven cursor-ahead source. It reads the fixed -`/realm/state/polylogue` archive root, requires the daemon to be stopped, and +repair route for exactly one proven cursor-ahead source. It reads the +configured `POLYLOGUE_ARCHIVE_ROOT` (using its resolved archive root), requires the daemon to be stopped, and writes a plan containing path and raw identifiers only as digests. Apply requires that immutable plan, a freshly verified `full_evidence` backup manifest with blob rollback evidence, and a new receipt path. The apply route diff --git a/polylogue/cli/click_command_registration.py b/polylogue/cli/click_command_registration.py index 2e6b67675d..6de7be4c25 100644 --- a/polylogue/cli/click_command_registration.py +++ b/polylogue/cli/click_command_registration.py @@ -50,10 +50,7 @@ class _LazyGroup(_LazyCommand, click.Group): """Lazy proxy for Click groups that need nested command dispatch.""" def invoke(self, ctx: click.Context) -> object: - # Dispatch through this proxy's delegated ``get_command``. Invoking - # the resolved group directly loses Click's child-command context and - # leaves its subcommand options attached to the parent group. - return click.Group.invoke(self, ctx) + return self._resolve().invoke(ctx) def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: return click.Group.parse_args(self, ctx, args) @@ -87,6 +84,13 @@ def list_commands(self, ctx: click.Context) -> list[str]: return resolved.list_commands(ctx) +class _NestedLazyGroup(_LazyGroup): + """Lazy group whose newly-added nested routes dispatch through the proxy.""" + + def invoke(self, ctx: click.Context) -> object: + return click.Group.invoke(self, ctx) + + _SHORT_HELP: dict[str, str] = { "agent": "Install executable agent guidance.", "agents": "Inspect agent coordination state.", diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index 875ec4a3e2..cf68779388 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -15,7 +15,7 @@ import click -from polylogue.cli.click_command_registration import _LazyCommand, _LazyGroup +from polylogue.cli.click_command_registration import _LazyCommand, _NestedLazyGroup # (cli name, submodule, attribute, short_help) _COMMANDS: tuple[tuple[str, str, str, str], ...] = ( @@ -250,7 +250,7 @@ def maintenance_group(ctx: click.Context) -> None: for _cli_name, _submodule, _attr, _short_help in _COMMANDS: _command_type = ( - _LazyGroup if _cli_name in {"archive-root-relocation", "source-continuity-recovery"} else _LazyCommand + _NestedLazyGroup if _cli_name in {"archive-root-relocation", "source-continuity-recovery"} else _LazyCommand ) maintenance_group.add_command( _command_type( diff --git a/polylogue/operations/_maintenance_receipt_fs.py b/polylogue/maintenance/receipt_fs.py similarity index 64% rename from polylogue/operations/_maintenance_receipt_fs.py rename to polylogue/maintenance/receipt_fs.py index 83b0bb17a2..6f7c7ef3cf 100644 --- a/polylogue/operations/_maintenance_receipt_fs.py +++ b/polylogue/maintenance/receipt_fs.py @@ -1,4 +1,4 @@ -"""Descriptor-pinned publication for retained offline-maintenance receipts.""" +"""Descriptor-pinned publication and reading of retained maintenance receipts.""" from __future__ import annotations @@ -60,17 +60,26 @@ def _remove_created_empty_child(parent_fd: int, name: str, *, expected: os.stat_ @contextmanager -def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> Iterator[int]: +def _maintenance_receipt_directory(archive_root: Path, directory_name: str, *, create: bool) -> Iterator[int | None]: """Yield a pinned child of an existing, non-symlink ``.maintenance-state``.""" child_name = _simple_name(directory_name, label="maintenance receipt directory name") root_fd = _open_directory(archive_root, label="archive root") state_fd = -1 child_fd = -1 try: - state_fd = _open_directory_at(root_fd, ".maintenance-state", label="maintenance state") + try: + state_fd = _open_directory_at(root_fd, ".maintenance-state", label="maintenance state") + except MaintenanceReceiptPathError as exc: + if not create and isinstance(exc.__cause__, FileNotFoundError): + yield None + return + raise try: child_fd = os.open(child_name, _DIRECTORY_FLAGS, dir_fd=state_fd) except FileNotFoundError: + if not create: + yield None + return created_child = False try: os.mkdir(child_name, mode=0o700, dir_fd=state_fd) @@ -102,6 +111,24 @@ def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> It os.close(root_fd) +@contextmanager +def maintenance_receipt_directory(archive_root: Path, directory_name: str) -> Iterator[int]: + """Yield a pinned receipt directory, creating it only for publication.""" + with _maintenance_receipt_directory(archive_root, directory_name, create=True) as directory_fd: + assert directory_fd is not None + yield directory_fd + + +@contextmanager +def existing_maintenance_receipt_directory(archive_root: Path, directory_name: str) -> Iterator[int | None]: + """Yield a pinned existing receipt directory, or ``None`` when absent. + + Startup guards must not create maintenance state merely by inspecting it. + """ + with _maintenance_receipt_directory(archive_root, directory_name, create=False) as directory_fd: + yield directory_fd + + def read_optional_receipt(directory_fd: int, filename: str) -> bytes | None: """Read one regular, single-linked receipt relative to a pinned directory.""" name = _simple_name(filename, label="maintenance receipt filename") @@ -121,6 +148,49 @@ def read_optional_receipt(directory_fd: int, filename: str) -> bytes | None: os.close(descriptor) +def iter_pinned_receipts(directory_fd: int, *, suffix: str = ".json") -> Iterator[tuple[str, bytes]]: + """Enumerate regular receipts through one pinned directory descriptor. + + The enumeration captures each directory-entry identity, then the actual + read verifies that the opened ``O_NOFOLLOW`` descriptor is still that + entry. A rename between discovery and open therefore fails closed rather + than redirecting daemon preflight to a different receipt. + """ + entries: list[tuple[str, tuple[int, int]]] = [] + try: + with os.scandir(directory_fd) as scan: + for entry in scan: + if not entry.name.endswith(suffix): + continue + metadata = entry.stat(follow_symlinks=False) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise MaintenanceReceiptPathError( + f"maintenance receipt is not a regular single-linked file: {entry.name}" + ) + entries.append((entry.name, (metadata.st_dev, metadata.st_ino))) + except OSError as exc: + raise MaintenanceReceiptPathError("cannot enumerate maintenance receipts through pinned directory") from exc + for name, expected_identity in sorted(entries): + try: + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory_fd) + except OSError as exc: + raise MaintenanceReceiptPathError( + f"cannot open maintenance receipt without following links: {name}" + ) from exc + try: + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or (metadata.st_dev, metadata.st_ino) != expected_identity + ): + raise MaintenanceReceiptPathError(f"maintenance receipt changed during pinned enumeration: {name}") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + yield name, stream.read() + finally: + os.close(descriptor) + + def atomic_replace_receipt(directory_fd: int, filename: str, payload: bytes) -> None: """Fsync and atomically replace one file within a pinned receipt directory.""" name = _simple_name(filename, label="maintenance receipt filename") @@ -153,6 +223,8 @@ def atomic_replace_receipt(directory_fd: int, filename: str, payload: bytes) -> __all__ = [ "MaintenanceReceiptPathError", "atomic_replace_receipt", + "existing_maintenance_receipt_directory", + "iter_pinned_receipts", "maintenance_receipt_directory", "read_optional_receipt", ] diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index c4f5d4d61d..ce633eb03f 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -15,9 +15,11 @@ from polylogue.config import Config from polylogue.maintenance.offline_guard import offline_writer_block_reason -from polylogue.operations._maintenance_receipt_fs import ( +from polylogue.maintenance.receipt_fs import ( MaintenanceReceiptPathError, atomic_replace_receipt, + existing_maintenance_receipt_directory, + iter_pinned_receipts, maintenance_receipt_directory, read_optional_receipt, ) @@ -39,6 +41,7 @@ load_durable_change_train_manifest, rebind_released_source_train_archive_identity, write_durable_change_train_manifest, + write_source_continuity_relocation_transition, ) from polylogue.storage.sqlite.migration_runner import ( MigrationError, @@ -126,6 +129,7 @@ class ArchiveRootRelocationReceipt(BaseModel): manifest_before_sha256: tuple[str, ...] manifest_after_sha256: tuple[str, ...] resume_command: str + prepared_receipt_sha256: str | None = None receipt_sha256: str @@ -555,21 +559,29 @@ def _write_receipt(path: Path, receipt: ArchiveRootRelocationReceipt, *, expecte def load_archive_root_relocation_receipt(path: Path) -> ArchiveRootRelocationReceipt: - _real_file(path, label="archive-root relocation receipt") try: - encoded = path.read_bytes() - except (OSError, ValueError) as exc: - raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") from exc + root, directory_name = _receipt_directory_binding(path) + with existing_maintenance_receipt_directory(root, directory_name) as directory_fd: + if directory_fd is None: + raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") + encoded = read_optional_receipt(directory_fd, path.name) + except MaintenanceReceiptPathError as exc: + raise ArchiveRootRelocationError(f"unsafe archive-root relocation receipt path: {path}") from exc + if encoded is None: + raise ArchiveRootRelocationError(f"invalid archive-root relocation receipt: {path}") return _decode_receipt(encoded, path=path) def assert_no_prepared_archive_root_relocation(root: Path) -> None: - receipt_root = root / ".maintenance-state" / "archive-root-relocations" - if not receipt_root.exists(): - return - _real_directory(receipt_root, label="archive-root relocation receipt directory") - for path in sorted(receipt_root.glob("*.json")): - receipt = load_archive_root_relocation_receipt(path) + try: + with existing_maintenance_receipt_directory(root, "archive-root-relocations") as directory_fd: + if directory_fd is None: + return + receipts = tuple(iter_pinned_receipts(directory_fd)) + except MaintenanceReceiptPathError as exc: + raise ArchiveRootRelocationError(f"unsafe archive-root relocation receipt directory: {exc}") from exc + for filename, encoded in receipts: + receipt = _decode_receipt(encoded, path=root / ".maintenance-state" / "archive-root-relocations" / filename) if receipt.state == "prepared": raise ArchiveRootRelocationError( "archive-root relocation is prepared but incomplete; rerun " + receipt.resume_command @@ -623,6 +635,10 @@ def _revalidate_plan_live_state( if snapshots != plan.tiers: raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) + pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) + allowed_pending_relocation_receipt_sha256 = ( + pending_receipt.receipt_sha256 if pending_receipt is not None and pending_receipt.state == "prepared" else None + ) for item in plan.source_trains: path = Path(item.path) train = load_durable_change_train_manifest(path) @@ -631,15 +647,6 @@ def _revalidate_plan_live_state( for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") ) - if continuity_refs != item.source_continuity_receipt_digests: - raise ArchiveRootRelocationError(f"archive-root relocation continuity receipts changed: {path}") - if train.source_continuity_evidence is not None: - try: - _validate_source_continuity_refresh_receipt(root, train) - except DurableChangeTrainError as exc: - raise ArchiveRootRelocationError( - f"archive-root relocation continuity receipt is invalid: {path}" - ) from exc before = _sha256_file(path) == item.before_manifest_sha256 after = ( train.revision == item.before_revision + (1 if item.requires_rebind else 0) @@ -652,6 +659,19 @@ def _revalidate_plan_live_state( ) if not before and not after: raise ArchiveRootRelocationError(f"archive-root relocation manifest changed: {path}") + if before and continuity_refs != item.source_continuity_receipt_digests: + raise ArchiveRootRelocationError(f"archive-root relocation continuity receipts changed: {path}") + if train.source_continuity_evidence is not None: + try: + _validate_source_continuity_refresh_receipt( + root, + train, + allowed_pending_relocation_receipt_sha256=allowed_pending_relocation_receipt_sha256, + ) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + f"archive-root relocation continuity receipt is invalid: {path}" + ) from exc def _require_offline_apply_boundary(root: Path) -> None: @@ -735,10 +755,27 @@ def _apply_archive_root_relocation_locked( train = load_durable_change_train_manifest(path) actual_hash = _sha256_file(path) if actual_hash == item.before_manifest_sha256 and item.requires_rebind: + continuity_transition_ref = None + if train.source_continuity_evidence is not None: + transition_digest = write_source_continuity_relocation_transition( + root, + train=train, + archive_identity_digest=item.after_archive_identity_digest, + relocation_plan_sha256=plan.plan_sha256, + relocation_receipt_sha256=receipt.receipt_sha256, + ) + continuity_transition_ref = f"proof:source-continuity-relocation:{transition_digest}" updated = rebind_released_source_train_archive_identity( train, archive_identity_digest=item.after_archive_identity_digest, - proof_ref=f"proof:archive-root-relocation:{receipt.receipt_sha256}", + proof_refs=tuple( + ref + for ref in ( + f"proof:archive-root-relocation:{receipt.receipt_sha256}", + continuity_transition_ref, + ) + if ref is not None + ), ) write_durable_change_train_manifest(path, updated, expected_revision=item.before_revision) elif ( @@ -758,6 +795,7 @@ def _apply_archive_root_relocation_locked( manifest_before_sha256=before_hashes, manifest_after_sha256=tuple(after_hashes), resume_command=command, + prepared_receipt_sha256=receipt.receipt_sha256, ) _write_receipt(receipt_path, committed, expected=receipt.receipt_sha256) return ArchiveRootRelocationResult( diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 7400bf7dd4..fe3b99b630 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -15,6 +15,7 @@ import sqlite3 import stat import tempfile +from importlib import resources from pathlib import Path from typing import Literal, cast @@ -23,9 +24,11 @@ from polylogue.config import Config from polylogue.maintenance.blob_ref_liveness_reconciliation import census_blob_ref_liveness from polylogue.maintenance.offline_guard import offline_writer_block_reason -from polylogue.operations._maintenance_receipt_fs import ( +from polylogue.maintenance.receipt_fs import ( MaintenanceReceiptPathError, atomic_replace_receipt, + existing_maintenance_receipt_directory, + iter_pinned_receipts, maintenance_receipt_directory, read_optional_receipt, ) @@ -62,7 +65,7 @@ RECEIPT_FORMAT: Literal["polylogue.historical-source-continuity-recovery-receipt.v1"] = ( "polylogue.historical-source-continuity-recovery-receipt.v1" ) -_HISTORICAL_OPERATION_EVIDENCE = Path(__file__).with_name("historical-source-continuity-operation-20260807.json") +_HISTORICAL_OPERATION_EVIDENCE_RESOURCE = "historical-source-continuity-operation-20260807.json" class HistoricalSourceContinuityRecoveryError(RuntimeError): @@ -182,13 +185,18 @@ def _real_directory(path: Path, *, label: str) -> Path: return resolved +def _historical_operation_evidence_bytes() -> bytes: + """Read the immutable operation evidence from the installed package.""" + try: + return resources.files("polylogue.operations").joinpath(_HISTORICAL_OPERATION_EVIDENCE_RESOURCE).read_bytes() + except FileNotFoundError as exc: + raise HistoricalSourceContinuityRecoveryError("immutable historical operation evidence is unreadable") from exc + + def _historical_operation_evidence() -> HistoricalOperationEvidence: - _real_file(_HISTORICAL_OPERATION_EVIDENCE, label="immutable historical operation evidence") try: - return HistoricalOperationEvidence.model_validate_json( - _HISTORICAL_OPERATION_EVIDENCE.read_text(encoding="utf-8") - ) - except (OSError, ValueError) as exc: + return HistoricalOperationEvidence.model_validate_json(_historical_operation_evidence_bytes()) + except ValueError as exc: raise HistoricalSourceContinuityRecoveryError("immutable historical operation evidence is unreadable") from exc @@ -221,7 +229,7 @@ def _verify_historical_operation_evidence( raise HistoricalSourceContinuityRecoveryError( "historical continuity recovery inputs do not match immutable offline evidence" ) - return _sha256(_HISTORICAL_OPERATION_EVIDENCE) + return hashlib.sha256(_historical_operation_evidence_bytes()).hexdigest() def _sealed_plan(**values: object) -> HistoricalSourceContinuityRecoveryPlan: @@ -846,21 +854,33 @@ def _write_receipt(path: Path, receipt: HistoricalSourceContinuityRecoveryReceip def load_historical_source_continuity_recovery_receipt(path: Path) -> HistoricalSourceContinuityRecoveryReceipt: - _real_file(path, label="historical continuity recovery receipt") try: - encoded = path.read_bytes() - except (OSError, ValueError) as exc: - raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") from exc + root, directory_name = _recovery_receipt_directory_binding(path) + with existing_maintenance_receipt_directory(root, directory_name) as directory_fd: + if directory_fd is None: + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") + encoded = read_optional_receipt(directory_fd, path.name) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"unsafe historical continuity recovery receipt path: {path}" + ) from exc + if encoded is None: + raise HistoricalSourceContinuityRecoveryError("invalid historical continuity recovery receipt") return _decode_recovery_receipt(encoded) def assert_no_prepared_historical_source_continuity_recovery(root: Path) -> None: - receipt_root = root / ".maintenance-state" / "historical-source-continuity-recoveries" - if not receipt_root.exists(): - return - _real_directory(receipt_root, label="historical continuity recovery receipt directory") - for path in sorted(receipt_root.glob("*.json")): - receipt = load_historical_source_continuity_recovery_receipt(path) + try: + with existing_maintenance_receipt_directory(root, "historical-source-continuity-recoveries") as directory_fd: + if directory_fd is None: + return + receipts = tuple(iter_pinned_receipts(directory_fd)) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"unsafe historical continuity recovery receipt directory: {exc}" + ) from exc + for _filename, encoded in receipts: + receipt = _decode_recovery_receipt(encoded) if receipt.state == "prepared": raise HistoricalSourceContinuityRecoveryError( "historical source continuity recovery is prepared but incomplete; rerun " + receipt.resume_command diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 6f6b34b211..c8dceb7aff 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -18,6 +18,13 @@ from pathlib import Path from typing import Final, Literal, cast +from polylogue.maintenance.receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + existing_maintenance_receipt_directory, + maintenance_receipt_directory, + read_optional_receipt, +) from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, BlobRefLivenessCandidateDigest, @@ -75,6 +82,7 @@ _MIGRATION_NAME_RE = re.compile(r"^(?P\d{3,})_[a-z0-9_]+\.sql$") _DROP_SQL_RE = re.compile(r"(?is)\bDROP\s+(?:TABLE|INDEX|TRIGGER|VIEW)\b") _SOURCE_CONTINUITY_PENDING_FORMAT = "polylogue.source-continuity-pending.v1" +_SOURCE_CONTINUITY_RELOCATION_FORMAT = "polylogue.source-continuity-relocation.v1" _SourceContinuityMutationKind = Literal["blob_ref_liveness", "raw_authority_recovery"] _FRESH_DURABLE_BOOTSTRAP_FORMAT = "polylogue.durable-bootstrap.v1" _FRESH_DURABLE_BOOTSTRAP_MARKER = ".bootstrap" @@ -559,7 +567,7 @@ def rebind_released_source_train_archive_identity( train: DurableChangeTrain, *, archive_identity_digest: str, - proof_ref: str, + proof_refs: tuple[str, ...], ) -> DurableChangeTrain: """Return the one permitted root-relocation revision of a source train.""" if train.tier is not ArchiveTier.SOURCE or train.state is not DurableChangeTrainState.RELEASED: @@ -571,13 +579,17 @@ def rebind_released_source_train_archive_identity( evidence = replace(train.apply_evidence, post=post) continuity = train.source_continuity_evidence if continuity is not None: + if not any(ref.startswith("proof:source-continuity-relocation:") for ref in proof_refs): + raise DurableChangeTrainError( + "archive-root relocation requires an authenticated source-continuity relocation transition" + ) continuity = replace(continuity, archive_identity_digest=archive_identity_digest) updated = replace( train, revision=train.revision + 1, apply_evidence=evidence, source_continuity_evidence=continuity, - proof_refs=_migration_runner._append_proof_refs(train.proof_refs, proof_ref), + proof_refs=_migration_runner._append_proof_refs(train.proof_refs, *proof_refs), ) validate_durable_change_train_manifest(updated) return updated @@ -1095,6 +1107,8 @@ def _validate_source_mutation_receipt_bytes( def _validate_source_continuity_refresh_receipt( archive_root: Path, train: DurableChangeTrain, + *, + allowed_pending_relocation_receipt_sha256: str | None = None, ) -> None: """Require the latest source continuity evidence to retain its receipt.""" if train.source_continuity_evidence is None: @@ -1106,12 +1120,31 @@ def _validate_source_continuity_refresh_receipt( for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") ] + relocation_refs = [ + ref.removeprefix("proof:source-continuity-relocation:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-relocation:") + ] if not refresh_refs: raise DurableChangeTrainError("source continuity evidence has no retained refresh receipt") - matches = 0 + refresh_payloads: dict[str, dict[str, object]] = {} for digest in refresh_refs: receipt_path = refresh_root / f"{digest}.json" payload = _read_source_continuity_refresh_receipt(receipt_path, digest=digest, train=train) + refresh_payloads[digest] = payload + matches = sum(payload.get("source_after") == expected_after for payload in refresh_payloads.values()) + for digest in relocation_refs: + payload = _read_source_continuity_relocation_receipt( + archive_root, + digest=digest, + train=train, + allowed_pending_relocation_receipt_sha256=allowed_pending_relocation_receipt_sha256, + ) + refresh_digest = payload.get("refresh_receipt_sha256") + if not isinstance(refresh_digest, str) or refresh_digest not in refresh_payloads: + raise DurableChangeTrainError("source continuity relocation transition lacks its retained refresh receipt") + if payload.get("source_before") != refresh_payloads[refresh_digest].get("source_after"): + raise DurableChangeTrainError("source continuity relocation transition does not preserve refresh authority") if payload.get("source_after") == expected_after: matches += 1 if matches != 1: @@ -1144,6 +1177,124 @@ def _read_source_continuity_refresh_receipt( return payload +def _read_source_continuity_relocation_receipt( + archive_root: Path, + *, + digest: str, + train: DurableChangeTrain, + allowed_pending_relocation_receipt_sha256: str | None, +) -> dict[str, object]: + """Load a root-relocation transition without replacing its older receipt.""" + from polylogue.operations.archive_root_relocation import load_archive_root_relocation_receipt + + receipt_path = archive_root / ".maintenance-state" / "source-continuity-relocations" / f"{digest}.json" + try: + with existing_maintenance_receipt_directory(archive_root, "source-continuity-relocations") as directory_fd: + encoded = None if directory_fd is None else read_optional_receipt(directory_fd, receipt_path.name) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError("source continuity relocation receipt is unreadable") from exc + if encoded is None: + raise DurableChangeTrainError("source continuity relocation receipt is missing") + try: + raw = json.loads(encoded) + except json.JSONDecodeError as exc: + raise DurableChangeTrainError("source continuity relocation receipt is unreadable") from exc + if not isinstance(raw, dict): + raise DurableChangeTrainError("source continuity relocation receipt is not an object") + payload = cast(dict[str, object], raw) + transition_sha256 = payload.pop("transition_sha256", None) + if transition_sha256 != digest or _canonical_json_sha256(payload) != digest: + raise DurableChangeTrainError("source continuity relocation receipt checksum mismatch") + if payload.get("format") != _SOURCE_CONTINUITY_RELOCATION_FORMAT or payload.get("train_id") != train.train_id: + raise DurableChangeTrainError("source continuity relocation receipt does not bind this source train") + plan_sha256 = payload.get("relocation_plan_sha256") + relocation_receipt_sha256 = payload.get("relocation_receipt_sha256") + if not isinstance(plan_sha256, str) or not isinstance(relocation_receipt_sha256, str): + raise DurableChangeTrainError("source continuity relocation receipt lacks relocation authority") + relocation_receipt = load_archive_root_relocation_receipt( + archive_root / ".maintenance-state" / "archive-root-relocations" / f"{plan_sha256}.json" + ) + receipt_succeeds_transition = ( + relocation_receipt.receipt_sha256 == relocation_receipt_sha256 + or relocation_receipt.prepared_receipt_sha256 == relocation_receipt_sha256 + ) + if ( + relocation_receipt.plan_sha256 != plan_sha256 + or not receipt_succeeds_transition + or f"proof:archive-root-relocation:{relocation_receipt_sha256}" not in train.proof_refs + ): + raise DurableChangeTrainError("source continuity relocation receipt does not bind the relocation receipt") + if ( + relocation_receipt.state != "committed" + and relocation_receipt_sha256 != allowed_pending_relocation_receipt_sha256 + ): + raise DurableChangeTrainError("source continuity relocation receipt is not committed") + return payload + + +def write_source_continuity_relocation_transition( + archive_root: Path, + *, + train: DurableChangeTrain, + archive_identity_digest: str, + relocation_plan_sha256: str, + relocation_receipt_sha256: str, +) -> str: + """Bind relocated source continuity to its immutable prior refresh receipt. + + This is intentionally a new receipt rather than an edit to the historical + refresh artifact: the old receipt remains authority for the old identity, + while this transition authenticates the sole permitted identity rewrite. + """ + if train.source_continuity_evidence is None: + raise DurableChangeTrainError("source continuity relocation requires retained continuity evidence") + _migration_runner._validate_sha256(archive_identity_digest, label="relocated archive identity") + _migration_runner._validate_sha256(relocation_plan_sha256, label="relocation plan") + _migration_runner._validate_sha256(relocation_receipt_sha256, label="relocation receipt") + old_after = _migration_runner._manifest_json_value(train.source_continuity_evidence) + refresh_refs = [ + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") + ] + matching_refreshes: list[str] = [] + for digest in refresh_refs: + payload = _read_source_continuity_refresh_receipt( + archive_root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json", + digest=digest, + train=train, + ) + if payload.get("source_after") == old_after: + matching_refreshes.append(digest) + if len(matching_refreshes) != 1: + raise DurableChangeTrainError("source continuity relocation requires exactly one retained refresh authority") + relocated = _migration_runner._manifest_json_value( + replace(train.source_continuity_evidence, archive_identity_digest=archive_identity_digest) + ) + payload = { + "format": _SOURCE_CONTINUITY_RELOCATION_FORMAT, + "train_id": train.train_id, + "refresh_receipt_sha256": matching_refreshes[0], + "source_before": old_after, + "source_after": relocated, + "relocation_plan_sha256": relocation_plan_sha256, + "relocation_receipt_sha256": relocation_receipt_sha256, + } + digest = _canonical_json_sha256(payload) + encoded = (json.dumps({**payload, "transition_sha256": digest}, indent=2, sort_keys=True) + "\n").encode() + try: + with maintenance_receipt_directory(archive_root, "source-continuity-relocations") as directory_fd: + current = read_optional_receipt(directory_fd, f"{digest}.json") + if current is not None: + if current != encoded: + raise DurableChangeTrainError("source continuity relocation receipt collision") + return digest + atomic_replace_receipt(directory_fd, f"{digest}.json", encoded) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError("cannot persist source continuity relocation receipt") from exc + return digest + + def refresh_released_source_train_continuity( archive_root: Path, *, @@ -1924,6 +2075,13 @@ def _verify_released_train_live_tier( return None historical = _historical_schema_evidence(train) expected_identity = train.apply_evidence.post.archive_identity_digest + if train.source_continuity_evidence is not None: + # A relocated recovered train must keep proving the retained refresh + # chain even after a later train advances the live source tier. The + # historical-version branch used to compare only the rewritten + # manifest digest, leaving that receipt authority unauthenticated. + _validate_source_continuity_refresh_receipt(archive_root, train) + expected_identity = train.source_continuity_evidence.archive_identity_digest if not _archive_identity_continuity_matches( actual.archive_identity_digest, expected_identity, diff --git a/tests/unit/devtools/test_verify_distribution_surface.py b/tests/unit/devtools/test_verify_distribution_surface.py index fb76e2451a..869d5ed675 100644 --- a/tests/unit/devtools/test_verify_distribution_surface.py +++ b/tests/unit/devtools/test_verify_distribution_surface.py @@ -16,6 +16,13 @@ def test_verify_wheel_surface_accepts_runtime_scripts(tmp_path: Path) -> None: surface._verify_wheel_surface(wheel) +def test_verify_wheel_surface_requires_historical_operation_resource(tmp_path: Path) -> None: + wheel = _write_wheel(tmp_path, entry_points=_runtime_entry_points(), include_resources=False) + + with pytest.raises(surface.DistributionVerificationError, match="historical-source-continuity-operation"): + surface._verify_wheel_surface(wheel) + + def test_verify_distribution_surface_builds_sdist_wheel_and_smokes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -50,6 +57,7 @@ def fake_run(cmd: tuple[str, ...], *, cwd: Path, env: dict[str, str] | None = No import_probes = [call for call in calls if len(call) >= 4 and call[1:3] == ("-I", "-c")] assert len(import_probes) == 2 assert all("polylogue.archive.query.expression" in call[3] for call in import_probes) + assert all("historical-source-continuity-operation-20260807.json" in call[3] for call in import_probes) smoke_commands = [" ".join(call) for call in calls] assert sum("polylogue --plain ops diagnostics workload --json" in call for call in smoke_commands) == 2 assert sum("polylogue --plain ops diagnostics space --json" in call for call in smoke_commands) == 2 @@ -81,11 +89,15 @@ def _write_wheel( *, entry_points: str, extra_files: dict[str, str] | None = None, + include_resources: bool = True, ) -> Path: wheel = directory / "polylogue-0.1.0-py3-none-any.whl" with zipfile.ZipFile(wheel, "w") as archive: archive.writestr("polylogue/__init__.py", "") archive.writestr("polylogue/_build_info.py", 'BUILD_COMMIT = "deadbeef"\nBUILD_DIRTY = False\n') + if include_resources: + archive.writestr("polylogue/operations/__init__.py", "") + archive.writestr("polylogue/operations/historical-source-continuity-operation-20260807.json", "{}\n") archive.writestr("polylogue-0.1.0.dist-info/entry_points.txt", entry_points) for name, content in (extra_files or {}).items(): archive.writestr(name, content) diff --git a/tests/unit/operations/test_maintenance_receipt_fs.py b/tests/unit/operations/test_maintenance_receipt_fs.py index 23645f8000..f34cf41e17 100644 --- a/tests/unit/operations/test_maintenance_receipt_fs.py +++ b/tests/unit/operations/test_maintenance_receipt_fs.py @@ -7,7 +7,7 @@ import pytest -from polylogue.operations._maintenance_receipt_fs import ( +from polylogue.maintenance.receipt_fs import ( MaintenanceReceiptPathError, maintenance_receipt_directory, ) @@ -30,7 +30,7 @@ def fail_state_fsync(descriptor: int) -> None: raise OSError("directory fsync failed") real_fsync(descriptor) - monkeypatch.setattr("polylogue.operations._maintenance_receipt_fs.os.fsync", fail_state_fsync) + monkeypatch.setattr("polylogue.maintenance.receipt_fs.os.fsync", fail_state_fsync) with pytest.raises(MaintenanceReceiptPathError, match="maintenance receipt directory"): with maintenance_receipt_directory(root, "new-child"): pytest.fail("the failed child directory must not be yielded") diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 5fc9dcb564..fb294526fb 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -19,6 +19,7 @@ RelocationTierEvidence, _check_backup_against_live, apply_archive_root_relocation, + assert_no_prepared_archive_root_relocation, prepare_archive_root_relocation, ) from polylogue.operations.archive_root_relocation import ( @@ -35,6 +36,7 @@ _table_content_digest, _verify_historical_operation_evidence, _write_refresh_receipt, + assert_no_prepared_historical_source_continuity_recovery, ) from polylogue.operations.historical_source_continuity_recovery import ( _legacy_liveness_receipt as _validate_legacy_liveness_receipt, @@ -45,7 +47,7 @@ from polylogue.operations.historical_source_continuity_recovery import ( _write_receipt as _write_continuity_receipt, ) -from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation +from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation, OwnedArchiveLocation from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, BlobRefLivenessCandidateDigest, @@ -58,9 +60,11 @@ rebind_released_source_train_archive_identity, ) from polylogue.storage.sqlite.migration_runner import ( + _canonical_json_sha256, apply_durable_change_train, capture_durable_database_evidence, capture_durable_restart_convergence, + capture_durable_schema_inventory, prove_durable_change_train, record_durable_writer_release, release_durable_change_train, @@ -87,6 +91,21 @@ def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict assert "--old-root" in nested.output +def test_relocation_nested_dispatch_keeps_analyze_facets_on_the_real_action(cli_workspace: dict[str, object]) -> None: + """Nested maintenance routing must not turn the existing aggregate action into a silent no-op.""" + archive_root = cli_workspace["archive_root"] + assert isinstance(archive_root, Path) + result = CliRunner().invoke( + cli, + ["--plain", "analyze", "--facets"], + env={"POLYLOGUE_ARCHIVE_ROOT": str(archive_root)}, + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "Facets (global)" in result.output + + def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( workspace_env: dict[str, Path], tmp_path: Path ) -> None: @@ -344,7 +363,7 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( updated = rebind_released_source_train_archive_identity( before, archive_identity_digest="a" * 64, - proof_ref="proof:archive-root-relocation:receipt", + proof_refs=("proof:archive-root-relocation:receipt",), ) assert updated.revision == before.revision + 1 @@ -362,7 +381,10 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( rebound_current_authority = rebind_released_source_train_archive_identity( current_authority, archive_identity_digest="c" * 64, - proof_ref="proof:archive-root-relocation:receipt-current", + proof_refs=( + "proof:archive-root-relocation:receipt-current", + "proof:source-continuity-relocation:" + "e" * 64, + ), ) assert current_authority.source_continuity_evidence is not None assert rebound_current_authority.source_continuity_evidence == replace( @@ -371,6 +393,52 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( ) +def _attach_retained_source_continuity(root: Path, manifest: Path) -> None: + """Create the exact ordinary refresh artifact retained by a recovered train.""" + train = load_durable_change_train_manifest(manifest) + assert train.apply_evidence is not None + with sqlite3.connect(root / "source.db") as connection: + current = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + legacy_identity_digest = ArchiveIdentity.resolve(root).authority_identity_digest + retained_current = replace(current, archive_identity_digest=legacy_identity_digest) + recovered_apply_evidence = replace( + train.apply_evidence, + post=replace(train.apply_evidence.post, archive_identity_digest=legacy_identity_digest), + ) + payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": "historical-recovery", + "evidence_ref": "proof:historical-source-continuity-recovery", + "backup_manifest": "/authenticated/pre/manifest.json", + "backup_manifest_sha256": "a" * 64, + "mutation_receipt": "/authenticated/liveness.jsonl", + "mutation_receipt_sha256": "b" * 64, + "train_id": train.train_id, + "source_before": _evidence_payload(recovered_apply_evidence.post), + "source_after": _evidence_payload(retained_current), + "refreshed_at_ms": retained_current.observed_at_ms, + } + digest = _canonical_json_sha256(payload) + _write_refresh_receipt( + root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json", + {**payload, "refresh_sha256": digest}, + ) + recovered = replace( + train, + revision=train.revision + 1, + apply_evidence=recovered_apply_evidence, + source_continuity_evidence=retained_current, + proof_refs=(*train.proof_refs, f"proof:source-continuity-refresh:{digest}"), + ) + write_durable_change_train_manifest(manifest, recovered, expected_revision=train.revision) + + +def _evidence_payload(evidence: object) -> dict[str, object]: + from polylogue.operations.historical_source_continuity_recovery import _evidence_payload as render + + return render(evidence) # type: ignore[arg-type] + + def _released_moved_source_train( root: Path, monkeypatch: pytest.MonkeyPatch, *, include_orphan_blob_ref: bool = False ) -> Path: @@ -651,6 +719,110 @@ def test_receipt_writers_never_create_through_a_symlinked_maintenance_state(tmp_ assert not tuple(outside.iterdir()) +def test_relocation_startup_reader_rejects_receipt_swapped_after_enumeration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Daemon preflight reads the enumerated relocation receipt descriptor, not a replacement pathname.""" + root = tmp_path / "archive" + state = root / ".maintenance-state" / "archive-root-relocations" + state.mkdir(parents=True) + receipt = state / ("a" * 64 + ".json") + substitute = state / "replacement.json" + _write_relocation_receipt( + receipt, + _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256="a" * 64, + authorization="a" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="resume relocation", + ), + expected=None, + ) + _write_relocation_receipt( + substitute, + _sealed_relocation_receipt( + state="committed", + revision=1, + plan_sha256="b" * 64, + authorization="b" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="replacement", + ), + expected=None, + ) + real_open = os.open + swapped = False + + def swap_after_enumeration(path: str, flags: int, *args: object, **kwargs: object) -> int: + nonlocal swapped + if path == receipt.name and kwargs.get("dir_fd") is not None and not swapped: + swapped = True + os.replace(substitute, receipt) + return real_open(path, flags, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "open", swap_after_enumeration) + with pytest.raises(ArchiveRootRelocationError, match="changed during pinned enumeration"): + assert_no_prepared_archive_root_relocation(root) + assert swapped + + +def test_historical_startup_reader_rejects_receipt_swapped_after_enumeration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Historical recovery preflight also pins the receipt it enumerated.""" + root = tmp_path / "archive" + state = root / ".maintenance-state" / "historical-source-continuity-recoveries" + state.mkdir(parents=True) + receipt = state / ("c" * 64 + ".json") + substitute = state / "replacement.json" + _write_continuity_receipt( + receipt, + _sealed_continuity_receipt( + state="prepared", + revision=0, + plan_sha256="c" * 64, + authorization="c" * 64, + train_before_sha256="d" * 64, + train_after_sha256=None, + refresh_receipt_sha256="e" * 64, + resume_command="resume continuity", + ), + expected=None, + ) + _write_continuity_receipt( + substitute, + _sealed_continuity_receipt( + state="committed", + revision=1, + plan_sha256="f" * 64, + authorization="f" * 64, + train_before_sha256="0" * 64, + train_after_sha256="1" * 64, + refresh_receipt_sha256="2" * 64, + resume_command="replacement", + ), + expected=None, + ) + real_open = os.open + swapped = False + + def swap_after_enumeration(path: str, flags: int, *args: object, **kwargs: object) -> int: + nonlocal swapped + if path == receipt.name and kwargs.get("dir_fd") is not None and not swapped: + swapped = True + os.replace(substitute, receipt) + return real_open(path, flags, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(os, "open", swap_after_enumeration) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="changed during pinned enumeration"): + assert_no_prepared_historical_source_continuity_recovery(root) + assert swapped + + def test_historical_continuity_recovery_cli_rejects_an_unbound_synthetic_operation( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -716,6 +888,7 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ old_root = workspace_env["archive_root"] manifest = _released_moved_source_train(old_root, monkeypatch) + _attach_retained_source_continuity(old_root, manifest) backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved" @@ -796,6 +969,26 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ ) is None ) + # This is the same verifier branch that rejected the deployed v27 + # manifest after later source trains had advanced the archive. The + # real SQLite tier advances here; the fixture supplies its matching + # canonical inventory because it deliberately has no synthetic v3 DDL. + connection.execute("PRAGMA user_version = 3") + connection.commit() + advanced = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + live_inventory = capture_durable_schema_inventory(connection) + forward = trains._verify_released_train_live_tier( + new_root, + connection, + trains.load_durable_change_train_manifest(moved_manifest), + current_target_version=advanced.user_version, + actual_evidence=advanced, + live_inventory=live_inventory, + canonical_inventory=live_inventory, + ) + assert forward is not None + assert forward.historical_target_version == 2 + assert forward.observed_live_version == 3 def test_plan_rejects_the_real_stale_source_train_shape_before_receipt_write( From 8cd13163c9733f9b1b064b406a0d71742477d902 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 01:53:18 +0200 Subject: [PATCH 15/39] fix: relocate active index pointers with archive roots Bind the old and mapped active-index targets into the sealed relocation plan and receipts. Publish the mapped target atomically under the owned destination root before source-train CAS, allowing prepared recovery to resume after pointer publication.\n\nThe regression exercises a real promoted generation and rejects an external pointer target. --- .../operations/archive_root_relocation.py | 204 +++++++++++++++++- .../storage/test_archive_root_relocation.py | 100 +++++++++ 2 files changed, 300 insertions(+), 4 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index ce633eb03f..a043b7bef4 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -8,6 +8,7 @@ import sqlite3 import stat import tempfile +import uuid from pathlib import Path from typing import Literal @@ -92,6 +93,19 @@ class RelocationSourceTrain(BaseModel): source_continuity_receipt_digests: tuple[str, ...] +class RelocationActiveIndexPointer(BaseModel): + """The active index pointer's old target and its owned destination mapping.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + old_target: str + new_target: str + old_resolved_target: str + new_resolved_target: str + device: int + inode: int + + class ArchiveRootRelocationPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -111,6 +125,7 @@ class ArchiveRootRelocationPlan(BaseModel): backup_profile: Literal["full_evidence"] backup_tier_inventory: tuple[str, ...] tiers: tuple[RelocationTierEvidence, ...] + active_index_pointer: RelocationActiveIndexPointer | None source_trains: tuple[RelocationSourceTrain, ...] stopped_daemon_evidence_ref: str single_writer_evidence_ref: str @@ -128,6 +143,9 @@ class ArchiveRootRelocationReceipt(BaseModel): authorization: str manifest_before_sha256: tuple[str, ...] manifest_after_sha256: tuple[str, ...] + active_index_pointer_old_target: str | None = None + active_index_pointer_new_target: str | None = None + active_index_pointer_new_resolved_target: str | None = None resume_command: str prepared_receipt_sha256: str | None = None receipt_sha256: str @@ -219,11 +237,16 @@ def _tier_snapshot( *, old_device: int, old_inode: int, + active_index_pointer: RelocationActiveIndexPointer | None = None, ) -> RelocationTierEvidence: - location = ArchiveLocation.resolve(root) - identity = location.active_tier(tier.value) - path = identity.configured_path - resolved_path = identity.resolved_path + if tier is ArchiveTier.INDEX and active_index_pointer is not None: + path = Path(active_index_pointer.new_target) + resolved_path = Path(active_index_pointer.new_resolved_target) + else: + location = ArchiveLocation.resolve(root) + identity = location.active_tier(tier.value) + path = identity.configured_path + resolved_path = identity.resolved_path if tier is ArchiveTier.INDEX: # The promoted index route deliberately uses an active-generation # pointer. Snapshot the resolved generation, never a shadow path. @@ -265,6 +288,144 @@ def _tier_snapshot( ) +def _read_active_index_pointer(root: Path) -> tuple[Path, Path] | None: + """Read one absolute pointer target without resolving a stale old-root path.""" + pointer = root / ".index-active-pointer" + try: + metadata = pointer.lstat() + except FileNotFoundError: + return None + except OSError as exc: + raise ArchiveRootRelocationError(f"cannot inspect active index pointer: {pointer}") from exc + try: + if stat.S_ISLNK(metadata.st_mode): + raw = os.readlink(pointer) + elif stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1: + raw = pointer.read_text(encoding="utf-8").strip() + else: + raise ArchiveRootRelocationError( + f"active index pointer is not a regular single-linked file or symlink: {pointer}" + ) + except (OSError, UnicodeDecodeError) as exc: + raise ArchiveRootRelocationError(f"cannot read active index pointer: {pointer}") from exc + target = Path(raw) + if not target.is_absolute() or target.name != "index.db": + raise ArchiveRootRelocationError(f"invalid active index pointer target: {target}") + return pointer, Path(os.path.abspath(target)) + + +def _active_index_pointer_evidence(*, old_root: Path, new_root: Path) -> RelocationActiveIndexPointer | None: + """Map an old-root-owned target before the relocation can publish it anew.""" + pointer = _read_active_index_pointer(new_root) + if pointer is None: + return None + _pointer_path, old_target = pointer + try: + relative_target = old_target.relative_to(old_root) + except ValueError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation active index pointer target is not owned by the old root" + ) from exc + new_target = new_root / relative_target + try: + new_resolved_target = new_target.resolve(strict=True) + except OSError as exc: + raise ArchiveRootRelocationError(f"cannot resolve mapped active index pointer target: {new_target}") from exc + if not new_resolved_target.is_relative_to(new_root): + raise ArchiveRootRelocationError( + "archive-root relocation mapped active index pointer target escapes the destination root" + ) + metadata = _real_file(new_resolved_target, label="mapped active index pointer target") + old_resolved_target = old_root / new_resolved_target.relative_to(new_root) + return RelocationActiveIndexPointer( + old_target=str(old_target), + new_target=str(new_target), + old_resolved_target=str(old_resolved_target), + new_resolved_target=str(new_resolved_target), + device=metadata.st_dev, + inode=metadata.st_ino, + ) + + +def _validate_active_index_pointer( + root: Path, + pointer: RelocationActiveIndexPointer | None, +) -> None: + """Accept only the sealed pre-publication or post-publication pointer state.""" + if pointer is None: + if _read_active_index_pointer(root) is not None: + raise ArchiveRootRelocationError("archive-root relocation active index pointer appeared after planning") + return + current = _read_active_index_pointer(root) + if current is None: + raise ArchiveRootRelocationError("archive-root relocation active index pointer disappeared") + _pointer_path, target = current + if str(target) not in {pointer.old_target, pointer.new_target}: + raise ArchiveRootRelocationError("archive-root relocation active index pointer target changed") + if str(target) == pointer.new_target: + try: + resolved = Path(pointer.new_target).resolve(strict=True) + except OSError as exc: + raise ArchiveRootRelocationError("archive-root relocation mapped active index pointer disappeared") from exc + metadata = _real_file(resolved, label="mapped active index pointer target") + if str(resolved) != pointer.new_resolved_target or (metadata.st_dev, metadata.st_ino) != ( + pointer.device, + pointer.inode, + ): + raise ArchiveRootRelocationError("archive-root relocation mapped active index pointer changed") + + +def _publish_active_index_pointer(root: Path, pointer: RelocationActiveIndexPointer | None) -> None: + """Atomically publish the sealed mapped target beneath the owned destination root.""" + if pointer is None: + return + _validate_active_index_pointer(root, pointer) + current = _read_active_index_pointer(root) + assert current is not None + _path, target = current + if str(target) == pointer.new_target: + return + if str(target) != pointer.old_target: + raise ArchiveRootRelocationError("archive-root relocation active index pointer target changed") + directory_fd = -1 + temporary = f".index-active-pointer.relocation-{uuid.uuid4().hex}.tmp" + descriptor = -1 + try: + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + root_metadata = root.stat() + pinned_metadata = os.fstat(directory_fd) + if (root_metadata.st_dev, root_metadata.st_ino) != (pinned_metadata.st_dev, pinned_metadata.st_ino): + raise ArchiveRootRelocationError( + "archive-root relocation destination root changed during pointer publication" + ) + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + dir_fd=directory_fd, + ) + payload = (pointer.new_target + "\n").encode("utf-8") + os.write(descriptor, payload) + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, ".index-active-pointer", src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + except OSError as exc: + raise ArchiveRootRelocationError("cannot atomically publish mapped active index pointer") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + if directory_fd >= 0: + try: + os.unlink(temporary, dir_fd=directory_fd) + except FileNotFoundError: + pass + finally: + os.close(directory_fd) + _validate_active_index_pointer(root, pointer) + + def _source_trains( root: Path, *, @@ -435,12 +596,14 @@ def prepare_archive_root_relocation( manifest.get("archive_root_source_identity"), label="archive root" ) old_tier_identities = _authenticated_old_tier_identities(manifest) + active_index_pointer = _active_index_pointer_evidence(old_root=old_resolved, new_root=new_resolved) snapshots = tuple( _tier_snapshot( new_resolved, tier, old_device=old_tier_identities[tier.value][0], old_inode=old_tier_identities[tier.value][1], + active_index_pointer=active_index_pointer, ) for tier in ArchiveTier ) @@ -479,6 +642,7 @@ def prepare_archive_root_relocation( backup_profile="full_evidence", backup_tier_inventory=tuple(sorted(f"{tier}.db" for tier in _TIER_NAMES)), tiers=snapshots, + active_index_pointer=active_index_pointer, source_trains=trains, stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, single_writer_evidence_ref=single_writer_evidence_ref, @@ -629,12 +793,14 @@ def _revalidate_plan_live_state( tier, old_device=old_tiers[tier.value][0], old_inode=old_tiers[tier.value][1], + active_index_pointer=plan.active_index_pointer, ) for tier in ArchiveTier ) if snapshots != plan.tiers: raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) + _validate_active_index_pointer(root, plan.active_index_pointer) pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) allowed_pending_relocation_receipt_sha256 = ( pending_receipt.receipt_sha256 if pending_receipt is not None and pending_receipt.state == "prepared" else None @@ -731,6 +897,15 @@ def _apply_archive_root_relocation_locked( authorization=authorization, manifest_before_sha256=before_hashes, manifest_after_sha256=(), + active_index_pointer_old_target=( + plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None + ), + active_index_pointer_new_target=( + plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None + ), + active_index_pointer_new_resolved_target=( + plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None + ), resume_command=command, ) existing_receipt = _load_receipt_for_update(receipt_path) @@ -738,6 +913,17 @@ def _apply_archive_root_relocation_locked( receipt = existing_receipt if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") + expected_pointer_receipt = ( + plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None, + plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None, + plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None, + ) + if ( + receipt.active_index_pointer_old_target, + receipt.active_index_pointer_new_target, + receipt.active_index_pointer_new_resolved_target, + ) != expected_pointer_receipt: + raise ArchiveRootRelocationError("archive-root relocation receipt active index pointer binding changed") if receipt.state == "committed": if tuple(_sha256_file(Path(item.path)) for item in plan.source_trains) != receipt.manifest_after_sha256: raise ArchiveRootRelocationError("archive-root relocation committed receipt does not match manifests") @@ -749,6 +935,7 @@ def _apply_archive_root_relocation_locked( ) else: _write_receipt(receipt_path, receipt, expected=None) + _publish_active_index_pointer(root, plan.active_index_pointer) after_hashes: list[str] = [] for item in plan.source_trains: path = Path(item.path) @@ -794,6 +981,15 @@ def _apply_archive_root_relocation_locked( authorization=authorization, manifest_before_sha256=before_hashes, manifest_after_sha256=tuple(after_hashes), + active_index_pointer_old_target=( + plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None + ), + active_index_pointer_new_target=( + plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None + ), + active_index_pointer_new_resolved_target=( + plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None + ), resume_command=command, prepared_receipt_sha256=receipt.receipt_sha256, ) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index fb294526fb..c62748601a 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -53,6 +53,7 @@ BlobRefLivenessCandidateDigest, classify_blob_ref_liveness, ) +from polylogue.storage.index_generation import IndexGenerationStore from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, @@ -520,6 +521,25 @@ def _released_moved_source_train( return manifest +def _activate_movable_index_generation(root: Path) -> Path: + """Promote a real generation while retaining a move-safe conventional symlink. + + The active-pointer target is deliberately absolute, as it is in a live + generation layout. The conventional index symlink is relative so the + regression isolates relocation's pointer publication rather than a second + broken absolute symlink. + """ + store = IndexGenerationStore.for_archive_root(root) + generation = store.create(owner_id="relocation-test", source_snapshot="snapshot") + store.promote(generation) + target = Path(generation.index_path).resolve(strict=True) + conventional = root / "index.db" + conventional.unlink() + conventional.symlink_to(target.relative_to(root)) + (root / ".index-active-pointer").write_text(str(target), encoding="utf-8") + return target + + def _legacy_liveness_receipt( path: Path, *, @@ -991,6 +1011,86 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ assert forward.observed_live_version == 3 +def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_publication_crash( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real relocation route must move the active generation pointer with its root. + + Anti-vacuity: this uses the production index-generation promotion and the + real relocation prepare/apply functions. Before the repair, preparation + follows the stale absolute pointer beneath ``old_root`` and rejects the + otherwise valid moved archive before any receipt can be written. + """ + from polylogue.operations import archive_root_relocation as relocation + + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + _attach_retained_source_continuity(old_root, manifest) + old_active_target = _activate_movable_index_generation(old_root) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + assert plan.active_index_pointer is not None + assert plan.active_index_pointer.old_target == str(old_active_target) + assert plan.active_index_pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) + real_publish = relocation._publish_active_index_pointer + + def crash_after_pointer_publication(*args: object, **kwargs: object) -> None: + real_publish(*args, **kwargs) + raise RuntimeError("crash after active pointer publication") + + monkeypatch.setattr(relocation, "_publish_active_index_pointer", crash_after_pointer_publication) + with pytest.raises(RuntimeError, match="crash after active pointer publication"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert (new_root / ".index-active-pointer").read_text( + encoding="utf-8" + ).strip() == plan.active_index_pointer.new_target + with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): + assert_no_prepared_archive_root_relocation(new_root) + + monkeypatch.setattr(relocation, "_publish_active_index_pointer", real_publish) + result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert result.state == "committed" + assert ArchiveLocation.resolve(new_root).active_index_path == Path(plan.active_index_pointer.new_resolved_target) + assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" + + +def test_relocation_rejects_an_active_pointer_not_owned_by_the_old_root( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The remap boundary cannot turn an arbitrary external index into authority.""" + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + _attach_retained_source_continuity(old_root, manifest) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + foreign = tmp_path / "foreign" / "index.db" + foreign.parent.mkdir() + foreign.write_bytes(b"foreign") + (old_root / ".index-active-pointer").write_text(str(foreign), encoding="utf-8") + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + + with pytest.raises(ArchiveRootRelocationError, match="not owned by the old root"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + def test_plan_rejects_the_real_stale_source_train_shape_before_receipt_write( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 73d1516f78f81c227318e363fbbcf1f35eb852cd Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 02:01:04 +0200 Subject: [PATCH 16/39] test: cover historical source continuity recovery Add a context-local pinned fixture evidence resource for tests without changing the immutable packaged descriptor. Exercise the public recovery plan and apply routes through prepared and refresh-publication crashes, startup admission blocking, refresh receipt publication, train CAS, and idempotent resume. --- .../historical_source_continuity_recovery.py | 29 +++ .../storage/test_archive_root_relocation.py | 232 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index fe3b99b630..5ae6764e57 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -15,6 +15,9 @@ import sqlite3 import stat import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar from importlib import resources from pathlib import Path from typing import Literal, cast @@ -66,6 +69,9 @@ "polylogue.historical-source-continuity-recovery-receipt.v1" ) _HISTORICAL_OPERATION_EVIDENCE_RESOURCE = "historical-source-continuity-operation-20260807.json" +_TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE: ContextVar[Path | None] = ContextVar( + "test_historical_operation_evidence_resource", default=None +) class HistoricalSourceContinuityRecoveryError(RuntimeError): @@ -187,12 +193,35 @@ def _real_directory(path: Path, *, label: str) -> Path: def _historical_operation_evidence_bytes() -> bytes: """Read the immutable operation evidence from the installed package.""" + test_resource = _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.get() + if test_resource is not None: + _real_file(test_resource, label="test historical operation evidence") + try: + return test_resource.read_bytes() + except OSError as exc: + raise HistoricalSourceContinuityRecoveryError("test historical operation evidence is unreadable") from exc try: return resources.files("polylogue.operations").joinpath(_HISTORICAL_OPERATION_EVIDENCE_RESOURCE).read_bytes() except FileNotFoundError as exc: raise HistoricalSourceContinuityRecoveryError("immutable historical operation evidence is unreadable") from exc +@contextmanager +def _test_historical_operation_evidence_resource(path: Path) -> Iterator[None]: + """Scope a pinned fixture resource without changing production evidence selection. + + Production execution always reads the immutable packaged descriptor above. + Tests alone opt into this context-local resource to exercise the real plan + and apply operations against a synthetic, independently sealed history. + """ + _real_file(path, label="test historical operation evidence") + token = _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.set(path) + try: + yield + finally: + _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.reset(token) + + def _historical_operation_evidence() -> HistoricalOperationEvidence: try: return HistoricalOperationEvidence.model_validate_json(_historical_operation_evidence_bytes()) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index c62748601a..50a470817b 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -33,7 +33,9 @@ _assert_complete_source_semantic_delta, _assert_exact_liveness_delta, _current_evidence, + _sha256, _table_content_digest, + _test_historical_operation_evidence_resource, _verify_historical_operation_evidence, _write_refresh_receipt, assert_no_prepared_historical_source_continuity_recovery, @@ -570,6 +572,80 @@ def _legacy_liveness_receipt( path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") +def _pinned_historical_operation_evidence( + path: Path, + *, + mutation_receipt: Path, + candidates: tuple[BlobRefLivenessCandidate, ...], + pre_manifest: Path, + post_manifest: Path, +) -> None: + """Write the fixture's immutable-shaped descriptor from independently produced artifacts.""" + digest = BlobRefLivenessCandidateDigest() + for candidate in candidates: + digest.update(candidate) + payload = { + "format": "polylogue.historical-source-continuity-operation-evidence.v1", + "operation": "blob-ref-liveness-reconciliation-20260807", + "mutation_receipt_sha256": _sha256(mutation_receipt), + "candidate_count": len(candidates), + "candidate_digest": digest.hexdigest(), + "pre_backup_manifest_sha256": _sha256(pre_manifest), + "pre_backup_receipt_sha256": _sha256(pre_manifest.parent / "verification-receipt.json"), + "pre_source_sha256": _sha256(pre_manifest.parent / "source.db"), + "post_backup_manifest_sha256": _sha256(post_manifest), + "post_backup_receipt_sha256": _sha256(post_manifest.parent / "verification-receipt.json"), + "post_source_sha256": _sha256(post_manifest.parent / "source.db"), + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _historical_continuity_fixture( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path, Path, Path, Path]: + """Build real backups, a legacy receipt, a released train, and the pinned fixture descriptor.""" + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch, include_orphan_blob_ref=True) + pre_backup = backup_archive(output_dir=tmp_path / "pre", profile="rebuildable_cache_exclude", verify=True) + assert pre_backup.ok and pre_backup.output_path is not None + pre_manifest = Path(pre_backup.output_path) / "manifest.json" + with sqlite3.connect(f"file:{old_root / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + prior = classify_blob_ref_liveness(connection) + assert prior.orphaned_count == 1 + mutation_receipt = tmp_path / "legacy-liveness.jsonl" + _legacy_liveness_receipt( + mutation_receipt, + old_root=old_root, + pre_manifest=pre_manifest, + candidates=prior.candidates, + ) + with sqlite3.connect(old_root / "source.db") as connection: + connection.execute("DELETE FROM blob_refs WHERE ref_id = 'deleted'") + post_backup = backup_archive(output_dir=tmp_path / "post", profile="rebuildable_cache_exclude", verify=True) + assert post_backup.ok and post_backup.output_path is not None + post_manifest = Path(post_backup.output_path) / "manifest.json" + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + evidence = tmp_path / "pinned-historical-evidence.json" + _pinned_historical_operation_evidence( + evidence, + mutation_receipt=mutation_receipt, + candidates=prior.candidates, + pre_manifest=pre_manifest, + post_manifest=post_manifest, + ) + return new_root, mutation_receipt, pre_manifest, post_manifest, evidence + + +def _maintenance_json_output(output: str) -> dict[str, object]: + """Maintenance commands retain the root-provenance line before JSON output.""" + _provenance, separator, payload = output.partition("\n") + assert separator and payload.startswith("{") + decoded = json.loads(payload) + assert isinstance(decoded, dict) + return decoded + + def _write_liveness_delta_database(path: Path, *, keep_body: str = "kept", include_candidate: bool = True) -> None: with sqlite3.connect(path) as connection: connection.executescript( @@ -900,6 +976,162 @@ def test_historical_continuity_recovery_cli_rejects_an_unbound_synthetic_operati assert "immutable offline evidence" in plan_result.output +def test_historical_continuity_recovery_cli_recovers_pinned_fixture_and_resumes_crashes( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Exercise the real CLI bridge through prepared and refresh-publication interruptions. + + Anti-vacuity: the fixture's descriptor only authorizes independently made + backup, receipt, and SQLite artifacts. The test invokes the public plan + and apply routes, then inspects the production refresh receipt and durable + train CAS result. Removing either route's operation wiring leaves no plan, + no prepared admission block, or no train revision. + """ + from polylogue.operations import historical_source_continuity_recovery as recovery + + new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(new_root)} + plan_path = tmp_path / "continuity-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + plan_payload = _maintenance_json_output(planned.output) + plan_sha256 = str(plan_payload["plan_sha256"]) + plan_train = Path(str(plan_payload["source_train_path"])) + train_before = load_durable_change_train_manifest(plan_train) + real_write_refresh = recovery._write_refresh_receipt + + def crash_before_refresh(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("crash after prepared receipt") + + monkeypatch.setattr(recovery, "_write_refresh_receipt", crash_before_refresh) + with pytest.raises(RuntimeError, match="crash after prepared receipt"): + CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + assert_no_prepared_historical_source_continuity_recovery(new_root) + + def crash_after_refresh(*args: object, **kwargs: object) -> None: + real_write_refresh(*args, **kwargs) + raise RuntimeError("crash after refresh receipt") + + monkeypatch.setattr(recovery, "_write_refresh_receipt", crash_after_refresh) + with pytest.raises(RuntimeError, match="crash after refresh receipt"): + CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + assert_no_prepared_historical_source_continuity_recovery(new_root) + + monkeypatch.setattr(recovery, "_write_refresh_receipt", real_write_refresh) + applied = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert applied.exit_code == 0, applied.output + result = _maintenance_json_output(applied.output) + assert result["state"] == "committed" + refresh_path = Path(str(result["refresh_receipt_path"])) + refresh_payload = json.loads(refresh_path.read_text(encoding="utf-8")) + assert refresh_payload["refresh_sha256"] == _canonical_json_sha256( + {key: value for key, value in refresh_payload.items() if key != "refresh_sha256"} + ) + train_after = load_durable_change_train_manifest(plan_train) + assert train_after.revision == train_before.revision + 1 + assert train_after.source_continuity_evidence is not None + assert_no_prepared_historical_source_continuity_recovery(new_root) + rerun = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert rerun.exit_code == 0, rerun.output + assert _maintenance_json_output(rerun.output)["state"] == "committed" + + def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_crash( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a3c447bdbce34a1aac70efc341319be5104c581f Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 02:02:46 +0200 Subject: [PATCH 17/39] test: type recovery interruption seams Give the crash injectors the exact production function signatures so strict type checking continues to cover the exercised CLI recovery paths. --- tests/unit/storage/test_archive_root_relocation.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 50a470817b..42ae5856b5 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -16,6 +16,7 @@ from polylogue.daemon.backup import backup_archive from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, + RelocationActiveIndexPointer, RelocationTierEvidence, _check_backup_against_live, apply_archive_root_relocation, @@ -1052,8 +1053,8 @@ def crash_before_refresh(*_args: object, **_kwargs: object) -> None: with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): assert_no_prepared_historical_source_continuity_recovery(new_root) - def crash_after_refresh(*args: object, **kwargs: object) -> None: - real_write_refresh(*args, **kwargs) + def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: + real_write_refresh(path, payload) raise RuntimeError("crash after refresh receipt") monkeypatch.setattr(recovery, "_write_refresh_receipt", crash_after_refresh) @@ -1277,8 +1278,8 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public assert plan.active_index_pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) real_publish = relocation._publish_active_index_pointer - def crash_after_pointer_publication(*args: object, **kwargs: object) -> None: - real_publish(*args, **kwargs) + def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPointer | None) -> None: + real_publish(root, pointer) raise RuntimeError("crash after active pointer publication") monkeypatch.setattr(relocation, "_publish_active_index_pointer", crash_after_pointer_publication) From 77001a5d919147862cfc9b5e4afa2bbb889224ce Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 02:15:55 +0200 Subject: [PATCH 18/39] fix: bind recovery evidence through apply --- .../operations/archive_root_relocation.py | 7 +- .../historical_source_continuity_recovery.py | 13 +++ .../storage/test_archive_root_relocation.py | 108 ++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index a043b7bef4..290c31d446 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -405,7 +405,12 @@ def _publish_active_index_pointer(root: Path, pointer: RelocationActiveIndexPoin dir_fd=directory_fd, ) payload = (pointer.new_target + "\n").encode("utf-8") - os.write(descriptor, payload) + offset = 0 + while offset < len(payload): + written = os.write(descriptor, payload[offset:]) + if written <= 0: + raise OSError("active index pointer write made no progress") + offset += written os.fsync(descriptor) os.close(descriptor) descriptor = -1 diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 5ae6764e57..302dab68a5 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -940,6 +940,19 @@ def _revalidate( ) if count != plan.legacy_candidate_count or digest != plan.legacy_candidate_digest: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery legacy receipt changed") + historical_evidence_sha256 = _verify_historical_operation_evidence( + mutation_receipt=Path(plan.mutation_receipt_path), + candidates=count, + candidate_digest=digest, + pre_manifest=Path(plan.pre_backup_manifest_path), + pre_receipt=pre_receipt, + pre_source=Path(plan.pre_backup_manifest_path).parent / "source.db", + post_manifest=Path(plan.post_backup_manifest_path), + post_receipt=post_receipt, + post_source=Path(plan.post_backup_manifest_path).parent / "source.db", + ) + if historical_evidence_sha256 != plan.historical_evidence_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery evidence binding changed") current = _current_evidence(root) if not _evidence_matches_plan(current, plan.source_after) or current.content_sha256 != post.content_sha256: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery current source changed") diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 42ae5856b5..7692b0ee1f 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio import json import os import shutil import sqlite3 from dataclasses import replace from pathlib import Path +from unittest.mock import Mock import pytest from click.testing import CliRunner @@ -1052,6 +1054,24 @@ def crash_before_refresh(*_args: object, **_kwargs: object) -> None: ) with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): assert_no_prepared_historical_source_continuity_recovery(new_root) + from polylogue.daemon import cli as daemon_cli + + blocked_components = Mock() + monkeypatch.setattr("polylogue.paths.archive_root", lambda: new_root) + monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", blocked_components) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + blocked_components.assert_not_called() def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: real_write_refresh(path, payload) @@ -1111,6 +1131,21 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: assert train_after.revision == train_before.revision + 1 assert train_after.source_continuity_evidence is not None assert_no_prepared_historical_source_continuity_recovery(new_root) + admitted_components = Mock(side_effect=RuntimeError("daemon admission reached")) + monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", admitted_components) + with pytest.raises(RuntimeError, match="daemon admission reached"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + admitted_components.assert_called_once() rerun = CliRunner().invoke( cli, [ @@ -1133,6 +1168,65 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: assert _maintenance_json_output(rerun.output)["state"] == "committed" +def test_historical_continuity_recovery_apply_rechecks_the_pinned_evidence_binding( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A sealed plan cannot outlive the exact historical-evidence descriptor it authenticated.""" + new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(new_root)} + plan_path = tmp_path / "continuity-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + plan_sha256 = str(_maintenance_json_output(planned.output)["plan_sha256"]) + evidence.write_bytes(evidence.read_bytes() + b"\n") + applied = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert applied.exit_code != 0 + assert "evidence binding changed" in applied.output + + def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_crash( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1277,14 +1371,28 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public assert plan.active_index_pointer.old_target == str(old_active_target) assert plan.active_index_pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) real_publish = relocation._publish_active_index_pointer + real_write = os.write + short_pointer_write = False + + def write_pointer_in_two_calls(descriptor: int, payload: bytes) -> int: + nonlocal short_pointer_write + expected = (plan.active_index_pointer.new_target + "\n").encode("utf-8") + if not short_pointer_write and payload == expected: + short_pointer_write = True + partial = len(payload) - 1 + assert real_write(descriptor, payload[:partial]) == partial + return partial + return real_write(descriptor, payload) def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPointer | None) -> None: real_publish(root, pointer) raise RuntimeError("crash after active pointer publication") + monkeypatch.setattr(relocation.os, "write", write_pointer_in_two_calls) monkeypatch.setattr(relocation, "_publish_active_index_pointer", crash_after_pointer_publication) with pytest.raises(RuntimeError, match="crash after active pointer publication"): apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert short_pointer_write assert (new_root / ".index-active-pointer").read_text( encoding="utf-8" ).strip() == plan.active_index_pointer.new_target From b246e55893d872c8db115c62ed9845c1a6298f62 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 02:17:15 +0200 Subject: [PATCH 19/39] test: type pointer publication regression --- tests/unit/storage/test_archive_root_relocation.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 7692b0ee1f..809d66752b 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1367,16 +1367,17 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public single_writer_evidence_ref="proof:archive-ownership-lock", ) - assert plan.active_index_pointer is not None - assert plan.active_index_pointer.old_target == str(old_active_target) - assert plan.active_index_pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) + pointer = plan.active_index_pointer + assert pointer is not None + assert pointer.old_target == str(old_active_target) + assert pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) real_publish = relocation._publish_active_index_pointer real_write = os.write short_pointer_write = False def write_pointer_in_two_calls(descriptor: int, payload: bytes) -> int: nonlocal short_pointer_write - expected = (plan.active_index_pointer.new_target + "\n").encode("utf-8") + expected = (pointer.new_target + "\n").encode("utf-8") if not short_pointer_write and payload == expected: short_pointer_write = True partial = len(payload) - 1 @@ -1388,7 +1389,7 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo real_publish(root, pointer) raise RuntimeError("crash after active pointer publication") - monkeypatch.setattr(relocation.os, "write", write_pointer_in_two_calls) + monkeypatch.setattr(os, "write", write_pointer_in_two_calls) monkeypatch.setattr(relocation, "_publish_active_index_pointer", crash_after_pointer_publication) with pytest.raises(RuntimeError, match="crash after active pointer publication"): apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) From b7f873a93498e1c562006176694cec4fdedf1199 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 02:18:52 +0200 Subject: [PATCH 20/39] test: satisfy pointer type contract --- tests/unit/storage/test_archive_root_relocation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 809d66752b..b9a69212c7 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1394,16 +1394,14 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo with pytest.raises(RuntimeError, match="crash after active pointer publication"): apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) assert short_pointer_write - assert (new_root / ".index-active-pointer").read_text( - encoding="utf-8" - ).strip() == plan.active_index_pointer.new_target + assert (new_root / ".index-active-pointer").read_text(encoding="utf-8").strip() == pointer.new_target with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): assert_no_prepared_archive_root_relocation(new_root) monkeypatch.setattr(relocation, "_publish_active_index_pointer", real_publish) result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) assert result.state == "committed" - assert ArchiveLocation.resolve(new_root).active_index_path == Path(plan.active_index_pointer.new_resolved_target) + assert ArchiveLocation.resolve(new_root).active_index_path == Path(pointer.new_resolved_target) assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" From 30b01238f0bc5a3151a015f8df3f2630656f3016 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 03:02:48 +0200 Subject: [PATCH 21/39] fix: bind archive relocation recovery authority Historical source-continuity recovery authenticated backup evidence by path and bytes, so a copied destination could inherit old authority. Relocation also required a moved-root backup to authenticate the retired path, and resume accepted equivalent continuity evidence. Carry authenticated source device/inode identity into recovery plans, validate it at the destination, bind refresh receipts exactly to their plan, and authenticate relocation backups at the moved root. Revalidate refresh, transition, and relocation receipt bindings across CAS resume. Add production CLI regressions for copied destinations, the documented recovery/backup/relocation sequence, and foreign receipt substitution. --- docs/maintenance.md | 4 +- .../operations/archive_root_relocation.py | 124 ++++++-- .../historical_source_continuity_recovery.py | 214 +++++++++++-- polylogue/storage/sqlite/migration_runner.py | 34 +- .../storage/test_archive_root_relocation.py | 296 ++++++++++++++++-- 5 files changed, 565 insertions(+), 107 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index 2f64cc5e59..3c6c0cc0a0 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -41,7 +41,7 @@ command's migration result alone. ## Relocating an archive root -Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. It requires the daemon to be stopped, archive ownership, and a successful verified `full_evidence` backup whose receipt is authenticated against the old root path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. `--old-root` names the retired pre-move root for the identity transition and active-index pointer mapping. Create a fresh verified `full_evidence` backup after setting `POLYLOGUE_ARCHIVE_ROOT` to the moved root. The relocation plan authenticates that backup against the moved root and revalidates its device/inode inventory there; it never asks a moved-root backup to authenticate the nonexistent retired path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json @@ -59,7 +59,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-contin POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-continuity-recovery apply --plan /safe/continuity-plan.json --authorize PLAN_SHA256 --output-format json ``` -After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root before running the separate archive-root-relocation plan/apply transition. A prepared bridge receipt blocks daemon startup and names its exact resume command. +After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root. Use that moved-root manifest with the separate archive-root-relocation plan/apply transition while `--old-root` continues to name the retired pre-move root. A prepared bridge receipt blocks daemon startup and names its exact resume command. ### Rebuild deployment-currency preflight diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 290c31d446..38af072db3 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -69,8 +69,8 @@ class RelocationTierEvidence(BaseModel): tier: str configured_path: str resolved_path: str - old_device: int - old_inode: int + backup_device: int + backup_inode: int device: int inode: int size_bytes: int @@ -112,8 +112,8 @@ class ArchiveRootRelocationPlan(BaseModel): format: Literal["polylogue.archive-root-relocation-plan.v1"] = PLAN_FORMAT old_configured_root: str old_resolved_root: str - old_root_device: int - old_root_inode: int + backup_root_device: int + backup_root_inode: int new_configured_root: str new_resolved_root: str new_root_device: int @@ -235,8 +235,8 @@ def _tier_snapshot( root: Path, tier: ArchiveTier, *, - old_device: int, - old_inode: int, + backup_device: int, + backup_inode: int, active_index_pointer: RelocationActiveIndexPointer | None = None, ) -> RelocationTierEvidence: if tier is ArchiveTier.INDEX and active_index_pointer is not None: @@ -275,8 +275,8 @@ def _tier_snapshot( tier=tier.value, configured_path=str(path.absolute()), resolved_path=str(resolved_path), - old_device=old_device, - old_inode=old_inode, + backup_device=backup_device, + backup_inode=backup_inode, device=metadata.st_dev, inode=metadata.st_ino, size_bytes=metadata.st_size, @@ -514,7 +514,7 @@ def _authenticated_identity(payload: object, *, label: str) -> tuple[int, int]: return device, inode -def _authenticated_old_tier_identities(manifest: dict[str, object]) -> dict[str, tuple[int, int]]: +def _authenticated_backup_tier_identities(manifest: dict[str, object]) -> dict[str, tuple[int, int]]: fingerprints = manifest.get("tier_source_fingerprints") if not isinstance(fingerprints, dict): raise ArchiveRootRelocationError("backup lacks authenticated tier identity inventory") @@ -524,8 +524,8 @@ def _authenticated_old_tier_identities(manifest: dict[str, object]) -> dict[str, } -def _require_identity_continuity(*, old_device: int, old_inode: int, device: int, inode: int, label: str) -> None: - if (old_device, old_inode) != (device, inode): +def _require_identity_continuity(*, backup_device: int, backup_inode: int, device: int, inode: int, label: str) -> None: + if (backup_device, backup_inode) != (device, inode): raise ArchiveRootRelocationError( f"archive-root relocation requires {label} device/inode continuity; a copied archive is not accepted" ) @@ -550,8 +550,8 @@ def _check_backup_against_live( if not isinstance(fingerprint, dict) or not isinstance(artifact, dict): raise ArchiveRootRelocationError(f"backup lacks {filename} evidence") fields = { - "device": snapshot.old_device, - "inode": snapshot.old_inode, + "device": snapshot.backup_device, + "inode": snapshot.backup_inode, "size_bytes": snapshot.size_bytes, "sha256": snapshot.sha256, "user_version": snapshot.user_version, @@ -564,8 +564,8 @@ def _check_backup_against_live( ): raise ArchiveRootRelocationError(f"backup receipt differs from relocated {filename}") _require_identity_continuity( - old_device=snapshot.old_device, - old_inode=snapshot.old_inode, + backup_device=snapshot.backup_device, + backup_inode=snapshot.backup_inode, device=snapshot.device, inode=snapshot.inode, label=filename, @@ -592,22 +592,22 @@ def prepare_archive_root_relocation( try: manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( backup_manifest, - old_configured_root=old_configured, - old_archive_root=old_resolved, + backup_configured_root=new_configured, + backup_archive_root=new_resolved, ) except MigrationError as exc: raise ArchiveRootRelocationError(str(exc)) from exc - old_root_device, old_root_inode = _authenticated_identity( + backup_root_device, backup_root_inode = _authenticated_identity( manifest.get("archive_root_source_identity"), label="archive root" ) - old_tier_identities = _authenticated_old_tier_identities(manifest) + backup_tier_identities = _authenticated_backup_tier_identities(manifest) active_index_pointer = _active_index_pointer_evidence(old_root=old_resolved, new_root=new_resolved) snapshots = tuple( _tier_snapshot( new_resolved, tier, - old_device=old_tier_identities[tier.value][0], - old_inode=old_tier_identities[tier.value][1], + backup_device=backup_tier_identities[tier.value][0], + backup_inode=backup_tier_identities[tier.value][1], active_index_pointer=active_index_pointer, ) for tier in ArchiveTier @@ -625,8 +625,8 @@ def prepare_archive_root_relocation( ) root_metadata = new_resolved.stat() _require_identity_continuity( - old_device=old_root_device, - old_inode=old_root_inode, + backup_device=backup_root_device, + backup_inode=backup_root_inode, device=root_metadata.st_dev, inode=root_metadata.st_ino, label="root", @@ -634,8 +634,8 @@ def prepare_archive_root_relocation( return _sealed_plan( old_configured_root=str(old_configured), old_resolved_root=str(old_resolved), - old_root_device=old_root_device, - old_root_inode=old_root_inode, + backup_root_device=backup_root_device, + backup_root_inode=backup_root_inode, new_configured_root=str(new_configured), new_resolved_root=str(new_resolved), new_root_device=root_metadata.st_dev, @@ -757,6 +757,59 @@ def assert_no_prepared_archive_root_relocation(root: Path) -> None: ) +def _validate_plan_continuity_binding( + root: Path, + *, + plan: ArchiveRootRelocationPlan, + item: RelocationSourceTrain, + train: object, + relocation_receipt: ArchiveRootRelocationReceipt | None, +) -> None: + """Bind resumed continuity authority to this relocation plan and its CAS receipt.""" + from polylogue.storage.sqlite.migration_runner import DurableChangeTrain + + assert isinstance(train, DurableChangeTrain) + refresh_refs = tuple( + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") + ) + if refresh_refs != item.source_continuity_receipt_digests: + raise ArchiveRootRelocationError("archive-root relocation exact refresh proof changed") + after = train.revision == item.before_revision + (1 if item.requires_rebind else 0) + if not after or train.source_continuity_evidence is None: + return + if relocation_receipt is None or relocation_receipt.plan_sha256 != plan.plan_sha256: + raise ArchiveRootRelocationError("archive-root relocation exact receipt binding is missing") + receipt_digest = relocation_receipt.prepared_receipt_sha256 or relocation_receipt.receipt_sha256 + if f"proof:archive-root-relocation:{receipt_digest}" not in train.proof_refs: + raise ArchiveRootRelocationError("archive-root relocation exact receipt binding is missing") + transition_refs = tuple( + ref.removeprefix("proof:source-continuity-relocation:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-relocation:") + ) + matches = 0 + for digest in transition_refs: + path = root / ".maintenance-state" / "source-continuity-relocations" / f"{digest}.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof is unreadable") from exc + if not isinstance(payload, dict) or payload.pop("transition_sha256", None) != digest: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") + if _canonical_sha256(payload) != digest: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") + if ( + payload.get("relocation_plan_sha256") == plan.plan_sha256 + and payload.get("relocation_receipt_sha256") == receipt_digest + and payload.get("refresh_receipt_sha256") in item.source_continuity_receipt_digests + ): + matches += 1 + if matches != 1: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof is missing") + + def _revalidate_plan_live_state( root: Path, plan: ArchiveRootRelocationPlan, @@ -769,8 +822,8 @@ def _revalidate_plan_live_state( try: manifest_path, receipt_path, manifest, receipt = validate_full_evidence_backup_for_archive_root_relocation( Path(plan.backup_manifest_path), - old_configured_root=Path(plan.old_configured_root), - old_archive_root=Path(plan.old_resolved_root), + backup_configured_root=Path(plan.new_configured_root), + backup_archive_root=root, ) except MigrationError as exc: raise ArchiveRootRelocationError(str(exc)) from exc @@ -785,19 +838,19 @@ def _revalidate_plan_live_state( if plan.backup_tier_inventory != expected_inventory: raise ArchiveRootRelocationError("archive-root relocation plan tier inventory changed") if _authenticated_identity(manifest.get("archive_root_source_identity"), label="archive root") != ( - plan.old_root_device, - plan.old_root_inode, + plan.backup_root_device, + plan.backup_root_inode, ): - raise ArchiveRootRelocationError("archive-root relocation old root identity authority changed") - old_tiers = {item.tier: (item.old_device, item.old_inode) for item in plan.tiers} - if len(plan.tiers) != len(ArchiveTier) or set(old_tiers) != {tier.value for tier in ArchiveTier}: + raise ArchiveRootRelocationError("archive-root relocation moved-root identity authority changed") + backup_tiers = {item.tier: (item.backup_device, item.backup_inode) for item in plan.tiers} + if len(plan.tiers) != len(ArchiveTier) or set(backup_tiers) != {tier.value for tier in ArchiveTier}: raise ArchiveRootRelocationError("archive-root relocation plan tier evidence is incomplete") snapshots = tuple( _tier_snapshot( root, tier, - old_device=old_tiers[tier.value][0], - old_inode=old_tiers[tier.value][1], + backup_device=backup_tiers[tier.value][0], + backup_inode=backup_tiers[tier.value][1], active_index_pointer=plan.active_index_pointer, ) for tier in ArchiveTier @@ -843,6 +896,9 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError( f"archive-root relocation continuity receipt is invalid: {path}" ) from exc + _validate_plan_continuity_binding( + root, plan=plan, item=item, train=train, relocation_receipt=pending_receipt + ) def _require_offline_apply_boundary(root: Path) -> None: diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 302dab68a5..9915539647 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -36,7 +36,7 @@ read_optional_receipt, ) from polylogue.paths import render_root -from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation +from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation, TierFileIdentity from polylogue.storage.backup_attestation import BackupAttestationError, verify_verification_receipt from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, @@ -99,6 +99,14 @@ class HistoricalSourceContinuityRecoveryPlan(BaseModel): post_backup_manifest_sha256: str post_backup_receipt_path: str post_backup_receipt_sha256: str + pre_backup_source_device: int + pre_backup_source_inode: int + post_backup_source_device: int + post_backup_source_inode: int + new_source_device: int + new_source_inode: int + refresh_proof_id: str + refresh_receipt_sha256: str source_train_path: str source_train_revision: int source_train_sha256: str @@ -289,7 +297,7 @@ def _verify_receipt(receipt: HistoricalSourceContinuityRecoveryReceipt) -> None: def _backup_source_evidence( manifest_path: Path, *, old_source_path: Path -) -> tuple[Path, dict[str, object], DurableDatabaseEvidence]: +) -> tuple[Path, dict[str, object], DurableDatabaseEvidence, tuple[int, int]]: """Authenticate one old-path source backup without assuming it is full-evidence.""" _real_file(manifest_path, label="historical backup manifest") backup_root = _real_directory(manifest_path.parent, label="historical backup directory") @@ -322,6 +330,9 @@ def _backup_source_evidence( raise HistoricalSourceContinuityRecoveryError("historical backup lacks source artifact authority") if fingerprint.get("path") != str(old_source_path) or artifact.get("source_fingerprint") != fingerprint: raise HistoricalSourceContinuityRecoveryError("historical backup source path authority changed") + device, inode = fingerprint.get("device"), fingerprint.get("inode") + if type(device) is not int or type(inode) is not int: + raise HistoricalSourceContinuityRecoveryError("historical backup lacks authenticated source device/inode") backup_source = backup_root / "source.db" _real_file(backup_source, label="historical backup source.db") actual = {"sha256": _sha256(backup_source), "size_bytes": backup_source.stat().st_size} @@ -337,7 +348,94 @@ def _backup_source_evidence( or artifact.get("user_version") != evidence.user_version ): raise HistoricalSourceContinuityRecoveryError("historical backup source version differs from its receipt") - return receipt_path, manifest, evidence + return receipt_path, manifest, evidence, (device, inode) + + +def _require_source_identity(root: Path, *, device: int, inode: int, label: str) -> TierFileIdentity: + identity = TierFileIdentity.resolve("source", root / "source.db") + if not identity.exists or (identity.device, identity.inode) != (device, inode): + raise HistoricalSourceContinuityRecoveryError( + f"historical continuity recovery requires source.db device/inode continuity for {label}; " + "a copied archive is not accepted" + ) + return identity + + +def _refresh_proof_id( + *, + old_root: Path, + new_root: Path, + source_train_sha256: str, + historical_evidence_sha256: str, + pre_identity: tuple[int, int], + post_identity: tuple[int, int], + new_identity: TierFileIdentity, +) -> str: + return _canonical_json_sha256( + { + "old_root": str(old_root), + "new_root": str(new_root), + "source_train_sha256": source_train_sha256, + "historical_evidence_sha256": historical_evidence_sha256, + "pre_identity": pre_identity, + "post_identity": post_identity, + "new_identity": (new_identity.device, new_identity.inode), + } + ) + + +def _refresh_payload(plan: HistoricalSourceContinuityRecoveryPlan, *, train_id: str) -> dict[str, object]: + observed_at_ms = plan.source_after.get("observed_at_ms") + if type(observed_at_ms) is not int: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery plan has invalid refresh timestamp" + ) + return { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": plan.legacy_candidate_digest, + "evidence_ref": "proof:historical-source-continuity-recovery:" + plan.refresh_proof_id, + "refresh_proof_id": plan.refresh_proof_id, + "backup_manifest": plan.pre_backup_manifest_path, + "backup_manifest_sha256": plan.pre_backup_manifest_sha256, + "mutation_receipt": plan.mutation_receipt_path, + "mutation_receipt_sha256": plan.mutation_receipt_sha256, + "train_id": train_id, + "source_before": plan.source_before, + "source_after": plan.source_after, + "refreshed_at_ms": observed_at_ms, + "historical_bridge": { + "pre_backup": plan.pre_backup_manifest_sha256, + "post_backup": plan.post_backup_manifest_sha256, + "pre_backup_source_identity": [plan.pre_backup_source_device, plan.pre_backup_source_inode], + "post_backup_source_identity": [plan.post_backup_source_device, plan.post_backup_source_inode], + "new_source_identity": [plan.new_source_device, plan.new_source_inode], + "legacy_candidate_count": plan.legacy_candidate_count, + "legacy_candidate_digest": plan.legacy_candidate_digest, + "census": plan.census, + }, + } + + +def _validate_exact_refresh_binding( + root: Path, plan: HistoricalSourceContinuityRecoveryPlan, train: DurableChangeTrain +) -> None: + expected_ref = "proof:source-continuity-refresh:" + plan.refresh_receipt_sha256 + if expected_ref not in train.proof_refs or train.source_continuity_evidence is None: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof is missing") + refresh_path = _refresh_path(root, plan.refresh_receipt_sha256) + try: + payload = json.loads(refresh_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery exact refresh proof is unreadable" + ) from exc + if not isinstance(payload, dict) or payload.pop("refresh_sha256", None) != plan.refresh_receipt_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof changed") + expected_payload = _refresh_payload(plan, train_id=train.train_id) + if payload != expected_payload or _canonical_json_sha256(payload) != plan.refresh_receipt_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof changed") + if not _evidence_matches_plan(train.source_continuity_evidence, plan.source_after): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof changed") def _legacy_liveness_receipt(receipt_path: Path, *, old_source_path: Path, pre_manifest: Path) -> tuple[int, str]: @@ -707,8 +805,21 @@ def prepare_historical_source_continuity_recovery( "historical continuity recovery requires distinct old and new roots" ) old_source = old_resolved / "source.db" - pre_receipt, _pre_manifest, pre = _backup_source_evidence(pre_backup_manifest, old_source_path=old_source) - post_receipt, _post_manifest, post = _backup_source_evidence(post_backup_manifest, old_source_path=old_source) + pre_receipt, _pre_manifest, pre, pre_identity = _backup_source_evidence( + pre_backup_manifest, old_source_path=old_source + ) + post_receipt, _post_manifest, post, post_identity = _backup_source_evidence( + post_backup_manifest, old_source_path=old_source + ) + if pre_identity != post_identity: + raise HistoricalSourceContinuityRecoveryError( + "historical backups do not retain one authenticated source identity" + ) + new_source_identity = _require_source_identity( + root, device=pre_identity[0], inode=pre_identity[1], label="pre backup" + ) + _require_source_identity(root, device=post_identity[0], inode=post_identity[1], label="post backup") + assert new_source_identity.device is not None and new_source_identity.inode is not None candidates, candidate_digest = _legacy_liveness_receipt( mutation_receipt, old_source_path=old_source, pre_manifest=pre_backup_manifest.absolute() ) @@ -775,6 +886,39 @@ def prepare_historical_source_continuity_recovery( if train.source_continuity_evidence is not None: raise HistoricalSourceContinuityRecoveryError("current released source train already has continuity authority") census = _census(root) + refresh_proof_id = _refresh_proof_id( + old_root=old_resolved, + new_root=root, + source_train_sha256=_sha256(train_path), + historical_evidence_sha256=historical_evidence_sha256, + pre_identity=pre_identity, + post_identity=post_identity, + new_identity=new_source_identity, + ) + refresh_payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": candidate_digest, + "evidence_ref": "proof:historical-source-continuity-recovery:" + refresh_proof_id, + "refresh_proof_id": refresh_proof_id, + "backup_manifest": str(pre_backup_manifest.absolute()), + "backup_manifest_sha256": _sha256(pre_backup_manifest), + "mutation_receipt": str(mutation_receipt.absolute()), + "mutation_receipt_sha256": _sha256(mutation_receipt), + "train_id": train.train_id, + "source_before": source_before, + "source_after": _evidence_payload(current), + "refreshed_at_ms": current.observed_at_ms, + "historical_bridge": { + "pre_backup": _sha256(pre_backup_manifest), + "post_backup": _sha256(post_backup_manifest), + "pre_backup_source_identity": list(pre_identity), + "post_backup_source_identity": list(post_identity), + "new_source_identity": [new_source_identity.device, new_source_identity.inode], + "legacy_candidate_count": candidates, + "legacy_candidate_digest": candidate_digest, + "census": census, + }, + } return _sealed_plan( old_configured_root=str(old_configured), old_resolved_root=str(old_resolved), @@ -793,6 +937,14 @@ def prepare_historical_source_continuity_recovery( post_backup_manifest_sha256=_sha256(post_backup_manifest), post_backup_receipt_path=str(post_receipt), post_backup_receipt_sha256=_sha256(post_receipt), + pre_backup_source_device=pre_identity[0], + pre_backup_source_inode=pre_identity[1], + post_backup_source_device=post_identity[0], + post_backup_source_inode=post_identity[1], + new_source_device=new_source_identity.device, + new_source_inode=new_source_identity.inode, + refresh_proof_id=refresh_proof_id, + refresh_receipt_sha256=_canonical_json_sha256(refresh_payload), source_train_path=str(train_path), source_train_revision=train.revision, source_train_sha256=_sha256(train_path), @@ -924,8 +1076,22 @@ def _revalidate( if str(root) != plan.new_resolved_root: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery configured root changed") old_source = Path(plan.old_resolved_root) / "source.db" - pre_receipt, _m, pre = _backup_source_evidence(Path(plan.pre_backup_manifest_path), old_source_path=old_source) - post_receipt, _m2, post = _backup_source_evidence(Path(plan.post_backup_manifest_path), old_source_path=old_source) + pre_receipt, _m, pre, pre_identity = _backup_source_evidence( + Path(plan.pre_backup_manifest_path), old_source_path=old_source + ) + post_receipt, _m2, post, post_identity = _backup_source_evidence( + Path(plan.post_backup_manifest_path), old_source_path=old_source + ) + if pre_identity != (plan.pre_backup_source_device, plan.pre_backup_source_inode) or post_identity != ( + plan.post_backup_source_device, + plan.post_backup_source_inode, + ): + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery backup identity changed") + new_identity = _require_source_identity( + root, device=plan.new_source_device, inode=plan.new_source_inode, label="sealed destination" + ) + if (new_identity.device, new_identity.inode) != pre_identity or pre_identity != post_identity: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source identity changed") bindings = ( (Path(plan.mutation_receipt_path), plan.mutation_receipt_sha256), (Path(plan.pre_backup_manifest_path), plan.pre_backup_manifest_sha256), @@ -1007,27 +1173,12 @@ def _apply_historical_source_continuity_recovery_locked( resolved = _real_directory(root, label="configured archive root") _revalidate(resolved, plan, stopped=stopped_daemon_evidence_ref, writer=single_writer_evidence_ref) planned_current = _evidence_from_plan(plan.source_after) - refresh_payload = { - "format": "polylogue.source-continuity-refresh.v1", - "operation_id": plan.legacy_candidate_digest, - "evidence_ref": "proof:historical-source-continuity-recovery:" + plan.plan_sha256, - "backup_manifest": plan.pre_backup_manifest_path, - "backup_manifest_sha256": plan.pre_backup_manifest_sha256, - "mutation_receipt": plan.mutation_receipt_path, - "mutation_receipt_sha256": plan.mutation_receipt_sha256, - "train_id": load_durable_change_train_manifest(Path(plan.source_train_path)).train_id, - "source_before": plan.source_before, - "source_after": plan.source_after, - "refreshed_at_ms": planned_current.observed_at_ms, - "historical_bridge": { - "pre_backup": plan.pre_backup_manifest_sha256, - "post_backup": plan.post_backup_manifest_sha256, - "legacy_candidate_count": plan.legacy_candidate_count, - "legacy_candidate_digest": plan.legacy_candidate_digest, - "census": plan.census, - }, - } + refresh_payload = _refresh_payload( + plan, train_id=load_durable_change_train_manifest(Path(plan.source_train_path)).train_id + ) refresh_digest = _canonical_json_sha256(refresh_payload) + if refresh_digest != plan.refresh_receipt_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery sealed refresh proof changed") refresh_path = _refresh_path(resolved, refresh_digest) command = f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance source-continuity-recovery apply --plan --authorize {plan.plan_sha256} --output-format json" receipt_path = _receipt_path(resolved, plan) @@ -1051,6 +1202,7 @@ def _apply_historical_source_continuity_recovery_locked( if receipt.state == "committed": train = load_durable_change_train_manifest(Path(plan.source_train_path)) _validate_source_continuity_refresh_receipt(resolved, train) + _validate_exact_refresh_binding(resolved, plan, train) return HistoricalSourceContinuityRecoveryResult( state="committed", plan_sha256=plan.plan_sha256, @@ -1071,12 +1223,8 @@ def _apply_historical_source_continuity_recovery_locked( write_durable_change_train_manifest(path, updated, expected_revision=plan.source_train_revision) else: _validate_source_continuity_refresh_receipt(resolved, train) - if train.source_continuity_evidence is None or not _evidence_matches_plan( - train.source_continuity_evidence, plan.source_after - ): - raise HistoricalSourceContinuityRecoveryError( - "historical continuity recovery source train is neither exact before nor after" - ) + _validate_exact_refresh_binding(resolved, plan, train) + _validate_exact_refresh_binding(resolved, plan, load_durable_change_train_manifest(path)) committed = _sealed_receipt( state="committed", revision=1, diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 35578aa7a0..c606fe3cbe 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -846,10 +846,10 @@ def validate_migration_backup_live_fingerprint( def validate_full_evidence_backup_for_archive_root_relocation( path: Path, *, - old_configured_root: Path, - old_archive_root: Path, + backup_configured_root: Path, + backup_archive_root: Path, ) -> tuple[Path, Path, dict[str, object], dict[str, object]]: - """Authenticate complete old-root backup evidence for a root relocation.""" + """Authenticate complete full-evidence backup at the moved archive root.""" manifest_path, receipt_path, backup_root, manifest, receipt = _load_verified_backup_package(path) if manifest.get("profile") != "full_evidence": raise MigrationError("archive-root relocation requires a verified full_evidence backup") @@ -860,9 +860,13 @@ def validate_full_evidence_backup_for_archive_root_relocation( raise MigrationError("archive-root relocation backup must contain the exact complete tier set") for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT): try: - verify_verification_receipt(receipt, tier=tier.value, live_tier_path=old_archive_root / f"{tier.value}.db") + verify_verification_receipt( + receipt, tier=tier.value, live_tier_path=backup_archive_root / f"{tier.value}.db" + ) except BackupAttestationError as exc: - raise MigrationError(f"archive-root relocation old-root authority failed for {tier.value}: {exc}") from exc + raise MigrationError( + f"archive-root relocation moved-root authority failed for {tier.value}: {exc}" + ) from exc validated_artifacts = _validate_closed_backup_package( backup_root, manifest, @@ -884,29 +888,29 @@ def validate_full_evidence_backup_for_archive_root_relocation( raise MigrationError(f"archive-root relocation backup lacks authenticated inode authority for {filename}") recorded_path = fingerprint.get("path") if not isinstance(recorded_path, str): - raise MigrationError(f"archive-root relocation backup lacks old path authority for {filename}") + raise MigrationError(f"archive-root relocation backup lacks moved-tier path authority for {filename}") recorded = Path(recorded_path).resolve(strict=False) if tier == ArchiveTier.INDEX.value: - if not recorded.is_relative_to(old_archive_root.resolve(strict=False)): - raise MigrationError("archive-root relocation backup active index is outside the old archive root") - elif recorded != (old_archive_root / filename).resolve(strict=False): - raise MigrationError(f"archive-root relocation backup belongs to a different old tier path: {filename}") + if not recorded.is_relative_to(backup_archive_root.resolve(strict=False)): + raise MigrationError("archive-root relocation backup active index is outside the moved archive root") + elif recorded != (backup_archive_root / filename).resolve(strict=False): + raise MigrationError(f"archive-root relocation backup belongs to a different moved tier path: {filename}") root_identity = manifest.get("archive_root_source_identity") if not isinstance(root_identity, dict) or not all( isinstance(root_identity.get(field), int) for field in ("device", "inode") ): - raise MigrationError("archive-root relocation backup lacks authenticated root inode authority") + raise MigrationError("archive-root relocation backup lacks authenticated moved-root inode authority") recorded_root = root_identity.get("resolved_path") - if not isinstance(recorded_root, str) or Path(recorded_root).resolve(strict=False) != old_archive_root.resolve( + if not isinstance(recorded_root, str) or Path(recorded_root).resolve(strict=False) != backup_archive_root.resolve( strict=False ): - raise MigrationError("archive-root relocation backup belongs to a different old archive root") + raise MigrationError("archive-root relocation backup belongs to a different moved archive root") recorded_configured_root = root_identity.get("configured_path") if ( not isinstance(recorded_configured_root, str) - or Path(recorded_configured_root).absolute() != old_configured_root.absolute() + or Path(recorded_configured_root).absolute() != backup_configured_root.absolute() ): - raise MigrationError("archive-root relocation backup belongs to a different configured old archive root") + raise MigrationError("archive-root relocation backup belongs to a different configured moved archive root") return manifest_path, receipt_path, manifest, receipt diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index b9a69212c7..af836b7004 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -64,6 +64,7 @@ DURABLE_MIGRATION_ADOPTION_FLOORS, load_durable_change_train_manifest, rebind_released_source_train_archive_identity, + recover_released_source_train_continuity, ) from polylogue.storage.sqlite.migration_runner import ( _canonical_json_sha256, @@ -113,14 +114,15 @@ def test_relocation_nested_dispatch_keeps_analyze_facets_on_the_real_action(cli_ def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( - workspace_env: dict[str, Path], tmp_path: Path + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The plan enters backup attestation and immutable archive inspection, never a write route.""" old_root = workspace_env["archive_root"] - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved-archive" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None before = { path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") } @@ -141,10 +143,13 @@ def test_plan_refuses_fresh_bootstrap_without_writing_the_moved_archive( def test_plan_rejects_mutated_manifest_and_stale_authenticated_receipt( - workspace_env: dict[str, Path], tmp_path: Path + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The old-path HMAC cannot bypass manifest-byte or closed-package binding.""" old_root = workspace_env["archive_root"] + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) first = backup_archive(output_dir=tmp_path / "first", profile="full_evidence", verify=True) second = backup_archive(output_dir=tmp_path / "second", profile="full_evidence", verify=True) assert first.ok and first.output_path is not None @@ -154,8 +159,6 @@ def test_plan_rejects_mutated_manifest_and_stale_authenticated_receipt( second_receipt = Path(second.output_path) / "verification-receipt.json" original_manifest = first_manifest.read_bytes() original_receipt = first_receipt.read_bytes() - new_root = tmp_path / "moved" - os.rename(old_root, new_root) first_manifest.write_bytes(original_manifest + b"\n") with pytest.raises(ArchiveRootRelocationError, match="does not match manifest"): @@ -195,7 +198,7 @@ def test_plan_rejects_byte_identical_copied_archive_with_new_inodes( assert (old_root / "source.db").read_bytes() == (new_root / "source.db").read_bytes() assert (old_root / "source.db").stat().st_ino != (new_root / "source.db").stat().st_ino - with pytest.raises(ArchiveRootRelocationError, match="device/inode continuity"): + with pytest.raises(ArchiveRootRelocationError, match="moved-root authority"): prepare_archive_root_relocation( old_root=old_root, new_root=new_root, @@ -213,8 +216,8 @@ def test_tier_identity_rejects_a_changed_device_with_a_coincident_inode(tmp_path tier="source", configured_path=str(tmp_path / "source.db"), resolved_path=str(tmp_path / "source.db"), - old_device=41, - old_inode=99, + backup_device=41, + backup_inode=99, device=42, inode=99, size_bytes=1, @@ -225,8 +228,8 @@ def test_tier_identity_rejects_a_changed_device_with_a_coincident_inode(tmp_path quick_check=("ok",), ) fingerprint = { - "device": snapshot.old_device, - "inode": snapshot.old_inode, + "device": snapshot.backup_device, + "inode": snapshot.backup_inode, "size_bytes": snapshot.size_bytes, "sha256": snapshot.sha256, "user_version": snapshot.user_version, @@ -248,10 +251,11 @@ def test_plan_rejects_root_device_change_with_a_coincident_inode( old_root = workspace_env["archive_root"] _released_moved_source_train(old_root, monkeypatch) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None real_authenticated_identity = relocation._authenticated_identity def changed_root_device(payload: object, *, label: str) -> tuple[int, int]: @@ -1236,10 +1240,11 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ old_root = workspace_env["archive_root"] manifest = _released_moved_source_train(old_root, monkeypatch) _attach_retained_source_continuity(old_root, manifest) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None moved_manifest = new_root / manifest.relative_to(old_root) with sqlite3.connect(new_root / "source.db") as connection: with pytest.raises(Exception, match="continuity proof failed"): @@ -1258,8 +1263,8 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ stopped_daemon_evidence_ref="proof:daemon-stopped", single_writer_evidence_ref="proof:archive-ownership-lock", ) - assert plan.old_root_inode == plan.new_root_inode - assert all(item.old_inode == item.inode for item in plan.tiers) + assert plan.backup_root_inode == plan.new_root_inode + assert all(item.backup_inode == item.inode for item in plan.tiers) assert database_before == { path.name: (path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) for path in new_root.glob("*.db") } @@ -1354,10 +1359,11 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public manifest = _released_moved_source_train(old_root, monkeypatch) _attach_retained_source_continuity(old_root, manifest) old_active_target = _activate_movable_index_generation(old_root) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None plan = prepare_archive_root_relocation( old_root=old_root, @@ -1412,14 +1418,15 @@ def test_relocation_rejects_an_active_pointer_not_owned_by_the_old_root( old_root = workspace_env["archive_root"] manifest = _released_moved_source_train(old_root, monkeypatch) _attach_retained_source_continuity(old_root, manifest) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None foreign = tmp_path / "foreign" / "index.db" foreign.parent.mkdir() foreign.write_bytes(b"foreign") (old_root / ".index-active-pointer").write_text(str(foreign), encoding="utf-8") new_root = tmp_path / "moved" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None with pytest.raises(ArchiveRootRelocationError, match="not owned by the old root"): prepare_archive_root_relocation( @@ -1448,10 +1455,11 @@ def test_plan_rejects_the_real_stale_source_train_shape_before_receipt_write( ), ) write_durable_change_train_manifest(manifest, stale, expected_revision=released.revision) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "moved" os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None manifest_before = (new_root / manifest.relative_to(old_root)).read_bytes() with pytest.raises(ArchiveRootRelocationError, match="typed source-continuity refresh"): @@ -1465,3 +1473,245 @@ def test_plan_rejects_the_real_stale_source_train_shape_before_receipt_write( assert (new_root / manifest.relative_to(old_root)).read_bytes() == manifest_before assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() + + +def test_historical_continuity_recovery_cli_rejects_a_byte_identical_copied_archive( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The historical old-path attestation must not authorize a copied destination file.""" + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + copied_root = tmp_path / "copied" + shutil.copytree(moved_root, copied_root, symlinks=True) + plan_path = tmp_path / "copied-continuity-plan.json" + + with _test_historical_operation_evidence_resource(evidence): + result = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env={"POLYLOGUE_ARCHIVE_ROOT": str(copied_root)}, + catch_exceptions=False, + ) + + assert result.exit_code != 0 + assert "device/inode continuity" in result.output + + +def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_relocation( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The documented recovery, fresh backup, then relocation sequence uses public commands.""" + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(moved_root)} + continuity_plan = tmp_path / "continuity-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(continuity_plan), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + continuity_digest = str(_maintenance_json_output(planned.output)["plan_sha256"]) + recovered = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(continuity_plan), + "--authorize", + continuity_digest, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert recovered.exit_code == 0, recovered.output + + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(moved_root)) + backup = backup_archive(output_dir=tmp_path / "moved-backup", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + relocation_plan = tmp_path / "relocation-plan.json" + relocated = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--backup-manifest", + str(Path(backup.output_path) / "manifest.json"), + "--output", + str(relocation_plan), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + + assert relocated.exit_code == 0, relocated.output + + +def test_historical_continuity_recovery_resume_rejects_a_foreign_same_evidence_receipt( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A prepared recovery may resume only with its sealed refresh receipt and CAS revision.""" + from polylogue.operations import historical_source_continuity_recovery as recovery + + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(moved_root)} + plan_path = tmp_path / "continuity-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + plan = _maintenance_json_output(planned.output) + plan_sha256 = str(plan["plan_sha256"]) + source_before = plan.get("source_before") + source_after = plan.get("source_after") + assert isinstance(source_before, dict) and isinstance(source_after, dict) + observed_at_ms = source_after.get("observed_at_ms") + assert type(observed_at_ms) is int + real_write_refresh = recovery._write_refresh_receipt + monkeypatch.setattr( + recovery, "_write_refresh_receipt", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("crash")) + ) + with pytest.raises(RuntimeError, match="crash"): + CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + monkeypatch.setattr(recovery, "_write_refresh_receipt", real_write_refresh) + train_path = Path(str(plan["source_train_path"])) + train = load_durable_change_train_manifest(train_path) + foreign_payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": "foreign", + "evidence_ref": "proof:foreign-continuity", + "backup_manifest": str(pre_manifest), + "backup_manifest_sha256": _sha256(pre_manifest), + "mutation_receipt": str(mutation_receipt), + "mutation_receipt_sha256": _sha256(mutation_receipt), + "train_id": train.train_id, + "source_before": source_before, + "source_after": source_after, + "refreshed_at_ms": observed_at_ms, + } + foreign_digest = _canonical_json_sha256(foreign_payload) + real_write_refresh( + moved_root / ".maintenance-state" / "source-continuity-refreshes" / f"{foreign_digest}.json", + {**foreign_payload, "refresh_sha256": foreign_digest}, + ) + substituted = recover_released_source_train_continuity( + train, + current_evidence=recovery._evidence_from_plan(source_after), + proof_ref=f"proof:source-continuity-refresh:{foreign_digest}", + ) + write_durable_change_train_manifest(train_path, substituted, expected_revision=train.revision) + resumed = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + + assert resumed.exit_code != 0 + assert "exact refresh proof" in resumed.output From 41f6fed2623781de1cc5848460a8c1fd636c281e Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 03:28:45 +0200 Subject: [PATCH 22/39] fix: bind no-rebind relocation continuity Historical continuity recovery leaves the released source train on the moved archive identity, so relocation has no identity rebind to perform. The relocation validator still required an exact relocation transition and receipt, while apply skipped the CAS revision that would create them. CAS-revise continuity-bearing trains even when their identity already matches, publish the exact plan and prepared-receipt transition, and treat that transition as superseding its retained refresh authority. Extend the public CLI sequence through relocation apply and repair the daemon prepared-receipt admission regression for moved-root backup semantics. --- .../operations/archive_root_relocation.py | 22 ++++++++---- .../storage/sqlite/durable_change_train.py | 11 ++++-- tests/unit/daemon/test_daemon_cli.py | 12 +++++-- .../storage/test_archive_root_relocation.py | 34 +++++++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 38af072db3..38795240c1 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -757,12 +757,18 @@ def assert_no_prepared_archive_root_relocation(root: Path) -> None: ) +def _requires_train_update(item: RelocationSourceTrain) -> bool: + """Return whether relocation must CAS-revise this released train.""" + return item.requires_rebind or bool(item.source_continuity_receipt_digests) + + def _validate_plan_continuity_binding( root: Path, *, plan: ArchiveRootRelocationPlan, item: RelocationSourceTrain, train: object, + before: bool, relocation_receipt: ArchiveRootRelocationReceipt | None, ) -> None: """Bind resumed continuity authority to this relocation plan and its CAS receipt.""" @@ -776,8 +782,7 @@ def _validate_plan_continuity_binding( ) if refresh_refs != item.source_continuity_receipt_digests: raise ArchiveRootRelocationError("archive-root relocation exact refresh proof changed") - after = train.revision == item.before_revision + (1 if item.requires_rebind else 0) - if not after or train.source_continuity_evidence is None: + if before or train.source_continuity_evidence is None: return if relocation_receipt is None or relocation_receipt.plan_sha256 != plan.plan_sha256: raise ArchiveRootRelocationError("archive-root relocation exact receipt binding is missing") @@ -873,7 +878,7 @@ def _revalidate_plan_live_state( ) before = _sha256_file(path) == item.before_manifest_sha256 after = ( - train.revision == item.before_revision + (1 if item.requires_rebind else 0) + train.revision == item.before_revision + int(_requires_train_update(item)) and train.apply_evidence is not None and train.apply_evidence.post.archive_identity_digest == item.after_archive_identity_digest and ( @@ -897,7 +902,12 @@ def _revalidate_plan_live_state( f"archive-root relocation continuity receipt is invalid: {path}" ) from exc _validate_plan_continuity_binding( - root, plan=plan, item=item, train=train, relocation_receipt=pending_receipt + root, + plan=plan, + item=item, + train=train, + before=before, + relocation_receipt=pending_receipt, ) @@ -1002,7 +1012,7 @@ def _apply_archive_root_relocation_locked( path = Path(item.path) train = load_durable_change_train_manifest(path) actual_hash = _sha256_file(path) - if actual_hash == item.before_manifest_sha256 and item.requires_rebind: + if actual_hash == item.before_manifest_sha256 and _requires_train_update(item): continuity_transition_ref = None if train.source_continuity_evidence is not None: transition_digest = write_source_continuity_relocation_transition( @@ -1027,7 +1037,7 @@ def _apply_archive_root_relocation_locked( ) write_durable_change_train_manifest(path, updated, expected_revision=item.before_revision) elif ( - train.revision != item.before_revision + (1 if item.requires_rebind else 0) + train.revision != item.before_revision + int(_requires_train_update(item)) or train.apply_evidence is None or train.apply_evidence.post.archive_identity_digest != item.after_archive_identity_digest ): diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index c8dceb7aff..eba04c977c 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1132,7 +1132,11 @@ def _validate_source_continuity_refresh_receipt( receipt_path = refresh_root / f"{digest}.json" payload = _read_source_continuity_refresh_receipt(receipt_path, digest=digest, train=train) refresh_payloads[digest] = payload - matches = sum(payload.get("source_after") == expected_after for payload in refresh_payloads.values()) + matching_authorities = { + ("refresh", digest) + for digest, payload in refresh_payloads.items() + if payload.get("source_after") == expected_after + } for digest in relocation_refs: payload = _read_source_continuity_relocation_receipt( archive_root, @@ -1146,8 +1150,9 @@ def _validate_source_continuity_refresh_receipt( if payload.get("source_before") != refresh_payloads[refresh_digest].get("source_after"): raise DurableChangeTrainError("source continuity relocation transition does not preserve refresh authority") if payload.get("source_after") == expected_after: - matches += 1 - if matches != 1: + matching_authorities.discard(("refresh", refresh_digest)) + matching_authorities.add(("relocation", digest)) + if len(matching_authorities) != 1: raise DurableChangeTrainError( "source continuity evidence does not identify exactly one matching refresh receipt" ) diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index af0f9e79df..10a5c0f859 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4113,16 +4113,18 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, apply_archive_root_relocation, + assert_no_prepared_archive_root_relocation, prepare_archive_root_relocation, ) from tests.unit.storage.test_archive_root_relocation import _released_moved_source_train old_root = workspace_env["archive_root"] _released_moved_source_train(old_root, monkeypatch) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None root = tmp_path / "relocated-archive" os.rename(old_root, root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None plan = prepare_archive_root_relocation( old_root=old_root, new_root=root, @@ -4138,7 +4140,12 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( with pytest.raises(RuntimeError, match="leave prepared relocation receipt"): apply_archive_root_relocation(root=root, plan=plan, authorization=plan.plan_sha256) configure = Mock() + admission = Mock(wraps=assert_no_prepared_archive_root_relocation) monkeypatch.setattr("polylogue.paths.archive_root", lambda: root) + monkeypatch.setattr( + "polylogue.operations.archive_root_relocation.assert_no_prepared_archive_root_relocation", + admission, + ) monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", configure) with pytest.raises(ArchiveRootRelocationError, match="archive-root-relocation apply"): @@ -4154,6 +4161,7 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( ) ) + admission.assert_called_once_with(root) configure.assert_not_called() diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index af836b7004..c2e16d83ae 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1598,6 +1598,40 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re ) assert relocated.exit_code == 0, relocated.output + relocation_payload = _maintenance_json_output(relocated.output) + relocation_digest = str(relocation_payload["plan_sha256"]) + relocation_plan_payload = json.loads(relocation_plan.read_text(encoding="utf-8")) + source_trains = relocation_plan_payload["source_trains"] + assert isinstance(source_trains, list) and len(source_trains) == 1 + assert source_trains[0]["requires_rebind"] is False + + applied = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "apply", + "--plan", + str(relocation_plan), + "--authorize", + relocation_digest, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + + assert applied.exit_code == 0, applied.output + applied_payload = _maintenance_json_output(applied.output) + assert applied_payload["state"] == "committed" + train = load_durable_change_train_manifest(Path(source_trains[0]["path"])) + relocation_refs = tuple(ref for ref in train.proof_refs if ref.startswith("proof:archive-root-relocation:")) + transition_refs = tuple(ref for ref in train.proof_refs if ref.startswith("proof:source-continuity-relocation:")) + assert len(relocation_refs) == 1 + assert len(transition_refs) == 1 def test_historical_continuity_recovery_resume_rejects_a_foreign_same_evidence_receipt( From 6c288a3157e16dc6f036db429187c1d231bc46bc Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 03:28:59 +0200 Subject: [PATCH 23/39] docs: correct moved-root relocation backup flow Archive relocation now authenticates a fresh full-evidence backup against the moved root. Document the executable order: complete any historical continuity bridge, create and verify the backup under the moved-root configuration, then use the retired root only for transition and pointer mapping. --- docs/archive-backup.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 7dd4c3479a..1cb1bff04d 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -39,13 +39,24 @@ contains recent writes creates an incomplete backup. ## Offline archive-root relocation -An inode-preserving filesystem move is the only supported way to change a configured archive root without restoring or rebuilding it. Stop the daemon, move the complete root without copying its database files, and retain the verified `full_evidence` backup made at the old root. Then point `POLYLOGUE_ARCHIVE_ROOT` at the destination and create the bound plan: +An inode-preserving filesystem move is the only supported way to change a configured archive root without restoring or rebuilding it. Stop the daemon and move the complete root without copying its database files. Set `POLYLOGUE_ARCHIVE_ROOT` to the moved root before creating relocation backup evidence. + +If the current released source train lacks continuity authority for historical source changes, first run the `source-continuity-recovery` plan and apply sequence documented in [Maintenance Operations](maintenance.md#recovering-the-one-historical-liveness-receipt-shape). Its authenticated pre/post backup evidence belongs to the retired path and is used only for that bridge. After the bridge commits, or immediately after the move when no bridge is required, create and verify a fresh complete backup at the moved root: + +```bash +POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ + polylogue ops backup \ + --output-dir /safe/operator/location/relocation-backup \ + --profile full_evidence --verify +``` + +Use the `manifest.json` printed by that command to create the bound relocation plan. `--old-root` names the retired pre-move root only for the identity transition and active-index pointer mapping: ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ polylogue ops maintenance archive-root-relocation plan \ --old-root /old/archive/root \ - --backup-manifest /path/to/verified-full-evidence/manifest.json \ + --backup-manifest /safe/operator/location/relocation-backup/PACKAGE/manifest.json \ --output /safe/operator/location/relocation-plan.json --output-format json ``` @@ -58,7 +69,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --authorize PLAN_SHA256 --output-format json ``` -The route reads every SQLite file immutably and refuses copied files, WAL sidecars, missing HMAC authority for the old path, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census; it is a separate offline transition, not an exception inside relocation. After it commits, make and verify a fresh `full_evidence` backup at the moved root before relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises only released source train manifests and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence, outside this code path. +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, moved-root backup receipts that do not authenticate the current tier paths, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census. That bridge is a separate offline transition, not an exception inside relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises released source train manifests when identity or continuity proof requires it and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence outside this code path. For a deployed archive, run these commands only from the Nix package built from the post-merge commit selected for deployment. Record that merge SHA and the resulting Nix store path in the operator receipt, verify the daemon executable resolves to that exact package, and keep `POLYLOGUE_ARCHIVE_ROOT` set to the configured deployed root. Do not resume a stopped daemon with an older deployed package or a branch checkout: its durable-train vocabulary may predate the relocation transition. From 25617719bae66b0135fda2ae95ffe92759e1fa4c Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 04:09:44 +0200 Subject: [PATCH 24/39] fix: pin retained continuity refresh proofs --- .../historical_source_continuity_recovery.py | 12 +- .../storage/sqlite/durable_change_train.py | 35 ++--- .../storage/test_archive_root_relocation.py | 123 ++++++++++++++++++ 3 files changed, 150 insertions(+), 20 deletions(-) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 9915539647..6627524002 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -49,6 +49,7 @@ DurableChangeTrain, DurableChangeTrainError, DurableChangeTrainState, + _read_source_continuity_refresh_receipt, _released_train_manifests_by_target, _require_released_train_chain, _validate_source_continuity_refresh_receipt, @@ -422,15 +423,16 @@ def _validate_exact_refresh_binding( expected_ref = "proof:source-continuity-refresh:" + plan.refresh_receipt_sha256 if expected_ref not in train.proof_refs or train.source_continuity_evidence is None: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof is missing") - refresh_path = _refresh_path(root, plan.refresh_receipt_sha256) try: - payload = json.loads(refresh_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + payload = _read_source_continuity_refresh_receipt( + root, + digest=plan.refresh_receipt_sha256, + train=train, + ) + except DurableChangeTrainError as exc: raise HistoricalSourceContinuityRecoveryError( "historical continuity recovery exact refresh proof is unreadable" ) from exc - if not isinstance(payload, dict) or payload.pop("refresh_sha256", None) != plan.refresh_receipt_sha256: - raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof changed") expected_payload = _refresh_payload(plan, train_id=train.train_id) if payload != expected_payload or _canonical_json_sha256(payload) != plan.refresh_receipt_sha256: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery exact refresh proof changed") diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index eba04c977c..a361925eb7 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1114,7 +1114,6 @@ def _validate_source_continuity_refresh_receipt( if train.source_continuity_evidence is None: return expected_after = _migration_runner._manifest_json_value(train.source_continuity_evidence) - refresh_root = archive_root / ".maintenance-state" / "source-continuity-refreshes" refresh_refs = [ ref.removeprefix("proof:source-continuity-refresh:") for ref in train.proof_refs @@ -1129,8 +1128,7 @@ def _validate_source_continuity_refresh_receipt( raise DurableChangeTrainError("source continuity evidence has no retained refresh receipt") refresh_payloads: dict[str, dict[str, object]] = {} for digest in refresh_refs: - receipt_path = refresh_root / f"{digest}.json" - payload = _read_source_continuity_refresh_receipt(receipt_path, digest=digest, train=train) + payload = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) refresh_payloads[digest] = payload matching_authorities = { ("refresh", digest) @@ -1159,15 +1157,23 @@ def _validate_source_continuity_refresh_receipt( def _read_source_continuity_refresh_receipt( - receipt_path: Path, + archive_root: Path, *, digest: str, train: DurableChangeTrain, ) -> dict[str, object]: """Load one train-retained refresh artifact and authenticate its identity.""" + receipt_path = archive_root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json" try: - raw = json.loads(receipt_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + with existing_maintenance_receipt_directory(archive_root, "source-continuity-refreshes") as directory_fd: + encoded = None if directory_fd is None else read_optional_receipt(directory_fd, receipt_path.name) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError(f"source continuity refresh receipt is unreadable: {receipt_path}") from exc + if encoded is None: + raise DurableChangeTrainError(f"source continuity refresh receipt is missing: {receipt_path}") + try: + raw = json.loads(encoded) + except json.JSONDecodeError as exc: raise DurableChangeTrainError(f"source continuity refresh receipt is unreadable: {receipt_path}") from exc if not isinstance(raw, dict): raise DurableChangeTrainError(f"source continuity refresh receipt is not an object: {receipt_path}") @@ -1265,7 +1271,7 @@ def write_source_continuity_relocation_transition( matching_refreshes: list[str] = [] for digest in refresh_refs: payload = _read_source_continuity_refresh_receipt( - archive_root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json", + archive_root, digest=digest, train=train, ) @@ -1445,7 +1451,7 @@ def _refresh_released_source_train_continuity_locked( digest = existing_path.stem if digest not in retained_refs: continue - existing = _read_source_continuity_refresh_receipt(existing_path, digest=digest, train=train) + existing = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) if existing.get("mutation_receipt_sha256") == mutation_digest: retained_refreshes.append((existing_path, existing)) for existing_path, existing in retained_refreshes: @@ -1518,13 +1524,12 @@ def _refresh_released_source_train_continuity_locked( _migration_runner._fsync_manifest_directory(refresh_root.parent) refresh_path = refresh_root / f"{refresh_digest}.json" if refresh_path.exists(): - try: - existing = json.loads(refresh_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise DurableChangeTrainError( - f"source continuity refresh receipt is unreadable: {refresh_path}" - ) from exc - if existing != {**payload, "refresh_sha256": refresh_digest}: + existing = _read_source_continuity_refresh_receipt( + archive_root, + digest=refresh_digest, + train=train, + ) + if existing != payload: raise DurableChangeTrainError("source continuity refresh receipt collision") else: encoded = ( diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index c2e16d83ae..f76273172e 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -62,6 +62,7 @@ from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, + DurableChangeTrainError, load_durable_change_train_manifest, rebind_released_source_train_archive_identity, recover_released_source_train_continuity, @@ -1150,6 +1151,33 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: ) ) admitted_components.assert_called_once() + foreign_refresh = tmp_path / "foreign-recovery-refresh.json" + shutil.copyfile(refresh_path, foreign_refresh) + refresh_path.unlink() + refresh_path.symlink_to(foreign_refresh) + train_before_rejected_resume = plan_train.read_bytes() + with pytest.raises(DurableChangeTrainError, match="refresh receipt is unreadable"): + CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert plan_train.read_bytes() == train_before_rejected_resume + refresh_path.unlink() + shutil.copyfile(foreign_refresh, refresh_path) rerun = CliRunner().invoke( cli, [ @@ -1605,6 +1633,101 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re assert isinstance(source_trains, list) and len(source_trains) == 1 assert source_trains[0]["requires_rebind"] is False + refresh_digests = source_trains[0]["source_continuity_receipt_digests"] + assert isinstance(refresh_digests, list) and len(refresh_digests) == 1 + refresh_path = moved_root / ".maintenance-state" / "source-continuity-refreshes" / f"{refresh_digests[0]}.json" + foreign_refresh = tmp_path / "foreign-refresh.json" + shutil.copyfile(refresh_path, foreign_refresh) + refresh_path.unlink() + refresh_path.symlink_to(foreign_refresh) + train_path = Path(str(source_trains[0]["path"])) + protected_paths = (*sorted(moved_root.glob("*.db")), train_path) + before_rejections = { + path: (path.stat().st_dev, path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) + for path in protected_paths + } + relocation_receipt = moved_root / ".maintenance-state" / "archive-root-relocations" / f"{relocation_digest}.json" + + rejected_plan = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--backup-manifest", + str(Path(backup.output_path) / "manifest.json"), + "--output", + str(tmp_path / "rejected-relocation-plan.json"), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert rejected_plan.exit_code != 0 + assert "source continuity authority is invalid" in rejected_plan.output + + rejected_apply = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "apply", + "--plan", + str(relocation_plan), + "--authorize", + relocation_digest, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert rejected_apply.exit_code != 0 + assert "continuity receipt is invalid" in rejected_apply.output + + from polylogue.daemon import cli as daemon_cli + from polylogue.operations import durable_change_train as durable_operations + + configure = Mock() + admission = Mock(wraps=durable_operations.reconcile_durable_change_trains_on_startup) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.USER, 10_000) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.AUDIT, 10_000) + monkeypatch.setattr("polylogue.paths.archive_root", lambda: moved_root) + monkeypatch.setattr( + "polylogue.operations.durable_change_train.reconcile_durable_change_trains_on_startup", + admission, + ) + monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", configure) + with pytest.raises(DurableChangeTrainError, match="refresh receipt is unreadable"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + admission.assert_called_once_with(moved_root) + configure.assert_called_once() + assert not relocation_receipt.exists() + assert { + path: (path.stat().st_dev, path.stat().st_ino, path.stat().st_mtime_ns, path.read_bytes()) + for path in protected_paths + } == before_rejections + + refresh_path.unlink() + shutil.copyfile(foreign_refresh, refresh_path) + applied = CliRunner().invoke( cli, [ From ef4c08c4a612ec42fdee956669f640c6cfdf3a46 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 04:09:58 +0200 Subject: [PATCH 25/39] docs: require complete relocation backup tiers --- docs/archive-backup.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 1cb1bff04d..0d648a7e03 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -14,7 +14,8 @@ The configured archive root contains these durable paths: | `index.db` | Parsed sessions, messages, FTS/search indexes, graph rows, and derived read models. | Rebuildable from `source.db`; include in full evidence backups for faster restore, but cache-exclude profiles may omit it. | | `embeddings.db` | Vector rows, embedding status, and catch-up metadata. | Back up when present. It is rebuildable, but expensive and may require provider cost. | | `user.db` | Human/user/agent overlays stored as assertions, immutable annotation schema definitions and batch provenance, settings, and context-delivery receipts. | Always back up. This tier is irreplaceable user state. | -| `ops.db` | Daemon cursors, attempts, convergence debt, stage events, and operational telemetry. | Disposable. Include only in diagnostics bundles or incident snapshots. | +| `audit.db` | Append-only mutation authority, authorizations, attempts, receipts, and continuity heads. | Always back up. Relocation full-evidence backups require it. | +| `ops.db` | Daemon cursors, attempts, convergence debt, stage events, and operational telemetry. | Disposable for ordinary restore profiles, but required by the exact relocation full-evidence tier contract. | | `blob/` | Content-addressed binary payloads keyed by SHA-256. | Back up referenced blobs with `source.db`/`user.db`; do not prune by age alone. | `polylogue ops maintenance archive-plan --output-format json` is the machine-readable @@ -28,7 +29,7 @@ Use these profiles when choosing what to copy: | Profile | Include | Exclude | Use case | | --- | --- | --- | --- | -| Full evidence | `source.db`, `index.db`, `embeddings.db`, `user.db`, referenced `blob/`, and optional `ops.db` snapshot. | Temporary SQLite `*-wal`/`*-shm` only after a clean checkpoint. | Fastest complete restore with raw evidence, read models, vectors, and overlays. | +| Full evidence | All six archive tiers: `source.db`, `index.db`, `embeddings.db`, `user.db`, `ops.db`, and `audit.db`, plus referenced `blob/`. | Temporary SQLite `*-wal`/`*-shm` only after a clean checkpoint. | Complete relocation authority and the fastest restore with raw evidence, read models, vectors, overlays, audit authority, and operational state. | | User overlays | `user.db` and any assertion/note evidence blobs referenced by user-owned rows. | `index.db`, `ops.db`, rebuildable search/derived models. | Protect irreplaceable human/agent state before resets or schema rebuilds. | | Rebuildable-cache exclude | `source.db`, `user.db`, referenced `blob/`, optionally `embeddings.db`. | `index.db`, `ops.db`, derived/cache artifacts. | Small backup that can rebuild parsed/indexed data locally. | | Diagnostics bundle | `ops.db`, `archive-plan` JSON, `daemon-workload-probe` JSON, logs, and readonly status outputs. | Private raw blobs unless explicitly needed for the incident. | Bug reports and incident triage without over-sharing archive contents. | @@ -50,7 +51,24 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --profile full_evidence --verify ``` -Use the `manifest.json` printed by that command to create the bound relocation plan. `--old-root` names the retired pre-move root only for the identity transition and active-index pointer mapping: +For relocation, the profile name alone is insufficient. The moved root must already contain every `ArchiveTier`, and the new backup manifest must contain this exact set with no omitted tiers: + +```json +{ + "profile": "full_evidence", + "included_tiers": [ + "source.db", + "index.db", + "embeddings.db", + "user.db", + "ops.db", + "audit.db" + ], + "omitted_tiers": [] +} +``` + +The relocation validator compares `included_tiers` as a set, so JSON list order is not significant. It rejects a missing `audit.db`, a missing `ops.db`, any extra tier, or any non-empty `omitted_tiers` value. Use the `manifest.json` printed by the backup command to create the bound relocation plan. `--old-root` names the retired pre-move root only for the identity transition and active-index pointer mapping: ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ From 22fc5cf988409d3b4577882cf025d9c569c55279 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 05:13:57 +0200 Subject: [PATCH 26/39] fix: chain archive relocation authority Authenticate repeated root moves through typed predecessor transitions and exact retained plan, receipt, and manifest hashes. Rebind every affected durable train, preserve promoted index generations, and enforce writer ownership for standalone watch admission. --- polylogue/daemon/backup.py | 26 +- polylogue/daemon/cli.py | 13 +- polylogue/maintenance/receipt_fs.py | 19 +- .../operations/archive_root_relocation.py | 514 ++++++++++++------ .../historical_source_continuity_recovery.py | 37 +- .../storage/sqlite/durable_change_train.py | 231 ++++++-- polylogue/storage/sqlite/migration_runner.py | 34 +- .../unit/cli/test_archive_maintenance_cli.py | 4 +- tests/unit/daemon/test_daemon_cli.py | 27 +- .../storage/test_archive_root_relocation.py | 406 +++++++++++++- 10 files changed, 988 insertions(+), 323 deletions(-) diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index 2da3a8aa0d..4e59edab3e 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -214,7 +214,31 @@ def _json_str_list(value: object) -> list[str]: def _all_archive_tiers(root: Path) -> dict[str, Path]: - return archive_tier_paths(root) + tiers = archive_tier_paths(root) + index = tiers["index"] + if index.is_symlink() and not index.exists(): + target = Path(os.readlink(index)) + pointer = root / ".index-active-pointer" + if not target.is_absolute() or pointer.is_symlink() or not pointer.is_file(): + return tiers + try: + configured_target = Path(pointer.read_text(encoding="utf-8").strip()) + relative = target.relative_to(configured_target.parent) + except (OSError, ValueError): + return tiers + if not configured_target.is_absolute() or configured_target.name != "index.db": + return tiers + mapped = root / relative + if ( + len(relative.parts) < 3 + or relative.parts[0] != ".index-generations" + or relative.parts[-1] != "index.db" + or not mapped.is_file() + or mapped.is_symlink() + ): + return tiers + tiers["index"] = mapped + return tiers def _profile_archive_tiers(root: Path, profile: BackupProfile) -> dict[str, Path]: diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index a2096a6e74..73735f2943 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -3402,6 +3402,8 @@ def parameter_is_default(name: str) -> bool: ) def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: from polylogue.config import resolve_runtime_config + from polylogue.operations.durable_change_train import acquire_durable_archive_ownership + from polylogue.paths import archive_root runtime_source_paths = resolve_runtime_config().source_paths sources = _watch_sources_from_roots( @@ -3414,7 +3416,16 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: f"Watching {len(sources)} source(s); debounce={debounce_s}s. Ctrl-C to stop.", err=True, ) - asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) + archive_root_path = Path(archive_root()) + archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) + archive_owner = acquire_durable_archive_ownership( + archive_root_path, + owner_id=f"watch:{os.getpid()}", + ) + try: + asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) + finally: + archive_owner.release() __all__ = [ diff --git a/polylogue/maintenance/receipt_fs.py b/polylogue/maintenance/receipt_fs.py index 6f7c7ef3cf..dad95e1e5c 100644 --- a/polylogue/maintenance/receipt_fs.py +++ b/polylogue/maintenance/receipt_fs.py @@ -23,9 +23,9 @@ def _simple_name(value: str, *, label: str) -> str: return value -def _open_directory(path: Path, *, label: str) -> int: +def _open_directory(path: Path | str, *, label: str, parent_fd: int | None = None) -> int: try: - descriptor = os.open(path, _DIRECTORY_FLAGS) + descriptor = os.open(path, _DIRECTORY_FLAGS, dir_fd=parent_fd) except OSError as exc: raise MaintenanceReceiptPathError(f"cannot pin {label} without following links: {path}") from exc if not stat.S_ISDIR(os.fstat(descriptor).st_mode): @@ -34,17 +34,6 @@ def _open_directory(path: Path, *, label: str) -> int: return descriptor -def _open_directory_at(parent_fd: int, name: str, *, label: str) -> int: - try: - descriptor = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) - except OSError as exc: - raise MaintenanceReceiptPathError(f"cannot pin {label} without following links: {name}") from exc - if not stat.S_ISDIR(os.fstat(descriptor).st_mode): - os.close(descriptor) - raise MaintenanceReceiptPathError(f"{label} is not a real directory: {name}") - return descriptor - - def _remove_created_empty_child(parent_fd: int, name: str, *, expected: os.stat_result) -> None: """Remove only the empty child this operation created, through its pinned parent.""" try: @@ -68,7 +57,7 @@ def _maintenance_receipt_directory(archive_root: Path, directory_name: str, *, c child_fd = -1 try: try: - state_fd = _open_directory_at(root_fd, ".maintenance-state", label="maintenance state") + state_fd = _open_directory(".maintenance-state", label="maintenance state", parent_fd=root_fd) except MaintenanceReceiptPathError as exc: if not create and isinstance(exc.__cause__, FileNotFoundError): yield None @@ -86,7 +75,7 @@ def _maintenance_receipt_directory(archive_root: Path, directory_name: str, *, c created_child = True except FileExistsError: pass - child_fd = _open_directory_at(state_fd, child_name, label="maintenance receipt directory") + child_fd = _open_directory(child_name, label="maintenance receipt directory", parent_fd=state_fd) if created_child: child_metadata = os.fstat(child_fd) try: diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 38795240c1..aa147d2931 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -10,7 +10,7 @@ import tempfile import uuid from pathlib import Path -from typing import Literal +from typing import Literal, cast from pydantic import BaseModel, ConfigDict @@ -29,7 +29,9 @@ ArchiveIdentity, ArchiveLocation, ArchiveOwnershipError, + ArchiveTierName, OwnedArchiveLocation, + TierFileIdentity, ) from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( @@ -38,21 +40,25 @@ DurableChangeTrainState, _released_train_manifests_by_target, _require_released_train_chain, + _validate_archive_root_relocation_receipts, _validate_source_continuity_refresh_receipt, load_durable_change_train_manifest, - rebind_released_source_train_archive_identity, + rebind_released_durable_train_archive_identity, write_durable_change_train_manifest, write_source_continuity_relocation_transition, ) from polylogue.storage.sqlite.migration_runner import ( + DURABLE_MIGRATION_TIERS, + DurableChangeTrain, MigrationError, capture_durable_database_evidence, capture_durable_schema_inventory, + durable_change_train_to_payload, validate_full_evidence_backup_for_archive_root_relocation, ) from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec -PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v1"] = "polylogue.archive-root-relocation-plan.v1" +PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v2"] = "polylogue.archive-root-relocation-plan.v2" RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = "polylogue.archive-root-relocation-receipt.v1" _TIER_NAMES = tuple(tier.value for tier in ArchiveTier) _DURABLE_TIER_NAMES = ("source", "user", "audit") @@ -66,7 +72,7 @@ class ArchiveRootRelocationError(RuntimeError): class RelocationTierEvidence(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - tier: str + tier: ArchiveTierName configured_path: str resolved_path: str backup_device: int @@ -81,16 +87,18 @@ class RelocationTierEvidence(BaseModel): quick_check: tuple[str, ...] -class RelocationSourceTrain(BaseModel): +class RelocationDurableTrain(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) + tier: Literal["source", "user", "audit"] + train_id: str path: str before_revision: int before_manifest_sha256: str before_archive_identity_digest: str after_archive_identity_digest: str requires_rebind: bool - source_continuity_receipt_digests: tuple[str, ...] + continuity_receipt_digests: tuple[str, ...] class RelocationActiveIndexPointer(BaseModel): @@ -102,6 +110,8 @@ class RelocationActiveIndexPointer(BaseModel): new_target: str old_resolved_target: str new_resolved_target: str + conventional_symlink_old_target: str | None = None + conventional_symlink_new_target: str | None = None device: int inode: int @@ -109,7 +119,7 @@ class RelocationActiveIndexPointer(BaseModel): class ArchiveRootRelocationPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - format: Literal["polylogue.archive-root-relocation-plan.v1"] = PLAN_FORMAT + format: Literal["polylogue.archive-root-relocation-plan.v2"] = PLAN_FORMAT old_configured_root: str old_resolved_root: str backup_root_device: int @@ -126,7 +136,7 @@ class ArchiveRootRelocationPlan(BaseModel): backup_tier_inventory: tuple[str, ...] tiers: tuple[RelocationTierEvidence, ...] active_index_pointer: RelocationActiveIndexPointer | None - source_trains: tuple[RelocationSourceTrain, ...] + durable_trains: tuple[RelocationDurableTrain, ...] stopped_daemon_evidence_ref: str single_writer_evidence_ref: str bound_confirmation: str @@ -327,10 +337,30 @@ def _active_index_pointer_evidence(*, old_root: Path, new_root: Path) -> Relocat "archive-root relocation active index pointer target is not owned by the old root" ) from exc new_target = new_root / relative_target - try: - new_resolved_target = new_target.resolve(strict=True) - except OSError as exc: - raise ArchiveRootRelocationError(f"cannot resolve mapped active index pointer target: {new_target}") from exc + conventional_old_target: str | None = None + conventional_new_target: str | None = None + if new_target.is_symlink(): + conventional_old_target = os.readlink(new_target) + raw_conventional_target = Path(conventional_old_target) + if raw_conventional_target.is_absolute(): + try: + conventional_relative = raw_conventional_target.relative_to(old_root) + except ValueError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation conventional index target is not owned by the old root" + ) from exc + conventional_new_target = str(new_root / conventional_relative) + new_resolved_target = Path(conventional_new_target).resolve(strict=True) + else: + conventional_new_target = conventional_old_target + new_resolved_target = (new_target.parent / raw_conventional_target).resolve(strict=True) + else: + try: + new_resolved_target = new_target.resolve(strict=True) + except OSError as exc: + raise ArchiveRootRelocationError( + f"cannot resolve mapped active index pointer target: {new_target}" + ) from exc if not new_resolved_target.is_relative_to(new_root): raise ArchiveRootRelocationError( "archive-root relocation mapped active index pointer target escapes the destination root" @@ -342,6 +372,8 @@ def _active_index_pointer_evidence(*, old_root: Path, new_root: Path) -> Relocat new_target=str(new_target), old_resolved_target=str(old_resolved_target), new_resolved_target=str(new_resolved_target), + conventional_symlink_old_target=conventional_old_target, + conventional_symlink_new_target=conventional_new_target, device=metadata.st_dev, inode=metadata.st_ino, ) @@ -362,6 +394,18 @@ def _validate_active_index_pointer( _pointer_path, target = current if str(target) not in {pointer.old_target, pointer.new_target}: raise ArchiveRootRelocationError("archive-root relocation active index pointer target changed") + conventional = Path(pointer.new_target) + if pointer.conventional_symlink_old_target is not None: + if not conventional.is_symlink(): + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink disappeared") + conventional_target = os.readlink(conventional) + if conventional_target not in { + pointer.conventional_symlink_old_target, + pointer.conventional_symlink_new_target, + }: + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink changed") + elif conventional.is_symlink(): + raise ArchiveRootRelocationError("archive-root relocation conventional index unexpectedly became a symlink") if str(target) == pointer.new_target: try: resolved = Path(pointer.new_target).resolve(strict=True) @@ -375,11 +419,45 @@ def _validate_active_index_pointer( raise ArchiveRootRelocationError("archive-root relocation mapped active index pointer changed") +def _publish_conventional_index_symlink(root: Path, pointer: RelocationActiveIndexPointer) -> None: + """Publish the mapped production ``index.db`` symlink before its pointer.""" + old_target = pointer.conventional_symlink_old_target + new_target = pointer.conventional_symlink_new_target + if old_target is None or new_target is None or old_target == new_target: + return + conventional = Path(pointer.new_target) + if not conventional.is_symlink(): + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink disappeared") + current = os.readlink(conventional) + if current == new_target: + return + if current != old_target: + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink changed") + directory_fd = -1 + temporary = f".index.db.relocation-{uuid.uuid4().hex}.tmp" + try: + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + os.symlink(new_target, temporary, dir_fd=directory_fd) + os.replace(temporary, "index.db", src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + except OSError as exc: + raise ArchiveRootRelocationError("cannot atomically publish mapped conventional index symlink") from exc + finally: + if directory_fd >= 0: + try: + os.unlink(temporary, dir_fd=directory_fd) + except FileNotFoundError: + pass + finally: + os.close(directory_fd) + + def _publish_active_index_pointer(root: Path, pointer: RelocationActiveIndexPointer | None) -> None: """Atomically publish the sealed mapped target beneath the owned destination root.""" if pointer is None: return _validate_active_index_pointer(root, pointer) + _publish_conventional_index_symlink(root, pointer) current = _read_active_index_pointer(root) assert current is not None _path, target = current @@ -431,75 +509,100 @@ def _publish_active_index_pointer(root: Path, pointer: RelocationActiveIndexPoin _validate_active_index_pointer(root, pointer) -def _source_trains( +def _durable_trains( root: Path, *, - source_version: int, - source_content_sha256: str, - after_identity_digest: str, -) -> tuple[RelocationSourceTrain, ...]: + old_root: Path, + snapshots: tuple[RelocationTierEvidence, ...], +) -> tuple[RelocationDurableTrain, ...]: manifest_root = root / ".maintenance-state" / "durable-change-trains" _real_directory(root / ".maintenance-state", label="maintenance state") _real_directory(manifest_root, label="durable change-train state") if (manifest_root / ".bootstrap").exists() or (manifest_root / ".bootstrap.pending").exists(): raise ArchiveRootRelocationError("archive-root relocation does not support fresh-bootstrap train authority") - manifests = _released_train_manifests_by_target(manifest_root, ArchiveTier.SOURCE) - if not manifests: - raise ArchiveRootRelocationError("archive-root relocation requires released source train evidence") - try: - _require_released_train_chain( - ArchiveTier.SOURCE, - manifests, - current_version=source_version, + tier_identities = tuple( + TierFileIdentity( + item.tier, + Path(item.configured_path), + Path(item.resolved_path), + item.device, + item.inode, ) - except DurableChangeTrainError as exc: - raise ArchiveRootRelocationError("archive-root relocation source train chain is not released") from exc - expected_targets = set(range(DURABLE_MIGRATION_ADOPTION_FLOORS[ArchiveTier.SOURCE] + 1, source_version + 1)) - if set(manifests) != expected_targets: - raise ArchiveRootRelocationError("archive-root relocation found an unexpected source train target") - trains: list[RelocationSourceTrain] = [] - for _target, train in sorted(manifests.items()): - path = manifest_root / f"source-{train.slot:03d}.json" - _real_file(path, label="source train manifest") - if train.state is not DurableChangeTrainState.RELEASED or train.apply_evidence is None: - raise ArchiveRootRelocationError(f"source train is not released: {path}") - continuity_refs = tuple( - ref.removeprefix("proof:source-continuity-refresh:") - for ref in train.proof_refs - if ref.startswith("proof:source-continuity-refresh:") - ) - if ( - train.target_version == source_version - and train.source_continuity_evidence is None - and train.apply_evidence.post.content_sha256 != source_content_sha256 - ): - raise ArchiveRootRelocationError( - "archive-root relocation requires a typed source-continuity refresh for the live source train; " - "the released source train still carries stale source content authority" - ) - if train.source_continuity_evidence is not None: + for item in snapshots + ) + index_identity = next(item for item in tier_identities if item.name == "index") + legacy_identity = ArchiveIdentity( + configured_root=old_root, + tiers=tier_identities, + active_generation=index_identity.stable_id, + ).authority_identity_digest + snapshots_by_tier = {item.tier: item for item in snapshots} + trains: list[RelocationDurableTrain] = [] + for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): + snapshot = snapshots_by_tier[tier.value] + manifests = _released_train_manifests_by_target(manifest_root, tier) + expected_targets = set(range(DURABLE_MIGRATION_ADOPTION_FLOORS[tier] + 1, snapshot.user_version + 1)) + if set(manifests) != expected_targets: + raise ArchiveRootRelocationError(f"archive-root relocation found an unexpected {tier.value} train target") + if manifests: + try: + _require_released_train_chain(tier, manifests, current_version=snapshot.user_version) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + f"archive-root relocation {tier.value} train chain is not released" + ) from exc + tier_identity = next(item for item in tier_identities if item.name == tier.value) + after_identity_digest = hashlib.sha256(tier_identity.stable_id.encode()).hexdigest() + for _target, train in sorted(manifests.items()): + path = manifest_root / f"{tier.value}-{train.slot:03d}.json" + _real_file(path, label=f"{tier.value} train manifest") + if train.state is not DurableChangeTrainState.RELEASED or train.apply_evidence is None: + raise ArchiveRootRelocationError(f"durable train is not released: {path}") try: - _validate_source_continuity_refresh_receipt(root, train) + _validate_archive_root_relocation_receipts(root, train) except DurableChangeTrainError as exc: raise ArchiveRootRelocationError( - "archive-root relocation source continuity authority is invalid" + f"archive-root relocation {tier.value} train relocation authority is invalid" ) from exc - trains.append( - RelocationSourceTrain( - path=str(path), - before_revision=train.revision, - before_manifest_sha256=_sha256_file(path), - before_archive_identity_digest=train.apply_evidence.post.archive_identity_digest, - after_archive_identity_digest=after_identity_digest, - requires_rebind=train.apply_evidence.post.archive_identity_digest != after_identity_digest, - source_continuity_receipt_digests=continuity_refs, + continuity_refs = tuple( + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") ) - ) - if trains[-1].before_archive_identity_digest == after_identity_digest and not ( - train.target_version == source_version and train.source_continuity_evidence is not None - ): - raise ArchiveRootRelocationError( - f"released source train already carries the current archive identity: {path}" + if ( + tier is ArchiveTier.SOURCE + and train.target_version == snapshot.user_version + and train.source_continuity_evidence is None + and train.apply_evidence.post.content_sha256 != snapshot.content_sha256 + ): + raise ArchiveRootRelocationError( + "archive-root relocation requires a typed source-continuity refresh for the live source train; " + "the released source train still carries stale source content authority" + ) + if train.source_continuity_evidence is not None: + try: + _validate_source_continuity_refresh_receipt(root, train) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation source continuity authority is invalid" + ) from exc + before_identity = train.apply_evidence.post.archive_identity_digest + if before_identity not in {after_identity_digest, legacy_identity}: + raise ArchiveRootRelocationError( + f"archive-root relocation {tier.value} train does not authenticate the moved tier identity" + ) + trains.append( + RelocationDurableTrain( + tier=cast(Literal["source", "user", "audit"], tier.value), + train_id=train.train_id, + path=str(path), + before_revision=train.revision, + before_manifest_sha256=_sha256_file(path), + before_archive_identity_digest=before_identity, + after_archive_identity_digest=after_identity_digest, + requires_rebind=before_identity != after_identity_digest, + continuity_receipt_digests=continuity_refs, + ) ) return tuple(trains) @@ -613,15 +716,10 @@ def prepare_archive_root_relocation( for tier in ArchiveTier ) _check_backup_against_live(new_resolved, manifest=manifest, receipt=receipt, snapshots=snapshots) - location_identity = ArchiveIdentity.resolve_location(ArchiveLocation.resolve(new_resolved)) - source_identity_digest = hashlib.sha256(location_identity.tier("source").stable_id.encode()).hexdigest() - source_version = next(item.user_version for item in snapshots if item.tier == "source") - source_content_sha256 = next(item.content_sha256 for item in snapshots if item.tier == "source") - trains = _source_trains( + trains = _durable_trains( new_resolved, - source_version=source_version, - source_content_sha256=source_content_sha256, - after_identity_digest=source_identity_digest, + old_root=old_resolved, + snapshots=snapshots, ) root_metadata = new_resolved.stat() _require_identity_continuity( @@ -648,7 +746,7 @@ def prepare_archive_root_relocation( backup_tier_inventory=tuple(sorted(f"{tier}.db" for tier in _TIER_NAMES)), tiers=snapshots, active_index_pointer=active_index_pointer, - source_trains=trains, + durable_trains=trains, stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, single_writer_evidence_ref=single_writer_evidence_ref, bound_confirmation="archive-root-relocation", @@ -683,6 +781,31 @@ def _receipt_path(root: Path, plan: ArchiveRootRelocationPlan) -> Path: return root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json" +def _retained_plan_path(root: Path, plan: ArchiveRootRelocationPlan) -> Path: + return root / ".maintenance-state" / "archive-root-relocation-plans" / f"{plan.plan_sha256}.json" + + +def _retain_plan(root: Path, plan: ArchiveRootRelocationPlan) -> Path: + path = _retained_plan_path(root, plan) + encoded = (json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + try: + with maintenance_receipt_directory(root, "archive-root-relocation-plans") as directory_fd: + current = read_optional_receipt(directory_fd, path.name) + if current is not None and current != encoded: + raise ArchiveRootRelocationError("archive-root relocation retained plan collision") + if current is None: + atomic_replace_receipt(directory_fd, path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise ArchiveRootRelocationError("cannot retain archive-root relocation plan") from exc + return path + + +def _train_manifest_sha256(train: DurableChangeTrain) -> str: + payload = durable_change_train_to_payload(train) + encoded = (json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode() + return hashlib.sha256(encoded).hexdigest() + + def _receipt_directory_binding(path: Path) -> tuple[Path, str]: state_root = path.parent.parent if state_root.name != ".maintenance-state" or path.suffix != ".json": @@ -757,16 +880,56 @@ def assert_no_prepared_archive_root_relocation(root: Path) -> None: ) -def _requires_train_update(item: RelocationSourceTrain) -> bool: +def _requires_train_update(item: RelocationDurableTrain) -> bool: """Return whether relocation must CAS-revise this released train.""" - return item.requires_rebind or bool(item.source_continuity_receipt_digests) + return item.requires_rebind or bool(item.continuity_receipt_digests) + + +def _pointer_receipt_fields( + pointer: RelocationActiveIndexPointer | None, +) -> tuple[str | None, str | None, str | None]: + if pointer is None: + return (None, None, None) + return (pointer.old_target, pointer.new_target, pointer.new_resolved_target) + + +def _relocated_train( + root: Path, + *, + plan: ArchiveRootRelocationPlan, + item: RelocationDurableTrain, + train: DurableChangeTrain, + relocation_receipt_sha256: str, +) -> DurableChangeTrain: + continuity_transition_ref = None + if train.source_continuity_evidence is not None: + transition_digest = write_source_continuity_relocation_transition( + root, + train=train, + archive_identity_digest=item.after_archive_identity_digest, + relocation_plan_sha256=plan.plan_sha256, + relocation_receipt_sha256=relocation_receipt_sha256, + ) + continuity_transition_ref = f"proof:source-continuity-relocation:{transition_digest}" + return rebind_released_durable_train_archive_identity( + train, + archive_identity_digest=item.after_archive_identity_digest, + proof_refs=tuple( + ref + for ref in ( + f"proof:archive-root-relocation:{relocation_receipt_sha256}", + continuity_transition_ref, + ) + if ref is not None + ), + ) def _validate_plan_continuity_binding( root: Path, *, plan: ArchiveRootRelocationPlan, - item: RelocationSourceTrain, + item: RelocationDurableTrain, train: object, before: bool, relocation_receipt: ArchiveRootRelocationReceipt | None, @@ -780,15 +943,17 @@ def _validate_plan_continuity_binding( for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") ) - if refresh_refs != item.source_continuity_receipt_digests: + if refresh_refs != item.continuity_receipt_digests: raise ArchiveRootRelocationError("archive-root relocation exact refresh proof changed") - if before or train.source_continuity_evidence is None: + if before: return if relocation_receipt is None or relocation_receipt.plan_sha256 != plan.plan_sha256: raise ArchiveRootRelocationError("archive-root relocation exact receipt binding is missing") receipt_digest = relocation_receipt.prepared_receipt_sha256 or relocation_receipt.receipt_sha256 if f"proof:archive-root-relocation:{receipt_digest}" not in train.proof_refs: raise ArchiveRootRelocationError("archive-root relocation exact receipt binding is missing") + if train.source_continuity_evidence is None: + return transition_refs = tuple( ref.removeprefix("proof:source-continuity-relocation:") for ref in train.proof_refs @@ -798,8 +963,12 @@ def _validate_plan_continuity_binding( for digest in transition_refs: path = root / ".maintenance-state" / "source-continuity-relocations" / f"{digest}.json" try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + with existing_maintenance_receipt_directory(root, "source-continuity-relocations") as directory_fd: + encoded = None if directory_fd is None else read_optional_receipt(directory_fd, path.name) + if encoded is None: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof is missing") + payload = json.loads(encoded) + except (MaintenanceReceiptPathError, json.JSONDecodeError) as exc: raise ArchiveRootRelocationError("archive-root relocation exact transition proof is unreadable") from exc if not isinstance(payload, dict) or payload.pop("transition_sha256", None) != digest: raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") @@ -808,7 +977,6 @@ def _validate_plan_continuity_binding( if ( payload.get("relocation_plan_sha256") == plan.plan_sha256 and payload.get("relocation_receipt_sha256") == receipt_digest - and payload.get("refresh_receipt_sha256") in item.source_continuity_receipt_digests ): matches += 1 if matches != 1: @@ -866,29 +1034,44 @@ def _revalidate_plan_live_state( _validate_active_index_pointer(root, plan.active_index_pointer) pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) allowed_pending_relocation_receipt_sha256 = ( - pending_receipt.receipt_sha256 if pending_receipt is not None and pending_receipt.state == "prepared" else None + (pending_receipt.prepared_receipt_sha256 or pending_receipt.receipt_sha256) + if pending_receipt is not None and pending_receipt.state == "prepared" + else None ) - for item in plan.source_trains: + if ( + pending_receipt is not None + and pending_receipt.manifest_after_sha256 + and len(pending_receipt.manifest_after_sha256) != len(plan.durable_trains) + ): + raise ArchiveRootRelocationError("archive-root relocation receipt manifest binding changed") + for index, item in enumerate(plan.durable_trains): path = Path(item.path) train = load_durable_change_train_manifest(path) + manifest_sha256 = _sha256_file(path) continuity_refs = tuple( ref.removeprefix("proof:source-continuity-refresh:") for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") ) - before = _sha256_file(path) == item.before_manifest_sha256 + before = manifest_sha256 == item.before_manifest_sha256 after = ( - train.revision == item.before_revision + int(_requires_train_update(item)) - and train.apply_evidence is not None - and train.apply_evidence.post.archive_identity_digest == item.after_archive_identity_digest - and ( - train.source_continuity_evidence is None - or train.source_continuity_evidence.archive_identity_digest == item.after_archive_identity_digest - ) + pending_receipt is not None + and bool(pending_receipt.manifest_after_sha256) + and manifest_sha256 == pending_receipt.manifest_after_sha256[index] ) if not before and not after: raise ArchiveRootRelocationError(f"archive-root relocation manifest changed: {path}") - if before and continuity_refs != item.source_continuity_receipt_digests: + try: + _validate_archive_root_relocation_receipts( + root, + train, + allowed_pending_relocation_receipt_sha256=allowed_pending_relocation_receipt_sha256, + ) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + f"archive-root relocation retained train authority is invalid: {path}" + ) from exc + if before and continuity_refs != item.continuity_receipt_digests: raise ArchiveRootRelocationError(f"archive-root relocation continuity receipts changed: {path}") if train.source_continuity_evidence is not None: try: @@ -901,14 +1084,14 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError( f"archive-root relocation continuity receipt is invalid: {path}" ) from exc - _validate_plan_continuity_binding( - root, - plan=plan, - item=item, - train=train, - before=before, - relocation_receipt=pending_receipt, - ) + _validate_plan_continuity_binding( + root, + plan=plan, + item=item, + train=train, + before=before, + relocation_receipt=pending_receipt, + ) def _require_offline_apply_boundary(root: Path) -> None: @@ -948,7 +1131,7 @@ def _apply_archive_root_relocation_locked( plan: ArchiveRootRelocationPlan, authorization: str, ) -> ArchiveRootRelocationResult: - """CAS-rewrite only released source manifests under the owned offline boundary.""" + """CAS-rewrite released durable manifests under the owned offline boundary.""" _verify_plan(plan) if authorization != plan.plan_sha256 or plan.bound_confirmation != "archive-root-relocation": raise ArchiveRootRelocationError("archive-root relocation authorization does not bind this plan") @@ -956,11 +1139,13 @@ def _apply_archive_root_relocation_locked( raise ArchiveRootRelocationError("archive-root relocation plan is bound to a different configured root") _revalidate_plan_live_state(root, plan) receipt_path = _receipt_path(root, plan) + retained_plan_path = _retain_plan(root, plan) command = ( f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance archive-root-relocation " - f"apply --plan --authorize {plan.plan_sha256} --output-format json" + f"apply --plan {retained_plan_path} --authorize {plan.plan_sha256} --output-format json" ) - before_hashes = tuple(item.before_manifest_sha256 for item in plan.source_trains) + before_hashes = tuple(item.before_manifest_sha256 for item in plan.durable_trains) + pointer_fields = _pointer_receipt_fields(plan.active_index_pointer) receipt = _sealed_receipt( state="prepared", revision=0, @@ -968,15 +1153,9 @@ def _apply_archive_root_relocation_locked( authorization=authorization, manifest_before_sha256=before_hashes, manifest_after_sha256=(), - active_index_pointer_old_target=( - plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None - ), - active_index_pointer_new_target=( - plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None - ), - active_index_pointer_new_resolved_target=( - plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None - ), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], resume_command=command, ) existing_receipt = _load_receipt_for_update(receipt_path) @@ -984,90 +1163,99 @@ def _apply_archive_root_relocation_locked( receipt = existing_receipt if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") - expected_pointer_receipt = ( - plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None, - plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None, - plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None, - ) if ( receipt.active_index_pointer_old_target, receipt.active_index_pointer_new_target, receipt.active_index_pointer_new_resolved_target, - ) != expected_pointer_receipt: + ) != pointer_fields: raise ArchiveRootRelocationError("archive-root relocation receipt active index pointer binding changed") if receipt.state == "committed": - if tuple(_sha256_file(Path(item.path)) for item in plan.source_trains) != receipt.manifest_after_sha256: + if tuple(_sha256_file(Path(item.path)) for item in plan.durable_trains) != receipt.manifest_after_sha256: raise ArchiveRootRelocationError("archive-root relocation committed receipt does not match manifests") return ArchiveRootRelocationResult( state="committed", plan_sha256=plan.plan_sha256, receipt_path=str(receipt_path), - changed_manifests=tuple(item.path for item in plan.source_trains), + changed_manifests=tuple(item.path for item in plan.durable_trains), ) else: _write_receipt(receipt_path, receipt, expected=None) + preparation_receipt_sha256 = receipt.prepared_receipt_sha256 or receipt.receipt_sha256 + if receipt.state == "prepared" and not receipt.manifest_after_sha256: + expected_after: list[str] = [] + for item in plan.durable_trains: + path = Path(item.path) + train = load_durable_change_train_manifest(path) + if _sha256_file(path) != item.before_manifest_sha256: + raise ArchiveRootRelocationError( + f"archive-root relocation manifest changed before expected CAS binding: {path}" + ) + expected_train = ( + _relocated_train( + root, + plan=plan, + item=item, + train=train, + relocation_receipt_sha256=preparation_receipt_sha256, + ) + if _requires_train_update(item) + else train + ) + expected_after.append(_train_manifest_sha256(expected_train)) + bound_prepared = _sealed_receipt( + state="prepared", + revision=1, + plan_sha256=plan.plan_sha256, + authorization=authorization, + manifest_before_sha256=before_hashes, + manifest_after_sha256=tuple(expected_after), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command=command, + prepared_receipt_sha256=preparation_receipt_sha256, + ) + _write_receipt(receipt_path, bound_prepared, expected=receipt.receipt_sha256) + receipt = bound_prepared _publish_active_index_pointer(root, plan.active_index_pointer) after_hashes: list[str] = [] - for item in plan.source_trains: + for index, item in enumerate(plan.durable_trains): path = Path(item.path) train = load_durable_change_train_manifest(path) actual_hash = _sha256_file(path) if actual_hash == item.before_manifest_sha256 and _requires_train_update(item): - continuity_transition_ref = None - if train.source_continuity_evidence is not None: - transition_digest = write_source_continuity_relocation_transition( - root, - train=train, - archive_identity_digest=item.after_archive_identity_digest, - relocation_plan_sha256=plan.plan_sha256, - relocation_receipt_sha256=receipt.receipt_sha256, - ) - continuity_transition_ref = f"proof:source-continuity-relocation:{transition_digest}" - updated = rebind_released_source_train_archive_identity( - train, - archive_identity_digest=item.after_archive_identity_digest, - proof_refs=tuple( - ref - for ref in ( - f"proof:archive-root-relocation:{receipt.receipt_sha256}", - continuity_transition_ref, - ) - if ref is not None - ), + updated = _relocated_train( + root, + plan=plan, + item=item, + train=train, + relocation_receipt_sha256=preparation_receipt_sha256, ) + if _train_manifest_sha256(updated) != receipt.manifest_after_sha256[index]: + raise ArchiveRootRelocationError("archive-root relocation expected manifest binding changed") write_durable_change_train_manifest(path, updated, expected_revision=item.before_revision) - elif ( - train.revision != item.before_revision + int(_requires_train_update(item)) - or train.apply_evidence is None - or train.apply_evidence.post.archive_identity_digest != item.after_archive_identity_digest - ): + elif actual_hash != receipt.manifest_after_sha256[index]: raise ArchiveRootRelocationError( f"archive-root relocation manifest is neither exact before nor after: {path}" ) after_hashes.append(_sha256_file(path)) committed = _sealed_receipt( state="committed", - revision=1, + revision=2, plan_sha256=plan.plan_sha256, authorization=authorization, manifest_before_sha256=before_hashes, manifest_after_sha256=tuple(after_hashes), - active_index_pointer_old_target=( - plan.active_index_pointer.old_target if plan.active_index_pointer is not None else None - ), - active_index_pointer_new_target=( - plan.active_index_pointer.new_target if plan.active_index_pointer is not None else None - ), - active_index_pointer_new_resolved_target=( - plan.active_index_pointer.new_resolved_target if plan.active_index_pointer is not None else None - ), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], resume_command=command, - prepared_receipt_sha256=receipt.receipt_sha256, + prepared_receipt_sha256=preparation_receipt_sha256, ) _write_receipt(receipt_path, committed, expected=receipt.receipt_sha256) return ArchiveRootRelocationResult( state="committed", plan_sha256=plan.plan_sha256, receipt_path=str(receipt_path), - changed_manifests=tuple(item.path for item in plan.source_trains), + changed_manifests=tuple(item.path for item in plan.durable_trains), ) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 6627524002..b084ceec75 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -15,9 +15,6 @@ import sqlite3 import stat import tempfile -from collections.abc import Iterator -from contextlib import contextmanager -from contextvars import ContextVar from importlib import resources from pathlib import Path from typing import Literal, cast @@ -63,16 +60,13 @@ capture_durable_schema_inventory, ) -PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v1"] = ( - "polylogue.historical-source-continuity-recovery-plan.v1" +PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v2"] = ( + "polylogue.historical-source-continuity-recovery-plan.v2" ) RECEIPT_FORMAT: Literal["polylogue.historical-source-continuity-recovery-receipt.v1"] = ( "polylogue.historical-source-continuity-recovery-receipt.v1" ) _HISTORICAL_OPERATION_EVIDENCE_RESOURCE = "historical-source-continuity-operation-20260807.json" -_TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE: ContextVar[Path | None] = ContextVar( - "test_historical_operation_evidence_resource", default=None -) class HistoricalSourceContinuityRecoveryError(RuntimeError): @@ -82,7 +76,7 @@ class HistoricalSourceContinuityRecoveryError(RuntimeError): class HistoricalSourceContinuityRecoveryPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - format: Literal["polylogue.historical-source-continuity-recovery-plan.v1"] = PLAN_FORMAT + format: Literal["polylogue.historical-source-continuity-recovery-plan.v2"] = PLAN_FORMAT old_configured_root: str old_resolved_root: str new_configured_root: str @@ -202,35 +196,12 @@ def _real_directory(path: Path, *, label: str) -> Path: def _historical_operation_evidence_bytes() -> bytes: """Read the immutable operation evidence from the installed package.""" - test_resource = _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.get() - if test_resource is not None: - _real_file(test_resource, label="test historical operation evidence") - try: - return test_resource.read_bytes() - except OSError as exc: - raise HistoricalSourceContinuityRecoveryError("test historical operation evidence is unreadable") from exc try: return resources.files("polylogue.operations").joinpath(_HISTORICAL_OPERATION_EVIDENCE_RESOURCE).read_bytes() except FileNotFoundError as exc: raise HistoricalSourceContinuityRecoveryError("immutable historical operation evidence is unreadable") from exc -@contextmanager -def _test_historical_operation_evidence_resource(path: Path) -> Iterator[None]: - """Scope a pinned fixture resource without changing production evidence selection. - - Production execution always reads the immutable packaged descriptor above. - Tests alone opt into this context-local resource to exercise the real plan - and apply operations against a synthetic, independently sealed history. - """ - _real_file(path, label="test historical operation evidence") - token = _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.set(path) - try: - yield - finally: - _TEST_HISTORICAL_OPERATION_EVIDENCE_RESOURCE.reset(token) - - def _historical_operation_evidence() -> HistoricalOperationEvidence: try: return HistoricalOperationEvidence.model_validate_json(_historical_operation_evidence_bytes()) @@ -1144,12 +1115,12 @@ def apply_historical_source_continuity_recovery( ) -> HistoricalSourceContinuityRecoveryResult: """Acquire archive ownership before the API can publish receipts or a CAS revision.""" resolved = _real_directory(root, label="configured archive root") - _require_offline_ownership_boundary(resolved) with OwnedArchiveLocation.acquire( ArchiveLocation.resolve(resolved), owner_id=f"historical-source-continuity-recovery:{os.getpid()}", allow_reentrant=True, ): + _require_offline_ownership_boundary(resolved) return _apply_historical_source_continuity_recovery_locked( root=resolved, plan=plan, diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index a361925eb7..8aaa4c451f 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -22,6 +22,7 @@ MaintenanceReceiptPathError, atomic_replace_receipt, existing_maintenance_receipt_directory, + iter_pinned_receipts, maintenance_receipt_directory, read_optional_receipt, ) @@ -82,8 +83,9 @@ _MIGRATION_NAME_RE = re.compile(r"^(?P\d{3,})_[a-z0-9_]+\.sql$") _DROP_SQL_RE = re.compile(r"(?is)\bDROP\s+(?:TABLE|INDEX|TRIGGER|VIEW)\b") _SOURCE_CONTINUITY_PENDING_FORMAT = "polylogue.source-continuity-pending.v1" -_SOURCE_CONTINUITY_RELOCATION_FORMAT = "polylogue.source-continuity-relocation.v1" +_SOURCE_CONTINUITY_RELOCATION_FORMAT = "polylogue.source-continuity-relocation.v2" _SourceContinuityMutationKind = Literal["blob_ref_liveness", "raw_authority_recovery"] +_SourceContinuityAuthorityKind = Literal["refresh", "relocation"] _FRESH_DURABLE_BOOTSTRAP_FORMAT = "polylogue.durable-bootstrap.v1" _FRESH_DURABLE_BOOTSTRAP_MARKER = ".bootstrap" @@ -105,6 +107,18 @@ class DurableSourceContinuitySemanticError(DurableChangeTrainError): """A committed source mutation cannot satisfy immutable train evidence.""" +@dataclass(frozen=True, slots=True) +class _SourceContinuityAuthorityRef: + kind: _SourceContinuityAuthorityKind + sha256: str + + +@dataclass(frozen=True, slots=True) +class _SourceContinuityAuthorityNode: + ref: _SourceContinuityAuthorityRef + source_after: object + + @dataclass(frozen=True, slots=True) class DurableMigrationSidecar: """A deterministic package resource binding one SQL slot to its train.""" @@ -563,17 +577,20 @@ def _persist_train_transition(path: Path, train: DurableChangeTrain, *, expected return load_durable_change_train_manifest(path) -def rebind_released_source_train_archive_identity( +def rebind_released_durable_train_archive_identity( train: DurableChangeTrain, *, archive_identity_digest: str, proof_refs: tuple[str, ...], ) -> DurableChangeTrain: - """Return the one permitted root-relocation revision of a source train.""" - if train.tier is not ArchiveTier.SOURCE or train.state is not DurableChangeTrainState.RELEASED: - raise DurableChangeTrainError("archive-root relocation requires a released source train") + """Return the one permitted root-relocation revision of a durable train.""" + if ( + train.tier not in _migration_runner.DURABLE_MIGRATION_TIERS + or train.state is not DurableChangeTrainState.RELEASED + ): + raise DurableChangeTrainError("archive-root relocation requires a released durable train") if train.apply_evidence is None: - raise DurableChangeTrainError("archive-root relocation requires source train apply evidence") + raise DurableChangeTrainError("archive-root relocation requires durable train apply evidence") _migration_runner._validate_sha256(archive_identity_digest, label="relocated archive identity") post = replace(train.apply_evidence.post, archive_identity_digest=archive_identity_digest) evidence = replace(train.apply_evidence, post=post) @@ -612,6 +629,8 @@ def recover_released_source_train_continuity( raise DurableChangeTrainError("historical continuity recovery requires a released source train") if train.apply_evidence is None: raise DurableChangeTrainError("historical continuity recovery requires source train apply evidence") + if train.source_continuity_evidence is not None: + raise DurableChangeTrainError("historical continuity recovery cannot replace existing continuity authority") if current_evidence.tier is not ArchiveTier.SOURCE or current_evidence.user_version != train.target_version: raise DurableChangeTrainError("historical continuity recovery has the wrong live source schema") if current_evidence.quick_check != ("ok",): @@ -1109,10 +1128,10 @@ def _validate_source_continuity_refresh_receipt( train: DurableChangeTrain, *, allowed_pending_relocation_receipt_sha256: str | None = None, -) -> None: +) -> _SourceContinuityAuthorityRef | None: """Require the latest source continuity evidence to retain its receipt.""" if train.source_continuity_evidence is None: - return + return None expected_after = _migration_runner._manifest_json_value(train.source_continuity_evidence) refresh_refs = [ ref.removeprefix("proof:source-continuity-refresh:") @@ -1130,30 +1149,72 @@ def _validate_source_continuity_refresh_receipt( for digest in refresh_refs: payload = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) refresh_payloads[digest] = payload - matching_authorities = { - ("refresh", digest) + nodes: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityNode] = { + _SourceContinuityAuthorityRef("refresh", digest): _SourceContinuityAuthorityNode( + ref=_SourceContinuityAuthorityRef("refresh", digest), + source_after=payload.get("source_after"), + ) for digest, payload in refresh_payloads.items() - if payload.get("source_after") == expected_after } + relocation_payloads: dict[_SourceContinuityAuthorityRef, dict[str, object]] = {} for digest in relocation_refs: + ref = _SourceContinuityAuthorityRef("relocation", digest) payload = _read_source_continuity_relocation_receipt( archive_root, digest=digest, train=train, allowed_pending_relocation_receipt_sha256=allowed_pending_relocation_receipt_sha256, ) - refresh_digest = payload.get("refresh_receipt_sha256") - if not isinstance(refresh_digest, str) or refresh_digest not in refresh_payloads: - raise DurableChangeTrainError("source continuity relocation transition lacks its retained refresh receipt") - if payload.get("source_before") != refresh_payloads[refresh_digest].get("source_after"): - raise DurableChangeTrainError("source continuity relocation transition does not preserve refresh authority") - if payload.get("source_after") == expected_after: - matching_authorities.discard(("refresh", refresh_digest)) - matching_authorities.add(("relocation", digest)) + relocation_payloads[ref] = payload + + predecessors: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} + successor_by_authority: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} + for ref, payload in relocation_payloads.items(): + raw_predecessor = payload.get("predecessor_authority") + if not isinstance(raw_predecessor, dict) or set(raw_predecessor) != {"kind", "sha256"}: + raise DurableChangeTrainError("source continuity relocation transition lacks typed predecessor authority") + kind = raw_predecessor.get("kind") + predecessor_digest = raw_predecessor.get("sha256") + if kind not in {"refresh", "relocation"} or not isinstance(predecessor_digest, str): + raise DurableChangeTrainError("source continuity relocation transition has invalid predecessor authority") + predecessor = _SourceContinuityAuthorityRef(cast(_SourceContinuityAuthorityKind, kind), predecessor_digest) + if predecessor in successor_by_authority: + raise DurableChangeTrainError("source continuity relocation authority branches ambiguously") + predecessors[ref] = predecessor + successor_by_authority[predecessor] = ref + + resolving: set[_SourceContinuityAuthorityRef] = set() + + def resolve(ref: _SourceContinuityAuthorityRef) -> _SourceContinuityAuthorityNode: + existing = nodes.get(ref) + if existing is not None: + return existing + payload = relocation_payloads.get(ref) + if payload is None: + raise DurableChangeTrainError("source continuity relocation transition lacks its retained predecessor") + if ref in resolving: + raise DurableChangeTrainError("source continuity relocation authority contains a cycle") + resolving.add(ref) + predecessor_node = resolve(predecessors[ref]) + resolving.remove(ref) + if payload.get("source_before") != predecessor_node.source_after: + raise DurableChangeTrainError( + "source continuity relocation transition does not preserve predecessor authority" + ) + node = _SourceContinuityAuthorityNode(ref=ref, source_after=payload.get("source_after")) + nodes[ref] = node + return node + + for ref in relocation_payloads: + resolve(ref) + matching_authorities = [ + node.ref + for node in nodes.values() + if node.ref not in successor_by_authority and node.source_after == expected_after + ] if len(matching_authorities) != 1: - raise DurableChangeTrainError( - "source continuity evidence does not identify exactly one matching refresh receipt" - ) + raise DurableChangeTrainError("source continuity evidence does not identify exactly one terminal authority") + return matching_authorities[0] def _read_source_continuity_refresh_receipt( @@ -1243,6 +1304,108 @@ def _read_source_continuity_relocation_receipt( return payload +def _validate_archive_root_relocation_receipts( + archive_root: Path, + train: DurableChangeTrain, + *, + allowed_pending_relocation_receipt_sha256: str | None = None, +) -> None: + """Resolve retained relocation proofs as one exact manifest transition chain.""" + proof_digests = tuple( + ref.removeprefix("proof:archive-root-relocation:") + for ref in train.proof_refs + if ref.startswith("proof:archive-root-relocation:") + ) + if not proof_digests: + return + from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + ArchiveRootRelocationPlan, + _decode_receipt, + _verify_plan, + ) + + try: + with existing_maintenance_receipt_directory(archive_root, "archive-root-relocations") as receipt_fd: + receipt_rows = () if receipt_fd is None else tuple(iter_pinned_receipts(receipt_fd)) + with existing_maintenance_receipt_directory(archive_root, "archive-root-relocation-plans") as plan_fd: + if plan_fd is None: + raise DurableChangeTrainError("archive-root relocation proof has no retained plan authority") + plan_rows = dict(iter_pinned_receipts(plan_fd)) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError("archive-root relocation proof authority is unreadable") from exc + transitions: list[tuple[str, str, str]] = [] + for proof_digest in proof_digests: + matches = [] + try: + for filename, encoded in receipt_rows: + receipt = _decode_receipt( + encoded, + path=archive_root / ".maintenance-state" / "archive-root-relocations" / filename, + ) + if (receipt.prepared_receipt_sha256 or receipt.receipt_sha256) == proof_digest and ( + receipt.state == "committed" or proof_digest == allowed_pending_relocation_receipt_sha256 + ): + matches.append(receipt) + except ArchiveRootRelocationError as exc: + raise DurableChangeTrainError("archive-root relocation proof receipt is invalid") from exc + if len(matches) != 1: + raise DurableChangeTrainError( + "archive-root relocation proof does not resolve exactly one committed receipt or the explicitly pending receipt" + ) + receipt = matches[0] + encoded_plan = plan_rows.get(f"{receipt.plan_sha256}.json") + if encoded_plan is None: + raise DurableChangeTrainError("archive-root relocation proof retained plan is missing") + try: + plan = ArchiveRootRelocationPlan.model_validate_json(encoded_plan) + _verify_plan(plan) + except (ArchiveRootRelocationError, ValueError) as exc: + raise DurableChangeTrainError("archive-root relocation proof retained plan is invalid") from exc + item_indexes = tuple( + index + for index, item in enumerate(plan.durable_trains) + if item.train_id == train.train_id and item.tier == train.tier.value + ) + if len(item_indexes) != 1: + raise DurableChangeTrainError("archive-root relocation proof does not bind this durable train") + expected_before = tuple(item.before_manifest_sha256 for item in plan.durable_trains) + if receipt.manifest_before_sha256 != expected_before or len(receipt.manifest_after_sha256) != len( + plan.durable_trains + ): + raise DurableChangeTrainError("archive-root relocation proof receipt does not bind its exact plan") + item_index = item_indexes[0] + item = plan.durable_trains[item_index] + transitions.append( + ( + item.before_manifest_sha256, + receipt.manifest_after_sha256[item_index], + item.after_archive_identity_digest, + ) + ) + by_before = {before: (after, identity) for before, after, identity in transitions} + if len(by_before) != len(transitions): + raise DurableChangeTrainError("archive-root relocation proof chain branches ambiguously") + after_hashes = {after for _before, after, _identity in transitions} + roots = [before for before in by_before if before not in after_hashes] + if len(roots) != 1: + raise DurableChangeTrainError("archive-root relocation proof chain has no unique predecessor") + visited: set[str] = set() + current_hash = roots[0] + latest_identity: str | None = None + while current_hash in by_before: + if current_hash in visited: + raise DurableChangeTrainError("archive-root relocation proof chain contains a cycle") + visited.add(current_hash) + current_hash, latest_identity = by_before[current_hash] + current_payload = durable_change_train_to_payload(train) + current_encoded = (json.dumps(current_payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode() + if len(visited) != len(transitions) or hashlib.sha256(current_encoded).hexdigest() != current_hash: + raise DurableChangeTrainError("archive-root relocation proof chain does not bind the exact current manifest") + if train.apply_evidence is None or latest_identity != train.apply_evidence.post.archive_identity_digest: + raise DurableChangeTrainError("archive-root relocation proof does not bind the latest durable identity") + + def write_source_continuity_relocation_transition( archive_root: Path, *, @@ -1251,7 +1414,7 @@ def write_source_continuity_relocation_transition( relocation_plan_sha256: str, relocation_receipt_sha256: str, ) -> str: - """Bind relocated source continuity to its immutable prior refresh receipt. + """Bind relocated source continuity to its latest authenticated authority. This is intentionally a new receipt rather than an edit to the historical refresh artifact: the old receipt remains authority for the old identity, @@ -1263,29 +1426,16 @@ def write_source_continuity_relocation_transition( _migration_runner._validate_sha256(relocation_plan_sha256, label="relocation plan") _migration_runner._validate_sha256(relocation_receipt_sha256, label="relocation receipt") old_after = _migration_runner._manifest_json_value(train.source_continuity_evidence) - refresh_refs = [ - ref.removeprefix("proof:source-continuity-refresh:") - for ref in train.proof_refs - if ref.startswith("proof:source-continuity-refresh:") - ] - matching_refreshes: list[str] = [] - for digest in refresh_refs: - payload = _read_source_continuity_refresh_receipt( - archive_root, - digest=digest, - train=train, - ) - if payload.get("source_after") == old_after: - matching_refreshes.append(digest) - if len(matching_refreshes) != 1: - raise DurableChangeTrainError("source continuity relocation requires exactly one retained refresh authority") + predecessor = _validate_source_continuity_refresh_receipt(archive_root, train) + if predecessor is None: + raise DurableChangeTrainError("source continuity relocation requires retained continuity authority") relocated = _migration_runner._manifest_json_value( replace(train.source_continuity_evidence, archive_identity_digest=archive_identity_digest) ) payload = { "format": _SOURCE_CONTINUITY_RELOCATION_FORMAT, "train_id": train.train_id, - "refresh_receipt_sha256": matching_refreshes[0], + "predecessor_authority": {"kind": predecessor.kind, "sha256": predecessor.sha256}, "source_before": old_after, "source_after": relocated, "relocation_plan_sha256": relocation_plan_sha256, @@ -2065,6 +2215,7 @@ def _verify_released_train_live_tier( """Verify a released train remains represented after later trains advance it.""" if train.apply_evidence is None: raise DurableChangeTrainError(f"{train.state.value} train lacks post-apply continuity evidence") + _validate_archive_root_relocation_receipts(archive_root, train) actual = actual_evidence or capture_durable_database_evidence(conn, train.tier) if actual.user_version < train.target_version: raise DurableChangeTrainError( diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index c606fe3cbe..4d4c27a646 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -762,7 +762,7 @@ def _validate_backup_manifest_covers_tier( """Validate that ``path`` has a successful backup verification receipt. ``require_attestation`` gates the cryptographic HMAC attestation check. - Attestations are only ever minted for durable tiers (source, user) by + Attestations are only ever minted for durable tiers (source, user, audit) by ``daemon/backup.py``'s ``_write_successful_verification_receipt`` -- a derived tier (index, embeddings) can never carry one, by design, so requiring it for those tiers would make backup-manifest validation @@ -925,14 +925,8 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root 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": + manifest_path, receipt_path, backup_root, manifest, receipt = _load_verified_backup_package(path) + if manifest.get("profile") != "full_evidence": raise MigrationError("audit adoption requires a verified full_evidence backup") included = set(_json_str_list(manifest.get("included_tiers"))) required_tiers = {"source", "index", "embeddings", "user"} @@ -944,15 +938,6 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root 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( - 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() try: if backup_root.samefile(archive_root): @@ -968,24 +953,13 @@ def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root ) 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( + artifacts = _validate_closed_backup_package( 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 sorted(included_tiers): live_path = archive_root / f"{tier}.db" artifact = artifacts[tier] diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 2b77bdc01a..aa611a47b1 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -103,8 +103,8 @@ def test_archive_root_relocation_cli_help_exposes_only_plan_and_apply( ) assert result.exit_code == 0, result.output - assert "plan" in result.output - assert "apply" in result.output + commands = result.output.split("Commands:\n", 1)[1] + assert {line.strip().split()[0] for line in commands.splitlines() if line.startswith(" ")} == {"apply", "plan"} def test_archive_root_relocation_apply_is_in_the_public_command_inventory() -> None: diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 10a5c0f859..d5168314fd 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -26,6 +26,7 @@ from polylogue.daemon.health import DaemonHealth, HealthSeverity, HealthTier from polylogue.sources.live import WatchSource from polylogue.sources.live.cursor import CursorStore +from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.raw_authority import RawReplayPlanOutcome, RawReplayPlanStatus from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database from polylogue.storage.sqlite.archive_tiers.embeddings import EMBEDDINGS_SCHEMA_VERSION @@ -2227,13 +2228,25 @@ def test_polylogued_run_rejects_empty_component_set() -> None: assert "at least one daemon component must be enabled" in result.output -def test_polylogued_watch_uses_default_sources() -> None: +def test_polylogued_watch_uses_default_sources(workspace_env: dict[str, Path]) -> None: runner = CliRunner() sources = (WatchSource(name="codex", root=Path("/tmp/codex")),) + observed_coroutine: object | None = None + + def assert_owned(coroutine: object) -> None: + nonlocal observed_coroutine + observed_coroutine = coroutine + with pytest.raises(ArchiveOwnershipError): + OwnedArchiveLocation.acquire( + ArchiveLocation.resolve(workspace_env["archive_root"]), + owner_id="competing-maintenance", + ) + assert inspect.iscoroutine(coroutine) + cast(Any, coroutine).close() with ( patch("polylogue.daemon.cli.default_sources", return_value=sources) as default_sources, - patch("polylogue.daemon.cli.asyncio.run") as run, + patch("polylogue.daemon.cli.asyncio.run", side_effect=assert_owned), ): result = runner.invoke(main, ["watch", "--debounce-s", "0.25"]) @@ -2241,13 +2254,11 @@ def test_polylogued_watch_uses_default_sources() -> None: assert default_sources.call_count == 1 assert default_sources.call_args.kwargs["hermes_root"] == Path.home() / ".hermes" assert default_sources.call_args.kwargs["beads_roots"] == () - coroutine = run.call_args.kwargs.get("main") or run.call_args.args[0] - assert inspect.iscoroutine(coroutine) - coroutine.close() + assert observed_coroutine is not None assert "Watching 1 source(s); debounce=0.25s" in result.stderr -def test_polylogued_watch_builds_sources_from_roots(tmp_path: Path) -> None: +def test_polylogued_watch_builds_sources_from_roots(workspace_env: dict[str, Path], tmp_path: Path) -> None: root_a = tmp_path / "claude-code" root_b = tmp_path / "codex" @@ -4134,7 +4145,7 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( ) with monkeypatch.context() as scoped: scoped.setattr( - "polylogue.operations.archive_root_relocation.rebind_released_source_train_archive_identity", + "polylogue.operations.archive_root_relocation.rebind_released_durable_train_archive_identity", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("leave prepared relocation receipt")), ) with pytest.raises(RuntimeError, match="leave prepared relocation receipt"): @@ -4148,7 +4159,7 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( ) monkeypatch.setattr("polylogue.daemon.status_snapshot.configure_runtime_components", configure) - with pytest.raises(ArchiveRootRelocationError, match="archive-root-relocation apply"): + with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): asyncio.run( daemon_cli.run_daemon_services( sources=(), diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index f76273172e..ca814fc8f4 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -3,13 +3,16 @@ from __future__ import annotations import asyncio +import hashlib import json import os import shutil import sqlite3 +from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import replace from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from click.testing import CliRunner @@ -38,7 +41,6 @@ _current_evidence, _sha256, _table_content_digest, - _test_historical_operation_evidence_resource, _verify_historical_operation_evidence, _write_refresh_receipt, assert_no_prepared_historical_source_continuity_recovery, @@ -62,9 +64,10 @@ from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import ( DURABLE_MIGRATION_ADOPTION_FLOORS, + DurableChangeTrain, DurableChangeTrainError, load_durable_change_train_manifest, - rebind_released_source_train_archive_identity, + rebind_released_durable_train_archive_identity, recover_released_source_train_continuity, ) from polylogue.storage.sqlite.migration_runner import ( @@ -73,6 +76,7 @@ capture_durable_database_evidence, capture_durable_restart_convergence, capture_durable_schema_inventory, + durable_change_train_to_payload, prove_durable_change_train, record_durable_writer_release, release_durable_change_train, @@ -80,6 +84,16 @@ ) +@contextmanager +def _test_historical_operation_evidence_resource(path: Path) -> Iterator[None]: + """Patch the packaged descriptor reader only within a synthetic test scope.""" + with patch( + "polylogue.operations.historical_source_continuity_recovery._historical_operation_evidence_bytes", + side_effect=lambda: path.read_bytes(), + ): + yield + + def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict[str, object]) -> None: """The production maintenance dispatcher exposes the explicit relocation route.""" result = CliRunner().invoke( @@ -192,14 +206,15 @@ def test_plan_rejects_byte_identical_copied_archive_with_new_inodes( """Authenticated pre-move inode facts distinguish a move from copytree bytes.""" old_root = workspace_env["archive_root"] _released_moved_source_train(old_root, monkeypatch) - backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) - assert backup.ok and backup.output_path is not None new_root = tmp_path / "copied" shutil.copytree(old_root, new_root, symlinks=True) assert (old_root / "source.db").read_bytes() == (new_root / "source.db").read_bytes() assert (old_root / "source.db").stat().st_ino != (new_root / "source.db").stat().st_ino + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None - with pytest.raises(ArchiveRootRelocationError, match="moved-root authority"): + with pytest.raises(ArchiveRootRelocationError, match="does not authenticate the moved tier identity"): prepare_archive_root_relocation( old_root=old_root, new_root=new_root, @@ -371,7 +386,7 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( before = released before_evidence = before.apply_evidence assert before_evidence is not None - updated = rebind_released_source_train_archive_identity( + updated = rebind_released_durable_train_archive_identity( before, archive_identity_digest="a" * 64, proof_refs=("proof:archive-root-relocation:receipt",), @@ -389,7 +404,7 @@ def test_rebind_rewrites_only_the_released_source_identity_fields( source_continuity_evidence=replace(before_evidence.post, observed_at_ms=before.released_at_ms + 1), proof_refs=(*before.proof_refs, "proof:source-continuity-refresh:" + "d" * 64), ) - rebound_current_authority = rebind_released_source_train_archive_identity( + rebound_current_authority = rebind_released_durable_train_archive_identity( current_authority, archive_identity_digest="c" * 64, proof_refs=( @@ -519,7 +534,10 @@ def _released_moved_source_train( released, apply_evidence=replace( released.apply_evidence, - post=replace(source_post, archive_identity_digest="b" * 64), + post=replace( + source_post, + archive_identity_digest=ArchiveIdentity.resolve(root).authority_identity_digest, + ), ), proof=source_proof, ) @@ -528,26 +546,97 @@ def _released_moved_source_train( manifest = manifest_root / "source-002.json" write_durable_change_train_manifest(manifest, historical, expected_revision=-1) monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.SOURCE, 1) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.USER, 10_000) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.AUDIT, 10_000) return manifest -def _activate_movable_index_generation(root: Path) -> Path: - """Promote a real generation while retaining a move-safe conventional symlink. +def _released_moved_durable_train( + root: Path, + monkeypatch: pytest.MonkeyPatch, + tier: ArchiveTier, +) -> Path: + """Build one real released non-source train for complete relocation coverage.""" + from tests.unit.storage import test_durable_change_train as trains - The active-pointer target is deliberately absolute, as it is in a live - generation layout. The conventional index symlink is relative so the - regression isolates relocation's pointer publication rather than a second - broken absolute symlink. - """ + database = root / f"{tier.value}.db" + database.unlink() + trains._create_current_database(database) + trains._install_synthetic_migration(root.parent, monkeypatch, tier) + train = trains._admitted(tier) + with sqlite3.connect(database) as connection: + train = trains._reserve_and_authorize(connection, train, archive_root=root) + train = apply_durable_change_train(connection, train) + train = record_durable_writer_release(train, evidence_ref=f"proof:{tier.value}-writer-release") + with sqlite3.connect(database) as connection: + restart = capture_durable_restart_convergence( + connection, + train, + runtime_consumers=trains._runtime_results(), + evidence_ref=f"proof:{tier.value}-restart", + ) + train = prove_durable_change_train( + train, + fresh_ddl_parity=trains._parity(tier), + runtime_consumers=trains._runtime_results(), + restart_convergence=restart, + ) + released = release_durable_change_train(train, evidence_ref=f"proof:{tier.value}-released") + manifest = root / ".maintenance-state" / "durable-change-trains" / f"{tier.value}-002.json" + write_durable_change_train_manifest(manifest, released, expected_revision=-1) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, tier, 1) + return manifest + + +def _clone_released_durable_train_for_tier( + root: Path, + source_manifest: Path, + tier: ArchiveTier, + monkeypatch: pytest.MonkeyPatch, +) -> Path: + """Retarget one validated released fixture train to another durable tier.""" + source = load_durable_change_train_manifest(source_manifest) + assert source.fresh_ddl_parity is not None + assert source.reservation is not None + assert source.backup_authorization is not None + assert source.pre_apply_evidence is not None + assert source.apply_evidence is not None + assert source.proof is not None + cloned = replace( + source, + train_id=f"train:{tier.value}:v{source.target_version}", + tier=tier, + migration=replace(source.migration, tier=tier), + fresh_ddl_parity=replace(source.fresh_ddl_parity, tier=tier), + reservation=replace(source.reservation, tier_path=str(root / f"{tier.value}.db")), + backup_authorization=replace( + source.backup_authorization, + live_tier_path=str(root / f"{tier.value}.db"), + ), + pre_apply_evidence=replace(source.pre_apply_evidence, tier=tier), + apply_evidence=replace( + source.apply_evidence, + pre=replace(source.apply_evidence.pre, tier=tier), + post=replace(source.apply_evidence.post, tier=tier), + migration_result=replace(source.apply_evidence.migration_result, tier=tier), + ), + proof=replace( + source.proof, + fresh_ddl_parity=replace(source.proof.fresh_ddl_parity, tier=tier), + ), + ) + manifest = root / ".maintenance-state" / "durable-change-trains" / f"{tier.value}-002.json" + write_durable_change_train_manifest(manifest, cloned, expected_revision=-1) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, tier, 1) + return manifest + + +def _activate_movable_index_generation(root: Path) -> Path: + """Promote a real generation using the production absolute symlink layout.""" store = IndexGenerationStore.for_archive_root(root) generation = store.create(owner_id="relocation-test", source_snapshot="snapshot") store.promote(generation) - target = Path(generation.index_path).resolve(strict=True) - conventional = root / "index.db" - conventional.unlink() - conventional.symlink_to(target.relative_to(root)) - (root / ".index-active-pointer").write_text(str(target), encoding="utf-8") - return target + return Path(generation.index_path).resolve(strict=True) def _legacy_liveness_receipt( @@ -819,6 +908,21 @@ def test_receipt_writers_never_create_through_a_symlinked_maintenance_state(tmp_ continuity_root / ".maintenance-state" / "source-continuity-refreshes" / ("f" * 64 + ".json"), {"refresh_sha256": "f" * 64}, ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="unsafe"): + _write_continuity_receipt( + continuity_root / ".maintenance-state" / "historical-source-continuity-recoveries" / ("f" * 64 + ".json"), + _sealed_continuity_receipt( + state="prepared", + revision=0, + plan_sha256="f" * 64, + authorization="f" * 64, + train_before_sha256="0" * 64, + train_after_sha256=None, + refresh_receipt_sha256="1" * 64, + resume_command="resume continuity", + ), + expected=None, + ) assert not tuple(outside.iterdir()) @@ -1275,7 +1379,7 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ assert backup.ok and backup.output_path is not None moved_manifest = new_root / manifest.relative_to(old_root) with sqlite3.connect(new_root / "source.db") as connection: - with pytest.raises(Exception, match="continuity proof failed"): + with pytest.raises(DurableChangeTrainError, match="continuity proof failed"): trains._verify_released_train_live_tier( new_root, connection, @@ -1314,7 +1418,7 @@ def test_prepare_apply_rebinds_a_real_released_train_and_resumes_after_prepared_ assert not (new_root / ".maintenance-state" / "archive-root-relocations").exists() with monkeypatch.context() as scoped: scoped.setattr( - "polylogue.operations.archive_root_relocation.rebind_released_source_train_archive_identity", + "polylogue.operations.archive_root_relocation.rebind_released_durable_train_archive_identity", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("crash")), ) with pytest.raises(RuntimeError, match="crash"): @@ -1385,8 +1489,8 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public old_root = workspace_env["archive_root"] manifest = _released_moved_source_train(old_root, monkeypatch) - _attach_retained_source_continuity(old_root, manifest) old_active_target = _activate_movable_index_generation(old_root) + _attach_retained_source_continuity(old_root, manifest) new_root = tmp_path / "moved" os.rename(old_root, new_root) monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) @@ -1403,8 +1507,11 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public pointer = plan.active_index_pointer assert pointer is not None - assert pointer.old_target == str(old_active_target) - assert pointer.new_target == str(new_root / old_active_target.relative_to(old_root)) + assert pointer.old_target == str(old_root / "index.db") + assert pointer.new_target == str(new_root / "index.db") + assert pointer.old_resolved_target == str(old_active_target) + assert pointer.conventional_symlink_old_target == str(old_active_target) + assert pointer.conventional_symlink_new_target == str(new_root / old_active_target.relative_to(old_root)) real_publish = relocation._publish_active_index_pointer real_write = os.write short_pointer_write = False @@ -1435,10 +1542,177 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo monkeypatch.setattr(relocation, "_publish_active_index_pointer", real_publish) result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) assert result.state == "committed" - assert ArchiveLocation.resolve(new_root).active_index_path == Path(pointer.new_resolved_target) + relocated_location = ArchiveLocation.resolve(new_root) + assert relocated_location.active_index_path == Path(pointer.new_target) + assert relocated_location.active_index.resolved_path == Path(pointer.new_resolved_target) assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" +def test_relocation_accepts_a_modern_no_rebind_train_without_rewriting_it( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An inode-preserving move leaves modern tier identity authority unchanged.""" + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + train = load_durable_change_train_manifest(manifest) + assert train.apply_evidence is not None + identity = ArchiveIdentity.resolve(old_root).tier("source").stable_id + modern = replace( + train, + revision=train.revision + 1, + apply_evidence=replace( + train.apply_evidence, + post=replace( + train.apply_evidence.post, archive_identity_digest=hashlib.sha256(identity.encode()).hexdigest() + ), + ), + ) + write_durable_change_train_manifest(manifest, modern, expected_revision=train.revision) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert len(plan.durable_trains) == 1 + assert plan.durable_trains[0].requires_rebind is False + moved_manifest = Path(plan.durable_trains[0].path) + before = moved_manifest.read_bytes() + assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" + assert moved_manifest.read_bytes() == before + + +def test_relocation_resume_rejects_a_same_revision_manifest_substituted_after_cas( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Prepared recovery accepts only the exact post-CAS bytes bound before mutation.""" + from polylogue.operations import archive_root_relocation as relocation + + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + real_write = write_durable_change_train_manifest + + def crash_after_cas(path: Path, train: DurableChangeTrain, *, expected_revision: int) -> None: + real_write(path, train, expected_revision=expected_revision) + raise RuntimeError("crash after relocation manifest CAS") + + monkeypatch.setattr(relocation, "write_durable_change_train_manifest", crash_after_cas) + with pytest.raises(RuntimeError, match="crash after relocation manifest CAS"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + monkeypatch.setattr(relocation, "write_durable_change_train_manifest", real_write) + retained_plan = new_root / ".maintenance-state" / "archive-root-relocation-plans" / f"{plan.plan_sha256}.json" + assert retained_plan.is_file() + prepared_receipt = json.loads( + (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").read_text( + encoding="utf-8" + ) + ) + assert f"--plan {retained_plan}" in prepared_receipt["resume_command"] + train_path = Path(plan.durable_trains[0].path) + relocated = load_durable_change_train_manifest(train_path) + substituted = replace(relocated, proof_refs=(*relocated.proof_refs, "proof:foreign-substitution")) + payload = durable_change_train_to_payload(substituted) + train_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + with pytest.raises(ArchiveRootRelocationError, match="manifest changed"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + +def test_continuity_free_rebind_requires_its_retained_relocation_receipt( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Daemon admission resolves relocation authority even without source refresh evidence.""" + from polylogue.storage.sqlite import durable_change_train as trains + + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert result.state == "committed" + Path(result.receipt_path or "").unlink() + train = load_durable_change_train_manifest(Path(plan.durable_trains[0].path)) + with sqlite3.connect(new_root / "source.db") as connection: + with pytest.raises(DurableChangeTrainError, match="committed receipt"): + trains._verify_released_train_live_tier(new_root, connection, train) + + +def test_relocation_rebinds_released_trains_for_every_durable_tier( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Legacy source, user, and audit trains move under one exact CAS plan.""" + old_root = workspace_env["archive_root"] + source_manifest = _released_moved_source_train(old_root, monkeypatch) + user_manifest = _released_moved_durable_train(old_root, monkeypatch, ArchiveTier.USER) + manifests = [ + source_manifest, + user_manifest, + _clone_released_durable_train_for_tier(old_root, user_manifest, ArchiveTier.AUDIT, monkeypatch), + ] + legacy_identity = ArchiveIdentity.resolve(old_root).authority_identity_digest + for manifest in manifests: + train = load_durable_change_train_manifest(manifest) + assert train.apply_evidence is not None + rebound = replace( + train, + revision=train.revision + 1, + apply_evidence=replace( + train.apply_evidence, + post=replace(train.apply_evidence.post, archive_identity_digest=legacy_identity), + ), + ) + write_durable_change_train_manifest(manifest, rebound, expected_revision=train.revision) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert {item.tier for item in plan.durable_trains} == {"source", "user", "audit"} + assert all(item.requires_rebind for item in plan.durable_trains) + assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" + identity = ArchiveIdentity.resolve(new_root) + for item in plan.durable_trains: + train = load_durable_change_train_manifest(Path(item.path)) + assert train.apply_evidence is not None + expected = hashlib.sha256(identity.tier(item.tier).stable_id.encode()).hexdigest() + assert train.apply_evidence.post.archive_identity_digest == expected + + def test_relocation_rejects_an_active_pointer_not_owned_by_the_old_root( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1629,11 +1903,11 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re relocation_payload = _maintenance_json_output(relocated.output) relocation_digest = str(relocation_payload["plan_sha256"]) relocation_plan_payload = json.loads(relocation_plan.read_text(encoding="utf-8")) - source_trains = relocation_plan_payload["source_trains"] + source_trains = relocation_plan_payload["durable_trains"] assert isinstance(source_trains, list) and len(source_trains) == 1 assert source_trains[0]["requires_rebind"] is False - refresh_digests = source_trains[0]["source_continuity_receipt_digests"] + refresh_digests = source_trains[0]["continuity_receipt_digests"] assert isinstance(refresh_digests, list) and len(refresh_digests) == 1 refresh_path = moved_root / ".maintenance-state" / "source-continuity-refreshes" / f"{refresh_digests[0]}.json" foreign_refresh = tmp_path / "foreign-refresh.json" @@ -1756,6 +2030,78 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re assert len(relocation_refs) == 1 assert len(transition_refs) == 1 + second_root = tmp_path / "moved-again" + os.rename(moved_root, second_root) + second_env = {"POLYLOGUE_ARCHIVE_ROOT": str(second_root)} + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(second_root)) + second_backup = backup_archive(output_dir=tmp_path / "second-moved-backup", profile="full_evidence", verify=True) + assert second_backup.ok and second_backup.output_path is not None + second_plan = tmp_path / "second-relocation-plan.json" + second_planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "plan", + "--old-root", + str(moved_root), + "--backup-manifest", + str(Path(second_backup.output_path) / "manifest.json"), + "--output", + str(second_plan), + "--output-format", + "json", + ], + env=second_env, + catch_exceptions=False, + ) + assert second_planned.exit_code == 0, second_planned.output + second_digest = str(_maintenance_json_output(second_planned.output)["plan_sha256"]) + second_applied = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "archive-root-relocation", + "apply", + "--plan", + str(second_plan), + "--authorize", + second_digest, + "--output-format", + "json", + ], + env=second_env, + catch_exceptions=False, + ) + assert second_applied.exit_code == 0, second_applied.output + second_payload = json.loads(second_plan.read_text(encoding="utf-8")) + second_train_path = Path(str(second_payload["durable_trains"][0]["path"])) + second_train = load_durable_change_train_manifest(second_train_path) + second_transition_refs = tuple( + ref for ref in second_train.proof_refs if ref.startswith("proof:source-continuity-relocation:") + ) + assert len(second_transition_refs) == 2 + latest_transition = json.loads( + ( + second_root + / ".maintenance-state" + / "source-continuity-relocations" + / f"{second_transition_refs[-1].rsplit(':', 1)[-1]}.json" + ).read_text(encoding="utf-8") + ) + assert latest_transition["predecessor_authority"] == { + "kind": "relocation", + "sha256": second_transition_refs[-2].rsplit(":", 1)[-1], + } + from polylogue.storage.sqlite import durable_change_train as trains + + with sqlite3.connect(second_root / "source.db") as connection: + assert trains._verify_released_train_live_tier(second_root, connection, second_train) is None + def test_historical_continuity_recovery_resume_rejects_a_foreign_same_evidence_receipt( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch From 8dee19353adf481e55ba4684940e9096f88439aa Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 05:14:10 +0200 Subject: [PATCH 27/39] docs: align relocation recovery contract Document the moved-root full-evidence sequence, all durable train rewrites, retained-plan resume path, and repeated relocation proof chain. --- docs/archive-backup.md | 2 +- docs/maintenance.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 0d648a7e03..81b9f229b0 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -87,7 +87,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --authorize PLAN_SHA256 --output-format json ``` -The route reads every SQLite file immutably and refuses copied files, WAL sidecars, moved-root backup receipts that do not authenticate the current tier paths, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any non-released source train. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census. That bridge is a separate offline transition, not an exception inside relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver, and the plan binds the resolved generation rather than a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises released source train manifests when identity or continuity proof requires it and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. A prepared receipt blocks daemon startup and prints the exact resume command. Live application and post-move observation remain operator evidence outside this code path. +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, moved-root backup receipts that do not authenticate the current tier paths, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any incomplete released durable-train chain. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census. That bridge is a separate offline transition, not an exception inside relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver; the plan binds its resolved generation and apply atomically remaps an absolute in-root symlink rather than selecting a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises released `source`, `user`, and `audit` train manifests when identity or continuity proof requires it, retains the exact plan, and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. Repeated relocations must form one unbranched chain through the prior authenticated relocation authority and exact manifest hashes. A prepared receipt blocks daemon startup and prints the exact retained-plan resume command. Live application and post-move observation remain operator evidence outside this code path. For a deployed archive, run these commands only from the Nix package built from the post-merge commit selected for deployment. Record that merge SHA and the resulting Nix store path in the operator receipt, verify the daemon executable resolves to that exact package, and keep `POLYLOGUE_ARCHIVE_ROOT` set to the configured deployed root. Do not resume a stopped daemon with an older deployed package or a branch checkout: its durable-train vocabulary may predate the relocation transition. diff --git a/docs/maintenance.md b/docs/maintenance.md index 3c6c0cc0a0..c6a820537f 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -41,14 +41,14 @@ command's migration result alone. ## Relocating an archive root -Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. `--old-root` names the retired pre-move root for the identity transition and active-index pointer mapping. Create a fresh verified `full_evidence` backup after setting `POLYLOGUE_ARCHIVE_ROOT` to the moved root. The relocation plan authenticates that backup against the moved root and revalidates its device/inode inventory there; it never asks a moved-root backup to authenticate the nonexistent retired path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and writes only released source durable-train manifests plus its receipt; it never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. `--old-root` names the retired pre-move root for the identity transition and active-index pointer mapping. Create a fresh verified `full_evidence` backup after setting `POLYLOGUE_ARCHIVE_ROOT` to the moved root. The relocation plan authenticates that backup against the moved root and revalidates its device/inode inventory there; it never asks a moved-root backup to authenticate the nonexistent retired path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and CAS-revises only the released `source`, `user`, and `audit` durable-train manifests that require relocation authority, together with the retained exact plan and prepared/committed receipts. It never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation apply --plan /safe/relocation-plan.json --authorize PLAN_SHA256 --output-format json ``` -If apply stops after recording a prepared receipt, daemon startup fails closed and names the exact apply command. Rerun that command with the same plan and authorization after restoring offline ownership. Do not use this operation for a copy, restore, new archive, migration, or live service move. +If apply stops after recording a prepared receipt, daemon startup fails closed and names an exact command whose plan path is the retained plan bound by that receipt. Rerun that command with the same authorization after restoring offline ownership. Resume accepts only the exact post-CAS manifest hashes and relocation proof chain sealed for that plan. Do not use this operation for a copy, restore, new archive, migration, or live service move. ### Recovering the one historical liveness receipt shape From bf61c7dfeb14bb99328405aaf06937aff187f8cc Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 05:33:14 +0200 Subject: [PATCH 28/39] fix: close relocation admission gaps --- polylogue/daemon/cli.py | 6 +++ .../operations/archive_root_relocation.py | 39 +++++++++++---- .../storage/sqlite/durable_change_train.py | 49 ++++++++++--------- tests/unit/daemon/test_daemon_cli.py | 9 ++++ .../storage/test_archive_root_relocation.py | 39 +++++++++++++++ 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 73735f2943..ff5ac6d06c 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -3402,7 +3402,11 @@ def parameter_is_default(name: str) -> bool: ) def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: from polylogue.config import resolve_runtime_config + from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation from polylogue.operations.durable_change_train import acquire_durable_archive_ownership + from polylogue.operations.historical_source_continuity_recovery import ( + assert_no_prepared_historical_source_continuity_recovery, + ) from polylogue.paths import archive_root runtime_source_paths = resolve_runtime_config().source_paths @@ -3423,6 +3427,8 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: owner_id=f"watch:{os.getpid()}", ) try: + assert_no_prepared_archive_root_relocation(archive_root_path) + assert_no_prepared_historical_source_continuity_recovery(archive_root_path) asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) finally: archive_owner.release() diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index aa147d2931..a40439a539 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -426,20 +426,41 @@ def _publish_conventional_index_symlink(root: Path, pointer: RelocationActiveInd if old_target is None or new_target is None or old_target == new_target: return conventional = Path(pointer.new_target) - if not conventional.is_symlink(): - raise ArchiveRootRelocationError("archive-root relocation conventional index symlink disappeared") - current = os.readlink(conventional) - if current == new_target: - return - if current != old_target: - raise ArchiveRootRelocationError("archive-root relocation conventional index symlink changed") + try: + relative_parent = conventional.parent.relative_to(root) + except ValueError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation conventional index symlink escapes the destination root" + ) from exc directory_fd = -1 - temporary = f".index.db.relocation-{uuid.uuid4().hex}.tmp" + temporary = f".{conventional.name}.relocation-{uuid.uuid4().hex}.tmp" try: directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + for component in relative_parent.parts: + if component in {"", ".", ".."}: + raise ArchiveRootRelocationError( + "archive-root relocation conventional index symlink has an unsafe parent" + ) + next_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=directory_fd, + ) + os.close(directory_fd) + directory_fd = next_fd + metadata = os.stat(conventional.name, dir_fd=directory_fd, follow_symlinks=False) + if not stat.S_ISLNK(metadata.st_mode): + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink disappeared") + current = os.readlink(conventional.name, dir_fd=directory_fd) + if current == new_target: + return + if current != old_target: + raise ArchiveRootRelocationError("archive-root relocation conventional index symlink changed") os.symlink(new_target, temporary, dir_fd=directory_fd) - os.replace(temporary, "index.db", src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.replace(temporary, conventional.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) + except ArchiveRootRelocationError: + raise except OSError as exc: raise ArchiveRootRelocationError("cannot atomically publish mapped conventional index symlink") from exc finally: diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 8aaa4c451f..c209bb1ab2 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1183,30 +1183,33 @@ def _validate_source_continuity_refresh_receipt( predecessors[ref] = predecessor successor_by_authority[predecessor] = ref - resolving: set[_SourceContinuityAuthorityRef] = set() - - def resolve(ref: _SourceContinuityAuthorityRef) -> _SourceContinuityAuthorityNode: - existing = nodes.get(ref) - if existing is not None: - return existing - payload = relocation_payloads.get(ref) - if payload is None: - raise DurableChangeTrainError("source continuity relocation transition lacks its retained predecessor") - if ref in resolving: - raise DurableChangeTrainError("source continuity relocation authority contains a cycle") - resolving.add(ref) - predecessor_node = resolve(predecessors[ref]) - resolving.remove(ref) - if payload.get("source_before") != predecessor_node.source_after: - raise DurableChangeTrainError( - "source continuity relocation transition does not preserve predecessor authority" - ) - node = _SourceContinuityAuthorityNode(ref=ref, source_after=payload.get("source_after")) - nodes[ref] = node - return node - for ref in relocation_payloads: - resolve(ref) + if ref in nodes: + continue + trail: list[_SourceContinuityAuthorityRef] = [] + trail_refs: set[_SourceContinuityAuthorityRef] = set() + current = ref + while current not in nodes: + if current in trail_refs: + raise DurableChangeTrainError("source continuity relocation authority contains a cycle") + if current not in relocation_payloads: + raise DurableChangeTrainError("source continuity relocation transition lacks its retained predecessor") + trail.append(current) + trail_refs.add(current) + current = predecessors[current] + predecessor_node = nodes[current] + for transition_ref in reversed(trail): + payload = relocation_payloads[transition_ref] + if payload.get("source_before") != predecessor_node.source_after: + raise DurableChangeTrainError( + "source continuity relocation transition does not preserve predecessor authority" + ) + node = _SourceContinuityAuthorityNode( + ref=transition_ref, + source_after=payload.get("source_after"), + ) + nodes[transition_ref] = node + predecessor_node = node matching_authorities = [ node.ref for node in nodes.values() diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index d5168314fd..662e787456 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4175,6 +4175,15 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( admission.assert_called_once_with(root) configure.assert_not_called() + watcher = Mock() + monkeypatch.setattr(daemon_cli, "run_live_watcher", watcher) + with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): + CliRunner().invoke(main, ["watch"], catch_exceptions=False) + + assert admission.call_count == 2 + admission.assert_called_with(root) + watcher.assert_not_called() + def test_emit_daemon_lifecycle_event_carries_dev_loop_context( tmp_path: Path, diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index ca814fc8f4..63f2e32445 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1548,6 +1548,45 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" +def test_active_index_publication_updates_the_bound_nested_conventional_symlink(tmp_path: Path) -> None: + """Pointer publication updates the exact conventional path sealed by the plan. + + Anti-vacuity: ``_publish_active_index_pointer`` is the production apply + helper. Replacing a hard-coded ``/index.db`` leaves this nested + conventional symlink stale while publishing a pointer that selects it. + """ + from polylogue.operations import archive_root_relocation as relocation + + old_root = tmp_path / "old" + new_root = tmp_path / "new" + conventional = new_root / "nested" / "index.db" + resolved = new_root / ".index-generations" / "gen-1" / "index.db" + conventional.parent.mkdir(parents=True) + resolved.parent.mkdir(parents=True) + resolved.write_bytes(b"index generation") + old_resolved = old_root / resolved.relative_to(new_root) + conventional.symlink_to(old_resolved) + old_conventional = old_root / conventional.relative_to(new_root) + (new_root / ".index-active-pointer").write_text(str(old_conventional), encoding="utf-8") + metadata = resolved.stat() + pointer = RelocationActiveIndexPointer( + old_target=str(old_conventional), + new_target=str(conventional), + old_resolved_target=str(old_resolved), + new_resolved_target=str(resolved), + conventional_symlink_old_target=str(old_resolved), + conventional_symlink_new_target=str(resolved), + device=metadata.st_dev, + inode=metadata.st_ino, + ) + + relocation._publish_active_index_pointer(new_root, pointer) + + assert os.readlink(conventional) == str(resolved) + assert not (new_root / "index.db").exists() + assert (new_root / ".index-active-pointer").read_text(encoding="utf-8").strip() == str(conventional) + + def test_relocation_accepts_a_modern_no_rebind_train_without_rewriting_it( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From c3cc3d2923af01dd86660af1e3465537be8cfd90 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 05:51:43 +0200 Subject: [PATCH 29/39] fix: make continuity recovery resumable --- docs/maintenance.md | 2 +- .../maintenance/_archive_root_relocation.py | 4 +- .../_source_continuity_recovery.py | 6 +- .../historical_source_continuity_recovery.py | 68 ++++++++++---- .../storage/test_archive_root_relocation.py | 89 +++++++++++++++++-- 5 files changed, 143 insertions(+), 26 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index c6a820537f..0bd7fcf2f6 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -59,7 +59,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-contin POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-continuity-recovery apply --plan /safe/continuity-plan.json --authorize PLAN_SHA256 --output-format json ``` -After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root. Use that moved-root manifest with the separate archive-root-relocation plan/apply transition while `--old-root` continues to name the retired pre-move root. A prepared bridge receipt blocks daemon startup and names its exact resume command. +After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root. Use that moved-root manifest with the separate archive-root-relocation plan/apply transition while `--old-root` continues to name the retired pre-move root. Before publishing a prepared bridge receipt, apply retains the exact sealed plan under `.maintenance-state/historical-source-continuity-recovery-plans/`; the blocking daemon error names that retained path in its executable resume command. ### Rebuild deployment-currency preflight diff --git a/polylogue/cli/commands/maintenance/_archive_root_relocation.py b/polylogue/cli/commands/maintenance/_archive_root_relocation.py index 56e3220d4d..dade4a8682 100644 --- a/polylogue/cli/commands/maintenance/_archive_root_relocation.py +++ b/polylogue/cli/commands/maintenance/_archive_root_relocation.py @@ -15,7 +15,7 @@ prepare_archive_root_relocation, write_archive_root_relocation_plan, ) -from polylogue.operations.durable_change_train import acquire_durable_archive_ownership +from polylogue.operations.durable_change_train import ArchiveOwnershipError, acquire_durable_archive_ownership from polylogue.paths import archive_root @@ -54,7 +54,7 @@ def archive_root_relocation_plan_command( single_writer_evidence_ref="proof:archive-ownership-lock", ) write_archive_root_relocation_plan(plan, output) - except (ArchiveRootRelocationError, OSError) as exc: + except (ArchiveOwnershipError, ArchiveRootRelocationError, OSError) as exc: raise click.ClickException(str(exc)) from exc if output_format == "json": click.echo(json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True)) diff --git a/polylogue/cli/commands/maintenance/_source_continuity_recovery.py b/polylogue/cli/commands/maintenance/_source_continuity_recovery.py index 1dc870f007..be3985307a 100644 --- a/polylogue/cli/commands/maintenance/_source_continuity_recovery.py +++ b/polylogue/cli/commands/maintenance/_source_continuity_recovery.py @@ -8,7 +8,7 @@ import click -from polylogue.operations.durable_change_train import acquire_durable_archive_ownership +from polylogue.operations.durable_change_train import ArchiveOwnershipError, acquire_durable_archive_ownership from polylogue.operations.historical_source_continuity_recovery import ( HistoricalSourceContinuityRecoveryError, apply_historical_source_continuity_recovery, @@ -58,7 +58,7 @@ def source_continuity_recovery_plan_command( single_writer_evidence_ref="proof:archive-ownership-lock", ) write_historical_source_continuity_recovery_plan(plan, output) - except (HistoricalSourceContinuityRecoveryError, OSError) as exc: + except (ArchiveOwnershipError, HistoricalSourceContinuityRecoveryError, OSError) as exc: raise click.ClickException(str(exc)) from exc click.echo( json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) @@ -89,7 +89,7 @@ def source_continuity_recovery_apply_command(plan_path: Path, authorize: str, ou stopped_daemon_evidence_ref=stopped, single_writer_evidence_ref="proof:archive-ownership-lock", ) - except (HistoricalSourceContinuityRecoveryError, OSError) as exc: + except (ArchiveOwnershipError, HistoricalSourceContinuityRecoveryError, OSError) as exc: raise click.ClickException(str(exc)) from exc click.echo( json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index b084ceec75..fcdaddfa54 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -33,7 +33,12 @@ read_optional_receipt, ) from polylogue.paths import render_root -from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation, TierFileIdentity +from polylogue.storage.archive_identity import ( + ArchiveLocation, + ArchiveOwnershipError, + OwnedArchiveLocation, + TierFileIdentity, +) from polylogue.storage.backup_attestation import BackupAttestationError, verify_verification_receipt from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, @@ -958,6 +963,29 @@ def load_historical_source_continuity_recovery_plan(path: Path) -> HistoricalSou return plan +def _retained_plan_path(root: Path, plan: HistoricalSourceContinuityRecoveryPlan) -> Path: + return root / ".maintenance-state" / "historical-source-continuity-recovery-plans" / f"{plan.plan_sha256}.json" + + +def _retain_plan(root: Path, plan: HistoricalSourceContinuityRecoveryPlan) -> Path: + """Retain the exact sealed plan before publishing resumable operation state.""" + _verify_plan(plan) + path = _retained_plan_path(root, plan) + encoded = (json.dumps(plan.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + try: + with maintenance_receipt_directory(root, "historical-source-continuity-recovery-plans") as directory_fd: + current = read_optional_receipt(directory_fd, path.name) + if current is not None and current != encoded: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery retained plan collision") + if current is None: + atomic_replace_receipt(directory_fd, path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise HistoricalSourceContinuityRecoveryError( + "cannot retain historical source continuity recovery plan" + ) from exc + return path + + def _recovery_receipt_directory_binding(path: Path) -> tuple[Path, str]: state_root = path.parent.parent if state_root.name != ".maintenance-state" or path.suffix != ".json": @@ -1115,19 +1143,24 @@ def apply_historical_source_continuity_recovery( ) -> HistoricalSourceContinuityRecoveryResult: """Acquire archive ownership before the API can publish receipts or a CAS revision.""" resolved = _real_directory(root, label="configured archive root") - with OwnedArchiveLocation.acquire( - ArchiveLocation.resolve(resolved), - owner_id=f"historical-source-continuity-recovery:{os.getpid()}", - allow_reentrant=True, - ): - _require_offline_ownership_boundary(resolved) - return _apply_historical_source_continuity_recovery_locked( - root=resolved, - plan=plan, - authorization=authorization, - stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, - single_writer_evidence_ref=single_writer_evidence_ref, - ) + try: + with OwnedArchiveLocation.acquire( + ArchiveLocation.resolve(resolved), + owner_id=f"historical-source-continuity-recovery:{os.getpid()}", + allow_reentrant=True, + ): + _require_offline_ownership_boundary(resolved) + return _apply_historical_source_continuity_recovery_locked( + root=resolved, + plan=plan, + authorization=authorization, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + single_writer_evidence_ref=single_writer_evidence_ref, + ) + except ArchiveOwnershipError as exc: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery could not acquire exclusive archive ownership" + ) from exc def _apply_historical_source_continuity_recovery_locked( @@ -1153,7 +1186,12 @@ def _apply_historical_source_continuity_recovery_locked( if refresh_digest != plan.refresh_receipt_sha256: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery sealed refresh proof changed") refresh_path = _refresh_path(resolved, refresh_digest) - command = f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance source-continuity-recovery apply --plan --authorize {plan.plan_sha256} --output-format json" + retained_plan_path = _retain_plan(resolved, plan) + command = ( + f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance " + f"source-continuity-recovery apply --plan {retained_plan_path} " + f"--authorize {plan.plan_sha256} --output-format json" + ) receipt_path = _receipt_path(resolved, plan) prepared = _sealed_receipt( state="prepared", diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 63f2e32445..3157b0d432 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -54,7 +54,12 @@ from polylogue.operations.historical_source_continuity_recovery import ( _write_receipt as _write_continuity_receipt, ) -from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation, OwnedArchiveLocation +from polylogue.storage.archive_identity import ( + ArchiveIdentity, + ArchiveLocation, + ArchiveOwnershipError, + OwnedArchiveLocation, +) from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, BlobRefLivenessCandidateDigest, @@ -113,6 +118,69 @@ def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict assert "--old-root" in nested.output +def test_recovery_cli_reports_archive_ownership_conflicts( + cli_workspace: dict[str, object], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Maintenance lock contention is a public CLI error, never an internal traceback.""" + placeholders = { + name: tmp_path / name + for name in ("mutation.jsonl", "pre-manifest.json", "post-manifest.json", "sealed-plan.json") + } + for path in placeholders.values(): + path.write_text("{}", encoding="utf-8") + + def reject_ownership(*_args: object, **_kwargs: object) -> None: + raise ArchiveOwnershipError("archive already owned") + + monkeypatch.setattr( + "polylogue.cli.commands.maintenance._source_continuity_recovery.acquire_durable_archive_ownership", + reject_ownership, + ) + plan_result = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(tmp_path / "old"), + "--mutation-receipt", + str(placeholders["mutation.jsonl"]), + "--pre-backup-manifest", + str(placeholders["pre-manifest.json"]), + "--post-backup-manifest", + str(placeholders["post-manifest.json"]), + "--output", + str(tmp_path / "out.json"), + ], + ) + assert plan_result.exit_code == 1 + assert "archive already owned" in plan_result.output + + monkeypatch.setattr( + "polylogue.cli.commands.maintenance._source_continuity_recovery.load_historical_source_continuity_recovery_plan", + lambda _path: object(), + ) + apply_result = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(placeholders["sealed-plan.json"]), + "--authorize", + "a" * 64, + ], + ) + assert apply_result.exit_code == 1 + assert "archive already owned" in apply_result.output + + def test_relocation_nested_dispatch_keeps_analyze_facets_on_the_real_action(cli_workspace: dict[str, object]) -> None: """Nested maintenance routing must not turn the existing aggregate action into a silent no-op.""" archive_root = cli_workspace["archive_root"] @@ -1161,6 +1229,17 @@ def crash_before_refresh(*_args: object, **_kwargs: object) -> None: env=command_env, catch_exceptions=False, ) + retained_plan = ( + new_root / ".maintenance-state" / "historical-source-continuity-recovery-plans" / f"{plan_sha256}.json" + ) + assert retained_plan.read_bytes() == plan_path.read_bytes() + prepared_receipt = json.loads( + ( + new_root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan_sha256}.json" + ).read_text(encoding="utf-8") + ) + assert f"--plan {retained_plan}" in prepared_receipt["resume_command"] + plan_path.unlink() with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): assert_no_prepared_historical_source_continuity_recovery(new_root) from polylogue.daemon import cli as daemon_cli @@ -1197,7 +1276,7 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: "source-continuity-recovery", "apply", "--plan", - str(plan_path), + str(retained_plan), "--authorize", plan_sha256, "--output-format", @@ -1219,7 +1298,7 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: "source-continuity-recovery", "apply", "--plan", - str(plan_path), + str(retained_plan), "--authorize", plan_sha256, "--output-format", @@ -1270,7 +1349,7 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: "source-continuity-recovery", "apply", "--plan", - str(plan_path), + str(retained_plan), "--authorize", plan_sha256, "--output-format", @@ -1291,7 +1370,7 @@ def crash_after_refresh(path: Path, payload: dict[str, object]) -> None: "source-continuity-recovery", "apply", "--plan", - str(plan_path), + str(retained_plan), "--authorize", plan_sha256, "--output-format", From 80b34459177d49e4a2705e5127f770cd8e8a2dc1 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 06:51:45 +0200 Subject: [PATCH 30/39] fix: chain archive relocation authority --- docs/archive-backup.md | 2 +- docs/maintenance.md | 4 +- polylogue/daemon/backup.py | 77 ++-- .../operations/archive_root_relocation.py | 327 ++++++++++++++++- .../historical_source_continuity_recovery.py | 25 +- .../storage/sqlite/durable_change_train.py | 216 +++++++++--- tests/unit/daemon/test_daemon_cli.py | 8 +- .../storage/test_archive_root_relocation.py | 329 +++++++++++++++++- 8 files changed, 894 insertions(+), 94 deletions(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index 81b9f229b0..a29f4b7498 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -87,7 +87,7 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ --authorize PLAN_SHA256 --output-format json ``` -The route reads every SQLite file immutably and refuses copied files, WAL sidecars, moved-root backup receipts that do not authenticate the current tier paths, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any incomplete released durable-train chain. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census. That bridge is a separate offline transition, not an exception inside relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver; the plan binds its resolved generation and apply atomically remaps an absolute in-root symlink rather than selecting a shadow index path. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises released `source`, `user`, and `audit` train manifests when identity or continuity proof requires it, retains the exact plan, and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. Repeated relocations must form one unbranched chain through the prior authenticated relocation authority and exact manifest hashes. A prepared receipt blocks daemon startup and prints the exact retained-plan resume command. Live application and post-move observation remain operator evidence outside this code path. +The route reads every SQLite file immutably and refuses copied files, WAL sidecars, moved-root backup receipts that do not authenticate the current tier paths, changed bytes/schema/version/tier inventory, fresh-bootstrap authority, or any incomplete released durable-train chain. A live source train whose historical content differs from the current source must first carry receipt-backed source-continuity authority. For the one pre-#3868 liveness receipt shape, create that authority with `source-continuity-recovery` using authenticated pre/post backups and a fresh zero-orphan census. That bridge is a separate offline transition, not an exception inside relocation. Relocation records both configured and resolved paths. A configured `index.db` active-generation symlink is permitted only through the existing `ArchiveLocation` resolver; the plan binds its resolved generation, every retained generation's absolute metadata and tier links, and apply remaps those exact objects before publishing the active pointer. Apply writes no SQLite rows, blobs, or sidecars. It CAS-revises released `source`, `user`, and `audit` train manifests when identity or continuity proof requires it, retains the exact plan, and records a prepared then committed receipt under `.maintenance-state/archive-root-relocations/`. Repeated relocations and intervening source refreshes must form one unbranched chain through typed predecessor authority and exact before/after manifest hashes. A prepared receipt blocks daemon startup and prints a shell-quoted exact retained-plan resume command. Live application and post-move observation remain operator evidence outside this code path. For a deployed archive, run these commands only from the Nix package built from the post-merge commit selected for deployment. Record that merge SHA and the resulting Nix store path in the operator receipt, verify the daemon executable resolves to that exact package, and keep `POLYLOGUE_ARCHIVE_ROOT` set to the configured deployed root. Do not resume a stopped daemon with an older deployed package or a branch checkout: its durable-train vocabulary may predate the relocation transition. diff --git a/docs/maintenance.md b/docs/maintenance.md index 0bd7fcf2f6..9115d87523 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -41,7 +41,7 @@ command's migration result alone. ## Relocating an archive root -Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. `--old-root` names the retired pre-move root for the identity transition and active-index pointer mapping. Create a fresh verified `full_evidence` backup after setting `POLYLOGUE_ARCHIVE_ROOT` to the moved root. The relocation plan authenticates that backup against the moved root and revalidates its device/inode inventory there; it never asks a moved-root backup to authenticate the nonexistent retired path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence and CAS-revises only the released `source`, `user`, and `audit` durable-train manifests that require relocation authority, together with the retained exact plan and prepared/committed receipts. It never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. +Use `ops maintenance archive-root-relocation` only after an offline inode-preserving root move. `--old-root` names the retired pre-move root for the identity transition and active-index pointer mapping. Create a fresh verified `full_evidence` backup after setting `POLYLOGUE_ARCHIVE_ROOT` to the moved root. The relocation plan authenticates that backup against the moved root and revalidates its device/inode inventory there; it never asks a moved-root backup to authenticate the nonexistent retired path. A current source train with post-release source content must first have receipt-backed source-continuity authority; relocation verifies and rebinds that authority but never creates it. Planning is read-only. Applying revalidates all evidence, CAS-revises only the released `source`, `user`, and `audit` durable-train manifests that require relocation authority, and remaps every retained index generation's sealed `archive_root`, `index_path`, and absolute tier symlinks before publishing the active pointer. Mixed exact before/after generation states are accepted only for this plan's crash resume. The exact plan and prepared/committed receipts remain retained. Apply never opens SQLite read-write, changes a row, rebuilds, reindexes, or repairs startup state. ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance archive-root-relocation plan --old-root /old/archive/root --backup-manifest /path/to/manifest.json --output /safe/relocation-plan.json --output-format json @@ -61,6 +61,8 @@ POLYLOGUE_ARCHIVE_ROOT=/new/archive/root polylogue ops maintenance source-contin After this bridge commits, create and verify a fresh `full_evidence` backup at the moved root. Use that moved-root manifest with the separate archive-root-relocation plan/apply transition while `--old-root` continues to name the retired pre-move root. Before publishing a prepared bridge receipt, apply retains the exact sealed plan under `.maintenance-state/historical-source-continuity-recovery-plans/`; the blocking daemon error names that retained path in its executable resume command. +Later authenticated source maintenance writes a typed refresh receipt that binds its predecessor authority and the exact durable-train manifest hashes before and after the refresh. Repeated relocations and intervening refreshes therefore validate as one unbranched transition chain ending at the exact current manifest; matching only the current source hash or archive identity is not authority. + ### Rebuild deployment-currency preflight Before a managed `rebuild-index`, confirm that the package selected for the diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index 4e59edab3e..c7005fa364 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -215,29 +215,62 @@ def _json_str_list(value: object) -> list[str]: def _all_archive_tiers(root: Path) -> dict[str, Path]: tiers = archive_tier_paths(root) - index = tiers["index"] - if index.is_symlink() and not index.exists(): - target = Path(os.readlink(index)) - pointer = root / ".index-active-pointer" - if not target.is_absolute() or pointer.is_symlink() or not pointer.is_file(): - return tiers - try: - configured_target = Path(pointer.read_text(encoding="utf-8").strip()) - relative = target.relative_to(configured_target.parent) - except (OSError, ValueError): - return tiers - if not configured_target.is_absolute() or configured_target.name != "index.db": - return tiers - mapped = root / relative - if ( - len(relative.parts) < 3 - or relative.parts[0] != ".index-generations" - or relative.parts[-1] != "index.db" - or not mapped.is_file() - or mapped.is_symlink() + pointer = root / ".index-active-pointer" + if pointer.is_symlink() or not pointer.is_file(): + return tiers + try: + configured_target = Path(pointer.read_text(encoding="utf-8").strip()) + except OSError: + return tiers + if not configured_target.is_absolute() or configured_target.name != "index.db": + return tiers + if ( + configured_target.is_relative_to(root.absolute()) + and configured_target.is_file() + and configured_target.resolve().is_relative_to(root.resolve()) + ): + tiers["index"] = configured_target + return tiers + + # An inode-preserving root move leaves the absolute pointer and the + # promoted conventional symlink carrying the retired root until the + # relocation operation publishes their mapped forms. Locate the unique + # conventional symlink paired with that pointer, including a canonical + # index below the archive root rather than assuming ``root/index.db``. + mapped_candidates: list[tuple[int, Path]] = [] + for conventional in root.rglob("index.db"): + relative_conventional = conventional.relative_to(root) + if ".index-generations" in relative_conventional.parts: + continue + relative_parts = relative_conventional.parts + if len(relative_parts) > len(configured_target.parts) or configured_target.parts[-len(relative_parts) :] != ( + relative_parts ): - return tiers - tiers["index"] = mapped + continue + if conventional.is_file() and not conventional.is_symlink(): + mapped_candidates.append((len(relative_parts), conventional)) + continue + if conventional.is_symlink(): + target = Path(os.readlink(conventional)) + if not target.is_absolute(): + continue + try: + relative = target.relative_to(configured_target.parent) + except ValueError: + continue + mapped = conventional.parent / relative + if ( + len(relative.parts) >= 3 + and relative.parts[0] == ".index-generations" + and relative.parts[-1] == "index.db" + and mapped.is_file() + and not mapped.is_symlink() + ): + mapped_candidates.append((len(relative_parts), mapped)) + longest_suffix = max((length for length, _path in mapped_candidates), default=0) + unique_candidates = tuple(dict.fromkeys(path for length, path in mapped_candidates if length == longest_suffix)) + if len(unique_candidates) == 1: + tiers["index"] = unique_candidates[0] return tiers diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index a40439a539..948b3f2e18 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -5,6 +5,7 @@ import hashlib import json import os +import shlex import sqlite3 import stat import tempfile @@ -58,7 +59,7 @@ ) from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec -PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v2"] = "polylogue.archive-root-relocation-plan.v2" +PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v3"] = "polylogue.archive-root-relocation-plan.v3" RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = "polylogue.archive-root-relocation-receipt.v1" _TIER_NAMES = tuple(tier.value for tier in ArchiveTier) _DURABLE_TIER_NAMES = ("source", "user", "audit") @@ -116,10 +117,36 @@ class RelocationActiveIndexPointer(BaseModel): inode: int +class RelocationIndexGenerationSymlink(BaseModel): + """One generation-owned tier link whose absolute target moves with the root.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + path: str + old_target: str + new_target: str + + +class RelocationIndexGeneration(BaseModel): + """Exact before/after authority for retained index-generation metadata.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + generation_id: str + metadata_path: str + before_sha256: str + after_sha256: str + before_archive_root: str + after_archive_root: str + before_index_path: str + after_index_path: str + tier_symlinks: tuple[RelocationIndexGenerationSymlink, ...] + + class ArchiveRootRelocationPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - format: Literal["polylogue.archive-root-relocation-plan.v2"] = PLAN_FORMAT + format: Literal["polylogue.archive-root-relocation-plan.v3"] = PLAN_FORMAT old_configured_root: str old_resolved_root: str backup_root_device: int @@ -136,6 +163,7 @@ class ArchiveRootRelocationPlan(BaseModel): backup_tier_inventory: tuple[str, ...] tiers: tuple[RelocationTierEvidence, ...] active_index_pointer: RelocationActiveIndexPointer | None + index_generations: tuple[RelocationIndexGeneration, ...] durable_trains: tuple[RelocationDurableTrain, ...] stopped_daemon_evidence_ref: str single_writer_evidence_ref: str @@ -379,6 +407,265 @@ def _active_index_pointer_evidence(*, old_root: Path, new_root: Path) -> Relocat ) +_INDEX_GENERATION_TIER_LINKS = ("source.db", "user.db", "embeddings.db", "ops.db", "blob") + + +def _index_generation_metadata_bytes(payload: dict[str, object]) -> bytes: + """Match ``IndexGenerationStore._write``'s stable persisted representation.""" + return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + + +def _mapped_generation_path(value: object, *, old_root: Path, new_root: Path, label: str) -> tuple[str, str]: + if not isinstance(value, str): + raise ArchiveRootRelocationError(f"archive-root relocation index generation has invalid {label}") + path = Path(value) + if not path.is_absolute(): + raise ArchiveRootRelocationError(f"archive-root relocation index generation has non-absolute {label}") + if path.is_relative_to(old_root): + return value, str(new_root / path.relative_to(old_root)) + if path.is_relative_to(new_root): + return value, value + raise ArchiveRootRelocationError(f"archive-root relocation index generation {label} is not root-owned") + + +def _index_generations_root( + root: Path, + active_index_pointer: RelocationActiveIndexPointer | None, +) -> Path: + """Return the generation store paired with the plan's canonical index path.""" + canonical_index = Path(active_index_pointer.new_target) if active_index_pointer is not None else root / "index.db" + try: + canonical_index.relative_to(root) + except ValueError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation canonical index path escapes the destination root" + ) from exc + return canonical_index.parent / ".index-generations" + + +def _index_generation_evidence( + *, + old_root: Path, + new_root: Path, + active_index_pointer: RelocationActiveIndexPointer | None, +) -> tuple[RelocationIndexGeneration, ...]: + """Seal every retained generation's absolute metadata and tier links.""" + generations_root = _index_generations_root(new_root, active_index_pointer) + if not generations_root.exists() and not generations_root.is_symlink(): + return () + _real_directory(generations_root, label="index generations root") + rows: list[RelocationIndexGeneration] = [] + for generation_root in sorted(generations_root.glob("gen-*")): + _real_directory(generation_root, label="index generation") + metadata_path = generation_root / "generation.json" + _real_file(metadata_path, label="index generation metadata") + try: + encoded = metadata_path.read_bytes() + raw = json.loads(encoded) + except (OSError, json.JSONDecodeError) as exc: + raise ArchiveRootRelocationError("cannot read index generation metadata") from exc + if not isinstance(raw, dict): + raise ArchiveRootRelocationError("index generation metadata is not an object") + payload = cast(dict[str, object], raw) + generation_id = payload.get("generation_id") + if generation_id != generation_root.name: + raise ArchiveRootRelocationError("index generation metadata does not bind its directory") + before_archive_root, after_archive_root = _mapped_generation_path( + payload.get("archive_root"), old_root=old_root, new_root=new_root, label="archive root" + ) + if Path(after_archive_root) != new_root: + raise ArchiveRootRelocationError("index generation metadata archive root is not the destination root") + before_index_path, after_index_path = _mapped_generation_path( + payload.get("index_path"), old_root=old_root, new_root=new_root, label="index path" + ) + if Path(after_index_path) != generation_root / "index.db": + raise ArchiveRootRelocationError("index generation metadata index path does not bind its generation") + after_payload = { + **payload, + "archive_root": after_archive_root, + "index_path": after_index_path, + } + links: list[RelocationIndexGenerationSymlink] = [] + for filename in _INDEX_GENERATION_TIER_LINKS: + link = generation_root / filename + if not link.exists() and not link.is_symlink(): + continue + try: + metadata = link.lstat() + if not stat.S_ISLNK(metadata.st_mode): + raise ArchiveRootRelocationError("index generation tier member is not a symbolic link") + old_target = os.readlink(link) + except OSError as exc: + raise ArchiveRootRelocationError("cannot read index generation tier link") from exc + raw_target = Path(old_target) + if not raw_target.is_absolute(): + raise ArchiveRootRelocationError("index generation tier link target is not absolute") + _before, new_target = _mapped_generation_path( + old_target, + old_root=old_root, + new_root=new_root, + label=f"{filename} link target", + ) + if Path(new_target) != new_root / filename: + raise ArchiveRootRelocationError("index generation tier link does not bind its archive tier") + links.append( + RelocationIndexGenerationSymlink( + path=str(link), + old_target=old_target, + new_target=new_target, + ) + ) + rows.append( + RelocationIndexGeneration( + generation_id=generation_id, + metadata_path=str(metadata_path), + before_sha256=hashlib.sha256(encoded).hexdigest(), + after_sha256=hashlib.sha256(_index_generation_metadata_bytes(after_payload)).hexdigest(), + before_archive_root=before_archive_root, + after_archive_root=after_archive_root, + before_index_path=before_index_path, + after_index_path=after_index_path, + tier_symlinks=tuple(links), + ) + ) + return tuple(rows) + + +def _index_generation_payload_for_state( + item: RelocationIndexGeneration, *, after: bool, encoded: bytes +) -> dict[str, object]: + try: + raw = json.loads(encoded) + except json.JSONDecodeError as exc: + raise ArchiveRootRelocationError("archive-root relocation index generation metadata is unreadable") from exc + if not isinstance(raw, dict): + raise ArchiveRootRelocationError("archive-root relocation index generation metadata is not an object") + payload = cast(dict[str, object], raw) + expected_root = item.after_archive_root if after else item.before_archive_root + expected_index = item.after_index_path if after else item.before_index_path + if ( + payload.get("generation_id") != item.generation_id + or payload.get("archive_root") != expected_root + or payload.get("index_path") != expected_index + ): + raise ArchiveRootRelocationError("archive-root relocation index generation metadata binding changed") + return payload + + +def _validate_index_generation_state( + root: Path, + items: tuple[RelocationIndexGeneration, ...], + active_index_pointer: RelocationActiveIndexPointer | None, +) -> None: + generations_root = _index_generations_root(root, active_index_pointer) + if generations_root.exists() or generations_root.is_symlink(): + _real_directory(generations_root, label="index generations root") + current_paths = ( + {str(path / "generation.json") for path in generations_root.glob("gen-*")} + if generations_root.is_dir() and not generations_root.is_symlink() + else set() + ) + expected_paths = {item.metadata_path for item in items} + if current_paths != expected_paths: + raise ArchiveRootRelocationError("archive-root relocation index generation inventory changed") + for item in items: + metadata_path = Path(item.metadata_path) + _real_directory(metadata_path.parent, label="index generation") + _real_file(metadata_path, label="index generation metadata") + encoded = metadata_path.read_bytes() + digest = hashlib.sha256(encoded).hexdigest() + if digest == item.before_sha256: + _index_generation_payload_for_state(item, after=False, encoded=encoded) + elif digest == item.after_sha256: + _index_generation_payload_for_state(item, after=True, encoded=encoded) + else: + raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") + expected_link_paths = {link.path for link in item.tier_symlinks} + current_link_paths = { + str(metadata_path.parent / filename) + for filename in _INDEX_GENERATION_TIER_LINKS + if (metadata_path.parent / filename).exists() or (metadata_path.parent / filename).is_symlink() + } + if current_link_paths != expected_link_paths: + raise ArchiveRootRelocationError("archive-root relocation index generation tier inventory changed") + for link in item.tier_symlinks: + path = Path(link.path) + try: + metadata = path.lstat() + if not stat.S_ISLNK(metadata.st_mode): + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + target = os.readlink(path) + except OSError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation index generation tier link is unreadable" + ) from exc + if target not in {link.old_target, link.new_target}: + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + + +def _publish_index_generation_state( + root: Path, + items: tuple[RelocationIndexGeneration, ...], + active_index_pointer: RelocationActiveIndexPointer | None, +) -> None: + """CAS-publish mapped metadata and links; exact after states are idempotent.""" + _validate_index_generation_state(root, items, active_index_pointer) + for item in items: + metadata_path = Path(item.metadata_path) + encoded = metadata_path.read_bytes() + digest = hashlib.sha256(encoded).hexdigest() + if digest == item.before_sha256 and item.before_sha256 != item.after_sha256: + payload = _index_generation_payload_for_state(item, after=False, encoded=encoded) + after_payload = { + **payload, + "archive_root": item.after_archive_root, + "index_path": item.after_index_path, + } + after_encoded = _index_generation_metadata_bytes(after_payload) + if hashlib.sha256(after_encoded).hexdigest() != item.after_sha256: + raise ArchiveRootRelocationError("archive-root relocation index generation after binding changed") + with tempfile.NamedTemporaryFile( + dir=metadata_path.parent, + prefix=f".{metadata_path.name}.relocation-", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(after_encoded) + stream.flush() + os.fsync(stream.fileno()) + try: + os.replace(temporary, metadata_path) + directory_fd = os.open(metadata_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + temporary.unlink(missing_ok=True) + for link in item.tier_symlinks: + if link.old_target == link.new_target: + continue + path = Path(link.path) + current = os.readlink(path) + if current == link.new_target: + continue + if current != link.old_target: + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + temporary = path.parent / f".{path.name}.relocation-{uuid.uuid4().hex}.tmp" + try: + os.symlink(link.new_target, temporary) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + temporary.unlink(missing_ok=True) + _validate_index_generation_state(root, items, active_index_pointer) + + def _validate_active_index_pointer( root: Path, pointer: RelocationActiveIndexPointer | None, @@ -552,11 +839,19 @@ def _durable_trains( for item in snapshots ) index_identity = next(item for item in tier_identities if item.name == "index") - legacy_identity = ArchiveIdentity( + legacy_active_identity = ArchiveIdentity( configured_root=old_root, tiers=tier_identities, active_generation=index_identity.stable_id, ).authority_identity_digest + configured_location = ArchiveLocation.resolve(root) + configured_index_identity = configured_location.configured_tier("index") + legacy_configured_identity = ArchiveIdentity( + configured_root=old_root, + tiers=configured_location.configured_tiers, + active_generation=configured_index_identity.stable_id, + ).authority_identity_digest + accepted_legacy_identities = {legacy_active_identity, legacy_configured_identity} snapshots_by_tier = {item.tier: item for item in snapshots} trains: list[RelocationDurableTrain] = [] for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): @@ -608,7 +903,7 @@ def _durable_trains( "archive-root relocation source continuity authority is invalid" ) from exc before_identity = train.apply_evidence.post.archive_identity_digest - if before_identity not in {after_identity_digest, legacy_identity}: + if before_identity != after_identity_digest and before_identity not in accepted_legacy_identities: raise ArchiveRootRelocationError( f"archive-root relocation {tier.value} train does not authenticate the moved tier identity" ) @@ -726,6 +1021,11 @@ def prepare_archive_root_relocation( ) backup_tier_identities = _authenticated_backup_tier_identities(manifest) active_index_pointer = _active_index_pointer_evidence(old_root=old_resolved, new_root=new_resolved) + index_generations = _index_generation_evidence( + old_root=old_resolved, + new_root=new_resolved, + active_index_pointer=active_index_pointer, + ) snapshots = tuple( _tier_snapshot( new_resolved, @@ -767,6 +1067,7 @@ def prepare_archive_root_relocation( backup_tier_inventory=tuple(sorted(f"{tier}.db" for tier in _TIER_NAMES)), tiers=snapshots, active_index_pointer=active_index_pointer, + index_generations=index_generations, durable_trains=trains, stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, single_writer_evidence_ref=single_writer_evidence_ref, @@ -887,14 +1188,21 @@ def load_archive_root_relocation_receipt(path: Path) -> ArchiveRootRelocationRec def assert_no_prepared_archive_root_relocation(root: Path) -> None: try: - with existing_maintenance_receipt_directory(root, "archive-root-relocations") as directory_fd: + receipt_root = root.resolve(strict=True) + except OSError as exc: + raise ArchiveRootRelocationError(f"cannot resolve archive-root relocation archive root: {root}") from exc + try: + with existing_maintenance_receipt_directory(receipt_root, "archive-root-relocations") as directory_fd: if directory_fd is None: return receipts = tuple(iter_pinned_receipts(directory_fd)) except MaintenanceReceiptPathError as exc: raise ArchiveRootRelocationError(f"unsafe archive-root relocation receipt directory: {exc}") from exc for filename, encoded in receipts: - receipt = _decode_receipt(encoded, path=root / ".maintenance-state" / "archive-root-relocations" / filename) + receipt = _decode_receipt( + encoded, + path=receipt_root / ".maintenance-state" / "archive-root-relocations" / filename, + ) if receipt.state == "prepared": raise ArchiveRootRelocationError( "archive-root relocation is prepared but incomplete; rerun " + receipt.resume_command @@ -1053,6 +1361,7 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) _validate_active_index_pointer(root, plan.active_index_pointer) + _validate_index_generation_state(root, plan.index_generations, plan.active_index_pointer) pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) allowed_pending_relocation_receipt_sha256 = ( (pending_receipt.prepared_receipt_sha256 or pending_receipt.receipt_sha256) @@ -1162,8 +1471,9 @@ def _apply_archive_root_relocation_locked( receipt_path = _receipt_path(root, plan) retained_plan_path = _retain_plan(root, plan) command = ( - f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance archive-root-relocation " - f"apply --plan {retained_plan_path} --authorize {plan.plan_sha256} --output-format json" + f"POLYLOGUE_ARCHIVE_ROOT={shlex.quote(plan.new_configured_root)} polylogue ops maintenance " + f"archive-root-relocation apply --plan {shlex.quote(str(retained_plan_path))} " + f"--authorize {plan.plan_sha256} --output-format json" ) before_hashes = tuple(item.before_manifest_sha256 for item in plan.durable_trains) pointer_fields = _pointer_receipt_fields(plan.active_index_pointer) @@ -1238,6 +1548,7 @@ def _apply_archive_root_relocation_locked( ) _write_receipt(receipt_path, bound_prepared, expected=receipt.receipt_sha256) receipt = bound_prepared + _publish_index_generation_state(root, plan.index_generations, plan.active_index_pointer) _publish_active_index_pointer(root, plan.active_index_pointer) after_hashes: list[str] = [] for index, item in enumerate(plan.durable_trains): diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index fcdaddfa54..273dc66318 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -12,6 +12,7 @@ import io import json import os +import shlex import sqlite3 import stat import tempfile @@ -1053,7 +1054,15 @@ def load_historical_source_continuity_recovery_receipt(path: Path) -> Historical def assert_no_prepared_historical_source_continuity_recovery(root: Path) -> None: try: - with existing_maintenance_receipt_directory(root, "historical-source-continuity-recoveries") as directory_fd: + receipt_root = root.resolve(strict=True) + except OSError as exc: + raise HistoricalSourceContinuityRecoveryError( + f"cannot resolve historical continuity recovery archive root: {root}" + ) from exc + try: + with existing_maintenance_receipt_directory( + receipt_root, "historical-source-continuity-recoveries" + ) as directory_fd: if directory_fd is None: return receipts = tuple(iter_pinned_receipts(directory_fd)) @@ -1124,10 +1133,14 @@ def _revalidate( if not _evidence_matches_plan(current, plan.source_after) or current.content_sha256 != post.content_sha256: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery current source changed") train = load_durable_change_train_manifest(Path(plan.source_train_path)) - if _sha256(Path(plan.source_train_path)) != plan.source_train_sha256 and train.source_continuity_evidence is None: - raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source train changed") - if train.source_continuity_evidence is None: + train_sha256 = _sha256(Path(plan.source_train_path)) + if train_sha256 == plan.source_train_sha256: _assert_pre_train_authority(Path(plan.source_train_path), pre) + else: + if train.source_continuity_evidence is None: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source train changed") + _validate_source_continuity_refresh_receipt(root, train) + _validate_exact_refresh_binding(root, plan, train) if _census(root) != plan.census: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery liveness census changed") return current @@ -1188,8 +1201,8 @@ def _apply_historical_source_continuity_recovery_locked( refresh_path = _refresh_path(resolved, refresh_digest) retained_plan_path = _retain_plan(resolved, plan) command = ( - f"POLYLOGUE_ARCHIVE_ROOT={plan.new_configured_root} polylogue ops maintenance " - f"source-continuity-recovery apply --plan {retained_plan_path} " + f"POLYLOGUE_ARCHIVE_ROOT={shlex.quote(plan.new_configured_root)} polylogue ops maintenance " + f"source-continuity-recovery apply --plan {shlex.quote(str(retained_plan_path))} " f"--authorize {plan.plan_sha256} --output-format json" ) receipt_path = _receipt_path(resolved, plan) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index c209bb1ab2..1c130dc3f7 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -83,6 +83,9 @@ _MIGRATION_NAME_RE = re.compile(r"^(?P\d{3,})_[a-z0-9_]+\.sql$") _DROP_SQL_RE = re.compile(r"(?is)\bDROP\s+(?:TABLE|INDEX|TRIGGER|VIEW)\b") _SOURCE_CONTINUITY_PENDING_FORMAT = "polylogue.source-continuity-pending.v1" +_SOURCE_CONTINUITY_REFRESH_V1_FORMAT = "polylogue.source-continuity-refresh.v1" +_SOURCE_CONTINUITY_REFRESH_V2_FORMAT = "polylogue.source-continuity-refresh.v2" +_SOURCE_CONTINUITY_REFRESH_INTENT_REF = "proof:source-continuity-refresh:pending-receipt" _SOURCE_CONTINUITY_RELOCATION_FORMAT = "polylogue.source-continuity-relocation.v2" _SourceContinuityMutationKind = Literal["blob_ref_liveness", "raw_authority_recovery"] _SourceContinuityAuthorityKind = Literal["refresh", "relocation"] @@ -119,6 +122,47 @@ class _SourceContinuityAuthorityNode: source_after: object +def _durable_train_manifest_sha256(train: DurableChangeTrain) -> str: + payload = durable_change_train_to_payload(train) + encoded = (json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _finalize_source_continuity_refresh_intent( + intent: DurableChangeTrain, *, refresh_digest: str +) -> DurableChangeTrain: + refresh_ref = f"proof:source-continuity-refresh:{refresh_digest}" + if intent.proof_refs.count(_SOURCE_CONTINUITY_REFRESH_INTENT_REF) != 1: + raise DurableChangeTrainError("source continuity refresh intent has invalid receipt placeholder") + finalized = replace( + intent, + proof_refs=tuple( + refresh_ref if ref == _SOURCE_CONTINUITY_REFRESH_INTENT_REF else ref for ref in intent.proof_refs + ), + ) + validate_durable_change_train_manifest(finalized) + return finalized + + +def _source_continuity_refresh_intent(payload: dict[str, object], *, train_id: str) -> DurableChangeTrain: + raw_intent = payload.get("train_after_without_receipt") + if not isinstance(raw_intent, dict): + raise DurableChangeTrainError("source continuity refresh lacks exact train transition authority") + try: + intent = durable_change_train_from_payload(cast(dict[str, object], raw_intent)) + validate_durable_change_train_manifest(intent) + except (DurableChangeTrainError, TypeError, ValueError) as exc: + raise DurableChangeTrainError("source continuity refresh has invalid train transition authority") from exc + if ( + intent.train_id != train_id + or intent.source_continuity_evidence is None + or _migration_runner._manifest_json_value(intent.source_continuity_evidence) != payload.get("source_after") + or intent.proof_refs.count(_SOURCE_CONTINUITY_REFRESH_INTENT_REF) != 1 + ): + raise DurableChangeTrainError("source continuity refresh train transition authority changed") + return intent + + @dataclass(frozen=True, slots=True) class DurableMigrationSidecar: """A deterministic package resource binding one SQL slot to its train.""" @@ -1169,41 +1213,59 @@ def _validate_source_continuity_refresh_receipt( predecessors: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} successor_by_authority: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} - for ref, payload in relocation_payloads.items(): + + def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, object], *, required: bool) -> None: raw_predecessor = payload.get("predecessor_authority") + if raw_predecessor is None and not required: + return if not isinstance(raw_predecessor, dict) or set(raw_predecessor) != {"kind", "sha256"}: - raise DurableChangeTrainError("source continuity relocation transition lacks typed predecessor authority") + raise DurableChangeTrainError("source continuity transition lacks typed predecessor authority") kind = raw_predecessor.get("kind") predecessor_digest = raw_predecessor.get("sha256") if kind not in {"refresh", "relocation"} or not isinstance(predecessor_digest, str): - raise DurableChangeTrainError("source continuity relocation transition has invalid predecessor authority") + raise DurableChangeTrainError("source continuity transition has invalid predecessor authority") predecessor = _SourceContinuityAuthorityRef(cast(_SourceContinuityAuthorityKind, kind), predecessor_digest) if predecessor in successor_by_authority: - raise DurableChangeTrainError("source continuity relocation authority branches ambiguously") + raise DurableChangeTrainError("source continuity authority branches ambiguously") predecessors[ref] = predecessor successor_by_authority[predecessor] = ref - for ref in relocation_payloads: + for digest, payload in refresh_payloads.items(): + if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: + register_predecessor(_SourceContinuityAuthorityRef("refresh", digest), payload, required=False) + for ref, payload in relocation_payloads.items(): + register_predecessor(ref, payload, required=True) + + transition_payloads = { + **{ + _SourceContinuityAuthorityRef("refresh", digest): payload + for digest, payload in refresh_payloads.items() + if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT + and payload.get("predecessor_authority") is not None + }, + **relocation_payloads, + } + for ref in transition_payloads: if ref in nodes: - continue + if ref not in predecessors: + continue + nodes.pop(ref) trail: list[_SourceContinuityAuthorityRef] = [] trail_refs: set[_SourceContinuityAuthorityRef] = set() current = ref while current not in nodes: if current in trail_refs: - raise DurableChangeTrainError("source continuity relocation authority contains a cycle") - if current not in relocation_payloads: - raise DurableChangeTrainError("source continuity relocation transition lacks its retained predecessor") + raise DurableChangeTrainError("source continuity authority contains a cycle") + if current not in transition_payloads or current not in predecessors: + raise DurableChangeTrainError("source continuity transition lacks its retained predecessor") trail.append(current) trail_refs.add(current) current = predecessors[current] predecessor_node = nodes[current] for transition_ref in reversed(trail): - payload = relocation_payloads[transition_ref] + payload = transition_payloads[transition_ref] if payload.get("source_before") != predecessor_node.source_after: - raise DurableChangeTrainError( - "source continuity relocation transition does not preserve predecessor authority" - ) + raise DurableChangeTrainError("source continuity transition does not preserve predecessor authority") node = _SourceContinuityAuthorityNode( ref=transition_ref, source_after=payload.get("source_after"), @@ -1217,7 +1279,17 @@ def _validate_source_continuity_refresh_receipt( ] if len(matching_authorities) != 1: raise DurableChangeTrainError("source continuity evidence does not identify exactly one terminal authority") - return matching_authorities[0] + terminal = matching_authorities[0] + if terminal.kind == "refresh": + terminal_payload = refresh_payloads[terminal.sha256] + if terminal_payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: + intent = _source_continuity_refresh_intent(terminal_payload, train_id=train.train_id) + expected = _finalize_source_continuity_refresh_intent(intent, refresh_digest=terminal.sha256) + if durable_change_train_to_payload(expected) != durable_change_train_to_payload(train): + raise DurableChangeTrainError( + "source continuity refresh does not bind the exact current train manifest" + ) + return terminal def _read_source_continuity_refresh_receipt( @@ -1243,12 +1315,25 @@ def _read_source_continuity_refresh_receipt( raise DurableChangeTrainError(f"source continuity refresh receipt is not an object: {receipt_path}") payload = cast(dict[str, object], raw) refresh_sha256 = payload.pop("refresh_sha256", None) - if refresh_sha256 != digest or _canonical_json_sha256(payload) != digest: + receipt_format = payload.get("format") + if receipt_format == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT: + valid_checksum = _canonical_json_sha256(payload) == digest + elif receipt_format == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: + valid_checksum = ( + _canonical_json_sha256(payload) == digest + and isinstance(payload.get("train_before_sha256"), str) + and isinstance(payload.get("train_after_without_receipt"), dict) + ) + else: + valid_checksum = False + if refresh_sha256 != digest or not valid_checksum: raise DurableChangeTrainError(f"source continuity refresh receipt checksum mismatch: {receipt_path}") - if payload.get("format") != "polylogue.source-continuity-refresh.v1": + if receipt_format not in {_SOURCE_CONTINUITY_REFRESH_V1_FORMAT, _SOURCE_CONTINUITY_REFRESH_V2_FORMAT}: raise DurableChangeTrainError(f"source continuity refresh receipt format mismatch: {receipt_path}") if payload.get("train_id") != train.train_id: raise DurableChangeTrainError(f"source continuity refresh receipt train mismatch: {receipt_path}") + if receipt_format == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: + _source_continuity_refresh_intent(payload, train_id=train.train_id) return payload @@ -1386,25 +1471,47 @@ def _validate_archive_root_relocation_receipts( item.after_archive_identity_digest, ) ) + for ref in train.proof_refs: + if not ref.startswith("proof:source-continuity-refresh:"): + continue + digest = ref.removeprefix("proof:source-continuity-refresh:") + payload = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) + if payload.get("format") != _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: + continue + before = payload.get("train_before_sha256") + intent_payload = payload.get("train_after_without_receipt") + source_after = payload.get("source_after") + identity = source_after.get("archive_identity_digest") if isinstance(source_after, dict) else None + if not isinstance(before, str) or not isinstance(intent_payload, dict) or not isinstance(identity, str): + raise DurableChangeTrainError("source continuity refresh lacks exact train transition authority") + intent = _source_continuity_refresh_intent(payload, train_id=train.train_id) + after = _durable_train_manifest_sha256( + _finalize_source_continuity_refresh_intent(intent, refresh_digest=digest) + ) + _migration_runner._validate_sha256(before, label="source continuity refresh before manifest") + _migration_runner._validate_sha256(identity, label="source continuity refresh archive identity") + transitions.append((before, after, identity)) by_before = {before: (after, identity) for before, after, identity in transitions} if len(by_before) != len(transitions): - raise DurableChangeTrainError("archive-root relocation proof chain branches ambiguously") + raise DurableChangeTrainError("archive-root relocation and refresh proof chain branches ambiguously") after_hashes = {after for _before, after, _identity in transitions} roots = [before for before in by_before if before not in after_hashes] if len(roots) != 1: - raise DurableChangeTrainError("archive-root relocation proof chain has no unique predecessor") + raise DurableChangeTrainError("archive-root relocation and refresh proof chain has no unique predecessor") visited: set[str] = set() current_hash = roots[0] latest_identity: str | None = None while current_hash in by_before: if current_hash in visited: - raise DurableChangeTrainError("archive-root relocation proof chain contains a cycle") + raise DurableChangeTrainError("archive-root relocation and refresh proof chain contains a cycle") visited.add(current_hash) current_hash, latest_identity = by_before[current_hash] current_payload = durable_change_train_to_payload(train) current_encoded = (json.dumps(current_payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode() if len(visited) != len(transitions) or hashlib.sha256(current_encoded).hexdigest() != current_hash: - raise DurableChangeTrainError("archive-root relocation proof chain does not bind the exact current manifest") + raise DurableChangeTrainError( + "archive-root relocation and refresh proof chain does not bind the exact current manifest" + ) if train.apply_evidence is None or latest_identity != train.apply_evidence.post.archive_identity_digest: raise DurableChangeTrainError("archive-root relocation proof does not bind the latest durable identity") @@ -1613,7 +1720,7 @@ def _refresh_released_source_train_continuity_locked( # evidence, authenticates this idempotent completion. if ( current_matches_retained - and existing.get("source_before") == serialized_before + and existing.get("observed_source_before", existing.get("source_before")) == serialized_before and existing.get("source_after") == serialized_retained_current ): _validate_source_continuity_refresh_receipt(archive_root, train) @@ -1657,8 +1764,40 @@ def _refresh_released_source_train_continuity_locked( "source continuity refresh requires successful quick_check evidence" ) + predecessor = ( + _validate_source_continuity_refresh_receipt(archive_root, train) + if train.source_continuity_evidence is not None + else None + ) + retained_apply_evidence = train.apply_evidence + legacy_archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest + if ( + retained_apply_evidence.post.archive_identity_digest == legacy_archive_identity_digest + and current.archive_identity_digest != retained_apply_evidence.post.archive_identity_digest + ): + retained_apply_evidence = replace( + retained_apply_evidence, + post=replace( + retained_apply_evidence.post, + archive_identity_digest=current.archive_identity_digest, + ), + ) + if _SOURCE_CONTINUITY_REFRESH_INTENT_REF in train.proof_refs: + raise DurableChangeTrainError("source continuity refresh train retains an unfinished receipt intent") + references_without_receipt = _migration_runner._append_proof_refs( + train.proof_refs, evidence_ref, _SOURCE_CONTINUITY_REFRESH_INTENT_REF + ) + if train.proof is None: + raise DurableChangeTrainError("source continuity refresh requires train proof") + intent = replace( + train, + revision=train.revision + 1, + apply_evidence=retained_apply_evidence, + source_continuity_evidence=current, + proof_refs=references_without_receipt, + ) payload = { - "format": "polylogue.source-continuity-refresh.v1", + "format": _SOURCE_CONTINUITY_REFRESH_V2_FORMAT, "operation_id": operation_id, "evidence_ref": evidence_ref, "backup_manifest": str(backup_manifest), @@ -1666,11 +1805,18 @@ def _refresh_released_source_train_continuity_locked( "mutation_receipt": str(mutation_receipt), "mutation_receipt_sha256": mutation_digest, "train_id": train.train_id, - "source_before": serialized_before, + "predecessor_authority": ( + None if predecessor is None else {"kind": predecessor.kind, "sha256": predecessor.sha256} + ), + "source_before": _migration_runner._manifest_json_value(baseline), + "observed_source_before": serialized_before, "source_after": serialized_current, "refreshed_at_ms": current.observed_at_ms, + "train_before_sha256": _durable_train_manifest_sha256(train), + "train_after_without_receipt": durable_change_train_to_payload(intent), } refresh_digest = _canonical_json_sha256(payload) + updated = _finalize_source_continuity_refresh_intent(intent, refresh_digest=refresh_digest) refresh_root_existed = refresh_root.is_dir() refresh_root.mkdir(parents=True, exist_ok=True) if not refresh_root_existed: @@ -1706,30 +1852,6 @@ def _refresh_released_source_train_continuity_locked( finally: if temporary is not None: temporary.unlink(missing_ok=True) - refresh_ref = f"proof:source-continuity-refresh:{refresh_digest}" - references = _migration_runner._append_proof_refs(train.proof_refs, evidence_ref, refresh_ref) - if train.proof is None: - raise DurableChangeTrainError("source continuity refresh requires train proof") - retained_apply_evidence = train.apply_evidence - legacy_archive_identity_digest = ArchiveIdentity.resolve(archive_root).authority_identity_digest - if ( - retained_apply_evidence.post.archive_identity_digest == legacy_archive_identity_digest - and current.archive_identity_digest != retained_apply_evidence.post.archive_identity_digest - ): - retained_apply_evidence = replace( - retained_apply_evidence, - post=replace( - retained_apply_evidence.post, - archive_identity_digest=current.archive_identity_digest, - ), - ) - updated = replace( - train, - revision=train.revision + 1, - apply_evidence=retained_apply_evidence, - source_continuity_evidence=current, - proof_refs=references, - ) write_durable_change_train_manifest(manifest_path, updated, expected_revision=train.revision) return refresh_path diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 662e787456..8dc14159c3 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4150,9 +4150,11 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( ) with pytest.raises(RuntimeError, match="leave prepared relocation receipt"): apply_archive_root_relocation(root=root, plan=plan, authorization=plan.plan_sha256) + configured_alias = tmp_path / "configured-archive-alias" + configured_alias.symlink_to(root, target_is_directory=True) configure = Mock() admission = Mock(wraps=assert_no_prepared_archive_root_relocation) - monkeypatch.setattr("polylogue.paths.archive_root", lambda: root) + monkeypatch.setattr("polylogue.paths.archive_root", lambda: configured_alias) monkeypatch.setattr( "polylogue.operations.archive_root_relocation.assert_no_prepared_archive_root_relocation", admission, @@ -4172,7 +4174,7 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( ) ) - admission.assert_called_once_with(root) + admission.assert_called_once_with(configured_alias) configure.assert_not_called() watcher = Mock() @@ -4181,7 +4183,7 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( CliRunner().invoke(main, ["watch"], catch_exceptions=False) assert admission.call_count == 2 - admission.assert_called_with(root) + admission.assert_called_with(configured_alias) watcher.assert_not_called() diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 3157b0d432..2764f0110e 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -6,6 +6,7 @@ import hashlib import json import os +import shlex import shutil import sqlite3 from collections.abc import Iterator @@ -74,8 +75,10 @@ load_durable_change_train_manifest, rebind_released_durable_train_archive_identity, recover_released_source_train_continuity, + refresh_released_source_train_continuity, ) from polylogue.storage.sqlite.migration_runner import ( + DurableDatabaseEvidence, _canonical_json_sha256, apply_durable_change_train, capture_durable_database_evidence, @@ -527,6 +530,97 @@ def _attach_retained_source_continuity(root: Path, manifest: Path) -> None: write_durable_change_train_manifest(manifest, recovered, expected_revision=train.revision) +def _refresh_source_continuity_without_content_change(root: Path, evidence_root: Path) -> Path: + """Exercise the ordinary authenticated refresh route after a relocation.""" + evidence_root.mkdir(parents=True) + with sqlite3.connect(root / "source.db") as connection: + before = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + backup_manifest = evidence_root / "refresh-backup-manifest.json" + backup_manifest.write_text("{}\n", encoding="utf-8") + operation_id = BlobRefLivenessCandidateDigest().hexdigest() + mutation_receipt = evidence_root / "refresh-mutation-receipt.jsonl" + mutation_receipt.write_text( + json.dumps( + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "prepared", + "source_db": str(root / "source.db"), + "backup_manifest": str(backup_manifest), + "candidate_count": 0, + "candidate_digest": operation_id, + "backup_manifest_sha256": hashlib.sha256(backup_manifest.read_bytes()).hexdigest(), + } + ) + + "\n" + + json.dumps( + { + "kind": "blob_ref_liveness_reconciliation", + "phase": "committed", + "deleted_count": 0, + "post_orphaned_count": 0, + } + ) + + "\n", + encoding="utf-8", + ) + return refresh_released_source_train_continuity( + root, + mutation_receipt=mutation_receipt, + backup_manifest=backup_manifest, + pre_mutation_evidence=before, + operation_id=operation_id, + evidence_ref="proof:post-relocation-source-maintenance", + ) + + +def _substitute_foreign_historical_refresh( + root: Path, + *, + plan: dict[str, object], + mutation_receipt: Path, + backup_manifest: Path, +) -> Path: + """Install a valid but non-plan-owned continuity revision for rejection tests.""" + train_path = Path(str(plan["source_train_path"])) + train = load_durable_change_train_manifest(train_path) + source_before = plan["source_before"] + source_after = plan["source_after"] + assert isinstance(source_before, dict) and isinstance(source_after, dict) + observed_at_ms = source_after.get("observed_at_ms") + assert type(observed_at_ms) is int + payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": "foreign", + "evidence_ref": "proof:foreign-continuity", + "backup_manifest": str(backup_manifest), + "backup_manifest_sha256": _sha256(backup_manifest), + "mutation_receipt": str(mutation_receipt), + "mutation_receipt_sha256": _sha256(mutation_receipt), + "train_id": train.train_id, + "source_before": source_before, + "source_after": source_after, + "refreshed_at_ms": observed_at_ms, + } + digest = _canonical_json_sha256(payload) + _write_refresh_receipt( + root / ".maintenance-state" / "source-continuity-refreshes" / f"{digest}.json", + {**payload, "refresh_sha256": digest}, + ) + substituted = recover_released_source_train_continuity( + train, + current_evidence=_evidence_from_payload(source_after), + proof_ref=f"proof:source-continuity-refresh:{digest}", + ) + write_durable_change_train_manifest(train_path, substituted, expected_revision=train.revision) + return train_path + + +def _evidence_from_payload(payload: dict[str, object]) -> DurableDatabaseEvidence: + from polylogue.operations.historical_source_continuity_recovery import _evidence_from_plan + + return _evidence_from_plan(payload) + + def _evidence_payload(evidence: object) -> dict[str, object]: from polylogue.operations.historical_source_continuity_recovery import _evidence_payload as render @@ -1172,6 +1266,9 @@ def test_historical_continuity_recovery_cli_recovers_pinned_fixture_and_resumes_ new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( workspace_env, tmp_path, monkeypatch ) + shell_sensitive_root = tmp_path / "moved root;still-one-argument" + os.rename(new_root, shell_sensitive_root) + new_root = shell_sensitive_root command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(new_root)} plan_path = tmp_path / "continuity-plan.json" with _test_historical_operation_evidence_resource(evidence): @@ -1238,7 +1335,8 @@ def crash_before_refresh(*_args: object, **_kwargs: object) -> None: new_root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan_sha256}.json" ).read_text(encoding="utf-8") ) - assert f"--plan {retained_plan}" in prepared_receipt["resume_command"] + assert f"POLYLOGUE_ARCHIVE_ROOT={shlex.quote(str(new_root))}" in prepared_receipt["resume_command"] + assert f"--plan {shlex.quote(str(retained_plan))}" in prepared_receipt["resume_command"] plan_path.unlink() with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): assert_no_prepared_historical_source_continuity_recovery(new_root) @@ -1569,6 +1667,9 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public old_root = workspace_env["archive_root"] manifest = _released_moved_source_train(old_root, monkeypatch) old_active_target = _activate_movable_index_generation(old_root) + active_generation_id = old_active_target.parent.name + old_store = IndexGenerationStore.for_archive_root(old_root) + inactive_generation = old_store.create(owner_id="relocation-paused", source_snapshot="paused-snapshot") _attach_retained_source_continuity(old_root, manifest) new_root = tmp_path / "moved" os.rename(old_root, new_root) @@ -1591,6 +1692,10 @@ def test_relocation_remaps_an_active_generation_pointer_and_resumes_after_public assert pointer.old_resolved_target == str(old_active_target) assert pointer.conventional_symlink_old_target == str(old_active_target) assert pointer.conventional_symlink_new_target == str(new_root / old_active_target.relative_to(old_root)) + assert {item.generation_id for item in plan.index_generations} == { + active_generation_id, + inactive_generation.generation_id, + } real_publish = relocation._publish_active_index_pointer real_write = os.write short_pointer_write = False @@ -1615,6 +1720,16 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) assert short_pointer_write assert (new_root / ".index-active-pointer").read_text(encoding="utf-8").strip() == pointer.new_target + crashed_store = IndexGenerationStore.for_archive_root(new_root) + for generation_id in (active_generation_id, inactive_generation.generation_id): + generation = crashed_store.load(generation_id) + assert generation.archive_root == str(new_root) + assert generation.index_path == str(new_root / ".index-generations" / generation_id / "index.db") + generation_root = new_root / ".index-generations" / generation_id + for filename in ("source.db", "user.db", "embeddings.db", "ops.db", "blob"): + link = generation_root / filename + if link.is_symlink(): + assert os.readlink(link) == str(new_root / filename) with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): assert_no_prepared_archive_root_relocation(new_root) @@ -1625,6 +1740,115 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo assert relocated_location.active_index_path == Path(pointer.new_target) assert relocated_location.active_index.resolved_path == Path(pointer.new_resolved_target) assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" + relocated_store = IndexGenerationStore.for_archive_root(new_root) + promoted = relocated_store.promote(relocated_store.load(inactive_generation.generation_id)) + assert promoted.state == "active" + assert (new_root / "index.db").resolve(strict=True) == Path(promoted.index_path) + + +def test_relocation_remaps_generations_beside_a_nested_active_index( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Relocation follows the canonical index parent to its generation store. + + Anti-vacuity: the production ``IndexGenerationStore`` derives its retained + generation directory from the active pointer's parent. Root-only + inventory leaves both generation metadata and tier links carrying the + retired root after this public plan/apply sequence. + """ + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + nested_index = old_root / "nested" / "index.db" + nested_index.parent.mkdir() + (old_root / ".index-active-pointer").write_text(str(nested_index), encoding="utf-8") + store = IndexGenerationStore.for_archive_root(old_root) + active = store.create(owner_id="nested-active", source_snapshot="active-snapshot") + store.promote(active) + inactive = store.create(owner_id="nested-inactive", source_snapshot="inactive-snapshot") + _attach_retained_source_continuity(old_root, manifest) + + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + expected_root = new_root / "nested" / ".index-generations" + assert {Path(item.metadata_path).parent.parent for item in plan.index_generations} == {expected_root} + assert {item.generation_id for item in plan.index_generations} == { + active.generation_id, + inactive.generation_id, + } + result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert result.state == "committed" + relocated_store = IndexGenerationStore.for_archive_root(new_root) + assert relocated_store.generations_root == expected_root + for generation_id in (active.generation_id, inactive.generation_id): + generation = relocated_store.load(generation_id) + assert generation.archive_root == str(new_root) + assert generation.index_path == str(expected_root / generation_id / "index.db") + generation_root = expected_root / generation_id + for filename in ("source.db", "user.db", "embeddings.db", "ops.db", "blob"): + link = generation_root / filename + if link.is_symlink(): + assert os.readlink(link) == str(new_root / filename) + promoted = relocated_store.promote(relocated_store.load(inactive.generation_id)) + assert promoted.state == "active" + assert (new_root / "nested" / "index.db").resolve(strict=True) == Path(promoted.index_path) + + +def test_relocation_backup_maps_a_nested_regular_active_index( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The moved-root backup follows a stale pointer to a regular in-root index. + + Anti-vacuity: the production backup must fingerprint the nested active + inode, not the regular shadow at ``root/index.db``. Relocation's real + backup identity comparison rejects the shadow inode before planning. + """ + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + nested_index = old_root / "nested" / "index.db" + nested_index.parent.mkdir() + shutil.copy2(old_root / "index.db", nested_index) + (old_root / ".index-active-pointer").write_text(str(nested_index), encoding="utf-8") + _attach_retained_source_continuity(old_root, manifest) + + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + backup_manifest = Path(backup.output_path) / "manifest.json" + backup_payload = json.loads(backup_manifest.read_text(encoding="utf-8")) + index_fingerprint = backup_payload["tier_source_fingerprints"]["index.db"] + moved_nested_index = new_root / "nested" / "index.db" + assert (index_fingerprint["device"], index_fingerprint["inode"]) == ( + moved_nested_index.stat().st_dev, + moved_nested_index.stat().st_ino, + ) + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=backup_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert result.state == "committed" + assert plan.active_index_pointer is not None + assert plan.active_index_pointer.new_target == str(moved_nested_index) + assert ArchiveLocation.resolve(new_root).active_index.resolved_path == moved_nested_index def test_active_index_publication_updates_the_bound_nested_conventional_symlink(tmp_path: Path) -> None: @@ -1714,7 +1938,7 @@ def test_relocation_resume_rejects_a_same_revision_manifest_substituted_after_ca old_root = workspace_env["archive_root"] _released_moved_source_train(old_root, monkeypatch) - new_root = tmp_path / "moved" + new_root = tmp_path / "moved root;still-one-argument" os.rename(old_root, new_root) monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) @@ -1743,7 +1967,8 @@ def crash_after_cas(path: Path, train: DurableChangeTrain, *, expected_revision: encoding="utf-8" ) ) - assert f"--plan {retained_plan}" in prepared_receipt["resume_command"] + assert f"POLYLOGUE_ARCHIVE_ROOT={shlex.quote(str(new_root))}" in prepared_receipt["resume_command"] + assert f"--plan {shlex.quote(str(retained_plan))}" in prepared_receipt["resume_command"] train_path = Path(plan.durable_trains[0].path) relocated = load_durable_change_train_manifest(train_path) substituted = replace(relocated, proof_refs=(*relocated.proof_refs, "proof:foreign-substitution")) @@ -2148,6 +2373,27 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re assert len(relocation_refs) == 1 assert len(transition_refs) == 1 + refresh_path = _refresh_source_continuity_without_content_change(moved_root, tmp_path / "post-relocation-refresh") + refresh_payload = json.loads(refresh_path.read_text(encoding="utf-8")) + assert refresh_payload["format"] == "polylogue.source-continuity-refresh.v2" + refreshed_train = load_durable_change_train_manifest(Path(source_trains[0]["path"])) + from polylogue.storage.sqlite import durable_change_train as trains + + with sqlite3.connect(moved_root / "source.db") as connection: + assert trains._verify_released_train_live_tier(moved_root, connection, refreshed_train) is None + exact_refresh_bytes = refresh_path.read_bytes() + substituted_refresh = json.loads(exact_refresh_bytes) + intent_payload = substituted_refresh["train_after_without_receipt"] + assert isinstance(intent_payload, dict) + intent_refs = intent_payload["proof_refs"] + assert isinstance(intent_refs, list) + intent_refs.append("proof:foreign-same-evidence-substitution") + refresh_path.write_text(json.dumps(substituted_refresh, indent=2, sort_keys=True) + "\n", encoding="utf-8") + with sqlite3.connect(moved_root / "source.db") as connection: + with pytest.raises(DurableChangeTrainError, match="refresh receipt checksum mismatch"): + trains._verify_released_train_live_tier(moved_root, connection, refreshed_train) + refresh_path.write_bytes(exact_refresh_bytes) + second_root = tmp_path / "moved-again" os.rename(moved_root, second_root) second_env = {"POLYLOGUE_ARCHIVE_ROOT": str(second_root)} @@ -2212,10 +2458,9 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re ).read_text(encoding="utf-8") ) assert latest_transition["predecessor_authority"] == { - "kind": "relocation", - "sha256": second_transition_refs[-2].rsplit(":", 1)[-1], + "kind": "refresh", + "sha256": refresh_path.stem, } - from polylogue.storage.sqlite import durable_change_train as trains with sqlite3.connect(second_root / "source.db") as connection: assert trains._verify_released_train_live_tier(second_root, connection, second_train) is None @@ -2336,3 +2581,75 @@ def test_historical_continuity_recovery_resume_rejects_a_foreign_same_evidence_r assert resumed.exit_code != 0 assert "exact refresh proof" in resumed.output + + +def test_historical_recovery_rejects_foreign_train_authority_before_preparing( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A foreign valid refresh cannot make this recovery operation prepared.""" + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(moved_root)} + plan_path = tmp_path / "foreign-pre-prepare-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + plan = _maintenance_json_output(planned.output) + _substitute_foreign_historical_refresh( + moved_root, + plan=plan, + mutation_receipt=mutation_receipt, + backup_manifest=pre_manifest, + ) + applied = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + str(plan["plan_sha256"]), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + + assert applied.exit_code != 0 + assert "exact refresh proof" in applied.output + plan_sha256 = str(plan["plan_sha256"]) + assert not ( + moved_root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan_sha256}.json" + ).exists() + assert not ( + moved_root / ".maintenance-state" / "historical-source-continuity-recovery-plans" / f"{plan_sha256}.json" + ).exists() From acb9e4999807c5626adc4e4ba7c97e5fcff3642c Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 06:54:18 +0200 Subject: [PATCH 31/39] docs: sync relocation output schemas --- docs/schemas/cli-output/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/schemas/cli-output/README.md b/docs/schemas/cli-output/README.md index 07fedb6d09..634bc0a17d 100644 --- a/docs/schemas/cli-output/README.md +++ b/docs/schemas/cli-output/README.md @@ -31,6 +31,8 @@ devtools render cli-output-schemas --check # CI sync check | [`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` | +| [`archive-root-relocation-result.schema.json`](./archive-root-relocation-result.schema.json) | `polylogue ops maintenance archive-root-relocation apply --output-format json` | `ArchiveRootRelocationResult` | +| [`historical-source-continuity-recovery-result.schema.json`](./historical-source-continuity-recovery-result.schema.json) | `polylogue ops maintenance source-continuity-recovery apply --output-format json` | `HistoricalSourceContinuityRecoveryResult` | | [`machine-error.schema.json`](./machine-error.schema.json) | `polylogue --format json find (error path)` | `MachineErrorPayload` | | [`machine-success.schema.json`](./machine-success.schema.json) | `polylogue analyze --format json (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` | From d3c9cd288bbc2d2864a04a5a61057e9168872161 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 07:21:57 +0200 Subject: [PATCH 32/39] fix: close relocation authority proof gaps --- .../maintenance/_archive_root_relocation.py | 2 +- polylogue/daemon/backup.py | 14 +- .../storage/sqlite/durable_change_train.py | 181 ++++++++---------- .../storage/test_archive_root_relocation.py | 87 ++++++++- .../unit/storage/test_durable_change_train.py | 3 +- 5 files changed, 177 insertions(+), 110 deletions(-) diff --git a/polylogue/cli/commands/maintenance/_archive_root_relocation.py b/polylogue/cli/commands/maintenance/_archive_root_relocation.py index dade4a8682..db34bd4634 100644 --- a/polylogue/cli/commands/maintenance/_archive_root_relocation.py +++ b/polylogue/cli/commands/maintenance/_archive_root_relocation.py @@ -67,7 +67,7 @@ def archive_root_relocation_plan_command( @click.option("--authorize", required=True) @click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) def archive_root_relocation_apply_command(plan_path: Path, authorize: str, output_format: str) -> None: - """Apply the plan by CAS-revising released source manifests only.""" + """Apply the plan to durable trains and sealed index-generation topology.""" root = archive_root() try: plan = load_archive_root_relocation_plan(plan_path) diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index c7005fa364..4faaa2a9ff 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -216,12 +216,20 @@ def _json_str_list(value: object) -> list[str]: def _all_archive_tiers(root: Path) -> dict[str, Path]: tiers = archive_tier_paths(root) pointer = root / ".index-active-pointer" - if pointer.is_symlink() or not pointer.is_file(): - return tiers try: - configured_target = Path(pointer.read_text(encoding="utf-8").strip()) + pointer_metadata = pointer.lstat() except OSError: return tiers + try: + if stat.S_ISLNK(pointer_metadata.st_mode): + raw_target = os.readlink(pointer) + elif stat.S_ISREG(pointer_metadata.st_mode) and pointer_metadata.st_nlink == 1: + raw_target = pointer.read_text(encoding="utf-8").strip() + else: + return tiers + except (OSError, UnicodeDecodeError): + return tiers + configured_target = Path(raw_target) if not configured_target.is_absolute() or configured_target.name != "index.db": return tiers if ( diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 1c130dc3f7..cc0ebae1ea 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1404,73 +1404,73 @@ def _validate_archive_root_relocation_receipts( for ref in train.proof_refs if ref.startswith("proof:archive-root-relocation:") ) - if not proof_digests: - return - from polylogue.operations.archive_root_relocation import ( - ArchiveRootRelocationError, - ArchiveRootRelocationPlan, - _decode_receipt, - _verify_plan, - ) - - try: - with existing_maintenance_receipt_directory(archive_root, "archive-root-relocations") as receipt_fd: - receipt_rows = () if receipt_fd is None else tuple(iter_pinned_receipts(receipt_fd)) - with existing_maintenance_receipt_directory(archive_root, "archive-root-relocation-plans") as plan_fd: - if plan_fd is None: - raise DurableChangeTrainError("archive-root relocation proof has no retained plan authority") - plan_rows = dict(iter_pinned_receipts(plan_fd)) - except MaintenanceReceiptPathError as exc: - raise DurableChangeTrainError("archive-root relocation proof authority is unreadable") from exc transitions: list[tuple[str, str, str]] = [] - for proof_digest in proof_digests: - matches = [] + if proof_digests: + from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + ArchiveRootRelocationPlan, + _decode_receipt, + _verify_plan, + ) + try: - for filename, encoded in receipt_rows: - receipt = _decode_receipt( - encoded, - path=archive_root / ".maintenance-state" / "archive-root-relocations" / filename, + with existing_maintenance_receipt_directory(archive_root, "archive-root-relocations") as receipt_fd: + receipt_rows = () if receipt_fd is None else tuple(iter_pinned_receipts(receipt_fd)) + with existing_maintenance_receipt_directory(archive_root, "archive-root-relocation-plans") as plan_fd: + if plan_fd is None: + raise DurableChangeTrainError("archive-root relocation proof has no retained plan authority") + plan_rows = dict(iter_pinned_receipts(plan_fd)) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError("archive-root relocation proof authority is unreadable") from exc + for proof_digest in proof_digests: + matches = [] + try: + for filename, encoded in receipt_rows: + receipt = _decode_receipt( + encoded, + path=archive_root / ".maintenance-state" / "archive-root-relocations" / filename, + ) + if (receipt.prepared_receipt_sha256 or receipt.receipt_sha256) == proof_digest and ( + receipt.state == "committed" or proof_digest == allowed_pending_relocation_receipt_sha256 + ): + matches.append(receipt) + except ArchiveRootRelocationError as exc: + raise DurableChangeTrainError("archive-root relocation proof receipt is invalid") from exc + if len(matches) != 1: + raise DurableChangeTrainError( + "archive-root relocation proof does not resolve exactly one committed receipt or the explicitly " + "pending receipt" ) - if (receipt.prepared_receipt_sha256 or receipt.receipt_sha256) == proof_digest and ( - receipt.state == "committed" or proof_digest == allowed_pending_relocation_receipt_sha256 - ): - matches.append(receipt) - except ArchiveRootRelocationError as exc: - raise DurableChangeTrainError("archive-root relocation proof receipt is invalid") from exc - if len(matches) != 1: - raise DurableChangeTrainError( - "archive-root relocation proof does not resolve exactly one committed receipt or the explicitly pending receipt" + receipt = matches[0] + encoded_plan = plan_rows.get(f"{receipt.plan_sha256}.json") + if encoded_plan is None: + raise DurableChangeTrainError("archive-root relocation proof retained plan is missing") + try: + plan = ArchiveRootRelocationPlan.model_validate_json(encoded_plan) + _verify_plan(plan) + except (ArchiveRootRelocationError, ValueError) as exc: + raise DurableChangeTrainError("archive-root relocation proof retained plan is invalid") from exc + item_indexes = tuple( + index + for index, item in enumerate(plan.durable_trains) + if item.train_id == train.train_id and item.tier == train.tier.value ) - receipt = matches[0] - encoded_plan = plan_rows.get(f"{receipt.plan_sha256}.json") - if encoded_plan is None: - raise DurableChangeTrainError("archive-root relocation proof retained plan is missing") - try: - plan = ArchiveRootRelocationPlan.model_validate_json(encoded_plan) - _verify_plan(plan) - except (ArchiveRootRelocationError, ValueError) as exc: - raise DurableChangeTrainError("archive-root relocation proof retained plan is invalid") from exc - item_indexes = tuple( - index - for index, item in enumerate(plan.durable_trains) - if item.train_id == train.train_id and item.tier == train.tier.value - ) - if len(item_indexes) != 1: - raise DurableChangeTrainError("archive-root relocation proof does not bind this durable train") - expected_before = tuple(item.before_manifest_sha256 for item in plan.durable_trains) - if receipt.manifest_before_sha256 != expected_before or len(receipt.manifest_after_sha256) != len( - plan.durable_trains - ): - raise DurableChangeTrainError("archive-root relocation proof receipt does not bind its exact plan") - item_index = item_indexes[0] - item = plan.durable_trains[item_index] - transitions.append( - ( - item.before_manifest_sha256, - receipt.manifest_after_sha256[item_index], - item.after_archive_identity_digest, + if len(item_indexes) != 1: + raise DurableChangeTrainError("archive-root relocation proof does not bind this durable train") + expected_before = tuple(item.before_manifest_sha256 for item in plan.durable_trains) + if receipt.manifest_before_sha256 != expected_before or len(receipt.manifest_after_sha256) != len( + plan.durable_trains + ): + raise DurableChangeTrainError("archive-root relocation proof receipt does not bind its exact plan") + item_index = item_indexes[0] + item = plan.durable_trains[item_index] + transitions.append( + ( + item.before_manifest_sha256, + receipt.manifest_after_sha256[item_index], + item.after_archive_identity_digest, + ) ) - ) for ref in train.proof_refs: if not ref.startswith("proof:source-continuity-refresh:"): continue @@ -1491,6 +1491,8 @@ def _validate_archive_root_relocation_receipts( _migration_runner._validate_sha256(before, label="source continuity refresh before manifest") _migration_runner._validate_sha256(identity, label="source continuity refresh archive identity") transitions.append((before, after, identity)) + if not transitions: + return by_before = {before: (after, identity) for before, after, identity in transitions} if len(by_before) != len(transitions): raise DurableChangeTrainError("archive-root relocation and refresh proof chain branches ambiguously") @@ -1513,7 +1515,7 @@ def _validate_archive_root_relocation_receipts( "archive-root relocation and refresh proof chain does not bind the exact current manifest" ) if train.apply_evidence is None or latest_identity != train.apply_evidence.post.archive_identity_digest: - raise DurableChangeTrainError("archive-root relocation proof does not bind the latest durable identity") + raise DurableChangeTrainError("continuity transition proof does not bind the latest durable identity") def write_source_continuity_relocation_transition( @@ -1707,10 +1709,8 @@ def _refresh_released_source_train_continuity_locked( for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") } - for existing_path in sorted(refresh_root.glob("*.json")) if refresh_root.is_dir() else (): - digest = existing_path.stem - if digest not in retained_refs: - continue + for digest in sorted(retained_refs): + existing_path = refresh_root / f"{digest}.json" existing = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) if existing.get("mutation_receipt_sha256") == mutation_digest: retained_refreshes.append((existing_path, existing)) @@ -1817,41 +1817,20 @@ def _refresh_released_source_train_continuity_locked( } refresh_digest = _canonical_json_sha256(payload) updated = _finalize_source_continuity_refresh_intent(intent, refresh_digest=refresh_digest) - refresh_root_existed = refresh_root.is_dir() - refresh_root.mkdir(parents=True, exist_ok=True) - if not refresh_root_existed: - _migration_runner._fsync_manifest_directory(refresh_root.parent) refresh_path = refresh_root / f"{refresh_digest}.json" - if refresh_path.exists(): - existing = _read_source_continuity_refresh_receipt( - archive_root, - digest=refresh_digest, - train=train, - ) - if existing != payload: - raise DurableChangeTrainError("source continuity refresh receipt collision") - else: - encoded = ( - json.dumps({**payload, "refresh_sha256": refresh_digest}, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile( - dir=refresh_root, - prefix=f".{refresh_path.name}.", - suffix=".tmp", - delete=False, - ) as stream: - temporary = Path(stream.name) - stream.write(encoded) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, refresh_path) - temporary = None - _migration_runner._fsync_manifest_directory(refresh_root) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) + encoded = (json.dumps({**payload, "refresh_sha256": refresh_digest}, indent=2, sort_keys=True) + "\n").encode( + "utf-8" + ) + try: + with maintenance_receipt_directory(archive_root, "source-continuity-refreshes") as directory_fd: + current_receipt = read_optional_receipt(directory_fd, refresh_path.name) + if current_receipt is not None: + if current_receipt != encoded: + raise DurableChangeTrainError("source continuity refresh receipt collision") + else: + atomic_replace_receipt(directory_fd, refresh_path.name, encoded) + except MaintenanceReceiptPathError as exc: + raise DurableChangeTrainError("cannot persist source continuity refresh receipt") from exc write_durable_change_train_manifest(manifest_path, updated, expected_revision=train.revision) return refresh_path diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 2764f0110e..a4ecb5d1eb 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -72,6 +72,7 @@ DURABLE_MIGRATION_ADOPTION_FLOORS, DurableChangeTrain, DurableChangeTrainError, + assert_source_continuity_apply_allowed, load_durable_change_train_manifest, rebind_released_durable_train_archive_identity, recover_released_source_train_continuity, @@ -119,6 +120,13 @@ def test_archive_root_relocation_is_a_real_maintenance_route(cli_workspace: dict ) assert nested.exit_code == 0, nested.output assert "--old-root" in nested.output + apply_help = CliRunner().invoke( + cli, + ["--plain", "ops", "maintenance", "archive-root-relocation", "apply", "--help"], + catch_exceptions=False, + ) + assert apply_help.exit_code == 0, apply_help.output + assert "durable trains and sealed index-generation topology" in apply_help.output def test_recovery_cli_reports_archive_ownership_conflicts( @@ -982,6 +990,69 @@ def test_historical_source_delta_tags_sqlite_storage_classes_and_rejects_refresh assert not tuple(target.iterdir()) +def test_ordinary_source_continuity_refresh_rejects_symlink_receipt_directory( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The ordinary refresh writer cannot publish through a foreign directory. + + Anti-vacuity: the real post-maintenance refresh route reaches receipt + publication with a released source train. A path-based mkdir/replace + implementation writes the new v2 authority into ``outside``. + """ + root = workspace_env["archive_root"] + _released_moved_source_train(root, monkeypatch) + outside = tmp_path / "outside-refreshes" + outside.mkdir() + refresh_root = root / ".maintenance-state" / "source-continuity-refreshes" + refresh_root.symlink_to(outside, target_is_directory=True) + + with pytest.raises(DurableChangeTrainError, match="cannot persist source continuity refresh receipt"): + _refresh_source_continuity_without_content_change(root, tmp_path / "refresh-evidence") + + assert not tuple(outside.iterdir()) + + +def test_refresh_only_authority_chain_requires_exact_manifest_predecessors( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Two retained v2 refreshes must form one exact manifest-hash chain. + + Anti-vacuity: source-mutation admission invokes the production released + train validator. Without refresh-only transition validation, a terminal + receipt can preserve all evidence and final fields while substituting an + unrelated, well-formed predecessor manifest hash. + """ + from polylogue.storage.sqlite import durable_change_train as trains + + root = workspace_env["archive_root"] + manifest = _released_moved_source_train(root, monkeypatch) + _refresh_source_continuity_without_content_change(root, tmp_path / "first-refresh") + terminal_path = _refresh_source_continuity_without_content_change(root, tmp_path / "second-refresh") + train = load_durable_change_train_manifest(manifest) + + terminal_receipt = json.loads(terminal_path.read_text(encoding="utf-8")) + old_digest = terminal_receipt.pop("refresh_sha256") + assert isinstance(old_digest, str) + terminal_receipt["train_before_sha256"] = "f" * 64 + substituted_digest = _canonical_json_sha256(terminal_receipt) + substituted_receipt = {**terminal_receipt, "refresh_sha256": substituted_digest} + substituted_path = terminal_path.with_name(f"{substituted_digest}.json") + _write_refresh_receipt(substituted_path, substituted_receipt) + + intent = trains._source_continuity_refresh_intent(terminal_receipt, train_id=train.train_id) + substituted_train = trains._finalize_source_continuity_refresh_intent( + intent, + refresh_digest=substituted_digest, + ) + manifest.write_text( + json.dumps(durable_change_train_to_payload(substituted_train), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + with pytest.raises(DurableChangeTrainError, match="proof chain has no unique predecessor"): + assert_source_continuity_apply_allowed(root) + + def test_receipt_directory_swap_cannot_redirect_either_operation_outside_archive( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1805,8 +1876,12 @@ def test_relocation_remaps_generations_beside_a_nested_active_index( assert (new_root / "nested" / "index.db").resolve(strict=True) == Path(promoted.index_path) +@pytest.mark.parametrize("pointer_kind", ["regular", "symlink"]) def test_relocation_backup_maps_a_nested_regular_active_index( - workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + workspace_env: dict[str, Path], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + pointer_kind: str, ) -> None: """The moved-root backup follows a stale pointer to a regular in-root index. @@ -1819,7 +1894,11 @@ def test_relocation_backup_maps_a_nested_regular_active_index( nested_index = old_root / "nested" / "index.db" nested_index.parent.mkdir() shutil.copy2(old_root / "index.db", nested_index) - (old_root / ".index-active-pointer").write_text(str(nested_index), encoding="utf-8") + pointer = old_root / ".index-active-pointer" + if pointer_kind == "regular": + pointer.write_text(str(nested_index), encoding="utf-8") + else: + pointer.symlink_to(nested_index) _attach_retained_source_continuity(old_root, manifest) new_root = tmp_path / "moved" @@ -2286,7 +2365,7 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re catch_exceptions=False, ) assert rejected_plan.exit_code != 0 - assert "source continuity authority is invalid" in rejected_plan.output + assert "relocation authority is invalid" in rejected_plan.output rejected_apply = CliRunner().invoke( cli, @@ -2307,7 +2386,7 @@ def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_re catch_exceptions=False, ) assert rejected_apply.exit_code != 0 - assert "continuity receipt is invalid" in rejected_apply.output + assert "retained train authority is invalid" in rejected_apply.output from polylogue.daemon import cli as daemon_cli from polylogue.operations import durable_change_train as durable_operations diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 900ab0f02b..074c112f63 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -475,7 +475,8 @@ def record_refresh_fsync(path: Path) -> None: operation_id=_EMPTY_LIVENESS_DIGEST, evidence_ref="proof:mutation-1", ) - assert tmp_path / ".maintenance-state" in refresh_fsync_calls + assert not refreshed_path.is_symlink() + assert refreshed_path.stat().st_nlink == 1 refreshed = load_durable_change_train_manifest(manifest) assert refreshed.state is DurableChangeTrainState.RELEASED From 8fb8c83e8905531dcbdb168b3c3b7635d8a2932f Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 07:42:42 +0200 Subject: [PATCH 33/39] fix: seal relocation authority transitions --- .../operations/archive_root_relocation.py | 206 ++++++++++---- .../historical_source_continuity_recovery.py | 65 ++++- .../storage/sqlite/durable_change_train.py | 16 +- .../storage/test_archive_root_relocation.py | 260 +++++++++++++++++- 4 files changed, 479 insertions(+), 68 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 948b3f2e18..570020c576 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -10,6 +10,7 @@ import stat import tempfile import uuid +from contextlib import suppress from pathlib import Path from typing import Literal, cast @@ -134,6 +135,8 @@ class RelocationIndexGeneration(BaseModel): generation_id: str metadata_path: str + directory_device: int + directory_inode: int before_sha256: str after_sha256: str before_archive_root: str @@ -457,6 +460,7 @@ def _index_generation_evidence( rows: list[RelocationIndexGeneration] = [] for generation_root in sorted(generations_root.glob("gen-*")): _real_directory(generation_root, label="index generation") + generation_metadata = generation_root.stat() metadata_path = generation_root / "generation.json" _real_file(metadata_path, label="index generation metadata") try: @@ -519,6 +523,8 @@ def _index_generation_evidence( RelocationIndexGeneration( generation_id=generation_id, metadata_path=str(metadata_path), + directory_device=generation_metadata.st_dev, + directory_inode=generation_metadata.st_ino, before_sha256=hashlib.sha256(encoded).hexdigest(), after_sha256=hashlib.sha256(_index_generation_metadata_bytes(after_payload)).hexdigest(), before_archive_root=before_archive_root, @@ -603,6 +609,110 @@ def _validate_index_generation_state( raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") +def _open_pinned_generation_directory(root: Path, item: RelocationIndexGeneration) -> int: + """Open the plan-owned generation directory without following any link.""" + generation_root = Path(item.metadata_path).parent + try: + relative = generation_root.relative_to(root) + except ValueError as exc: + raise ArchiveRootRelocationError( + "archive-root relocation index generation directory escapes the destination root" + ) from exc + if ( + not relative.parts + or relative.parts[-1] != item.generation_id + or Path(item.metadata_path).name != "generation.json" + ): + raise ArchiveRootRelocationError("archive-root relocation index generation path binding changed") + directory_fd = -1 + try: + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + for component in relative.parts: + if component in {"", ".", ".."}: + raise ArchiveRootRelocationError("archive-root relocation index generation path is unsafe") + next_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=directory_fd, + ) + os.close(directory_fd) + directory_fd = next_fd + metadata = os.fstat(directory_fd) + if (metadata.st_dev, metadata.st_ino) != (item.directory_device, item.directory_inode): + raise ArchiveRootRelocationError("archive-root relocation index generation directory identity changed") + return directory_fd + except ArchiveRootRelocationError: + if directory_fd >= 0: + os.close(directory_fd) + raise + except OSError as exc: + if directory_fd >= 0: + os.close(directory_fd) + raise ArchiveRootRelocationError("cannot pin archive-root relocation index generation directory") from exc + + +def _read_pinned_generation_metadata(directory_fd: int) -> bytes: + descriptor = -1 + try: + descriptor = os.open( + "generation.json", + os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=directory_fd, + ) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise ArchiveRootRelocationError( + "archive-root relocation index generation metadata is not a real single-linked file" + ) + with os.fdopen(descriptor, "rb", closefd=False) as stream: + return stream.read() + except ArchiveRootRelocationError: + raise + except OSError as exc: + raise ArchiveRootRelocationError( + "cannot read pinned archive-root relocation index generation metadata" + ) from exc + finally: + if descriptor >= 0: + os.close(descriptor) + + +def _replace_pinned_generation_metadata(directory_fd: int, payload: bytes) -> None: + temporary = f".generation.json.relocation-{uuid.uuid4().hex}.tmp" + descriptor = -1 + try: + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + dir_fd=directory_fd, + ) + offset = 0 + while offset < len(payload): + written = os.write(descriptor, payload[offset:]) + if written <= 0: + raise ArchiveRootRelocationError( + "archive-root relocation index generation metadata write made no progress" + ) + offset += written + os.fsync(descriptor) + os.close(descriptor) + descriptor = -1 + os.replace(temporary, "generation.json", src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + except ArchiveRootRelocationError: + raise + except OSError as exc: + raise ArchiveRootRelocationError( + "cannot atomically publish pinned archive-root relocation index generation metadata" + ) from exc + finally: + if descriptor >= 0: + os.close(descriptor) + with suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory_fd) + + def _publish_index_generation_state( root: Path, items: tuple[RelocationIndexGeneration, ...], @@ -611,58 +721,56 @@ def _publish_index_generation_state( """CAS-publish mapped metadata and links; exact after states are idempotent.""" _validate_index_generation_state(root, items, active_index_pointer) for item in items: - metadata_path = Path(item.metadata_path) - encoded = metadata_path.read_bytes() - digest = hashlib.sha256(encoded).hexdigest() - if digest == item.before_sha256 and item.before_sha256 != item.after_sha256: - payload = _index_generation_payload_for_state(item, after=False, encoded=encoded) - after_payload = { - **payload, - "archive_root": item.after_archive_root, - "index_path": item.after_index_path, - } - after_encoded = _index_generation_metadata_bytes(after_payload) - if hashlib.sha256(after_encoded).hexdigest() != item.after_sha256: - raise ArchiveRootRelocationError("archive-root relocation index generation after binding changed") - with tempfile.NamedTemporaryFile( - dir=metadata_path.parent, - prefix=f".{metadata_path.name}.relocation-", - suffix=".tmp", - delete=False, - ) as stream: - temporary = Path(stream.name) - stream.write(after_encoded) - stream.flush() - os.fsync(stream.fileno()) - try: - os.replace(temporary, metadata_path) - directory_fd = os.open(metadata_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) - finally: - temporary.unlink(missing_ok=True) - for link in item.tier_symlinks: - if link.old_target == link.new_target: - continue - path = Path(link.path) - current = os.readlink(path) - if current == link.new_target: - continue - if current != link.old_target: - raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") - temporary = path.parent / f".{path.name}.relocation-{uuid.uuid4().hex}.tmp" - try: - os.symlink(link.new_target, temporary) - os.replace(temporary, path) - directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + directory_fd = _open_pinned_generation_directory(root, item) + try: + encoded = _read_pinned_generation_metadata(directory_fd) + digest = hashlib.sha256(encoded).hexdigest() + if digest == item.before_sha256 and item.before_sha256 != item.after_sha256: + payload = _index_generation_payload_for_state(item, after=False, encoded=encoded) + after_payload = { + **payload, + "archive_root": item.after_archive_root, + "index_path": item.after_index_path, + } + after_encoded = _index_generation_metadata_bytes(after_payload) + if hashlib.sha256(after_encoded).hexdigest() != item.after_sha256: + raise ArchiveRootRelocationError("archive-root relocation index generation after binding changed") + _replace_pinned_generation_metadata(directory_fd, after_encoded) + elif digest != item.after_sha256: + raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") + generation_root = Path(item.metadata_path).parent + for link in item.tier_symlinks: + path = Path(link.path) + if path.parent != generation_root or path.name not in _INDEX_GENERATION_TIER_LINKS: + raise ArchiveRootRelocationError( + "archive-root relocation index generation tier link path binding changed" + ) + if link.old_target == link.new_target: + continue + temporary = f".{path.name}.relocation-{uuid.uuid4().hex}.tmp" try: + metadata = os.stat(path.name, dir_fd=directory_fd, follow_symlinks=False) + if not stat.S_ISLNK(metadata.st_mode): + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + current = os.readlink(path.name, dir_fd=directory_fd) + if current == link.new_target: + continue + if current != link.old_target: + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + os.symlink(link.new_target, temporary, dir_fd=directory_fd) + os.replace(temporary, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) + except ArchiveRootRelocationError: + raise + except OSError as exc: + raise ArchiveRootRelocationError( + "cannot atomically publish pinned archive-root relocation index generation tier link" + ) from exc finally: - os.close(directory_fd) - finally: - temporary.unlink(missing_ok=True) + with suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory_fd) + finally: + os.close(directory_fd) _validate_index_generation_state(root, items, active_index_pointer) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 273dc66318..d4ceb50f22 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -64,6 +64,7 @@ DurableDatabaseEvidence, capture_durable_database_evidence, capture_durable_schema_inventory, + durable_change_train_to_payload, ) PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v2"] = ( @@ -111,6 +112,7 @@ class HistoricalSourceContinuityRecoveryPlan(BaseModel): source_train_path: str source_train_revision: int source_train_sha256: str + source_train_after_sha256: str source_before: dict[str, object] source_after: dict[str, object] census: dict[str, object] @@ -129,7 +131,7 @@ class HistoricalSourceContinuityRecoveryReceipt(BaseModel): plan_sha256: str authorization: str train_before_sha256: str - train_after_sha256: str | None + train_after_sha256: str refresh_receipt_sha256: str resume_command: str receipt_sha256: str @@ -177,6 +179,12 @@ def _canonical_json_sha256(payload: object) -> str: ).hexdigest() +def _train_manifest_sha256(train: DurableChangeTrain) -> str: + payload = durable_change_train_to_payload(train) + encoded = (json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _real_file(path: Path, *, label: str) -> None: try: metadata = path.lstat() @@ -266,11 +274,23 @@ def _sealed_receipt(**values: object) -> HistoricalSourceContinuityRecoveryRecei def _verify_plan(plan: HistoricalSourceContinuityRecoveryPlan) -> None: if plan.plan_sha256 != _canonical_json_sha256(plan.model_dump(mode="json", exclude={"plan_sha256"})): raise HistoricalSourceContinuityRecoveryError("historical continuity recovery plan checksum mismatch") + if len(plan.source_train_after_sha256) != 64 or any( + character not in "0123456789abcdef" for character in plan.source_train_after_sha256 + ): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery plan has invalid post-CAS train binding" + ) def _verify_receipt(receipt: HistoricalSourceContinuityRecoveryReceipt) -> None: if receipt.receipt_sha256 != _canonical_json_sha256(receipt.model_dump(mode="json", exclude={"receipt_sha256"})): raise HistoricalSourceContinuityRecoveryError("historical continuity recovery receipt checksum mismatch") + if len(receipt.train_after_sha256) != 64 or any( + character not in "0123456789abcdef" for character in receipt.train_after_sha256 + ): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery receipt has invalid post-CAS train binding" + ) def _backup_source_evidence( @@ -898,6 +918,12 @@ def prepare_historical_source_continuity_recovery( "census": census, }, } + refresh_digest = _canonical_json_sha256(refresh_payload) + expected_train = recover_released_source_train_continuity( + train, + current_evidence=current, + proof_ref="proof:source-continuity-refresh:" + refresh_digest, + ) return _sealed_plan( old_configured_root=str(old_configured), old_resolved_root=str(old_resolved), @@ -923,10 +949,11 @@ def prepare_historical_source_continuity_recovery( new_source_device=new_source_identity.device, new_source_inode=new_source_identity.inode, refresh_proof_id=refresh_proof_id, - refresh_receipt_sha256=_canonical_json_sha256(refresh_payload), + refresh_receipt_sha256=refresh_digest, source_train_path=str(train_path), source_train_revision=train.revision, source_train_sha256=_sha256(train_path), + source_train_after_sha256=_train_manifest_sha256(expected_train), source_before=source_before, source_after=_evidence_payload(current), census=census, @@ -1136,11 +1163,15 @@ def _revalidate( train_sha256 = _sha256(Path(plan.source_train_path)) if train_sha256 == plan.source_train_sha256: _assert_pre_train_authority(Path(plan.source_train_path), pre) - else: + elif train_sha256 == plan.source_train_after_sha256: if train.source_continuity_evidence is None: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source train changed") _validate_source_continuity_refresh_receipt(root, train) _validate_exact_refresh_binding(root, plan, train) + else: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery source train is neither the sealed pre-CAS nor post-CAS manifest" + ) if _census(root) != plan.census: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery liveness census changed") return current @@ -1212,7 +1243,7 @@ def _apply_historical_source_continuity_recovery_locked( plan_sha256=plan.plan_sha256, authorization=authorization, train_before_sha256=plan.source_train_sha256, - train_after_sha256=None, + train_after_sha256=plan.source_train_after_sha256, refresh_receipt_sha256=refresh_digest, resume_command=command, ) @@ -1223,7 +1254,19 @@ def _apply_historical_source_continuity_recovery_locked( raise HistoricalSourceContinuityRecoveryError( "historical continuity recovery receipt belongs to another plan" ) + if ( + receipt.train_before_sha256 != plan.source_train_sha256 + or receipt.train_after_sha256 != plan.source_train_after_sha256 + or receipt.refresh_receipt_sha256 != refresh_digest + ): + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery receipt does not bind this plan's exact CAS" + ) if receipt.state == "committed": + if _sha256(Path(plan.source_train_path)) != receipt.train_after_sha256: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery committed receipt does not bind the live train manifest" + ) train = load_durable_change_train_manifest(Path(plan.source_train_path)) _validate_source_continuity_refresh_receipt(resolved, train) _validate_exact_refresh_binding(resolved, plan, train) @@ -1244,10 +1287,20 @@ def _apply_historical_source_continuity_recovery_locked( updated = recover_released_source_train_continuity( train, current_evidence=planned_current, proof_ref="proof:source-continuity-refresh:" + refresh_digest ) + if _train_manifest_sha256(updated) != plan.source_train_after_sha256: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery post-CAS train binding changed" + ) write_durable_change_train_manifest(path, updated, expected_revision=plan.source_train_revision) - else: + elif _sha256(path) == plan.source_train_after_sha256: _validate_source_continuity_refresh_receipt(resolved, train) _validate_exact_refresh_binding(resolved, plan, train) + else: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery source train is neither the sealed pre-CAS nor post-CAS manifest" + ) + if _sha256(path) != plan.source_train_after_sha256: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery post-CAS train manifest changed") _validate_exact_refresh_binding(resolved, plan, load_durable_change_train_manifest(path)) committed = _sealed_receipt( state="committed", @@ -1255,7 +1308,7 @@ def _apply_historical_source_continuity_recovery_locked( plan_sha256=plan.plan_sha256, authorization=authorization, train_before_sha256=plan.source_train_sha256, - train_after_sha256=_sha256(path), + train_after_sha256=plan.source_train_after_sha256, refresh_receipt_sha256=refresh_digest, resume_command=command, ) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index cc0ebae1ea..0e3b50d2ce 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1272,14 +1272,14 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, ) nodes[transition_ref] = node predecessor_node = node - matching_authorities = [ - node.ref - for node in nodes.values() - if node.ref not in successor_by_authority and node.source_after == expected_after - ] - if len(matching_authorities) != 1: - raise DurableChangeTrainError("source continuity evidence does not identify exactly one terminal authority") - terminal = matching_authorities[0] + roots = [ref for ref in nodes if ref not in predecessors] + terminals = [node for node in nodes.values() if node.ref not in successor_by_authority] + if len(roots) != 1 or len(terminals) != 1: + raise DurableChangeTrainError("source continuity references do not form one connected authority chain") + terminal_node = terminals[0] + if terminal_node.source_after != expected_after: + raise DurableChangeTrainError("source continuity evidence does not identify the terminal authority") + terminal = terminal_node.ref if terminal.kind == "refresh": terminal_payload = refresh_payloads[terminal.sha256] if terminal_payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index a4ecb5d1eb..65a4f267cc 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -23,6 +23,7 @@ from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, RelocationActiveIndexPointer, + RelocationIndexGeneration, RelocationTierEvidence, _check_backup_against_live, apply_archive_root_relocation, @@ -1053,6 +1054,45 @@ def test_refresh_only_authority_chain_requires_exact_manifest_predecessors( assert_source_continuity_apply_allowed(root) +def test_source_continuity_rejects_a_disconnected_legacy_authority_component( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Every retained V1/V2/relocation authority must belong to one chain. + + Anti-vacuity: source-mutation admission calls the production continuity + graph validator. The older terminal-only check accepted an unrelated V1 + root whenever the legitimate V1 authority alone matched current evidence. + """ + root = workspace_env["archive_root"] + manifest = _released_moved_source_train(root, monkeypatch) + _attach_retained_source_continuity(root, manifest) + train = load_durable_change_train_manifest(manifest) + retained_digest = next( + ref.rsplit(":", 1)[-1] for ref in train.proof_refs if ref.startswith("proof:source-continuity-refresh:") + ) + retained_path = root / ".maintenance-state" / "source-continuity-refreshes" / f"{retained_digest}.json" + foreign_payload = json.loads(retained_path.read_text(encoding="utf-8")) + foreign_payload.pop("refresh_sha256") + foreign_payload["operation_id"] = "disconnected-legacy-authority" + foreign_after = dict(foreign_payload["source_after"]) + foreign_after["content_sha256"] = "f" * 64 + foreign_payload["source_after"] = foreign_after + foreign_digest = _canonical_json_sha256(foreign_payload) + _write_refresh_receipt( + retained_path.with_name(f"{foreign_digest}.json"), + {**foreign_payload, "refresh_sha256": foreign_digest}, + ) + disconnected = replace( + train, + revision=train.revision + 1, + proof_refs=(*train.proof_refs, f"proof:source-continuity-refresh:{foreign_digest}"), + ) + write_durable_change_train_manifest(manifest, disconnected, expected_revision=train.revision) + + with pytest.raises(DurableChangeTrainError, match="one connected authority chain"): + assert_source_continuity_apply_allowed(root) + + def test_receipt_directory_swap_cannot_redirect_either_operation_outside_archive( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1094,7 +1134,7 @@ def swapped_mkdir(path: str, mode: int = 0o777, *, dir_fd: int | None = None) -> plan_sha256="b" * 64, authorization="b" * 64, train_before_sha256="c" * 64, - train_after_sha256=None, + train_after_sha256="e" * 64, refresh_receipt_sha256="d" * 64, resume_command="resume continuity", ) @@ -1150,7 +1190,7 @@ def test_receipt_writers_never_create_through_a_symlinked_maintenance_state(tmp_ plan_sha256="f" * 64, authorization="f" * 64, train_before_sha256="0" * 64, - train_after_sha256=None, + train_after_sha256="2" * 64, refresh_receipt_sha256="1" * 64, resume_command="resume continuity", ), @@ -1228,7 +1268,7 @@ def test_historical_startup_reader_rejects_receipt_swapped_after_enumeration( plan_sha256="c" * 64, authorization="c" * 64, train_before_sha256="d" * 64, - train_after_sha256=None, + train_after_sha256="f" * 64, refresh_receipt_sha256="e" * 64, resume_command="resume continuity", ), @@ -1817,6 +1857,65 @@ def crash_after_pointer_publication(root: Path, pointer: RelocationActiveIndexPo assert (new_root / "index.db").resolve(strict=True) == Path(promoted.index_path) +def test_relocation_generation_publication_rejects_a_post_validation_directory_swap( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Generation publication pins the planned directory before every write. + + Anti-vacuity: the swap occurs after the production validator returns and + before publication. Path-based temporary files and replaces mutate the + byte-identical foreign generation; descriptor-pinned publication rejects + its different directory identity without changing any foreign artifact. + """ + from polylogue.operations import archive_root_relocation as relocation + + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + _activate_movable_index_generation(old_root) + _attach_retained_source_continuity(old_root, manifest) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert plan.index_generations + generation_root = Path(plan.index_generations[0].metadata_path).parent + outside = tmp_path / "foreign-generation" + shutil.copytree(generation_root, outside, symlinks=True) + outside_metadata_before = (outside / "generation.json").read_bytes() + outside_links_before = {path.name: os.readlink(path) for path in outside.iterdir() if path.is_symlink()} + detached = tmp_path / "detached-authoritative-generation" + real_validate = relocation._validate_index_generation_state + validation_calls = 0 + + def swap_after_publication_preflight( + root: Path, + items: tuple[RelocationIndexGeneration, ...], + pointer: RelocationActiveIndexPointer | None, + ) -> None: + nonlocal validation_calls + real_validate(root, items, pointer) + validation_calls += 1 + if validation_calls == 2: + os.rename(generation_root, detached) + generation_root.symlink_to(outside, target_is_directory=True) + + monkeypatch.setattr(relocation, "_validate_index_generation_state", swap_after_publication_preflight) + with pytest.raises(ArchiveRootRelocationError, match="cannot pin.*index generation directory"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert validation_calls == 2 + assert (outside / "generation.json").read_bytes() == outside_metadata_before + assert {path.name: os.readlink(path) for path in outside.iterdir() if path.is_symlink()} == outside_links_before + + def test_relocation_remaps_generations_beside_a_nested_active_index( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2659,7 +2758,158 @@ def test_historical_continuity_recovery_resume_rejects_a_foreign_same_evidence_r ) assert resumed.exit_code != 0 - assert "exact refresh proof" in resumed.output + assert "neither the sealed pre-CAS nor post-CAS manifest" in resumed.output + + +def test_historical_recovery_resume_and_committed_return_require_exact_post_cas_manifest( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Prepared and committed recovery accept only the plan-sealed post-CAS bytes. + + Anti-vacuity: the public apply route crashes immediately after the real + manifest CAS. A different checksummed released manifest retaining the + exact V1 recovery evidence plus one foreign proof ref used to resume and + later return as committed. + """ + from polylogue.operations import historical_source_continuity_recovery as recovery + + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + command_env = {"POLYLOGUE_ARCHIVE_ROOT": str(moved_root)} + plan_path = tmp_path / "exact-post-cas-plan.json" + with _test_historical_operation_evidence_resource(evidence): + planned = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "plan", + "--old-root", + str(workspace_env["archive_root"]), + "--mutation-receipt", + str(mutation_receipt), + "--pre-backup-manifest", + str(pre_manifest), + "--post-backup-manifest", + str(post_manifest), + "--output", + str(plan_path), + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert planned.exit_code == 0, planned.output + plan = _maintenance_json_output(planned.output) + plan_sha256 = str(plan["plan_sha256"]) + train_path = Path(str(plan["source_train_path"])) + real_write = write_durable_change_train_manifest + + def crash_after_manifest_cas(path: Path, train: DurableChangeTrain, *, expected_revision: int) -> None: + real_write(path, train, expected_revision=expected_revision) + raise RuntimeError("crash after historical recovery manifest CAS") + + monkeypatch.setattr(recovery, "write_durable_change_train_manifest", crash_after_manifest_cas) + with pytest.raises(RuntimeError, match="crash after historical recovery manifest CAS"): + CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + monkeypatch.setattr(recovery, "write_durable_change_train_manifest", real_write) + exact_post_cas = train_path.read_bytes() + exact_train = load_durable_change_train_manifest(train_path) + substituted = replace( + exact_train, + proof_refs=(*exact_train.proof_refs, "proof:foreign-post-cas-substitution"), + ) + train_path.write_text( + json.dumps(durable_change_train_to_payload(substituted), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + rejected_prepared = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert rejected_prepared.exit_code != 0 + assert "neither the sealed pre-CAS nor post-CAS manifest" in rejected_prepared.output + + train_path.write_bytes(exact_post_cas) + committed = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + assert committed.exit_code == 0, committed.output + train_path.write_text( + json.dumps(durable_change_train_to_payload(substituted), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + rejected_committed = CliRunner().invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(plan_path), + "--authorize", + plan_sha256, + "--output-format", + "json", + ], + env=command_env, + catch_exceptions=False, + ) + + assert rejected_committed.exit_code != 0 + assert "neither the sealed pre-CAS nor post-CAS manifest" in rejected_committed.output def test_historical_recovery_rejects_foreign_train_authority_before_preparing( @@ -2724,7 +2974,7 @@ def test_historical_recovery_rejects_foreign_train_authority_before_preparing( ) assert applied.exit_code != 0 - assert "exact refresh proof" in applied.output + assert "neither the sealed pre-CAS nor post-CAS manifest" in applied.output plan_sha256 = str(plan["plan_sha256"]) assert not ( moved_root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan_sha256}.json" From 42ae7279a0a701c761db153869718f7feaeae9a1 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 10:26:56 +0200 Subject: [PATCH 34/39] fix: seal index generation leaf identities Bind each retained generation metadata file and tier symlink to its pre-publication device and inode. Revalidate those leaves through a pinned generation descriptor before atomic relocation publication. --- .../operations/archive_root_relocation.py | 278 ++++++++++-------- .../storage/test_archive_root_relocation.py | 101 +++++++ 2 files changed, 264 insertions(+), 115 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 570020c576..f8ea50df72 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -124,6 +124,8 @@ class RelocationIndexGenerationSymlink(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) path: str + before_device: int + before_inode: int old_target: str new_target: str @@ -137,6 +139,8 @@ class RelocationIndexGeneration(BaseModel): metadata_path: str directory_device: int directory_inode: int + metadata_before_device: int + metadata_before_inode: int before_sha256: str after_sha256: str before_archive_root: str @@ -460,80 +464,91 @@ def _index_generation_evidence( rows: list[RelocationIndexGeneration] = [] for generation_root in sorted(generations_root.glob("gen-*")): _real_directory(generation_root, label="index generation") - generation_metadata = generation_root.stat() - metadata_path = generation_root / "generation.json" - _real_file(metadata_path, label="index generation metadata") + directory_fd = -1 try: - encoded = metadata_path.read_bytes() - raw = json.loads(encoded) - except (OSError, json.JSONDecodeError) as exc: - raise ArchiveRootRelocationError("cannot read index generation metadata") from exc - if not isinstance(raw, dict): - raise ArchiveRootRelocationError("index generation metadata is not an object") - payload = cast(dict[str, object], raw) - generation_id = payload.get("generation_id") - if generation_id != generation_root.name: - raise ArchiveRootRelocationError("index generation metadata does not bind its directory") - before_archive_root, after_archive_root = _mapped_generation_path( - payload.get("archive_root"), old_root=old_root, new_root=new_root, label="archive root" - ) - if Path(after_archive_root) != new_root: - raise ArchiveRootRelocationError("index generation metadata archive root is not the destination root") - before_index_path, after_index_path = _mapped_generation_path( - payload.get("index_path"), old_root=old_root, new_root=new_root, label="index path" - ) - if Path(after_index_path) != generation_root / "index.db": - raise ArchiveRootRelocationError("index generation metadata index path does not bind its generation") - after_payload = { - **payload, - "archive_root": after_archive_root, - "index_path": after_index_path, - } - links: list[RelocationIndexGenerationSymlink] = [] - for filename in _INDEX_GENERATION_TIER_LINKS: - link = generation_root / filename - if not link.exists() and not link.is_symlink(): - continue + directory_fd = os.open( + generation_root, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + ) + generation_metadata = os.fstat(directory_fd) + encoded, metadata = _read_pinned_generation_metadata(directory_fd) try: - metadata = link.lstat() - if not stat.S_ISLNK(metadata.st_mode): - raise ArchiveRootRelocationError("index generation tier member is not a symbolic link") - old_target = os.readlink(link) - except OSError as exc: - raise ArchiveRootRelocationError("cannot read index generation tier link") from exc - raw_target = Path(old_target) - if not raw_target.is_absolute(): - raise ArchiveRootRelocationError("index generation tier link target is not absolute") - _before, new_target = _mapped_generation_path( - old_target, - old_root=old_root, - new_root=new_root, - label=f"{filename} link target", + raw = json.loads(encoded) + except json.JSONDecodeError as exc: + raise ArchiveRootRelocationError("cannot read index generation metadata") from exc + if not isinstance(raw, dict): + raise ArchiveRootRelocationError("index generation metadata is not an object") + payload = cast(dict[str, object], raw) + generation_id = payload.get("generation_id") + if generation_id != generation_root.name: + raise ArchiveRootRelocationError("index generation metadata does not bind its directory") + before_archive_root, after_archive_root = _mapped_generation_path( + payload.get("archive_root"), old_root=old_root, new_root=new_root, label="archive root" ) - if Path(new_target) != new_root / filename: - raise ArchiveRootRelocationError("index generation tier link does not bind its archive tier") - links.append( - RelocationIndexGenerationSymlink( - path=str(link), - old_target=old_target, - new_target=new_target, - ) + if Path(after_archive_root) != new_root: + raise ArchiveRootRelocationError("index generation metadata archive root is not the destination root") + before_index_path, after_index_path = _mapped_generation_path( + payload.get("index_path"), old_root=old_root, new_root=new_root, label="index path" ) - rows.append( - RelocationIndexGeneration( - generation_id=generation_id, - metadata_path=str(metadata_path), - directory_device=generation_metadata.st_dev, - directory_inode=generation_metadata.st_ino, - before_sha256=hashlib.sha256(encoded).hexdigest(), - after_sha256=hashlib.sha256(_index_generation_metadata_bytes(after_payload)).hexdigest(), - before_archive_root=before_archive_root, - after_archive_root=after_archive_root, - before_index_path=before_index_path, - after_index_path=after_index_path, - tier_symlinks=tuple(links), + if Path(after_index_path) != generation_root / "index.db": + raise ArchiveRootRelocationError("index generation metadata index path does not bind its generation") + after_payload = { + **payload, + "archive_root": after_archive_root, + "index_path": after_index_path, + } + links: list[RelocationIndexGenerationSymlink] = [] + entries = set(os.listdir(directory_fd)) + for filename in _INDEX_GENERATION_TIER_LINKS: + if filename not in entries: + continue + link = generation_root / filename + old_target, link_metadata = _read_pinned_generation_symlink(directory_fd, filename) + raw_target = Path(old_target) + if not raw_target.is_absolute(): + raise ArchiveRootRelocationError("index generation tier link target is not absolute") + _before, new_target = _mapped_generation_path( + old_target, + old_root=old_root, + new_root=new_root, + label=f"{filename} link target", + ) + if Path(new_target) != new_root / filename: + raise ArchiveRootRelocationError("index generation tier link does not bind its archive tier") + links.append( + RelocationIndexGenerationSymlink( + path=str(link), + before_device=link_metadata.st_dev, + before_inode=link_metadata.st_ino, + old_target=old_target, + new_target=new_target, + ) + ) + metadata_path = generation_root / "generation.json" + rows.append( + RelocationIndexGeneration( + generation_id=generation_id, + metadata_path=str(metadata_path), + directory_device=generation_metadata.st_dev, + directory_inode=generation_metadata.st_ino, + metadata_before_device=metadata.st_dev, + metadata_before_inode=metadata.st_ino, + before_sha256=hashlib.sha256(encoded).hexdigest(), + after_sha256=hashlib.sha256(_index_generation_metadata_bytes(after_payload)).hexdigest(), + before_archive_root=before_archive_root, + after_archive_root=after_archive_root, + before_index_path=before_index_path, + after_index_path=after_index_path, + tier_symlinks=tuple(links), + ) ) - ) + except ArchiveRootRelocationError: + raise + except OSError as exc: + raise ArchiveRootRelocationError("cannot pin index generation evidence") from exc + finally: + if directory_fd >= 0: + os.close(directory_fd) return tuple(rows) @@ -576,37 +591,24 @@ def _validate_index_generation_state( raise ArchiveRootRelocationError("archive-root relocation index generation inventory changed") for item in items: metadata_path = Path(item.metadata_path) - _real_directory(metadata_path.parent, label="index generation") - _real_file(metadata_path, label="index generation metadata") - encoded = metadata_path.read_bytes() - digest = hashlib.sha256(encoded).hexdigest() - if digest == item.before_sha256: - _index_generation_payload_for_state(item, after=False, encoded=encoded) - elif digest == item.after_sha256: - _index_generation_payload_for_state(item, after=True, encoded=encoded) - else: - raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") - expected_link_paths = {link.path for link in item.tier_symlinks} - current_link_paths = { - str(metadata_path.parent / filename) - for filename in _INDEX_GENERATION_TIER_LINKS - if (metadata_path.parent / filename).exists() or (metadata_path.parent / filename).is_symlink() - } - if current_link_paths != expected_link_paths: - raise ArchiveRootRelocationError("archive-root relocation index generation tier inventory changed") - for link in item.tier_symlinks: - path = Path(link.path) - try: - metadata = path.lstat() - if not stat.S_ISLNK(metadata.st_mode): - raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") - target = os.readlink(path) - except OSError as exc: - raise ArchiveRootRelocationError( - "archive-root relocation index generation tier link is unreadable" - ) from exc - if target not in {link.old_target, link.new_target}: - raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + directory_fd = _open_pinned_generation_directory(root, item) + try: + _pinned_generation_metadata_state(directory_fd, item) + generation_root = metadata_path.parent + for link in item.tier_symlinks: + path = Path(link.path) + if path.parent != generation_root or path.name not in _INDEX_GENERATION_TIER_LINKS: + raise ArchiveRootRelocationError( + "archive-root relocation index generation tier link path binding changed" + ) + expected_link_names = {Path(link.path).name for link in item.tier_symlinks} + current_link_names = set(_INDEX_GENERATION_TIER_LINKS).intersection(os.listdir(directory_fd)) + if current_link_names != expected_link_names: + raise ArchiveRootRelocationError("archive-root relocation index generation tier inventory changed") + for link in item.tier_symlinks: + _pinned_generation_symlink_state(directory_fd, link) + finally: + os.close(directory_fd) def _open_pinned_generation_directory(root: Path, item: RelocationIndexGeneration) -> int: @@ -651,7 +653,7 @@ def _open_pinned_generation_directory(root: Path, item: RelocationIndexGeneratio raise ArchiveRootRelocationError("cannot pin archive-root relocation index generation directory") from exc -def _read_pinned_generation_metadata(directory_fd: int) -> bytes: +def _read_pinned_generation_metadata(directory_fd: int) -> tuple[bytes, os.stat_result]: descriptor = -1 try: descriptor = os.open( @@ -665,7 +667,7 @@ def _read_pinned_generation_metadata(directory_fd: int) -> bytes: "archive-root relocation index generation metadata is not a real single-linked file" ) with os.fdopen(descriptor, "rb", closefd=False) as stream: - return stream.read() + return stream.read(), metadata except ArchiveRootRelocationError: raise except OSError as exc: @@ -677,6 +679,60 @@ def _read_pinned_generation_metadata(directory_fd: int) -> bytes: os.close(descriptor) +def _read_pinned_generation_symlink(directory_fd: int, filename: str) -> tuple[str, os.stat_result]: + """Read one generation link relative to its pinned directory without following it.""" + try: + before = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False) + if not stat.S_ISLNK(before.st_mode): + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + target = os.readlink(filename, dir_fd=directory_fd) + after = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False) + except ArchiveRootRelocationError: + raise + except OSError as exc: + raise ArchiveRootRelocationError("archive-root relocation index generation tier link is unreadable") from exc + if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed while reading") + return target, after + + +def _pinned_generation_metadata_state( + directory_fd: int, + item: RelocationIndexGeneration, +) -> tuple[bytes, bool]: + """Return metadata bytes and whether they are the exact post-publication state.""" + encoded, metadata = _read_pinned_generation_metadata(directory_fd) + digest = hashlib.sha256(encoded).hexdigest() + if digest == item.before_sha256: + if (metadata.st_dev, metadata.st_ino) != ( + item.metadata_before_device, + item.metadata_before_inode, + ): + raise ArchiveRootRelocationError("archive-root relocation index generation metadata identity changed") + _index_generation_payload_for_state(item, after=False, encoded=encoded) + return encoded, False + if digest == item.after_sha256: + _index_generation_payload_for_state(item, after=True, encoded=encoded) + return encoded, True + raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") + + +def _pinned_generation_symlink_state( + directory_fd: int, + link: RelocationIndexGenerationSymlink, +) -> bool: + """Return whether a pinned tier link is in its exact post-publication state.""" + filename = Path(link.path).name + target, metadata = _read_pinned_generation_symlink(directory_fd, filename) + if target == link.old_target: + if (metadata.st_dev, metadata.st_ino) != (link.before_device, link.before_inode): + raise ArchiveRootRelocationError("archive-root relocation index generation tier link identity changed") + return False + if target == link.new_target: + return True + raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") + + def _replace_pinned_generation_metadata(directory_fd: int, payload: bytes) -> None: temporary = f".generation.json.relocation-{uuid.uuid4().hex}.tmp" descriptor = -1 @@ -723,9 +779,8 @@ def _publish_index_generation_state( for item in items: directory_fd = _open_pinned_generation_directory(root, item) try: - encoded = _read_pinned_generation_metadata(directory_fd) - digest = hashlib.sha256(encoded).hexdigest() - if digest == item.before_sha256 and item.before_sha256 != item.after_sha256: + encoded, metadata_is_after = _pinned_generation_metadata_state(directory_fd, item) + if not metadata_is_after and item.before_sha256 != item.after_sha256: payload = _index_generation_payload_for_state(item, after=False, encoded=encoded) after_payload = { **payload, @@ -736,8 +791,6 @@ def _publish_index_generation_state( if hashlib.sha256(after_encoded).hexdigest() != item.after_sha256: raise ArchiveRootRelocationError("archive-root relocation index generation after binding changed") _replace_pinned_generation_metadata(directory_fd, after_encoded) - elif digest != item.after_sha256: - raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") generation_root = Path(item.metadata_path).parent for link in item.tier_symlinks: path = Path(link.path) @@ -745,18 +798,13 @@ def _publish_index_generation_state( raise ArchiveRootRelocationError( "archive-root relocation index generation tier link path binding changed" ) + link_is_after = _pinned_generation_symlink_state(directory_fd, link) + if link_is_after: + continue if link.old_target == link.new_target: continue temporary = f".{path.name}.relocation-{uuid.uuid4().hex}.tmp" try: - metadata = os.stat(path.name, dir_fd=directory_fd, follow_symlinks=False) - if not stat.S_ISLNK(metadata.st_mode): - raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") - current = os.readlink(path.name, dir_fd=directory_fd) - if current == link.new_target: - continue - if current != link.old_target: - raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") os.symlink(link.new_target, temporary, dir_fd=directory_fd) os.replace(temporary, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) os.fsync(directory_fd) diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 65a4f267cc..79cd8a2a65 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -22,6 +22,7 @@ from polylogue.daemon.backup import backup_archive from polylogue.operations.archive_root_relocation import ( ArchiveRootRelocationError, + ArchiveRootRelocationPlan, RelocationActiveIndexPointer, RelocationIndexGeneration, RelocationTierEvidence, @@ -810,6 +811,32 @@ def _activate_movable_index_generation(root: Path) -> Path: return Path(generation.index_path).resolve(strict=True) +def _prepare_moved_root_relocation_with_generation( + workspace_env: dict[str, Path], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> tuple[Path, ArchiveRootRelocationPlan]: + """Prepare the public moved-root relocation sequence with a retained generation.""" + old_root = workspace_env["archive_root"] + manifest = _released_moved_source_train(old_root, monkeypatch) + _activate_movable_index_generation(old_root) + _attach_retained_source_continuity(old_root, manifest) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + plan = prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + assert plan.index_generations + return new_root, plan + + def _legacy_liveness_receipt( path: Path, *, @@ -1916,6 +1943,80 @@ def swap_after_publication_preflight( assert {path.name: os.readlink(path) for path in outside.iterdir() if path.is_symlink()} == outside_links_before +def test_relocation_apply_rejects_byte_identical_generation_metadata_substitution( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Public apply authenticates the planned generation metadata object. + + Anti-vacuity: a same-directory atomic replacement preserves the exact + metadata bytes and parent directory while changing only the leaf inode. + The production apply route must reject it before retaining a plan, writing + a receipt, publishing generation state, or rebinding a durable train. + """ + new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + generation = plan.index_generations[0] + metadata_path = Path(generation.metadata_path) + encoded = metadata_path.read_bytes() + planned_identity = (metadata_path.lstat().st_dev, metadata_path.lstat().st_ino) + assert (generation.metadata_before_device, generation.metadata_before_inode) == planned_identity + substitute = metadata_path.parent / ".generation.json.substitute" + substitute.write_bytes(encoded) + os.replace(substitute, metadata_path) + substituted_identity = (metadata_path.lstat().st_dev, metadata_path.lstat().st_ino) + assert substituted_identity != planned_identity + pointer_path = new_root / ".index-active-pointer" + pointer_before = pointer_path.read_bytes() + manifests_before = {item.path: Path(item.path).read_bytes() for item in plan.durable_trains} + + with pytest.raises(ArchiveRootRelocationError, match="generation metadata identity changed"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert metadata_path.read_bytes() == encoded + assert (metadata_path.lstat().st_dev, metadata_path.lstat().st_ino) == substituted_identity + assert pointer_path.read_bytes() == pointer_before + assert {path: Path(path).read_bytes() for path in manifests_before} == manifests_before + assert not (new_root / ".maintenance-state" / "archive-root-relocation-plans" / f"{plan.plan_sha256}.json").exists() + assert not (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").exists() + + +def test_relocation_apply_rejects_equivalent_generation_tier_symlink_substitution( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Public apply authenticates each planned generation tier-link object. + + Anti-vacuity: a fresh symlink with the same absolute target preserves the + planned directory, tier inventory, and link value while changing only the + leaf inode. The production apply route must reject it before any relocation + state is published. + """ + new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + generation = plan.index_generations[0] + link = next(item for item in generation.tier_symlinks if item.old_target != item.new_target) + link_path = Path(link.path) + planned_identity = (link_path.lstat().st_dev, link_path.lstat().st_ino) + assert (link.before_device, link.before_inode) == planned_identity + substitute = link_path.parent / f".{link_path.name}.substitute" + os.symlink(link.old_target, substitute) + os.replace(substitute, link_path) + substituted_identity = (link_path.lstat().st_dev, link_path.lstat().st_ino) + assert substituted_identity != planned_identity + pointer_path = new_root / ".index-active-pointer" + pointer_before = pointer_path.read_bytes() + metadata_before = Path(generation.metadata_path).read_bytes() + manifests_before = {item.path: Path(item.path).read_bytes() for item in plan.durable_trains} + + with pytest.raises(ArchiveRootRelocationError, match="generation tier link identity changed"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert os.readlink(link_path) == link.old_target + assert (link_path.lstat().st_dev, link_path.lstat().st_ino) == substituted_identity + assert Path(generation.metadata_path).read_bytes() == metadata_before + assert pointer_path.read_bytes() == pointer_before + assert {path: Path(path).read_bytes() for path in manifests_before} == manifests_before + assert not (new_root / ".maintenance-state" / "archive-root-relocation-plans" / f"{plan.plan_sha256}.json").exists() + assert not (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").exists() + + def test_relocation_remaps_generations_beside_a_nested_active_index( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 39507e67f265aa12e4d5740d2e2241c86fe2e832 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 14:08:55 +0200 Subject: [PATCH 35/39] fix(ops): finish relocation continuity repairs Address exact-head maintenance review findings across nested CLI dispatch, backup tier selection, offline writer ownership, receipt filesystem failures, and relocation authority validation. Compatibility: retain historical backup manifests without source inode fields, connected V1 continuity refresh histories, and prepared V3 relocation plans. V3 plans may only resume an already prepared transition; new publication requires V4 leaf identities and a bound prepared receipt. --- polylogue/cli/click_command_registration.py | 3 + .../cli/commands/maintenance/__init__.py | 6 +- polylogue/daemon/backup.py | 27 +- polylogue/daemon/cli.py | 14 +- polylogue/maintenance/receipt_fs.py | 2 + .../operations/archive_root_relocation.py | 152 +++++--- .../historical_source_continuity_recovery.py | 96 +++-- .../storage/sqlite/durable_change_train.py | 27 +- polylogue/storage/sqlite/migration_runner.py | 41 ++- tests/unit/daemon/test_backup.py | 68 ++++ tests/unit/daemon/test_daemon_cli.py | 36 +- .../operations/test_maintenance_receipt_fs.py | 14 + .../storage/test_archive_root_relocation.py | 348 +++++++++++++++++- 13 files changed, 738 insertions(+), 96 deletions(-) diff --git a/polylogue/cli/click_command_registration.py b/polylogue/cli/click_command_registration.py index 6de7be4c25..295e1a1dba 100644 --- a/polylogue/cli/click_command_registration.py +++ b/polylogue/cli/click_command_registration.py @@ -88,6 +88,9 @@ class _NestedLazyGroup(_LazyGroup): """Lazy group whose newly-added nested routes dispatch through the proxy.""" def invoke(self, ctx: click.Context) -> object: + resolved = self._resolve() + if isinstance(resolved, click.Group) and self.callback is None: + self.callback = resolved.callback return click.Group.invoke(self, ctx) diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index cf68779388..ad6cd9b114 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -227,6 +227,8 @@ ), ) +_NESTED_GROUP_COMMANDS = frozenset({"archive-root-relocation", "source-continuity-recovery"}) + @click.group("maintenance") @click.pass_context @@ -249,9 +251,7 @@ def maintenance_group(ctx: click.Context) -> None: for _cli_name, _submodule, _attr, _short_help in _COMMANDS: - _command_type = ( - _NestedLazyGroup if _cli_name in {"archive-root-relocation", "source-continuity-recovery"} else _LazyCommand - ) + _command_type = _NestedLazyGroup if _cli_name in _NESTED_GROUP_COMMANDS else _LazyCommand maintenance_group.add_command( _command_type( _cli_name, diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index 4faaa2a9ff..981cb8dba8 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -184,6 +184,20 @@ def _sqlite_user_version(path: Path) -> int: return int(conn.execute("PRAGMA user_version").fetchone()[0] or 0) +def _readable_sqlite_index(path: Path) -> bool: + """Return whether an active-pointer candidate is a readable SQLite index. + + Backup follows a genuinely live external index target, but a malformed or + stale pointer must not turn an otherwise valid backup into a copy of + arbitrary bytes. Relocation still authenticates that pointer separately. + """ + try: + _sqlite_user_version(path) + except (OSError, sqlite3.Error): + return False + return True + + def _sqlite_source_fingerprint(path: Path) -> dict[str, object]: metadata = path.stat() return { @@ -232,6 +246,14 @@ def _all_archive_tiers(root: Path) -> dict[str, Path]: configured_target = Path(raw_target) if not configured_target.is_absolute() or configured_target.name != "index.db": return tiers + if ( + not configured_target.is_relative_to(root.absolute()) + and configured_target.is_file() + and not configured_target.is_symlink() + and _readable_sqlite_index(configured_target) + ): + tiers["index"] = configured_target + return tiers if ( configured_target.is_relative_to(root.absolute()) and configured_target.is_file() @@ -246,7 +268,9 @@ def _all_archive_tiers(root: Path) -> dict[str, Path]: # conventional symlink paired with that pointer, including a canonical # index below the archive root rather than assuming ``root/index.db``. mapped_candidates: list[tuple[int, Path]] = [] - for conventional in root.rglob("index.db"): + target_parts = configured_target.relative_to(configured_target.anchor).parts + conventional_candidates = tuple(root.joinpath(*target_parts[-depth:]) for depth in range(1, len(target_parts) + 1)) + for conventional in dict.fromkeys(conventional_candidates): relative_conventional = conventional.relative_to(root) if ".index-generations" in relative_conventional.parts: continue @@ -273,6 +297,7 @@ def _all_archive_tiers(root: Path) -> dict[str, Path]: and relative.parts[-1] == "index.db" and mapped.is_file() and not mapped.is_symlink() + and _readable_sqlite_index(mapped) ): mapped_candidates.append((len(relative_parts), mapped)) longest_suffix = max((length for length, _path in mapped_candidates), default=0) diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index ff5ac6d06c..8fa040b0b9 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -2136,7 +2136,7 @@ async def run_live_watcher( *, sources: tuple[WatchSource, ...], debounce_s: float, -) -> None: +) -> bool: from polylogue.daemon.events import emit_catch_up_cycle from polylogue.paths import archive_root from polylogue.product.raw_authority import archive_writer_rebuild_exclusion @@ -2146,6 +2146,7 @@ async def run_live_watcher( with archive_writer_rebuild_exclusion(archive_root_path) as rebuild_exclusion: coordinator = daemon_write_coordinator() watcher: LiveWatcher | None = None + writer_drained = False try: async with Polylogue() as polylogue: watcher = LiveWatcher( @@ -2163,11 +2164,12 @@ async def run_live_watcher( if watcher is not None: watcher.stop() finally: - await _shutdown_writer_coordinator_with_rebuild_exclusion( + writer_drained = await _shutdown_writer_coordinator_with_rebuild_exclusion( coordinator, rebuild_exclusion, timeout=5.0, ) + return writer_drained async def run_daemon_services( @@ -3426,12 +3428,16 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: archive_root_path, owner_id=f"watch:{os.getpid()}", ) + writer_drained = False + watcher_started = False try: assert_no_prepared_archive_root_relocation(archive_root_path) assert_no_prepared_historical_source_continuity_recovery(archive_root_path) - asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) + watcher_started = True + writer_drained = asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) finally: - archive_owner.release() + if not watcher_started or writer_drained: + archive_owner.release() __all__ = [ diff --git a/polylogue/maintenance/receipt_fs.py b/polylogue/maintenance/receipt_fs.py index dad95e1e5c..306cd9dac3 100644 --- a/polylogue/maintenance/receipt_fs.py +++ b/polylogue/maintenance/receipt_fs.py @@ -75,6 +75,8 @@ def _maintenance_receipt_directory(archive_root: Path, directory_name: str, *, c created_child = True except FileExistsError: pass + except OSError as exc: + raise MaintenanceReceiptPathError(f"cannot create maintenance receipt directory: {child_name}") from exc child_fd = _open_directory(child_name, label="maintenance receipt directory", parent_fd=state_fd) if created_child: child_metadata = os.fstat(child_fd) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index f8ea50df72..7e203a4634 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -10,7 +10,7 @@ import stat import tempfile import uuid -from contextlib import suppress +from contextlib import closing, suppress from pathlib import Path from typing import Literal, cast @@ -60,14 +60,15 @@ ) from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec -PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v3"] = "polylogue.archive-root-relocation-plan.v3" +PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v4"] = "polylogue.archive-root-relocation-plan.v4" +_LEGACY_PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v3"] = "polylogue.archive-root-relocation-plan.v3" RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = "polylogue.archive-root-relocation-receipt.v1" _TIER_NAMES = tuple(tier.value for tier in ArchiveTier) _DURABLE_TIER_NAMES = ("source", "user", "audit") _SIDECARS = ("-wal", "-shm", "-journal") -class ArchiveRootRelocationError(RuntimeError): +class ArchiveRootRelocationError(DurableChangeTrainError): """The requested root move has no single safe offline transition.""" @@ -124,8 +125,8 @@ class RelocationIndexGenerationSymlink(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) path: str - before_device: int - before_inode: int + before_device: int | None = None + before_inode: int | None = None old_target: str new_target: str @@ -139,8 +140,8 @@ class RelocationIndexGeneration(BaseModel): metadata_path: str directory_device: int directory_inode: int - metadata_before_device: int - metadata_before_inode: int + metadata_before_device: int | None = None + metadata_before_inode: int | None = None before_sha256: str after_sha256: str before_archive_root: str @@ -153,7 +154,10 @@ class RelocationIndexGeneration(BaseModel): class ArchiveRootRelocationPlan(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - format: Literal["polylogue.archive-root-relocation-plan.v3"] = PLAN_FORMAT + format: Literal[ + "polylogue.archive-root-relocation-plan.v3", + "polylogue.archive-root-relocation-plan.v4", + ] = PLAN_FORMAT old_configured_root: str old_resolved_root: str backup_root_device: int @@ -214,7 +218,11 @@ def _canonical_sha256(payload: object) -> str: def _sealed_plan(**values: object) -> ArchiveRootRelocationPlan: plan = ArchiveRootRelocationPlan.model_validate({"format": PLAN_FORMAT, **values, "plan_sha256": ""}) - payload = plan.model_dump(mode="json", exclude={"plan_sha256"}) + payload = plan.model_dump( + mode="json", + exclude={"plan_sha256"}, + exclude_none=plan.format == _LEGACY_PLAN_FORMAT, + ) return plan.model_copy(update={"plan_sha256": _canonical_sha256(payload)}) @@ -225,9 +233,21 @@ def _sealed_receipt(**values: object) -> ArchiveRootRelocationReceipt: def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: - expected = _canonical_sha256(plan.model_dump(exclude={"plan_sha256"}, mode="json")) + expected = _canonical_sha256( + plan.model_dump( + exclude={"plan_sha256"}, + mode="json", + exclude_none=plan.format == _LEGACY_PLAN_FORMAT, + ) + ) if plan.plan_sha256 != expected: raise ArchiveRootRelocationError("archive-root relocation plan checksum mismatch") + if plan.format == PLAN_FORMAT: + for generation in plan.index_generations: + if generation.metadata_before_device is None or generation.metadata_before_inode is None: + raise ArchiveRootRelocationError("archive-root relocation v4 plan lacks generation metadata identity") + if any(link.before_device is None or link.before_inode is None for link in generation.tier_symlinks): + raise ArchiveRootRelocationError("archive-root relocation v4 plan lacks generation tier-link identity") def _verify_receipt(receipt: ArchiveRootRelocationReceipt) -> None: @@ -299,7 +319,7 @@ def _tier_snapshot( else: metadata = _real_file(path, label=f"{tier.value} tier") try: - with sqlite3.connect(f"file:{resolved_path}?mode=ro&immutable=1", uri=True) as connection: + with closing(sqlite3.connect(f"file:{resolved_path}?mode=ro&immutable=1", uri=True)) as connection: if tier is ArchiveTier.EMBEDDINGS: loaded, error = try_load_sqlite_vec(connection) if not loaded: @@ -577,6 +597,8 @@ def _validate_index_generation_state( root: Path, items: tuple[RelocationIndexGeneration, ...], active_index_pointer: RelocationActiveIndexPointer | None, + *, + allow_post_publication: bool, ) -> None: generations_root = _index_generations_root(root, active_index_pointer) if generations_root.exists() or generations_root.is_symlink(): @@ -593,7 +615,11 @@ def _validate_index_generation_state( metadata_path = Path(item.metadata_path) directory_fd = _open_pinned_generation_directory(root, item) try: - _pinned_generation_metadata_state(directory_fd, item) + _pinned_generation_metadata_state( + directory_fd, + item, + allow_post_publication=allow_post_publication, + ) generation_root = metadata_path.parent for link in item.tier_symlinks: path = Path(link.path) @@ -606,7 +632,11 @@ def _validate_index_generation_state( if current_link_names != expected_link_names: raise ArchiveRootRelocationError("archive-root relocation index generation tier inventory changed") for link in item.tier_symlinks: - _pinned_generation_symlink_state(directory_fd, link) + _pinned_generation_symlink_state( + directory_fd, + link, + allow_post_publication=allow_post_publication, + ) finally: os.close(directory_fd) @@ -699,19 +729,26 @@ def _read_pinned_generation_symlink(directory_fd: int, filename: str) -> tuple[s def _pinned_generation_metadata_state( directory_fd: int, item: RelocationIndexGeneration, + *, + allow_post_publication: bool, ) -> tuple[bytes, bool]: """Return metadata bytes and whether they are the exact post-publication state.""" encoded, metadata = _read_pinned_generation_metadata(directory_fd) digest = hashlib.sha256(encoded).hexdigest() if digest == item.before_sha256: - if (metadata.st_dev, metadata.st_ino) != ( - item.metadata_before_device, - item.metadata_before_inode, + if ( + item.metadata_before_device is not None + and item.metadata_before_inode is not None + and (metadata.st_dev, metadata.st_ino) != (item.metadata_before_device, item.metadata_before_inode) ): raise ArchiveRootRelocationError("archive-root relocation index generation metadata identity changed") _index_generation_payload_for_state(item, after=False, encoded=encoded) return encoded, False if digest == item.after_sha256: + if not allow_post_publication: + raise ArchiveRootRelocationError( + "archive-root relocation generation metadata reached its post-publication state without a prepared receipt" + ) _index_generation_payload_for_state(item, after=True, encoded=encoded) return encoded, True raise ArchiveRootRelocationError("archive-root relocation index generation metadata changed") @@ -720,15 +757,25 @@ def _pinned_generation_metadata_state( def _pinned_generation_symlink_state( directory_fd: int, link: RelocationIndexGenerationSymlink, + *, + allow_post_publication: bool, ) -> bool: """Return whether a pinned tier link is in its exact post-publication state.""" filename = Path(link.path).name target, metadata = _read_pinned_generation_symlink(directory_fd, filename) if target == link.old_target: - if (metadata.st_dev, metadata.st_ino) != (link.before_device, link.before_inode): + if ( + link.before_device is not None + and link.before_inode is not None + and (metadata.st_dev, metadata.st_ino) != (link.before_device, link.before_inode) + ): raise ArchiveRootRelocationError("archive-root relocation index generation tier link identity changed") return False if target == link.new_target: + if not allow_post_publication: + raise ArchiveRootRelocationError( + "archive-root relocation generation tier link reached its post-publication state without a prepared receipt" + ) return True raise ArchiveRootRelocationError("archive-root relocation index generation tier link changed") @@ -775,11 +822,15 @@ def _publish_index_generation_state( active_index_pointer: RelocationActiveIndexPointer | None, ) -> None: """CAS-publish mapped metadata and links; exact after states are idempotent.""" - _validate_index_generation_state(root, items, active_index_pointer) + _validate_index_generation_state(root, items, active_index_pointer, allow_post_publication=True) for item in items: directory_fd = _open_pinned_generation_directory(root, item) try: - encoded, metadata_is_after = _pinned_generation_metadata_state(directory_fd, item) + encoded, metadata_is_after = _pinned_generation_metadata_state( + directory_fd, + item, + allow_post_publication=True, + ) if not metadata_is_after and item.before_sha256 != item.after_sha256: payload = _index_generation_payload_for_state(item, after=False, encoded=encoded) after_payload = { @@ -798,7 +849,11 @@ def _publish_index_generation_state( raise ArchiveRootRelocationError( "archive-root relocation index generation tier link path binding changed" ) - link_is_after = _pinned_generation_symlink_state(directory_fd, link) + link_is_after = _pinned_generation_symlink_state( + directory_fd, + link, + allow_post_publication=True, + ) if link_is_after: continue if link.old_target == link.new_target: @@ -819,7 +874,7 @@ def _publish_index_generation_state( os.unlink(temporary, dir_fd=directory_fd) finally: os.close(directory_fd) - _validate_index_generation_state(root, items, active_index_pointer) + _validate_index_generation_state(root, items, active_index_pointer, allow_post_publication=True) def _validate_active_index_pointer( @@ -1445,25 +1500,25 @@ def _validate_plan_continuity_binding( if ref.startswith("proof:source-continuity-relocation:") ) matches = 0 - for digest in transition_refs: - path = root / ".maintenance-state" / "source-continuity-relocations" / f"{digest}.json" - try: - with existing_maintenance_receipt_directory(root, "source-continuity-relocations") as directory_fd: + try: + with existing_maintenance_receipt_directory(root, "source-continuity-relocations") as directory_fd: + for digest in transition_refs: + path = root / ".maintenance-state" / "source-continuity-relocations" / f"{digest}.json" encoded = None if directory_fd is None else read_optional_receipt(directory_fd, path.name) - if encoded is None: - raise ArchiveRootRelocationError("archive-root relocation exact transition proof is missing") - payload = json.loads(encoded) - except (MaintenanceReceiptPathError, json.JSONDecodeError) as exc: - raise ArchiveRootRelocationError("archive-root relocation exact transition proof is unreadable") from exc - if not isinstance(payload, dict) or payload.pop("transition_sha256", None) != digest: - raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") - if _canonical_sha256(payload) != digest: - raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") - if ( - payload.get("relocation_plan_sha256") == plan.plan_sha256 - and payload.get("relocation_receipt_sha256") == receipt_digest - ): - matches += 1 + if encoded is None: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof is missing") + payload = json.loads(encoded) + if not isinstance(payload, dict) or payload.pop("transition_sha256", None) != digest: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") + if _canonical_sha256(payload) != digest: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof changed") + if ( + payload.get("relocation_plan_sha256") == plan.plan_sha256 + and payload.get("relocation_receipt_sha256") == receipt_digest + ): + matches += 1 + except (MaintenanceReceiptPathError, json.JSONDecodeError) as exc: + raise ArchiveRootRelocationError("archive-root relocation exact transition proof is unreadable") from exc if matches != 1: raise ArchiveRootRelocationError("archive-root relocation exact transition proof is missing") @@ -1503,6 +1558,17 @@ def _revalidate_plan_live_state( backup_tiers = {item.tier: (item.backup_device, item.backup_inode) for item in plan.tiers} if len(plan.tiers) != len(ArchiveTier) or set(backup_tiers) != {tier.value for tier in ArchiveTier}: raise ArchiveRootRelocationError("archive-root relocation plan tier evidence is incomplete") + pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) + prepared_publication = pending_receipt is not None and ( + pending_receipt.state in {"prepared", "committed"} + and pending_receipt.revision >= 1 + and pending_receipt.prepared_receipt_sha256 is not None + and len(pending_receipt.manifest_after_sha256) == len(plan.durable_trains) + ) + if plan.format == _LEGACY_PLAN_FORMAT and not prepared_publication: + raise ArchiveRootRelocationError( + "archive-root relocation v3 plan lacks sealed leaf identities before publication; create a v4 plan" + ) snapshots = tuple( _tier_snapshot( root, @@ -1517,8 +1583,12 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError("archive-root relocation tier evidence changed") _check_backup_against_live(root, manifest=manifest, receipt=receipt, snapshots=snapshots) _validate_active_index_pointer(root, plan.active_index_pointer) - _validate_index_generation_state(root, plan.index_generations, plan.active_index_pointer) - pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) + _validate_index_generation_state( + root, + plan.index_generations, + plan.active_index_pointer, + allow_post_publication=prepared_publication, + ) allowed_pending_relocation_receipt_sha256 = ( (pending_receipt.prepared_receipt_sha256 or pending_receipt.receipt_sha256) if pending_receipt is not None and pending_receipt.state == "prepared" diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index d4ceb50f22..e21ad8b00a 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -52,6 +52,7 @@ DurableChangeTrain, DurableChangeTrainError, DurableChangeTrainState, + _durable_train_manifest_sha256, _read_source_continuity_refresh_receipt, _released_train_manifests_by_target, _require_released_train_chain, @@ -62,9 +63,9 @@ ) from polylogue.storage.sqlite.migration_runner import ( DurableDatabaseEvidence, + _canonical_json_sha256, capture_durable_database_evidence, capture_durable_schema_inventory, - durable_change_train_to_payload, ) PLAN_FORMAT: Literal["polylogue.historical-source-continuity-recovery-plan.v2"] = ( @@ -76,7 +77,7 @@ _HISTORICAL_OPERATION_EVIDENCE_RESOURCE = "historical-source-continuity-operation-20260807.json" -class HistoricalSourceContinuityRecoveryError(RuntimeError): +class HistoricalSourceContinuityRecoveryError(DurableChangeTrainError): """Historical evidence cannot prove this one recovery transition.""" @@ -101,10 +102,10 @@ class HistoricalSourceContinuityRecoveryPlan(BaseModel): post_backup_manifest_sha256: str post_backup_receipt_path: str post_backup_receipt_sha256: str - pre_backup_source_device: int - pre_backup_source_inode: int - post_backup_source_device: int - post_backup_source_inode: int + pre_backup_source_device: int | None + pre_backup_source_inode: int | None + post_backup_source_device: int | None + post_backup_source_inode: int | None new_source_device: int new_source_inode: int refresh_proof_id: str @@ -173,18 +174,6 @@ def _sha256(path: Path) -> str: return digest.hexdigest() -def _canonical_json_sha256(payload: object) -> str: - return hashlib.sha256( - json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True).encode("utf-8") - ).hexdigest() - - -def _train_manifest_sha256(train: DurableChangeTrain) -> str: - payload = durable_change_train_to_payload(train) - encoded = (json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - def _real_file(path: Path, *, label: str) -> None: try: metadata = path.lstat() @@ -295,7 +284,7 @@ def _verify_receipt(receipt: HistoricalSourceContinuityRecoveryReceipt) -> None: def _backup_source_evidence( manifest_path: Path, *, old_source_path: Path -) -> tuple[Path, dict[str, object], DurableDatabaseEvidence, tuple[int, int]]: +) -> tuple[Path, dict[str, object], DurableDatabaseEvidence, tuple[int, int] | None]: """Authenticate one old-path source backup without assuming it is full-evidence.""" _real_file(manifest_path, label="historical backup manifest") backup_root = _real_directory(manifest_path.parent, label="historical backup directory") @@ -329,8 +318,10 @@ def _backup_source_evidence( if fingerprint.get("path") != str(old_source_path) or artifact.get("source_fingerprint") != fingerprint: raise HistoricalSourceContinuityRecoveryError("historical backup source path authority changed") device, inode = fingerprint.get("device"), fingerprint.get("inode") - if type(device) is not int or type(inode) is not int: - raise HistoricalSourceContinuityRecoveryError("historical backup lacks authenticated source device/inode") + if (device is None) != (inode is None) or ( + device is not None and (type(device) is not int or type(inode) is not int) + ): + raise HistoricalSourceContinuityRecoveryError("historical backup has malformed source device/inode authority") backup_source = backup_root / "source.db" _real_file(backup_source, label="historical backup source.db") actual = {"sha256": _sha256(backup_source), "size_bytes": backup_source.stat().st_size} @@ -346,7 +337,8 @@ def _backup_source_evidence( or artifact.get("user_version") != evidence.user_version ): raise HistoricalSourceContinuityRecoveryError("historical backup source version differs from its receipt") - return receipt_path, manifest, evidence, (device, inode) + source_identity = None if device is None else (device, cast(int, inode)) + return receipt_path, manifest, evidence, source_identity def _require_source_identity(root: Path, *, device: int, inode: int, label: str) -> TierFileIdentity: @@ -359,14 +351,25 @@ def _require_source_identity(root: Path, *, device: int, inode: int, label: str) return identity +def _sealed_optional_source_identity(device: int | None, inode: int | None) -> tuple[int, int] | None: + """Decode an optional legacy backup identity without accepting a half-pair.""" + if device is None and inode is None: + return None + if type(device) is not int or type(inode) is not int: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery plan has malformed source identity" + ) + return device, inode + + def _refresh_proof_id( *, old_root: Path, new_root: Path, source_train_sha256: str, historical_evidence_sha256: str, - pre_identity: tuple[int, int], - post_identity: tuple[int, int], + pre_identity: tuple[int, int] | None, + post_identity: tuple[int, int] | None, new_identity: TierFileIdentity, ) -> str: return _canonical_json_sha256( @@ -814,10 +817,16 @@ def prepare_historical_source_continuity_recovery( raise HistoricalSourceContinuityRecoveryError( "historical backups do not retain one authenticated source identity" ) - new_source_identity = _require_source_identity( - root, device=pre_identity[0], inode=pre_identity[1], label="pre backup" - ) - _require_source_identity(root, device=post_identity[0], inode=post_identity[1], label="post backup") + if pre_identity is None: + new_source_identity = TierFileIdentity.resolve("source", root / "source.db") + if not new_source_identity.exists: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source.db is missing") + else: + assert post_identity is not None + new_source_identity = _require_source_identity( + root, device=pre_identity[0], inode=pre_identity[1], label="pre backup" + ) + _require_source_identity(root, device=post_identity[0], inode=post_identity[1], label="post backup") assert new_source_identity.device is not None and new_source_identity.inode is not None candidates, candidate_digest = _legacy_liveness_receipt( mutation_receipt, old_source_path=old_source, pre_manifest=pre_backup_manifest.absolute() @@ -910,8 +919,8 @@ def prepare_historical_source_continuity_recovery( "historical_bridge": { "pre_backup": _sha256(pre_backup_manifest), "post_backup": _sha256(post_backup_manifest), - "pre_backup_source_identity": list(pre_identity), - "post_backup_source_identity": list(post_identity), + "pre_backup_source_identity": None if pre_identity is None else list(pre_identity), + "post_backup_source_identity": None if post_identity is None else list(post_identity), "new_source_identity": [new_source_identity.device, new_source_identity.inode], "legacy_candidate_count": candidates, "legacy_candidate_digest": candidate_digest, @@ -942,10 +951,10 @@ def prepare_historical_source_continuity_recovery( post_backup_manifest_sha256=_sha256(post_backup_manifest), post_backup_receipt_path=str(post_receipt), post_backup_receipt_sha256=_sha256(post_receipt), - pre_backup_source_device=pre_identity[0], - pre_backup_source_inode=pre_identity[1], - post_backup_source_device=post_identity[0], - post_backup_source_inode=post_identity[1], + pre_backup_source_device=None if pre_identity is None else pre_identity[0], + pre_backup_source_inode=None if pre_identity is None else pre_identity[1], + post_backup_source_device=None if post_identity is None else post_identity[0], + post_backup_source_inode=None if post_identity is None else post_identity[1], new_source_device=new_source_identity.device, new_source_inode=new_source_identity.inode, refresh_proof_id=refresh_proof_id, @@ -953,7 +962,7 @@ def prepare_historical_source_continuity_recovery( source_train_path=str(train_path), source_train_revision=train.revision, source_train_sha256=_sha256(train_path), - source_train_after_sha256=_train_manifest_sha256(expected_train), + source_train_after_sha256=_durable_train_manifest_sha256(expected_train), source_before=source_before, source_after=_evidence_payload(current), census=census, @@ -1119,15 +1128,24 @@ def _revalidate( post_receipt, _m2, post, post_identity = _backup_source_evidence( Path(plan.post_backup_manifest_path), old_source_path=old_source ) - if pre_identity != (plan.pre_backup_source_device, plan.pre_backup_source_inode) or post_identity != ( + expected_pre_identity = _sealed_optional_source_identity( + plan.pre_backup_source_device, + plan.pre_backup_source_inode, + ) + expected_post_identity = _sealed_optional_source_identity( plan.post_backup_source_device, plan.post_backup_source_inode, - ): + ) + if pre_identity != expected_pre_identity: + raise HistoricalSourceContinuityRecoveryError("historical continuity recovery backup identity changed") + if post_identity != expected_post_identity: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery backup identity changed") new_identity = _require_source_identity( root, device=plan.new_source_device, inode=plan.new_source_inode, label="sealed destination" ) - if (new_identity.device, new_identity.inode) != pre_identity or pre_identity != post_identity: + if pre_identity is not None and ( + (new_identity.device, new_identity.inode) != pre_identity or pre_identity != post_identity + ): raise HistoricalSourceContinuityRecoveryError("historical continuity recovery source identity changed") bindings = ( (Path(plan.mutation_receipt_path), plan.mutation_receipt_sha256), @@ -1287,7 +1305,7 @@ def _apply_historical_source_continuity_recovery_locked( updated = recover_released_source_train_continuity( train, current_evidence=planned_current, proof_ref="proof:source-continuity-refresh:" + refresh_digest ) - if _train_manifest_sha256(updated) != plan.source_train_after_sha256: + if _durable_train_manifest_sha256(updated) != plan.source_train_after_sha256: raise HistoricalSourceContinuityRecoveryError( "historical continuity recovery post-CAS train binding changed" ) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 0e3b50d2ce..eaf3937eba 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1233,6 +1233,26 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, for digest, payload in refresh_payloads.items(): if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT: register_predecessor(_SourceContinuityAuthorityRef("refresh", digest), payload, required=False) + for digest, payload in refresh_payloads.items(): + if payload.get("format") != _SOURCE_CONTINUITY_REFRESH_V1_FORMAT: + continue + ref = _SourceContinuityAuthorityRef("refresh", digest) + source_before = payload.get("source_before") + candidates = [ + _SourceContinuityAuthorityRef("refresh", candidate_digest) + for candidate_digest, candidate_payload in refresh_payloads.items() + if candidate_digest != digest + and candidate_payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT + and candidate_payload.get("source_after") == source_before + ] + if len(candidates) > 1: + raise DurableChangeTrainError("legacy source continuity authority has ambiguous predecessor evidence") + if candidates: + predecessor = candidates[0] + if predecessor in successor_by_authority: + raise DurableChangeTrainError("source continuity authority branches ambiguously") + predecessors[ref] = predecessor + successor_by_authority[predecessor] = ref for ref, payload in relocation_payloads.items(): register_predecessor(ref, payload, required=True) @@ -1240,8 +1260,11 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, **{ _SourceContinuityAuthorityRef("refresh", digest): payload for digest, payload in refresh_payloads.items() - if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT - and payload.get("predecessor_authority") is not None + if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT + or ( + payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V2_FORMAT + and payload.get("predecessor_authority") is not None + ) }, **relocation_payloads, } diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 4d4c27a646..86f236aaca 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -540,6 +540,7 @@ def _validated_receipt_artifacts( *, target_tier: str | None, live_tier_path: Path | None, + live_tier_paths: Mapping[str, Path] | None = None, file_evidence: dict[str, dict[str, object]], ) -> dict[str, dict[str, object]]: included = _json_str_list(manifest.get("included_tiers")) @@ -564,7 +565,13 @@ def _validated_receipt_artifacts( backup_root, artifact, file_evidence=file_evidence, - live_tier_path=live_tier_path if target_tier is not None and tier == target_tier else None, + live_tier_path=( + live_tier_paths.get(tier) + if live_tier_paths is not None + else live_tier_path + if target_tier is not None and tier == target_tier + else None + ), ) by_tier[tier] = artifact if set(by_tier) != {name.removesuffix(".db") for name in included}: @@ -733,6 +740,7 @@ def _validate_closed_backup_package( *, target_tier: str | None, live_tier_path: Path | None, + live_tier_paths: Mapping[str, Path] | None = None, ) -> dict[str, dict[str, object]]: """Re-hash the complete closed package bound by a successful receipt.""" artifact_inventory = _cached_backup_artifact_inventory(backup_root) @@ -748,6 +756,7 @@ def _validate_closed_backup_package( receipt, target_tier=target_tier, live_tier_path=live_tier_path, + live_tier_paths=live_tier_paths, file_evidence=file_evidence, ) _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) @@ -851,6 +860,10 @@ def validate_full_evidence_backup_for_archive_root_relocation( ) -> tuple[Path, Path, dict[str, object], dict[str, object]]: """Authenticate complete full-evidence backup at the moved archive root.""" manifest_path, receipt_path, backup_root, manifest, receipt = _load_verified_backup_package(path) + resolved_backup_root = backup_root.resolve(strict=True) + resolved_archive_root = backup_archive_root.resolve(strict=True) + if resolved_backup_root == resolved_archive_root or resolved_backup_root.is_relative_to(resolved_archive_root): + raise MigrationError("archive-root relocation backup root must be separate from the moved archive root") if manifest.get("profile") != "full_evidence": raise MigrationError("archive-root relocation requires a verified full_evidence backup") expected_tiers = {f"{tier.value}.db" for tier in ArchiveTier} @@ -867,16 +880,36 @@ def validate_full_evidence_backup_for_archive_root_relocation( raise MigrationError( f"archive-root relocation moved-root authority failed for {tier.value}: {exc}" ) from exc + fingerprints = manifest.get("tier_source_fingerprints") + if not isinstance(fingerprints, dict): + raise MigrationError("archive-root relocation backup lacks complete tier evidence") + index_fingerprint = fingerprints.get("index.db") + if not isinstance(index_fingerprint, dict): + raise MigrationError("archive-root relocation backup lacks active index path authority") + recorded_index_path = index_fingerprint.get("path") + if not isinstance(recorded_index_path, str): + raise MigrationError("archive-root relocation backup lacks moved-tier path authority for index.db") + active_index_path = Path(recorded_index_path) + archive_root = backup_archive_root.resolve(strict=True) + if not active_index_path.is_absolute() or not active_index_path.resolve(strict=False).is_relative_to(archive_root): + raise MigrationError("archive-root relocation active index path escapes the moved archive root") + try: + active_index_metadata = active_index_path.lstat() + except OSError as exc: + raise MigrationError("archive-root relocation active index path is unavailable") from exc + if stat.S_ISLNK(active_index_metadata.st_mode) or not stat.S_ISREG(active_index_metadata.st_mode): + raise MigrationError("archive-root relocation active index path is not a real file") validated_artifacts = _validate_closed_backup_package( backup_root, manifest, receipt, target_tier=None, live_tier_path=None, + live_tier_paths={ + tier.value: active_index_path if tier is ArchiveTier.INDEX else backup_archive_root / f"{tier.value}.db" + for tier in ArchiveTier + }, ) - fingerprints = manifest.get("tier_source_fingerprints") - if not isinstance(fingerprints, dict): - raise MigrationError("archive-root relocation backup lacks complete tier evidence") if set(fingerprints) != expected_tiers or set(validated_artifacts) != {tier.value for tier in ArchiveTier}: raise MigrationError("archive-root relocation backup tier evidence is incomplete") for filename, fingerprint in fingerprints.items(): diff --git a/tests/unit/daemon/test_backup.py b/tests/unit/daemon/test_backup.py index 8fcf7bd692..46bff526d4 100644 --- a/tests/unit/daemon/test_backup.py +++ b/tests/unit/daemon/test_backup.py @@ -124,6 +124,74 @@ def test_backup_archive_includes_archive_files( assert marker == "native-user" +def test_backup_uses_a_valid_external_active_index_target(workspace_env: dict[str, Path], tmp_path: Path) -> None: + """The active pointer wins over a stale conventional index file. + + Anti-vacuity: the real tier selector receives an absolute, readable index + outside the archive root. Root-only fallback would silently back up the + stale conventional index instead. + """ + root = workspace_env["archive_root"] + conventional = root / "index.db" + with sqlite3.connect(conventional) as connection: + connection.execute("CREATE TABLE marker (value TEXT NOT NULL)") + connection.execute("INSERT INTO marker VALUES ('stale')") + external = tmp_path / "external" / "index.db" + external.parent.mkdir() + with sqlite3.connect(external) as connection: + connection.execute("CREATE TABLE marker (value TEXT NOT NULL)") + connection.execute("INSERT INTO marker VALUES ('active')") + pointer = root / ".index-active-pointer" + pointer.unlink(missing_ok=True) + pointer.write_text(str(external) + "\n", encoding="utf-8") + + assert backup_mod._all_archive_tiers(root)["index"] == external + + +def test_backup_ignores_an_invalid_external_active_index_target(workspace_env: dict[str, Path], tmp_path: Path) -> None: + """A malformed external pointer cannot poison full-evidence backup input.""" + root = workspace_env["archive_root"] + conventional = root / "index.db" + with sqlite3.connect(conventional) as connection: + connection.execute("CREATE TABLE marker (value TEXT NOT NULL)") + connection.execute("INSERT INTO marker VALUES ('conventional')") + external = tmp_path / "external" / "index.db" + external.parent.mkdir() + external.write_bytes(b"not a sqlite database") + pointer = root / ".index-active-pointer" + pointer.unlink(missing_ok=True) + pointer.write_text(str(external) + "\n", encoding="utf-8") + + assert backup_mod._all_archive_tiers(root)["index"] == conventional + + +def test_backup_maps_a_retired_nested_active_index_without_recursive_search( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A moved-root active pointer uses only its bounded path suffixes. + + Anti-vacuity: the real selector must map the retired nested conventional + link to its new generation file while recursive traversal is unavailable. + """ + root = workspace_env["archive_root"] + nested = root / "nested" + generation = nested / ".index-generations" / "gen-retained" / "index.db" + generation.parent.mkdir(parents=True) + with sqlite3.connect(generation) as connection: + connection.execute("CREATE TABLE marker (value TEXT NOT NULL)") + retired_root = root.parent / "retired-archive" + retired_index = retired_root / "nested" / "index.db" + nested_index = nested / "index.db" + nested_index.parent.mkdir(exist_ok=True) + nested_index.symlink_to(retired_index.parent / ".index-generations" / "gen-retained" / "index.db") + pointer = root / ".index-active-pointer" + pointer.unlink(missing_ok=True) + pointer.write_text(str(retired_index) + "\n", encoding="utf-8") + monkeypatch.setattr(Path, "rglob", lambda *_args, **_kwargs: pytest.fail("fallback must remain bounded")) + + assert backup_mod._all_archive_tiers(root)["index"] == generation + + def test_backup_retries_a_writer_commit_between_checkpoint_and_lock( workspace_env: dict[str, Path], tmp_path: Path, diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 8dc14159c3..375ab4082a 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -2476,10 +2476,11 @@ def stop(self) -> None: patch.object(daemon_cli, "LiveWatcher", FakeWatcher), patch.object(daemon_cli, "daemon_write_coordinator", return_value=Coordinator()), ): - asyncio.run(daemon_cli.run_live_watcher(sources=sources, debounce_s=1.0)) + writer_drained = asyncio.run(daemon_cli.run_live_watcher(sources=sources, debounce_s=1.0)) assert stopped == [True] assert shutdown_timeouts == [5.0] + assert writer_drained is True def test_run_live_watcher_refuses_before_entry_while_rebuild_lease_is_held( @@ -4186,6 +4187,39 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( admission.assert_called_with(configured_alias) watcher.assert_not_called() + from polylogue.operations.historical_source_continuity_recovery import HistoricalSourceContinuityRecoveryError + + continuity_admission = Mock( + side_effect=HistoricalSourceContinuityRecoveryError( + "historical source continuity recovery is prepared but incomplete; rerun resume-command" + ) + ) + monkeypatch.setattr( + "polylogue.operations.archive_root_relocation.assert_no_prepared_archive_root_relocation", + lambda _root: None, + ) + monkeypatch.setattr( + "polylogue.operations.historical_source_continuity_recovery.assert_no_prepared_historical_source_continuity_recovery", + continuity_admission, + ) + + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + asyncio.run( + daemon_cli.run_daemon_services( + sources=(), + debounce_s=1.0, + enable_watch=False, + enable_browser_capture=False, + browser_capture_host="127.0.0.1", + browser_capture_port=8765, + browser_capture_spool_path=None, + ) + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + CliRunner().invoke(main, ["watch"], catch_exceptions=False) + + assert continuity_admission.call_count == 2 + def test_emit_daemon_lifecycle_event_carries_dev_loop_context( tmp_path: Path, diff --git a/tests/unit/operations/test_maintenance_receipt_fs.py b/tests/unit/operations/test_maintenance_receipt_fs.py index f34cf41e17..8d4db86789 100644 --- a/tests/unit/operations/test_maintenance_receipt_fs.py +++ b/tests/unit/operations/test_maintenance_receipt_fs.py @@ -37,3 +37,17 @@ def fail_state_fsync(descriptor: int) -> None: assert not (state / "new-child").exists() assert existing.is_dir() + + +def test_receipt_directory_wraps_mkdir_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Publication callers receive the typed path error when directory creation fails.""" + root = tmp_path / "archive" + (root / ".maintenance-state").mkdir(parents=True) + + def deny_mkdir(*_args: object, **_kwargs: object) -> None: + raise PermissionError("read-only maintenance state") + + monkeypatch.setattr(os, "mkdir", deny_mkdir) + with pytest.raises(MaintenanceReceiptPathError, match="cannot create maintenance receipt directory"): + with maintenance_receipt_directory(root, "new-child"): + pytest.fail("the failed child directory must not be yielded") diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 79cd8a2a65..a16bc257cc 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -29,8 +29,12 @@ _check_backup_against_live, apply_archive_root_relocation, assert_no_prepared_archive_root_relocation, + load_archive_root_relocation_plan, prepare_archive_root_relocation, ) +from polylogue.operations.archive_root_relocation import ( + _sealed_plan as _sealed_relocation_plan, +) from polylogue.operations.archive_root_relocation import ( _sealed_receipt as _sealed_relocation_receipt, ) @@ -932,6 +936,89 @@ def _historical_continuity_fixture( return new_root, mutation_receipt, pre_manifest, post_manifest, evidence +def _downgrade_historical_backup_source_identity(manifest_path: Path, *, old_root: Path) -> None: + """Render a verified pre-inode backup shape using the original local keys.""" + from polylogue.storage.backup_attestation import sign_verification_receipt + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + receipt_path = manifest_path.with_name("verification-receipt.json") + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + fingerprint = manifest["tier_source_fingerprints"]["source.db"] + fingerprint.pop("device") + fingerprint.pop("inode") + for artifact in receipt["tier_artifacts"]: + if artifact["tier"] == "source": + artifact["source_fingerprint"] = fingerprint + manifest_encoded = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8") + manifest_path.write_bytes(manifest_encoded) + receipt["manifest_size_bytes"] = len(manifest_encoded) + receipt["manifest_sha256"] = hashlib.sha256(manifest_encoded).hexdigest() + for item in receipt["artifact_inventory"]: + if item.get("path") == "manifest.json": + item["size_bytes"] = len(manifest_encoded) + item["sha256"] = receipt["manifest_sha256"] + unsigned = {key: value for key, value in receipt.items() if key != "attestations"} + receipt_path.write_text( + json.dumps( + sign_verification_receipt( + unsigned, + authority_paths={ + "source": old_root / "source.db", + "user": old_root / "user.db", + "audit": old_root / "audit.db", + }, + ), + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + +def test_historical_recovery_consumes_verified_pre_inode_backup_manifests( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The recovery planner supports issued backups that predate inode fields. + + Anti-vacuity: both real backup manifests retain their successful HMAC + receipts and exact package bytes, but omit only the fields introduced by + this PR. Requiring the new fields makes an already-completed historical + mutation impossible to recover. + """ + from polylogue.operations.historical_source_continuity_recovery import prepare_historical_source_continuity_recovery + + new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + old_root = workspace_env["archive_root"] + _downgrade_historical_backup_source_identity(pre_manifest, old_root=old_root) + _downgrade_historical_backup_source_identity(post_manifest, old_root=old_root) + with sqlite3.connect(f"file:{pre_manifest.parent / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + candidates = classify_blob_ref_liveness(connection).candidates + _pinned_historical_operation_evidence( + evidence, + mutation_receipt=mutation_receipt, + candidates=candidates, + pre_manifest=pre_manifest, + post_manifest=post_manifest, + ) + + with _test_historical_operation_evidence_resource(evidence): + plan = prepare_historical_source_continuity_recovery( + old_root=old_root, + new_root=new_root, + mutation_receipt=mutation_receipt, + pre_backup_manifest=pre_manifest, + post_backup_manifest=post_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + assert plan.pre_backup_source_device is None + assert plan.post_backup_source_inode is None + + def _maintenance_json_output(output: str) -> dict[str, object]: """Maintenance commands retain the root-provenance line before JSON output.""" _provenance, separator, payload = output.partition("\n") @@ -1120,6 +1207,57 @@ def test_source_continuity_rejects_a_disconnected_legacy_authority_component( assert_source_continuity_apply_allowed(root) +def test_source_continuity_admits_connected_multi_refresh_v1_history( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Legacy V1 refreshes remain usable when sealed evidence forms one chain. + + Anti-vacuity: the production admission route validates every retained V1 + receipt. Removing V1 predecessor inference makes these two independently + sealed, sequential refreshes look like disconnected roots and rejects the + released train. + """ + root = workspace_env["archive_root"] + manifest = _released_moved_source_train(root, monkeypatch) + _attach_retained_source_continuity(root, manifest) + first = load_durable_change_train_manifest(manifest) + assert first.source_continuity_evidence is not None + with sqlite3.connect(root / "source.db") as connection: + observed = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) + terminal = replace( + observed, + archive_identity_digest=first.source_continuity_evidence.archive_identity_digest, + observed_at_ms=first.source_continuity_evidence.observed_at_ms + 1, + ) + second_payload = { + "format": "polylogue.source-continuity-refresh.v1", + "operation_id": "legacy-second-refresh", + "evidence_ref": "proof:legacy-second-refresh", + "backup_manifest": "/authenticated/second/manifest.json", + "backup_manifest_sha256": "c" * 64, + "mutation_receipt": "/authenticated/second.jsonl", + "mutation_receipt_sha256": "d" * 64, + "train_id": first.train_id, + "source_before": _evidence_payload(first.source_continuity_evidence), + "source_after": _evidence_payload(terminal), + "refreshed_at_ms": terminal.observed_at_ms, + } + second_digest = _canonical_json_sha256(second_payload) + _write_refresh_receipt( + root / ".maintenance-state" / "source-continuity-refreshes" / f"{second_digest}.json", + {**second_payload, "refresh_sha256": second_digest}, + ) + updated = replace( + first, + revision=first.revision + 1, + source_continuity_evidence=terminal, + proof_refs=(*first.proof_refs, f"proof:source-continuity-refresh:{second_digest}"), + ) + write_durable_change_train_manifest(manifest, updated, expected_revision=first.revision) + + assert_source_continuity_apply_allowed(root) + + def test_receipt_directory_swap_cannot_redirect_either_operation_outside_archive( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1926,9 +2064,11 @@ def swap_after_publication_preflight( root: Path, items: tuple[RelocationIndexGeneration, ...], pointer: RelocationActiveIndexPointer | None, + *, + allow_post_publication: bool, ) -> None: nonlocal validation_calls - real_validate(root, items, pointer) + real_validate(root, items, pointer, allow_post_publication=allow_post_publication) validation_calls += 1 if validation_calls == 2: os.rename(generation_root, detached) @@ -2017,6 +2157,212 @@ def test_relocation_apply_rejects_equivalent_generation_tier_symlink_substitutio assert not (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").exists() +def test_relocation_apply_rejects_generation_metadata_post_state_before_publication_begins( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A first apply cannot treat an after-state leaf as proof of publication. + + Anti-vacuity: the production plan carries only its revision-0 receipt, + which has no bound manifest transition, while generation metadata is + atomically changed to its exact planned after bytes. Removing the + publication-begun gate accepts the substituted state. + """ + from polylogue.operations import archive_root_relocation as relocation + + new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + generation = plan.index_generations[0] + metadata_path = Path(generation.metadata_path) + before = metadata_path.read_bytes() + payload = relocation._index_generation_payload_for_state(generation, after=False, encoded=before) + after = relocation._index_generation_metadata_bytes( + {**payload, "archive_root": generation.after_archive_root, "index_path": generation.after_index_path} + ) + assert hashlib.sha256(after).hexdigest() == generation.after_sha256 + replacement = metadata_path.with_name(".generation.json.after") + replacement.write_bytes(after) + os.replace(replacement, metadata_path) + + pointer_fields = relocation._pointer_receipt_fields(plan.active_index_pointer) + initial = _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=plan.plan_sha256, + manifest_before_sha256=tuple(item.before_manifest_sha256 for item in plan.durable_trains), + manifest_after_sha256=(), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command="polylogue ops maintenance archive-root-relocation apply", + ) + _write_relocation_receipt(relocation._receipt_path(new_root, plan), initial, expected=None) + + with pytest.raises(ArchiveRootRelocationError, match="post-publication state without a prepared receipt"): + apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + + assert metadata_path.read_bytes() == after + retained_initial = json.loads( + (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").read_text( + encoding="utf-8" + ) + ) + assert retained_initial["revision"] == 0 + assert retained_initial["manifest_after_sha256"] == [] + + +def test_relocation_v3_plan_decodes_but_requires_a_prepared_resume_receipt( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pre-leaf-sealing v3 plans refuse new publication instead of stranding resume. + + Anti-vacuity: the retained plan omits exactly the v4 leaf identities. The + public loader must still decode it so a prepared receipt can be inspected, + while the public apply route rejects a first publication that cannot prove + its original leaves. + """ + new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + legacy_payload = plan.model_dump(mode="json") + legacy_payload["format"] = "polylogue.archive-root-relocation-plan.v3" + legacy_payload.pop("plan_sha256") + for generation in legacy_payload["index_generations"]: + generation.pop("metadata_before_device") + generation.pop("metadata_before_inode") + for link in generation["tier_symlinks"]: + link.pop("before_device") + link.pop("before_inode") + legacy = _sealed_relocation_plan(**legacy_payload) + retained = tmp_path / "retained-v3-plan.json" + retained.write_text( + json.dumps(legacy.model_dump(mode="json", exclude_none=True), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + loaded = load_archive_root_relocation_plan(retained) + assert loaded.format == "polylogue.archive-root-relocation-plan.v3" + assert loaded.index_generations[0].metadata_before_device is None + with pytest.raises(ArchiveRootRelocationError, match="create a v4 plan"): + apply_archive_root_relocation(root=new_root, plan=loaded, authorization=loaded.plan_sha256) + + from polylogue.operations import archive_root_relocation as relocation + + pointer_fields = relocation._pointer_receipt_fields(loaded.active_index_pointer) + initial = _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256=loaded.plan_sha256, + authorization=loaded.plan_sha256, + manifest_before_sha256=tuple(item.before_manifest_sha256 for item in loaded.durable_trains), + manifest_after_sha256=(), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command="polylogue ops maintenance archive-root-relocation apply", + ) + expected_after = [] + for item in loaded.durable_trains: + train = load_durable_change_train_manifest(Path(item.path)) + expected = ( + relocation._relocated_train( + new_root, + plan=loaded, + item=item, + train=train, + relocation_receipt_sha256=initial.receipt_sha256, + ) + if relocation._requires_train_update(item) + else train + ) + expected_after.append(relocation._train_manifest_sha256(expected)) + prepared = _sealed_relocation_receipt( + state="prepared", + revision=1, + plan_sha256=loaded.plan_sha256, + authorization=loaded.plan_sha256, + manifest_before_sha256=tuple(item.before_manifest_sha256 for item in loaded.durable_trains), + manifest_after_sha256=tuple(expected_after), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command="polylogue ops maintenance archive-root-relocation apply", + prepared_receipt_sha256=initial.receipt_sha256, + ) + _write_relocation_receipt(relocation._receipt_path(new_root, loaded), prepared, expected=None) + + resumed = apply_archive_root_relocation(root=new_root, plan=loaded, authorization=loaded.plan_sha256) + assert resumed.state == "committed" + + +def test_relocation_rejects_backup_root_nested_under_moved_archive( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Relocation authority cannot be sourced from a package under the live root. + + Anti-vacuity: this uses a real verified backup package copied below the + moved archive. Removing backup-root separation lets the public planner + accept evidence that the operation can overwrite or recursively include. + """ + old_root = workspace_env["archive_root"] + _released_moved_source_train(old_root, monkeypatch) + new_root = tmp_path / "moved" + os.rename(old_root, new_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(new_root)) + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + nested = new_root / "retained-backup" + shutil.copytree(Path(backup.output_path), nested) + + with pytest.raises(ArchiveRootRelocationError, match="backup root must be separate"): + prepare_archive_root_relocation( + old_root=old_root, + new_root=new_root, + backup_manifest=nested / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + +def test_relocation_backup_validation_checks_every_live_tier_alias( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Full-evidence relocation validation gives every artifact its live tier. + + Anti-vacuity: a real full backup is revalidated through the production + validator. Removing the per-tier mapping passes ``None`` for every + artifact and this capture no longer observes the live alias boundary. + """ + from polylogue.storage.sqlite import migration_runner + + root = workspace_env["archive_root"] + backup = backup_archive(output_dir=tmp_path / "backups", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None + observed: dict[str, Path | None] = {} + real_validate = migration_runner._validate_tier_artifact + + def capture_live_alias( + backup_root: Path, + artifact: dict[str, object], + *, + file_evidence: dict[str, dict[str, object]], + live_tier_path: Path | None, + ) -> None: + observed[str(artifact["tier"])] = live_tier_path + real_validate( + backup_root, + artifact, + file_evidence=file_evidence, + live_tier_path=live_tier_path, + ) + + monkeypatch.setattr(migration_runner, "_validate_tier_artifact", capture_live_alias) + migration_runner.validate_full_evidence_backup_for_archive_root_relocation( + Path(backup.output_path) / "manifest.json", + backup_configured_root=root, + backup_archive_root=root, + ) + + assert observed == {tier.value: root / f"{tier.value}.db" for tier in ArchiveTier} + + def test_relocation_remaps_generations_beside_a_nested_active_index( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From e2a9fa5e49a2f431002be50d962d165eb5818f9f Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 14:23:03 +0200 Subject: [PATCH 36/39] fix(ops): bind relocation continuity authority Problem: legacy V1 source refresh receipts record fresh observation timestamps, while relocation resume accepted any structurally shaped revision-1 receipt as publication evidence. What changed: chain valid V1 refreshes by sealed source evidence and ordered refresh timestamps. Require a revision-1 receipt to recreate its sealed revision-0 preparation state before admitting post-publication leaves. Verification: devtools test selected relocation regressions; devtools verify --quick. --- .../operations/archive_root_relocation.py | 49 +++++++++++++++-- .../storage/sqlite/durable_change_train.py | 55 ++++++++++++++++++- .../storage/test_archive_root_relocation.py | 52 +++++++++++++++++- 3 files changed, 146 insertions(+), 10 deletions(-) diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 7e203a4634..84a602ded4 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -1433,6 +1433,48 @@ def _pointer_receipt_fields( return (pointer.old_target, pointer.new_target, pointer.new_resolved_target) +def _has_matching_prepared_publication_receipt( + plan: ArchiveRootRelocationPlan, receipt: ArchiveRootRelocationReceipt | None +) -> bool: + """Return whether ``receipt`` proves this plan reached publication. + + A revision-1 receipt replaces the revision-0 preparation receipt in place. + Its retained digest must therefore recreate that exact preparation receipt, + rather than merely asserting that a post-publication manifest tuple exists. + """ + if receipt is None or receipt.state not in {"prepared", "committed"} or receipt.revision < 1: + return False + before_hashes = tuple(item.before_manifest_sha256 for item in plan.durable_trains) + pointer_fields = _pointer_receipt_fields(plan.active_index_pointer) + if ( + receipt.plan_sha256 != plan.plan_sha256 + or receipt.authorization != plan.plan_sha256 + or receipt.manifest_before_sha256 != before_hashes + or len(receipt.manifest_after_sha256) != len(plan.durable_trains) + or ( + receipt.active_index_pointer_old_target, + receipt.active_index_pointer_new_target, + receipt.active_index_pointer_new_resolved_target, + ) + != pointer_fields + or receipt.prepared_receipt_sha256 is None + ): + return False + preparation = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=plan.plan_sha256, + manifest_before_sha256=before_hashes, + manifest_after_sha256=(), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command=receipt.resume_command, + ) + return receipt.prepared_receipt_sha256 == preparation.receipt_sha256 + + def _relocated_train( root: Path, *, @@ -1559,12 +1601,7 @@ def _revalidate_plan_live_state( if len(plan.tiers) != len(ArchiveTier) or set(backup_tiers) != {tier.value for tier in ArchiveTier}: raise ArchiveRootRelocationError("archive-root relocation plan tier evidence is incomplete") pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) - prepared_publication = pending_receipt is not None and ( - pending_receipt.state in {"prepared", "committed"} - and pending_receipt.revision >= 1 - and pending_receipt.prepared_receipt_sha256 is not None - and len(pending_receipt.manifest_after_sha256) == len(plan.durable_trains) - ) + prepared_publication = _has_matching_prepared_publication_receipt(plan, pending_receipt) if plan.format == _LEGACY_PLAN_FORMAT and not prepared_publication: raise ArchiveRootRelocationError( "archive-root relocation v3 plan lacks sealed leaf identities before publication; create a v4 plan" diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index eaf3937eba..a79a9b79aa 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -122,6 +122,50 @@ class _SourceContinuityAuthorityNode: source_after: object +def _legacy_source_continuity_evidence_matches(before: object, after: object) -> bool: + """Compare sealed V1 evidence while ignoring its observation timestamp. + + V1 refreshes captured a fresh pre-mutation observation for every run, so + the next receipt's ``source_before`` can differ from the preceding + ``source_after`` only in ``observed_at_ms``. The rest of the evidence is + still sealed and must decode as durable source evidence before it can + establish a legacy predecessor. + """ + try: + decoded_before = _migration_runner._decode_manifest_value( + DurableDatabaseEvidence, before, label="legacy source continuity predecessor evidence" + ) + decoded_after = _migration_runner._decode_manifest_value( + DurableDatabaseEvidence, after, label="legacy source continuity successor evidence" + ) + except DurableChangeTrainError as exc: + raise DurableChangeTrainError("legacy source continuity evidence is malformed") from exc + if not isinstance(decoded_before, DurableDatabaseEvidence) or not isinstance( + decoded_after, DurableDatabaseEvidence + ): + raise DurableChangeTrainError("legacy source continuity evidence decoded to the wrong type") + return replace(decoded_before, observed_at_ms=0) == replace(decoded_after, observed_at_ms=0) + + +def _legacy_source_continuity_refresh_timestamp(payload: dict[str, object]) -> int: + """Return the source-after observation time sealed by one V1 receipt.""" + refreshed_at_ms = payload.get("refreshed_at_ms") + source_after = payload.get("source_after") + try: + decoded_after = _migration_runner._decode_manifest_value( + DurableDatabaseEvidence, source_after, label="legacy source continuity refresh evidence" + ) + except DurableChangeTrainError as exc: + raise DurableChangeTrainError("legacy source continuity evidence is malformed") from exc + if ( + type(refreshed_at_ms) is not int + or not isinstance(decoded_after, DurableDatabaseEvidence) + or decoded_after.observed_at_ms != refreshed_at_ms + ): + raise DurableChangeTrainError("legacy source continuity refresh timestamp is invalid") + return refreshed_at_ms + + def _durable_train_manifest_sha256(train: DurableChangeTrain) -> str: payload = durable_change_train_to_payload(train) encoded = (json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8") @@ -1238,12 +1282,14 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, continue ref = _SourceContinuityAuthorityRef("refresh", digest) source_before = payload.get("source_before") + refreshed_at_ms = _legacy_source_continuity_refresh_timestamp(payload) candidates = [ _SourceContinuityAuthorityRef("refresh", candidate_digest) for candidate_digest, candidate_payload in refresh_payloads.items() if candidate_digest != digest and candidate_payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT - and candidate_payload.get("source_after") == source_before + and _legacy_source_continuity_refresh_timestamp(candidate_payload) < refreshed_at_ms + and _legacy_source_continuity_evidence_matches(candidate_payload.get("source_after"), source_before) ] if len(candidates) > 1: raise DurableChangeTrainError("legacy source continuity authority has ambiguous predecessor evidence") @@ -1287,7 +1333,12 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, predecessor_node = nodes[current] for transition_ref in reversed(trail): payload = transition_payloads[transition_ref] - if payload.get("source_before") != predecessor_node.source_after: + preserves_predecessor = payload.get("source_before") == predecessor_node.source_after + if payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT: + preserves_predecessor = _legacy_source_continuity_evidence_matches( + predecessor_node.source_after, payload.get("source_before") + ) + if not preserves_predecessor: raise DurableChangeTrainError("source continuity transition does not preserve predecessor authority") node = _SourceContinuityAuthorityNode( ref=transition_ref, diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index a16bc257cc..57ce1875a6 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1227,7 +1227,7 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( terminal = replace( observed, archive_identity_digest=first.source_continuity_evidence.archive_identity_digest, - observed_at_ms=first.source_continuity_evidence.observed_at_ms + 1, + observed_at_ms=first.source_continuity_evidence.observed_at_ms + 3, ) second_payload = { "format": "polylogue.source-continuity-refresh.v1", @@ -1238,7 +1238,11 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( "mutation_receipt": "/authenticated/second.jsonl", "mutation_receipt_sha256": "d" * 64, "train_id": first.train_id, - "source_before": _evidence_payload(first.source_continuity_evidence), + "source_before": _evidence_payload( + replace( + first.source_continuity_evidence, observed_at_ms=first.source_continuity_evidence.observed_at_ms + 2 + ) + ), "source_after": _evidence_payload(terminal), "refreshed_at_ms": terminal.observed_at_ms, } @@ -1258,6 +1262,50 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( assert_source_continuity_apply_allowed(root) +def test_relocation_revalidation_rejects_an_unbound_prepared_receipt_for_post_state( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Post-state leaves need the exact retained preparation receipt, not a shaped receipt. + + Anti-vacuity: the generation metadata is atomically replaced with the + planned after bytes and a revision-1 receipt has the expected structural + fields but a foreign preparation digest. The old structural gate admitted + it before the first publication attempt. + """ + from polylogue.operations import archive_root_relocation as relocation + + new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + generation = plan.index_generations[0] + metadata_path = Path(generation.metadata_path) + before = metadata_path.read_bytes() + payload = relocation._index_generation_payload_for_state(generation, after=False, encoded=before) + after = relocation._index_generation_metadata_bytes( + {**payload, "archive_root": generation.after_archive_root, "index_path": generation.after_index_path} + ) + replacement = metadata_path.with_name(".generation.json.after") + replacement.write_bytes(after) + os.replace(replacement, metadata_path) + + pointer_fields = relocation._pointer_receipt_fields(plan.active_index_pointer) + unbound = _sealed_relocation_receipt( + state="prepared", + revision=1, + plan_sha256=plan.plan_sha256, + authorization=plan.plan_sha256, + manifest_before_sha256=tuple(item.before_manifest_sha256 for item in plan.durable_trains), + manifest_after_sha256=tuple("0" * 64 for _item in plan.durable_trains), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command="polylogue ops maintenance archive-root-relocation apply", + prepared_receipt_sha256="f" * 64, + ) + _write_relocation_receipt(relocation._receipt_path(new_root, plan), unbound, expected=None) + + with pytest.raises(ArchiveRootRelocationError, match="post-publication state without a prepared receipt"): + relocation._revalidate_plan_live_state(new_root, plan) + + def test_receipt_directory_swap_cannot_redirect_either_operation_outside_archive( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 79ad2359a5ef555d6dc19e953a808aa3810d2dfb Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 14:34:23 +0200 Subject: [PATCH 37/39] fix(ops): preserve legacy maintenance recovery authority Problem: retained V1/V3 and historical-recovery artifacts could be rejected after the authority hardening, while watcher ownership conflicts escaped the CLI boundary. What changed: chain V1 refreshes by sealed evidence and nearest timestamp, allow a verified V3 revision-zero pre-state resume, preserve the historical ASCII-escaped recovery identity and null bridge shape, and render watch ownership conflicts as Click errors. Compatibility: V3 still rejects unproven post-state generation leaves. Co-Authored-By: Codex --- polylogue/daemon/cli.py | 17 +- .../operations/archive_root_relocation.py | 45 ++++- .../historical_source_continuity_recovery.py | 20 ++- .../storage/sqlite/durable_change_train.py | 13 +- tests/unit/daemon/test_daemon_cli.py | 15 ++ .../storage/test_archive_root_relocation.py | 167 ++++++++++++++---- 6 files changed, 225 insertions(+), 52 deletions(-) diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 8fa040b0b9..20f0b48ed5 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -3405,7 +3405,7 @@ def parameter_is_default(name: str) -> bool: def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: from polylogue.config import resolve_runtime_config from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation - from polylogue.operations.durable_change_train import acquire_durable_archive_ownership + from polylogue.operations.durable_change_train import ArchiveOwnershipError, acquire_durable_archive_ownership from polylogue.operations.historical_source_continuity_recovery import ( assert_no_prepared_historical_source_continuity_recovery, ) @@ -3418,16 +3418,19 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: beads_roots=runtime_source_paths.beads, ) + archive_root_path = Path(archive_root()) + archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + archive_owner = acquire_durable_archive_ownership( + archive_root_path, + owner_id=f"watch:{os.getpid()}", + ) + except ArchiveOwnershipError as exc: + raise click.ClickException(f"watch could not acquire exclusive archive ownership: {exc}") from exc click.echo( f"Watching {len(sources)} source(s); debounce={debounce_s}s. Ctrl-C to stop.", err=True, ) - archive_root_path = Path(archive_root()) - archive_root_path.mkdir(mode=0o700, parents=True, exist_ok=True) - archive_owner = acquire_durable_archive_ownership( - archive_root_path, - owner_id=f"watch:{os.getpid()}", - ) writer_drained = False watcher_started = False try: diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 84a602ded4..1255b6d3e5 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -1475,6 +1475,48 @@ def _has_matching_prepared_publication_receipt( return receipt.prepared_receipt_sha256 == preparation.receipt_sha256 +def _has_matching_initial_preparation_receipt( + plan: ArchiveRootRelocationPlan, receipt: ArchiveRootRelocationReceipt | None +) -> bool: + """Return whether a retained V3 preparation can safely resume pre-publication. + + V3 has no sealed generation-leaf identities. Its initial receipt proves an + interrupted apply only while every leaf is still checked as a before-state; + it never authorizes a post-state leaf or a fresh V3 publication. + """ + if receipt is None or receipt.state != "prepared" or receipt.revision != 0: + return False + before_hashes = tuple(item.before_manifest_sha256 for item in plan.durable_trains) + pointer_fields = _pointer_receipt_fields(plan.active_index_pointer) + if ( + receipt.plan_sha256 != plan.plan_sha256 + or receipt.authorization != plan.plan_sha256 + or receipt.manifest_before_sha256 != before_hashes + or receipt.manifest_after_sha256 + or ( + receipt.active_index_pointer_old_target, + receipt.active_index_pointer_new_target, + receipt.active_index_pointer_new_resolved_target, + ) + != pointer_fields + or receipt.prepared_receipt_sha256 is not None + ): + return False + preparation = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=plan.plan_sha256, + manifest_before_sha256=before_hashes, + manifest_after_sha256=(), + active_index_pointer_old_target=pointer_fields[0], + active_index_pointer_new_target=pointer_fields[1], + active_index_pointer_new_resolved_target=pointer_fields[2], + resume_command=receipt.resume_command, + ) + return receipt.receipt_sha256 == preparation.receipt_sha256 + + def _relocated_train( root: Path, *, @@ -1602,7 +1644,8 @@ def _revalidate_plan_live_state( raise ArchiveRootRelocationError("archive-root relocation plan tier evidence is incomplete") pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) prepared_publication = _has_matching_prepared_publication_receipt(plan, pending_receipt) - if plan.format == _LEGACY_PLAN_FORMAT and not prepared_publication: + prepared_resume = prepared_publication or _has_matching_initial_preparation_receipt(plan, pending_receipt) + if plan.format == _LEGACY_PLAN_FORMAT and not prepared_resume: raise ArchiveRootRelocationError( "archive-root relocation v3 plan lacks sealed leaf identities before publication; create a v4 plan" ) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index e21ad8b00a..1308ad2c79 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -63,7 +63,6 @@ ) from polylogue.storage.sqlite.migration_runner import ( DurableDatabaseEvidence, - _canonical_json_sha256, capture_durable_database_evidence, capture_durable_schema_inventory, ) @@ -174,6 +173,12 @@ def _sha256(path: Path) -> str: return digest.hexdigest() +def _canonical_json_sha256(payload: object) -> str: + """Keep the V2 recovery artifact checksum byte-identical to its original form.""" + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _real_file(path: Path, *, label: str) -> None: try: metadata = path.lstat() @@ -362,6 +367,11 @@ def _sealed_optional_source_identity(device: int | None, inode: int | None) -> t return device, inode +def _optional_source_identity_payload(device: int | None, inode: int | None) -> list[int] | None: + identity = _sealed_optional_source_identity(device, inode) + return None if identity is None else list(identity) + + def _refresh_proof_id( *, old_root: Path, @@ -407,8 +417,12 @@ def _refresh_payload(plan: HistoricalSourceContinuityRecoveryPlan, *, train_id: "historical_bridge": { "pre_backup": plan.pre_backup_manifest_sha256, "post_backup": plan.post_backup_manifest_sha256, - "pre_backup_source_identity": [plan.pre_backup_source_device, plan.pre_backup_source_inode], - "post_backup_source_identity": [plan.post_backup_source_device, plan.post_backup_source_inode], + "pre_backup_source_identity": _optional_source_identity_payload( + plan.pre_backup_source_device, plan.pre_backup_source_inode + ), + "post_backup_source_identity": _optional_source_identity_payload( + plan.post_backup_source_device, plan.post_backup_source_inode + ), "new_source_identity": [plan.new_source_device, plan.new_source_inode], "legacy_candidate_count": plan.legacy_candidate_count, "legacy_candidate_digest": plan.legacy_candidate_digest, diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index a79a9b79aa..94113decd8 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1284,17 +1284,22 @@ def register_predecessor(ref: _SourceContinuityAuthorityRef, payload: dict[str, source_before = payload.get("source_before") refreshed_at_ms = _legacy_source_continuity_refresh_timestamp(payload) candidates = [ - _SourceContinuityAuthorityRef("refresh", candidate_digest) + ( + _legacy_source_continuity_refresh_timestamp(candidate_payload), + _SourceContinuityAuthorityRef("refresh", candidate_digest), + ) for candidate_digest, candidate_payload in refresh_payloads.items() if candidate_digest != digest and candidate_payload.get("format") == _SOURCE_CONTINUITY_REFRESH_V1_FORMAT and _legacy_source_continuity_refresh_timestamp(candidate_payload) < refreshed_at_ms and _legacy_source_continuity_evidence_matches(candidate_payload.get("source_after"), source_before) ] - if len(candidates) > 1: - raise DurableChangeTrainError("legacy source continuity authority has ambiguous predecessor evidence") if candidates: - predecessor = candidates[0] + latest_timestamp = max(timestamp for timestamp, _ref in candidates) + latest = [ref for timestamp, ref in candidates if timestamp == latest_timestamp] + if len(latest) != 1: + raise DurableChangeTrainError("legacy source continuity authority has ambiguous predecessor evidence") + predecessor = latest[0] if predecessor in successor_by_authority: raise DurableChangeTrainError("source continuity authority branches ambiguously") predecessors[ref] = predecessor diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 375ab4082a..1c79c8e58a 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -2258,6 +2258,21 @@ def assert_owned(coroutine: object) -> None: assert "Watching 1 source(s); debounce=0.25s" in result.stderr +def test_polylogued_watch_reports_archive_ownership_conflict_as_click_error( + workspace_env: dict[str, Path], +) -> None: + """A competing daemon or maintenance owner must not escape the Click boundary.""" + root = workspace_env["archive_root"] + with OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root), owner_id="competing-writer"): + result = CliRunner().invoke(main, ["watch"]) + + assert result.exit_code == 1 + assert "Error: watch could not acquire exclusive archive ownership:" in result.output + assert "archive location already owned" in result.output + assert "Watching" not in result.output + assert "Traceback" not in result.output + + def test_polylogued_watch_builds_sources_from_roots(workspace_env: dict[str, Path], tmp_path: Path) -> None: root_a = tmp_path / "claude-code" root_b = tmp_path / "codex" diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 57ce1875a6..6cde10d92b 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -43,6 +43,7 @@ ) from polylogue.operations.historical_source_continuity_recovery import ( HistoricalSourceContinuityRecoveryError, + HistoricalSourceContinuityRecoveryReceipt, _assert_complete_source_semantic_delta, _assert_exact_liveness_delta, _current_evidence, @@ -50,7 +51,11 @@ _table_content_digest, _verify_historical_operation_evidence, _write_refresh_receipt, + apply_historical_source_continuity_recovery, assert_no_prepared_historical_source_continuity_recovery, + load_historical_source_continuity_recovery_plan, + load_historical_source_continuity_recovery_receipt, + prepare_historical_source_continuity_recovery, ) from polylogue.operations.historical_source_continuity_recovery import ( _legacy_liveness_receipt as _validate_legacy_liveness_receipt, @@ -986,8 +991,6 @@ def test_historical_recovery_consumes_verified_pre_inode_backup_manifests( this PR. Requiring the new fields makes an already-completed historical mutation impossible to recover. """ - from polylogue.operations.historical_source_continuity_recovery import prepare_historical_source_continuity_recovery - new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( workspace_env, tmp_path, monkeypatch ) @@ -1014,9 +1017,95 @@ def test_historical_recovery_consumes_verified_pre_inode_backup_manifests( stopped_daemon_evidence_ref="proof:daemon-stopped", single_writer_evidence_ref="proof:archive-ownership-lock", ) + result = apply_historical_source_continuity_recovery( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) assert plan.pre_backup_source_device is None assert plan.post_backup_source_inode is None + assert result.state == "committed" + + +def test_historical_recovery_loads_non_ascii_legacy_v2_artifacts( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """V2 plan and receipt checksums retain the original ASCII-escaped JSON identity. + + Anti-vacuity: paths and the resume command contain non-ASCII text and the + sealed checksums are computed with the historical ``ensure_ascii=True`` + encoding. Using the migration-runner canonicalizer rejects both retained + artifacts before an interrupted recovery can resume. + """ + new_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + old_root = workspace_env["archive_root"] + with sqlite3.connect(f"file:{pre_manifest.parent / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + candidates = classify_blob_ref_liveness(connection).candidates + _pinned_historical_operation_evidence( + evidence, + mutation_receipt=mutation_receipt, + candidates=candidates, + pre_manifest=pre_manifest, + post_manifest=post_manifest, + ) + with _test_historical_operation_evidence_resource(evidence): + plan = prepare_historical_source_continuity_recovery( + old_root=old_root, + new_root=new_root, + mutation_receipt=mutation_receipt, + pre_backup_manifest=pre_manifest, + post_backup_manifest=post_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + legacy_payload = plan.model_dump(mode="json", exclude={"plan_sha256"}) + legacy_payload["old_configured_root"] = str(tmp_path / "źródło") + plan_sha256 = hashlib.sha256( + json.dumps(legacy_payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True).encode("utf-8") + ).hexdigest() + retained_plan = tmp_path / "plan-źródło.json" + retained_plan.write_text( + json.dumps({**legacy_payload, "plan_sha256": plan_sha256}, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + loaded_plan = load_historical_source_continuity_recovery_plan(retained_plan) + assert loaded_plan.plan_sha256 == plan_sha256 + + receipt_payload = { + "state": "prepared", + "revision": 0, + "plan_sha256": plan_sha256, + "authorization": plan_sha256, + "train_before_sha256": plan.source_train_sha256, + "train_after_sha256": plan.source_train_after_sha256, + "refresh_receipt_sha256": plan.refresh_receipt_sha256, + "resume_command": "polylogue ops maintenance source-continuity-recovery apply --plan /tmp/źródło.json", + } + receipt_sha256 = hashlib.sha256( + json.dumps( + {"format": "polylogue.historical-source-continuity-recovery-receipt.v1", **receipt_payload}, + separators=(",", ":"), + sort_keys=True, + ensure_ascii=True, + ).encode("utf-8") + ).hexdigest() + receipt = HistoricalSourceContinuityRecoveryReceipt.model_validate( + { + "format": "polylogue.historical-source-continuity-recovery-receipt.v1", + **receipt_payload, + "receipt_sha256": receipt_sha256, + } + ) + receipt_path = new_root / ".maintenance-state" / "historical-source-continuity-recoveries" / f"{plan_sha256}.json" + receipt_path.parent.mkdir(parents=True) + receipt_path.write_text(json.dumps(receipt.model_dump(mode="json"), ensure_ascii=False), encoding="utf-8") + assert load_historical_source_continuity_recovery_receipt(receipt_path) == receipt def _maintenance_json_output(output: str) -> dict[str, object]: @@ -1224,7 +1313,7 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( assert first.source_continuity_evidence is not None with sqlite3.connect(root / "source.db") as connection: observed = capture_durable_database_evidence(connection, ArchiveTier.SOURCE) - terminal = replace( + second = replace( observed, archive_identity_digest=first.source_continuity_evidence.archive_identity_digest, observed_at_ms=first.source_continuity_evidence.observed_at_ms + 3, @@ -1243,8 +1332,8 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( first.source_continuity_evidence, observed_at_ms=first.source_continuity_evidence.observed_at_ms + 2 ) ), - "source_after": _evidence_payload(terminal), - "refreshed_at_ms": terminal.observed_at_ms, + "source_after": _evidence_payload(second), + "refreshed_at_ms": second.observed_at_ms, } second_digest = _canonical_json_sha256(second_payload) _write_refresh_receipt( @@ -1254,11 +1343,33 @@ def test_source_continuity_admits_connected_multi_refresh_v1_history( updated = replace( first, revision=first.revision + 1, - source_continuity_evidence=terminal, + source_continuity_evidence=second, proof_refs=(*first.proof_refs, f"proof:source-continuity-refresh:{second_digest}"), ) write_durable_change_train_manifest(manifest, updated, expected_revision=first.revision) + third = replace(second, observed_at_ms=second.observed_at_ms + 3) + third_payload = { + **second_payload, + "operation_id": "legacy-third-refresh", + "evidence_ref": "proof:legacy-third-refresh", + "source_before": _evidence_payload(replace(second, observed_at_ms=second.observed_at_ms + 2)), + "source_after": _evidence_payload(third), + "refreshed_at_ms": third.observed_at_ms, + } + third_digest = _canonical_json_sha256(third_payload) + _write_refresh_receipt( + root / ".maintenance-state" / "source-continuity-refreshes" / f"{third_digest}.json", + {**third_payload, "refresh_sha256": third_digest}, + ) + terminal = replace( + updated, + revision=updated.revision + 1, + source_continuity_evidence=third, + proof_refs=(*updated.proof_refs, f"proof:source-continuity-refresh:{third_digest}"), + ) + write_durable_change_train_manifest(manifest, terminal, expected_revision=updated.revision) + assert_source_continuity_apply_allowed(root) @@ -2205,7 +2316,7 @@ def test_relocation_apply_rejects_equivalent_generation_tier_symlink_substitutio assert not (new_root / ".maintenance-state" / "archive-root-relocations" / f"{plan.plan_sha256}.json").exists() -def test_relocation_apply_rejects_generation_metadata_post_state_before_publication_begins( +def test_relocation_v3_prepared_resume_rejects_generation_metadata_post_state( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A first apply cannot treat an after-state leaf as proof of publication. @@ -2217,7 +2328,17 @@ def test_relocation_apply_rejects_generation_metadata_post_state_before_publicat """ from polylogue.operations import archive_root_relocation as relocation - new_root, plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + new_root, current_plan = _prepare_moved_root_relocation_with_generation(workspace_env, tmp_path, monkeypatch) + legacy_payload = current_plan.model_dump(mode="json") + legacy_payload["format"] = "polylogue.archive-root-relocation-plan.v3" + legacy_payload.pop("plan_sha256") + for generation_payload in legacy_payload["index_generations"]: + generation_payload.pop("metadata_before_device") + generation_payload.pop("metadata_before_inode") + for link_payload in generation_payload["tier_symlinks"]: + link_payload.pop("before_device") + link_payload.pop("before_inode") + plan = _sealed_relocation_plan(**legacy_payload) generation = plan.index_generations[0] metadata_path = Path(generation.metadata_path) before = metadata_path.read_bytes() @@ -2306,35 +2427,7 @@ def test_relocation_v3_plan_decodes_but_requires_a_prepared_resume_receipt( active_index_pointer_new_resolved_target=pointer_fields[2], resume_command="polylogue ops maintenance archive-root-relocation apply", ) - expected_after = [] - for item in loaded.durable_trains: - train = load_durable_change_train_manifest(Path(item.path)) - expected = ( - relocation._relocated_train( - new_root, - plan=loaded, - item=item, - train=train, - relocation_receipt_sha256=initial.receipt_sha256, - ) - if relocation._requires_train_update(item) - else train - ) - expected_after.append(relocation._train_manifest_sha256(expected)) - prepared = _sealed_relocation_receipt( - state="prepared", - revision=1, - plan_sha256=loaded.plan_sha256, - authorization=loaded.plan_sha256, - manifest_before_sha256=tuple(item.before_manifest_sha256 for item in loaded.durable_trains), - manifest_after_sha256=tuple(expected_after), - active_index_pointer_old_target=pointer_fields[0], - active_index_pointer_new_target=pointer_fields[1], - active_index_pointer_new_resolved_target=pointer_fields[2], - resume_command="polylogue ops maintenance archive-root-relocation apply", - prepared_receipt_sha256=initial.receipt_sha256, - ) - _write_relocation_receipt(relocation._receipt_path(new_root, loaded), prepared, expected=None) + _write_relocation_receipt(relocation._receipt_path(new_root, loaded), initial, expected=None) resumed = apply_archive_root_relocation(root=new_root, plan=loaded, authorization=loaded.plan_sha256) assert resumed.state == "committed" From 5fddbe5eb67b6e72f123152be1113b7b4cad7c9a Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 14:37:13 +0200 Subject: [PATCH 38/39] docs: clarify relocation backup evidence --- docs/archive-backup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/archive-backup.md b/docs/archive-backup.md index a29f4b7498..87064eda1a 100644 --- a/docs/archive-backup.md +++ b/docs/archive-backup.md @@ -42,7 +42,7 @@ contains recent writes creates an incomplete backup. An inode-preserving filesystem move is the only supported way to change a configured archive root without restoring or rebuilding it. Stop the daemon and move the complete root without copying its database files. Set `POLYLOGUE_ARCHIVE_ROOT` to the moved root before creating relocation backup evidence. -If the current released source train lacks continuity authority for historical source changes, first run the `source-continuity-recovery` plan and apply sequence documented in [Maintenance Operations](maintenance.md#recovering-the-one-historical-liveness-receipt-shape). Its authenticated pre/post backup evidence belongs to the retired path and is used only for that bridge. After the bridge commits, or immediately after the move when no bridge is required, create and verify a fresh complete backup at the moved root: +If the current released source train lacks continuity authority for historical source changes, first run the `source-continuity-recovery` plan and apply sequence documented in [Maintenance Operations](maintenance.md#recovering-the-one-historical-liveness-receipt-shape). Its authenticated pre- and post-backup evidence belongs to the retired path and is used only for that bridge. After the bridge commits, or immediately after the move when no bridge is required, create and verify a fresh complete backup at the moved root: ```bash POLYLOGUE_ARCHIVE_ROOT=/new/archive/root \ From f61b2724685c6ec66ac7d20bfd3a421cfe26111f Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 14 Aug 2026 15:10:12 +0200 Subject: [PATCH 39/39] fix(ops): preserve prepared relocation recovery Problem: retained V1 relocation receipts and pre-inode historical backups could not prove every safe resume or destination condition after an upgrade. Offline source mutations could also invalidate a prepared recovery, and watch reported prepared-operation refusals as internal exceptions. What changed: emit versioned V2 relocation receipts while verifying V1 against its original field set, resume authenticated V3 revision-zero receipts, bind legacy recovery destinations to released-train identity, and fence source mutation admission on prepared operations. Watch now returns Click errors before its start message, and relocation output lists only rewritten manifests. Compatibility: V1 receipts, V3 plans, and V2 recovery JSON retain their original checksum semantics. Legacy copies remain refused. --- polylogue/daemon/cli.py | 21 ++- .../operations/archive_root_relocation.py | 70 +++++++--- .../historical_source_continuity_recovery.py | 33 +++++ .../storage/sqlite/durable_change_train.py | 7 + tests/unit/daemon/test_daemon_cli.py | 12 +- .../storage/test_archive_root_relocation.py | 122 +++++++++++++++--- 6 files changed, 221 insertions(+), 44 deletions(-) diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 20f0b48ed5..e5aa8eb97e 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -3404,9 +3404,13 @@ def parameter_is_default(name: str) -> bool: ) def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: from polylogue.config import resolve_runtime_config - from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation + from polylogue.operations.archive_root_relocation import ( + ArchiveRootRelocationError, + assert_no_prepared_archive_root_relocation, + ) from polylogue.operations.durable_change_train import ArchiveOwnershipError, acquire_durable_archive_ownership from polylogue.operations.historical_source_continuity_recovery import ( + HistoricalSourceContinuityRecoveryError, assert_no_prepared_historical_source_continuity_recovery, ) from polylogue.paths import archive_root @@ -3427,15 +3431,18 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: ) except ArchiveOwnershipError as exc: raise click.ClickException(f"watch could not acquire exclusive archive ownership: {exc}") from exc - click.echo( - f"Watching {len(sources)} source(s); debounce={debounce_s}s. Ctrl-C to stop.", - err=True, - ) writer_drained = False watcher_started = False try: - assert_no_prepared_archive_root_relocation(archive_root_path) - assert_no_prepared_historical_source_continuity_recovery(archive_root_path) + try: + assert_no_prepared_archive_root_relocation(archive_root_path) + assert_no_prepared_historical_source_continuity_recovery(archive_root_path) + except (ArchiveRootRelocationError, HistoricalSourceContinuityRecoveryError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + f"Watching {len(sources)} source(s); debounce={debounce_s}s. Ctrl-C to stop.", + err=True, + ) watcher_started = True writer_drained = asyncio.run(run_live_watcher(sources=sources, debounce_s=debounce_s)) finally: diff --git a/polylogue/operations/archive_root_relocation.py b/polylogue/operations/archive_root_relocation.py index 1255b6d3e5..d0981230df 100644 --- a/polylogue/operations/archive_root_relocation.py +++ b/polylogue/operations/archive_root_relocation.py @@ -62,7 +62,10 @@ PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v4"] = "polylogue.archive-root-relocation-plan.v4" _LEGACY_PLAN_FORMAT: Literal["polylogue.archive-root-relocation-plan.v3"] = "polylogue.archive-root-relocation-plan.v3" -RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = "polylogue.archive-root-relocation-receipt.v1" +RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v2"] = "polylogue.archive-root-relocation-receipt.v2" +_LEGACY_RECEIPT_FORMAT: Literal["polylogue.archive-root-relocation-receipt.v1"] = ( + "polylogue.archive-root-relocation-receipt.v1" +) _TIER_NAMES = tuple(tier.value for tier in ArchiveTier) _DURABLE_TIER_NAMES = ("source", "user", "audit") _SIDECARS = ("-wal", "-shm", "-journal") @@ -185,7 +188,10 @@ class ArchiveRootRelocationPlan(BaseModel): class ArchiveRootRelocationReceipt(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - format: Literal["polylogue.archive-root-relocation-receipt.v1"] = RECEIPT_FORMAT + format: Literal[ + "polylogue.archive-root-relocation-receipt.v1", + "polylogue.archive-root-relocation-receipt.v2", + ] = RECEIPT_FORMAT state: Literal["prepared", "committed"] revision: int plan_sha256: str @@ -216,6 +222,26 @@ def _canonical_sha256(payload: object) -> str: ).hexdigest() +def _receipt_checksum_payload(receipt: ArchiveRootRelocationReceipt) -> dict[str, object]: + """Render the exact field set that the receipt format originally sealed.""" + payload = receipt.model_dump(exclude={"receipt_sha256"}, mode="json") + if receipt.format == _LEGACY_RECEIPT_FORMAT: + return { + field: payload[field] + for field in ( + "format", + "state", + "revision", + "plan_sha256", + "authorization", + "manifest_before_sha256", + "manifest_after_sha256", + "resume_command", + ) + } + return payload + + def _sealed_plan(**values: object) -> ArchiveRootRelocationPlan: plan = ArchiveRootRelocationPlan.model_validate({"format": PLAN_FORMAT, **values, "plan_sha256": ""}) payload = plan.model_dump( @@ -228,8 +254,7 @@ def _sealed_plan(**values: object) -> ArchiveRootRelocationPlan: def _sealed_receipt(**values: object) -> ArchiveRootRelocationReceipt: receipt = ArchiveRootRelocationReceipt.model_validate({"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""}) - payload = receipt.model_dump(mode="json", exclude={"receipt_sha256"}) - return receipt.model_copy(update={"receipt_sha256": _canonical_sha256(payload)}) + return receipt.model_copy(update={"receipt_sha256": _canonical_sha256(_receipt_checksum_payload(receipt))}) def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: @@ -251,7 +276,7 @@ def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: def _verify_receipt(receipt: ArchiveRootRelocationReceipt) -> None: - expected = _canonical_sha256(receipt.model_dump(exclude={"receipt_sha256"}, mode="json")) + expected = _canonical_sha256(_receipt_checksum_payload(receipt)) if receipt.receipt_sha256 != expected: raise ArchiveRootRelocationError("archive-root relocation receipt checksum mismatch") @@ -1433,6 +1458,22 @@ def _pointer_receipt_fields( return (pointer.old_target, pointer.new_target, pointer.new_resolved_target) +def _receipt_pointer_fields_match( + receipt: ArchiveRootRelocationReceipt, pointer_fields: tuple[str | None, str | None, str | None] +) -> bool: + """Accept V1's original field set while binding V2 to active-index authority.""" + receipt_fields = ( + receipt.active_index_pointer_old_target, + receipt.active_index_pointer_new_target, + receipt.active_index_pointer_new_resolved_target, + ) + return ( + receipt_fields == (None, None, None) + if receipt.format == _LEGACY_RECEIPT_FORMAT + else receipt_fields == pointer_fields + ) + + def _has_matching_prepared_publication_receipt( plan: ArchiveRootRelocationPlan, receipt: ArchiveRootRelocationReceipt | None ) -> bool: @@ -1461,6 +1502,7 @@ def _has_matching_prepared_publication_receipt( ): return False preparation = _sealed_receipt( + format=receipt.format, state="prepared", revision=0, plan_sha256=plan.plan_sha256, @@ -1493,16 +1535,12 @@ def _has_matching_initial_preparation_receipt( or receipt.authorization != plan.plan_sha256 or receipt.manifest_before_sha256 != before_hashes or receipt.manifest_after_sha256 - or ( - receipt.active_index_pointer_old_target, - receipt.active_index_pointer_new_target, - receipt.active_index_pointer_new_resolved_target, - ) - != pointer_fields + or not _receipt_pointer_fields_match(receipt, pointer_fields) or receipt.prepared_receipt_sha256 is not None ): return False preparation = _sealed_receipt( + format=receipt.format, state="prepared", revision=0, plan_sha256=plan.plan_sha256, @@ -1800,11 +1838,7 @@ def _apply_archive_root_relocation_locked( receipt = existing_receipt if receipt.plan_sha256 != plan.plan_sha256 or receipt.authorization != authorization: raise ArchiveRootRelocationError("archive-root relocation receipt belongs to another plan") - if ( - receipt.active_index_pointer_old_target, - receipt.active_index_pointer_new_target, - receipt.active_index_pointer_new_resolved_target, - ) != pointer_fields: + if not _receipt_pointer_fields_match(receipt, pointer_fields): raise ArchiveRootRelocationError("archive-root relocation receipt active index pointer binding changed") if receipt.state == "committed": if tuple(_sha256_file(Path(item.path)) for item in plan.durable_trains) != receipt.manifest_after_sha256: @@ -1813,7 +1847,7 @@ def _apply_archive_root_relocation_locked( state="committed", plan_sha256=plan.plan_sha256, receipt_path=str(receipt_path), - changed_manifests=tuple(item.path for item in plan.durable_trains), + changed_manifests=tuple(item.path for item in plan.durable_trains if _requires_train_update(item)), ) else: _write_receipt(receipt_path, receipt, expected=None) @@ -1895,5 +1929,5 @@ def _apply_archive_root_relocation_locked( state="committed", plan_sha256=plan.plan_sha256, receipt_path=str(receipt_path), - changed_manifests=tuple(item.path for item in plan.durable_trains), + changed_manifests=tuple(item.path for item in plan.durable_trains if _requires_train_update(item)), ) diff --git a/polylogue/operations/historical_source_continuity_recovery.py b/polylogue/operations/historical_source_continuity_recovery.py index 1308ad2c79..85abb8c42f 100644 --- a/polylogue/operations/historical_source_continuity_recovery.py +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -35,6 +35,7 @@ ) from polylogue.paths import render_root from polylogue.storage.archive_identity import ( + ArchiveIdentity, ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation, @@ -356,6 +357,32 @@ def _require_source_identity(root: Path, *, device: int, inode: int, label: str) return identity +def _require_legacy_destination_train_identity( + train: DurableChangeTrain, identity: TierFileIdentity, *, old_configured_root: Path +) -> None: + """Use the pre-existing released train to distinguish a move from a copy.""" + if train.apply_evidence is None: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery requires released source authority" + ) + live_archive_identity = ArchiveIdentity.resolve(identity.configured_path.parent) + relocated_legacy_identity = ArchiveIdentity( + configured_root=old_configured_root, + tiers=live_archive_identity.tiers, + active_generation=live_archive_identity.active_generation, + ) + actual_identities = { + hashlib.sha256(identity.stable_id.encode("utf-8")).hexdigest(), + live_archive_identity.authority_identity_digest, + relocated_legacy_identity.authority_identity_digest, + } + if train.apply_evidence.post.archive_identity_digest not in actual_identities: + raise HistoricalSourceContinuityRecoveryError( + "historical continuity recovery requires source.db device/inode continuity from the released train; " + "a copied archive is not accepted" + ) + + def _sealed_optional_source_identity(device: int | None, inode: int | None) -> tuple[int, int] | None: """Decode an optional legacy backup identity without accepting a half-pair.""" if device is None and inode is None: @@ -905,6 +932,8 @@ def prepare_historical_source_continuity_recovery( train_path = manifest_root / f"source-{train.slot:03d}.json" _real_file(train_path, label="current released source train") _, source_before = _assert_pre_train_authority(train_path, pre) + if pre_identity is None: + _require_legacy_destination_train_identity(train, new_source_identity, old_configured_root=old_configured) if train.source_continuity_evidence is not None: raise HistoricalSourceContinuityRecoveryError("current released source train already has continuity authority") census = _census(root) @@ -1192,6 +1221,10 @@ def _revalidate( if not _evidence_matches_plan(current, plan.source_after) or current.content_sha256 != post.content_sha256: raise HistoricalSourceContinuityRecoveryError("historical continuity recovery current source changed") train = load_durable_change_train_manifest(Path(plan.source_train_path)) + if expected_pre_identity is None: + _require_legacy_destination_train_identity( + train, new_identity, old_configured_root=Path(plan.old_configured_root) + ) train_sha256 = _sha256(Path(plan.source_train_path)) if train_sha256 == plan.source_train_sha256: _assert_pre_train_authority(Path(plan.source_train_path), pre) diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 94113decd8..a80150ddb8 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -857,6 +857,13 @@ def assert_source_continuity_apply_allowed( ) -> None: """Reject a new source mutation that could invalidate continuity recovery.""" archive_root = archive_root.resolve() + from polylogue.operations.archive_root_relocation import assert_no_prepared_archive_root_relocation + from polylogue.operations.historical_source_continuity_recovery import ( + assert_no_prepared_historical_source_continuity_recovery, + ) + + assert_no_prepared_archive_root_relocation(archive_root) + assert_no_prepared_historical_source_continuity_recovery(archive_root) pending_root = archive_root / ".maintenance-state" / "source-continuity-pending" pending_intents = tuple(sorted(pending_root.glob("*.json"))) if pending_root.is_dir() else () if allowed_pending_operation_id is not None: diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 1c79c8e58a..e69b33b0e1 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -4195,8 +4195,10 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( watcher = Mock() monkeypatch.setattr(daemon_cli, "run_live_watcher", watcher) - with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): - CliRunner().invoke(main, ["watch"], catch_exceptions=False) + watch_result = CliRunner().invoke(main, ["watch"], catch_exceptions=False) + assert watch_result.exit_code == 1 + assert "prepared but incomplete" in watch_result.output + assert "Watching " not in watch_result.output assert admission.call_count == 2 admission.assert_called_with(configured_alias) @@ -4230,8 +4232,10 @@ def test_daemon_archive_root_relocation_prepared_receipt_blocks_components( browser_capture_spool_path=None, ) ) - with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): - CliRunner().invoke(main, ["watch"], catch_exceptions=False) + watch_result = CliRunner().invoke(main, ["watch"], catch_exceptions=False) + assert watch_result.exit_code == 1 + assert "prepared but incomplete" in watch_result.output + assert "Watching " not in watch_result.output assert continuity_admission.call_count == 2 diff --git a/tests/unit/storage/test_archive_root_relocation.py b/tests/unit/storage/test_archive_root_relocation.py index 6cde10d92b..8fd4db7ff2 100644 --- a/tests/unit/storage/test_archive_root_relocation.py +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -1628,6 +1628,51 @@ def swap_after_enumeration(path: str, flags: int, *args: object, **kwargs: objec assert swapped +def test_source_mutation_admission_fences_each_prepared_maintenance_operation(workspace_env: dict[str, Path]) -> None: + """The shared source-mutation boundary cannot invalidate either resume path. + + Anti-vacuity: both receipts are production-sealed files under the archive's + maintenance root. Calling the shared admission function proves the same + guard used by offline source mutations rejects them before SQLite work. + """ + root = workspace_env["archive_root"] + relocation_path = root / ".maintenance-state" / "archive-root-relocations" / ("a" * 64 + ".json") + _write_relocation_receipt( + relocation_path, + _sealed_relocation_receipt( + state="prepared", + revision=0, + plan_sha256="a" * 64, + authorization="a" * 64, + manifest_before_sha256=(), + manifest_after_sha256=(), + resume_command="resume relocation", + ), + expected=None, + ) + with pytest.raises(ArchiveRootRelocationError, match="prepared but incomplete"): + assert_source_continuity_apply_allowed(root) + + relocation_path.unlink() + recovery_path = root / ".maintenance-state" / "historical-source-continuity-recoveries" / ("b" * 64 + ".json") + _write_continuity_receipt( + recovery_path, + _sealed_continuity_receipt( + state="prepared", + revision=0, + plan_sha256="b" * 64, + authorization="b" * 64, + train_before_sha256="c" * 64, + train_after_sha256="d" * 64, + refresh_receipt_sha256="e" * 64, + resume_command="resume continuity", + ), + expected=None, + ) + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="prepared but incomplete"): + assert_source_continuity_apply_allowed(root) + + def test_historical_continuity_recovery_cli_rejects_an_unbound_synthetic_operation( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2414,20 +2459,22 @@ def test_relocation_v3_plan_decodes_but_requires_a_prepared_resume_receipt( from polylogue.operations import archive_root_relocation as relocation - pointer_fields = relocation._pointer_receipt_fields(loaded.active_index_pointer) - initial = _sealed_relocation_receipt( - state="prepared", - revision=0, - plan_sha256=loaded.plan_sha256, - authorization=loaded.plan_sha256, - manifest_before_sha256=tuple(item.before_manifest_sha256 for item in loaded.durable_trains), - manifest_after_sha256=(), - active_index_pointer_old_target=pointer_fields[0], - active_index_pointer_new_target=pointer_fields[1], - active_index_pointer_new_resolved_target=pointer_fields[2], - resume_command="polylogue ops maintenance archive-root-relocation apply", - ) - _write_relocation_receipt(relocation._receipt_path(new_root, loaded), initial, expected=None) + legacy_receipt = { + "format": "polylogue.archive-root-relocation-receipt.v1", + "state": "prepared", + "revision": 0, + "plan_sha256": loaded.plan_sha256, + "authorization": loaded.plan_sha256, + "manifest_before_sha256": [item.before_manifest_sha256 for item in loaded.durable_trains], + "manifest_after_sha256": [], + "resume_command": "polylogue ops maintenance archive-root-relocation apply", + } + legacy_receipt["receipt_sha256"] = hashlib.sha256( + json.dumps(legacy_receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() + receipt_path = relocation._receipt_path(new_root, loaded) + receipt_path.parent.mkdir(parents=True, exist_ok=True) + receipt_path.write_text(json.dumps(legacy_receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") resumed = apply_archive_root_relocation(root=new_root, plan=loaded, authorization=loaded.plan_sha256) assert resumed.state == "committed" @@ -2692,8 +2739,12 @@ def test_relocation_accepts_a_modern_no_rebind_train_without_rewriting_it( assert plan.durable_trains[0].requires_rebind is False moved_manifest = Path(plan.durable_trains[0].path) before = moved_manifest.read_bytes() - assert apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256).state == "committed" + result = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert result.state == "committed" + assert result.changed_manifests == () assert moved_manifest.read_bytes() == before + repeated = apply_archive_root_relocation(root=new_root, plan=plan, authorization=plan.plan_sha256) + assert repeated.changed_manifests == () def test_relocation_resume_rejects_a_same_revision_manifest_substituted_after_cas( @@ -2927,6 +2978,47 @@ def test_historical_continuity_recovery_cli_rejects_a_byte_identical_copied_arch assert "device/inode continuity" in result.output +def test_historical_recovery_rejects_a_copied_legacy_backup_destination( + workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Legacy tier fingerprints still bind the moved source-file identity. + + Anti-vacuity: the two authentic backup packages omit only their original + source-file device/inode fields, then a byte-identical archive copy is + presented to the real planning API. Accepting a self-observed destination + identity would let that copy receive fresh continuity authority. + """ + moved_root, mutation_receipt, pre_manifest, post_manifest, evidence = _historical_continuity_fixture( + workspace_env, tmp_path, monkeypatch + ) + old_root = workspace_env["archive_root"] + _downgrade_historical_backup_source_identity(pre_manifest, old_root=old_root) + _downgrade_historical_backup_source_identity(post_manifest, old_root=old_root) + with sqlite3.connect(f"file:{pre_manifest.parent / 'source.db'}?mode=ro&immutable=1", uri=True) as connection: + candidates = classify_blob_ref_liveness(connection).candidates + _pinned_historical_operation_evidence( + evidence, + mutation_receipt=mutation_receipt, + candidates=candidates, + pre_manifest=pre_manifest, + post_manifest=post_manifest, + ) + copied_root = tmp_path / "copied-legacy" + shutil.copytree(moved_root, copied_root, symlinks=True) + + with _test_historical_operation_evidence_resource(evidence): + with pytest.raises(HistoricalSourceContinuityRecoveryError, match="source.db device/inode continuity"): + prepare_historical_source_continuity_recovery( + old_root=old_root, + new_root=copied_root, + mutation_receipt=mutation_receipt, + pre_backup_manifest=pre_manifest, + post_backup_manifest=post_manifest, + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + + def test_cli_runs_historical_recovery_then_uses_a_fresh_moved_root_backup_for_relocation( workspace_env: dict[str, Path], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: