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/devtools/render_cli_output_schemas.py b/devtools/render_cli_output_schemas.py index 875852facd..aa23ff8dfa 100644 --- a/devtools/render_cli_output_schemas.py +++ b/devtools/render_cli_output_schemas.py @@ -26,6 +26,8 @@ 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.operations.historical_source_continuity_recovery import HistoricalSourceContinuityRecoveryResult from polylogue.surfaces.payloads import ( ArchiveDebtListPayload, ImportExplainPayload, @@ -270,6 +272,20 @@ 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="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/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 fe4addc4b2..87064eda1a 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. | @@ -37,6 +38,59 @@ 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 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- 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 \ + polylogue ops backup \ + --output-dir /safe/operator/location/relocation-backup \ + --profile full_evidence --verify +``` + +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 \ + polylogue ops maintenance archive-root-relocation plan \ + --old-root /old/archive/root \ + --backup-manifest /safe/operator/location/relocation-backup/PACKAGE/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, 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. + ## Restore Rules Restore into an isolated archive root first: @@ -141,8 +195,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/db/polylogue/user.db "SELECT count(*) FROM assertions;" -sqlite3 /realm/db/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)** — @@ -169,10 +224,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 47847f455a..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/db/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/docs/maintenance.md b/docs/maintenance.md index 83a2559699..9115d87523 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -39,6 +39,30 @@ 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. `--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 +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 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 + +`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. 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/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` | 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/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..295e1a1dba 100644 --- a/polylogue/cli/click_command_registration.py +++ b/polylogue/cli/click_command_registration.py @@ -84,6 +84,16 @@ 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: + resolved = self._resolve() + if isinstance(resolved, click.Group) and self.callback is None: + self.callback = resolved.callback + 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 e575b802dc..ad6cd9b114 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, _NestedLazyGroup # (cli name, submodule, attribute, short_help) _COMMANDS: tuple[tuple[str, str, str, str], ...] = ( @@ -46,6 +46,18 @@ "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.", + ), + ( + "source-continuity-recovery", + "_source_continuity_recovery", + "source_continuity_recovery_command", + "Recover one authenticated pre-#3868 source liveness transition offline.", + ), ( "run-preview", "_run_preview", @@ -215,6 +227,8 @@ ), ) +_NESTED_GROUP_COMMANDS = frozenset({"archive-root-relocation", "source-continuity-recovery"}) + @click.group("maintenance") @click.pass_context @@ -237,8 +251,9 @@ def maintenance_group(ctx: click.Context) -> None: for _cli_name, _submodule, _attr, _short_help in _COMMANDS: + _command_type = _NestedLazyGroup if _cli_name in _NESTED_GROUP_COMMANDS else _LazyCommand maintenance_group.add_command( - _LazyCommand( + _command_type( _cli_name, f"polylogue.cli.commands.maintenance.{_submodule}", _attr, 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..db34bd4634 --- /dev/null +++ b/polylogue/cli/commands/maintenance/_archive_root_relocation.py @@ -0,0 +1,84 @@ +"""``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 ArchiveOwnershipError, 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 (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)) + 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 to durable trains and sealed index-generation topology.""" + root = archive_root() + try: + plan = load_archive_root_relocation_plan(plan_path) + 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": + 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/cli/commands/maintenance/_source_continuity_recovery.py b/polylogue/cli/commands/maintenance/_source_continuity_recovery.py new file mode 100644 index 0000000000..be3985307a --- /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 ArchiveOwnershipError, 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 (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) + 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 (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) + if output_format == "json" + else f"Historical source continuity recovery {result.state}: {result.receipt_path}" + ) diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index d84be55f16..981cb8dba8 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -184,21 +184,127 @@ 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 { "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 [] def _all_archive_tiers(root: Path) -> dict[str, Path]: - return archive_tier_paths(root) + tiers = archive_tier_paths(root) + pointer = root / ".index-active-pointer" + try: + 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 ( + 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() + 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]] = [] + 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 + relative_parts = relative_conventional.parts + if len(relative_parts) > len(configured_target.parts) or configured_target.parts[-len(relative_parts) :] != ( + relative_parts + ): + 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() + and _readable_sqlite_index(mapped) + ): + 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 def _profile_archive_tiers(root: Path, profile: BackupProfile) -> dict[str, Path]: @@ -471,6 +577,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 +592,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 +712,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/daemon/cli.py b/polylogue/daemon/cli.py index f849bccac5..e5aa8eb97e 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( @@ -2262,6 +2264,15 @@ 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 + 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 @@ -3393,6 +3404,16 @@ 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 ( + 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 runtime_source_paths = resolve_runtime_config().source_paths sources = _watch_sources_from_roots( @@ -3401,11 +3422,32 @@ def watch_command(roots: tuple[Path, ...], debounce_s: float) -> None: beads_roots=runtime_source_paths.beads, ) - click.echo( - 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) + 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 + writer_drained = False + watcher_started = False + try: + 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: + if not watcher_started or writer_drained: + archive_owner.release() __all__ = [ 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/maintenance/receipt_fs.py b/polylogue/maintenance/receipt_fs.py new file mode 100644 index 0000000000..306cd9dac3 --- /dev/null +++ b/polylogue/maintenance/receipt_fs.py @@ -0,0 +1,221 @@ +"""Descriptor-pinned publication and reading of retained 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 | str, *, label: str, parent_fd: int | None = None) -> int: + try: + 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): + os.close(descriptor) + raise MaintenanceReceiptPathError(f"{label} is not a real directory: {path}") + 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, *, 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: + try: + 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 + 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) + 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) + 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}" + ) 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) + + +@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") + 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 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") + 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", + "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 new file mode 100644 index 0000000000..d0981230df --- /dev/null +++ b/polylogue/operations/archive_root_relocation.py @@ -0,0 +1,1933 @@ +"""One explicit offline transition for an inode-preserving archive-root move.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shlex +import sqlite3 +import stat +import tempfile +import uuid +from contextlib import closing, suppress +from pathlib import Path +from typing import Literal, cast + +from pydantic import BaseModel, ConfigDict + +from polylogue.config import Config +from polylogue.maintenance.offline_guard import offline_writer_block_reason +from polylogue.maintenance.receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + existing_maintenance_receipt_directory, + iter_pinned_receipts, + maintenance_receipt_directory, + read_optional_receipt, +) +from polylogue.paths import render_root +from polylogue.storage.archive_identity import ( + ArchiveIdentity, + ArchiveLocation, + ArchiveOwnershipError, + ArchiveTierName, + OwnedArchiveLocation, + TierFileIdentity, +) +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_archive_root_relocation_receipts, + _validate_source_continuity_refresh_receipt, + load_durable_change_train_manifest, + 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.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.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") + + +class ArchiveRootRelocationError(DurableChangeTrainError): + """The requested root move has no single safe offline transition.""" + + +class RelocationTierEvidence(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + tier: ArchiveTierName + configured_path: str + resolved_path: str + backup_device: int + backup_inode: int + device: int + inode: int + size_bytes: int + sha256: str + user_version: int + schema_inventory_sha256: str + content_sha256: str + quick_check: tuple[str, ...] + + +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 + 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 + conventional_symlink_old_target: str | None = None + conventional_symlink_new_target: str | None = None + device: int + 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 + before_device: int | None = None + before_inode: int | None = None + 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 + directory_device: int + directory_inode: int + metadata_before_device: int | None = None + metadata_before_inode: int | None = None + 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.v3", + "polylogue.archive-root-relocation-plan.v4", + ] = PLAN_FORMAT + old_configured_root: str + old_resolved_root: str + backup_root_device: int + backup_root_inode: int + 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, ...] + active_index_pointer: RelocationActiveIndexPointer | None + index_generations: tuple[RelocationIndexGeneration, ...] + durable_trains: tuple[RelocationDurableTrain, ...] + 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", + "polylogue.archive-root-relocation-receipt.v2", + ] = RECEIPT_FORMAT + state: Literal["prepared", "committed"] + revision: int + plan_sha256: str + 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 + + +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 _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( + mode="json", + exclude={"plan_sha256"}, + exclude_none=plan.format == _LEGACY_PLAN_FORMAT, + ) + return plan.model_copy(update={"plan_sha256": _canonical_sha256(payload)}) + + +def _sealed_receipt(**values: object) -> ArchiveRootRelocationReceipt: + receipt = ArchiveRootRelocationReceipt.model_validate({"format": RECEIPT_FORMAT, **values, "receipt_sha256": ""}) + return receipt.model_copy(update={"receipt_sha256": _canonical_sha256(_receipt_checksum_payload(receipt))}) + + +def _verify_plan(plan: ArchiveRootRelocationPlan) -> None: + 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: + expected = _canonical_sha256(_receipt_checksum_payload(receipt)) + 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, + *, + 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: + 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. + metadata = _real_file(resolved_path, label="active index tier") + else: + metadata = _real_file(path, label=f"{tier.value} tier") + try: + 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: + 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), + backup_device=backup_device, + backup_inode=backup_inode, + 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 _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 + 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" + ) + 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), + conventional_symlink_old_target=conventional_old_target, + conventional_symlink_new_target=conventional_new_target, + device=metadata.st_dev, + inode=metadata.st_ino, + ) + + +_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") + directory_fd = -1 + try: + 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: + 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(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] = [] + 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) + + +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, + *, + allow_post_publication: bool, +) -> 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) + directory_fd = _open_pinned_generation_directory(root, item) + try: + _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) + 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, + allow_post_publication=allow_post_publication, + ) + finally: + os.close(directory_fd) + + +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) -> tuple[bytes, os.stat_result]: + 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(), metadata + 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 _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, + *, + 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 ( + 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") + + +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 ( + 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") + + +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, ...], + 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, 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, + 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 = { + **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) + 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" + ) + 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: + continue + temporary = f".{path.name}.relocation-{uuid.uuid4().hex}.tmp" + try: + 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: + with suppress(FileNotFoundError): + os.unlink(temporary, dir_fd=directory_fd) + finally: + os.close(directory_fd) + _validate_index_generation_state(root, items, active_index_pointer, allow_post_publication=True) + + +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") + 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) + 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_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) + 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".{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, 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: + 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 + 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") + 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 + 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 _durable_trains( + root: Path, + *, + 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") + tier_identities = tuple( + TierFileIdentity( + item.tier, + Path(item.configured_path), + Path(item.resolved_path), + item.device, + item.inode, + ) + for item in snapshots + ) + index_identity = next(item for item in tier_identities if item.name == "index") + 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): + 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_archive_root_relocation_receipts(root, train) + except DurableChangeTrainError as exc: + raise ArchiveRootRelocationError( + f"archive-root relocation {tier.value} train relocation authority is invalid" + ) from exc + continuity_refs = tuple( + ref.removeprefix("proof:source-continuity-refresh:") + for ref in train.proof_refs + if ref.startswith("proof:source-continuity-refresh:") + ) + 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 != 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" + ) + 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) + + +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_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") + return { + tier.value: _authenticated_identity(fingerprints.get(f"{tier.value}.db"), label=f"{tier.value} tier") + for tier in ArchiveTier + } + + +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" + ) + + +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 = { + "device": snapshot.backup_device, + "inode": snapshot.backup_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") + 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}") + _require_identity_continuity( + backup_device=snapshot.backup_device, + backup_inode=snapshot.backup_inode, + device=snapshot.device, + inode=snapshot.inode, + label=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, + backup_configured_root=new_configured, + backup_archive_root=new_resolved, + ) + except MigrationError as exc: + raise ArchiveRootRelocationError(str(exc)) from exc + backup_root_device, backup_root_inode = _authenticated_identity( + manifest.get("archive_root_source_identity"), label="archive root" + ) + 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, + tier, + 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 + ) + _check_backup_against_live(new_resolved, manifest=manifest, receipt=receipt, snapshots=snapshots) + trains = _durable_trains( + new_resolved, + old_root=old_resolved, + snapshots=snapshots, + ) + root_metadata = new_resolved.stat() + _require_identity_continuity( + backup_device=backup_root_device, + backup_inode=backup_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), + 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, + 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, + 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, + 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 _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": + 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) + root, directory_name = _receipt_directory_binding(path) + try: + 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: + try: + 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: + try: + 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=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 + ) + + +def _requires_train_update(item: RelocationDurableTrain) -> bool: + """Return whether relocation must CAS-revise this released train.""" + 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 _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: + """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( + format=receipt.format, + 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 _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 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, + 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, + *, + 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: RelocationDurableTrain, + train: object, + before: bool, + 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.continuity_receipt_digests: + raise ArchiveRootRelocationError("archive-root relocation exact refresh proof changed") + 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 + if ref.startswith("proof:source-continuity-relocation:") + ) + matches = 0 + 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) + 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") + + +def _revalidate_plan_live_state( + root: Path, + plan: ArchiveRootRelocationPlan, +) -> None: + """Recheck every immutable plan binding while allowing CAS resume states.""" + 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), + backup_configured_root=Path(plan.new_configured_root), + backup_archive_root=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") + 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.backup_root_device, + plan.backup_root_inode, + ): + 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") + pending_receipt = _load_receipt_for_update(_receipt_path(root, plan)) + prepared_publication = _has_matching_prepared_publication_receipt(plan, pending_receipt) + 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" + ) + snapshots = tuple( + _tier_snapshot( + root, + tier, + 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 + ) + 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) + _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" + else None + ) + 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 = manifest_sha256 == item.before_manifest_sha256 + after = ( + 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}") + 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: + _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 + _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: + 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, +) -> ArchiveRootRelocationResult: + """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 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") + 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(root, plan) + receipt_path = _receipt_path(root, plan) + retained_plan_path = _retain_plan(root, plan) + command = ( + 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) + receipt = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=authorization, + 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=command, + ) + 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 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: + 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.durable_trains if _requires_train_update(item)), + ) + 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_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): + 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): + 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 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=2, + plan_sha256=plan.plan_sha256, + authorization=authorization, + manifest_before_sha256=before_hashes, + manifest_after_sha256=tuple(after_hashes), + 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, 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.durable_trains if _requires_train_update(item)), + ) 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 new file mode 100644 index 0000000000..85abb8c42f --- /dev/null +++ b/polylogue/operations/historical_source_continuity_recovery.py @@ -0,0 +1,1386 @@ +"""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 shlex +import sqlite3 +import stat +import tempfile +from importlib import resources +from pathlib import Path +from typing import Literal, cast + +from pydantic import BaseModel, ConfigDict + +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.maintenance.receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + existing_maintenance_receipt_directory, + iter_pinned_receipts, + maintenance_receipt_directory, + read_optional_receipt, +) +from polylogue.paths import render_root +from polylogue.storage.archive_identity import ( + ArchiveIdentity, + ArchiveLocation, + ArchiveOwnershipError, + OwnedArchiveLocation, + TierFileIdentity, +) +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, + _durable_train_manifest_sha256, + _read_source_continuity_refresh_receipt, + _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, + capture_durable_schema_inventory, +) + +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" + + +class HistoricalSourceContinuityRecoveryError(DurableChangeTrainError): + """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.v2"] = 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 + historical_evidence_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 + 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 + refresh_receipt_sha256: str + 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] + 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 + 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 + + +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: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + 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() + 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 _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: + try: + return HistoricalOperationEvidence.model_validate_json(_historical_operation_evidence_bytes()) + except 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 hashlib.sha256(_historical_operation_evidence_bytes()).hexdigest() + + +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") + 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( + manifest_path: Path, *, old_source_path: Path +) -> 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") + 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") + device, inode = fingerprint.get("device"), fingerprint.get("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} + 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") + 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: + 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 _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: + 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 _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, + new_root: Path, + source_train_sha256: str, + historical_evidence_sha256: str, + pre_identity: tuple[int, int] | None, + post_identity: tuple[int, int] | None, + 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": _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, + "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") + try: + 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 + 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]: + """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: + _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( + "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 _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 = [_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: + raise HistoricalSourceContinuityRecoveryError(f"cannot deterministically read source table: {table}") from exc + 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: + 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: + """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 _require_offline_ownership_boundary(root: Path) -> None: + """Make offline authority real for callers that bypass the Click adapter.""" + 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; {reason}" + ) + + +def _write_refresh_receipt(path: Path, payload: dict[str, object]) -> None: + """Publish a retained receipt beneath a pinned, non-symlink directory.""" + 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 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( + *, + 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, 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" + ) + 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() + ) + 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 + ) 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, + ) + _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 ( + 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 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) + 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": 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, + "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), + 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), + historical_evidence_sha256=historical_evidence_sha256, + 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), + 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, + 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=_durable_train_manifest_sha256(expected_train), + 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 _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": + 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) + root, directory_name = _recovery_receipt_directory_binding(path) + try: + 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: + try: + 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: + try: + 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)) + 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 + ) + + +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, 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 + ) + 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 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), + (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") + 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") + 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) + 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 + + +def apply_historical_source_continuity_recovery( + *, + root: Path, + plan: HistoricalSourceContinuityRecoveryPlan, + 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") + 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( + *, + 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 = _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) + retained_plan_path = _retain_plan(resolved, plan) + command = ( + 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) + prepared = _sealed_receipt( + state="prepared", + revision=0, + plan_sha256=plan.plan_sha256, + authorization=authorization, + train_before_sha256=plan.source_train_sha256, + train_after_sha256=plan.source_train_after_sha256, + refresh_receipt_sha256=refresh_digest, + resume_command=command, + ) + 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" + ) + 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) + 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 + encoded = {**refresh_payload, "refresh_sha256": refresh_digest} + _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: + updated = recover_released_source_train_continuity( + train, current_evidence=planned_current, proof_ref="proof:source-continuity-refresh:" + refresh_digest + ) + if _durable_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) + 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", + revision=1, + plan_sha256=plan.plan_sha256, + authorization=authorization, + train_before_sha256=plan.source_train_sha256, + train_after_sha256=plan.source_train_after_sha256, + 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 7b5b6bb50d..a80150ddb8 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -18,6 +18,14 @@ from pathlib import Path from typing import Final, Literal, cast +from polylogue.maintenance.receipt_fs import ( + MaintenanceReceiptPathError, + atomic_replace_receipt, + existing_maintenance_receipt_directory, + iter_pinned_receipts, + maintenance_receipt_directory, + read_optional_receipt, +) from polylogue.storage.blob_ref_liveness import ( BlobRefLivenessCandidate, BlobRefLivenessCandidateDigest, @@ -75,7 +83,12 @@ _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"] _FRESH_DURABLE_BOOTSTRAP_FORMAT = "polylogue.durable-bootstrap.v1" _FRESH_DURABLE_BOOTSTRAP_MARKER = ".bootstrap" @@ -97,6 +110,103 @@ 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 + + +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") + 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.""" @@ -555,6 +665,78 @@ def _persist_train_transition(path: Path, train: DurableChangeTrain, *, expected return load_durable_change_train_manifest(path) +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 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 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) + 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_refs), + ) + validate_durable_change_train_manifest(updated) + 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 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",): + 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, *, @@ -675,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: @@ -1032,55 +1221,439 @@ def _validate_source_mutation_receipt_bytes( def _validate_source_continuity_refresh_receipt( archive_root: Path, train: DurableChangeTrain, -) -> None: + *, + allowed_pending_relocation_receipt_sha256: str | 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_root = archive_root / ".maintenance-state" / "source-continuity-refreshes" refresh_refs = [ ref.removeprefix("proof:source-continuity-refresh:") 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) - if payload.get("source_after") == expected_after: - matches += 1 - if matches != 1: - raise DurableChangeTrainError( - "source continuity evidence does not identify exactly one matching refresh receipt" + payload = _read_source_continuity_refresh_receipt(archive_root, digest=digest, train=train) + refresh_payloads[digest] = payload + 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() + } + 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, ) + relocation_payloads[ref] = payload + + predecessors: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} + successor_by_authority: dict[_SourceContinuityAuthorityRef, _SourceContinuityAuthorityRef] = {} + + 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 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 transition has invalid predecessor authority") + predecessor = _SourceContinuityAuthorityRef(cast(_SourceContinuityAuthorityKind, kind), predecessor_digest) + if predecessor in successor_by_authority: + raise DurableChangeTrainError("source continuity authority branches ambiguously") + predecessors[ref] = predecessor + successor_by_authority[predecessor] = ref + + 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") + refreshed_at_ms = _legacy_source_continuity_refresh_timestamp(payload) + candidates = [ + ( + _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 candidates: + 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 + successor_by_authority[predecessor] = ref + 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_V1_FORMAT + or ( + 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: + 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 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 = transition_payloads[transition_ref] + 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, + source_after=payload.get("source_after"), + ) + nodes[transition_ref] = node + predecessor_node = node + 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: + 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( - 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}") 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 +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 _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:") + ) + transitions: list[tuple[str, str, str]] = [] + if proof_digests: + 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 + 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, + ) + ) + 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)) + 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") + 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 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 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 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("continuity transition proof does not bind the latest durable identity") + + +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 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, + 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) + 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, + "predecessor_authority": {"kind": predecessor.kind, "sha256": predecessor.sha256}, + "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, *, @@ -1222,11 +1795,9 @@ 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 - existing = _read_source_continuity_refresh_receipt(existing_path, digest=digest, train=train) + 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)) for existing_path, existing in retained_refreshes: @@ -1235,7 +1806,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) @@ -1279,60 +1850,11 @@ def _refresh_released_source_train_continuity_locked( "source continuity refresh requires successful quick_check evidence" ) - payload = { - "format": "polylogue.source-continuity-refresh.v1", - "operation_id": operation_id, - "evidence_ref": evidence_ref, - "backup_manifest": str(backup_manifest), - "backup_manifest_sha256": backup_digest, - "mutation_receipt": str(mutation_receipt), - "mutation_receipt_sha256": mutation_digest, - "train_id": train.train_id, - "source_before": serialized_before, - "source_after": serialized_current, - "refreshed_at_ms": current.observed_at_ms, - } - refresh_digest = _canonical_json_sha256(payload) - 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(): - 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}: - 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) - 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") + 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 ( @@ -1346,13 +1868,55 @@ def _refresh_released_source_train_continuity_locked( archive_identity_digest=current.archive_identity_digest, ), ) - updated = replace( + 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, + proof_refs=references_without_receipt, ) + payload = { + "format": _SOURCE_CONTINUITY_REFRESH_V2_FORMAT, + "operation_id": operation_id, + "evidence_ref": evidence_ref, + "backup_manifest": str(backup_manifest), + "backup_manifest_sha256": backup_digest, + "mutation_receipt": str(mutation_receipt), + "mutation_receipt_sha256": mutation_digest, + "train_id": train.train_id, + "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_path = refresh_root / f"{refresh_digest}.json" + 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 @@ -1841,6 +2405,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( @@ -1861,6 +2426,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, @@ -2302,8 +2874,14 @@ 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 + 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/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index f367ad6f85..86f236aaca 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -538,8 +538,9 @@ def _validated_receipt_artifacts( manifest: dict[str, object], receipt: dict[str, object], *, - target_tier: str, + 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 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}: @@ -701,13 +708,70 @@ 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, + 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) + 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, + live_tier_paths=live_tier_paths, + 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: """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 @@ -718,54 +782,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,56 +830,123 @@ 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 +def validate_full_evidence_backup_for_archive_root_relocation( + path: Path, + *, + backup_configured_root: Path, + backup_archive_root: Path, +) -> 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} + 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") + for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT): + try: + 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 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 + }, + ) + 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 moved-tier path authority for {filename}") + recorded = Path(recorded_path).resolve(strict=False) + if tier == ArchiveTier.INDEX.value: + 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 moved-root inode authority") + recorded_root = root_identity.get("resolved_path") + 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 moved archive root") + recorded_configured_root = root_identity.get("configured_path") + if ( + not isinstance(recorded_configured_root, str) + or Path(recorded_configured_root).absolute() != backup_configured_root.absolute() + ): + raise MigrationError("archive-root relocation backup belongs to a different configured moved archive root") + 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. @@ -855,14 +958,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"} @@ -874,15 +971,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): @@ -898,24 +986,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 dbd62b9b23..aa611a47b1 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, @@ -91,6 +92,29 @@ 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 + 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: + """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, 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 710f0d26c9..e69b33b0e1 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,26 @@ 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_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" @@ -2465,10 +2491,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( @@ -4104,6 +4131,115 @@ 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( + 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, + 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) + 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, + backup_manifest=Path(backup.output_path) / "manifest.json", + stopped_daemon_evidence_ref="proof:daemon-stopped", + single_writer_evidence_ref="proof:archive-ownership-lock", + ) + with monkeypatch.context() as scoped: + scoped.setattr( + "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"): + 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: configured_alias) + 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="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, + ) + ) + + admission.assert_called_once_with(configured_alias) + configure.assert_not_called() + + watcher = Mock() + monkeypatch.setattr(daemon_cli, "run_live_watcher", watcher) + 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) + 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, + ) + ) + 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 + + def test_emit_daemon_lifecycle_event_carries_dev_loop_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 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 new file mode 100644 index 0000000000..8d4db86789 --- /dev/null +++ b/tests/unit/operations/test_maintenance_receipt_fs.py @@ -0,0 +1,53 @@ +"""Failure atomicity for descriptor-pinned maintenance receipt directories.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from polylogue.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.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() + + +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 new file mode 100644 index 0000000000..8fd4db7ff2 --- /dev/null +++ b/tests/unit/storage/test_archive_root_relocation.py @@ -0,0 +1,3664 @@ +"""Regression coverage for the offline inode-preserving archive-root move.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shlex +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, patch + +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, + ArchiveRootRelocationPlan, + RelocationActiveIndexPointer, + RelocationIndexGeneration, + RelocationTierEvidence, + _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, +) +from polylogue.operations.archive_root_relocation import ( + _write_receipt as _write_relocation_receipt, +) +from polylogue.operations.historical_source_continuity_recovery import ( + HistoricalSourceContinuityRecoveryError, + HistoricalSourceContinuityRecoveryReceipt, + _assert_complete_source_semantic_delta, + _assert_exact_liveness_delta, + _current_evidence, + _sha256, + _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, +) +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 ( + ArchiveIdentity, + ArchiveLocation, + ArchiveOwnershipError, + OwnedArchiveLocation, +) +from polylogue.storage.blob_ref_liveness import ( + BlobRefLivenessCandidate, + 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, + DurableChangeTrain, + DurableChangeTrainError, + assert_source_continuity_apply_allowed, + 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, + 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, + write_durable_change_train_manifest, +) + + +@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( + cli, + ["--plain", "ops", "maintenance", "archive-root-relocation", "--help"], + catch_exceptions=False, + ) + + 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 + 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( + 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"] + 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, monkeypatch: pytest.MonkeyPatch +) -> None: + """The plan enters backup attestation and immutable archive inspection, never a write route.""" + old_root = workspace_env["archive_root"] + 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") + } + + 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 + + +def test_plan_rejects_mutated_manifest_and_stale_authenticated_receipt( + 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 + 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() + + 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) + 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="does not authenticate the moved tier identity"): + 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_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"), + backup_device=41, + backup_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.backup_device, + "inode": snapshot.backup_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) + 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]: + 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: + """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 + before_evidence = before.apply_evidence + assert before_evidence is not None + updated = rebind_released_durable_train_archive_identity( + before, + archive_identity_digest="a" * 64, + proof_refs=("proof:archive-root-relocation:receipt",), + ) + + assert updated.revision == before.revision + 1 + assert updated.apply_evidence == replace( + before_evidence, + 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_durable_train_archive_identity( + current_authority, + archive_identity_digest="c" * 64, + 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( + current_authority.source_continuity_evidence, + archive_identity_digest="c" * 64, + ) + + +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 _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 + + return render(evidence) # type: ignore[arg-type] + + +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 + + 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 + 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( + source_post, + archive_identity_digest=ArchiveIdentity.resolve(root).authority_identity_digest, + ), + ), + proof=source_proof, + ) + 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) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.USER, 10_000) + monkeypatch.setitem(DURABLE_MIGRATION_ADOPTION_FLOORS, ArchiveTier.AUDIT, 10_000) + return manifest + + +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 + + 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) + 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, + *, + old_root: Path, + pre_manifest: Path, + candidates: tuple[BlobRefLivenessCandidate, ...], +) -> None: + """Encode the exact pre-#3868 shape: no backup digest or postcondition field.""" + 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 _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 _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. + """ + 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", + ) + 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]: + """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( + """ + 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_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="unsafe"): + _write_refresh_receipt( + state / "source-continuity-refreshes" / ("a" * 64 + ".json"), + {"refresh_sha256": "a" * 64}, + ) + 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_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_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) + second = replace( + observed, + archive_identity_digest=first.source_continuity_evidence.archive_identity_digest, + observed_at_ms=first.source_continuity_evidence.observed_at_ms + 3, + ) + 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( + replace( + first.source_continuity_evidence, observed_at_ms=first.source_continuity_evidence.observed_at_ms + 2 + ) + ), + "source_after": _evidence_payload(second), + "refreshed_at_ms": second.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=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) + + +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: + """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="e" * 64, + 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}, + ) + 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="2" * 64, + refresh_receipt_sha256="1" * 64, + resume_command="resume continuity", + ), + expected=None, + ) + + 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="f" * 64, + 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_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: + """The production CLI refuses a file-backed substitute for the attested operation.""" + + 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 + 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" + legacy_receipt = tmp_path / "legacy-liveness.jsonl" + _legacy_liveness_receipt( + legacy_receipt, + old_root=old_root, + pre_manifest=pre_manifest, + candidates=prior.candidates, + ) + 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) + + 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 + 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 + ) + 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): + 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, + ) + 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"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) + 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) + 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(retained_plan), + "--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(retained_plan), + "--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) + 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() + 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(retained_plan), + "--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, + [ + "--plain", + "ops", + "maintenance", + "source-continuity-recovery", + "apply", + "--plan", + str(retained_plan), + "--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_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: + """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) + _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 + moved_manifest = new_root / manifest.relative_to(old_root) + with sqlite3.connect(new_root / "source.db") as connection: + with pytest.raises(DurableChangeTrainError, 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 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") + } + 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_durable_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, + ) + result = apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + ) + assert result.state == "committed" + assert ( + apply_archive_root_relocation( + root=new_root, + plan=plan, + authorization=plan.plan_sha256, + ).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 + ) + # 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_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) + 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) + 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", + ) + + pointer = plan.active_index_pointer + assert pointer is not None + 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)) + 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 + + def write_pointer_in_two_calls(descriptor: int, payload: bytes) -> int: + nonlocal short_pointer_write + expected = (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(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() == 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) + + 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" + 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" + 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_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, + *, + allow_post_publication: bool, + ) -> None: + nonlocal validation_calls + real_validate(root, items, pointer, allow_post_publication=allow_post_publication) + 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_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_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. + + 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, 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() + 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 + + 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" + + +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: + """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) + + +@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, + pointer_kind: str, +) -> 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) + 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" + 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: + """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: + """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() + 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( + 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 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) + 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"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")) + 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: + """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) + 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( + 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: + """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) + 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"): + 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() + + +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_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: + """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 + 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["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]["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 "relocation 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 "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 + + 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, + [ + "--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 + + 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)} + 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": "refresh", + "sha256": refresh_path.stem, + } + + 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 +) -> 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 "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( + 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 "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" + ).exists() + assert not ( + moved_root / ".maintenance-state" / "historical-source-continuity-recovery-plans" / f"{plan_sha256}.json" + ).exists() 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) 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