diff --git a/CLAUDE.md b/CLAUDE.md index 43b0bb8cfb..7321576791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ caller; it exists only as test infrastructure (`polylogue-enium`). `TopologyEdgeStatus` = unresolved/resolved/repaired/**quarantined** (cycle-break). -### The five tiers (durability is the axis) +### The six tiers (durability is the axis) | Tier | durability | holds | | --- | --- | --- | @@ -119,6 +119,7 @@ caller; it exists only as test infrastructure (`polylogue-enium`). | `index.db` | **rebuildable** | the whole parsed tree, FTS, `session_links`, cost tables, and all materialized insights | | `embeddings.db` | rebuildable | `vec0` virtual table (Voyage 1024-dim), meta, status | | `user.db` | **durable, irreplaceable** | unified `assertions`, settings/context receipts, immutable annotation schemas + batch provenance | +| `audit.db` | **durable, append-only authority** | mutation previews, authorizations, attempts, receipts, and continuity heads | | `ops.db` | disposable | ingest cursors, attempts, `convergence_debt`, cursor-lag samples, daemon events, embed catch-up runs | `user.db` is a **single unified `assertions` table** keyed by a closed @@ -186,8 +187,8 @@ snapshot reference check) to bridge the acquire-blob → commit-row window. Two evolution regimes, enforced by `devtools lab policy schema-versioning`: -- **Durable tiers** (`source.db`, `user.db`): explicit **additive** numbered SQL - migrations under `storage/sqlite/migrations/{source,user}/NNN_*.sql`, one +- **Durable tiers** (`source.db`, `user.db`, `audit.db`): explicit **additive** numbered SQL + migrations under `storage/sqlite/migrations/{source,user,audit}/NNN_*.sql`, one `PRAGMA user_version` step at a time, behind a **verified backup manifest**. Destructive durable changes need a copy-forward design + explicit consent. - **Derived tiers** (`index.db`, `embeddings.db`): no migration *chain*, but not diff --git a/devtools/render_cli_output_schemas.py b/devtools/render_cli_output_schemas.py index b8a656f320..f5d23acc09 100644 --- a/devtools/render_cli_output_schemas.py +++ b/devtools/render_cli_output_schemas.py @@ -24,6 +24,7 @@ from devtools.command_catalog import control_plane_command from devtools.render_support import write_if_changed from polylogue.archive.query.metadata import terminal_query_cli_surfaces, terminal_query_source_list +from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload from polylogue.operations.action_contracts import ActionAffordanceListPayload from polylogue.surfaces.payloads import ( ArchiveDebtListPayload, @@ -259,6 +260,16 @@ class CliOutputSchema: "MCP action_affordances", ), ), + CliOutputSchema( + name="migrate-tier-result", + title="Migrate Tier Result", + description=( + "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable " + "adoption and restore receipt references." + ), + model=MigrateTierResultPayload, + surfaces=("polylogue ops maintenance migrate-tier --output-format json",), + ), CliOutputSchema( name="machine-error", title="Machine Error Envelope", diff --git a/devtools/validation_lane_catalog_contracts.py b/devtools/validation_lane_catalog_contracts.py index 1897f627da..4ee6223395 100644 --- a/devtools/validation_lane_catalog_contracts.py +++ b/devtools/validation_lane_catalog_contracts.py @@ -281,6 +281,7 @@ "mutate-clear-corrections", "mutate-delete-session", "mutate-session-excision", + "mutate-session-lifecycle-request", "mutate-identity-reset", ), tags=("contract", "mutation", "operation-executor"), diff --git a/devtools/verify_schema_upgrade_lane.py b/devtools/verify_schema_upgrade_lane.py index 81fc34126b..763f6d1d5e 100644 --- a/devtools/verify_schema_upgrade_lane.py +++ b/devtools/verify_schema_upgrade_lane.py @@ -5,7 +5,7 @@ Polylogue has two schema-evolution regimes: -* Durable tiers (``source.db`` and ``user.db``) may use explicit additive SQL +* Durable tiers (``source.db``, ``user.db``, and ``audit.db``) may use explicit additive SQL migrations with a backup gate. * Derived/rebuildable tiers (``index.db`` and ``embeddings.db``) do not use migration chains. They are rebuilt or blue-green replaced from durable source @@ -86,7 +86,7 @@ ROOT = _get_root() STORAGE_SQLITE_DIR = ROOT / "polylogue" / "storage" / "sqlite" MIGRATIONS_DIR = STORAGE_SQLITE_DIR / "migrations" -ALLOWED_MIGRATION_TIERS = {"source", "user"} +ALLOWED_MIGRATION_TIERS = {"source", "user", "audit"} # Upgrade-shaped helper name patterns. Matched against ``def `` # at the top level of any module under ``polylogue/storage/sqlite/``. @@ -345,7 +345,8 @@ def main(argv: list[str] | None = None) -> int: helpers = _collect_upgrade_helpers() invalid_migrations = _invalid_migration_paths() durable_change_train_reports = { - tier.value: durable_change_train_policy_report(tier) for tier in (ArchiveTier.SOURCE, ArchiveTier.USER) + tier.value: durable_change_train_policy_report(tier) + for tier in (ArchiveTier.SOURCE, ArchiveTier.USER, ArchiveTier.AUDIT) } durable_migration_collisions = durable_migration_collision_report(_durable_migration_claims_on_disk()) delta_report = index_delta_declaration_report(INDEX_SCHEMA_VERSION) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2202e2a1b4..89b36c80b4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -801,6 +801,7 @@ The schema files live under `docs/schemas/cli-output/`. | `session-neighbor-candidate` | `SessionNeighborCandidatePayload` | `polylogue read --view neighbors --format json` | | `mutation-result` | `MutationResultPayload` | `polylogue find then delete --dry-run`
`polylogue find then delete --yes`
`MCP mutation tools`
`daemon mutation endpoints` | | `action-affordance-list` | `ActionAffordanceListPayload` | `polylogue config action-affordances`
`GET /api/action-affordances`
`MCP action_affordances` | +| `migrate-tier-result` | `MigrateTierResultPayload` | `polylogue ops maintenance migrate-tier --output-format json` | | `machine-error` | `MachineErrorPayload` | `polylogue * --machine (error path)` | | `machine-success` | `MachineSuccessPayload` | `polylogue * --machine (success path)` | | `query-error` | `QueryErrorPayload` | `GET /api/sessions?query=... (error path)`
`daemon query/read error responses`
`MCP query/read error responses` | diff --git a/docs/internals.md b/docs/internals.md index ae9e4c0868..534d667129 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -86,9 +86,9 @@ Polylogue has two schema-evolution regimes, keyed by tier durability. - Tier version constants under `storage/sqlite/archive_tiers/` are the authority. The canonical fresh schema is described directly by each tier DDL. -- **Durable tiers** (`source.db`, `user.db`) may use explicit additive +- **Durable tiers** (`source.db`, `user.db`, `audit.db`) may use explicit additive migrations. Migration SQL lives under - `storage/sqlite/migrations/{source,user}/NNN_name.sql`, advances + `storage/sqlite/migrations/{source,user,audit}/NNN_name.sql`, advances `PRAGMA user_version` one step at a time, and requires a verified backup manifest containing the affected tier before it runs. Verification restores the backup into scratch, checks every included SQLite tier and referenced @@ -685,10 +685,11 @@ rebuilds or blue-green-replaces the tier from durable source/user evidence. Files that are not configured archive paths are not classified or handled by the archive runtime. -For **durable tiers** (`source.db`, `user.db`) the boundary is different, because -`user.db` holds irreplaceable human assertions that cannot be rebuilt from -source. These tiers use explicit *additive* numbered SQL migrations under -`storage/sqlite/migrations/{source,user}/NNN_*.sql`, applied one `PRAGMA +For **durable tiers** (`source.db`, `user.db`, `audit.db`) the boundary is different, because +`user.db` holds irreplaceable human assertions and `audit.db` holds immutable +mutation authority and receipt evidence; neither can be rebuilt from source. +These tiers use explicit *additive* numbered SQL migrations under +`storage/sqlite/migrations/{source,user,audit}/NNN_*.sql`, applied one `PRAGMA user_version` step at a time by `migration_runner.py` behind a **verified backup manifest** for the affected tier. Additive means `CREATE TABLE`/`CREATE INDEX`/ `ADD COLUMN`/bounded backfill; destructive durable-tier changes require a diff --git a/docs/maintenance.md b/docs/maintenance.md index ecde62ad50..d039765175 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -728,8 +728,8 @@ polylogue ops diagnostics workload --json | jq .fts_trigger_state.all_present If FTS remains non-ready after daemon convergence, the underlying issue is structural (missing columns, corrupted index file, or a broken write path). -Stop the daemon, restore from backup or rebuild the affected index tier, and -open an issue with the probe output attached. +Stop the daemon, restore or rebuild the affected index tier, and open an issue +with the probe output attached. ### Inspecting a raw-authority census diff --git a/docs/plans/mutation-census.yaml b/docs/plans/mutation-census.yaml index 60425b3d70..6aa47e68b2 100644 --- a/docs/plans/mutation-census.yaml +++ b/docs/plans/mutation-census.yaml @@ -61,6 +61,14 @@ rows: adapters: - polylogue.cli.commands.excise.excise_command + - operation: mutate-session-lifecycle-request + spec_name: mutate-session-lifecycle-request + status: executor-routed + actuator: polylogue.operations.mutation_actuators.SessionLifecycleRequestActuator + surfaces: [cli] + adapters: + - polylogue.cli.commands.excise.excise_command (--mode mirror/primary) + - operation: mutate-identity-reset spec_name: mutate-identity-reset status: executor-routed diff --git a/docs/schemas/cli-output/README.md b/docs/schemas/cli-output/README.md index a79694e19a..58301b8477 100644 --- a/docs/schemas/cli-output/README.md +++ b/docs/schemas/cli-output/README.md @@ -30,6 +30,7 @@ devtools render cli-output-schemas --check # CI sync check | [`session-neighbor-candidate.schema.json`](./session-neighbor-candidate.schema.json) | `polylogue read --view neighbors --format json` | `SessionNeighborCandidatePayload` | | [`mutation-result.schema.json`](./mutation-result.schema.json) | `polylogue find then delete --dry-run`
`polylogue find then delete --yes`
`MCP mutation tools`
`daemon mutation endpoints` | `MutationResultPayload` | | [`action-affordance-list.schema.json`](./action-affordance-list.schema.json) | `polylogue config action-affordances`
`GET /api/action-affordances`
`MCP action_affordances` | `ActionAffordanceListPayload` | +| [`migrate-tier-result.schema.json`](./migrate-tier-result.schema.json) | `polylogue ops maintenance migrate-tier --output-format json` | `MigrateTierResultPayload` | | [`machine-error.schema.json`](./machine-error.schema.json) | `polylogue * --machine (error path)` | `MachineErrorPayload` | | [`machine-success.schema.json`](./machine-success.schema.json) | `polylogue * --machine (success path)` | `MachineSuccessPayload` | | [`query-error.schema.json`](./query-error.schema.json) | `GET /api/sessions?query=... (error path)`
`daemon query/read error responses`
`MCP query/read error responses` | `QueryErrorPayload` | diff --git a/docs/schemas/cli-output/migrate-tier-result.schema.json b/docs/schemas/cli-output/migrate-tier-result.schema.json new file mode 100644 index 0000000000..2f8752d845 --- /dev/null +++ b/docs/schemas/cli-output/migrate-tier-result.schema.json @@ -0,0 +1,304 @@ +{ + "$defs": { + "DurableRecoveryPayload": { + "additionalProperties": false, + "description": "Typed recovery evidence for a blocked durable publication.", + "properties": { + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Code" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + }, + "state": { + "title": "State", + "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target" + } + }, + "required": [ + "state", + "code", + "target", + "detail" + ], + "title": "DurableRecoveryPayload", + "type": "object" + }, + "MigrateTierErrorPayload": { + "additionalProperties": false, + "description": "Blocked result for one durable-tier migration route.", + "properties": { + "backup_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Manifest" + }, + "durable_recovery": { + "anyOf": [ + { + "$ref": "#/$defs/DurableRecoveryPayload" + }, + { + "type": "null" + } + ] + }, + "error": { + "title": "Error", + "type": "string" + }, + "ok": { + "const": false, + "title": "Ok", + "type": "boolean" + }, + "path": { + "title": "Path", + "type": "string" + }, + "stopped_daemon_evidence_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopped Daemon Evidence Ref" + }, + "tier": { + "title": "Tier", + "type": "string" + } + }, + "required": [ + "ok", + "tier", + "path", + "backup_manifest", + "stopped_daemon_evidence_ref", + "error", + "durable_recovery" + ], + "title": "MigrateTierErrorPayload", + "type": "object" + }, + "MigrateTierSuccessPayload": { + "additionalProperties": false, + "description": "Successful result for one durable-tier migration route.", + "properties": { + "adoption_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Adoption Receipt" + }, + "applied_versions": { + "items": { + "type": "integer" + }, + "title": "Applied Versions", + "type": "array" + }, + "backup_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Manifest" + }, + "backup_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backup Receipt" + }, + "forward_version_receipt": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Forward Version Receipt" + }, + "from_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "From Version" + }, + "initialized": { + "title": "Initialized", + "type": "boolean" + }, + "ok": { + "const": true, + "title": "Ok", + "type": "boolean" + }, + "path": { + "title": "Path", + "type": "string" + }, + "restore_receipt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Restore Receipt" + }, + "stopped_daemon_evidence_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stopped Daemon Evidence Ref" + }, + "tier": { + "title": "Tier", + "type": "string" + }, + "to_version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "To Version" + }, + "train_manifest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train Manifest" + }, + "train_state": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Train State" + } + }, + "required": [ + "ok", + "tier", + "path", + "initialized", + "adoption_receipt", + "restore_receipt", + "backup_manifest", + "stopped_daemon_evidence_ref", + "train_manifest", + "train_state", + "backup_receipt", + "from_version", + "to_version", + "applied_versions", + "forward_version_receipt" + ], + "title": "MigrateTierSuccessPayload", + "type": "object" + } + }, + "$id": "https://polylogue.dev/schemas/cli-output/migrate-tier-result.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Result from `polylogue ops maintenance migrate-tier --output-format json`, including durable adoption and restore receipt references.\n\nGenerated from `polylogue.cli.commands.maintenance._migrate_tier.MigrateTierResultPayload` by `devtools render cli-output-schemas`. Do not edit by hand.", + "discriminator": { + "mapping": { + "False": "#/$defs/MigrateTierErrorPayload", + "True": "#/$defs/MigrateTierSuccessPayload" + }, + "propertyName": "ok" + }, + "oneOf": [ + { + "$ref": "#/$defs/MigrateTierSuccessPayload" + }, + { + "$ref": "#/$defs/MigrateTierErrorPayload" + } + ], + "title": "Migrate Tier Result", + "x-polylogue-cli-surfaces": [ + "polylogue ops maintenance migrate-tier --output-format json" + ], + "x-polylogue-source-model": "MigrateTierResultPayload" +} diff --git a/docs/test-quality-workflows.md b/docs/test-quality-workflows.md index 0140b1efb5..da802b2df3 100644 --- a/docs/test-quality-workflows.md +++ b/docs/test-quality-workflows.md @@ -26,9 +26,9 @@ Current registry snapshot: - covered runtime paths: `38` - covered runtime artifacts: `62` -- covered runtime operations: `57` +- covered runtime operations: `58` - covered maintenance targets: `5` -- covered declared operation targets: `79` +- covered declared operation targets: `80` - uncovered runtime paths: — - uncovered runtime artifacts: — - uncovered runtime operations: — @@ -399,7 +399,7 @@ These projections explain which executable lanes, inferred fixture scenarios, or | `validation-lane` | `maintenance-workflows` | — | — | — | — | — | — | `contract`
`maintenance` | Health, maintenance selection, cache/live provenance, and machine output | | `validation-lane` | `memory-budget` | `session-query-loop` | `message_fts`
`session_query_results` | — | — | `query-sessions` | — | `live`
`retrieval`
`readiness` | Live archive grouped retrieval command under an explicit RSS budget | | `validation-lane` | `mixed-consumer-contracts` | — | — | — | — | — | — | — | CLI, facade, and readiness surfaces consuming the same evidence/inference insight model | -| `validation-lane` | `mutation-routes` | `tag-mutation-loop`
`metadata-mutation-loop`
`mark-mutation-loop`
`annotation-mutation-loop`
`blackboard-post-loop`
`assertion-candidate-capture-loop`
`raw-authority-blocker-resolution-loop`
`raw-authority-recovery-loop`
`saved-view-mutation-loop`
`recall-pack-mutation-loop`
`workspace-mutation-loop`
`correction-mutation-loop`
`session-delete-loop`
`session-excision-loop`
`identity-reset-loop`
`message-fts-readiness-loop`
`session-insight-repair-loop` | `sessions`
`assertions`
`archive_deleted_session`
`raw_sessions`
`blob_refs`
`excision_receipt`
`suppression_rows`
`raw_authority_plans`
`raw_authority_blockers`
`raw_authority_blocker_resolution`
`raw_authority_census_ledger`
`raw_authority_census_recovery_receipt`
`raw_revision_heads`
`raw_revision_applications`
`raw_authority_index_seed_recovery_receipt` | — | — | `mutate-add-tag`
`mutate-remove-tag`
`mutate-bulk-tag-sessions`
`mutate-set-metadata`
`mutate-delete-metadata`
`mutate-add-mark`
`mutate-remove-mark`
`mutate-save-annotation`
`mutate-delete-annotation`
`mutate-blackboard-post`
`mutate-capture-assertion-candidate`
`mutate-import-annotation-batch`
`mutate-rebuild-index`
`mutate-update-index`
`mutate-rebuild-insights`
`mutate-resolve-raw-authority-blocker`
`mutate-reset-raw-authority-census`
`mutate-prune-orphaned-index-revision-seeds`
`mutate-save-saved-view`
`mutate-delete-saved-view`
`mutate-save-recall-pack`
`mutate-delete-recall-pack`
`mutate-save-workspace`
`mutate-delete-workspace`
`mutate-record-correction`
`mutate-delete-correction`
`mutate-clear-corrections`
`mutate-delete-session`
`mutate-session-excision`
`mutate-identity-reset` | — | `contract`
`mutation`
`operation-executor` | Executor-routed mutation actuators and transaction receipts over their declared runtime closures | +| `validation-lane` | `mutation-routes` | `tag-mutation-loop`
`metadata-mutation-loop`
`mark-mutation-loop`
`annotation-mutation-loop`
`blackboard-post-loop`
`assertion-candidate-capture-loop`
`raw-authority-blocker-resolution-loop`
`raw-authority-recovery-loop`
`saved-view-mutation-loop`
`recall-pack-mutation-loop`
`workspace-mutation-loop`
`correction-mutation-loop`
`session-delete-loop`
`session-excision-loop`
`identity-reset-loop`
`message-fts-readiness-loop`
`session-insight-repair-loop` | `sessions`
`assertions`
`archive_deleted_session`
`raw_sessions`
`blob_refs`
`excision_receipt`
`suppression_rows`
`raw_authority_plans`
`raw_authority_blockers`
`raw_authority_blocker_resolution`
`raw_authority_census_ledger`
`raw_authority_census_recovery_receipt`
`raw_revision_heads`
`raw_revision_applications`
`raw_authority_index_seed_recovery_receipt` | — | — | `mutate-add-tag`
`mutate-remove-tag`
`mutate-bulk-tag-sessions`
`mutate-set-metadata`
`mutate-delete-metadata`
`mutate-add-mark`
`mutate-remove-mark`
`mutate-save-annotation`
`mutate-delete-annotation`
`mutate-blackboard-post`
`mutate-capture-assertion-candidate`
`mutate-import-annotation-batch`
`mutate-rebuild-index`
`mutate-update-index`
`mutate-rebuild-insights`
`mutate-resolve-raw-authority-blocker`
`mutate-reset-raw-authority-census`
`mutate-prune-orphaned-index-revision-seeds`
`mutate-save-saved-view`
`mutate-delete-saved-view`
`mutate-save-recall-pack`
`mutate-delete-recall-pack`
`mutate-save-workspace`
`mutate-delete-workspace`
`mutate-record-correction`
`mutate-delete-correction`
`mutate-clear-corrections`
`mutate-delete-session`
`mutate-session-excision`
`mutate-session-lifecycle-request`
`mutate-identity-reset` | — | `contract`
`mutation`
`operation-executor` | Executor-routed mutation actuators and transaction receipts over their declared runtime closures | | `validation-lane` | `pipeline-probe-chatgpt` | `source-acquisition-loop`
`raw-reparse-loop`
`raw-archive-ingest-loop` | `configured_sources`
`source_payload_stream`
`raw_validation_state`
`artifact_observation_rows`
`validation_backlog`
`parse_backlog`
`parse_quarantine`
`archive_session_rows` | — | — | `acquire-raw-sessions`
`plan-validation-backlog`
`plan-parse-backlog`
`ingest-archive-runtime` | — | — | Synthetic ChatGPT parse-stage pipeline probe under explicit runtime and RSS budgets | | `validation-lane` | `probabilistic-enrichment-cleanup-live` | `archive-debt-query-loop`
`message-fts-readiness-loop`
`retrieval-band-readiness-loop` | `archive_readiness`
`embedding_status_results`
`message_fts`
`archive_debt_results`
`session_insight_readiness`
`retrieval_band_readiness` | — | — | `query-archive-debt`
`cli.json-contract`
`project-archive-readiness` | — | `insights`
`debt`
`live`
`maintenance`
`preview` | Bounded live archive lane for cleanup/debt preview and maintenance budgets | | `validation-lane` | `probabilistic-enrichment-contracts` | — | — | — | — | — | — | — | Session-enrichment contracts across CLI, facade, storage, and retrieval-band status | diff --git a/polylogue/annotations/importer.py b/polylogue/annotations/importer.py index 4fa5dc2713..5647bf0b91 100644 --- a/polylogue/annotations/importer.py +++ b/polylogue/annotations/importer.py @@ -23,9 +23,11 @@ from polylogue.annotations.write import assertion_id_for_schema_annotation, upsert_annotation_assertion from polylogue.core.json import JSONDocument, require_json_document from polylogue.core.refs import EvidenceRef, parse_public_ref +from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_transaction import ( ConfirmationStrength, MutationPlan, + MutationPrincipal, MutationReceipt, OperationExecutor, build_plan, @@ -468,18 +470,18 @@ async def default_resolver(ref: str) -> bool: abstained_count=abstained_count, created_at_ms=created_at_ms, ) - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(user_db_path.parent) actuator = AnnotationBatchImportActuator() - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor=request.actor_ref, - role="write", - capability="annotations.import_annotation_batch", - confirmation_strength="role_only", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + request.actor_ref, + frozenset({"archive.annotation.import_batch"}), + "internal", + "write", ) - receipt = executor.execute(actuator, plan, authorization, args) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=user_db_path.parent) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) batch = cast(AnnotationBatch, receipt.domain_receipt["batch"]) imported_outcomes = cast( tuple[AnnotationImportRowOutcome, ...], diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index c8c587a214..23ff20dbdf 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -2687,23 +2687,20 @@ def _execute_facade_mutation( outside this helper. Returns ``(receipt, plan)`` because a couple of callers read ``plan.context`` back after the archive handle closes. """ - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.bindings import runtime_operation_binding + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: args = build_args(archive) - executor = OperationExecutor() - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="facade", - role="write", - capability=capability, - confirmation_strength="role_only", - ) - receipt = executor.execute(actuator, plan, authorization, args) - return receipt, plan + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("facade", frozenset({capability}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) + return receipt, preview.plan async def import_annotation_batch( self, @@ -6574,8 +6571,9 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") -- shares one preview/authorization/receipt contract instead of calling ``ArchiveStore.delete_sessions`` independently. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionDeleteActuator, SessionDeleteArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.surfaces.payloads import DeleteSessionResult @@ -6589,18 +6587,14 @@ async def delete_session_safe(self, session_id: str, *, actor: str = "user:api") detail="session_not_found", ) actuator = SessionDeleteActuator() - executor = OperationExecutor() + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) args = SessionDeleteArgs(archive=archive, session_ids=(resolved,)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor=actor, - role="write", - capability="archive.delete_session", - confirmation_strength="confirm_flag", - ) - receipt = executor.execute(actuator, plan, authorization, args) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal(actor, frozenset({"archive.delete_session"}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") + receipt = executor.execute_bound(binding, preview, authorization, args) deleted = receipt.affected_count > 0 return DeleteSessionResult( outcome="deleted" if deleted else "not_found", diff --git a/polylogue/api/ingest.py b/polylogue/api/ingest.py index b67a070a5f..a65e52ccd3 100644 --- a/polylogue/api/ingest.py +++ b/polylogue/api/ingest.py @@ -60,22 +60,19 @@ async def parse_sources( async def rebuild_index(self) -> bool: """Rebuild the derived block-FTS index through the mutation executor.""" from polylogue.config import active_archive_root as _active_archive_root + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import IndexRebuildActuator, IndexRebuildArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore with ArchiveStore.open_existing(_active_archive_root(self.config), read_only=False) as archive: actuator = IndexRebuildActuator() args = IndexRebuildArgs(archive=archive) - executor = OperationExecutor() - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="facade", - role="write", - capability="archive.rebuild_index", - confirmation_strength="role_only", - ) - receipt = executor.execute(actuator, plan, authorization, args) + root = _active_archive_root(self.config) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("facade", frozenset({"archive.rebuild_index"}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) return receipt.status in {"applied", "already_satisfied"} diff --git a/polylogue/cli/archive_query.py b/polylogue/cli/archive_query.py index 506c344209..f6db297840 100644 --- a/polylogue/cli/archive_query.py +++ b/polylogue/cli/archive_query.py @@ -2140,19 +2140,15 @@ def _emit_delete( ``ArchiveStore.delete_sessions`` directly, so preview/authorization/ receipt semantics cannot diverge between adapters. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionDeleteActuator, SessionDeleteArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.surfaces.payloads import MutationResultPayload dry_run = bool(params.get("dry_run")) force = bool(params.get("force")) count = len(session_ids) - actuator = SessionDeleteActuator() - executor = OperationExecutor() - prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) - plan = executor.prepare(actuator, prepare_args) - if dry_run: # ``session_count`` = matched, ``affected_count`` = deleted (0 in a # preview); ``session_ids`` enumerates the sessions that would be deleted. @@ -2204,15 +2200,14 @@ def _emit_delete( ).to_json(exclude_none=True) ) return - authorization = executor.authorize( - actuator, - plan, - actor="user:cli", - role="write", - capability="archive.delete_session", - confirmation_strength="confirm_flag", - ) - receipt = executor.execute(actuator, plan, authorization, prepare_args) + actuator = SessionDeleteActuator() + executor = OperationExecutor.for_archive_root(archive.archive_root) + prepare_args = SessionDeleteArgs(archive=archive, session_ids=session_ids) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("user:cli", frozenset({"archive.delete_session"}), "cli", "write") + preview = executor.prepare_bound_for_archive(binding, prepare_args, principal, archive_root=archive.archive_root) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") + receipt = executor.execute_bound(binding, preview, authorization, prepare_args) deleted = receipt.affected_count # ``session_count`` = matched, ``affected_count`` = sessions actually deleted. click.echo( diff --git a/polylogue/cli/commands/excise.py b/polylogue/cli/commands/excise.py index 163e49be89..1779b82876 100644 --- a/polylogue/cli/commands/excise.py +++ b/polylogue/cli/commands/excise.py @@ -125,8 +125,6 @@ def excise_command( root = archive_root() if mode != "standalone": - from polylogue.security.lifecycle import submit_lifecycle_request - target_ref = f"session:{session_id}" if dry_run: _emit( @@ -161,26 +159,33 @@ def excise_command( env.ui.console.print("Aborted.") return - import sqlite3 - - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database - from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier + from polylogue.operations.bindings import runtime_operation_binding + from polylogue.operations.mutation_actuators import SessionLifecycleRequestActuator, SessionLifecycleRequestArgs + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor + from polylogue.security.lifecycle import LifecycleMode + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - user_db = root / "user.db" - initialize_archive_database(user_db, ArchiveTier.USER) - conn = sqlite3.connect(user_db) - try: - with conn: - assertion_id = submit_lifecycle_request( - conn, - target_ref=target_ref, - mode=mode, # type: ignore[arg-type] - reason=reason, - actor=actor, - now_ms=_now_ms(), - ) - finally: - conn.close() + initialize_active_archive_root(root) + lifecycle_actuator = SessionLifecycleRequestActuator() + lifecycle_args = SessionLifecycleRequestArgs( + archive_root=root, + session_id=session_id, + mode=cast(LifecycleMode, mode), + reason=reason, + actor=actor, + now_ms=_now_ms(), + ) + executor = OperationExecutor.for_archive_root(root) + lifecycle_binding = runtime_operation_binding(lifecycle_actuator) + lifecycle_principal = MutationPrincipal(actor, frozenset({"archive.request_session_lifecycle"}), "cli", "write") + lifecycle_preview = executor.prepare_bound_for_archive( + lifecycle_binding, lifecycle_args, lifecycle_principal, archive_root=root + ) + lifecycle_authorization = executor.authorize_bound( + lifecycle_binding, lifecycle_preview, lifecycle_principal, confirmation_strength="confirm_flag" + ) + receipt = executor.execute_bound(lifecycle_binding, lifecycle_preview, lifecycle_authorization, lifecycle_args) + assertion_id = cast(str, receipt.domain_receipt["assertion_id"]) _emit( env, status="ok", @@ -195,12 +200,12 @@ def excise_command( ) return + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import SessionExcisionActuator, SessionExcisionArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor from polylogue.security.excision import plan_session_excision actuator = SessionExcisionActuator() - executor = OperationExecutor() excision_args = SessionExcisionArgs( archive_root=root, session_id=session_id, @@ -312,16 +317,12 @@ def excise_command( # EXECUTE revalidates the hash immediately before mutating -- a stale or # tampered authorization refuses (``PlanStaleError``) rather than excising # the wrong target set. - executor_plan = executor.prepare(actuator, excision_args) - authorization = executor.authorize( - actuator, - executor_plan, - actor=actor, - role="write", - capability="archive.excise_session", - confirmation_strength="confirm_flag", - ) - executor_receipt = executor.execute(actuator, executor_plan, authorization, excision_args) + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal(actor, frozenset({"archive.excise_session"}), "cli", "write") + preview = executor.prepare_bound_for_archive(binding, excision_args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") + executor_receipt = executor.execute_bound(binding, preview, authorization, excision_args) if executor_receipt.status == "blocked": _emit( env, @@ -349,7 +350,7 @@ def excise_command( affected_count=executor_receipt.affected_count, output_format=output_format, plain_message=detail_message, - detail=executor_receipt.receipt_ref, + detail=cast(str | None, domain_receipt.get("receipt_assertion_id")) or executor_receipt.receipt_ref, ) diff --git a/polylogue/cli/commands/maintenance/_migrate_tier.py b/polylogue/cli/commands/maintenance/_migrate_tier.py index 88eaadb28e..c135e955a0 100644 --- a/polylogue/cli/commands/maintenance/_migrate_tier.py +++ b/polylogue/cli/commands/maintenance/_migrate_tier.py @@ -19,21 +19,79 @@ import os import sqlite3 from pathlib import Path +from typing import Annotated, Literal import click +from pydantic import BaseModel, ConfigDict, Field, RootModel from polylogue.operations.durable_change_train import ( ArchiveOwnershipError, + AuditContinuityError, DurablePublicationError, acquire_durable_archive_ownership, + adopt_missing_audit_tier, execute_durable_change_train, initialize_missing_durable_tier, + restore_adopted_audit_tier, ) from polylogue.paths import archive_root from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS, MigrationError +class DurableRecoveryPayload(BaseModel): + """Typed recovery evidence for a blocked durable publication.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + state: str + code: str | None + target: str | None + detail: str | None + + +class MigrateTierSuccessPayload(BaseModel): + """Successful result for one durable-tier migration route.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[True] + tier: str + path: str + initialized: bool + adoption_receipt: str | None + restore_receipt: str | None + backup_manifest: str | None + stopped_daemon_evidence_ref: str | None + train_manifest: str | None + train_state: str | None + backup_receipt: str | None + from_version: int | None + to_version: int | None + applied_versions: list[int] + forward_version_receipt: dict[str, object] | None + + +class MigrateTierErrorPayload(BaseModel): + """Blocked result for one durable-tier migration route.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ok: Literal[False] + tier: str + path: str + backup_manifest: str | None + stopped_daemon_evidence_ref: str | None + error: str + durable_recovery: DurableRecoveryPayload | None + + +class MigrateTierResultPayload( + RootModel[Annotated[MigrateTierSuccessPayload | MigrateTierErrorPayload, Field(discriminator="ok")]] +): + """Published success/error union for the migrate-tier JSON surface.""" + + def _daemon_pidfile_is_live(pidfile: Path) -> bool: """Return whether the archive pidfile names a live polylogued process.""" try: @@ -65,11 +123,26 @@ def _require_stopped_daemon(root: Path) -> str: is_flag=True, help="Initialize this durable tier only when its database file is absent; never replaces an existing file.", ) +@click.option( + "--adopt-established-audit", + is_flag=True, + help=( + "Create missing audit.db for an established archive only with a freshly verified full_evidence backup; " + "writes an immutable adoption receipt." + ), +) +@click.option( + "--restore-adopted-audit", + is_flag=True, + help="Atomically restore adopted audit.db from a scratch-verified full_evidence backup and append continuity.", +) @click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) def migrate_tier_command( tier: str, backup_manifest: Path | None, initialize_missing: bool, + adopt_established_audit: bool, + restore_adopted_audit: bool, output_format: str, ) -> None: """Apply additive migrations for one durable archive tier. @@ -85,10 +158,41 @@ def migrate_tier_command( stopped_daemon_evidence_ref: str | None = None initialized = False initialized_version: int | None = None + adoption_receipt: Path | None = None + restore_receipt: Path | None = None try: + if sum((initialize_missing, adopt_established_audit, restore_adopted_audit)) > 1: + raise MigrationError( + "choose only one of --initialize-missing, --adopt-established-audit, or --restore-adopted-audit" + ) + if (adopt_established_audit or restore_adopted_audit) and archive_tier is not ArchiveTier.AUDIT: + option = "--adopt-established-audit" if adopt_established_audit else "--restore-adopted-audit" + raise MigrationError(f"{option} is only valid for the audit tier") + if (adopt_established_audit or restore_adopted_audit) and backup_manifest is None: + option = "--adopt-established-audit" if adopt_established_audit else "--restore-adopted-audit" + raise MigrationError(f"{option} requires --backup-manifest") with acquire_durable_archive_ownership(path.parent, owner_id=f"migrate-tier:{os.getpid()}") as archive_owner: stopped_daemon_evidence_ref = _require_stopped_daemon(path.parent) - if initialize_missing: + if adopt_established_audit: + assert backup_manifest is not None + initialized_version, adoption_receipt = adopt_missing_audit_tier( + path, + backup_manifest=backup_manifest, + directory_fd=archive_owner.directory_fd, + stopped_daemon_check=lambda: _require_stopped_daemon(path.parent), + ) + initialized = True + execution = None + elif restore_adopted_audit: + assert backup_manifest is not None + restore_receipt = restore_adopted_audit_tier( + path, + backup_manifest=backup_manifest, + directory_fd=archive_owner.directory_fd, + stopped_daemon_check=lambda: _require_stopped_daemon(path.parent), + ) + execution = None + elif initialize_missing: initialized_version = initialize_missing_durable_tier( path, archive_tier, @@ -105,25 +209,21 @@ def migrate_tier_command( single_writer_evidence_ref="proof:archive-ownership-lock", release_archive_ownership=archive_owner.release, ) - except (sqlite3.Error, MigrationError, ArchiveOwnershipError) as exc: + except (sqlite3.Error, MigrationError, ArchiveOwnershipError, AuditContinuityError) as exc: if output_format == "json": - click.echo( - json.dumps( - { - "ok": False, - "tier": tier, - "path": str(path), - "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, - "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, - "error": str(exc), - "durable_recovery": ( - exc.cleanup.as_dict() if isinstance(exc, DurablePublicationError) and exc.cleanup else None - ), - }, - indent=2, - sort_keys=True, - ) + cleanup = exc.cleanup if isinstance(exc, DurablePublicationError) else None + error_payload = MigrateTierErrorPayload( + ok=False, + tier=tier, + path=str(path), + backup_manifest=str(backup_manifest) if backup_manifest is not None else None, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + error=str(exc), + durable_recovery=( + DurableRecoveryPayload.model_validate(cleanup.as_dict()) if cleanup is not None else None + ), ) + click.echo(json.dumps(error_payload.model_dump(mode="json"), indent=2, sort_keys=True)) else: click.echo(f"Migration blocked for {tier}: {exc}", err=True) if isinstance(exc, DurablePublicationError) and exc.cleanup is not None: @@ -137,24 +237,24 @@ def migrate_tier_command( result = execution.migration_result if execution is not None else None receipt = execution.forward_version_receipt if execution is not None else None - payload = { - "ok": True, - "tier": tier, - "path": str(path), - "initialized": initialized, - "backup_manifest": str(backup_manifest) if backup_manifest is not None else None, - "stopped_daemon_evidence_ref": stopped_daemon_evidence_ref, - "train_manifest": ( + success_payload = MigrateTierSuccessPayload( + ok=True, + tier=tier, + path=str(path), + initialized=initialized, + adoption_receipt=str(adoption_receipt) if adoption_receipt is not None else None, + restore_receipt=str(restore_receipt) if restore_receipt is not None else None, + backup_manifest=str(backup_manifest) if backup_manifest is not None else None, + stopped_daemon_evidence_ref=stopped_daemon_evidence_ref, + train_manifest=( str(execution.manifest_path) if execution is not None and execution.manifest_path is not None else None ), - "train_state": execution.train.state.value if execution is not None and execution.train is not None else None, - "backup_receipt": str(result.backup_receipt) - if result is not None and result.backup_receipt is not None - else None, - "from_version": result.from_version if result is not None else 0 if initialized else None, - "to_version": result.to_version if result is not None else initialized_version, - "applied_versions": list(result.applied_versions) if result is not None else [], - "forward_version_receipt": ( + train_state=execution.train.state.value if execution is not None and execution.train is not None else None, + backup_receipt=str(result.backup_receipt) if result is not None and result.backup_receipt is not None else None, + from_version=result.from_version if result is not None else 0 if initialized else None, + to_version=result.to_version if result is not None else initialized_version, + applied_versions=list(result.applied_versions) if result is not None else [], + forward_version_receipt=( { "tier": receipt.tier.value, "historical_train_id": receipt.historical_train_id, @@ -167,11 +267,17 @@ def migrate_tier_command( if receipt is not None else None ), - } + ) if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) + click.echo(json.dumps(success_payload.model_dump(mode="json"), indent=2, sort_keys=True)) return + if adoption_receipt is not None: + click.echo(f"Adopted missing audit tier at schema version {initialized_version}; receipt: {adoption_receipt}.") + return + if restore_receipt is not None: + click.echo(f"Restored adopted audit tier; continuity receipt: {restore_receipt}.") + return if initialized: click.echo(f"Initialized missing {tier} tier at schema version {initialized_version}.") return diff --git a/polylogue/cli/commands/maintenance/_raw_identity.py b/polylogue/cli/commands/maintenance/_raw_identity.py index 971a2b716c..7cbc3da44c 100644 --- a/polylogue/cli/commands/maintenance/_raw_identity.py +++ b/polylogue/cli/commands/maintenance/_raw_identity.py @@ -212,14 +212,16 @@ def raw_authority_blocker_resolve_command( """ if not confirmed: raise click.ClickException("refusing to resolve a durable blocker without --yes") + from polylogue.operations.bindings import BindingValidationError, runtime_operation_binding from polylogue.operations.mutation_actuators import BlockerResolveActuator, BlockerResolveArgs from polylogue.operations.mutation_transaction import ( + MutationPrincipal, MutationTransactionError, OperationExecutor, ) actuator = BlockerResolveActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(env.config.archive_root) args = BlockerResolveArgs( archive_root=env.config.archive_root, blocker_id=blocker_id, @@ -228,17 +230,24 @@ def raw_authority_blocker_resolve_command( judgment_disposition=judgment_disposition, ) try: - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="cli", - role="write", - capability="raw_authority.resolve_blocker", - confirmation_strength="confirm_flag", + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "cli", + frozenset({"archive.raw_authority.resolve_blocker"}), + "cli", + "write", ) - result = executor.execute(actuator, plan, authorization, args) - except (FileNotFoundError, KeyError, RuntimeError, ValueError, MutationTransactionError) as exc: + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=env.config.archive_root) + authorization = executor.authorize_bound(binding, preview, principal) + result = executor.execute_bound(binding, preview, authorization, args) + except ( + BindingValidationError, + FileNotFoundError, + KeyError, + RuntimeError, + ValueError, + MutationTransactionError, + ) as exc: raise click.ClickException(str(exc)) from exc if result.status != "applied": raise click.ClickException(f"blocker {blocker_id!r} not found or already resolved") diff --git a/polylogue/cli/commands/reset.py b/polylogue/cli/commands/reset.py index 46b3cf322e..ed22da465b 100644 --- a/polylogue/cli/commands/reset.py +++ b/polylogue/cli/commands/reset.py @@ -210,24 +210,21 @@ def _apply_identity_reset(session_ids: list[str], *, reason: str) -> tuple[int, instead of tombstoning directly. Returns ``(suppressed_count, deleted_archive_rows)``. """ + from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import IdentityResetActuator, IdentityResetArgs - from polylogue.operations.mutation_transaction import OperationExecutor + from polylogue.operations.mutation_transaction import MutationPrincipal, OperationExecutor if not session_ids: return 0, 0 actuator = IdentityResetActuator() - executor = OperationExecutor() - args = IdentityResetArgs(archive_root=_archive_root(), session_ids=tuple(session_ids), reason=reason) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, - plan, - actor="user:cli", - role="write", - capability="archive.identity_reset", - confirmation_strength="confirm_flag", - ) - receipt = executor.execute(actuator, plan, authorization, args) + root = _archive_root() + executor = OperationExecutor.for_archive_root(root) + args = IdentityResetArgs(archive_root=root, session_ids=tuple(session_ids), reason=reason) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("user:cli", frozenset({"archive.identity_reset"}), "cli", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + authorization = executor.authorize_bound(binding, preview, principal, confirmation_strength="confirm_flag") + receipt = executor.execute_bound(binding, preview, authorization, args) domain = receipt.domain_receipt suppressed = cast("int", domain.get("suppressed_count", receipt.affected_count)) deleted = cast("int", domain.get("deleted_archive_rows", 0)) diff --git a/polylogue/daemon/backup.py b/polylogue/daemon/backup.py index 8687ad0c55..d84be55f16 100644 --- a/polylogue/daemon/backup.py +++ b/polylogue/daemon/backup.py @@ -966,7 +966,7 @@ def _write_successful_verification_receipt(backup_root: Path, verification: dict artifacts = verified_evidence.get("tier_artifacts") if isinstance(artifacts, list): for artifact in artifacts: - if not isinstance(artifact, dict) or artifact.get("tier") not in {"source", "user"}: + if not isinstance(artifact, dict) or artifact.get("tier") not in {"source", "user", "audit"}: continue fingerprint = artifact.get("source_fingerprint") source_path = fingerprint.get("path") if isinstance(fingerprint, dict) else None diff --git a/polylogue/maintenance/raw_authority_recovery.py b/polylogue/maintenance/raw_authority_recovery.py index 8dec53c0e7..0c025f77dd 100644 --- a/polylogue/maintenance/raw_authority_recovery.py +++ b/polylogue/maintenance/raw_authority_recovery.py @@ -29,11 +29,13 @@ ConfirmationStrength, DestructiveClass, MutationPlan, + MutationPrincipal, MutationReceipt, MutationTransactionError, OperationExecutor, PlanStaleError, build_plan, + compute_parameter_digest, make_target_ref, ) from polylogue.paths import render_root @@ -116,18 +118,31 @@ def _file_fingerprint(path: Path) -> dict[str, object]: raise RawAuthorityRecoveryError(f"recovery tier is not readable: {path}") from exc digest = hashlib.sha256() try: - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - except OSError as exc: + if path.name == "source.db": + # Audit continuity is an executor-owned authority side effect, not + # a raw-authority recovery input. Hash the logical source image + # while excluding that mutable WAL control row so PREPARE's own + # audit writes cannot invalidate its bound recovery plan. + with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as connection: + for line in connection.iterdump(): + if "audit_continuity_control" not in line: + digest.update(line.encode("utf-8")) + digest.update(b"\n") + else: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except (OSError, sqlite3.Error) as exc: raise RawAuthorityRecoveryError(f"could not fingerprint recovery tier: {path}") from exc - return { + fingerprint: dict[str, object] = { "path": str(path.resolve(strict=False)), - "size_bytes": stat.st_size, "sha256": digest.hexdigest(), "device": stat.st_dev, "inode": stat.st_ino, } + if path.name != "source.db": + fingerprint["size_bytes"] = stat.st_size + return fingerprint def _pointer_fingerprint(root: Path) -> dict[str, object]: @@ -185,10 +200,15 @@ def update(value: object) -> None: def _protected_digest(conn: sqlite3.Connection, *, excluded: tuple[str, ...]) -> str: + # OperationExecutor journals its own authorization transitions in this + # source-tier control table. Those transitions are verified by audit + # continuity itself and cannot make the recovery target set safe or + # unsafe, so they must not self-invalidate a bound recovery plan. + volatile_control_tables = {"audit_continuity_control"} tables = sorted( str(row[0]) for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") - if str(row[0]) not in excluded + if str(row[0]) not in excluded and str(row[0]) not in volatile_control_tables ) return _digest({name: _table_digest(conn, name) for name in tables}) @@ -1492,19 +1512,6 @@ def apply_raw_authority_recovery( or str(backup_manifest.resolve(strict=False)) != selected.backup_authority.get("manifest_path") ): raise RawAuthorityRecoveryError("apply backup manifest does not match the plan authority") - existing = _receipt_for_plan(selected) - if existing is not None: - _require_apply_preconditions(Path(selected.archive_root)) - _validate_existing_receipt(selected, existing) - _refresh_source_train_continuity(selected) - return RawAuthorityRecoveryReport( - plan=selected, - applied=False, - status="already_satisfied", - receipt_path=Path(selected.receipt_path), - after_counts=cast(dict[str, int], existing.get("after_counts")), - postflight=cast(dict[str, object], existing.get("postflight")), - ) if selected.backup_authority is None: raise RawAuthorityRecoveryError("apply requires a dry-run plan with verified backup authority") operation = RecoveryOperation(selected.operation) @@ -1522,40 +1529,74 @@ def apply_raw_authority_recovery( if operation is RecoveryOperation.RESET_CENSUS else PruneOrphanedIndexRevisionSeedsActuator() ) - executor = OperationExecutor() + from polylogue.operations.bindings import runtime_operation_binding + try: location = ArchiveLocation.resolve(root) - # A final receipt may be missing after a process crash or I/O failure. - # Only exact committed postflight evidence can skip a fresh executor - # authorization. An uncommitted intent is evidence of interruption, - # not authority to perform the destructive mutation. - if _intent_for_plan(selected) is not None: - with OwnedArchiveLocation.acquire( - location, owner_id=f"raw-authority-recovery:{selected.operation_id}" - ) as owned: - current_location = ArchiveLocation.resolve(root) - assert_owns_archive_location(owned, current_location) - with RebuildLease(root): - if _committed_postflight(selected) is not None: - return _apply_plan(selected) - prepared = executor.prepare(actuator, args) - if prepared.context.get("recovery_plan_digest") != selected.plan_digest: - raise PlanStaleError("recovery plan is stale before lease acquisition") - authorization = executor.authorize( - actuator, - prepared, - actor="cli:maintenance", - role="maintenance", - capability="archive.raw_authority_recovery", - confirmation_strength="confirm_flag", - ) with OwnedArchiveLocation.acquire( location, owner_id=f"raw-authority-recovery:{selected.operation_id}" ) as owned: current_location = ArchiveLocation.resolve(root) assert_owns_archive_location(owned, current_location) + _require_apply_preconditions(root) with RebuildLease(root): - result = executor.execute(actuator, prepared, authorization, args) + existing = _receipt_for_plan(selected) + if existing is not None: + _validate_existing_receipt(selected, existing) + _refresh_source_train_continuity(selected) + return RawAuthorityRecoveryReport( + plan=selected, + applied=False, + status="already_satisfied", + receipt_path=Path(selected.receipt_path), + after_counts=cast(dict[str, int], existing.get("after_counts")), + postflight=cast(dict[str, object], existing.get("postflight")), + ) + # A final receipt may be missing after a process crash or I/O failure. + # Only exact committed postflight evidence can skip a fresh executor + # authorization. An uncommitted intent is evidence of interruption, + # not authority to perform the destructive mutation. + if _intent_for_plan(selected) is not None and _committed_postflight(selected) is not None: + executor = OperationExecutor.for_archive_root(root) + recovered = _apply_plan(selected) + raw_plan = build_plan( + operation=actuator.operation, + destructive_class=actuator.destructive_class, + target_refs=( + make_target_ref("source", operation.value) + if operation is RecoveryOperation.RESET_CENSUS + else make_target_ref("index", operation.value), + ), + affected_tiers=("source",) if operation is RecoveryOperation.RESET_CENSUS else ("index",), + reversible=False, + context={"recovery_plan_digest": selected.plan_digest, "operation_id": selected.operation_id}, + ) + operation_id = executor.find_interrupted_operation( + operation_name=actuator.operation, + parameter_digest=compute_parameter_digest(raw_plan), + ) + if operation_id is None: + raise RawAuthorityRecoveryError("committed recovery has no matching interrupted audit attempt") + executor.reconcile_operation( + operation_id, + outcome="applied", + domain_receipt_ref=str(recovered.receipt_path) if recovered.receipt_path is not None else None, + reason="exact raw-authority postflight proved the prior domain commit", + ) + return recovered + executor = OperationExecutor.for_archive_root(root) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal( + "cli:maintenance", + frozenset({"archive.raw_authority_recovery"}), + "maintenance", + "maintenance", + ) + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=root) + if preview.plan.context.get("recovery_plan_digest") != selected.plan_digest: + raise PlanStaleError("recovery plan is stale after ownership acquisition") + authorization = executor.authorize_bound(binding, preview, principal) + result = executor.execute_bound(binding, preview, authorization, args) except ( ArchiveLocationError, ArchiveOwnershipError, diff --git a/polylogue/operations/audit.py b/polylogue/operations/audit.py index d9fb4b51d2..a02de6e392 100644 --- a/polylogue/operations/audit.py +++ b/polylogue/operations/audit.py @@ -4,13 +4,18 @@ import hashlib import json +import math +import os import secrets import sqlite3 import time -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager +from dataclasses import dataclass, fields, is_dataclass +from enum import Enum +from functools import wraps from pathlib import Path -from typing import Literal +from typing import Any, Literal, TypeVar, cast from polylogue.operations.mutation_transaction import ( MutationAuthorization, @@ -18,8 +23,16 @@ MutationPreview, MutationPrincipal, MutationReceipt, + MutationTarget, + TokenExpiredError, + validate_mutation_plan_integrity, +) +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + assert_verified_audit_leaf, + open_verified_audit_connection, ) -from polylogue.storage.sqlite.archive_tiers.audit import AUDIT_DDL, AUDIT_SCHEMA_VERSION AuditTargetState = Literal[ "pending", @@ -32,6 +45,36 @@ "acknowledged", "cancelled", ] +_F = TypeVar("_F", bound=Callable[..., object]) +_CONFIRMATION_STRENGTH_ORDER = {"role_only": 0, "confirm_flag": 1, "bound_token": 2} + + +def _run_state_for_targets(states: list[str]) -> tuple[str, str | None]: + """Derive the parent lifecycle state from the complete target set.""" + + if not states: + return "completed", None + if "unknown" in states: + return "interrupted", "unknown_effect" + if "rejected" in states: + return "failed", "target_rejected" + if "failed" in states: + return "failed", "domain_failure" + if states and all(state in {"applied", "already_satisfied"} for state in states): + return "completed", None + return "running", None + + +def _receipt_event_detail(receipt: MutationReceipt | None, *, status: str, reason: str | None) -> dict[str, object]: + """Return bounded audit evidence without copying user-authored domain payloads.""" + + return { + "status": status, + "reason": (reason or "")[:512], + "receipt_ref": None if receipt is None else receipt.receipt_ref, + "target_count": 0 if receipt is None else len(receipt.target_refs), + "affected_count": 0 if receipt is None else receipt.affected_count, + } def token_sha256(token: str) -> str: @@ -40,26 +83,513 @@ def token_sha256(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _linux_process_start_ticks(pid: int) -> str | None: + """Return Linux /proc start ticks without misparsing a spaced process name.""" + + try: + _prefix, delimiter, suffix = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").rpartition(")") + if not delimiter: + return None + return suffix.split()[19] + except (IndexError, OSError): + return None + + +def _attempt_owner_liveness(owner_id: str | None) -> Literal["live", "dead", "unknown"]: + """Classify an owner without mistaking unavailable liveness evidence for death.""" + + if owner_id is None: + return "unknown" + parts = owner_id.split(":") + if len(parts) not in {2, 3} or parts[0] != "pid": + return "unknown" + try: + pid = int(parts[1]) + os.kill(pid, 0) + except ProcessLookupError: + return "dead" + except (OSError, ValueError): + return "unknown" + if len(parts) == 2: + return "live" + start_ticks = _linux_process_start_ticks(pid) + if start_ticks is None: + return "unknown" + return "live" if start_ticks == parts[2] else "dead" + + +def _current_process_attempt_owner() -> str: + """Return a local process identity that rejects PID reuse when available.""" + + pid = os.getpid() + start_ticks = _linux_process_start_ticks(pid) + if start_ticks is None: + return f"pid:{pid}" + return f"pid:{pid}:{start_ticks}" + + +def _attempt_owner_is_live(owner_id: str | None) -> bool: + """Return whether an attempt's recorded local process is still its owner.""" + + return _attempt_owner_liveness(owner_id) == "live" + + +@dataclass(frozen=True, slots=True) +class _StoredAuthorizationDigest: + """A persisted digest available only while replaying a continuity command.""" + + value: str + + +def _continuity_mutation(kind: str) -> Callable[[_F], _F]: + """Route one audit repository state transition through the source WAL.""" + + def decorate(method: _F) -> _F: + @wraps(method) + def wrapped(self: AuditRepository, *args: object, **kwargs: object) -> object: + # The audit tier can be upgraded before source.db installs its + # matching WAL table. Keep that release window operational; the + # coordinator becomes mandatory as soon as both schema halves are + # present. + if not self._continuity.is_available(): + return method(self, *args, **kwargs) + mutation = AuditMutation( + kind=kind, + mutation_id=f"audit-mutation:{secrets.token_urlsafe(18)}", + created_at_ms=int(time.time() * 1000), + payload=self._continuity_payload(kind, args, kwargs), + ) + + def apply(conn: sqlite3.Connection, _mutation: AuditMutation) -> object: + self._coordinated_connection = conn + self._coordinated_mutation = _mutation + try: + return method(self, *args, **kwargs) + finally: + self._coordinated_mutation = None + self._coordinated_connection = None + + return self._continuity.execute(mutation, apply) + + return cast(_F, wrapped) + + return decorate + + +def _target_from_payload(raw: object) -> MutationTarget: + value = cast(dict[str, object], raw) + return MutationTarget( + kind=cast(str, value["kind"]), + ref=cast(str, value["ref"]), + policy_key=cast(str, value["policy_key"]), + identity_digest=cast(str, value["identity_digest"]), + effect_identity=cast(str, value["effect_identity"]), + durability=cast(Any, value["durability"]), + recovery=cast(Any, value["recovery"]), + ) + + +def _context_sha256(context: Mapping[str, object]) -> str: + """Bind omitted authored context without retaining it in source.db.""" + + encoded = json.dumps(context, sort_keys=True, default=str, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _replay_plan_payload(plan: MutationPlan) -> dict[str, object]: + """Persist only the plan fields the audit replay path consumes.""" + + return { + "operation": plan.operation, + "destructive_class": plan.destructive_class, + "target_refs": list(plan.target_refs), + "affected_tiers": list(plan.affected_tiers), + "reversible": plan.reversible, + "prepared_at": plan.prepared_at, + "plan_hash": plan.plan_hash, + "context_sha256": _context_sha256(plan.context), + "operation_version": plan.operation_version, + "archive_instance_id": plan.archive_instance_id, + "archive_identity_digest": plan.archive_identity_digest, + "required_capabilities": list(plan.required_capabilities), + "required_confirmation": plan.required_confirmation, + "targets": [target.canonical_dict() for target in plan.targets], + "parameter_digest": plan.parameter_digest, + "target_digest": plan.target_digest, + "prepared_at_ms": plan.prepared_at_ms, + "expires_at_ms": plan.expires_at_ms, + } + + +def _plan_from_payload(raw: object) -> MutationPlan: + value = cast(dict[str, object], raw) + raw_context = value.get("context") + if raw_context is None: + context_digest = value.get("context_sha256") + if not isinstance(context_digest, str) or len(context_digest) != 64: + raise ValueError("replayed plan lacks an authored-context digest") + context: Mapping[str, object] = {} + elif isinstance(raw_context, Mapping): + # Compatibility for pending commands written before this replay-only + # format. New commands never write authored context to source.db. + context = cast(Mapping[str, object], raw_context) + else: + raise ValueError("replayed plan context is malformed") + return MutationPlan( + operation=cast(str, value["operation"]), + destructive_class=cast(Any, value["destructive_class"]), + target_refs=tuple(cast(list[str], value["target_refs"])), + affected_tiers=tuple(cast(list[str], value["affected_tiers"])), + reversible=cast(bool, value["reversible"]), + prepared_at=cast(str, value["prepared_at"]), + plan_hash=cast(str, value["plan_hash"]), + context=context, + operation_version=cast(int, value["operation_version"]), + archive_instance_id=cast(str, value["archive_instance_id"]), + archive_identity_digest=cast(str, value["archive_identity_digest"]), + required_capabilities=tuple(cast(list[str], value["required_capabilities"])), + required_confirmation=cast(Any, value["required_confirmation"]), + targets=tuple(_target_from_payload(item) for item in cast(list[object], value["targets"])), + parameter_digest=cast(str, value["parameter_digest"]), + target_digest=cast(str, value["target_digest"]), + prepared_at_ms=cast(int, value["prepared_at_ms"]), + expires_at_ms=cast(int, value["expires_at_ms"]), + ) + + +def _principal_payload(principal: MutationPrincipal) -> dict[str, object]: + return { + "actor_ref": principal.actor_ref, + "capabilities": sorted(principal.capabilities), + "surface": principal.surface, + "role_label": principal.role_label, + } + + +def _principal_from_payload(raw: object) -> MutationPrincipal: + value = cast(dict[str, object], raw) + return MutationPrincipal( + cast(str, value["actor_ref"]), + frozenset(cast(list[str], value["capabilities"])), + cast(Any, value["surface"]), + cast(str | None, value.get("role_label")), + ) + + +def _preview_payload(preview: MutationPreview) -> dict[str, object]: + return {"preview_ref": preview.preview_ref, "plan": _replay_plan_payload(preview.plan)} + + +def _preview_from_payload(raw: object) -> MutationPreview: + value = cast(dict[str, object], raw) + return MutationPreview(preview_ref=cast(str, value["preview_ref"]), plan=_plan_from_payload(value["plan"])) + + +def _authorization_payload(authorization: MutationAuthorization) -> dict[str, object]: + return { + **authorization.to_dict(), + "token_sha256": None if authorization.token is None else token_sha256(authorization.token), + } + + +def _authorization_from_payload(raw: object) -> MutationAuthorization: + value = cast(dict[str, object], raw) + return MutationAuthorization( + plan_hash=cast(str, value["plan_hash"]), + actor=cast(str, value["actor"]), + role=cast(str, value["role"]), + capability=cast(str, value["capability"]), + confirmation_strength=cast(Any, value["confirmation_strength"]), + authorized_at=cast(str, value["authorized_at"]), + preview_ref=cast(str | None, value.get("preview_ref")), + authorization_id=cast(str | None, value.get("authorization_id")), + token=None, + expires_at_ms=cast(int | None, value.get("expires_at_ms")), + capabilities=tuple(cast(list[str], value["capabilities"])), + surface=cast(Any, value.get("surface")), + ) + + +def _stored_authorization_digest(raw: object) -> _StoredAuthorizationDigest: + value = cast(dict[str, object], raw) + digest = value.get("token_sha256") + if not isinstance(digest, str) or len(digest) != 64: + raise ValueError("replayed bound authorization lacks a token digest") + return _StoredAuthorizationDigest(digest) + + +def _json_primitive(value: object) -> object: + """Project typed receipt values into finite, replayable JSON primitives.""" + + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise TypeError("continuity receipt contains a non-finite float") + return value + if isinstance(value, Enum): + return _json_primitive(value.value) + if isinstance(value, Mapping): + normalized: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError("continuity receipt object keys must be strings") + normalized[key] = _json_primitive(item) + return normalized + if isinstance(value, (list, tuple)): + return [_json_primitive(item) for item in value] + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _json_primitive(model_dump(mode="json")) + if is_dataclass(value) and not isinstance(value, type): + # Dataclass receipt values can carry private derived caches (for + # example AnnotationBatch's canonical byte payload). Persist only the + # constructor fields that define the replayable public value. + return _json_primitive({field.name: getattr(value, field.name) for field in fields(value) if field.init}) + if isinstance(value, Path): + return str(value) + raise TypeError(f"continuity receipt cannot encode {type(value).__qualname__}") + + +def _receipt_payload(receipt: MutationReceipt) -> dict[str, object]: + """Persist only finalization data consumed by the audit state transition.""" + + return { + "operation": receipt.operation, + "plan_hash": receipt.plan_hash, + "status": receipt.status, + "target_refs": list(receipt.target_refs), + "affected_count": receipt.affected_count, + "receipt_ref": receipt.receipt_ref, + "applied_at": receipt.applied_at, + "operation_id": receipt.operation_id, + } + + +def _receipt_from_payload(raw: object) -> MutationReceipt: + value = cast(dict[str, object], raw) + return MutationReceipt( + operation=cast(str, value["operation"]), + plan_hash=cast(str, value["plan_hash"]), + status=cast(Any, value["status"]), + target_refs=tuple(cast(list[str], value["target_refs"])), + affected_count=cast(int, value["affected_count"]), + detail=cast(str | None, value.get("detail")), + receipt_ref=cast(str | None, value.get("receipt_ref")), + applied_at=cast(str, value["applied_at"]), + domain_receipt=cast(dict[str, object], value.get("domain_receipt", {})), + operation_id=cast(str | None, value.get("operation_id")), + ) + + class AuditRepository: """Small synchronous repository whose methods make audit transactions explicit.""" - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, *, attempt_owner_id: str | None = None) -> None: self.path = path - self.path.parent.mkdir(parents=True, exist_ok=True) + self._attempt_owner_id = attempt_owner_id + self._continuity = AuditContinuityCoordinator(path.parent) + self._coordinated_connection: sqlite3.Connection | None = None + self._coordinated_mutation: AuditMutation | None = None + + @classmethod + def for_archive_root(cls, archive_root: Path, *, attempt_owner_id: str | None = None) -> AuditRepository: + """Build the repository for an already-initialized archive root.""" + + return cls(archive_root / "audit.db", attempt_owner_id=attempt_owner_id) + + @staticmethod + def current_process_attempt_owner() -> str: + """Return the process identity assigned to production mutation attempts.""" + + return _current_process_attempt_owner() + + def reconcile_continuity(self) -> None: + """Reject audit bytes that cannot prove the source control head.""" + + self._assert_regular_audit_leaf() + self._continuity.reconcile(self._replay_pending_mutation) + + def _assert_regular_audit_leaf(self) -> None: + """Refuse an audit pathname that redirects authority outside the archive root.""" + + try: + assert_verified_audit_leaf(self.path) + except AuditLeafError as exc: + raise RuntimeError(str(exc)) from exc @contextmanager def _connection(self) -> Iterator[sqlite3.Connection]: - conn = sqlite3.connect(self.path) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - conn.executescript(AUDIT_DDL) - conn.execute(f"PRAGMA user_version = {AUDIT_SCHEMA_VERSION}") - conn.commit() + self._assert_regular_audit_leaf() + if self._coordinated_connection is not None: + yield self._coordinated_connection + return + with open_verified_audit_connection(self.path) as conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + except BaseException: + conn.rollback() + raise + else: + conn.commit() + + def _continuity_payload( + self, kind: str, args: tuple[object, ...], kwargs: Mapping[str, object] + ) -> dict[str, object]: + """Encode exact typed replay inputs before source.db prepares a command.""" + + values = dict(kwargs) + if kind == "ensure_archive_authority": + archive_instance_id = cast(str | None, values.get("archive_instance_id")) + return { + "now_ms": cast(int, values["now_ms"]), + # Keep caller intent separate from the deterministic value used + # only if this command has to create the authority row. A live + # call with ``None`` accepts an existing id; replay must retain + # that same optional semantic rather than treating a generated + # value as an asserted authority id. + "archive_instance_id": archive_instance_id, + "generated_archive_instance_id": ( + None if archive_instance_id is not None else f"archive:{secrets.token_hex(16)}" + ), + } + if kind == "create_preview": + plan, principal = cast(MutationPlan, args[0]), cast(MutationPrincipal, args[1]) + return { + "preview_id": f"preview:{secrets.token_urlsafe(18)}", + "plan": _replay_plan_payload(plan), + "principal": _principal_payload(principal), + } + if kind == "issue_authorization": + if isinstance(args[0], _StoredAuthorizationDigest): + preview, principal, authorization = ( + cast(MutationPreview, args[1]), + cast(MutationPrincipal, args[2]), + cast(MutationAuthorization, args[3]), + ) + else: + preview, principal, authorization = ( + cast(MutationPreview, args[0]), + cast(MutationPrincipal, args[1]), + cast(MutationAuthorization, args[2]), + ) + return { + "authorization_id": f"authorization:{secrets.token_urlsafe(18)}", + "issued_at_ms": cast(int, values.get("issued_at_ms", int(time.time() * 1000))), + "preview": _preview_payload(preview), + "principal": _principal_payload(principal), + "authorization": _authorization_payload(authorization), + } + if kind == "consume_authorization_and_start": + if isinstance(args[0], _StoredAuthorizationDigest): + preview, authorization = cast(MutationPreview, args[1]), cast(MutationAuthorization, args[2]) + else: + preview, authorization = cast(MutationPreview, args[0]), cast(MutationAuthorization, args[1]) + return { + "operation_id": f"operation:{secrets.token_urlsafe(18)}", + "attempt_id": f"attempt:{secrets.token_urlsafe(18)}", + # The command can be replayed by a fresh repository process. + # Keep the original actuator owner, rather than accidentally + # assigning its pre-effect attempt to the recovery process. + "attempt_owner_id": self._attempt_owner_id, + "now_ms": int(time.time() * 1000), + "preview": _preview_payload(preview), + "authorization": _authorization_payload(authorization), + } + if kind == "finalize_attempt": + operation_id = cast(str, args[0]) + return { + "operation_id": operation_id, + "status": cast(str, values["status"]), + "receipt": None + if values.get("receipt") is None + else _receipt_payload(cast(MutationReceipt, values["receipt"])), + "error_summary": values.get("error_summary"), + "unknown_reason": values.get("unknown_reason"), + "now_ms": int(time.time() * 1000), + } + if kind == "reconcile_attempt": + operation_id = cast(str, args[0]) + return { + "operation_id": operation_id, + "outcome": cast(str, values["outcome"]), + "domain_receipt_ref": values.get("domain_receipt_ref"), + "reason": values.get("reason"), + "now_ms": int(time.time() * 1000), + } + if kind == "recover_abandoned_attempts": + return {"now_ms": int(time.time() * 1000)} + raise RuntimeError(f"unregistered audit continuity mutation {kind!r}") + + def _replay_pending_mutation(self, conn: sqlite3.Connection, mutation: AuditMutation) -> object: + """Replay the stored typed command without allocating fresh ids or clocks.""" + + payload = mutation.payload + self._coordinated_connection = conn + self._coordinated_mutation = mutation try: - yield conn + if mutation.kind == "ensure_archive_authority": + return cast(Any, self.ensure_archive_authority).__wrapped__( + self, + now_ms=cast(int, payload["now_ms"]), + archive_instance_id=cast(str | None, payload.get("archive_instance_id")), + ) + if mutation.kind == "create_preview": + return cast(Any, self.create_preview).__wrapped__( + self, _plan_from_payload(payload["plan"]), _principal_from_payload(payload["principal"]) + ) + if mutation.kind == "issue_authorization": + return self._persist_authorization( + _stored_authorization_digest(payload["authorization"]), + _preview_from_payload(payload["preview"]), + _principal_from_payload(payload["principal"]), + _authorization_from_payload(payload["authorization"]), + issued_at_ms=cast(int, payload["issued_at_ms"]), + ) + if mutation.kind == "consume_authorization_and_start": + return self._consume_authorization( + _stored_authorization_digest(payload["authorization"]), + _preview_from_payload(payload["preview"]), + _authorization_from_payload(payload["authorization"]), + ) + if mutation.kind == "finalize_attempt": + return cast(Any, self.finalize_attempt).__wrapped__( + self, + cast(str, payload["operation_id"]), + status=cast(str, payload["status"]), + receipt=None if payload["receipt"] is None else _receipt_from_payload(payload["receipt"]), + error_summary=cast(str | None, payload.get("error_summary")), + unknown_reason=cast(str | None, payload.get("unknown_reason")), + ) + if mutation.kind == "reconcile_attempt": + return cast(Any, self.reconcile_attempt).__wrapped__( + self, + cast(str, payload["operation_id"]), + outcome=cast(Literal["applied", "absent", "unknown"], payload["outcome"]), + domain_receipt_ref=cast(str | None, payload.get("domain_receipt_ref")), + reason=cast(str | None, payload.get("reason")), + ) + if mutation.kind == "recover_abandoned_attempts": + return cast(Any, self._recover_abandoned_attempts).__wrapped__(self) + raise RuntimeError(f"unregistered audit continuity mutation {mutation.kind!r}") finally: - conn.close() + self._coordinated_mutation = None + self._coordinated_connection = None + + def _command_value(self, key: str, fallback: object) -> object: + if self._coordinated_mutation is None: + return fallback + return self._coordinated_mutation.payload.get(key, fallback) + + def _begin(self, conn: sqlite3.Connection) -> None: + """Start a standalone audit transaction, or reuse the coordinator's one.""" + if self._coordinated_connection is None: + conn.execute("BEGIN IMMEDIATE") + + @_continuity_mutation("ensure_archive_authority") def ensure_archive_authority(self, *, now_ms: int, archive_instance_id: str | None = None) -> str: """Create or return the immutable archive lineage id.""" @@ -70,20 +600,29 @@ def ensure_archive_authority(self, *, now_ms: int, archive_instance_id: str | No if archive_instance_id is not None and archive_instance_id != existing: raise ValueError("audit archive instance identity changed") return existing - instance_id = archive_instance_id or f"archive:{secrets.token_hex(16)}" + instance_id = cast( + str, + archive_instance_id + or self._command_value( + "generated_archive_instance_id", + self._command_value("archive_instance_id", ""), + ), + ) + if not instance_id: + raise RuntimeError("audit archive authority command lacks an instance identity") conn.execute( "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", (instance_id, now_ms), ) - conn.commit() return instance_id + @_continuity_mutation("create_preview") def create_preview(self, plan: MutationPlan, principal: MutationPrincipal) -> str: """Persist a bounded preview and its normalized target/capability rows.""" - preview_id = f"preview:{secrets.token_urlsafe(18)}" + preview_id = cast(str, self._command_value("preview_id", f"preview:{secrets.token_urlsafe(18)}")) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) conn.execute( """ INSERT INTO operation_previews ( @@ -153,7 +692,6 @@ def create_preview(self, plan: MutationPlan, principal: MutationPrincipal) -> st "INSERT INTO operation_preview_capabilities(preview_id, capability) VALUES (?, ?)", (preview_id, capability), ) - conn.commit() return preview_id def issue_authorization( @@ -161,17 +699,68 @@ def issue_authorization( preview: MutationPreview, principal: MutationPrincipal, authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, ) -> str: """Persist token digest and exact proved capabilities, never token material.""" if authorization.token is None: raise ValueError("bound authorization requires a token") - authorization_id = f"authorization:{secrets.token_urlsafe(18)}" - issued_at_ms = int(time.time() * 1000) + authorization_id = self._issue_authorization( + _StoredAuthorizationDigest(token_sha256(authorization.token)), + preview, + principal, + authorization, + issued_at_ms=issued_at_ms, + ) + if authorization_id is None: + raise TokenExpiredError("cannot authorize an expired preview") + return authorization_id + + @_continuity_mutation("issue_authorization") + def _issue_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + principal: MutationPrincipal, + authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, + ) -> str | None: + """Persist one token from the durable preview's exact authorization evidence.""" + + return self._persist_authorization( + token_digest, + preview, + principal, + authorization, + issued_at_ms=issued_at_ms, + ) + + def _persist_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + principal: MutationPrincipal, + authorization: MutationAuthorization, + *, + issued_at_ms: int | None = None, + ) -> str | None: + authorization_id = cast( + str, self._command_value("authorization_id", f"authorization:{secrets.token_urlsafe(18)}") + ) + effective_issued_at_ms = cast( + int, + self._command_value("issued_at_ms", issued_at_ms if issued_at_ms is not None else int(time.time() * 1000)), + ) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) preview_row = conn.execute( - "SELECT plan_hash, expires_at_ms, state, principal_actor_ref FROM operation_previews WHERE preview_id = ?", + """ + SELECT plan_hash, expires_at_ms, state, principal_actor_ref, + principal_surface, role_label, required_confirmation + FROM operation_previews WHERE preview_id = ? + """, (preview.preview_ref,), ).fetchone() if preview_row is None: @@ -180,8 +769,43 @@ def issue_authorization( raise ValueError("preview plan hash does not match its durable row") if str(preview_row[2]) != "prepared": raise ValueError("preview is not authorizable") - if principal.actor_ref != str(preview_row[3]): + durable_expires_at_ms = int(preview_row[1]) + durable_capabilities = tuple( + str(row[0]) + for row in conn.execute( + "SELECT capability FROM operation_preview_capabilities WHERE preview_id = ? ORDER BY capability", + (preview.preview_ref,), + ) + ) + if principal.actor_ref != str(preview_row[3]) or principal.surface != str(preview_row[4]): raise ValueError("authorization principal differs from preview principal") + if principal.role_label != cast(str | None, preview_row[5]): + raise ValueError("authorization role differs from preview principal") + if not set(durable_capabilities).issubset(principal.capabilities): + raise ValueError("authorization principal lacks the preview's required capabilities") + if ( + _CONFIRMATION_STRENGTH_ORDER.get(authorization.confirmation_strength, -1) + < _CONFIRMATION_STRENGTH_ORDER[str(preview_row[6])] + ): + raise ValueError("authorization confirmation is weaker than the durable preview") + if ( + authorization.preview_ref != preview.preview_ref + or authorization.plan_hash != str(preview_row[0]) + or authorization.actor != str(preview_row[3]) + or authorization.surface != str(preview_row[4]) + or authorization.role != (cast(str | None, preview_row[5]) or "") + or authorization.expires_at_ms != durable_expires_at_ms + or authorization.capabilities != durable_capabilities + or (durable_capabilities and authorization.capability not in durable_capabilities) + or (not durable_capabilities and authorization.capability != "") + ): + raise ValueError("authorization evidence differs from its durable preview") + if effective_issued_at_ms >= durable_expires_at_ms: + conn.execute( + "UPDATE operation_previews SET state = 'expired' WHERE preview_id = ? AND state = 'prepared'", + (preview.preview_ref,), + ) + return None conn.execute( """ INSERT INTO operation_authorizations( @@ -197,17 +821,16 @@ def issue_authorization( principal.surface, principal.role_label, authorization.confirmation_strength, - token_sha256(authorization.token), - issued_at_ms, - authorization.expires_at_ms or issued_at_ms, + token_digest.value, + effective_issued_at_ms, + durable_expires_at_ms, ), ) - for capability in authorization.capabilities: + for capability in durable_capabilities: conn.execute( "INSERT INTO operation_authorization_capabilities(authorization_id, capability) VALUES (?, ?)", (authorization_id, capability), ) - conn.commit() return authorization_id def consume_authorization_and_start(self, preview: MutationPreview, authorization: MutationAuthorization) -> str: @@ -215,35 +838,78 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio if authorization.token is None: raise ValueError("authorization token is missing") - operation_id = f"operation:{secrets.token_urlsafe(18)}" - attempt_id = f"attempt:{secrets.token_urlsafe(18)}" - now_ms = int(time.time() * 1000) + operation_id = self._consume_authorization_and_start( + _StoredAuthorizationDigest(token_sha256(authorization.token)), + preview, + authorization, + ) + if operation_id is None: + raise TokenExpiredError("authorization token is expired") + return operation_id + + @_continuity_mutation("consume_authorization_and_start") + def _consume_authorization_and_start( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + authorization: MutationAuthorization, + ) -> str | None: + """Commit an expired-token transition before reporting it to the caller.""" + + return self._consume_authorization(token_digest, preview, authorization) + + def _consume_authorization( + self, + token_digest: _StoredAuthorizationDigest, + preview: MutationPreview, + authorization: MutationAuthorization, + ) -> str | None: + validate_mutation_plan_integrity(preview.plan) + operation_id = cast(str, self._command_value("operation_id", f"operation:{secrets.token_urlsafe(18)}")) + attempt_id = cast(str, self._command_value("attempt_id", f"attempt:{secrets.token_urlsafe(18)}")) + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) row = conn.execute( """ SELECT a.authorization_id, a.preview_id, a.actor_ref, a.surface, - a.state, a.expires_at_ms, p.plan_hash + a.role_label, a.confirmation_strength, a.state, a.expires_at_ms, + p.plan_hash FROM operation_authorizations AS a JOIN operation_previews AS p ON p.preview_id = a.preview_id WHERE a.token_sha256 = ? """, - (token_sha256(authorization.token),), + (token_digest.value,), ).fetchone() if row is None or str(row[1]) != preview.preview_ref: raise ValueError("authorization token does not match preview") - if str(row[4]) != "active": + if str(row[6]) != "active": raise RuntimeError("authorization token is already consumed or revoked") - if int(row[5]) <= now_ms: + if int(row[7]) <= now_ms: conn.execute( "UPDATE operation_authorizations SET state = 'expired' WHERE authorization_id = ?", (str(row[0]),), ) - conn.commit() - raise RuntimeError("authorization token is expired") - if str(row[2]) != authorization.actor or str(row[3]) != (authorization.surface or ""): + return None + durable_capabilities = tuple( + str(capability_row[0]) + for capability_row in conn.execute( + "SELECT capability FROM operation_authorization_capabilities WHERE authorization_id = ? ORDER BY capability", + (str(row[0]),), + ) + ) + if ( + str(row[2]) != authorization.actor + or str(row[3]) != (authorization.surface or "") + or (cast(str | None, row[4]) or "") != authorization.role + or str(row[5]) != authorization.confirmation_strength + or int(row[7]) != authorization.expires_at_ms + or durable_capabilities != authorization.capabilities + or (durable_capabilities and authorization.capability not in durable_capabilities) + or (not durable_capabilities and authorization.capability != "") + ): raise ValueError("authorization principal mismatch") - if str(row[6]) != preview.plan.plan_hash or authorization.plan_hash != preview.plan.plan_hash: + if str(row[8]) != preview.plan.plan_hash or authorization.plan_hash != preview.plan.plan_hash: raise ValueError("authorization plan mismatch") conn.execute( "UPDATE operation_authorizations SET state = 'consumed', consumed_at_ms = ? WHERE authorization_id = ?", @@ -275,15 +941,15 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio preview.plan.parameter_digest, preview.plan.target_digest or preview.plan.plan_hash, preview.plan.target_count, - authorization.actor, - authorization.surface, - authorization.role, + str(row[2]), + str(row[3]), + cast(str | None, row[4]), now_ms, now_ms, now_ms, ), ) - for capability in authorization.capabilities: + for capability in durable_capabilities: conn.execute( "INSERT INTO operation_run_capabilities(operation_id, capability) VALUES (?, ?)", (operation_id, capability), @@ -310,10 +976,17 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio """ INSERT INTO operation_attempts( attempt_id, operation_id, target_ordinal, authorization_id, - state, started_at_ms - ) VALUES (?, ?, ?, ?, 'running', ?) + worker_id, state, started_at_ms + ) VALUES (?, ?, ?, ?, ?, 'running', ?) """, - (attempt_id, operation_id, 0 if preview.plan.targets else None, str(row[0]), now_ms), + ( + attempt_id, + operation_id, + 0 if preview.plan.targets else None, + str(row[0]), + cast(str | None, self._command_value("attempt_owner_id", self._attempt_owner_id)), + now_ms, + ), ) self._append_event( conn, @@ -324,9 +997,9 @@ def consume_authorization_and_start(self, preview: MutationPreview, authorizatio occurred_at_ms=now_ms, detail={"target_count": preview.plan.target_count}, ) - conn.commit() return operation_id + @_continuity_mutation("finalize_attempt") def finalize_attempt( self, operation_id: str, @@ -338,20 +1011,32 @@ def finalize_attempt( ) -> None: """Finalize one running attempt and parent run in one audit transaction.""" - now_ms = int(time.time() * 1000) - target_state: AuditTargetState = ( - "unknown" if status == "unknown" else "failed" if status == "failed" else "applied" + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) + target_state = cast( + AuditTargetState, + { + "unknown": "unknown", + "failed": "failed", + "blocked": "rejected", + "already_satisfied": "already_satisfied", + }.get(status, "applied"), + ) + attempt_state = ( + "unknown" + if target_state == "unknown" + else "failed" + if target_state in {"rejected", "failed"} + else "applied" ) - attempt_state = "unknown" if target_state == "unknown" else target_state with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") + self._begin(conn) run = conn.execute( "SELECT actor_ref FROM operation_runs WHERE operation_id = ?", (operation_id,) ).fetchone() if run is None: raise ValueError(f"unknown operation {operation_id!r}") target = conn.execute( - "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'running' ORDER BY ordinal LIMIT 1", + "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state IN ('running', 'pending') ORDER BY ordinal LIMIT 1", (operation_id,), ).fetchone() ordinal = int(target[0]) if target is not None else None @@ -363,45 +1048,37 @@ def finalize_attempt( """, (attempt_state, now_ms, error_summary, unknown_reason, operation_id), ) - if ordinal is not None: - conn.execute( - """ - UPDATE operation_targets - SET state = ?, completed_at_ms = ?, error_summary = ?, unknown_reason = ?, - domain_receipt_ref = ?, domain_receipt_kind = ? - WHERE operation_id = ? AND ordinal = ? - """, - ( - target_state, - now_ms, - error_summary, - unknown_reason, - None if receipt is None else receipt.receipt_ref, - None if receipt is None else "mutation-receipt", - operation_id, - ordinal, - ), - ) + conn.execute( + """ + UPDATE operation_targets + SET state = ?, completed_at_ms = ?, error_summary = ?, unknown_reason = ?, + domain_receipt_ref = ?, domain_receipt_kind = ? + WHERE operation_id = ? AND state IN ('running', 'pending') + """, + ( + target_state, + now_ms, + error_summary, + unknown_reason, + None if receipt is None else receipt.receipt_ref, + None if receipt is None else "mutation-receipt", + operation_id, + ), + ) states = [ str(row[0]) for row in conn.execute("SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,)) ] - if "unknown" in states: - run_status, terminal_reason = "interrupted", "unknown_effect" - elif "failed" in states: - run_status, terminal_reason = "failed", "domain_failure" - elif states and all(state in {"applied", "already_satisfied"} for state in states): - run_status, terminal_reason = "completed", None - else: - run_status, terminal_reason = "running", None + run_status, terminal_reason = _run_state_for_targets(states) conn.execute( """ UPDATE operation_runs SET status = ?, terminal_reason = ?, updated_at_ms = ?, completed_at_ms = CASE WHEN ? IN ('completed', 'failed', 'interrupted') THEN ? ELSE completed_at_ms END, + rejected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'rejected'), failed_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'failed'), unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), - affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state IN ('applied', 'already_satisfied')), + affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'applied'), error_summary = ?, unknown_reason = ? WHERE operation_id = ? """, @@ -414,6 +1091,7 @@ def finalize_attempt( operation_id, operation_id, operation_id, + operation_id, error_summary, unknown_reason, operation_id, @@ -428,10 +1106,56 @@ def finalize_attempt( to_state=run_status, actor_ref=str(run[0]), occurred_at_ms=now_ms, - detail={"status": status, "reason": (unknown_reason or error_summary or "")[:512]}, + detail=_receipt_event_detail(receipt, status=status, reason=unknown_reason or error_summary), ) - conn.commit() + def recover_abandoned_attempts(self) -> tuple[str, ...]: + """Recover only work that a prior process actually left running.""" + + with self._connection() as conn: + has_running = conn.execute("SELECT 1 FROM operation_attempts WHERE state = 'running' LIMIT 1").fetchone() + if has_running is None: + return () + return self._recover_abandoned_attempts() + + @_continuity_mutation("recover_abandoned_attempts") + def _recover_abandoned_attempts(self) -> tuple[str, ...]: + """Mark only attempts whose recorded owner is no longer live as unknown.""" + + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) + with self._connection() as conn: + self._begin(conn) + rows = conn.execute( + "SELECT operation_id, worker_id FROM operation_attempts WHERE state = 'running' ORDER BY operation_id" + ).fetchall() + operation_ids = tuple( + str(row[0]) for row in rows if _attempt_owner_liveness(cast(str | None, row[1])) == "dead" + ) + for operation_id in operation_ids: + conn.execute( + "UPDATE operation_attempts SET state = 'unknown', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'running'", + (now_ms, "process ended before audit finalization", operation_id), + ) + conn.execute( + "UPDATE operation_targets SET state = 'unknown', unknown_reason = ? WHERE operation_id = ? AND state IN ('running', 'pending')", + ("process ended before audit finalization", operation_id), + ) + conn.execute( + "UPDATE operation_runs SET status = 'interrupted', terminal_reason = 'unknown_effect', updated_at_ms = ?, completed_at_ms = ?, unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), unknown_reason = ? WHERE operation_id = ?", + (now_ms, now_ms, operation_id, "process ended before audit finalization", operation_id), + ) + self._append_event( + conn, + operation_id=operation_id, + event_type="attempt_unknown", + from_state="running", + to_state="interrupted", + occurred_at_ms=now_ms, + detail={"reason": "process ended before audit finalization"}, + ) + return operation_ids + + @_continuity_mutation("reconcile_attempt") def reconcile_attempt( self, operation_id: str, @@ -442,44 +1166,58 @@ def reconcile_attempt( ) -> None: """Persist an explicit applied/absent/unknown reconciliation decision.""" - now_ms = int(time.time() * 1000) + now_ms = cast(int, self._command_value("now_ms", int(time.time() * 1000))) with self._connection() as conn: - conn.execute("BEGIN IMMEDIATE") - target = conn.execute( - "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'unknown' ORDER BY ordinal LIMIT 1", + self._begin(conn) + rows = conn.execute( + "SELECT ordinal FROM operation_targets WHERE operation_id = ? AND state = 'unknown' ORDER BY ordinal", (operation_id,), - ).fetchone() - ordinal = int(target[0]) if target is not None else None - if ordinal is None: + ).fetchall() + if not rows: raise ValueError(f"operation {operation_id!r} has no unknown target to reconcile") + ordinal = int(rows[0][0]) target_state = "applied" if outcome == "applied" else "pending" if outcome == "absent" else "unknown" - run_state = ( - "completed" if target_state == "applied" else "running" if target_state == "pending" else "interrupted" - ) conn.execute( "UPDATE operation_attempts SET state = 'reconciled', finished_at_ms = ?, unknown_reason = ? WHERE operation_id = ? AND state = 'unknown'", (now_ms, reason, operation_id), ) conn.execute( - "UPDATE operation_targets SET state = ?, domain_receipt_ref = ?, domain_receipt_kind = ?, completed_at_ms = ? WHERE operation_id = ? AND ordinal = ?", + "UPDATE operation_targets SET state = ?, domain_receipt_ref = ?, domain_receipt_kind = ?, completed_at_ms = ? WHERE operation_id = ? AND state = 'unknown'", ( target_state, domain_receipt_ref, "domain" if domain_receipt_ref else None, now_ms if target_state == "applied" else None, operation_id, - ordinal, ), ) + states = [ + str(row[0]) + for row in conn.execute("SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,)) + ] + run_state, terminal_reason = _run_state_for_targets(states) conn.execute( - "UPDATE operation_runs SET status = ?, terminal_reason = ?, updated_at_ms = ?, completed_at_ms = CASE WHEN ? = 'completed' THEN ? ELSE completed_at_ms END WHERE operation_id = ?", + """ + UPDATE operation_runs + SET status = ?, terminal_reason = ?, updated_at_ms = ?, + completed_at_ms = CASE WHEN ? IN ('completed', 'failed', 'interrupted') THEN ? ELSE completed_at_ms END, + rejected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'rejected'), + failed_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'failed'), + unknown_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state = 'unknown'), + affected_count = (SELECT COUNT(*) FROM operation_targets WHERE operation_id = ? AND state IN ('applied', 'already_satisfied')) + WHERE operation_id = ? + """, ( run_state, - None if run_state == "completed" else "reconciliation_unknown", + terminal_reason, now_ms, run_state, now_ms, operation_id, + operation_id, + operation_id, + operation_id, + operation_id, ), ) self._append_event( @@ -490,15 +1228,31 @@ def reconcile_attempt( from_state="unknown", to_state=target_state, occurred_at_ms=now_ms, - detail={"reason": (reason or "")[:512]}, + detail={"reason": (reason or "")[:512], "target_count": len(rows)}, ) - conn.commit() def get_operation(self, operation_id: str) -> dict[str, object] | None: with self._connection() as conn: row = conn.execute("SELECT * FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() return dict(row) if row is not None else None + def find_interrupted_operation(self, *, operation_name: str, parameter_digest: str) -> str | None: + """Return the one interrupted operation bound to an exact durable parameter digest.""" + + with self._connection() as conn: + rows = conn.execute( + """ + SELECT operation_id + FROM operation_runs + WHERE operation_name = ? AND parameter_digest = ? AND status = 'interrupted' + ORDER BY started_at_ms, operation_id + """, + (operation_name, parameter_digest), + ).fetchall() + if len(rows) > 1: + raise RuntimeError("multiple interrupted operations share the same durable parameter digest") + return None if not rows else str(rows[0][0]) + def list_events(self, operation_id: str) -> tuple[dict[str, object], ...]: with self._connection() as conn: rows = conn.execute( diff --git a/polylogue/operations/bindings.py b/polylogue/operations/bindings.py index 89f3b48991..c2881976eb 100644 --- a/polylogue/operations/bindings.py +++ b/polylogue/operations/bindings.py @@ -103,9 +103,26 @@ def validate_operation_bindings( return catalog +def runtime_operation_binding(actuator: MutationActuator[ArgsT]) -> OperationBinding[ArgsT, object]: + """Resolve and validate the declared runtime binding for one actuator.""" + + from polylogue.operations.specs import build_runtime_operation_catalog + + operation = getattr(actuator, "operation", None) + if not isinstance(operation, str) or not operation: + raise BindingValidationError("runtime actuator has no declared operation name") + spec = build_runtime_operation_catalog().by_name().get(operation) + if spec is None: + raise BindingValidationError(f"no runtime OperationSpec for actuator {operation!r}") + binding: OperationBinding[ArgsT, object] = OperationBinding(spec, actuator) + binding.validate() + return binding + + __all__ = [ "BindingValidationError", "OperationBinding", "OperationBindingCatalog", + "runtime_operation_binding", "validate_operation_bindings", ] diff --git a/polylogue/operations/durable_change_train.py b/polylogue/operations/durable_change_train.py index d85164114b..d8bb537dee 100644 --- a/polylogue/operations/durable_change_train.py +++ b/polylogue/operations/durable_change_train.py @@ -2,17 +2,30 @@ from __future__ import annotations +import hashlib +import json import os +import re +import secrets import sqlite3 import stat import sys +import time from collections.abc import Callable +from contextlib import closing, suppress from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, cast from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.audit_continuity import ( + AuditContinuityCoordinator, + AuditContinuityError, + AuditMutation, + audit_semantic_sha256, +) from polylogue.storage.sqlite.durable_change_train import ( DurableChangeTrainExecution, ) @@ -22,7 +35,23 @@ from polylogue.storage.sqlite.durable_change_train import ( reconcile_durable_change_train_startup as _reconcile_durable_change_train_startup, ) -from polylogue.storage.sqlite.migration_runner import DurableRuntimeConsumerResult, MigrationError +from polylogue.storage.sqlite.migration_runner import ( + DurableRuntimeConsumerResult, + MigrationError, + _canonical_json_sha256, + capture_durable_schema_inventory, + validate_full_evidence_backup_for_adopted_audit_restore, + validate_full_evidence_backup_for_audit_adoption, +) + +_AUDIT_ADOPTION_RECEIPT_FORMAT = "polylogue.audit-tier-adoption.v1" +_AUDIT_ADOPTION_RECEIPT_NAME = "audit-adoption.json" +_AUDIT_ADOPTION_CONTINUITY_FORMAT = "polylogue.audit-tier-continuity.v1" +_AUDIT_ADOPTION_CONTINUITY_NAME = "audit-continuity.json" +_AUDIT_ADOPTION_RESTORE_FORMAT = "polylogue.audit-tier-restore.v1" +_AUDIT_ADOPTION_RESTORE_NAME = re.compile( + r"^audit-restore\.(?P[1-9][0-9]*)\.(?P[0-9a-f]{32})\.(?Pprepared|committed)\.json$" +) @dataclass(frozen=True, slots=True) @@ -62,7 +91,15 @@ def acquire_durable_archive_ownership(root: Path, *, owner_id: str) -> OwnedArch return OwnedArchiveLocation.acquire(location, owner_id=owner_id) -def initialize_missing_durable_tier(path: Path, tier: ArchiveTier, *, directory_fd: int | None = None) -> int: +def initialize_missing_durable_tier( + path: Path, + tier: ArchiveTier, + *, + directory_fd: int | None = None, + permit_established_archive: bool = False, + prepare_initialized_image: Callable[[sqlite3.Connection], None] | None = None, + pre_publish_check: Callable[[bytes], None] | None = None, +) -> int: """Initialize one absent durable tier while the caller owns the archive. This is deliberately separate from migration. A missing tier has no @@ -226,7 +263,7 @@ def assert_no_adoption_evidence(*, check_target: bool = True) -> None: has_retained_evidence = bool(directory_entries(evidence_relative, evidence_metadata, description)) if has_retained_evidence: adoption_markers.append(archive_root / evidence_relative) - if existing_siblings or adoption_markers: + if not permit_established_archive and (existing_siblings or adoption_markers): details = ", ".join(str(item) for item in (*existing_siblings, *adoption_markers)) raise MigrationError( f"cannot initialize missing {tier.value} tier in an established archive; " @@ -245,6 +282,8 @@ def assert_no_adoption_evidence(*, check_target: bool = True) -> None: memory_database = sqlite3.connect(":memory:") try: initialize_archive_tier(memory_database, tier) + if prepare_initialized_image is not None: + prepare_initialized_image(memory_database) initialized_image = memory_database.serialize() finally: memory_database.close() @@ -339,7 +378,10 @@ def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: # ``link`` is the atomic no-replacement check for the target itself; # re-census only evidence whose appearance would otherwise make this # empty tier an unsafe adoption. - assert_no_adoption_evidence(check_target=False) + if pre_publish_check is not None: + pre_publish_check(initialized_image) + else: + assert_no_adoption_evidence(check_target=False) try: os.link( f"/proc/self/fd/{descriptor}", @@ -415,6 +457,1122 @@ def cleanup_published_target(primary: BaseException) -> DurableCleanupOutcome: return ARCHIVE_VERSION_BY_TIER[tier] +def audit_adoption_receipt_path(archive_root: Path) -> Path: + """Return the durable-change-train ledger location for audit adoption.""" + return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_RECEIPT_NAME + + +def _audit_adoption_continuity_path(archive_root: Path) -> Path: + """Return the immutable identity binding for the published audit file.""" + return archive_root / ".maintenance-state" / "durable-change-trains" / _AUDIT_ADOPTION_CONTINUITY_NAME + + +def _audit_adoption_authority_digest(archive_root: Path) -> str: + """Bind adoption to the two irreplaceable archive authority tiers only.""" + from polylogue.storage.archive_identity import ArchiveIdentity + + durable_id = ArchiveIdentity.resolve(archive_root).durable_id + return hashlib.sha256(f"source-user-authority:{durable_id}".encode()).hexdigest() + + +def _audit_schema_inventory_sha256() -> str: + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier + + with closing(sqlite3.connect(":memory:")) as connection: + initialize_archive_tier(connection, ArchiveTier.AUDIT) + return capture_durable_schema_inventory(connection).sha256 + + +def _initial_audit_semantic_sha256(application_id: int) -> str: + """Rebuild the adopted audit image's stable content outside its head row.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier + + with closing(sqlite3.connect(":memory:")) as connection: + initialize_archive_tier(connection, ArchiveTier.AUDIT) + connection.execute(f"PRAGMA application_id = {application_id}") + connection.commit() + lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +def _open_audit_adoption_receipt_directory( + path: Path, + *, + archive_root: Path, + create: bool, + archive_directory_fd: int | None = None, +) -> int: + """Open the receipt parent without following any archive path component.""" + archive_root = archive_root.resolve() + expected_paths = {audit_adoption_receipt_path(archive_root), _audit_adoption_continuity_path(archive_root)} + try: + relative = path.relative_to(archive_root) + except ValueError: + relative = Path() + is_restore_record = ( + len(relative.parts) == 3 + and relative.parts[:2] == (".maintenance-state", "durable-change-trains") + and _AUDIT_ADOPTION_RESTORE_NAME.fullmatch(relative.name) is not None + ) + if path not in expected_paths and not is_restore_record: + raise MigrationError(f"audit adoption receipt path is outside its fixed archive location: {path}") + try: + current_fd = ( + os.dup(archive_directory_fd) + if archive_directory_fd is not None + else os.open( + archive_root, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + ) + except OSError as exc: + raise MigrationError(f"cannot anchor audit adoption receipt to archive root: {archive_root}") from exc + try: + for component in path.parent.relative_to(archive_root).parts: + try: + next_fd = os.open( + component, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=current_fd, + ) + except FileNotFoundError: + if not create: + raise + with suppress(FileExistsError): + os.mkdir(component, mode=0o700, dir_fd=current_fd) + next_fd = os.open( + component, + os.O_RDONLY + | getattr(os, "O_DIRECTORY", 0) + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + dir_fd=current_fd, + ) + metadata = os.fstat(next_fd) + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + os.close(next_fd) + raise MigrationError( + f"audit adoption receipt parent is not a private owned directory: {archive_root / component}" + ) + os.fsync(current_fd) + os.fsync(next_fd) + os.close(current_fd) + current_fd = next_fd + return current_fd + except BaseException as exc: + os.close(current_fd) + if isinstance(exc, (FileNotFoundError, MigrationError)): + raise + if isinstance(exc, OSError): + raise MigrationError( + f"audit adoption receipt path must not traverse outside archive-owned directories: {path}" + ) from exc + raise + + +def _write_immutable_audit_adoption_receipt( + path: Path, + payload: dict[str, object], + *, + archive_root: Path, + archive_directory_fd: int | None = None, + checksum_key: str = "receipt_sha256", +) -> None: + """Publish one pre-publication receipt without replacement and fsync it.""" + unsigned = dict(payload) + unsigned.pop(checksum_key, None) + payload = {**unsigned, checksum_key: _canonical_json_sha256(unsigned)} + encoded = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + descriptor: int | None = None + receipt_directory_fd: int | None = None + temporary_name = f".{path.name}.{secrets.token_hex(16)}.tmp" + published = False + try: + receipt_directory_fd = _open_audit_adoption_receipt_directory( + path, + archive_root=archive_root, + create=True, + archive_directory_fd=archive_directory_fd, + ) + descriptor = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=receipt_directory_fd, + ) + offset = 0 + while offset < len(encoded): + written = os.write(descriptor, encoded[offset:]) + if written <= 0: + raise MigrationError("immutable audit adoption receipt write made no progress") + offset += written + os.fsync(descriptor) + os.close(descriptor) + descriptor = None + os.link( + temporary_name, + path.name, + src_dir_fd=receipt_directory_fd, + dst_dir_fd=receipt_directory_fd, + follow_symlinks=False, + ) + published = True + os.fsync(receipt_directory_fd) + os.unlink(temporary_name, dir_fd=receipt_directory_fd) + os.fsync(receipt_directory_fd) + except FileExistsError as exc: + raise MigrationError(f"audit adoption receipt already exists and is immutable: {path}") from exc + except OSError as exc: + raise MigrationError(f"cannot publish immutable audit adoption receipt: {path}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + if receipt_directory_fd is not None and not published: + try: + os.unlink(temporary_name, dir_fd=receipt_directory_fd) + os.fsync(receipt_directory_fd) + except FileNotFoundError: + pass + except OSError: + pass + if receipt_directory_fd is not None: + os.close(receipt_directory_fd) + + +def _audit_adoption_image_binding(payload: dict[str, object]) -> tuple[str, int, int]: + """Return the receipt-bound initial image digest, size, and durable marker.""" + expected_image_sha256 = payload.get("audit_image_sha256") + expected_image_size = payload.get("audit_image_size") + application_id = payload.get("audit_application_id") + if ( + not isinstance(expected_image_sha256, str) + or not isinstance(expected_image_size, int) + or not isinstance(application_id, int) + ): + raise MigrationError("audit adoption receipt lacks a canonical audit image binding") + return expected_image_sha256, expected_image_size, application_id + + +def _load_audit_adoption_receipt(archive_root: Path) -> tuple[Path, dict[str, object]] | None: + receipt_path = audit_adoption_receipt_path(archive_root) + try: + receipt_directory_fd = _open_audit_adoption_receipt_directory( + receipt_path, + archive_root=archive_root, + create=False, + ) + except FileNotFoundError: + return None + receipt_fd: int | None = None + try: + receipt_fd = os.open( + receipt_path.name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=receipt_directory_fd, + ) + metadata = os.fstat(receipt_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit adoption receipt ownership or mode: {receipt_path}") + with os.fdopen(receipt_fd, "r", encoding="utf-8") as stream: + receipt_fd = None + payload = json.load(stream) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit adoption receipt: {receipt_path}") from exc + finally: + if receipt_fd is not None: + os.close(receipt_fd) + os.close(receipt_directory_fd) + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_RECEIPT_FORMAT: + raise MigrationError(f"audit adoption receipt format mismatch: {receipt_path}") + digest = payload.get("receipt_sha256") + unsigned = dict(payload) + unsigned.pop("receipt_sha256", None) + if not isinstance(digest, str) or digest != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit adoption receipt checksum mismatch: {receipt_path}") + if payload.get("source_user_authority_digest") != _audit_adoption_authority_digest(archive_root): + raise MigrationError("audit adoption receipt source/user authority mismatch") + _audit_adoption_image_binding(payload) + return receipt_path, payload + + +def _audit_file_identity(path: Path) -> tuple[int, int]: + """Read the audit leaf identity without following a replacement symlink.""" + try: + metadata = path.lstat() + except OSError as exc: + raise MigrationError(f"cannot inspect adopted audit tier: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise MigrationError(f"adopted audit tier is not a regular file: {path}") + return metadata.st_dev, metadata.st_ino + + +def _audit_live_metadata(audit_path: Path) -> tuple[int, int, tuple[str, ...]]: + """Read the durable markers that remain valid after an in-place migration.""" + uri = f"{audit_path.resolve(strict=False).as_uri()}?mode=ro" + with closing(sqlite3.connect(uri, uri=True)) as connection: + version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + application_id = int(connection.execute("PRAGMA application_id").fetchone()[0] or 0) + quick_check = tuple(str(row[0]) for row in connection.execute("PRAGMA quick_check")) + return version, application_id, quick_check + + +def _audit_file_sha256(audit_path: Path) -> str: + """Hash the exact regular-file image a continuity rebind is about to bless.""" + + _audit_file_identity(audit_path) + digest = hashlib.sha256() + try: + with audit_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise MigrationError(f"cannot hash adopted audit tier: {audit_path}") from exc + return digest.hexdigest() + + +def _validate_initial_audit_image( + audit_path: Path, + *, + expected_image_sha256: str, + expected_image_size: int, + expected_application_id: int, + expected_initial_version: int, +) -> tuple[int, int]: + """Authenticate the receipt-bound initial image before binding its identity.""" + file_identity = _audit_file_identity(audit_path) + try: + audit_image = audit_path.read_bytes() + except OSError as exc: + raise MigrationError(f"cannot read adopted audit tier: {audit_path}") from exc + if len(audit_image) != expected_image_size or hashlib.sha256(audit_image).hexdigest() != expected_image_sha256: + raise MigrationError("audit adoption receipt does not match the published canonical audit image") + version, application_id, quick_check = _audit_live_metadata(audit_path) + if version != expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("audit adoption receipt does not match the published canonical audit image") + return file_identity + + +def _load_audit_adoption_continuity(archive_root: Path) -> dict[str, object] | None: + """Load the immutable audit-file identity record, if publication reached it.""" + continuity_path = _audit_adoption_continuity_path(archive_root) + try: + continuity_directory_fd = _open_audit_adoption_receipt_directory( + continuity_path, + archive_root=archive_root, + create=False, + ) + except FileNotFoundError: + return None + continuity_fd: int | None = None + try: + continuity_fd = os.open( + continuity_path.name, + os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + dir_fd=continuity_directory_fd, + ) + metadata = os.fstat(continuity_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit adoption continuity ownership or mode: {continuity_path}") + with os.fdopen(continuity_fd, "r", encoding="utf-8") as stream: + continuity_fd = None + payload = json.load(stream) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit adoption continuity record: {continuity_path}") from exc + finally: + if continuity_fd is not None: + os.close(continuity_fd) + os.close(continuity_directory_fd) + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_CONTINUITY_FORMAT: + raise MigrationError(f"audit adoption continuity format mismatch: {continuity_path}") + digest = payload.get("continuity_sha256") + unsigned = dict(payload) + unsigned.pop("continuity_sha256", None) + if not isinstance(digest, str) or digest != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit adoption continuity checksum mismatch: {continuity_path}") + return payload + + +def _audit_restore_records(archive_root: Path) -> list[tuple[Path, dict[str, object]]]: + """Read restore state through the fixed, no-follow archive ledger path.""" + marker_path = _audit_adoption_continuity_path(archive_root) + try: + directory_fd = _open_audit_adoption_receipt_directory(marker_path, archive_root=archive_root, create=False) + except FileNotFoundError: + return [] + records: list[tuple[Path, dict[str, object]]] = [] + try: + for name in os.listdir(directory_fd): + match = _AUDIT_ADOPTION_RESTORE_NAME.fullmatch(name) + if match is None: + continue + path = marker_path.with_name(name) + fd: int | None = None + try: + fd = os.open( + name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=directory_fd + ) + metadata = os.fstat(fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o022 + ): + raise MigrationError(f"invalid audit restore record ownership or mode: {path}") + with os.fdopen(fd, "r", encoding="utf-8") as stream: + fd = None + payload = json.load(stream) + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError(f"invalid audit restore record: {path}") from exc + finally: + if fd is not None: + os.close(fd) + if not isinstance(payload, dict) or payload.get("format") != _AUDIT_ADOPTION_RESTORE_FORMAT: + raise MigrationError(f"audit restore record format mismatch: {path}") + checksum_key = "restore_sha256" if match["state"] == "prepared" else "continuity_sha256" + checksum = payload.get(checksum_key) + unsigned = dict(payload) + unsigned.pop(checksum_key, None) + if not isinstance(checksum, str) or checksum != _canonical_json_sha256(unsigned): + raise MigrationError(f"audit restore record checksum mismatch: {path}") + if payload.get("state") != match["state"] or payload.get("operation_id") != match["operation"]: + raise MigrationError(f"audit restore record filename does not match its payload: {path}") + if payload.get("generation") != int(match["generation"]): + raise MigrationError(f"audit restore record generation mismatch: {path}") + records.append((path, payload)) + finally: + os.close(directory_fd) + return records + + +def _latest_audit_adoption_continuity( + archive_root: Path, *, allow_incomplete_restore: bool = False +) -> dict[str, object] | None: + """Follow the immutable restore chain and expose its current generation.""" + continuity = _load_audit_adoption_continuity(archive_root) + if continuity is None: + return None + current_digest = continuity.get("continuity_sha256") + if not isinstance(current_digest, str): + raise MigrationError("audit adoption continuity lacks its immutable checksum") + records_by_generation: dict[int, dict[str, dict[str, object]]] = {} + for _path, payload in _audit_restore_records(archive_root): + generation = payload["generation"] + state = payload["state"] + assert isinstance(generation, int) + assert isinstance(state, str) + states = records_by_generation.setdefault(generation, {}) + if state in states: + raise MigrationError("audit restore records contain duplicate generation state") + states[state] = payload + for expected_generation in range(1, len(records_by_generation) + 1): + if expected_generation not in records_by_generation: + raise MigrationError("audit restore continuity generations are not contiguous") + states = records_by_generation[expected_generation] + prepared = states.get("prepared") + committed = states.get("committed") + if prepared is None or committed is None: + if allow_incomplete_restore and prepared is not None and committed is None: + return continuity + raise MigrationError( + "adopted audit restore is prepared but incomplete; rerun maintenance migrate-tier audit " + "--restore-adopted-audit with the same verified full_evidence backup" + ) + if ( + prepared.get("previous_continuity_sha256") != current_digest + or committed.get("previous_continuity_sha256") != current_digest + or committed.get("prepared_restore_sha256") != prepared.get("restore_sha256") + or committed.get("receipt_sha256") != continuity.get("receipt_sha256") + or committed.get("source_user_authority_digest") != continuity.get("source_user_authority_digest") + ): + raise MigrationError("audit restore continuity chain does not match the adopted archive") + next_digest = committed.get("continuity_sha256") + if not isinstance(next_digest, str): + raise MigrationError("committed audit restore record lacks its continuity checksum") + continuity = committed + current_digest = next_digest + return continuity + + +def _write_audit_adoption_continuity( + archive_root: Path, + *, + receipt_payload: dict[str, object], + expected_initial_file_identity: tuple[int, int] | None, + expected_audit_image_sha256: str, +) -> None: + """Publish the post-link audit identity that later detects stale replacement.""" + audit_path = archive_root / "audit.db" + device, inode = _audit_file_identity(audit_path) + if expected_initial_file_identity is not None and (device, inode) != expected_initial_file_identity: + raise MigrationError("audit tier changed before recording adoption continuity") + receipt_sha256 = receipt_payload.get("receipt_sha256") + if not isinstance(receipt_sha256, str): + raise MigrationError("audit adoption receipt lacks its checksum") + mutation_id = f"audit-adoption:{receipt_sha256}" + coordinator = AuditContinuityCoordinator(archive_root) + machine_head_started = coordinator.has_committed_mutation(mutation_id) or coordinator.has_pending_rebind( + mutation_id + ) + if expected_initial_file_identity is None and not machine_head_started: + raise MigrationError("audit adoption continuity is missing without an authenticated initial image") + if not machine_head_started and _audit_file_sha256(audit_path) != expected_audit_image_sha256: + raise MigrationError("audit image changed before recording adoption continuity") + application_id = receipt_payload.get("audit_application_id") + if not isinstance(application_id, int) or audit_semantic_sha256(audit_path) != _initial_audit_semantic_sha256( + application_id + ): + raise MigrationError("audit tier changed before recording adoption continuity") + payload: dict[str, object] = { + "format": _AUDIT_ADOPTION_CONTINUITY_FORMAT, + "receipt_sha256": receipt_payload["receipt_sha256"], + "source_user_authority_digest": receipt_payload["source_user_authority_digest"], + "audit_device": device, + "audit_inode": inode, + "audit_image_sha256": expected_audit_image_sha256, + } + unsigned = dict(payload) + payload["continuity_sha256"] = _canonical_json_sha256(unsigned) + # Advance the machine head before its immutable receipt says adoption is + # complete. A publication-first crash would leave both heads at genesis. + coordinator.seed_or_rebind( + mutation_id=mutation_id, + now_ms=int(time.time() * 1000), + evidence={ + "kind": "adoption", + "receipt_sha256": receipt_sha256, + "audit_image_sha256": expected_audit_image_sha256, + }, + ) + if _audit_file_identity(audit_path) != (device, inode): + raise MigrationError("audit tier changed while recording adoption continuity") + _write_immutable_audit_adoption_receipt( + _audit_adoption_continuity_path(archive_root), + payload, + archive_root=archive_root, + checksum_key="continuity_sha256", + ) + + +def _validate_audit_adoption_continuity( + archive_root: Path, + *, + receipt_payload: dict[str, object], + expected_initial_file_identity: tuple[int, int] | None, +) -> None: + """Require the published audit path to retain its adopted live identity.""" + continuity = _latest_audit_adoption_continuity(archive_root) + if continuity is None: + _write_audit_adoption_continuity( + archive_root, + receipt_payload=receipt_payload, + expected_initial_file_identity=expected_initial_file_identity, + expected_audit_image_sha256=cast(str, receipt_payload["audit_image_sha256"]), + ) + continuity = _latest_audit_adoption_continuity(archive_root) + assert continuity is not None + expected = (continuity.get("audit_device"), continuity.get("audit_inode")) + if ( + continuity.get("receipt_sha256") != receipt_payload.get("receipt_sha256") + or continuity.get("source_user_authority_digest") != receipt_payload.get("source_user_authority_digest") + or not all(isinstance(value, int) for value in expected) + or _audit_file_identity(archive_root / "audit.db") != expected + ): + raise MigrationError("audit adoption continuity does not match the live audit tier") + + +def _validate_audit_adoption_recovery_evidence(payload: dict[str, object], *, archive_root: Path) -> None: + manifest_value = payload.get("backup_manifest") + receipt_value = payload.get("backup_verification_receipt") + if not isinstance(manifest_value, str) or not isinstance(receipt_value, str): + raise MigrationError("audit adoption receipt lacks backup recovery evidence") + manifest_path = Path(manifest_value) + verification_receipt = Path(receipt_value) + try: + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() + except OSError as exc: + raise MigrationError("audit adoption recovery evidence is unavailable") from exc + if ( + payload.get("backup_manifest_sha256") != manifest_sha256 + or payload.get("backup_verification_receipt_sha256") != receipt_sha256 + ): + raise MigrationError("audit adoption recovery evidence no longer matches its immutable receipt") + validated_manifest, validated_receipt = validate_full_evidence_backup_for_audit_adoption( + manifest_path, + archive_root=archive_root, + ) + if validated_manifest != manifest_path.resolve() or validated_receipt != verification_receipt.resolve(): + raise MigrationError("audit adoption recovery evidence path changed") + + +def _recover_pending_audit_adoption( + archive_root: Path, + receipt_path: Path, + payload: dict[str, object], +) -> None: + """Complete a missing audit publication from its immutable, verified intent.""" + audit_path = archive_root / "audit.db" + _validate_audit_adoption_recovery_evidence(payload, archive_root=archive_root) + expected_sha256, expected_size, application_id = _audit_adoption_image_binding(payload) + + def prepare_initialized_image(connection: sqlite3.Connection) -> None: + connection.execute(f"PRAGMA application_id = {application_id}") + + def revalidate_before_publish(initialized_image: bytes) -> None: + if hashlib.sha256(initialized_image).hexdigest() != expected_sha256 or len(initialized_image) != expected_size: + raise MigrationError("audit adoption receipt does not match its recoverable canonical audit image") + _validate_audit_adoption_recovery_evidence(payload, archive_root=archive_root) + if receipt_path != audit_adoption_receipt_path(archive_root): + raise MigrationError("audit adoption receipt path changed during recovery") + + initialize_missing_durable_tier( + audit_path, + ArchiveTier.AUDIT, + permit_established_archive=True, + prepare_initialized_image=prepare_initialized_image, + pre_publish_check=revalidate_before_publish, + ) + + +def recover_pending_audit_adoption(archive_root: Path) -> bool: + """Publish a receipt-backed missing audit file before startup classification.""" + archive_root = archive_root.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + audit_path = archive_root / "audit.db" + if receipt is None or audit_path.is_file(): + return False + if _latest_audit_adoption_continuity(archive_root) is not None: + raise MigrationError( + "adopted audit tier is missing after continuity was recorded; run maintenance migrate-tier audit " + "--restore-adopted-audit --backup-manifest /manifest.json" + ) + receipt_path, payload = receipt + _recover_pending_audit_adoption(archive_root, receipt_path, payload) + return True + + +def validate_audit_adoption_receipt(archive_root: Path, *, require_initial_image: bool = False) -> Path | None: + """Validate a present adoption receipt before startup consumes its audit tier.""" + archive_root = archive_root.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + if receipt is None: + return None + receipt_path, payload = receipt + expected_image_sha256, expected_image_size, expected_application_id = _audit_adoption_image_binding(payload) + expected_initial_version = payload.get("audit_user_version") + if not isinstance(expected_initial_version, int): + raise MigrationError("audit adoption receipt lacks its initial audit schema version") + audit_path = archive_root / "audit.db" + continuity = _latest_audit_adoption_continuity(archive_root) + if not audit_path.is_file(): + if continuity is not None: + raise MigrationError( + "adopted audit tier is missing after continuity was recorded; run maintenance migrate-tier audit " + "--restore-adopted-audit --backup-manifest /manifest.json" + ) + _recover_pending_audit_adoption(archive_root, receipt_path, payload) + require_initial_image = True + initial_file_identity: tuple[int, int] | None = None + seeded_adoption_head = False + if continuity is None: + receipt_sha256 = payload.get("receipt_sha256") + if not isinstance(receipt_sha256, str): + raise MigrationError("audit adoption receipt lacks its checksum") + coordinator = AuditContinuityCoordinator(archive_root) + if coordinator.is_available(): + mutation_id = f"audit-adoption:{receipt_sha256}" + seeded_adoption_head = coordinator.has_committed_mutation(mutation_id) or coordinator.has_pending_rebind( + mutation_id + ) + if (continuity is None and not seeded_adoption_head) or require_initial_image: + initial_file_identity = _validate_initial_audit_image( + audit_path, + expected_image_sha256=expected_image_sha256, + expected_image_size=expected_image_size, + expected_application_id=expected_application_id, + expected_initial_version=expected_initial_version, + ) + else: + version, application_id, quick_check = _audit_live_metadata(audit_path) + if version < expected_initial_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("audit adoption receipt does not match the live audit tier") + _validate_audit_adoption_continuity( + archive_root, + receipt_payload=payload, + expected_initial_file_identity=initial_file_identity, + ) + return receipt_path + + +def adopt_missing_audit_tier( + path: Path, + *, + backup_manifest: Path, + directory_fd: int, + stopped_daemon_check: Callable[[], str], +) -> tuple[int, Path]: + """Adopt canonical ``audit.db`` into an established, offline archive. + + The receipt is published first, so a crash cannot leave an unproven audit + tier. It names the authenticated full-evidence backup and expected + canonical image; startup validates that immutable intent against the + linked database before accepting it. + """ + if path.name != "audit.db": + raise MigrationError(f"established-archive adoption is only supported for audit.db: {path}") + archive_root = path.parent.resolve() + receipt_path = audit_adoption_receipt_path(archive_root) + if _load_audit_adoption_receipt(archive_root) is not None: + validate_audit_adoption_receipt(archive_root) + return _audit_live_metadata(path)[0], receipt_path + if path.exists() or path.is_symlink(): + raise MigrationError(f"audit tier already exists; refusing established-archive adoption: {path}") + stopped_evidence = stopped_daemon_check() + manifest_path, verification_receipt = validate_full_evidence_backup_for_audit_adoption( + backup_manifest, + archive_root=archive_root, + ) + initial_authority_digest = _audit_adoption_authority_digest(archive_root) + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + verification_receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() + application_id = ( + int.from_bytes(hashlib.sha256(f"{initial_authority_digest}:{manifest_sha256}".encode()).digest()[:4], "big") + & 0x7FFFFFFF + ) + if application_id == 0: + application_id = 1 + payload: dict[str, object] = {} + + def prepare_initialized_image(connection: sqlite3.Connection) -> None: + connection.execute(f"PRAGMA application_id = {application_id}") + + def revalidate_before_publish(initialized_image: bytes) -> None: + if path.exists() or path.is_symlink(): + raise MigrationError(f"audit tier appeared during established-archive adoption: {path}") + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed during audit adoption") + validate_full_evidence_backup_for_audit_adoption(backup_manifest, archive_root=archive_root) + if _audit_adoption_authority_digest(archive_root) != initial_authority_digest: + raise MigrationError("source/user authority changed during audit adoption") + payload.update( + { + "format": _AUDIT_ADOPTION_RECEIPT_FORMAT, + "source_user_authority_digest": initial_authority_digest, + "backup_manifest": str(manifest_path.resolve()), + "backup_manifest_sha256": manifest_sha256, + "backup_verification_receipt": str(verification_receipt.resolve()), + "backup_verification_receipt_sha256": verification_receipt_sha256, + "stopped_daemon_evidence_ref": stopped_evidence, + "single_writer_evidence_ref": "proof:archive-ownership-lock", + "audit_schema_inventory_sha256": _audit_schema_inventory_sha256(), + "audit_user_version": ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT], + "audit_application_id": application_id, + "audit_image_sha256": hashlib.sha256(initialized_image).hexdigest(), + "audit_image_size": len(initialized_image), + } + ) + _write_immutable_audit_adoption_receipt( + receipt_path, + payload, + archive_root=archive_root, + archive_directory_fd=directory_fd, + ) + + version = initialize_missing_durable_tier( + path, + ArchiveTier.AUDIT, + directory_fd=directory_fd, + permit_established_archive=True, + prepare_initialized_image=prepare_initialized_image, + pre_publish_check=revalidate_before_publish, + ) + validate_audit_adoption_receipt(archive_root, require_initial_image=True) + return version, receipt_path + + +def _audit_restore_artifact_binding(receipt_path: Path) -> tuple[str, int, int]: + """Read the audit artifact facts after receipt authentication succeeded.""" + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise MigrationError("cannot read adopted-audit restore verification receipt") from exc + artifacts = receipt.get("tier_artifacts") if isinstance(receipt, dict) else None + audit = ( + next((item for item in artifacts if isinstance(item, dict) and item.get("tier") == "audit"), None) + if isinstance(artifacts, list) + else None + ) + if not isinstance(audit, dict): + raise MigrationError("adopted-audit restore receipt lacks audit artifact evidence") + sha256, size, version = audit.get("sha256"), audit.get("size_bytes"), audit.get("user_version") + if not isinstance(sha256, str) or not isinstance(size, int) or not isinstance(version, int): + raise MigrationError("adopted-audit restore receipt has invalid audit artifact evidence") + return sha256, size, version + + +def _copy_restore_artifact(source: Path, *, directory_fd: int, temporary_name: str, sha256: str, size: int) -> None: + """Copy an exact no-follow, unlinked backup artifact into the owned root.""" + source_fd: int | None = None + target_fd: int | None = None + try: + source_fd = os.open(source, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)) + source_metadata = os.fstat(source_fd) + if not stat.S_ISREG(source_metadata.st_mode) or source_metadata.st_nlink != 1: + raise MigrationError("adopted-audit restore artifact is not an unlinked regular file") + target_fd = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), + 0o600, + dir_fd=directory_fd, + ) + digest = hashlib.sha256() + copied = 0 + while chunk := os.read(source_fd, 1024 * 1024): + digest.update(chunk) + copied += len(chunk) + offset = 0 + while offset < len(chunk): + written = os.write(target_fd, chunk[offset:]) + if written <= 0: + raise MigrationError("adopted-audit restore artifact copy made no progress") + offset += written + if copied != size or digest.hexdigest() != sha256: + raise MigrationError("adopted-audit restore artifact changed while it was copied") + os.fsync(target_fd) + finally: + if target_fd is not None: + os.close(target_fd) + if source_fd is not None: + os.close(source_fd) + + +def _remove_stale_restore_staging(*, directory_fd: int, temporary_name: str) -> None: + """Remove one prior crash's private restore image before retrying its intent.""" + try: + metadata = os.stat(temporary_name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.geteuid() + or stat.S_IMODE(metadata.st_mode) & 0o077 + ): + raise MigrationError(f"invalid stale adopted-audit restore staging file: {temporary_name}") + os.unlink(temporary_name, dir_fd=directory_fd) + os.fsync(directory_fd) + + +def _remove_owned_audit_sidecars(*, directory_fd: int) -> None: + """Remove only audit.db's SQLite sidecars at the restore publication boundary.""" + + removed = False + for suffix in ("-wal", "-shm", "-journal"): + try: + os.unlink(f"audit.db{suffix}", dir_fd=directory_fd) + except FileNotFoundError: + continue + except OSError as exc: + raise MigrationError(f"cannot remove owned audit restore sidecar: audit.db{suffix}") from exc + removed = True + if removed: + os.fsync(directory_fd) + + +def _audit_file_matches_artifact(path: Path, *, sha256: str, size: int) -> bool: + """Check whether an interrupted restore already published the intended image.""" + try: + _audit_file_identity(path) + if path.stat().st_size == size: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() == sha256 + except OSError: + return False + return False + + +def restore_adopted_audit_tier( + path: Path, + *, + backup_manifest: Path, + directory_fd: int, + stopped_daemon_check: Callable[[], str], +) -> Path: + """Restore adopted ``audit.db`` and append its new continuity generation.""" + if path.name != "audit.db": + raise MigrationError(f"adopted-audit restore is only supported for audit.db: {path}") + archive_root = path.parent.resolve() + receipt = _load_audit_adoption_receipt(archive_root) + if receipt is None: + raise MigrationError("adopted-audit restore requires an existing audit adoption receipt") + _receipt_path, adoption = receipt + continuity = _latest_audit_adoption_continuity(archive_root, allow_incomplete_restore=True) + if continuity is None or continuity.get("receipt_sha256") != adoption.get("receipt_sha256"): + raise MigrationError("adopted-audit restore requires completed continuity for this adoption receipt") + stopped_evidence = stopped_daemon_check() + restore_records = _audit_restore_records(archive_root) + committed_restore_operations = { + (payload.get("generation"), payload.get("operation_id")) + for _path, payload in restore_records + if payload.get("state") == "committed" + } + pending_restore_operation_ids: list[str] = [] + pending_restore: dict[str, object] | None = None + for _path, payload in restore_records: + if payload.get("state") != "prepared": + continue + operation_id = payload.get("operation_id") + if (payload.get("generation"), operation_id) in committed_restore_operations: + continue + if not isinstance(operation_id, str): + raise MigrationError("adopted-audit restore has an invalid incomplete continuity record") + pending_restore_operation_ids.append(operation_id) + pending_restore = payload + if len(pending_restore_operation_ids) > 1: + raise MigrationError("adopted-audit restore has multiple or invalid incomplete continuity records") + has_pending_restore = bool(pending_restore_operation_ids) + source_continuity_rebind_mutation_id = ( + f"audit-restore:{pending_restore_operation_ids[0]}" if has_pending_restore else None + ) + manifest_path, verification_receipt = validate_full_evidence_backup_for_adopted_audit_restore( + backup_manifest, + archive_root=archive_root, + allow_source_continuity_rebind=has_pending_restore, + source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, + source_continuity_rebind_prepared_restore=pending_restore, + ) + artifact_sha256, artifact_size, artifact_version = _audit_restore_artifact_binding(verification_receipt) + expected_application_id = adoption.get("audit_application_id") + expected_initial_version = adoption.get("audit_user_version") + if not isinstance(expected_application_id, int) or not isinstance(expected_initial_version, int): + raise MigrationError("audit adoption receipt lacks its durable SQLite markers") + backup_version, backup_application_id, backup_quick_check = _audit_live_metadata(manifest_path.parent / "audit.db") + if ( + backup_version != artifact_version + or backup_version != ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + or backup_application_id != expected_application_id + or backup_quick_check != ("ok",) + ): + raise MigrationError("adopted-audit restore artifact does not belong to this audit adoption") + manifest_sha256 = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + verification_receipt_sha256 = hashlib.sha256(verification_receipt.read_bytes()).hexdigest() + + def revalidate_exact_backup() -> None: + current_manifest, current_receipt = validate_full_evidence_backup_for_adopted_audit_restore( + backup_manifest, + archive_root=archive_root, + allow_source_continuity_rebind=has_pending_restore, + source_continuity_rebind_mutation_id=source_continuity_rebind_mutation_id, + source_continuity_rebind_prepared_restore=pending_restore, + ) + if ( + current_manifest.resolve() != manifest_path.resolve() + or current_receipt.resolve() != verification_receipt.resolve() + or hashlib.sha256(current_manifest.read_bytes()).hexdigest() != manifest_sha256 + or hashlib.sha256(current_receipt.read_bytes()).hexdigest() != verification_receipt_sha256 + ): + raise MigrationError("adopted-audit restore backup changed during the operation") + + previous_continuity_sha256 = continuity.get("continuity_sha256") + if not isinstance(previous_continuity_sha256, str): + raise MigrationError("adopted-audit restore continuity lacks its checksum") + committed_generations: list[int] = [] + pending_records: list[tuple[Path, dict[str, object]]] = [] + committed_operations: set[tuple[int, str]] = set() + for record_path, payload in restore_records: + generation_value = payload.get("generation") + if payload.get("state") == "committed" and isinstance(generation_value, int): + committed_generations.append(generation_value) + operation_value = payload.get("operation_id") + if isinstance(operation_value, str): + committed_operations.add((generation_value, operation_value)) + elif payload.get("state") == "prepared": + pending_records.append((record_path, payload)) + unresolved_records: list[tuple[Path, dict[str, object]]] = [] + for record_path, payload in pending_records: + generation_value = payload.get("generation") + operation_value = payload.get("operation_id") + if not isinstance(generation_value, int) or not isinstance(operation_value, str): + raise MigrationError("incomplete adopted-audit restore has invalid identity fields") + if (generation_value, operation_value) not in committed_operations: + unresolved_records.append((record_path, payload)) + pending_records = unresolved_records + if len(pending_records) > 1: + raise MigrationError("adopted-audit restore has multiple incomplete continuity records") + if pending_records: + _pending_path, pending_payload = pending_records[0] + generation_value = pending_payload.get("generation") + operation_value = pending_payload.get("operation_id") + assert isinstance(generation_value, int) + assert isinstance(operation_value, str) + generation = generation_value + operation_id = operation_value + else: + generation = 1 + max(committed_generations, default=0) + operation_id = secrets.token_hex(16) + existing_rebind_created_at_ms = pending_restore.get("rebind_created_at_ms") if pending_restore is not None else None + if existing_rebind_created_at_ms is not None and not isinstance(existing_rebind_created_at_ms, int): + raise MigrationError("incomplete adopted-audit restore lacks deterministic rebind timing evidence") + rebind_created_at_ms = ( + existing_rebind_created_at_ms if isinstance(existing_rebind_created_at_ms, int) else int(time.time() * 1000) + ) + base_payload: dict[str, object] = { + "format": _AUDIT_ADOPTION_RESTORE_FORMAT, + "generation": generation, + "operation_id": operation_id, + "previous_continuity_sha256": previous_continuity_sha256, + "receipt_sha256": adoption["receipt_sha256"], + "source_user_authority_digest": adoption["source_user_authority_digest"], + "backup_manifest_sha256": manifest_sha256, + "backup_verification_receipt_sha256": verification_receipt_sha256, + "audit_artifact_sha256": artifact_sha256, + "audit_artifact_size": artifact_size, + "audit_artifact_user_version": artifact_version, + "rebind_created_at_ms": rebind_created_at_ms, + "stopped_daemon_evidence_ref": stopped_evidence, + "single_writer_evidence_ref": "proof:archive-ownership-lock", + } + if pending_records: + prepared_path, prepared = pending_records[0] + expected_prepared = {**base_payload, "state": "prepared"} + if any(prepared.get(key) != value for key, value in expected_prepared.items()): + raise MigrationError("incomplete adopted-audit restore does not match the supplied verified backup") + else: + prepared_path = _audit_adoption_continuity_path(archive_root).with_name( + f"audit-restore.{generation}.{operation_id}.prepared.json" + ) + prepared = {**base_payload, "state": "prepared"} + _write_immutable_audit_adoption_receipt( + prepared_path, + prepared, + archive_root=archive_root, + archive_directory_fd=directory_fd, + checksum_key="restore_sha256", + ) + temporary_name = f".audit.db.restore-{operation_id}.tmp" + _remove_stale_restore_staging(directory_fd=directory_fd, temporary_name=temporary_name) + rebind_mutation_id = f"audit-restore:{operation_id}" + coordinator = AuditContinuityCoordinator(archive_root) + rebind_already_committed = False + published = False + try: + if _audit_file_matches_artifact(archive_root / "audit.db", sha256=artifact_sha256, size=artifact_size): + published = True + else: + _copy_restore_artifact( + manifest_path.parent / "audit.db", + directory_fd=directory_fd, + temporary_name=temporary_name, + sha256=artifact_sha256, + size=artifact_size, + ) + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed during adopted-audit restore") + revalidate_exact_backup() + if _audit_adoption_authority_digest(archive_root) != adoption.get("source_user_authority_digest"): + raise MigrationError("source/user authority changed during adopted-audit restore") + _remove_owned_audit_sidecars(directory_fd=directory_fd) + if not published: + os.replace(temporary_name, path.name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) + os.fsync(directory_fd) + published = True + if _audit_file_sha256(path) != artifact_sha256: + raise MigrationError("adopted-audit restore published image changed before continuity rebind") + identity = _audit_file_identity(path) + version, application_id, quick_check = _audit_live_metadata(path) + if version != artifact_version or application_id != expected_application_id or quick_check != ("ok",): + raise MigrationError("adopted-audit restore published artifact is not the verified SQLite image") + committed_path = prepared_path.with_name(prepared_path.name.replace(".prepared.json", ".committed.json")) + prepared_restore_sha256 = prepared.get("restore_sha256") + if not isinstance(prepared_restore_sha256, str): + prepared_restore_sha256 = _canonical_json_sha256(prepared) + prepared_rebind_created_at_ms = prepared.get("rebind_created_at_ms") + if not isinstance(prepared_rebind_created_at_ms, int): + raise MigrationError("adopted-audit restore lacks deterministic rebind timing evidence") + rebind_mutation = AuditMutation( + "rebind", + rebind_mutation_id, + prepared_rebind_created_at_ms, + { + "kind": "verified_restore", + "prepared_restore_sha256": prepared_restore_sha256, + "audit_image_sha256": artifact_sha256, + }, + ) + if has_pending_restore: + with closing(sqlite3.connect(manifest_path.parent / "source.db")) as backup_source: + backup_head = backup_source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if backup_head is None: + raise MigrationError("adopted-audit restore backup lacks source continuity control") + try: + rebind_already_committed = coordinator.reconcile_restore_rebind( + rebind_mutation, + prior_generation=int(backup_head[0]), + prior_head_sha256=str(backup_head[1]), + ) + except AuditContinuityError as exc: + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") from exc + if stopped_daemon_check() != stopped_evidence: + raise MigrationError("daemon stopped proof changed after adopted-audit restore publication") + revalidate_exact_backup() + committed = { + **base_payload, + "state": "committed", + "prepared_restore_sha256": prepared_restore_sha256, + "audit_device": identity[0], + "audit_inode": identity[1], + "audit_image_sha256": artifact_sha256, + } + committed["continuity_sha256"] = _canonical_json_sha256(committed) + if not rebind_already_committed: + coordinator.seed_or_rebind( + mutation_id=rebind_mutation_id, + now_ms=rebind_mutation.created_at_ms, + evidence=rebind_mutation.payload, + ) + _write_immutable_audit_adoption_receipt( + committed_path, + committed, + archive_root=archive_root, + archive_directory_fd=directory_fd, + checksum_key="continuity_sha256", + ) + return committed_path + finally: + if not published: + with suppress(FileNotFoundError): + os.unlink(temporary_name, dir_fd=directory_fd) + os.fsync(directory_fd) + + def execute_durable_change_train( archive_root: Path, tier: ArchiveTier, @@ -444,8 +1602,14 @@ def reconcile_durable_change_trains_on_startup(root: Path) -> tuple[Path, ...]: __all__ = [ "acquire_durable_archive_ownership", + "adopt_missing_audit_tier", + "audit_adoption_receipt_path", + "AuditContinuityError", "ArchiveOwnershipError", "execute_durable_change_train", "initialize_missing_durable_tier", "reconcile_durable_change_trains_on_startup", + "recover_pending_audit_adoption", + "restore_adopted_audit_tier", + "validate_audit_adoption_receipt", ] diff --git a/polylogue/operations/mutation_actuators.py b/polylogue/operations/mutation_actuators.py index 424def4726..7248d0972c 100644 --- a/polylogue/operations/mutation_actuators.py +++ b/polylogue/operations/mutation_actuators.py @@ -39,6 +39,7 @@ build_plan, make_target_ref, ) +from polylogue.security.lifecycle import LifecycleMode from polylogue.storage.sqlite.connection_profile import open_connection if TYPE_CHECKING: @@ -214,6 +215,67 @@ def apply(self, plan: MutationPlan, args: SessionExcisionArgs) -> MutationReceip ) +# --------------------------------------------------------------------------- +# Lifecycle request (mutate-session-lifecycle-request) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class SessionLifecycleRequestArgs: + """Arguments for the durable mirror/primary excision-request outbox row.""" + + archive_root: Path + session_id: str + mode: LifecycleMode + reason: str + actor: str + now_ms: int + + +@dataclass(frozen=True, slots=True) +class SessionLifecycleRequestActuator: + """Create the local lifecycle request through the audit-backed executor.""" + + operation: str = "mutate-session-lifecycle-request" + destructive_class: DestructiveClass = "additive" + required_confirmation: ConfirmationStrength = "confirm_flag" + + def prepare(self, args: SessionLifecycleRequestArgs) -> MutationPlan: + return build_plan( + operation=self.operation, + destructive_class=self.destructive_class, + target_refs=(make_target_ref("session", args.session_id),), + affected_tiers=("user",), + reversible=True, + context={"mode": args.mode, "reason": args.reason}, + ) + + def apply(self, plan: MutationPlan, args: SessionLifecycleRequestArgs) -> MutationReceipt: + from polylogue.security.lifecycle import submit_lifecycle_request_with_outcome + + user_db = args.archive_root / "user.db" + with sqlite3.connect(user_db) as connection: + submission = submit_lifecycle_request_with_outcome( + connection, + target_ref=make_target_ref("session", args.session_id), + mode=args.mode, + reason=args.reason, + actor=args.actor, + now_ms=args.now_ms, + ) + return MutationReceipt( + operation=self.operation, + plan_hash=plan.plan_hash, + status="applied" if submission.created else "already_satisfied", + target_refs=plan.target_refs, + affected_count=1 if submission.created else 0, + detail=None, + receipt_ref=submission.assertion_id, + applied_at=plan.prepared_at, + domain_receipt={"assertion_id": submission.assertion_id, "mode": args.mode}, + ) + + # --------------------------------------------------------------------------- # Derived reset / identity tombstone (mutate-identity-reset) # --------------------------------------------------------------------------- @@ -2026,6 +2088,8 @@ def _resolve_session_id(archive: ArchiveStore, session_id: str) -> tuple[str, .. "SessionDeleteArgs", "SessionExcisionActuator", "SessionExcisionArgs", + "SessionLifecycleRequestActuator", + "SessionLifecycleRequestArgs", "TagAddActuator", "TagAddArgs", "TagRemoveActuator", diff --git a/polylogue/operations/mutation_transaction.py b/polylogue/operations/mutation_transaction.py index 202bdf4911..c9a1267971 100644 --- a/polylogue/operations/mutation_transaction.py +++ b/polylogue/operations/mutation_transaction.py @@ -44,6 +44,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field, replace from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, runtime_checkable if TYPE_CHECKING: @@ -225,6 +226,19 @@ def compute_target_digest(targets: tuple[MutationTarget, ...]) -> str: return _sha256_document([target.canonical_dict() for target in targets]) +def compute_parameter_digest(raw_plan: MutationPlan) -> str: + """Hash stable caller intent without the clock-bound preview envelope.""" + + return _sha256_document( + { + "operation": raw_plan.operation, + "destructive_class": raw_plan.destructive_class, + "affected_tiers": list(raw_plan.affected_tiers), + "context": {key: raw_plan.context[key] for key in sorted(raw_plan.context)}, + } + ) + + def compute_typed_plan_hash( *, operation: str, @@ -237,6 +251,7 @@ def compute_typed_plan_hash( destructive_class: DestructiveClass, required_confirmation: ConfirmationStrength, affected_tiers: tuple[str, ...], + context: Mapping[str, object], ) -> str: """Hash every authority-relevant field of a typed mutation plan.""" @@ -252,6 +267,7 @@ def compute_typed_plan_hash( "destructive_class": destructive_class, "required_confirmation": required_confirmation, "affected_tiers": list(affected_tiers), + "context": {key: context[key] for key in sorted(context)}, } ) @@ -363,6 +379,7 @@ def build_typed_plan( required_confirmation: ConfirmationStrength, prepared_at_ms: int, expires_at_ms: int, + context: Mapping[str, object] | None = None, ) -> MutationPlan: """Construct a plan whose hash covers the complete typed authority input.""" @@ -378,6 +395,7 @@ def build_typed_plan( destructive_class=destructive_class, required_confirmation=required_confirmation, affected_tiers=affected_tiers, + context=context or {}, ) return MutationPlan( operation=operation, @@ -387,6 +405,7 @@ def build_typed_plan( reversible=destructive_class in {"additive", "reversible"}, prepared_at=datetime.fromtimestamp(prepared_at_ms / 1000, UTC).isoformat(), plan_hash=plan_hash, + context=dict(context or {}), operation_version=operation_version, archive_instance_id=archive_instance_id, archive_identity_digest=archive_identity_digest, @@ -400,6 +419,28 @@ def build_typed_plan( ) +def validate_mutation_plan_integrity(plan: MutationPlan) -> None: + """Reject a reconstructed preview whose typed authority fields were changed.""" + + target_refs = tuple(target.ref for target in plan.targets) + target_digest = compute_target_digest(plan.targets) + plan_hash = compute_typed_plan_hash( + operation=plan.operation, + operation_version=plan.operation_version, + archive_instance_id=plan.archive_instance_id, + archive_identity_digest=plan.archive_identity_digest, + parameter_digest=plan.parameter_digest, + target_digest=target_digest, + required_capabilities=plan.required_capabilities, + destructive_class=plan.destructive_class, + required_confirmation=plan.required_confirmation, + affected_tiers=plan.affected_tiers, + context=plan.context, + ) + if plan.target_refs != target_refs or plan.target_digest != target_digest or plan.plan_hash != plan_hash: + raise AuthorizationMismatchError("preview plan payload does not match its authority hash") + + def build_plan( *, operation: str, @@ -546,10 +587,32 @@ def __init__( audit: AuditRepository | None = None, now_ms: Callable[[], int] | None = None, token_factory: Callable[[], str] | None = None, + archive_root: Path | None = None, ) -> None: self._audit = audit self._now_ms = now_ms or (lambda: int(datetime.now(UTC).timestamp() * 1000)) self._token_factory = token_factory or (lambda: secrets.token_urlsafe(32)) + self._archive_root = archive_root + + @classmethod + def for_archive_root( + cls, + archive_root: Path, + *, + now_ms: Callable[[], int] | None = None, + token_factory: Callable[[], str] | None = None, + ) -> OperationExecutor: + """Compose production mutation execution with the archive's audit tier.""" + + from polylogue.operations.audit import AuditRepository + + audit = AuditRepository.for_archive_root( + archive_root, + attempt_owner_id=AuditRepository.current_process_attempt_owner(), + ) + audit.reconcile_continuity() + audit.recover_abandoned_attempts() + return cls(audit=audit, now_ms=now_ms, token_factory=token_factory, archive_root=archive_root) def prepare(self, actuator: MutationActuator[ArgsT], args: ArgsT) -> MutationPlan: """PREPARE: resolve exact targets from live state. Never mutates.""" @@ -566,6 +629,7 @@ def prepare_bound( archive_identity_digest: str, parameter_digest: str, expires_at_ms: int | None = None, + raw_plan: MutationPlan | None = None, ) -> MutationPreview: """Prepare and durably record a versioned, capability-bound preview.""" @@ -574,7 +638,7 @@ def prepare_bound( raise SurfaceDeniedError(f"{binding.spec.name!r} is not allowed on {principal.surface!r}") plan = self._typed_plan_from_actuator( binding, - binding.actuator.prepare(args), + raw_plan or binding.actuator.prepare(args), archive_instance_id=archive_instance_id, archive_identity_digest=archive_identity_digest, parameter_digest=parameter_digest, @@ -585,6 +649,31 @@ def prepare_bound( preview_ref = self._audit.create_preview(plan, principal) return MutationPreview(preview_ref=preview_ref, plan=plan) + def prepare_bound_for_archive( + self, + binding: OperationBinding[ArgsT, object], + args: ArgsT, + principal: MutationPrincipal, + *, + archive_root: Path, + ) -> MutationPreview: + """Prepare a production mutation with live archive and audit authority.""" + + if self._audit is None: + raise MutationTransactionError("production mutation preparation requires a durable audit repository") + from polylogue.storage.archive_identity import ArchiveIdentity + + raw_plan = binding.actuator.prepare(args) + return self.prepare_bound( + binding, + args, + principal, + archive_instance_id=self._audit.ensure_archive_authority(now_ms=self._now_ms()), + archive_identity_digest=ArchiveIdentity.resolve(archive_root).authority_identity_digest, + parameter_digest=compute_parameter_digest(raw_plan), + raw_plan=raw_plan, + ) + def authorize_bound( self, binding: OperationBinding[ArgsT, object], @@ -596,6 +685,7 @@ def authorize_bound( """Issue a random one-time token bound to the persisted preview.""" binding.validate() + validate_mutation_plan_integrity(preview.plan) plan = preview.plan required = set(plan.required_capabilities) if not required.issubset(principal.capabilities): @@ -623,7 +713,9 @@ def authorize_bound( surface=principal.surface, ) if self._audit is not None: - authorization_id = self._audit.issue_authorization(preview, principal, authorization) + authorization_id = self._audit.issue_authorization( + preview, principal, authorization, issued_at_ms=self._now_ms() + ) authorization = replace(authorization, authorization_id=authorization_id) return authorization @@ -637,10 +729,21 @@ def execute_bound( """Consume a bound token, journal intent, apply, and finalize honestly.""" binding.validate() + validate_mutation_plan_integrity(preview.plan) if authorization.preview_ref != preview.preview_ref or authorization.token is None: raise AuthorizationMismatchError("authorization is not bound to this preview") - if authorization.expires_at_ms is not None and self._now_ms() >= authorization.expires_at_ms: + if ( + self._audit is None + and authorization.expires_at_ms is not None + and self._now_ms() >= authorization.expires_at_ms + ): raise TokenExpiredError("authorization token is expired") + if self._archive_root is not None: + from polylogue.storage.archive_identity import ArchiveIdentity + + live_identity = ArchiveIdentity.resolve(self._archive_root).authority_identity_digest + if live_identity != preview.plan.archive_identity_digest: + raise PlanStaleError("archive identity changed after the bound preview was prepared") fresh_plan = self._typed_plan_from_actuator( binding, binding.actuator.prepare(args), @@ -702,6 +805,16 @@ def reconcile_operation( reason=reason, ) + def find_interrupted_operation(self, *, operation_name: str, parameter_digest: str) -> str | None: + """Find the uniquely identified interrupted durable attempt for a recovery route.""" + + if self._audit is None: + raise MutationTransactionError("interrupted-operation lookup requires a durable audit repository") + return self._audit.find_interrupted_operation( + operation_name=operation_name, + parameter_digest=parameter_digest, + ) + def _typed_plan_from_actuator( self, binding: OperationBinding[ArgsT, object], @@ -774,6 +887,7 @@ def _typed_plan_from_actuator( required_confirmation=required_confirmation, prepared_at_ms=self._now_ms(), expires_at_ms=expires_at_ms, + context=plan.context, ) def authorize( @@ -870,8 +984,10 @@ def make_target_ref(kind: Literal["session", "message", "block", "source", "inde "TokenExpiredError", "build_plan", "build_typed_plan", + "compute_parameter_digest", "compute_plan_hash", "compute_target_digest", "compute_typed_plan_hash", "make_target_ref", + "validate_mutation_plan_integrity", ] diff --git a/polylogue/operations/specs.py b/polylogue/operations/specs.py index 3abb4912d1..0b56d1a38d 100644 --- a/polylogue/operations/specs.py +++ b/polylogue/operations/specs.py @@ -2,12 +2,13 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum from functools import lru_cache from typing import Literal from polylogue.core.json import JSONDocument, JSONDocumentList, json_document +from polylogue.core.user_state_targets import TARGET_KIND_NAMES from polylogue.operations.mutation_transaction import ( IdempotencyPolicy, Surface, @@ -849,6 +850,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbWrite",), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("internal",), + target_authority=( + TargetAuthorityPolicy( + key="annotation-import", + target_kinds=("annotation-batch", "assertion"), + required_capabilities=("archive.annotation.import_batch",), + destructive_class="reversible", + required_confirmation="role_only", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), ), OperationSpec( name="mutate-rebuild-index", @@ -872,6 +885,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="index-rebuild", + target_kinds=("source",), + required_capabilities=("archive.rebuild_index",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-update-index", @@ -895,6 +920,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="index-update", + target_kinds=("source",), + required_capabilities=("archive.update_index",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-rebuild-insights", @@ -918,6 +955,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite"), safety_guards=("write_role_required",), executor_status="executor-routed", + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key="insights-rebuild", + target_kinds=("session",), + required_capabilities=("archive.rebuild_insights",), + destructive_class="maintenance", + required_confirmation="role_only", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-resolve-raw-authority-blocker", @@ -944,6 +993,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="raw-authority-blocker", + target_kinds=("raw-authority-blocker",), + required_capabilities=("archive.raw_authority.resolve_blocker",), + destructive_class="reset", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("reconcile_required",), + ), + ), ), OperationSpec( name="mutate-reset-raw-authority-census", @@ -969,6 +1030,7 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("maintenance",), target_authority=( TargetAuthorityPolicy( key="raw-authority-recovery-source", @@ -1004,6 +1066,7 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("maintenance",), target_authority=( TargetAuthorityPolicy( key="raw-authority-recovery-index", @@ -1257,6 +1320,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("api", "cli"), + target_authority=( + TargetAuthorityPolicy( + key="session-delete", + target_kinds=("session",), + required_capabilities=("archive.delete_session",), + destructive_class="delete", + required_confirmation="confirm_flag", + allowed_durabilities=("derived",), + allowed_recovery=("rebuild",), + ), + ), ), OperationSpec( name="mutate-session-excision", @@ -1282,6 +1357,54 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="session-excision", + target_kinds=("session",), + required_capabilities=("archive.excise_session",), + destructive_class="excise", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), + ), + OperationSpec( + name="mutate-session-lifecycle-request", + kind=OperationKind.MAINTENANCE, + description=( + "Create one durable mirror/primary session lifecycle-request outbox row through " + "OperationExecutor so its intent, authorization, and receipt share audit continuity authority." + ), + consumes=("archive_session_rows",), + produces=("excision_receipt",), + path_targets=("session-excision-loop",), + code_refs=( + "polylogue.cli.commands.excise.excise_command", + "polylogue.security.lifecycle.submit_lifecycle_request", + "polylogue.operations.mutation_actuators.SessionLifecycleRequestActuator", + ), + surfaces=("cli",), + mutates_state=True, + previewable=True, + idempotent=True, + effects=("DbRead", "DbWrite", "Destructive"), + safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), + executor_status="executor-routed", + allowed_surfaces=("cli",), + affected_tiers=("user", "audit"), + target_authority=( + TargetAuthorityPolicy( + key="session-lifecycle-request", + target_kinds=("session",), + required_capabilities=("archive.request_session_lifecycle",), + destructive_class="additive", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("retry_convergent",), + ), + ), ), OperationSpec( name="mutate-identity-reset", @@ -1307,6 +1430,18 @@ def to_dict(self) -> JSONDocumentList: effects=("DbRead", "DbWrite", "Destructive"), safety_guards=("write_role_required", "confirmed_before_execute", "explicit_dry_run_evidence"), executor_status="executor-routed", + allowed_surfaces=("cli",), + target_authority=( + TargetAuthorityPolicy( + key="identity-reset", + target_kinds=("session",), + required_capabilities=("archive.identity_reset",), + destructive_class="reset", + required_confirmation="confirm_flag", + allowed_durabilities=("durable",), + allowed_recovery=("reconcile_required",), + ), + ), ), OperationSpec( name="project-archive-readiness", @@ -1521,6 +1656,79 @@ def to_dict(self) -> JSONDocumentList: ) +_USER_MUTATION_TARGET_KINDS = ( + "annotation", + "assertion", + "blackboard", + "correction", + "recall_pack", + "saved_view", + "workspace", + *TARGET_KIND_NAMES, +) + +_LEGACY_EXECUTOR_CAPABILITIES: dict[str, str] = { + "mutate-add-tag": "archive.add_tag", + "mutate-remove-tag": "archive.remove_tag", + "mutate-bulk-tag-sessions": "archive.bulk_tag_sessions", + "mutate-set-metadata": "archive.set_metadata", + "mutate-delete-metadata": "archive.delete_metadata", + "mutate-add-mark": "archive.add_mark", + "mutate-remove-mark": "archive.remove_mark", + "mutate-save-annotation": "archive.save_annotation", + "mutate-delete-annotation": "archive.delete_annotation", + "mutate-blackboard-post": "archive.post_blackboard_note", + "mutate-capture-assertion-candidate": "archive.capture_assertion_candidate", + "mutate-save-saved-view": "archive.save_view", + "mutate-delete-saved-view": "archive.delete_view", + "mutate-save-recall-pack": "archive.create_recall_pack", + "mutate-delete-recall-pack": "archive.delete_recall_pack", + "mutate-save-workspace": "archive.save_workspace", + "mutate-delete-workspace": "archive.delete_workspace", + "mutate-record-correction": "archive.record_correction", + "mutate-delete-correction": "archive.delete_correction", + "mutate-clear-corrections": "archive.clear_corrections", +} + + +def _declare_executor_authority(specs: tuple[OperationSpec, ...]) -> tuple[OperationSpec, ...]: + """Give every executor route a specific capability and surface boundary.""" + + declared: list[OperationSpec] = [] + for spec in specs: + if spec.executor_status != "executor-routed" or spec.target_authority: + declared.append(spec) + continue + capability = _LEGACY_EXECUTOR_CAPABILITIES.get(spec.name) + if capability is None: + raise ValueError(f"executor-routed operation lacks target authority: {spec.name}") + declared.append( + replace( + spec, + allowed_surfaces=("api",), + target_authority=( + TargetAuthorityPolicy( + key=spec.name.removeprefix("mutate-"), + target_kinds=_USER_MUTATION_TARGET_KINDS, + required_capabilities=(capability,), + destructive_class="reversible", + required_confirmation="role_only", + allowed_durabilities=("durable",), + allowed_recovery=("none",), + ), + ), + ) + ) + return tuple(declared) + + +RUNTIME_OPERATION_SPECS = _declare_executor_authority(RUNTIME_OPERATION_SPECS) +DECLARED_OPERATION_SPECS = ( + *RUNTIME_OPERATION_SPECS, + *DECLARED_CONTROL_PLANE_OPERATION_SPECS, +) + + def _validate_executor_status() -> None: """t46.9 AC1: every mutating spec must declare an executor_status. diff --git a/polylogue/security/lifecycle.py b/polylogue/security/lifecycle.py index d61c246901..7fb4265bdb 100644 --- a/polylogue/security/lifecycle.py +++ b/polylogue/security/lifecycle.py @@ -146,6 +146,14 @@ class LifecycleRequestRow: history: tuple[Mapping[str, JSONValue], ...] +@dataclass(frozen=True, slots=True) +class LifecycleRequestSubmission: + """The durable lifecycle assertion and whether this call created it.""" + + assertion_id: str + created: bool + + def _request_assertion_id(target_ref: str, mode: str) -> str: digest = hashlib.sha256() for part in ("excision-request", target_ref, mode): @@ -169,11 +177,31 @@ def submit_lifecycle_request( target returns the same ``assertion_id`` and does not reset its state -- only :func:`drive_lifecycle_request` advances it. """ + return submit_lifecycle_request_with_outcome( + conn, + target_ref=target_ref, + mode=mode, + reason=reason, + actor=actor, + now_ms=now_ms, + ).assertion_id + + +def submit_lifecycle_request_with_outcome( + conn: sqlite3.Connection, + *, + target_ref: str, + mode: LifecycleMode, + reason: str, + actor: str = "user:local", + now_ms: int, +) -> LifecycleRequestSubmission: + """Create a request or report that its exact durable assertion already exists.""" from polylogue.storage.sqlite.archive_tiers.user_write import read_assertion_envelope, upsert_assertion assertion_id = _request_assertion_id(target_ref, mode) if read_assertion_envelope(conn, assertion_id) is not None: - return assertion_id + return LifecycleRequestSubmission(assertion_id=assertion_id, created=False) upsert_assertion( conn, assertion_id=assertion_id, @@ -195,7 +223,7 @@ def submit_lifecycle_request( context_policy={"inject": False}, now_ms=now_ms, ) - return assertion_id + return LifecycleRequestSubmission(assertion_id=assertion_id, created=True) def read_lifecycle_request(conn: sqlite3.Connection, assertion_id: str) -> LifecycleRequestRow | None: @@ -388,6 +416,7 @@ def apply_primary_invalidation_if_confirmed( "ExcisionLifecycleContract", "LifecycleInvalidationOutcome", "LifecycleRequestRow", + "LifecycleRequestSubmission", "SinexContractFake", "apply_primary_invalidation_if_confirmed", "drive_lifecycle_request", @@ -395,4 +424,5 @@ def apply_primary_invalidation_if_confirmed( "primary_may_invalidate_locally", "read_lifecycle_request", "submit_lifecycle_request", + "submit_lifecycle_request_with_outcome", ] diff --git a/polylogue/storage/sqlite/archive_tiers/audit.py b/polylogue/storage/sqlite/archive_tiers/audit.py index 01f43118de..c4de2a8f75 100644 --- a/polylogue/storage/sqlite/archive_tiers/audit.py +++ b/polylogue/storage/sqlite/archive_tiers/audit.py @@ -6,7 +6,9 @@ from __future__ import annotations -AUDIT_SCHEMA_VERSION = 1 +from polylogue.storage.sqlite.audit_continuity import AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 + +AUDIT_SCHEMA_VERSION = 2 AUDIT_DDL = """ CREATE TABLE IF NOT EXISTS archive_authority ( @@ -214,6 +216,22 @@ ) STRICT; CREATE INDEX IF NOT EXISTS idx_operation_events_type_time ON operation_events(event_type, occurred_at_ms); + +-- The audit head is deliberately independent of filesystem identity. source.db +-- records the authoritative committed generation; every audit mutation advances +-- this row in the same audit transaction as its domain rows. +CREATE TABLE IF NOT EXISTS audit_continuity_head ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + generation INTEGER NOT NULL CHECK(generation >= 0), + head_sha256 TEXT NOT NULL CHECK(length(head_sha256) = 64), + mutation_id TEXT, + advanced_at_ms INTEGER NOT NULL CHECK(advanced_at_ms >= 0) +) STRICT; +INSERT OR IGNORE INTO audit_continuity_head( + singleton, generation, head_sha256, mutation_id, advanced_at_ms +) VALUES (1, 0, '__AUDIT_CONTINUITY_GENESIS_HEAD__', NULL, 0); """ +AUDIT_DDL = AUDIT_DDL.replace("__AUDIT_CONTINUITY_GENESIS_HEAD__", AUDIT_CONTINUITY_GENESIS_HEAD_SHA256) + __all__ = ["AUDIT_DDL", "AUDIT_SCHEMA_VERSION"] diff --git a/polylogue/storage/sqlite/archive_tiers/bootstrap.py b/polylogue/storage/sqlite/archive_tiers/bootstrap.py index c085c85866..e124c0ff10 100644 --- a/polylogue/storage/sqlite/archive_tiers/bootstrap.py +++ b/polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -11,6 +11,7 @@ from polylogue.storage.sqlite.archive_tiers import ARCHIVE_DDL_BY_TIER, ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.index_convergence import apply_index_benign_ddl_convergence from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.audit_leaf import AuditLeafError, assert_verified_audit_leaf from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS from polylogue.storage.sqlite.sqlite_vec_extension import try_load_sqlite_vec @@ -311,6 +312,7 @@ def initialize_archive_database( def initialize_active_archive_root(root: Path) -> None: """Create or initialize every tier database in an archive root.""" + from polylogue.operations.durable_change_train import audit_adoption_receipt_path, recover_pending_audit_adoption from polylogue.storage.archive_identity import ( ArchiveLocation, OwnedArchiveLocation, @@ -334,6 +336,21 @@ def initialize_active_archive_root(root: Path) -> None: allow_reentrant=True, ) as owned: + def assert_regular_audit_leaf() -> None: + """Reject an audit pathname that could redirect durable authority outside this root.""" + + audit_path = root / archive_tier_spec(ArchiveTier.AUDIT).filename + try: + audit_path.lstat() + except FileNotFoundError: + return + except OSError as exc: + raise RuntimeError(f"cannot inspect audit tier leaf: {audit_path}") from exc + try: + assert_verified_audit_leaf(audit_path) + except AuditLeafError as exc: + raise RuntimeError(str(exc)) from exc + def assert_owned_root() -> None: """Refuse pathname writes after the owned root has been replaced.""" assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) @@ -341,14 +358,34 @@ def assert_owned_root() -> None: # Classify the archive after acquiring ownership. Another process may # publish a marker or durable train while the probe is in flight. assert_owned_root() + assert_regular_audit_leaf() durable_tier_exists = any( (root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS ) manifest_root = root / ".maintenance-state" / "durable-change-trains" - has_durable_train_state = any(manifest_root.glob("*.json")) + pending_audit_adoption = audit_adoption_receipt_path(root).exists() + has_durable_train_state = any( + path.name not in {"audit-adoption.json", "audit-continuity.json"} + and not path.name.startswith("audit-restore.") + for path in manifest_root.glob("*.json") + ) has_bootstrap_marker = (manifest_root / ".bootstrap").is_file() pending_bootstrap_path = manifest_root / ".bootstrap.pending" has_pending_bootstrap = pending_bootstrap_path.is_file() + + def classify_paths() -> tuple[bool, bool]: + durable_exists = any((root / archive_tier_spec(tier).filename).exists() for tier in DURABLE_MIGRATION_TIERS) + adoption = ( + (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() + and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) + and manifest_root.is_dir() + and not has_durable_train_state + and not has_bootstrap_marker + and not has_pending_bootstrap + ) + return durable_exists, adoption + + durable_tier_exists, pre_marker_adoption = classify_paths() if has_pending_bootstrap: _validate_fresh_durable_bootstrap_intent(root) if has_durable_train_state: @@ -365,23 +402,39 @@ def assert_owned_root() -> None: recovering_fresh_durable_bootstrap = fresh_durable_bootstrap or ( has_pending_bootstrap and not has_bootstrap_marker ) - pre_marker_adoption = ( - (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() - and all((root / archive_tier_spec(tier).filename).is_file() for tier in DURABLE_MIGRATION_TIERS) - and manifest_root.is_dir() - and not has_durable_train_state - and not has_bootstrap_marker - and not has_pending_bootstrap - ) if fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap_intent(root) + if pending_audit_adoption: + assert_owned_root() + recover_pending_audit_adoption(root) + # Receipt-backed recovery can add audit.db to a legacy archive. + # Recompute the path-sensitive classification before deciding + # whether startup must create the missing bootstrap marker. + durable_tier_exists, pre_marker_adoption = classify_paths() + established_archive = has_bootstrap_marker or ( + (root / archive_tier_spec(ArchiveTier.SOURCE).filename).is_file() + and (root / archive_tier_spec(ArchiveTier.USER).filename).is_file() + ) + if ( + durable_tier_exists + and not recovering_fresh_durable_bootstrap + and established_archive + and not (root / archive_tier_spec(ArchiveTier.AUDIT).filename).is_file() + ): + raise RuntimeError( + "established archive is missing audit.db; use maintenance migrate-tier audit " + "--adopt-established-audit with a verified full_evidence backup" + ) if not recovering_fresh_durable_bootstrap and not pre_marker_adoption: assert_owned_root() reconcile_durable_change_trains_on_startup(root) for spec in ARCHIVE_TIER_SPECS.values(): assert_owned_root() initialize_archive_database(root / spec.filename, spec.tier) + # Mutation composition performs source/audit reconciliation immediately + # before it consumes authority. Ordinary archive opens stay read-only + # with respect to continuity, including their steady-state path. if recovering_fresh_durable_bootstrap: assert_owned_root() _record_fresh_durable_bootstrap(root) diff --git a/polylogue/storage/sqlite/archive_tiers/source.py b/polylogue/storage/sqlite/archive_tiers/source.py index 1e0cd3750f..344f567149 100644 --- a/polylogue/storage/sqlite/archive_tiers/source.py +++ b/polylogue/storage/sqlite/archive_tiers/source.py @@ -20,8 +20,9 @@ ) from polylogue.storage.sqlite.archive_tiers.common import check, literal_check, nullable_check from polylogue.storage.sqlite.archive_tiers.types import ProvenRevisionAuthority +from polylogue.storage.sqlite.audit_continuity import AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 -SOURCE_SCHEMA_VERSION = 31 +SOURCE_SCHEMA_VERSION = 32 SOURCE_DDL = f""" CREATE TABLE IF NOT EXISTS raw_sessions ( @@ -857,6 +858,30 @@ PRIMARY KEY(blob_hash) ) STRICT; +-- The source tier is the durable cross-tier write-ahead command log for +-- audit.db. A singleton row gives exactly one committed head and at most one +-- canonical, replayable pending mutation. A pending command is never prose: +-- its JSON is the typed operation input used to complete an interrupted audit +-- write on startup. +CREATE TABLE IF NOT EXISTS audit_continuity_control ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + committed_generation INTEGER NOT NULL CHECK(committed_generation >= 0), + committed_head_sha256 TEXT NOT NULL CHECK(length(committed_head_sha256) = 64), + pending_mutation_id TEXT UNIQUE, + pending_payload_json TEXT, + pending_payload_sha256 TEXT CHECK(pending_payload_sha256 IS NULL OR length(pending_payload_sha256) = 64), + prepared_at_ms INTEGER, + CHECK( + (pending_mutation_id IS NULL AND pending_payload_json IS NULL AND pending_payload_sha256 IS NULL AND prepared_at_ms IS NULL) + OR + (pending_mutation_id IS NOT NULL AND pending_payload_json IS NOT NULL AND pending_payload_sha256 IS NOT NULL AND prepared_at_ms IS NOT NULL AND prepared_at_ms >= 0) + ) +) STRICT; +INSERT OR IGNORE INTO audit_continuity_control( + singleton, committed_generation, committed_head_sha256, + pending_mutation_id, pending_payload_json, pending_payload_sha256, prepared_at_ms +) VALUES (1, 0, '{AUDIT_CONTINUITY_GENESIS_HEAD_SHA256}', NULL, NULL, NULL, NULL); + """ __all__ = ["SOURCE_DDL", "SOURCE_SCHEMA_VERSION"] diff --git a/polylogue/storage/sqlite/audit_continuity.py b/polylogue/storage/sqlite/audit_continuity.py new file mode 100644 index 0000000000..109b530d60 --- /dev/null +++ b/polylogue/storage/sqlite/audit_continuity.py @@ -0,0 +1,731 @@ +"""Replayable cross-tier write-ahead control for durable ``audit.db`` writes. + +SQLite cannot atomically commit transactions spanning source.db and audit.db. +The source control row is therefore the authoritative write-ahead command: +prepare it in source.db, commit the audit mutation plus its head, then promote +the source head. Startup can complete the first two crash windows because the +pending row contains the exact typed command, and it rejects an audit image +whose head regressed after source promotion. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import stat +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import TypeVar, cast + +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + VerifiedAuditLeaf, + open_verified_audit_connection, + open_verified_audit_read_connection, + open_verified_sqlite_read_connection, + open_verified_sqlite_write_connection, +) + +_FORMAT = "polylogue.audit-continuity-command.v1" +AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 = "3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f" +_SOURCE_CONTINUITY_SCHEMA_VERSION = 32 +_AUDIT_CONTINUITY_SCHEMA_VERSION = 2 +_T = TypeVar("_T") + + +class AuditContinuityError(RuntimeError): + """Audit and source durable control state cannot prove one continuity head.""" + + +@dataclass(frozen=True, slots=True) +class AuditMutation: + """One typed audit command with generated identity and replay inputs.""" + + kind: str + mutation_id: str + created_at_ms: int + payload: Mapping[str, object] + + def command(self) -> dict[str, object]: + return { + "kind": self.kind, + "mutation_id": self.mutation_id, + "created_at_ms": self.created_at_ms, + "payload": dict(self.payload), + } + + @classmethod + def from_command(cls, raw: object) -> AuditMutation: + if not isinstance(raw, dict): + raise AuditContinuityError("pending audit continuity command is not an object") + kind = raw.get("kind") + mutation_id = raw.get("mutation_id") + created_at_ms = raw.get("created_at_ms") + payload = raw.get("payload") + if ( + not isinstance(kind, str) + or not kind + or not isinstance(mutation_id, str) + or not mutation_id + or not isinstance(created_at_ms, int) + or created_at_ms < 0 + or not isinstance(payload, dict) + ): + raise AuditContinuityError("pending audit continuity command is malformed") + return cls(kind=kind, mutation_id=mutation_id, created_at_ms=created_at_ms, payload=payload) + + +def _canonical_json(payload: object) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _sha256(payload: object) -> str: + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def prepared_audit_continuity_command( + mutation: AuditMutation, *, prior_generation: int, prior_head_sha256: str +) -> dict[str, object]: + """Derive the sole source-WAL command and target for one mutation.""" + + command = mutation.command() + command_sha256 = _sha256(command) + return { + "format": _FORMAT, + "prior_generation": prior_generation, + "prior_head_sha256": prior_head_sha256, + "next_generation": prior_generation + 1, + "command": command, + "command_sha256": command_sha256, + "next_head_sha256": _sha256({"previous_head_sha256": prior_head_sha256, "command_sha256": command_sha256}), + } + + +def audit_semantic_sha256(path: Path) -> str: + """Hash audit content while excluding the self-mutating continuity head.""" + + try: + with open_verified_audit_read_connection(path) as connection: + return _audit_semantic_sha256_connection(connection) + except (AuditLeafError, sqlite3.DatabaseError) as exc: + raise AuditContinuityError("cannot hash audit content for continuity validation") from exc + + +@contextmanager +def _open_source_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + try: + with open_verified_sqlite_read_connection(path) as connection: + yield connection + except AuditLeafError as exc: + raise AuditContinuityError(f"cannot safely read source continuity tier: {path}: {exc}") from exc + + +@contextmanager +def _open_source_write_connection(path: Path) -> Iterator[sqlite3.Connection]: + try: + with open_verified_sqlite_write_connection(path) as connection: + yield connection + except AuditLeafError as exc: + raise AuditContinuityError(f"cannot safely write source continuity tier: {path}: {exc}") from exc + + +def _entry_is_absent(path: Path) -> bool: + try: + path.lstat() + except FileNotFoundError: + return True + except OSError as exc: + raise AuditContinuityError(f"cannot inspect audit continuity tier entry: {path}") from exc + return False + + +def _audit_semantic_sha256_connection(connection: sqlite3.Connection) -> str: + """Return the continuity-independent semantic digest for one open audit DB.""" + + lines = (line for line in connection.iterdump() if "audit_continuity_head" not in line) + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +class AuditContinuityCoordinator: + """Coordinate typed audit commands through source.db's durable WAL row.""" + + def __init__( + self, + archive_root: Path, + *, + phase_hook: Callable[[str, AuditMutation], None] | None = None, + ) -> None: + self.archive_root = archive_root.resolve() + self.source_path = self.archive_root / "source.db" + self.audit_path = self.archive_root / "audit.db" + self._phase_hook = phase_hook + + def execute(self, mutation: AuditMutation, apply: Callable[[sqlite3.Connection, AuditMutation], _T]) -> _T: + """Prepare one command, commit audit bytes, then promote source control.""" + + self._phase("before_source_prepare", mutation) + prepared = self._prepare(mutation) + self._phase("after_source_prepare", mutation) + try: + result = self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") + except Exception: + # _apply_prepared has exited its audit transaction before this + # handler runs. Clear this exact source WAL entry only when the + # audit head still proves no commit happened, so validation rejects + # cannot wedge every later audit mutation. + self._abort_prepared(prepared) + raise + self._phase("after_audit_commit", mutation) + self._promote(prepared) + self._phase("after_source_promotion", mutation) + return result + + def reconcile(self, apply: Callable[[sqlite3.Connection, AuditMutation], object]) -> None: + """Deterministically complete a pending command or reject a stale audit image.""" + + if not self.is_available(): + return + prepared = self._pending() + if prepared is None: + self._assert_committed_head_matches_audit() + return + mutation = AuditMutation.from_command(prepared["command"]) + self._apply_prepared(prepared, apply, allow_rebind=mutation.kind == "rebind") + self._promote(prepared) + + def reconcile_pending_rebind(self, mutation_id: str) -> bool: + """Complete only the named operation-owned rebind command, if pending.""" + + prepared = self._pending() + if prepared is None: + return self.has_committed_mutation(mutation_id) + mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind != "rebind" or mutation.mutation_id != mutation_id: + raise AuditContinuityError("pending audit continuity command does not belong to this restore rebind") + if not self.is_available(): + raise AuditContinuityError("pending restore rebind lacks a readable audit continuity head") + self._apply_prepared(prepared, lambda _conn, _mutation: None, allow_rebind=True) + self._promote(prepared) + return True + + def has_pending_rebind(self, mutation_id: str) -> bool: + """Return whether source.db has the named restore-owned rebind prepared.""" + + prepared = self._pending() + if prepared is None: + return False + mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind != "rebind" or mutation.mutation_id != mutation_id: + raise AuditContinuityError("pending audit continuity command does not belong to this restore rebind") + return True + + def is_available(self) -> bool: + """Return whether both schema halves needed for coordinated writes exist.""" + + if _entry_is_absent(self.source_path) or _entry_is_absent(self.audit_path): + return False + try: + with ( + _open_source_read_connection(self.source_path) as source, + open_verified_audit_read_connection(self.audit_path) as audit, + ): + source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) + audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) + source_has_control = self._has_table(source, "audit_continuity_control") + audit_has_head = self._has_table(audit, "audit_continuity_head") + if not source_has_control and source_version >= _SOURCE_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current source schema is missing audit continuity control") + if not audit_has_head and audit_version >= _AUDIT_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current audit schema is missing audit continuity head") + if not source_has_control or not audit_has_head: + return False + source.execute("SELECT 1 FROM audit_continuity_control WHERE singleton = 1").fetchone() + audit.execute("SELECT 1 FROM audit_continuity_head WHERE singleton = 1").fetchone() + if self._is_unbound_populated_precontinuity_audit(source, audit): + raise AuditContinuityError( + "populated pre-continuity audit journal requires authenticated post-migration binding" + ) + except AuditLeafError as exc: + raise AuditContinuityError(str(exc)) from exc + except sqlite3.OperationalError as exc: + raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc + except sqlite3.DatabaseError as exc: + raise AuditContinuityError("cannot inspect audit continuity compatibility state") from exc + return True + + def needs_precontinuity_binding(self) -> bool: + """Return whether a migrated populated audit journal still has only genesis heads.""" + + self._require_paths() + try: + with ( + _open_source_read_connection(self.source_path) as source, + open_verified_audit_read_connection(self.audit_path) as audit, + ): + source_version = int(source.execute("PRAGMA user_version").fetchone()[0] or 0) + audit_version = int(audit.execute("PRAGMA user_version").fetchone()[0] or 0) + source_has_control = self._has_table(source, "audit_continuity_control") + audit_has_head = self._has_table(audit, "audit_continuity_head") + if not source_has_control and source_version >= _SOURCE_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current source schema is missing audit continuity control") + if not audit_has_head and audit_version >= _AUDIT_CONTINUITY_SCHEMA_VERSION: + raise AuditContinuityError("current audit schema is missing audit continuity head") + return ( + source_has_control + and audit_has_head + and self._is_unbound_populated_precontinuity_audit(source, audit) + ) + except AuditLeafError as exc: + raise AuditContinuityError("cannot inspect pre-continuity audit binding state") from exc + except sqlite3.DatabaseError as exc: + raise AuditContinuityError("cannot inspect pre-continuity audit binding state") from exc + + def bind_precontinuity_audit(self, *, mutation_id: str, now_ms: int, audit_semantic_sha256: str) -> None: + """Bind a populated v1 audit journal through its first source-backed head. + + Published v2/v32 migrations seeded matching genesis rows for both fresh + and upgraded archives. A populated upgraded journal needs this explicit + command, whose head commits the authenticated pre-migration semantic + digest, before ordinary coordinated mutations are allowed. + """ + + if len(audit_semantic_sha256) != 64: + raise AuditContinuityError("pre-continuity binding requires an audit semantic sha256") + if self.has_committed_mutation(mutation_id): + return + prepared = self._pending() + if prepared is not None: + pending = AuditMutation.from_command(prepared["command"]) + if pending.kind != "bind_precontinuity_audit" or pending.mutation_id != mutation_id: + raise AuditContinuityError( + "pending audit continuity command does not belong to this pre-continuity binding" + ) + self._apply_prepared(prepared, lambda _conn, _mutation: None) + self._promote(prepared) + return + if not self.needs_precontinuity_binding(): + raise AuditContinuityError("pre-continuity audit binding no longer has matching unbound genesis heads") + self.execute( + AuditMutation( + "bind_precontinuity_audit", + mutation_id, + now_ms, + {"audit_semantic_sha256": audit_semantic_sha256}, + ), + lambda _conn, _mutation: None, + ) + + def runtime_probe(self) -> str: + """Exercise the coordinator's released-schema or compatibility state.""" + + if not self.is_available(): + return "standby until source.db and audit.db both install continuity control" + if self._pending() is not None: + raise AuditContinuityError("runtime probe found an unreconciled audit continuity command") + self._assert_committed_head_matches_audit() + return "reconciled matching source/audit continuity heads" + + def seed_or_rebind(self, *, mutation_id: str, now_ms: int, evidence: Mapping[str, object]) -> None: + """Advance continuity after an authenticated adoption or verified restore. + + This is intentionally a typed WAL command too. The caller has already + authenticated the external publication; this method only binds that + exact evidence to the new audit image without trusting inode identity. + """ + + expected_image_sha256 = evidence.get("audit_image_sha256") + if not isinstance(expected_image_sha256, str) or len(expected_image_sha256) != 64: + raise AuditContinuityError("rebind requires an exact audit image sha256") + if self.has_committed_mutation(mutation_id): + return + # Adoption and restore publish their immutable evidence only after + # this machine head advances. Resume this exact source-WAL command on + # retry instead of treating it as an unrelated competing mutation. + if self.has_pending_rebind(mutation_id): + self.reconcile_pending_rebind(mutation_id) + return + mutation = AuditMutation("rebind", mutation_id, now_ms, dict(evidence)) + + # A verified restored image can contain an older audit head. Its + # authenticated image hash is the authority to rebind it. + self.execute(mutation, lambda _conn, _mutation: None) + + def has_committed_mutation(self, mutation_id: str) -> bool: + """Return whether both tiers already committed this exact mutation id.""" + self._require_paths() + try: + with ( + _open_source_read_connection(self.source_path) as source, + open_verified_audit_read_connection(self.audit_path) as audit, + ): + source_row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + except sqlite3.DatabaseError as exc: + if "no such table" in str(exc).lower(): + return False + raise AuditContinuityError("cannot read audit continuity commit state") from exc + if source_row is None or audit_row is None: + raise AuditContinuityError("audit continuity control row is missing") + if (int(source_row[0]), str(source_row[1])) != (int(audit_row[0]), str(audit_row[1])): + return False + return isinstance(audit_row[2], str) and audit_row[2] == mutation_id + + def reconcile_restore_rebind( + self, + mutation: AuditMutation, + *, + prior_generation: int, + prior_head_sha256: str, + ) -> bool: + """Resume one exact restore rebind without minting a second source head.""" + + if mutation.kind != "rebind": + raise AuditContinuityError("restore continuity reconciliation requires a rebind mutation") + expected = prepared_audit_continuity_command( + mutation, prior_generation=prior_generation, prior_head_sha256=prior_head_sha256 + ) + pending = self._pending() + if pending is not None: + if pending != expected: + raise AuditContinuityError("pending restore rebind does not match its immutable prepared evidence") + self._apply_prepared(pending, lambda _conn, _mutation: None, allow_rebind=True) + self._promote(pending) + return True + with _open_source_read_connection(self.source_path) as source: + row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + prior = (prior_generation, prior_head_sha256) + target_generation = expected["next_generation"] + target_head = expected["next_head_sha256"] + if not isinstance(target_generation, int) or not isinstance(target_head, str): + raise AuditContinuityError("restore rebind target is malformed") + target = (target_generation, target_head) + current = (int(row[0]), str(row[1])) + if current == prior: + return False + if current != target: + raise AuditContinuityError("promoted restore rebind does not match its immutable prepared evidence") + self._repair_promoted_rebind(expected) + return True + + def _phase(self, name: str, mutation: AuditMutation) -> None: + if self._phase_hook is not None: + self._phase_hook(name, mutation) + + def _prepare(self, mutation: AuditMutation) -> dict[str, object]: + self._require_paths() + with _open_source_write_connection(self.source_path) as conn, conn: + conn.row_factory = sqlite3.Row + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT committed_generation, committed_head_sha256, pending_mutation_id FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + if row[2] is not None: + raise AuditContinuityError("another audit continuity mutation is already pending") + prepared = prepared_audit_continuity_command( + mutation, prior_generation=int(row[0]), prior_head_sha256=str(row[1]) + ) + payload_json = _canonical_json(prepared) + conn.execute( + """ + UPDATE audit_continuity_control + SET pending_mutation_id = ?, pending_payload_json = ?, pending_payload_sha256 = ?, prepared_at_ms = ? + WHERE singleton = 1 AND pending_mutation_id IS NULL + """, + (mutation.mutation_id, payload_json, _sha256(prepared), mutation.created_at_ms), + ) + conn.commit() + return prepared + + def _pending(self) -> dict[str, object] | None: + self._require_paths() + with _open_source_read_connection(self.source_path) as conn: + row = conn.execute( + "SELECT committed_generation, committed_head_sha256, pending_payload_json, pending_payload_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("source audit continuity control is missing") + pending_json = row[2] + if pending_json is None: + return None + if not isinstance(pending_json, str): + raise AuditContinuityError("source audit continuity pending command is malformed") + try: + prepared = json.loads(pending_json) + except json.JSONDecodeError as exc: + raise AuditContinuityError("source audit continuity pending command is invalid JSON") from exc + if not isinstance(prepared, dict) or _sha256(prepared) != row[3]: + raise AuditContinuityError("source audit continuity pending command checksum mismatch") + if ( + prepared.get("format") != _FORMAT + or prepared.get("prior_generation") != row[0] + or prepared.get("prior_head_sha256") != row[1] + ): + raise AuditContinuityError("source audit continuity pending command does not bind its committed head") + self._validate_prepared(prepared) + return prepared + + def _apply_prepared( + self, + prepared: dict[str, object], + apply: Callable[[sqlite3.Connection, AuditMutation], _T], + *, + allow_rebind: bool = False, + ) -> _T: + self._validate_prepared(prepared) + mutation = AuditMutation.from_command(prepared["command"]) + if mutation.kind == "rebind" and not self._audit_has_prepared_target(prepared, mutation): + # Writer setup persists WAL mode in the main header. Authenticate a + # restored image before opening that mutating connection, but do + # not re-hash an audit side that already committed this target. + self._assert_rebind_image(mutation) + with open_verified_audit_connection(self.audit_path) as conn, conn: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing") + current = (int(row[0]), str(row[1]), row[2]) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + if current[:2] == target and current[2] == mutation.mutation_id: + conn.commit() + return cast(_T, None) + if mutation.kind == "bind_precontinuity_audit": + self._assert_precontinuity_audit_semantics(conn, mutation) + if current[:2] != prior: + if allow_rebind and mutation.kind == "rebind": + pass + else: + raise AuditContinuityError("audit continuity head does not match the prepared source command") + result = ( + cast(_T, None) if mutation.kind in {"rebind", "bind_precontinuity_audit"} else apply(conn, mutation) + ) + conn.execute( + "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? WHERE singleton = 1", + (*target, mutation.mutation_id, mutation.created_at_ms), + ) + conn.commit() + return result + + def _promote(self, prepared: Mapping[str, object]) -> None: + mutation = AuditMutation.from_command(prepared["command"]) + with _open_source_write_connection(self.source_path) as conn, conn: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.execute( + """ + UPDATE audit_continuity_control + SET committed_generation = ?, committed_head_sha256 = ?, + pending_mutation_id = NULL, pending_payload_json = NULL, + pending_payload_sha256 = NULL, prepared_at_ms = NULL + WHERE singleton = 1 AND pending_mutation_id = ? AND pending_payload_sha256 = ? + """, + ( + prepared["next_generation"], + prepared["next_head_sha256"], + mutation.mutation_id, + _sha256(dict(prepared)), + ), + ) + if cursor.rowcount != 1: + raise AuditContinuityError("source audit continuity promotion lost its prepared command") + conn.commit() + + def _abort_prepared(self, prepared: Mapping[str, object]) -> None: + """Discard a rejected WAL command after proving its audit transaction rolled back.""" + + mutation = AuditMutation.from_command(prepared["command"]) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + with open_verified_audit_connection(self.audit_path) as audit: + audit.execute("BEGIN IMMEDIATE") + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing while aborting a prepared command") + current = (int(row[0]), str(row[1]), row[2]) + if current[:2] == target and current[2] == mutation.mutation_id: + # The audit commit did land. Keep the WAL command for normal + # promotion instead of mistaking an ambiguous failure for rollback. + return + if current[:2] != prior: + raise AuditContinuityError("cannot abort prepared command after an unrelated audit head change") + with _open_source_write_connection(self.source_path) as source, source: + source.execute("BEGIN IMMEDIATE") + cursor = source.execute( + """ + UPDATE audit_continuity_control + SET pending_mutation_id = NULL, pending_payload_json = NULL, + pending_payload_sha256 = NULL, prepared_at_ms = NULL + WHERE singleton = 1 AND committed_generation = ? AND committed_head_sha256 = ? + AND pending_mutation_id = ? AND pending_payload_sha256 = ? + """, + (prior[0], prior[1], mutation.mutation_id, _sha256(dict(prepared))), + ) + if cursor.rowcount != 1: + raise AuditContinuityError("source audit continuity abort lost its prepared command") + source.commit() + + def _repair_promoted_rebind(self, prepared: Mapping[str, object]) -> None: + """Advance a restored audit head to an already-promoted exact target.""" + + mutation = AuditMutation.from_command(prepared["command"]) + prior = (cast(int, prepared["prior_generation"]), str(prepared["prior_head_sha256"])) + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + self._assert_rebind_image(mutation) + with open_verified_audit_connection(self.audit_path) as audit, audit: + audit.execute("BEGIN IMMEDIATE") + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if row is None: + raise AuditContinuityError("audit continuity head is missing while repairing promoted rebind") + current = (int(row[0]), str(row[1]), row[2]) + if current[:2] == target and current[2] == mutation.mutation_id: + audit.commit() + return + if current[:2] != prior: + raise AuditContinuityError("restored audit head does not match the exact promoted rebind prior") + audit.execute( + "UPDATE audit_continuity_head SET generation = ?, head_sha256 = ?, mutation_id = ?, advanced_at_ms = ? " + "WHERE singleton = 1", + (*target, mutation.mutation_id, mutation.created_at_ms), + ) + audit.commit() + + def _audit_has_prepared_target(self, prepared: Mapping[str, object], mutation: AuditMutation) -> bool: + target = (cast(int, prepared["next_generation"]), str(prepared["next_head_sha256"])) + try: + with open_verified_audit_read_connection(self.audit_path) as audit: + row = audit.execute( + "SELECT generation, head_sha256, mutation_id FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + except (AuditLeafError, sqlite3.DatabaseError) as exc: + raise AuditContinuityError("cannot inspect audit continuity head before rebind") from exc + return row is not None and (int(row[0]), str(row[1])) == target and row[2] == mutation.mutation_id + + def _assert_committed_head_matches_audit(self) -> None: + with ( + _open_source_read_connection(self.source_path) as source, + open_verified_audit_read_connection(self.audit_path) as audit, + ): + source_row = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_row = audit.execute( + "SELECT generation, head_sha256 FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if source_row is None or audit_row is None: + raise AuditContinuityError("audit continuity control row is missing") + if (int(source_row[0]), str(source_row[1])) != (int(audit_row[0]), str(audit_row[1])): + raise AuditContinuityError("audit continuity head regressed or was replaced after source promotion") + + def _validate_prepared(self, prepared: Mapping[str, object]) -> None: + command = prepared.get("command") + if not isinstance(command, dict) or prepared.get("format") != _FORMAT: + raise AuditContinuityError("audit continuity command format mismatch") + prior_generation = prepared.get("prior_generation") + next_generation = prepared.get("next_generation") + if not isinstance(prior_generation, int) or not isinstance(next_generation, int): + raise AuditContinuityError("audit continuity command generations are malformed") + if next_generation != prior_generation + 1: + raise AuditContinuityError("audit continuity command generation is non-monotonic") + command_sha256 = _sha256(command) + if prepared.get("command_sha256") != command_sha256: + raise AuditContinuityError("audit continuity command payload checksum mismatch") + expected_head = _sha256( + {"previous_head_sha256": prepared.get("prior_head_sha256"), "command_sha256": command_sha256} + ) + if prepared.get("next_head_sha256") != expected_head: + raise AuditContinuityError("audit continuity command head checksum mismatch") + + def _assert_rebind_image(self, mutation: AuditMutation) -> None: + expected = mutation.payload.get("audit_image_sha256") + if not isinstance(expected, str) or len(expected) != 64: + raise AuditContinuityError("rebind command lacks an audit image sha256") + digest = hashlib.sha256() + try: + with VerifiedAuditLeaf(self.audit_path.parent, filename=self.audit_path.name) as leaf: + with leaf.anchored_path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + leaf.assert_unchanged() + except (AuditLeafError, OSError) as exc: + raise AuditContinuityError("cannot read audit image for rebind") from exc + if digest.hexdigest() != expected: + raise AuditContinuityError("audit image changed before continuity rebind") + + @staticmethod + def _has_table(connection: sqlite3.Connection, name: str) -> bool: + return ( + connection.execute("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?", (name,)).fetchone() + is not None + ) + + def _is_unbound_populated_precontinuity_audit(self, source: sqlite3.Connection, audit: sqlite3.Connection) -> bool: + source_head = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control WHERE singleton = 1" + ).fetchone() + audit_head = audit.execute( + "SELECT generation, head_sha256 FROM audit_continuity_head WHERE singleton = 1" + ).fetchone() + if source_head != (0, AUDIT_CONTINUITY_GENESIS_HEAD_SHA256) or audit_head != ( + 0, + AUDIT_CONTINUITY_GENESIS_HEAD_SHA256, + ): + return False + tables = tuple( + str(row[0]) + for row in audit.execute( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' " + "AND name != 'audit_continuity_head' ORDER BY name" + ) + ) + for name in tables: + quoted_name = name.replace('"', '""') + if audit.execute(f'SELECT 1 FROM "{quoted_name}" LIMIT 1').fetchone() is not None: + return True + return False + + def _assert_precontinuity_audit_semantics(self, connection: sqlite3.Connection, mutation: AuditMutation) -> None: + expected = mutation.payload.get("audit_semantic_sha256") + if not isinstance(expected, str) or len(expected) != 64: + raise AuditContinuityError("pre-continuity binding lacks an audit semantic sha256") + if _audit_semantic_sha256_connection(connection) != expected: + raise AuditContinuityError("pre-continuity audit journal differs from its authenticated migration evidence") + + def _require_paths(self) -> None: + for path in (self.source_path, self.audit_path): + try: + metadata = path.lstat() + except FileNotFoundError as exc: + raise AuditContinuityError("audit continuity requires initialized source.db and audit.db") from exc + except OSError as exc: + raise AuditContinuityError(f"cannot inspect audit continuity tier entry: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise AuditContinuityError(f"audit continuity tier entry is not an owned regular file: {path}") + + +__all__ = [ + "AuditContinuityCoordinator", + "AuditContinuityError", + "AuditMutation", + "audit_semantic_sha256", + "prepared_audit_continuity_command", +] diff --git a/polylogue/storage/sqlite/audit_leaf.py b/polylogue/storage/sqlite/audit_leaf.py new file mode 100644 index 0000000000..a91ed78a35 --- /dev/null +++ b/polylogue/storage/sqlite/audit_leaf.py @@ -0,0 +1,457 @@ +"""Descriptor-anchored access to the archive-owned ``audit.db`` leaf.""" + +from __future__ import annotations + +import fcntl +import os +import sqlite3 +import stat +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +_SQLITE_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal") + + +class AuditLeafError(RuntimeError): + """The audit pathname cannot prove it is one archive-owned database file.""" + + +@dataclass(frozen=True, slots=True) +class _AuditLeafIdentity: + device: int + inode: int + + +class VerifiedAuditLeaf: + """Keep one archive directory descriptor and verify its ``audit.db`` leaf. + + A writer holds the verified main leaf while SQLite opens a child path that + is proven to resolve back to that descriptor's directory. The main leaf + and any SQLite sidecar are checked before and after opening, so a + replacement or redirected sidecar is rejected before a caller receives a + connection. + """ + + def __init__(self, archive_root: Path, *, filename: str = "audit.db", lock_writer: bool = False) -> None: + self._archive_root = archive_root + self._filename = filename + self._lock_writer = lock_writer + self._directory_fd: int | None = None + self._leaf_fd: int | None = None + self._directory_identity: _AuditLeafIdentity | None = None + self._identity: _AuditLeafIdentity | None = None + self._anchored_path: Path | None = None + self._writer_lock_held = False + self._sidecar_fds: dict[str, int] = {} + self._sidecar_identities: dict[str, _AuditLeafIdentity] = {} + self._first_transaction_guard_armed = False + + def __enter__(self) -> VerifiedAuditLeaf: + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC + nofollow = getattr(os, "O_NOFOLLOW", 0) + try: + self._directory_fd = os.open(self._archive_root, directory_flags | nofollow) + directory_metadata = os.fstat(self._directory_fd) + self._validate_directory(directory_metadata) + self._directory_identity = _AuditLeafIdentity(directory_metadata.st_dev, directory_metadata.st_ino) + expected = self._validate(self._lstat_leaf_metadata()) + self._leaf_fd = self._open_leaf() + metadata = os.fstat(self._leaf_fd) + self._identity = self._validate(metadata) + if self._identity != expected: + raise AuditLeafError(f"audit tier leaf changed while opening: {self._archive_root / self._filename}") + if self._lock_writer: + self._acquire_writer_lock() + self._anchored_path = self._resolve_portable_child_path() + self._assert_sidecar_namespace() + except BaseException as exc: + self._close_after_failed_enter() + if isinstance(exc, AuditLeafError): + raise + raise AuditLeafError(f"cannot safely open audit tier leaf: {self._archive_root / self._filename}") from exc + return self + + def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: + self.close() + + def sqlite_uri(self, *, readonly: bool = False) -> str: + if readonly: + # ``immutable=1`` is unsafe for the live authority database: a + # committed head may still reside in WAL, and immutable readers + # deliberately ignore locking and change detection. ``mode=ro`` + # preserves WAL visibility without granting write access. + return f"{self.anchored_path.as_uri()}?mode=ro" + return f"{self.anchored_path.as_uri()}?mode=rw" + + @property + def anchored_path(self) -> Path: + """Return the descriptor-anchored path SQLite and byte readers may open.""" + + if self._anchored_path is None: + raise RuntimeError("audit leaf descriptor is closed") + return self._anchored_path + + def assert_unchanged(self) -> None: + """Require the current directory entry to retain the inspected inode.""" + + if self._identity is None or self._directory_identity is None: + raise RuntimeError("audit leaf descriptor is closed") + try: + current = self._validate(self._open_leaf_metadata()) + anchored = self._stat_path(self.anchored_path) + anchored_directory = self._stat_path(self.anchored_path.parent) + self._assert_sidecar_namespace() + except OSError as exc: + raise AuditLeafError(f"cannot revalidate audit tier leaf: {self._archive_root / self._filename}") from exc + if ( + current != self._identity + or _AuditLeafIdentity(anchored.st_dev, anchored.st_ino) != self._identity + or _AuditLeafIdentity(anchored_directory.st_dev, anchored_directory.st_ino) != self._directory_identity + ): + raise AuditLeafError(f"audit tier leaf changed during SQLite open: {self._archive_root / self._filename}") + + def close(self) -> None: + directory_fd, leaf_fd = self._directory_fd, self._leaf_fd + writer_lock_held = self._writer_lock_held + self._directory_fd = None + self._leaf_fd = None + self._directory_identity = None + self._identity = None + self._anchored_path = None + self._writer_lock_held = False + sidecar_fds, self._sidecar_fds = self._sidecar_fds, {} + self._sidecar_identities = {} + self._first_transaction_guard_armed = False + errors: list[OSError] = [] + for descriptor in sidecar_fds.values(): + try: + os.close(descriptor) + except OSError as exc: + errors.append(exc) + if leaf_fd is not None: + if writer_lock_held: + try: + fcntl.flock(leaf_fd, fcntl.LOCK_UN) + except OSError as exc: + errors.append(exc) + try: + os.close(leaf_fd) + except OSError as exc: + errors.append(exc) + if directory_fd is not None: + try: + os.close(directory_fd) + except OSError as exc: + errors.append(exc) + if errors: + raise errors[0] + + def _close_after_failed_enter(self) -> None: + try: + self.close() + except OSError: + return + + def _acquire_writer_lock(self) -> None: + if self._leaf_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + try: + fcntl.flock(self._leaf_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise AuditLeafError( + f"audit tier already has an active writer: {self._archive_root / self._filename}" + ) from exc + self._writer_lock_held = True + + def _open_leaf(self) -> int: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) + return os.open(self._filename, flags, dir_fd=self._directory_fd) + + def _open_leaf_metadata(self) -> os.stat_result: + descriptor = self._open_leaf() + try: + return os.fstat(descriptor) + finally: + os.close(descriptor) + + def _lstat_leaf_metadata(self) -> os.stat_result: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + return os.stat(self._filename, dir_fd=self._directory_fd, follow_symlinks=False) + + def _resolve_portable_child_path(self) -> Path: + if self._directory_fd is None or self._identity is None: + raise RuntimeError("audit leaf descriptor is closed") + directory = self._native_directory_path() + if directory is not None: + candidate = directory / self._filename + if self._matches_identity(candidate, self._identity): + return candidate + descriptor_child = self._descriptor_child_path() + if descriptor_child is not None: + return descriptor_child + raise AuditLeafError(f"cannot access audit tier through a verified descriptor: {self._archive_root}") + + def _descriptor_child_path(self) -> Path | None: + """Return a descriptor-directory child only where the host proves it works.""" + + if self._directory_fd is None or self._identity is None: + raise RuntimeError("audit leaf descriptor is closed") + for directory in (Path("/proc/self/fd"), Path("/dev/fd")): + candidate = directory / str(self._directory_fd) / self._filename + if self._matches_identity(candidate, self._identity): + return candidate + return None + + def _native_directory_path(self) -> Path | None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + request = getattr(fcntl, "F_GETPATH", None) + if not isinstance(request, int): + return None + try: + raw = fcntl.fcntl(self._directory_fd, request, b"\0" * 1024) + except OSError: + return None + if not isinstance(raw, bytes): + return None + encoded = raw.split(b"\0", 1)[0] + if not encoded: + return None + try: + candidate = Path(os.fsdecode(encoded)) + directory = os.fstat(self._directory_fd) + metadata = self._stat_path(candidate) + except OSError: + return None + if (metadata.st_dev, metadata.st_ino) != (directory.st_dev, directory.st_ino): + return None + return candidate + + def _assert_sidecar_namespace(self) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for suffix in _SQLITE_SIDECAR_SUFFIXES: + filename = f"{self._filename}{suffix}" + try: + expected = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + except FileNotFoundError: + continue + descriptor = os.open( + filename, + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + dir_fd=self._directory_fd, + ) + try: + actual = self._validate(os.fstat(descriptor), description="audit tier sidecar", filename=filename) + finally: + os.close(descriptor) + if actual != expected: + raise AuditLeafError(f"audit tier sidecar changed while opening: {self._archive_root / filename}") + self._assert_pinned_sidecars() + + def prepare_writable_sqlite(self, connection: sqlite3.Connection) -> None: + """Create and pin SQLite's WAL namespace before exposing a writer. + + Opening ``audit.db`` alone does not create WAL/SHM. Force that setup + while the verified main-leaf lock is held, then retain descriptors for + both files so a later pathname replacement is detectable before an + application transaction is authorized. + """ + + if not self._lock_writer: + raise RuntimeError("audit leaf is not a writer") + try: + journal_mode = connection.execute("PRAGMA journal_mode = WAL").fetchone() + if journal_mode is None or str(journal_mode[0]).lower() != "wal": + raise AuditLeafError("audit tier must use WAL before writable access") + connection.execute("BEGIN IMMEDIATE") + connection.commit() + self._pin_writable_sidecars() + self.assert_unchanged() + self._first_transaction_guard_armed = True + except sqlite3.DatabaseError as exc: + raise AuditLeafError("cannot establish the audit SQLite WAL namespace") from exc + + def install_transaction_guard(self, connection: sqlite3.Connection) -> None: + """Reject a sidecar replacement before SQLite starts an application tx.""" + + def authorize( + action: int, argument1: str | None, _argument2: str | None, _database: str | None, _trigger: str | None + ) -> int: + if action == sqlite3.SQLITE_TRANSACTION and argument1 == "BEGIN" and self._first_transaction_guard_armed: + self._assert_pinned_sidecars(allow_absent=False) + self._first_transaction_guard_armed = False + return sqlite3.SQLITE_OK + + connection.set_authorizer(authorize) + + def _pin_writable_sidecars(self) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for suffix in ("-wal", "-shm"): + filename = f"{self._filename}{suffix}" + try: + expected = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + descriptor = os.open( + filename, + os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0), + dir_fd=self._directory_fd, + ) + except FileNotFoundError as exc: + raise AuditLeafError( + f"audit tier did not create required WAL sidecar: {self._archive_root / filename}" + ) from exc + try: + actual = self._validate(os.fstat(descriptor), description="audit tier sidecar", filename=filename) + except BaseException: + os.close(descriptor) + raise + if actual != expected: + os.close(descriptor) + raise AuditLeafError(f"audit tier sidecar changed while pinning: {self._archive_root / filename}") + self._sidecar_fds[filename] = descriptor + self._sidecar_identities[filename] = actual + + def _assert_pinned_sidecars(self, *, allow_absent: bool = True) -> None: + if self._directory_fd is None: + raise RuntimeError("audit leaf descriptor is closed") + for filename, identity in self._sidecar_identities.items(): + try: + current = self._validate( + os.stat(filename, dir_fd=self._directory_fd, follow_symlinks=False), + description="audit tier sidecar", + filename=filename, + ) + pinned = self._validate( + os.fstat(self._sidecar_fds[filename]), description="audit tier sidecar", filename=filename + ) + except FileNotFoundError as exc: + if allow_absent: + continue + raise AuditLeafError( + f"audit tier sidecar disappeared during SQLite access: {self._archive_root / filename}" + ) from exc + except OSError as exc: + raise AuditLeafError(f"cannot inspect audit tier sidecar: {self._archive_root / filename}") from exc + if current != identity or pinned != identity: + raise AuditLeafError( + f"audit tier sidecar changed during SQLite access: {self._archive_root / filename}" + ) + + @staticmethod + def _stat_path(path: Path) -> os.stat_result: + return os.stat(path) + + def _matches_identity(self, path: Path, identity: _AuditLeafIdentity) -> bool: + try: + metadata = self._stat_path(path) + except OSError: + return False + return (metadata.st_dev, metadata.st_ino) == (identity.device, identity.inode) + + def _validate( + self, + metadata: os.stat_result, + *, + description: str = "audit tier", + filename: str | None = None, + ) -> _AuditLeafIdentity: + path = self._archive_root / (filename or self._filename) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise AuditLeafError(f"{description} must be an archive-owned regular file with one link: {path}") + if metadata.st_uid != os.geteuid(): + raise AuditLeafError(f"{description} must be owned by the current effective user: {path}") + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise AuditLeafError(f"{description} must not be writable by group or other: {path}") + return _AuditLeafIdentity(metadata.st_dev, metadata.st_ino) + + def _validate_directory(self, metadata: os.stat_result) -> None: + if metadata.st_uid != os.geteuid(): + raise AuditLeafError( + f"audit tier directory must be owned by the current effective user: {self._archive_root}" + ) + if metadata.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + raise AuditLeafError(f"audit tier directory must not be writable by group or other: {self._archive_root}") + + +@contextmanager +def open_verified_audit_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open one writable audit connection pinned to an owned leaf descriptor.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name, lock_writer=True) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri(), uri=True) + try: + leaf.prepare_writable_sqlite(connection) + leaf.install_transaction_guard(connection) + yield connection + leaf.assert_unchanged() + finally: + connection.rollback() + connection.close() + + +@contextmanager +def open_verified_sqlite_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open a read-only SQLite leaf through a no-follow directory descriptor.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri(readonly=True), uri=True) + try: + leaf.assert_unchanged() + yield connection + leaf.assert_unchanged() + finally: + connection.close() + + +@contextmanager +def open_verified_sqlite_write_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open an existing writable SQLite leaf through a no-follow descriptor.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name) as leaf: + connection = sqlite3.connect(leaf.sqlite_uri(), uri=True) + try: + leaf.assert_unchanged() + yield connection + leaf.assert_unchanged() + finally: + connection.close() + + +@contextmanager +def open_verified_audit_read_connection(path: Path) -> Iterator[sqlite3.Connection]: + """Open one live-WAL-aware, read-only audit connection.""" + + with open_verified_sqlite_read_connection(path) as connection: + yield connection + + +def assert_verified_audit_leaf(path: Path) -> None: + """Check an existing audit leaf without exposing its descriptor to callers.""" + + with VerifiedAuditLeaf(path.parent, filename=path.name): + return + + +__all__ = [ + "AuditLeafError", + "VerifiedAuditLeaf", + "assert_verified_audit_leaf", + "open_verified_audit_connection", + "open_verified_audit_read_connection", + "open_verified_sqlite_read_connection", + "open_verified_sqlite_write_connection", +] diff --git a/polylogue/storage/sqlite/connection_profile.py b/polylogue/storage/sqlite/connection_profile.py index a928b25bcb..1f6133f246 100644 --- a/polylogue/storage/sqlite/connection_profile.py +++ b/polylogue/storage/sqlite/connection_profile.py @@ -540,11 +540,12 @@ def open_daemon_connection( return conn -def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: - """Return a validated descriptor URI on platforms that expose one.""" - descriptor_metadata = os.fstat(opened_main_fd) +def descriptor_alias_path(opened_fd: int) -> Path | None: + """Return a validated portable pathname alias for an opened descriptor.""" + + descriptor_metadata = os.fstat(opened_fd) for directory in ("/dev/fd", "/proc/self/fd"): - candidate = f"{directory}/{opened_main_fd}" + candidate = Path(directory) / str(opened_fd) try: alias_metadata = os.stat(candidate) except OSError: @@ -553,10 +554,16 @@ def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: descriptor_metadata.st_dev, descriptor_metadata.st_ino, ): - return f"file:{candidate}{suffix}" + return candidate return None +def _descriptor_database_uri(opened_main_fd: int, suffix: str) -> str | None: + """Return a validated descriptor URI on platforms that expose one.""" + alias = descriptor_alias_path(opened_main_fd) + return None if alias is None else f"file:{alias}{suffix}" + + def open_readonly_connection( path: str | Path, *, @@ -649,6 +656,7 @@ def connection_context(path: str | Path, *, timeout: float = DB_TIMEOUT) -> Iter "WRITE_MMAP_SIZE_BYTES", "check_mapped_bytes_budget_against_cgroup_limit", "connection_context", + "descriptor_alias_path", "log_mapped_bytes_budget_check", "mapped_bytes_budget", "open_daemon_connection", diff --git a/polylogue/storage/sqlite/durable_change_train.py b/polylogue/storage/sqlite/durable_change_train.py index 1dda7033ed..7b5b6bb50d 100644 --- a/polylogue/storage/sqlite/durable_change_train.py +++ b/polylogue/storage/sqlite/durable_change_train.py @@ -1,4 +1,4 @@ -"""Durable source/user migration change-train authority.""" +"""Durable source/user/audit migration change-train authority.""" from __future__ import annotations @@ -69,6 +69,7 @@ DURABLE_MIGRATION_ADOPTION_FLOORS: Final[dict[ArchiveTier, int]] = { ArchiveTier.SOURCE: 26, ArchiveTier.USER: 10, + ArchiveTier.AUDIT: 1, } _SIDECAR_NAME_RE = re.compile(r"^(?P\d{3,})\.train\.json$") _MIGRATION_NAME_RE = re.compile(r"^(?P\d{3,})_[a-z0-9_]+\.sql$") @@ -77,6 +78,14 @@ _SourceContinuityMutationKind = Literal["blob_ref_liveness", "raw_authority_recovery"] _FRESH_DURABLE_BOOTSTRAP_FORMAT = "polylogue.durable-bootstrap.v1" _FRESH_DURABLE_BOOTSTRAP_MARKER = ".bootstrap" + + +def _is_audit_continuity_receipt(path: Path) -> bool: + """Return whether a maintenance receipt is not a durable train manifest.""" + + return path.name in {"audit-adoption.json", "audit-continuity.json"} or path.name.startswith("audit-restore.") + + _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER = ".bootstrap.pending" @@ -330,7 +339,7 @@ def _record_fresh_durable_bootstrap(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(marker_root.glob("*.json")): + if marker_path.exists() or any(not _is_audit_continuity_receipt(path) for path in marker_root.glob("*.json")): raise DurableChangeTrainError(f"cannot record fresh durable bootstrap over existing train state: {marker_root}") if pending_path.is_file(): _validate_fresh_durable_bootstrap_intent(archive_root) @@ -363,7 +372,7 @@ def _record_fresh_durable_bootstrap_intent(archive_root: Path) -> None: marker_root = archive_root / ".maintenance-state" / "durable-change-trains" marker_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_MARKER pending_path = marker_root / _FRESH_DURABLE_BOOTSTRAP_PENDING_MARKER - if marker_path.exists() or any(marker_root.glob("*.json")): + if marker_path.exists() or any(not _is_audit_continuity_receipt(path) for path in marker_root.glob("*.json")): raise DurableChangeTrainError( f"cannot record fresh durable bootstrap intent over existing train state: {marker_root}" ) @@ -482,7 +491,7 @@ def _fresh_durable_bootstrap_versions(archive_root: Path, marker_root: Path) -> def _durable_identity_digest(identity: object) -> str: - """Digest only the durable source/user identity for bootstrap receipts.""" + """Digest the durable source/user/audit identity for bootstrap receipts.""" from polylogue.storage.archive_identity import ArchiveIdentity if not isinstance(identity, ArchiveIdentity): @@ -507,7 +516,7 @@ def _adopt_pre_marker_durable_bootstrap(archive_root: Path) -> None: manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" if (manifest_root / _FRESH_DURABLE_BOOTSTRAP_MARKER).is_file(): return - if any(manifest_root.glob("*.json")): + if any(not _is_audit_continuity_receipt(path) for path in manifest_root.glob("*.json")): return for tier in DURABLE_MIGRATION_ADOPTION_FLOORS: tier_path = archive_root / f"{tier.value}.db" @@ -1453,6 +1462,15 @@ def _runtime_consumer_results( f"runtime consumer {consumer.consumer_id} is source-tier-only: {reference}" ) detail = _probe_raw_failure_disposition_apply(cast(Callable[..., object], value), archive_root) + elif reference.endswith(":AuditRepository.reconcile_continuity"): + from polylogue.operations.audit import AuditRepository + + AuditRepository.for_archive_root(archive_root).reconcile_continuity() + detail = "reconciled matching source/audit continuity heads" + elif reference.endswith(":AuditContinuityCoordinator"): + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + detail = AuditContinuityCoordinator(archive_root).runtime_probe() elif not any( parameter.default is inspect.Parameter.empty and parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) @@ -1962,6 +1980,8 @@ def _released_train_manifests_by_target( if not manifest_root.is_dir(): return manifests_by_target for path in sorted(manifest_root.glob(f"{tier.value}-*.json")): + if _is_audit_continuity_receipt(path): + continue train = load_durable_change_train_manifest(path) if train.target_version in manifests_by_target: raise DurableChangeTrainError( @@ -2282,6 +2302,9 @@ def _reconcile_durable_change_train_startup_locked( live_evidence_cache: dict[ArchiveTier, _DurableForwardVersionEvidence] | None = None, ) -> tuple[Path, ...]: """Reconcile persisted trains while the caller holds archive ownership.""" + from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + + validate_audit_adoption_receipt(archive_root) deferred_tiers = _recover_pending_source_continuity_intents(archive_root) manifest_root = archive_root / ".maintenance-state" / "durable-change-trains" reconciled: list[Path] = [] @@ -2291,7 +2314,9 @@ def _reconcile_durable_change_train_startup_locked( canonical_inventory_by_tier: dict[ArchiveTier, _migration_runner.DurableSchemaInventory] = {} manifests_by_tier: dict[ArchiveTier, dict[int, DurableChangeTrain]] = {} validated_tiers: set[ArchiveTier] = set() - manifest_paths = tuple(sorted(manifest_root.glob("*.json"))) + manifest_paths = tuple( + path for path in sorted(manifest_root.glob("*.json")) if not _is_audit_continuity_receipt(path) + ) fresh_bootstrap_versions = _fresh_durable_bootstrap_versions(archive_root, manifest_root) def record_reconciled(path: Path) -> None: @@ -2357,7 +2382,9 @@ def record_reconciled(path: Path) -> None: if current_version <= adoption_floor: continue manifests_by_tier[tier] = _released_train_manifests_by_target(manifest_root, tier) - tier_manifest_paths = tuple(manifest_root.glob(f"{tier.value}-*.json")) + tier_manifest_paths = tuple( + path for path in manifest_root.glob(f"{tier.value}-*.json") if not _is_audit_continuity_receipt(path) + ) bootstrap_version = fresh_bootstrap_versions.get(tier) if bootstrap_version is not None and current_version < bootstrap_version: raise DurableChangeTrainError( diff --git a/polylogue/storage/sqlite/migration_runner.py b/polylogue/storage/sqlite/migration_runner.py index 05c89a9396..f367ad6f85 100644 --- a/polylogue/storage/sqlite/migration_runner.py +++ b/polylogue/storage/sqlite/migration_runner.py @@ -539,7 +539,7 @@ def _validated_receipt_artifacts( receipt: dict[str, object], *, target_tier: str, - live_tier_path: Path, + live_tier_path: Path | None, file_evidence: dict[str, dict[str, object]], ) -> dict[str, dict[str, object]]: included = _json_str_list(manifest.get("included_tiers")) @@ -844,6 +844,459 @@ def validate_migration_backup_live_fingerprint( return receipt_path +def validate_full_evidence_backup_for_audit_adoption(path: Path, *, archive_root: Path) -> tuple[Path, Path]: + """Authorize creation of a missing audit tier in an established archive. + + Unlike a normal tier migration there is no live ``audit.db`` connection to + attest. The route therefore requires the complete pre-audit file set, + validates the existing source/user attestations, and compares every + retained tier's recorded source fingerprint with the still-offline live + archive. This is intentionally stricter than ordinary migration backup + validation: an adoption is only safe when the backup is full evidence for + this exact established archive, not merely a restorable subset. + """ + manifest_path = _backup_manifest_path(path) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"audit adoption requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or manifest.get("profile") != "full_evidence": + raise MigrationError("audit adoption requires a verified full_evidence backup") + included = set(_json_str_list(manifest.get("included_tiers"))) + required_tiers = {"source", "index", "embeddings", "user"} + permitted_tiers = required_tiers | {"ops"} + included_tiers = {name.removesuffix(".db") for name in included} + if ( + not required_tiers.issubset(included_tiers) + or included_tiers - permitted_tiers + or len(included_tiers) != len(included) + ): + raise MigrationError("audit adoption backup must contain every non-optional established tier and no audit tier") + receipt_path = _receipt_path(manifest_path) + if not receipt_path.exists() and not receipt_path.is_symlink(): + raise MigrationError( + 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): + raise MigrationError("audit adoption backup root aliases the live archive root") + except OSError as exc: + raise MigrationError("cannot compare audit adoption backup root with the live archive") from exc + for authority_tier in ("source", "user"): + try: + verify_verification_receipt( + receipt, + tier=authority_tier, + live_tier_path=archive_root / f"{authority_tier}.db", + ) + except BackupAttestationError as exc: + raise MigrationError(f"audit adoption backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("audit adoption backup receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("audit adoption backup receipt does not match manifest bytes") + artifacts = _validated_receipt_artifacts( + backup_root, + manifest, + receipt, + target_tier="audit", + live_tier_path=archive_root / "audit.db", + file_evidence=file_evidence, + ) + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("audit adoption backup receipt does not match the closed artifact inventory") + for tier in sorted(included_tiers): + live_path = archive_root / f"{tier}.db" + artifact = artifacts[tier] + artifact_path = backup_root / f"{tier}.db" + if not live_path.is_file(): + raise MigrationError(f"audit adoption live tier is missing: {live_path}") + try: + if artifact_path.samefile(live_path): + raise MigrationError(f"audit adoption backup tier artifact aliases the live tier: {tier}.db") + except OSError as exc: + raise MigrationError(f"cannot compare audit adoption backup tier with live tier: {tier}.db") from exc + fingerprint = artifact.get("source_fingerprint") + if not isinstance(fingerprint, dict): + raise MigrationError(f"audit adoption backup lacks a live source fingerprint for {tier}.db") + if Path(str(fingerprint.get("path") or "")).resolve(strict=False) != live_path.resolve(strict=False): + raise MigrationError(f"audit adoption backup belongs to a different archive tier: {tier}.db") + wal_path = live_path.with_name(f"{live_path.name}-wal") + if wal_path.exists() and wal_path.stat().st_size: + raise MigrationError(f"audit adoption backup has live WAL divergence for {tier}.db") + if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + if str(fingerprint.get("sha256")) != _sha256_file(live_path): + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError(f"audit adoption backup is stale for {tier}.db") + return manifest_path, receipt_path + + +def validate_full_evidence_backup_for_adopted_audit_restore( + path: Path, + *, + archive_root: Path, + allow_source_continuity_rebind: bool = False, + source_continuity_rebind_mutation_id: str | None = None, + source_continuity_rebind_prepared_restore: Mapping[str, object] | None = None, +) -> tuple[Path, Path]: + """Authorize replacing adopted ``audit.db`` from one exact backup. + + The audit file may be absent or unreadable, so its stable path authority is + verified without opening it. Every other captured tier must still match + the scratch-verified full-evidence snapshot byte for byte, except that a + retry after continuity promotion may differ only in source.db's control + row. + """ + manifest_path = _backup_manifest_path(path) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"adopted-audit restore requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or manifest.get("profile") != "full_evidence": + raise MigrationError("adopted-audit restore requires a verified full_evidence backup") + included = set(_json_str_list(manifest.get("included_tiers"))) + required_tiers = {"source", "index", "embeddings", "user", "audit"} + permitted_tiers = required_tiers | {"ops"} + included_tiers = {name.removesuffix(".db") for name in included} + if ( + not required_tiers.issubset(included_tiers) + or included_tiers - permitted_tiers + or len(included_tiers) != len(included) + ): + raise MigrationError("adopted-audit restore backup must contain every non-optional tier including audit") + receipt_path = _receipt_path(manifest_path) + if not receipt_path.exists() and not receipt_path.is_symlink(): + raise MigrationError( + f"adopted-audit restore requires a successful backup verification receipt; missing {receipt_path}" + ) + _require_regular_backup_artifact(receipt_path, backup_root=backup_root, label="backup verification receipt") + receipt = _load_json(receipt_path, label="verification receipt") + if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": + raise MigrationError("adopted-audit restore requires a successful backup verification receipt") + archive_root = archive_root.resolve() + try: + if backup_root.samefile(archive_root): + raise MigrationError("adopted-audit restore backup root aliases the live archive root") + except OSError as exc: + raise MigrationError("cannot compare adopted-audit restore backup root with the live archive") from exc + for authority_tier in ("source", "user", "audit"): + try: + verify_verification_receipt( + receipt, tier=authority_tier, live_tier_path=archive_root / f"{authority_tier}.db" + ) + except BackupAttestationError as exc: + raise MigrationError(f"adopted-audit restore backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("adopted-audit restore receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("adopted-audit restore receipt does not match manifest bytes") + artifacts = _validated_receipt_artifacts( + backup_root, manifest, receipt, target_tier="audit", live_tier_path=None, file_evidence=file_evidence + ) + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("adopted-audit restore receipt does not match the closed artifact inventory") + for tier in sorted(included_tiers): + live_path = archive_root / f"{tier}.db" + artifact_path = backup_root / f"{tier}.db" + if live_path.is_file(): + try: + if artifact_path.samefile(live_path): + raise MigrationError(f"adopted-audit restore backup tier artifact aliases the live tier: {tier}.db") + except OSError as exc: + raise MigrationError( + f"cannot compare adopted-audit restore backup tier with live tier: {tier}.db" + ) from exc + if tier not in {"source", "user"}: + continue + fingerprint = artifacts[tier].get("source_fingerprint") + if not isinstance(fingerprint, dict): + raise MigrationError(f"adopted-audit restore backup lacks a live source fingerprint for {tier}.db") + if Path(str(fingerprint.get("path") or "")).resolve(strict=False) != live_path.resolve(strict=False): + raise MigrationError(f"adopted-audit restore backup belongs to a different archive tier: {tier}.db") + if not live_path.is_file(): + raise MigrationError(f"adopted-audit restore live tier is missing: {live_path}") + if tier == "source" and allow_source_continuity_rebind: + if not source_continuity_rebind_mutation_id: + raise MigrationError("adopted-audit restore lacks an operation-owned source continuity rebind") + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError("adopted-audit restore backup is stale for source.db") + # A retry can retain this operation's committed source WAL after + # a crash. Validate SQLite's logical WAL view, not only its file. + _validate_source_continuity_rebind_delta( + artifact_path, + live_path, + expected_mutation_id=source_continuity_rebind_mutation_id, + prepared_restore=source_continuity_rebind_prepared_restore, + ) + continue + wal_path = live_path.with_name(f"{live_path.name}-wal") + if wal_path.exists() and wal_path.stat().st_size: + raise MigrationError(f"adopted-audit restore has live WAL divergence for {tier}.db") + if _json_int(fingerprint.get("size_bytes")) != live_path.stat().st_size: + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + if str(fingerprint.get("sha256")) != _sha256_file(live_path): + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + if _json_int(fingerprint.get("user_version")) != _sqlite_user_version(live_path): + raise MigrationError(f"adopted-audit restore backup is stale for {tier}.db") + return manifest_path, receipt_path + + +def _authenticated_backup_audit_semantic_sha256(backup_manifest: Path, *, audit_path: Path) -> str: + """Read the pre-migration audit digest from a receipt-authenticated backup image.""" + + manifest_path = _backup_manifest_path(backup_manifest) + if not manifest_path.exists() and not manifest_path.is_symlink(): + raise MigrationError(f"pre-continuity binding requires an existing backup manifest; missing {manifest_path}") + backup_root = manifest_path.parent + _require_real_backup_directory(backup_root, label="backup root") + _require_regular_backup_artifact(manifest_path, backup_root=backup_root, label="backup manifest") + manifest = _load_json(manifest_path, label="manifest") + if manifest.get("format") != "polylogue-backup-v1" or "audit.db" not in _json_str_list( + manifest.get("included_tiers") + ): + raise MigrationError("pre-continuity binding requires a backup containing audit.db") + receipt_path = _receipt_path(manifest_path) + _require_regular_backup_artifact(receipt_path, backup_root=backup_root, label="backup verification receipt") + receipt = _load_json(receipt_path, label="verification receipt") + if receipt.get("format") != VERIFICATION_RECEIPT_FORMAT or receipt.get("verdict") != "success": + raise MigrationError("pre-continuity binding requires a successful backup verification receipt") + try: + verify_verification_receipt(receipt, tier="audit", live_tier_path=audit_path) + except BackupAttestationError as exc: + raise MigrationError(f"pre-continuity binding backup authentication failed: {exc}") from exc + artifact_inventory = _cached_backup_artifact_inventory(backup_root) + file_evidence = {str(item["path"]): item for item in artifact_inventory if item.get("type") == "file"} + manifest_evidence = file_evidence.get("manifest.json", {}) + if _json_int(receipt.get("manifest_size_bytes")) != _json_int(manifest_evidence.get("size_bytes")): + raise MigrationError("pre-continuity binding receipt does not match manifest size") + if receipt.get("manifest_sha256") != manifest_evidence.get("sha256"): + raise MigrationError("pre-continuity binding receipt does not match manifest bytes") + if receipt.get("artifact_inventory") != artifact_inventory: + raise MigrationError("pre-continuity binding receipt does not match the closed artifact inventory") + artifacts = _validated_receipt_artifacts( + backup_root, + manifest, + receipt, + target_tier="audit", + live_tier_path=None, + file_evidence=file_evidence, + ) + if "audit" not in artifacts: + raise MigrationError("pre-continuity binding backup does not contain an audit artifact") + _validate_blob_inventory(backup_root, manifest, receipt, file_evidence=file_evidence) + from polylogue.storage.sqlite.audit_continuity import audit_semantic_sha256 + + return audit_semantic_sha256(backup_root / "audit.db") + + +def _bind_populated_precontinuity_audit(conn: sqlite3.Connection, *, backup_manifest: Path | None) -> None: + """Bind a legacy populated audit journal once both published schema halves exist.""" + + archive_root = _connection_main_path(conn).parent + # Durable tier migrations also run against deliberately partial archives: + # source-only repair images, user-tier fixtures, and train scratch roots. + # Pre-continuity binding is meaningful only after both halves exist. A + # present-but-invalid pair still reaches the verified coordinator below + # and fails closed; absence is a legitimate non-applicable state here. + entries: dict[str, os.stat_result | None] = {} + for name in ("source.db", "audit.db"): + path = archive_root / name + try: + metadata = path.lstat() + except FileNotFoundError: + entries[name] = None + continue + except OSError as exc: + raise MigrationError(f"cannot inspect pre-continuity {name}: {path}") from exc + if not stat.S_ISREG(metadata.st_mode): + raise MigrationError(f"invalid pre-continuity {name} entry: {path}") + entries[name] = metadata + if entries["source.db"] is None or entries["audit.db"] is None: + return + from polylogue.storage.sqlite.audit_continuity import ( + AuditContinuityCoordinator, + AuditContinuityError, + audit_semantic_sha256, + ) + + coordinator = AuditContinuityCoordinator(archive_root) + try: + if not coordinator.needs_precontinuity_binding(): + return + except AuditContinuityError as exc: + raise MigrationError("cannot inspect pre-continuity audit binding state") from exc + if backup_manifest is None: + raise MigrationError("populated pre-continuity audit journal requires a verified backup for continuity binding") + audit_path = archive_root / "audit.db" + expected = _authenticated_backup_audit_semantic_sha256(backup_manifest, audit_path=audit_path) + try: + actual = audit_semantic_sha256(audit_path) + except AuditContinuityError as exc: + raise MigrationError("cannot hash populated audit journal for continuity binding") from exc + if actual != expected: + raise MigrationError("populated audit journal differs from its authenticated pre-migration backup") + try: + coordinator.bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", + now_ms=int(time.time() * 1000), + audit_semantic_sha256=expected, + ) + except AuditContinuityError as exc: + raise MigrationError("cannot bind populated pre-continuity audit journal") from exc + + +def _validate_source_continuity_rebind_delta( + backup_path: Path, + live_path: Path, + *, + expected_mutation_id: str, + prepared_restore: Mapping[str, object] | None, +) -> None: + """Allow a retrying restore to differ only in the source continuity table.""" + + try: + with closing(sqlite3.connect(f"{live_path.resolve(strict=True).as_uri()}?mode=ro", uri=True)) as connection: + connection.execute( + "ATTACH DATABASE ? AS backup_source", (f"{backup_path.resolve(strict=True).as_uri()}?mode=ro",) + ) + control = connection.execute( + "SELECT pending_mutation_id, pending_payload_json, pending_payload_sha256 " + "FROM main.audit_continuity_control WHERE singleton = 1" + ).fetchone() + if control is None: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + backup_head = connection.execute( + "SELECT committed_generation, committed_head_sha256 " + "FROM backup_source.audit_continuity_control WHERE singleton = 1" + ).fetchone() + if backup_head is None or not isinstance(backup_head[0], int) or not isinstance(backup_head[1], str): + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + expected_prepared: dict[str, object] | None = None + if prepared_restore is not None: + from polylogue.storage.sqlite.audit_continuity import AuditMutation, prepared_audit_continuity_command + + operation_id = prepared_restore.get("operation_id") + created_at_ms = prepared_restore.get("rebind_created_at_ms") + restore_sha256 = prepared_restore.get("restore_sha256") + audit_image_sha256 = prepared_restore.get("audit_artifact_sha256") + if ( + not isinstance(operation_id, str) + or not isinstance(created_at_ms, int) + or created_at_ms < 0 + or not isinstance(restore_sha256, str) + or not isinstance(audit_image_sha256, str) + ): + raise MigrationError("adopted-audit restore rebind lacks immutable prepared evidence") + expected_prepared = prepared_audit_continuity_command( + AuditMutation( + "rebind", + f"audit-restore:{operation_id}", + created_at_ms, + { + "kind": "verified_restore", + "prepared_restore_sha256": restore_sha256, + "audit_image_sha256": audit_image_sha256, + }, + ), + prior_generation=int(backup_head[0]), + prior_head_sha256=str(backup_head[1]), + ) + pending_mutation_id, pending_payload_json, pending_payload_sha256 = control + if pending_mutation_id is not None: + if ( + pending_mutation_id != expected_mutation_id + or not isinstance(pending_payload_json, str) + or not isinstance(pending_payload_sha256, str) + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + try: + prepared = json.loads(pending_payload_json) + except json.JSONDecodeError as exc: + raise MigrationError("adopted-audit restore source continuity rebind is malformed") from exc + if ( + not isinstance(prepared, dict) + or hashlib.sha256( + json.dumps(prepared, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() + != pending_payload_sha256 + or expected_prepared is None + or prepared != expected_prepared + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + else: + source_head = connection.execute( + "SELECT committed_generation, committed_head_sha256 " + "FROM main.audit_continuity_control WHERE singleton = 1" + ).fetchone() + expected_target: tuple[int, str] | None = None + if expected_prepared is not None: + next_generation = expected_prepared["next_generation"] + next_head = expected_prepared["next_head_sha256"] + if isinstance(next_generation, int) and isinstance(next_head, str): + expected_target = (next_generation, next_head) + # A crash after source promotion may leave audit.db absent or + # unreadable. The verified image is republished before its + # head is consulted by the restore coordinator, which then + # authenticates the exact mutation id. Here we can only admit + # the source control-row delta while proving all other source + # rows remain byte-for-byte equivalent below. + if source_head != backup_head and ( + source_head is None or expected_target is None or source_head != expected_target + ): + raise MigrationError("adopted-audit restore source continuity rebind is not operation-owned") + schema_sql = """ + SELECT type, name, tbl_name, sql + FROM {schema}.sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + AND name != 'audit_continuity_control' + ORDER BY type, name + """ + live_schema = connection.execute(schema_sql.format(schema="main")).fetchall() + backup_schema = connection.execute(schema_sql.format(schema="backup_source")).fetchall() + if live_schema != backup_schema: + raise MigrationError("adopted-audit restore backup is stale for source.db") + table_names = [str(row[1]) for row in live_schema if row[0] == "table"] + for table_name in table_names: + quoted = _quote_sqlite_identifier(table_name) + live_count = int(connection.execute(f"SELECT COUNT(*) FROM main.{quoted}").fetchone()[0]) + backup_count = int(connection.execute(f"SELECT COUNT(*) FROM backup_source.{quoted}").fetchone()[0]) + if live_count != backup_count: + raise MigrationError("adopted-audit restore backup is stale for source.db") + columns = [str(row[1]) for row in connection.execute(f"PRAGMA main.table_info({quoted})")] + if not columns: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") + grouped_columns = ", ".join(_quote_sqlite_identifier(column) for column in columns) + for left, right in (("main", "backup_source"), ("backup_source", "main")): + differs = connection.execute( + f"SELECT 1 FROM (SELECT {grouped_columns}, COUNT(*) AS multiplicity FROM {left}.{quoted} " + f"GROUP BY {grouped_columns} EXCEPT SELECT {grouped_columns}, COUNT(*) AS multiplicity " + f"FROM {right}.{quoted} GROUP BY {grouped_columns}) LIMIT 1" + ).fetchone() + if differs is not None: + raise MigrationError("adopted-audit restore backup is stale for source.db") + except sqlite3.DatabaseError as exc: + raise MigrationError("cannot compare adopted-audit restore source continuity delta") from exc + + def validate_backup_manifest_covers_derived_tier( path: Path, tier: ArchiveTier, *, connection: sqlite3.Connection ) -> Path: @@ -927,6 +1380,7 @@ def migrate_archive_tier( # computed here is re-derived from a fresh read once the lock is held. precheck_version = int(conn.execute("PRAGMA user_version").fetchone()[0] or 0) if precheck_version == target_version: + _bind_populated_precontinuity_audit(conn, backup_manifest=backup_manifest) return MigrationResult( tier=tier, from_version=precheck_version, @@ -1080,6 +1534,7 @@ def migrate_archive_tier( conn.commit() if foreign_keys_were_on: conn.execute("PRAGMA foreign_keys = ON") + _bind_populated_precontinuity_audit(conn, backup_manifest=backup_manifest) return MigrationResult( tier=tier, from_version=start_version, @@ -3452,6 +3907,8 @@ def write_durable_change_train_manifest( "validate_durable_change_train_manifest", "validate_backup_manifest_covers_derived_tier", "validate_migration_backup_live_fingerprint", + "validate_full_evidence_backup_for_audit_adoption", + "validate_full_evidence_backup_for_adopted_audit_restore", "validate_migration_backup_manifest", "write_durable_change_train_manifest", ] diff --git a/polylogue/storage/sqlite/migrations/audit/002.train.json b/polylogue/storage/sqlite/migrations/audit/002.train.json new file mode 100644 index 0000000000..f905a37cc9 --- /dev/null +++ b/polylogue/storage/sqlite/migrations/audit/002.train.json @@ -0,0 +1,62 @@ +{ + "manifest_format": "polylogue.durable-change-train.v1", + "train_id": "train:audit:v2", + "tier": "audit", + "current_version": 1, + "target_version": 2, + "slot": 2, + "owner_ref": "feature/fix/audit-continuity", + "migration": { + "tier": "audit", + "target_version": 2, + "slot": 2, + "path": "002_audit_continuity_head.sql", + "owner_ref": "polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql", + "sql_sha256": "71e1f3c6a2b9cec46934884045022ae1c62dd6dba4e65bee23b37cb5f6e5a804", + "requires_backup": true + }, + "riders": [ + { + "rider_id": "rider:audit-continuity-head", + "owner_ref": "feature/fix/audit-continuity", + "schema_objects": ["table:audit_continuity_head"], + "runtime_consumers": [ + { + "consumer_id": "audit-continuity-coordinator", + "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", + "behavior_proof_ref": "proof:audit-v2:cross-tier-continuity", + "roles": ["read", "write"] + }, + { + "consumer_id": "audit-continuity-startup-reconcile", + "production_ref": "polylogue.operations.audit:AuditRepository.reconcile_continuity", + "behavior_proof_ref": "proof:audit-v2:startup-continuity-reconcile", + "roles": ["read"] + } + ], + "behavior_proof_refs": ["proof:audit-v2:cross-tier-continuity", "proof:audit-v2:startup-continuity-reconcile"], + "after_rider_ids": [], + "trust_floor_exception_ref": null + } + ], + "ordering_constraints": [], + "drop_constraints": [], + "row_change_allowances": [], + "backup_plan_ref": "backup-profile:audit-tier", + "state": "declared", + "revision": 0, + "declared_at_ms": 0, + "admitted_at_ms": null, + "admission_evidence_ref": null, + "fresh_ddl_parity": null, + "reservation": null, + "backup_authorization": null, + "pre_apply_evidence": null, + "apply_evidence": null, + "proof": null, + "failure": null, + "released_at_ms": null, + "release_evidence_ref": null, + "proof_refs": [], + "manifest_sha256": "c80342407e50cf0ecb7e30aa2bb56b132c705ac848f992ca8ccdf1b1634c78b0" +} diff --git a/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql b/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql new file mode 100644 index 0000000000..63efe35ec5 --- /dev/null +++ b/polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql @@ -0,0 +1,10 @@ +CREATE TABLE audit_continuity_head ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + generation INTEGER NOT NULL CHECK(generation >= 0), + head_sha256 TEXT NOT NULL CHECK(length(head_sha256) = 64), + mutation_id TEXT, + advanced_at_ms INTEGER NOT NULL CHECK(advanced_at_ms >= 0) +) STRICT; +INSERT INTO audit_continuity_head( + singleton, generation, head_sha256, mutation_id, advanced_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, 0); diff --git a/polylogue/storage/sqlite/migrations/source/032.train.json b/polylogue/storage/sqlite/migrations/source/032.train.json new file mode 100644 index 0000000000..8c0b70309b --- /dev/null +++ b/polylogue/storage/sqlite/migrations/source/032.train.json @@ -0,0 +1,63 @@ +{ + "manifest_format": "polylogue.durable-change-train.v1", + "train_id": "train:source:v32", + "tier": "source", + "current_version": 31, + "target_version": 32, + "slot": 32, + "owner_ref": "feature/fix/audit-continuity", + "migration": { + "tier": "source", + "target_version": 32, + "slot": 32, + "path": "032_audit_continuity_control.sql", + "owner_ref": "polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql", + "sql_sha256": "75e18a3c6fd3b40779643b75ec29f9a428a40463f0378937dc10fe32a14b6d0f", + "requires_backup": true + }, + "riders": [ + { + "rider_id": "rider:source-audit-continuity-control", + "owner_ref": "feature/fix/audit-continuity", + "schema_objects": ["table:audit_continuity_control"], + "runtime_consumers": [ + { + "consumer_id": "audit-continuity-coordinator", + "production_ref": "polylogue.storage.sqlite.audit_continuity:AuditContinuityCoordinator", + "behavior_proof_ref": "proof:source-v32:cross-tier-continuity", + "roles": ["read", "write"] + }, + { + "consumer_id": "audit-continuity-startup-reconcile", + "production_ref": "polylogue.operations.audit:AuditRepository.reconcile_continuity", + "behavior_proof_ref": "proof:source-v32:startup-continuity-reconcile", + "roles": ["read"] + } + ], + "behavior_proof_refs": ["proof:source-v32:cross-tier-continuity", "proof:source-v32:startup-continuity-reconcile"], + "after_rider_ids": [], + "trust_floor_exception_ref": null + } + ], + "ordering_constraints": [], + "drop_constraints": [], + "row_change_allowances": [], + "backup_plan_ref": "backup-profile:source-tier", + "state": "declared", + "revision": 0, + "declared_at_ms": 0, + "admitted_at_ms": null, + "admission_evidence_ref": null, + "fresh_ddl_parity": null, + "reservation": null, + "backup_authorization": null, + "pre_apply_evidence": null, + "apply_evidence": null, + "proof": null, + "failure": null, + "released_at_ms": null, + "release_evidence_ref": null, + "proof_refs": [], + "source_continuity_evidence": null, + "manifest_sha256": "2ed0badb9da8b9cbc03298dd539c996ef2cfbe9cd18058fd226d84ecdf5a22ba" +} diff --git a/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql b/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql new file mode 100644 index 0000000000..c5826d682e --- /dev/null +++ b/polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql @@ -0,0 +1,18 @@ +CREATE TABLE audit_continuity_control ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + committed_generation INTEGER NOT NULL CHECK(committed_generation >= 0), + committed_head_sha256 TEXT NOT NULL CHECK(length(committed_head_sha256) = 64), + pending_mutation_id TEXT UNIQUE, + pending_payload_json TEXT, + pending_payload_sha256 TEXT CHECK(pending_payload_sha256 IS NULL OR length(pending_payload_sha256) = 64), + prepared_at_ms INTEGER, + CHECK( + (pending_mutation_id IS NULL AND pending_payload_json IS NULL AND pending_payload_sha256 IS NULL AND prepared_at_ms IS NULL) + OR + (pending_mutation_id IS NOT NULL AND pending_payload_json IS NOT NULL AND pending_payload_sha256 IS NOT NULL AND prepared_at_ms IS NOT NULL AND prepared_at_ms >= 0) + ) +) STRICT; +INSERT INTO audit_continuity_control( + singleton, committed_generation, committed_head_sha256, + pending_mutation_id, pending_payload_json, pending_payload_sha256, prepared_at_ms +) VALUES (1, 0, '3230fdd585a4fd2d71b7d720bcfe5d697ff120fdb32aecde394e89d407c7198f', NULL, NULL, NULL, NULL); diff --git a/tests/unit/annotations/test_importer.py b/tests/unit/annotations/test_importer.py index 7f79d84ccc..44866eff97 100644 --- a/tests/unit/annotations/test_importer.py +++ b/tests/unit/annotations/test_importer.py @@ -20,6 +20,7 @@ from polylogue.archive.message.roles import Role from polylogue.archive.query.expression import parse_unit_source_expression from polylogue.core.enums import AssertionKind, BlockType, BranchType, Provider +from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import OperationExecutor from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore @@ -119,19 +120,25 @@ async def test_import_roundtrip_keeps_failures_candidates_and_independent_batche """The registered import route writes real user-tier provenance and candidates. Anti-vacuity: ``import_annotation_batch`` must dispatch the real - ``AnnotationBatchImportActuator`` through ``OperationExecutor`` before the + ``AnnotationBatchImportActuator`` through ``OperationExecutor.execute_bound`` before the transaction reaches ``user.db``. Removing that executor dispatch or restoring a direct persistence call leaves ``executed`` empty even though a toy persistence stub could still appear green. """ executed: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def spy(self: OperationExecutor, actuator: object, plan: object, authorization: object, args: object) -> object: - executed.append(type(actuator).__name__) - return original_execute(self, actuator, plan, authorization, args) # type: ignore[arg-type] + def spy( + self: OperationExecutor, + binding: OperationBinding[object, object], + preview: object, + authorization: object, + args: object, + ) -> object: + executed.append(type(binding.actuator).__name__) + return original_execute_bound(self, binding, preview, authorization, args) # type: ignore[arg-type] - monkeypatch.setattr(OperationExecutor, "execute", spy) + monkeypatch.setattr(OperationExecutor, "execute_bound", spy) archive_root = workspace_env["archive_root"] with ArchiveStore(archive_root) as archive: diff --git a/tests/unit/api/test_facade_contracts.py b/tests/unit/api/test_facade_contracts.py index 8f99311b44..8816b992fc 100644 --- a/tests/unit/api/test_facade_contracts.py +++ b/tests/unit/api/test_facade_contracts.py @@ -49,6 +49,7 @@ delegation_edge_object_id, delegation_subtree_object_id, ) +from polylogue.operations.bindings import OperationBinding from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession from polylogue.storage.block_anchor import format_block_anchor from polylogue.storage.runtime.store_constants import SESSION_INSIGHT_MATERIALIZER_VERSION @@ -356,7 +357,7 @@ async def test_facade_capture_candidate_dispatches_executor_and_persists_user_ro """The real facade route cannot bypass the executor and still pass. Production dependency: ``PolylogueArchiveMixin.capture_assertion_candidate`` - calls ``OperationExecutor.execute`` and the actuator writes ``user.db``. + calls ``OperationExecutor.execute_bound`` and the actuator writes ``user.db``. Removing that dispatch, or restoring the former direct helper call, makes the captured actuator list empty or leaves no candidate row. """ @@ -365,13 +366,19 @@ async def test_facade_capture_candidate_dispatches_executor_and_persists_user_ro archive = _archive(tmp_path) captured: list[str] = [] - original_execute = OperationExecutor.execute - - def spy(self: OperationExecutor, actuator: object, plan: object, authorization: object, args: object) -> object: - captured.append(type(actuator).__name__) - return original_execute(self, actuator, plan, authorization, args) # type: ignore[arg-type] - - monkeypatch.setattr(OperationExecutor, "execute", spy) + original_execute_bound = OperationExecutor.execute_bound + + def spy( + self: OperationExecutor, + binding: OperationBinding[object, object], + preview: object, + authorization: object, + args: object, + ) -> object: + captured.append(type(binding.actuator).__name__) + return original_execute_bound(self, binding, preview, authorization, args) # type: ignore[arg-type] + + monkeypatch.setattr(OperationExecutor, "execute_bound", spy) try: result = await archive.capture_assertion_candidate( body_text="facade candidate", diff --git a/tests/unit/api/test_operation_executor_routes.py b/tests/unit/api/test_operation_executor_routes.py index 4a5358b6da..7dbf3f0711 100644 --- a/tests/unit/api/test_operation_executor_routes.py +++ b/tests/unit/api/test_operation_executor_routes.py @@ -18,9 +18,9 @@ def _seed_archive(archive_root: Path, *, native_id: str) -> str: + initialize_active_archive_root(archive_root) source_db = archive_root / "source.db" index_db = archive_root / "index.db" - initialize_active_archive_root(archive_root) raw_id = f"raw-{native_id}" session_id = f"codex-session:{native_id}" with sqlite3.connect(source_db) as conn: @@ -53,13 +53,13 @@ async def test_facade_rebuild_and_update_index_use_executor_and_real_routes( session_id = _seed_archive(archive_root, native_id="route-index") archive = Polylogue(archive_root=archive_root, db_path=archive_root / "index.db") calls: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def record_execute(self: OperationExecutor, actuator, plan, authorization, args): # type: ignore[no-untyped-def] - calls.append(actuator.operation) - return original_execute(self, actuator, plan, authorization, args) + def record_execute_bound(self: OperationExecutor, binding, preview, authorization, args): # type: ignore[no-untyped-def] + calls.append(binding.actuator.operation) + return original_execute_bound(self, binding, preview, authorization, args) - monkeypatch.setattr(OperationExecutor, "execute", record_execute) + monkeypatch.setattr(OperationExecutor, "execute_bound", record_execute_bound) try: assert await archive.rebuild_index() is True assert await archive.update_index([session_id]) is True @@ -78,13 +78,13 @@ async def test_facade_rebuild_insights_uses_executor_and_real_materializer( session_id = _seed_archive(archive_root, native_id="route-insights") archive = Polylogue(archive_root=archive_root, db_path=archive_root / "index.db") calls: list[str] = [] - original_execute = OperationExecutor.execute + original_execute_bound = OperationExecutor.execute_bound - def record_execute(self: OperationExecutor, actuator, plan, authorization, args): # type: ignore[no-untyped-def] - calls.append(actuator.operation) - return original_execute(self, actuator, plan, authorization, args) + def record_execute_bound(self: OperationExecutor, binding, preview, authorization, args): # type: ignore[no-untyped-def] + calls.append(binding.actuator.operation) + return original_execute_bound(self, binding, preview, authorization, args) - monkeypatch.setattr(OperationExecutor, "execute", record_execute) + monkeypatch.setattr(OperationExecutor, "execute_bound", record_execute_bound) try: counts = await archive.rebuild_insights(session_ids=[session_id]) finally: diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 43029d1619..f87e2fcaf9 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -18,9 +18,15 @@ from polylogue.cli.click_app import cli from polylogue.cli.commands.maintenance import _rebuild_index as maintenance_rebuild_index +from polylogue.cli.commands.maintenance._migrate_tier import ( + MigrateTierErrorPayload, + MigrateTierResultPayload, + MigrateTierSuccessPayload, +) from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import json_document +from polylogue.daemon.backup import backup_archive from polylogue.maintenance.raw_authority_recovery import ( RecoveryOperation, inspect_raw_authority_recovery, @@ -31,6 +37,7 @@ from polylogue.storage.blob_gc import read_gc_history from polylogue.storage.blob_publication import ArchiveBlobPublisher from polylogue.storage.raw_authority import RawReplayPlan, record_raw_authority_census +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.archive import ArchiveSessionSearchHit, ArchiveSessionSummary, ArchiveStore from polylogue.storage.sqlite.archive_tiers.archive_init import ( ArchiveInitResult, @@ -446,6 +453,18 @@ def _stage_uninitialized_archive(cli_workspace: dict[str, Path]) -> None: ) +def _full_evidence_backup_without_audit(root: Path) -> Path: + """Create the real verified backup an established-audit adoption consumes.""" + result = backup_archive( + output_dir=root.parent / "backups", + profile="full_evidence", + verify=True, + ) + assert result.ok, result.error + assert result.output_path is not None + return Path(result.output_path) / "manifest.json" + + def _write_gc_candidate(cli_workspace: dict[str, Path], blob_hash: str) -> Path: blob_root = cli_workspace["archive_root"] / "blob" path = blob_root / blob_hash[:2] / blob_hash[2:] @@ -2280,13 +2299,16 @@ def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( assert result.exit_code == 0, result.output payload = json.loads(result.stdout) + result_payload = MigrateTierResultPayload.model_validate(payload).root + assert isinstance(result_payload, MigrateTierSuccessPayload) + assert result_payload.initialized is True assert payload["ok"] is True assert payload["tier"] == "audit" assert payload["initialized"] is True assert payload["from_version"] == 0 - assert payload["to_version"] == 1 + assert payload["to_version"] == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] with sqlite3.connect(audit_db) as conn: - assert conn.execute("PRAGMA user_version").fetchone() == (1,) + assert conn.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) assert conn.execute("PRAGMA integrity_check").fetchone() == ("ok",) @@ -2753,6 +2775,52 @@ def fail_after_publish(descriptor: int) -> None: } +def test_migrate_tier_cli_serializes_a_prepublication_failure_against_its_schema( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed pre-link publication emits the published nullable recovery target.""" + import jsonschema + + from polylogue.cli.commands.maintenance import _migrate_tier + from polylogue.operations.durable_change_train import DurableCleanupOutcome, DurablePublicationError + + _stage_uninitialized_archive(cli_workspace) + + def fail_prepublication(*_args: object, **_kwargs: object) -> int: + raise DurablePublicationError("pre-publication write failed", cleanup=DurableCleanupOutcome("not_attempted")) + + monkeypatch.setattr(_migrate_tier, "initialize_missing_durable_tier", fail_prepublication) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--initialize-missing", + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["durable_recovery"] == { + "code": None, + "detail": None, + "state": "not_attempted", + "target": None, + } + schema = json.loads( + (Path(__file__).parents[3] / "docs/schemas/cli-output/migrate-tier-result.schema.json").read_text( + encoding="utf-8" + ) + ) + jsonschema.validate(instance=payload, schema=schema) + + def test_migrate_tier_cli_preserves_replacement_during_checked_leaf_cleanup( cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -3289,6 +3357,355 @@ def test_migrate_tier_cli_missing_initialization_refuses_malformed_train_marker( assert not (root / "audit.db").exists() +def test_migrate_tier_cli_adopts_established_audit_from_verified_full_evidence_backup( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production CLI publishes v1 only after the real backup verifier succeeds.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + receipt = Path(str(payload["adoption_receipt"])) + assert payload["initialized"] is True + assert payload["to_version"] == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + assert receipt.is_file() + with sqlite3.connect(root / "audit.db") as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",) + + +def test_migrate_tier_cli_adoption_allows_a_full_evidence_backup_without_ops( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production adoption command accepts full evidence when optional ops.db is absent.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + (root / "ops.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert (root / "audit.db").is_file() + + +def test_migrate_tier_cli_adoption_rejects_live_wal_divergence( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The production backup gate rejects logical source changes held only in a live WAL.""" + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + source = root / "source.db" + with sqlite3.connect(source) as connection: + assert connection.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + connection.execute("CREATE TABLE adoption_wal_probe (value TEXT)") + connection.commit() + assert (root / "source.db-wal").stat().st_size > 0 + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "live WAL" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + + +@pytest.mark.parametrize("backup_case", ["missing", "stale", "wrong_archive"]) +def test_migrate_tier_cli_adoption_refuses_unbound_backup( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, backup_case: str +) -> None: + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + backup_arguments = ["--backup-manifest", str(manifest)] + if backup_case == "missing": + backup_arguments = [] + elif backup_case == "stale": + source = root / "source.db" + source.write_bytes(source.read_bytes() + b"stale-after-backup") + else: + foreign_root = root.parent / "foreign-archive" + shutil.copytree(root, foreign_root) + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(foreign_root)) + root = foreign_root + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + *backup_arguments, + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert not (root / "audit.db").exists() + error = json.loads(result.stdout)["error"] + if backup_case == "missing": + assert "adopt-established-audit" in error + else: + assert "audit adoption" in error + + +def test_migrate_tier_cli_adoption_refuses_live_writer_before_receipt_or_sql( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + root = cli_workspace["archive_root"] + (root / "audit.db").unlink() + manifest = _full_evidence_backup_without_audit(root) + monkeypatch.setattr( + "polylogue.cli.commands.maintenance._migrate_tier._daemon_pidfile_is_live", + lambda _pidfile: True, + ) + + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert "daemon to be stopped" in json.loads(result.stdout)["error"] + assert not (root / "audit.db").exists() + assert not (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").exists() + + +def test_migrate_tier_cli_restores_adopted_audit_from_verified_full_evidence( + cli_workspace: dict[str, Path], cli_runner: CliRunner +) -> None: + """The operator-facing command rebinds a corrupted adopted tier instead of leaving startup wedged.""" + root = cli_workspace["archive_root"] + audit_path = root / "audit.db" + audit_path.unlink() + pre_adoption = _full_evidence_backup_without_audit(root) + adopted = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(pre_adoption), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + assert adopted.exit_code == 0, adopted.output + verified = backup_archive(output_dir=root.parent / "adopted-audit-restore", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + + restored = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--restore-adopted-audit", + "--backup-manifest", + str(Path(verified.output_path) / "manifest.json"), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert restored.exit_code == 0, restored.output + payload = json.loads(restored.stdout) + assert payload["restore_receipt"].endswith(".committed.json") + with sqlite3.connect(audit_path) as connection: + assert connection.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert connection.execute("SELECT generation FROM audit_continuity_head").fetchone() == (2,) + + +@pytest.mark.parametrize("output_format", ["json", "plain"]) +def test_migrate_tier_cli_reports_adopted_audit_continuity_failures( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, output_format: str +) -> None: + """Continuity refusal stays within the command's declared error contract.""" + + from polylogue.cli.commands.maintenance import _migrate_tier + from polylogue.storage.sqlite.audit_continuity import AuditContinuityError + + manifest = cli_workspace["archive_root"] / "backup-manifest.json" + manifest.write_text("manifest", encoding="utf-8") + + def refuse_restore(*_args: object, **_kwargs: object) -> Path: + raise AuditContinuityError("inconsistent audit continuity head") + + monkeypatch.setattr(_migrate_tier, "restore_adopted_audit_tier", refuse_restore) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--restore-adopted-audit", + "--backup-manifest", + str(manifest), + "--output-format", + output_format, + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + if output_format == "json": + payload = json.loads(result.stdout) + result_payload = MigrateTierResultPayload.model_validate(payload).root + assert isinstance(result_payload, MigrateTierErrorPayload) + assert result_payload.error == "inconsistent audit continuity head" + else: + assert "Migration blocked for audit: inconsistent audit continuity head" in result.stderr + + +@pytest.mark.parametrize("publication_failure", ["race", "interrupted"]) +def test_migrate_tier_cli_adoption_fails_closed_during_publication( + cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, publication_failure: str +) -> None: + root = cli_workspace["archive_root"] + audit = root / "audit.db" + audit.unlink() + manifest = _full_evidence_backup_without_audit(root) + real_link = os.link + + def fail_or_race( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name != "audit.db": + real_link( + source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks + ) + return + if publication_failure == "race": + assert dst_dir_fd is not None + # This is a *valid* v1 audit database with a different image, not + # merely malformed bytes. Startup must reject the durable receipt + # after the atomic no-replace link detects the foreign target. + target_root = Path(os.readlink(f"/proc/self/fd/{dst_dir_fd}")) + with sqlite3.connect(target_root / Path(destination).name) as foreign: + initialize_archive_tier(foreign, ArchiveTier.AUDIT) + foreign.execute("PRAGMA application_id = 41") + foreign.commit() + real_link( + source, destination, src_dir_fd=src_dir_fd, dst_dir_fd=dst_dir_fd, follow_symlinks=follow_symlinks + ) + return + raise OSError("simulated interrupted audit publication") + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.link", fail_or_race) + result = cli_runner.invoke( + cli, + [ + "--plain", + "ops", + "maintenance", + "migrate-tier", + "audit", + "--adopt-established-audit", + "--backup-manifest", + str(manifest), + "--output-format", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + if publication_failure == "race": + with sqlite3.connect(audit) as foreign: + assert foreign.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert foreign.execute("PRAGMA quick_check").fetchone() == ("ok",) + from polylogue.operations.durable_change_train import reconcile_durable_change_trains_on_startup + from polylogue.storage.sqlite.migration_runner import MigrationError + + with pytest.raises(MigrationError, match="published canonical audit image"): + reconcile_durable_change_trains_on_startup(root) + else: + assert not audit.exists() + assert (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").is_file() + + def test_rebuild_index_empty_source_still_runs_the_schema_currency_guard( cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: diff --git a/tests/unit/cli/test_cli_output_schemas.py b/tests/unit/cli/test_cli_output_schemas.py index 6f94d7c370..a402c10bef 100644 --- a/tests/unit/cli/test_cli_output_schemas.py +++ b/tests/unit/cli/test_cli_output_schemas.py @@ -121,6 +121,27 @@ def test_machine_success_payload_validates_against_schema() -> None: jsonschema.validate(instance=instance, schema=schema) +def test_migrate_tier_error_payload_validates_against_schema() -> None: + """A blocked migrate-tier result remains valid against its published union.""" + import jsonschema + + from polylogue.cli.commands.maintenance._migrate_tier import MigrateTierResultPayload + + schema = _load_published_schema("migrate-tier-result") + payload = MigrateTierResultPayload.model_validate( + { + "ok": False, + "tier": "audit", + "path": "/archive/audit.db", + "backup_manifest": None, + "stopped_daemon_evidence_ref": None, + "error": "missing audit tier", + "durable_recovery": None, + } + ) + jsonschema.validate(instance=payload.model_dump(mode="json"), schema=schema) + + def test_mutation_result_payload_validates_against_schema() -> None: """A real MutationResultPayload must validate against the published schema.""" import jsonschema diff --git a/tests/unit/cli/test_excise.py b/tests/unit/cli/test_excise.py index 110b6f0998..0bd9a540bf 100644 --- a/tests/unit/cli/test_excise.py +++ b/tests/unit/cli/test_excise.py @@ -13,17 +13,15 @@ from click.testing import CliRunner from polylogue.cli import cli -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier def _seed_session(archive_root: Path, *, native_id: str) -> str: archive_root.mkdir(parents=True, exist_ok=True) + initialize_active_archive_root(archive_root) source_db = archive_root / "source.db" index_db = archive_root / "index.db" - initialize_archive_database(source_db, ArchiveTier.SOURCE) - initialize_archive_database(index_db, ArchiveTier.INDEX) source_conn = sqlite3.connect(source_db) source_conn.execute("PRAGMA foreign_keys = ON") @@ -131,6 +129,37 @@ def test_dry_run_reports_plan_without_mutating(self, tmp_path: Path) -> None: index_conn.close() assert count == 1 + def test_dry_run_does_not_construct_a_mutating_audit_executor(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="dry-run-no-executor") + with ( + patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root), + patch("polylogue.operations.mutation_transaction.OperationExecutor.for_archive_root") as factory, + ): + result = CliRunner().invoke( + cli, + ["ops", "excise", "--session", session_id, "--reason", "r", "--dry-run", "--json"], + ) + + assert result.exit_code == 0, result.output + factory.assert_not_called() + + def test_declined_confirmation_does_not_construct_a_mutating_audit_executor(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="declined-no-executor") + with ( + patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root), + patch("polylogue.operations.mutation_transaction.OperationExecutor.for_archive_root") as factory, + ): + result = CliRunner().invoke( + cli, + ["ops", "excise", "--session", session_id, "--reason", "r"], + input="n\n", + ) + + assert result.exit_code == 0, result.output + factory.assert_not_called() + def test_without_yes_aborts_in_json_mode(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" session_id = _seed_session(archive_root, native_id="no-yes-1") @@ -246,6 +275,10 @@ def test_primary_yes_creates_pending_request_without_touching_local_content(self assert row is not None assert row[0] == "excision_request" assert row[1] == f"session:{session_id}" + with sqlite3.connect(archive_root / "audit.db") as audit_connection: + assert audit_connection.execute( + "SELECT status FROM operation_runs WHERE operation_name = 'mutate-session-lifecycle-request'" + ).fetchone() == ("completed",) # Local content is untouched by mirror/primary mode. index_conn = sqlite3.connect(archive_root / "index.db") @@ -257,6 +290,101 @@ def test_primary_yes_creates_pending_request_without_touching_local_content(self index_conn.close() assert count == 1 + def test_replayed_primary_request_is_an_audited_noop(self, tmp_path: Path) -> None: + """The CLI route records the existing lifecycle assertion as idempotent. + + Anti-vacuity: unconditionally applied actuator receipts make the + second completed audit run report one affected target instead of zero. + """ + + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-replay") + command = [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ] + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + runner = CliRunner() + first = runner.invoke(cli, command) + replay = runner.invoke(cli, command) + + assert first.exit_code == replay.exit_code == 0 + with sqlite3.connect(archive_root / "audit.db") as connection: + assert connection.execute("SELECT state FROM operation_targets ORDER BY rowid").fetchall() == [ + ("applied",), + ("already_satisfied",), + ] + assert connection.execute( + "SELECT affected_count FROM operation_runs ORDER BY requested_at_ms, operation_id" + ).fetchall() == [(1,), (0,)] + + def test_primary_refuses_missing_audit_without_writing_a_lifecycle_request(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-missing-audit") + (archive_root / "audit.db").unlink() + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + result = CliRunner().invoke( + cli, + [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "missing audit.db" in str(result.exception) + with sqlite3.connect(archive_root / "user.db") as connection: + assert connection.execute("SELECT COUNT(*) FROM assertions WHERE kind = 'excision_request'").fetchone() == ( + 0, + ) + + def test_primary_refuses_broken_audit_continuity_without_writing_a_lifecycle_request(self, tmp_path: Path) -> None: + archive_root = tmp_path / "archive" + session_id = _seed_session(archive_root, native_id="primary-broken-continuity") + with sqlite3.connect(archive_root / "audit.db") as connection: + connection.execute("UPDATE audit_continuity_head SET head_sha256 = ? WHERE singleton = 1", ("0" * 64,)) + connection.commit() + + with patch("polylogue.cli.commands.excise.archive_root", return_value=archive_root): + result = CliRunner().invoke( + cli, + [ + "ops", + "excise", + "--session", + session_id, + "--reason", + "leak", + "--mode", + "primary", + "--yes", + "--json", + ], + ) + + assert result.exit_code != 0 + assert "continuity head regressed" in str(result.exception) + with sqlite3.connect(archive_root / "user.db") as connection: + assert connection.execute("SELECT COUNT(*) FROM assertions WHERE kind = 'excision_request'").fetchone() == ( + 0, + ) + class TestExciseLineageSafety: """CLI coverage for the polylogue-27m fix-round lineage-safety guard.""" diff --git a/tests/unit/daemon/test_backup.py b/tests/unit/daemon/test_backup.py index d3798c680d..8fcf7bd692 100644 --- a/tests/unit/daemon/test_backup.py +++ b/tests/unit/daemon/test_backup.py @@ -310,7 +310,7 @@ def test_backup_archive_copies_precious_tiers_and_referenced_blobs( receipt = json.loads(receipt_path.read_text(encoding="utf-8")) assert receipt["format"] == "polylogue-backup-verification-receipt-v2" attestations = {item["tier"]: item for item in receipt["attestations"]} - assert set(attestations) == {"source", "user"} + assert set(attestations) == {"audit", "source", "user"} assert attestations["user"]["algorithm"] == "hmac-sha256" assert len(attestations["user"]["mac"]) == 64 key_path = attestation_key_path(workspace_env["archive_root"] / "user.db") @@ -631,7 +631,7 @@ def test_backup_includes_reserved_blob_and_verifies_exact_hash_inventory( assert inventory == [ { "blob_hash": blob_hash, - "protection": ["reserved"], + "protection": ["referenced", "reserved"], "size_bytes": len(payload), } ] diff --git a/tests/unit/maintenance/test_raw_authority_reset.py b/tests/unit/maintenance/test_raw_authority_reset.py index d9be33c63e..465e802fdf 100644 --- a/tests/unit/maintenance/test_raw_authority_reset.py +++ b/tests/unit/maintenance/test_raw_authority_reset.py @@ -12,6 +12,7 @@ import pytest +from polylogue.maintenance import raw_authority_recovery from polylogue.maintenance.raw_authority_recovery import ( PruneOrphanedIndexRevisionSeedsActuator, RawAuthorityRecoveryError, @@ -26,6 +27,7 @@ write_recovery_plan, ) from polylogue.operations.mutation_transaction import OperationExecutor +from polylogue.storage.archive_identity import ArchiveOwnershipError, OwnedArchiveLocation from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.durable_change_train import write_source_continuity_pending_intent @@ -215,7 +217,7 @@ def test_census_reset_refuses_wal_visible_ledger_drift(tmp_path: Path, monkeypat refreshed = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.RESET_CENSUS, backup_manifest=backup) assert refreshed.ledger_digest != plan.ledger_digest assert refreshed.plan_digest != plan.plan_digest - with pytest.raises(RawAuthorityRecoveryError, match="stale before lease acquisition"): + with pytest.raises(RawAuthorityRecoveryError, match="stale after ownership acquisition"): apply_raw_authority_recovery(plan) with sqlite3.connect(source_db) as conn: assert conn.execute("SELECT residual_json FROM raw_authority_censuses WHERE census_id = 'c1'").fetchone() == ( @@ -276,6 +278,9 @@ def fail_final_receipt(root: Path, path: Path, payload: dict[str, object], *, di receipt = json.loads(receipt_path.read_text(encoding="utf-8")) assert receipt["operation_id"] == operation_id assert receipt["plan_digest"] == recovered.plan.plan_digest + with sqlite3.connect(tmp_path / "audit.db") as audit: + assert audit.execute("SELECT status FROM operation_runs").fetchone() == ("completed",) + assert audit.execute("SELECT state FROM operation_targets").fetchone() == ("applied",) def test_persisted_recovery_plan_ignores_process_scoped_archive_metadata( @@ -524,7 +529,7 @@ def test_uncommitted_recovery_intent_reauthorizes_through_executor( def require_authorization(*_args: object, **_kwargs: object) -> Never: raise RuntimeError("executor authorization was required") - monkeypatch.setattr(OperationExecutor, "authorize", require_authorization) + monkeypatch.setattr(OperationExecutor, "authorize_bound", require_authorization) with pytest.raises(RuntimeError, match="executor authorization was required"): apply_raw_authority_recovery(plan) with sqlite3.connect(tmp_path / "source.db") as conn: @@ -887,6 +892,48 @@ def test_census_reset_refuses_a_competing_source_continuity_intent( assert conn.execute("SELECT COUNT(*) FROM raw_authority_censuses").fetchone() == (1,) +@pytest.mark.parametrize("refusal", ["daemon", "owner"]) +def test_recovery_refusal_precedes_continuity_reconciliation_and_preview( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, refusal: str +) -> None: + """Offline refusal leaves source/audit bytes and their continuity rows unchanged.""" + + initialize_active_archive_root(tmp_path) + _seed_ledger(tmp_path / "source.db") + _seed_raw(tmp_path / "source.db", "r-keep") + backup = _backup_authority(tmp_path, monkeypatch, tier="source") + plan = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.RESET_CENSUS, backup_manifest=backup) + source_db = tmp_path / "source.db" + audit_db = tmp_path / "audit.db" + before_source = source_db.read_bytes() + before_audit = audit_db.read_bytes() + with sqlite3.connect(source_db) as source: + before_control = source.execute("SELECT * FROM audit_continuity_control").fetchall() + with sqlite3.connect(audit_db) as audit: + before_head = audit.execute("SELECT * FROM audit_continuity_head").fetchall() + + if refusal == "daemon": + monkeypatch.setattr(raw_authority_recovery, "running_daemon_pid", lambda _config: 123) + error = "polylogued is running" + else: + + def reject_owner(*_args: object, **_kwargs: object) -> object: + raise ArchiveOwnershipError("competing archive owner") + + monkeypatch.setattr(OwnedArchiveLocation, "acquire", reject_owner) + error = "competing archive owner" + + with pytest.raises(RawAuthorityRecoveryError, match=error): + apply_raw_authority_recovery(plan) + + assert source_db.read_bytes() == before_source + assert audit_db.read_bytes() == before_audit + with sqlite3.connect(source_db) as source: + assert source.execute("SELECT * FROM audit_continuity_control").fetchall() == before_control + with sqlite3.connect(audit_db) as audit: + assert audit.execute("SELECT * FROM audit_continuity_head").fetchall() == before_head + + def test_uncommitted_index_prune_intent_reauthorizes_before_deleting_candidates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -898,12 +945,12 @@ def test_uncommitted_index_prune_intent_reauthorizes_before_deleting_candidates( plan = inspect_raw_authority_recovery(tmp_path, RecoveryOperation.PRUNE_INDEX_SEEDS, backup_manifest=backup) _write_recovery_intent(plan) - original_authorize = OperationExecutor.authorize + original_authorize = OperationExecutor.authorize_bound def require_authorization(*_args: object, **_kwargs: object) -> Never: raise RuntimeError("executor authorization was required") - monkeypatch.setattr(OperationExecutor, "authorize", require_authorization) + monkeypatch.setattr(OperationExecutor, "authorize_bound", require_authorization) with pytest.raises(RuntimeError, match="executor authorization was required"): resume_raw_authority_recovery( tmp_path, @@ -914,7 +961,7 @@ def require_authorization(*_args: object, **_kwargs: object) -> Never: assert conn.execute("SELECT COUNT(*) FROM raw_revision_heads").fetchone() == (2,) assert conn.execute("SELECT COUNT(*) FROM raw_revision_applications").fetchone() == (2,) - monkeypatch.setattr(OperationExecutor, "authorize", original_authorize) + monkeypatch.setattr(OperationExecutor, "authorize_bound", original_authorize) resumed = resume_raw_authority_recovery( tmp_path, RecoveryOperation.PRUNE_INDEX_SEEDS, diff --git a/tests/unit/operations/test_mutation_actuators.py b/tests/unit/operations/test_mutation_actuators.py index 4bc357f06c..5f6d9f118e 100644 --- a/tests/unit/operations/test_mutation_actuators.py +++ b/tests/unit/operations/test_mutation_actuators.py @@ -31,6 +31,7 @@ from polylogue.core.enums import AssertionKind, Provider from polylogue.insights.feedback import LearningCorrection +from polylogue.operations.bindings import runtime_operation_binding from polylogue.operations.mutation_actuators import ( AnnotationDeleteActuator, AnnotationDeleteArgs, @@ -82,7 +83,12 @@ WorkspaceSaveActuator, WorkspaceSaveArgs, ) -from polylogue.operations.mutation_transaction import ConfirmationRequiredError, OperationExecutor, PlanStaleError +from polylogue.operations.mutation_transaction import ( + ConfirmationRequiredError, + MutationPrincipal, + OperationExecutor, + PlanStaleError, +) from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.user_write import ( @@ -140,22 +146,26 @@ def test_prepare_only_plans_currently_existing_sessions(self, tmp_path: Path) -> def test_full_lifecycle_deletes_the_session_row(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" archive_root.mkdir() + initialize_active_archive_root(archive_root) session_id = _seed_archive_session(archive_root, native_id="beta") with ArchiveStore.open_existing(archive_root, read_only=False) as archive: actuator = SessionDeleteActuator() - executor = OperationExecutor() + executor = OperationExecutor.for_archive_root(archive_root) args = SessionDeleteArgs(archive=archive, session_ids=(session_id,)) - plan = executor.prepare(actuator, args) - authorization = executor.authorize( - actuator, plan, actor="test", role="write", capability="test", confirmation_strength="confirm_flag" - ) - receipt = executor.execute(actuator, plan, authorization, args) + binding = runtime_operation_binding(actuator) + principal = MutationPrincipal("test", frozenset({"archive.delete_session"}), "api", "write") + preview = executor.prepare_bound_for_archive(binding, args, principal, archive_root=archive_root) + authorization = executor.authorize_bound(binding, preview, principal) + receipt = executor.execute_bound(binding, preview, authorization, args) assert receipt.status == "applied" assert receipt.affected_count == 1 with sqlite3.connect(archive_root / "index.db") as conn: assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 + with sqlite3.connect(archive_root / "audit.db") as conn: + assert conn.execute("SELECT state FROM operation_previews").fetchone()[0] == "consumed" + assert conn.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" def test_execute_without_authorization_confirm_flag_refuses(self, tmp_path: Path) -> None: archive_root = tmp_path / "archive" diff --git a/tests/unit/operations/test_mutations.py b/tests/unit/operations/test_mutations.py index 3a7f410b53..73bca480cb 100644 --- a/tests/unit/operations/test_mutations.py +++ b/tests/unit/operations/test_mutations.py @@ -7,6 +7,7 @@ from __future__ import annotations +import sqlite3 from pathlib import Path import pytest @@ -146,6 +147,9 @@ async def test_delete_then_not_found(self, workspace_env: dict[str, Path]) -> No assert first.session_id == _native("conv-del") assert second.outcome == "not_found" assert second.detail == "session_not_found" + with sqlite3.connect(workspace_env["archive_root"] / "audit.db") as audit: + assert audit.execute("SELECT state FROM operation_previews").fetchone()[0] == "consumed" + assert audit.execute("SELECT status FROM operation_runs").fetchone()[0] == "completed" async def test_missing_session_returns_not_found(self, workspace_env: dict[str, Path]) -> None: db_path = _seed(workspace_env) diff --git a/tests/unit/operations/test_operation_audit.py b/tests/unit/operations/test_operation_audit.py index f55dda476d..1186d8da40 100644 --- a/tests/unit/operations/test_operation_audit.py +++ b/tests/unit/operations/test_operation_audit.py @@ -1,14 +1,27 @@ from __future__ import annotations +import json +import os import sqlite3 -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field, replace from pathlib import Path +from typing import Any, cast import pytest +from pydantic import BaseModel -from polylogue.operations.audit import AuditRepository +from polylogue.operations.audit import ( + AuditRepository, + _attempt_owner_is_live, + _attempt_owner_liveness, + _current_process_attempt_owner, + token_sha256, +) from polylogue.operations.bindings import OperationBinding from polylogue.operations.mutation_transaction import ( + AuditFinalizationError, + AuthorizationMismatchError, CapabilityDeniedError, ConfirmationStrength, DestructiveClass, @@ -19,9 +32,18 @@ PlanStaleError, TargetAuthorityPolicy, TargetDurability, + TokenExpiredError, build_plan, ) from polylogue.operations.specs import OperationKind, OperationSpec +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator, AuditMutation +from polylogue.storage.sqlite.audit_leaf import ( + AuditLeafError, + VerifiedAuditLeaf, + open_verified_audit_connection, + open_verified_audit_read_connection, +) @dataclass @@ -31,15 +53,16 @@ class _Actuator: changed: bool = False calls: int = 0 crash: bool = False + target_refs: tuple[str, ...] = ("session:fixture",) destructive_class: DestructiveClass = "reversible" required_confirmation: ConfirmationStrength = "role_only" def prepare(self, _args: object) -> MutationPlan: - target = "session:changed" if self.changed else "session:fixture" + targets = ("session:changed",) if self.changed else self.target_refs return build_plan( operation=self.operation, destructive_class="reversible", - target_refs=(target,), + target_refs=targets, affected_tiers=("user",), reversible=True, ) @@ -53,13 +76,50 @@ def apply(self, plan: MutationPlan, _args: object) -> MutationReceipt: plan_hash=plan.plan_hash, status="applied", target_refs=plan.target_refs, - affected_count=1, + affected_count=len(plan.target_refs), detail=None, receipt_ref=None, applied_at="now", ) +@dataclass(frozen=True) +class _TypedDomainBatch: + batch_ref: str + rows: tuple[str, ...] + _cached_bytes: bytes = field(init=False, repr=False, default=b"private-cache") + + +class _TypedDomainOutcome(BaseModel): + row_ref: str + status: str + + +@dataclass +class _TypedReceiptActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + receipt = super().apply(plan, args) + return replace( + receipt, + domain_receipt={ + "batch": _TypedDomainBatch("annotation-batch:typed", ("assertion:typed",)), + "outcomes": (_TypedDomainOutcome(row_ref="assertion:typed", status="imported"),), + }, + ) + + +@dataclass +class _AlreadySatisfiedActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + return replace(super().apply(plan, args), status="already_satisfied", affected_count=0) + + +@dataclass +class _FailedReceiptActuator(_Actuator): + def apply(self, plan: MutationPlan, args: object) -> MutationReceipt: + return replace(super().apply(plan, args), status="failed", affected_count=0) + + def _binding( actuator: _Actuator, *, target_durability: TargetDurability = "derived" ) -> OperationBinding[object, object]: @@ -90,8 +150,13 @@ def _principal() -> MutationPrincipal: return MutationPrincipal("actor:test", frozenset({"archive.fixture.write"}), "internal", "system") +def _audit(tmp_path: Path) -> AuditRepository: + initialize_active_archive_root(tmp_path) + return AuditRepository.for_archive_root(tmp_path) + + def test_token_is_digest_only_and_consumption_run_attempt_are_atomic(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) audit.ensure_archive_authority(now_ms=1) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "raw-secret-token") @@ -105,6 +170,9 @@ def test_token_is_digest_only_and_consumption_run_attempt_are_atomic(tmp_path: P ) authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) assert "raw-secret-token" not in (tmp_path / "audit.db").read_bytes().decode("utf-8", errors="ignore") + digest_as_bearer = replace(authorization, token=f"sha256:{token_sha256('raw-secret-token')}") + with pytest.raises(ValueError, match="does not match preview"): + audit.consume_authorization_and_start(preview, digest_as_bearer) receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) assert receipt.operation_id is not None operation = audit.get_operation(receipt.operation_id) @@ -134,8 +202,910 @@ def test_prepare_bound_uses_the_declared_durable_target_for_legacy_actuators() - assert preview.plan.targets[0].recovery == "none" +def test_production_executor_factory_persists_audit_preview(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + actuator = _Actuator() + executor = OperationExecutor.for_archive_root(tmp_path, token_factory=lambda: "factory-token") + + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:test", + archive_identity_digest="identity:test", + parameter_digest="params:test", + ) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT preview_id FROM operation_previews").fetchone()[0] == preview.preview_ref + + +def test_audit_authority_rejects_a_symlinked_audit_leaf_without_touching_its_target(tmp_path: Path) -> None: + """Bootstrap and direct audit access never follow an audit path outside its archive root.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + external_audit = tmp_path.parent / "external-audit.db" + external_audit.write_bytes(audit_path.read_bytes()) + audit_path.unlink() + audit_path.symlink_to(external_audit) + before = external_audit.read_bytes() + + with pytest.raises(RuntimeError, match="archive-owned regular file"): + initialize_active_archive_root(tmp_path) + with pytest.raises(RuntimeError, match="archive-owned regular file"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert external_audit.read_bytes() == before + + +def test_audit_authority_rejects_a_hardlinked_audit_leaf_without_touching_its_target(tmp_path: Path) -> None: + """A regular-looking audit leaf must still have exactly one archive-owned link.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + external_audit = tmp_path.parent / "external-hardlinked-audit.db" + external_audit.write_bytes(audit_path.read_bytes()) + audit_path.unlink() + audit_path.hardlink_to(external_audit) + before = external_audit.read_bytes() + + with pytest.raises(RuntimeError, match="one link"): + initialize_active_archive_root(tmp_path) + with pytest.raises(RuntimeError, match="one link"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert external_audit.read_bytes() == before + + +def test_audit_authority_rejects_a_foreign_owned_audit_leaf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The authority leaf must belong to this effective archive owner. + + Anti-vacuity: removing the uid comparison accepts the otherwise-valid + single-linked regular file and allows the authority check to proceed. + """ + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + before = audit_path.read_bytes() + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.os.geteuid", lambda: audit_path.stat().st_uid + 1) + + with pytest.raises(RuntimeError, match="current effective user"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert audit_path.read_bytes() == before + + +def test_audit_leaf_uses_the_verified_native_directory_when_descriptor_children_are_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A portable native descriptor path is used before pseudo-filesystem traversal. + + Anti-vacuity: removing the F_GETPATH-style route makes this macOS-shaped + host fail closed because neither pseudo-filesystem child is available. + """ + + initialize_active_archive_root(tmp_path) + + def native_path_from_descriptor(_fd: int, _request: int, _buffer: bytes) -> bytes: + return os.fsencode(tmp_path) + b"\0" + + monkeypatch.setattr(VerifiedAuditLeaf, "_descriptor_child_path", lambda _self: None) + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.fcntl.F_GETPATH", 50, raising=False) + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.fcntl.fcntl", native_path_from_descriptor) + with VerifiedAuditLeaf(tmp_path) as leaf: + assert leaf.anchored_path == tmp_path / "audit.db" + + +def test_audit_leaf_closes_its_directory_descriptor_after_validation_failure(tmp_path: Path) -> None: + """Rejected leaves do not retain one descriptor per failed authority request. + + Anti-vacuity: the old OSError-only cleanup leaves ``_directory_fd`` set + after this symlink validation error. + """ + + target = tmp_path.parent / "external-audit-leaf.db" + target.write_bytes(b"external") + (tmp_path / "audit.db").symlink_to(target) + leaf = VerifiedAuditLeaf(tmp_path) + + with pytest.raises(AuditLeafError): + leaf.__enter__() + + assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_a_foreign_sidecar_without_leaking_descriptors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The SQLite namespace is validated with the main leaf before any writer opens. + + Anti-vacuity: checking only audit.db accepts this foreign-owned WAL leaf + and lets SQLite consume attacker-controlled sidecar bytes. + """ + + initialize_active_archive_root(tmp_path) + sidecar = tmp_path / "audit.db-wal" + sidecar.write_bytes(b"not a sqlite wal") + leaf = VerifiedAuditLeaf(tmp_path) + real_stat = sidecar.stat() + real_os_stat = os.stat + + def foreign_sidecar_metadata( + path: str | bytes | os.PathLike[str] | os.PathLike[bytes], *args: Any, **kwargs: Any + ) -> os.stat_result: + metadata = real_os_stat(path, *args, **kwargs) + if path == "audit.db-wal": + values = list(metadata) + values[4] = real_stat.st_uid + 1 + return os.stat_result(values) + return metadata + + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.os.stat", foreign_sidecar_metadata) + + with pytest.raises(AuditLeafError, match="sidecar.*current effective user"): + leaf.__enter__() + + assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_group_writable_archive_directory(tmp_path: Path) -> None: + """A second Unix principal cannot plant an SQLite sidecar in the authority namespace.""" + + initialize_active_archive_root(tmp_path) + tmp_path.chmod(0o770) + leaf = VerifiedAuditLeaf(tmp_path) + + with pytest.raises(AuditLeafError, match="directory must not be writable by group or other"): + leaf.__enter__() + + assert leaf._directory_fd is None + assert leaf._leaf_fd is None + + +def test_audit_leaf_rejects_group_writable_main_and_sidecar_files(tmp_path: Path) -> None: + """UID equality alone cannot grant exclusive write authority over SQLite files.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + audit_path.chmod(0o660) + with pytest.raises(AuditLeafError, match="audit tier must not be writable by group or other"): + VerifiedAuditLeaf(tmp_path).__enter__() + + audit_path.chmod(0o600) + sidecar = tmp_path / "audit.db-wal" + sidecar.write_bytes(b"not a sqlite wal") + sidecar.chmod(0o660) + with pytest.raises(AuditLeafError, match="sidecar must not be writable by group or other"): + VerifiedAuditLeaf(tmp_path).__enter__() + + +def test_audit_leaf_serializes_writers_across_the_main_and_sidecar_namespace(tmp_path: Path) -> None: + """A second writer cannot validate then race the first SQLite namespace owner. + + Anti-vacuity: without the nonblocking main-leaf lock, both contexts open + and can independently create or replace the audit sidecar namespace. + """ + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + with open_verified_audit_connection(audit_path): + with pytest.raises(AuditLeafError, match="active writer"): + with open_verified_audit_connection(audit_path): + pass + + +def test_audit_authority_rejects_a_leaf_replaced_during_sqlite_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """SQLite never yields a connection after the descriptor-checked leaf changes.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + replacement = tmp_path / "replacement-audit.db" + replacement.write_bytes(audit_path.read_bytes()) + before = replacement.read_bytes() + original_connect = cast(Callable[..., sqlite3.Connection], sqlite3.connect) + swapped = False + + def replace_after_open(database: object, *args: object, **kwargs: object) -> sqlite3.Connection: + nonlocal swapped + connection = original_connect(database, *args, **kwargs) + database_text = str(database) + if ( + not swapped + and database_text.split("?", 1)[0].endswith("/audit.db") + and ("/dev/fd/" in database_text or "/proc/self/fd/" in database_text) + ): + swapped = True + audit_path.unlink() + replacement.replace(audit_path) + return connection + + monkeypatch.setattr("polylogue.storage.sqlite.audit_leaf.sqlite3.connect", replace_after_open) + + with pytest.raises(RuntimeError, match="changed during SQLite open"): + AuditRepository.for_archive_root(tmp_path).ensure_archive_authority(now_ms=1) + + assert swapped + assert audit_path.read_bytes() == before + + +def test_verified_audit_writer_rejects_a_wal_replacement_before_first_application_begin(tmp_path: Path) -> None: + """The production writer pins WAL/SHM before a caller can start its transaction.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + wal_path = audit_path.with_name("audit.db-wal") + + with pytest.raises(sqlite3.DatabaseError, match="not authorized"): + with open_verified_audit_connection(audit_path) as connection: + replacement = tmp_path / "replacement-audit.db-wal" + replacement.write_bytes(wal_path.read_bytes()) + replacement.replace(wal_path) + connection.execute("BEGIN IMMEDIATE") + + +def test_verified_audit_reader_observes_a_committed_live_wal_head(tmp_path: Path) -> None: + """Read-only authority checks include commits still resident in the live WAL.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + archive_id = "archive:live-wal-read" + + with open_verified_audit_connection(audit_path) as writer: + writer.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, 1, 1)", + (archive_id,), + ) + writer.commit() + assert audit_path.with_name("audit.db-wal").exists() + + with open_verified_audit_read_connection(audit_path) as reader: + assert reader.execute( + "SELECT archive_instance_id FROM archive_authority WHERE archive_instance_id = ?", (archive_id,) + ).fetchone() == (archive_id,) + + +def test_verified_audit_writer_coexists_with_an_older_read_transaction(tmp_path: Path) -> None: + """Persistent WAL mode lets a later writer proceed while a reader retains its snapshot.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + with open_verified_audit_connection(audit_path) as writer: + assert writer.execute("PRAGMA journal_mode").fetchone() == ("wal",) + + with open_verified_audit_read_connection(audit_path) as reader: + reader.execute("BEGIN") + reader.execute("SELECT generation FROM audit_continuity_head").fetchone() + with open_verified_audit_connection(audit_path) as writer: + writer.execute("BEGIN IMMEDIATE") + writer.execute("UPDATE audit_continuity_head SET advanced_at_ms = advanced_at_ms") + writer.commit() + + +def test_production_factory_does_not_abandon_a_live_same_process_attempt(tmp_path: Path) -> None: + """A second composition-root call recognizes the first executor's owner.""" + initialize_active_archive_root(tmp_path) + actuator = _Actuator() + first = OperationExecutor.for_archive_root(tmp_path, token_factory=lambda: "first-owner-token") + preview = first.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:live-owner", + archive_identity_digest="identity:live-owner", + parameter_digest="params:live-owner", + ) + authorization = first.authorize_bound(_binding(actuator), preview, _principal()) + assert first._audit is not None + operation_id = first._audit.consume_authorization_and_start(preview, authorization) + + OperationExecutor.for_archive_root(tmp_path) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert ( + conn.execute( + "SELECT state, worker_id FROM operation_attempts WHERE operation_id = ?", (operation_id,) + ).fetchone()[0] + == "running" + ) + + +def test_recovery_marks_a_dead_process_owned_attempt_unknown(tmp_path: Path) -> None: + """Restart recovery remains active when the recorded owner no longer exists.""" + initialize_active_archive_root(tmp_path) + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "dead-owner-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:dead-owner", + archive_identity_digest="identity:dead-owner", + parameter_digest="params:dead-owner", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute( + "UPDATE operation_attempts SET worker_id = 'pid:999999999:0' WHERE operation_id = ?", (operation_id,) + ) + conn.commit() + + assert audit.recover_abandoned_attempts() == (operation_id,) + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() == ( + "interrupted", + ) + + +@pytest.mark.parametrize("owner_id", [None, "external:unverifiable"]) +def test_recovery_preserves_attempts_with_unproven_owners(tmp_path: Path, owner_id: str | None) -> None: + """Legacy or externally-owned attempts stay running until their owner is proven dead.""" + + initialize_active_archive_root(tmp_path) + audit = _audit(tmp_path) + executor = OperationExecutor(audit=audit, token_factory=lambda: "unproven-owner-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:unproven-owner", + archive_identity_digest="identity:unproven-owner", + parameter_digest="params:unproven-owner", + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute("UPDATE operation_attempts SET worker_id = ? WHERE operation_id = ?", (owner_id, operation_id)) + conn.commit() + + assert audit.recover_abandoned_attempts() == () + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs WHERE operation_id = ?", (operation_id,)).fetchone() == ( + "running", + ) + + +def test_process_owner_liveness_is_unknown_when_start_ticks_cannot_be_read(monkeypatch: pytest.MonkeyPatch) -> None: + """A live PID with unreadable identity evidence is not proof that its owner died.""" + + monkeypatch.setattr("polylogue.operations.audit.os.kill", lambda _pid, _signal: None) + monkeypatch.setattr(Path, "read_text", lambda _self, *, encoding: (_ for _ in ()).throw(OSError("denied"))) + + assert _attempt_owner_liveness("pid:321:known-start") == "unknown" + + +def test_process_owner_uses_proc_start_ticks_after_a_spaced_process_name(monkeypatch: pytest.MonkeyPatch) -> None: + """PID reuse remains detectable when /proc's parenthesized comm has spaces.""" + + state = {"stat": "321 (worker process) S " + " ".join(["0"] * 17 + ["stable", "old", "0"])} + + def read_text(self: Path, *, encoding: str) -> str: + assert self == Path("/proc/321/stat") + assert encoding == "utf-8" + return state["stat"] + + monkeypatch.setattr("polylogue.operations.audit.os.getpid", lambda: 321) + monkeypatch.setattr("polylogue.operations.audit.os.kill", lambda _pid, _signal: None) + monkeypatch.setattr(Path, "read_text", read_text) + + owner = _current_process_attempt_owner() + assert owner == "pid:321:old" + + state["stat"] = "321 (worker process) S " + " ".join(["0"] * 17 + ["stable", "new", "0"]) + assert not _attempt_owner_is_live(owner) + + +def test_audit_repository_cannot_bypass_the_continuity_coordinator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + audit = _audit(tmp_path) + + def reject_bypass(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("coordinator required") + + monkeypatch.setattr(AuditContinuityCoordinator, "execute", reject_bypass) + with pytest.raises(RuntimeError, match="coordinator required"): + audit.ensure_archive_authority(now_ms=1) + + +def test_audit_repository_replays_a_prepared_mutation_with_its_original_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + audit = _audit(tmp_path) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("crash after prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match="crash after prepare"): + audit.ensure_archive_authority(now_ms=123, archive_instance_id="archive:replayed") + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT archive_instance_id, created_at_ms FROM archive_authority").fetchone() == ( + "archive:replayed", + 123, + ) + + +def test_replayed_start_keeps_the_crashed_owner_recoverable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Recovery never adopts an actuator-less pre-effect attempt into its own process.""" + + initialize_active_archive_root(tmp_path) + crashed_owner = "pid:999999999:0" + first = AuditRepository.for_archive_root(tmp_path, attempt_owner_id=crashed_owner) + executor = OperationExecutor(audit=first, token_factory=lambda: "replayed-owner-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:replayed-owner", + archive_identity_digest="identity:replayed-owner", + parameter_digest="params:replayed-owner", + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_start(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "consume_authorization_and_start" and phase == "after_source_prepare": + raise RuntimeError("crash after start prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_start) + with pytest.raises(RuntimeError, match="crash after start prepare"): + first.consume_authorization_and_start(preview, authorization) + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + recovery = AuditRepository.for_archive_root(tmp_path, attempt_owner_id="pid:12345:recovery") + recovery.reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + operation_id = str(conn.execute("SELECT operation_id FROM operation_runs").fetchone()[0]) + assert conn.execute( + "SELECT worker_id FROM operation_attempts WHERE operation_id = ?", (operation_id,) + ).fetchone() == (crashed_owner,) + + assert recovery.recover_abandoned_attempts() == (operation_id,) + assert recovery.get_operation(operation_id)["status"] == "interrupted" # type: ignore[index] + + +def test_optional_archive_authority_id_replays_without_changing_existing_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An omitted authority id remains omitted across a pre-commit crash.""" + + audit = _audit(tmp_path) + assert audit.ensure_archive_authority(now_ms=1, archive_instance_id="archive:existing") == "archive:existing" + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "ensure_archive_authority" and phase == "after_source_prepare": + raise RuntimeError("crash after optional authority prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match="optional authority prepare"): + audit.ensure_archive_authority(now_ms=2) + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT archive_instance_id, created_at_ms FROM archive_authority").fetchone() == ( + "archive:existing", + 1, + ) + + +def test_typed_domain_receipt_replays_after_source_prepare_crash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real executor route persists and replays typed receipt values as JSON.""" + + audit = _audit(tmp_path) + actuator = _TypedReceiptActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "typed-receipt-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:typed-receipt", + archive_identity_digest="identity:typed-receipt", + parameter_digest="params:typed-receipt", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + original_phase = AuditContinuityCoordinator._phase + + def interrupt_finalize(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "finalize_attempt" and phase == "after_source_prepare": + raise RuntimeError("crash after typed receipt prepare") + original_phase(self, phase, mutation) + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_finalize) + with pytest.raises(AuditFinalizationError, match="not reported completed"): + executor.execute_bound(_binding(actuator), preview, authorization, object()) + with sqlite3.connect(tmp_path / "source.db") as source: + pending_payload = str(source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0]) + assert "private-cache" not in pending_payload + assert "annotation-batch:typed" not in pending_payload + assert '"domain_receipt"' not in pending_payload + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", original_phase) + + AuditRepository.for_archive_root(tmp_path).reconcile_continuity() + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT status FROM operation_runs").fetchone() == ("completed",) + receipt_json = str( + conn.execute("SELECT detail_json FROM operation_events WHERE event_type = 'attempt_finalized'").fetchone()[ + 0 + ] + ) + assert "private-cache" not in receipt_json + detail = json.loads(receipt_json) + assert detail["affected_count"] == 1 + assert "domain_receipt" not in detail + assert "annotation-batch:typed" not in receipt_json + with sqlite3.connect(tmp_path / "source.db") as source: + command = source.execute("SELECT pending_payload_json FROM audit_continuity_control").fetchone()[0] + assert command is None + + +def test_atomic_batch_finalization_marks_every_target_and_terminates_run(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=("session:first", "session:second")) + executor = OperationExecutor(audit=audit, token_factory=lambda: "batch-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:batch", + archive_identity_digest="identity:batch", + parameter_digest="params:batch", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, affected_count, unknown_count FROM operation_runs WHERE operation_id = ?", + (receipt.operation_id,), + ).fetchone() == ("completed", 2, 0) + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ? ORDER BY ordinal", + (receipt.operation_id,), + ).fetchall() == [("applied",), ("applied",)] + + +def test_zero_target_finalization_completes_a_successful_noop(tmp_path: Path) -> None: + """The real start/finalize route terminalizes a successful empty target set.""" + + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=()) + executor = OperationExecutor(audit=audit, token_factory=lambda: "zero-target-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:zero-target", + archive_identity_digest="identity:zero-target", + parameter_digest="params:zero-target", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, terminal_reason, affected_count FROM operation_runs WHERE operation_id = ?", + (receipt.operation_id,), + ).fetchone() == ("completed", None, 0) + + +def test_expired_authorization_is_durably_marked_before_execute_refuses( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The bound execution route commits expiry instead of rolling it back with the refusal.""" + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "expired-token") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:expired", + archive_identity_digest="identity:expired", + parameter_digest="params:expired", + expires_at_ms=61_000, + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + clock[0] = 61_000 + monkeypatch.setattr("polylogue.operations.audit.time.time", lambda: 61.0) + + with pytest.raises(TokenExpiredError): + executor.execute_bound(_binding(_Actuator()), preview, authorization, object()) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("expired",) + + +def test_authorization_expiry_is_canonicalized_from_the_durable_preview(tmp_path: Path) -> None: + """A caller cannot issue a longer-lived bearer than the preview authorizes. + + Anti-vacuity: storing ``authorization.expires_at_ms`` accepts the forged + expiry and leaves an authorization row that outlives its durable preview. + """ + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "canonical-expiry") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:canonical-expiry", + archive_identity_digest="identity:canonical-expiry", + parameter_digest="params:canonical-expiry", + expires_at_ms=2_000, + ) + authorization = executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + + with pytest.raises(ValueError, match="evidence differs"): + audit.issue_authorization( + preview, + _principal(), + replace(authorization, token="forged-expiry", expires_at_ms=3_000), + issued_at_ms=1_100, + ) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT expires_at_ms FROM operation_previews").fetchone() == (2_000,) + assert conn.execute("SELECT expires_at_ms FROM operation_authorizations").fetchone() == (2_000,) + + +def test_authorization_consumption_uses_durable_actor_and_capability_evidence(tmp_path: Path) -> None: + """Execution refuses reconstructed authority that differs from durable rows. + + Anti-vacuity: persisting run fields from the caller object records this + substituted actor/capability instead of the issued authorization evidence. + """ + + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "durable-evidence") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:durable-evidence", + archive_identity_digest="identity:durable-evidence", + parameter_digest="params:durable-evidence", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + forged = replace( + authorization, + actor="actor:substituted", + role="administrator", + capability="archive.substituted.write", + capabilities=("archive.substituted.write",), + ) + + with pytest.raises(ValueError, match="principal mismatch"): + audit.consume_authorization_and_start(preview, forged) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM operation_runs").fetchone() == (0,) + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("active",) + assert conn.execute("SELECT actor_ref FROM operation_authorizations").fetchone() == ("actor:test",) + assert conn.execute("SELECT capability FROM operation_authorization_capabilities").fetchone() == ( + "archive.fixture.write", + ) + + +def test_authorization_replay_preserves_the_prepared_expiry_and_issue_clock( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A retried WAL authorization reuses its prepared durable evidence verbatim. + + Anti-vacuity: omitting ``issued_at_ms`` during replay silently substitutes + the recovery clock and can make an authorization valid longer than the + original prepared command proved. + """ + + audit = _audit(tmp_path) + clock = [1_000] + executor = OperationExecutor(audit=audit, now_ms=lambda: clock[0], token_factory=lambda: "replayed-expiry") + preview = executor.prepare_bound( + _binding(_Actuator()), + object(), + _principal(), + archive_instance_id="archive:replayed-expiry", + archive_identity_digest="identity:replayed-expiry", + parameter_digest="params:replayed-expiry", + expires_at_ms=2_000, + ) + original_abort = AuditContinuityCoordinator._abort_prepared + + def interrupt_issue(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "issue_authorization" and phase == "after_source_prepare": + raise RuntimeError("crash after authorization prepare") + + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_issue) + monkeypatch.setattr(AuditContinuityCoordinator, "_abort_prepared", lambda _self, _prepared: None) + with pytest.raises(RuntimeError, match="authorization prepare"): + executor.authorize_bound(_binding(_Actuator()), preview, _principal()) + + monkeypatch.setattr(AuditContinuityCoordinator, "_abort_prepared", original_abort) + audit.reconcile_continuity() + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT issued_at_ms, expires_at_ms FROM operation_authorizations").fetchone() == ( + 1_000, + 2_000, + ) + + +def test_already_satisfied_receipt_preserves_target_state_and_zero_affected_count(tmp_path: Path) -> None: + """A nonempty idempotent success remains distinct from an applied domain effect.""" + + audit = _audit(tmp_path) + actuator = _AlreadySatisfiedActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "already-satisfied-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:already-satisfied", + archive_identity_digest="identity:already-satisfied", + parameter_digest="params:already-satisfied", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("already_satisfied",) + assert conn.execute( + "SELECT status, affected_count FROM operation_runs WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ( + "completed", + 0, + ) + + +def test_failed_receipt_marks_the_audit_attempt_failed(tmp_path: Path) -> None: + """A domain-declared failure cannot leave an applied attempt receipt. + + Anti-vacuity: restoring the rejected-only attempt mapping makes the target + failed while this attempt row incorrectly returns applied. + """ + + audit = _audit(tmp_path) + actuator = _FailedReceiptActuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "failed-receipt-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:failed-receipt", + archive_identity_digest="identity:failed-receipt", + parameter_digest="params:failed-receipt", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + receipt = executor.execute_bound(_binding(actuator), preview, authorization, object()) + + assert receipt.operation_id is not None + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT state FROM operation_attempts WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("failed",) + assert conn.execute( + "SELECT status FROM operation_runs WHERE operation_id = ?", (receipt.operation_id,) + ).fetchone() == ("failed",) + + +def test_tampered_preview_payload_refuses_before_audit_intent(tmp_path: Path) -> None: + """Execution cannot journal targets substituted into a reconstructed preview. + + Anti-vacuity: deleting the typed hash validation consumes the token and + creates an audit run whose target set comes from the altered preview. + """ + + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "tampered-preview-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:tampered-preview", + archive_identity_digest="identity:tampered-preview", + parameter_digest="params:tampered-preview", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + tampered_preview = replace(preview, plan=replace(preview.plan, targets=())) + + with pytest.raises(AuthorizationMismatchError, match="authority hash"): + executor.execute_bound(_binding(actuator), tampered_preview, authorization, object()) + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM operation_runs").fetchone() == (0,) + assert conn.execute("SELECT state FROM operation_authorizations").fetchone() == ("active",) + + +def test_blocked_finalization_rejects_targets_and_fails_parent_run(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator() + executor = OperationExecutor(audit=audit, token_factory=lambda: "blocked-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:blocked", + archive_identity_digest="identity:blocked", + parameter_digest="params:blocked", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + + audit.finalize_attempt(operation_id, status="blocked") + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, terminal_reason, rejected_count FROM operation_runs WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("failed", "target_rejected", 1) + assert conn.execute( + "SELECT state FROM operation_targets WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("rejected",) + + +def test_reconciliation_resolves_the_full_unknown_atomic_batch(tmp_path: Path) -> None: + audit = _audit(tmp_path) + actuator = _Actuator(target_refs=("session:first", "session:second")) + executor = OperationExecutor(audit=audit, token_factory=lambda: "reconcile-token") + preview = executor.prepare_bound( + _binding(actuator), + object(), + _principal(), + archive_instance_id="archive:reconcile", + archive_identity_digest="identity:reconcile", + parameter_digest="params:reconcile", + ) + authorization = executor.authorize_bound(_binding(actuator), preview, _principal()) + operation_id = audit.consume_authorization_and_start(preview, authorization) + with sqlite3.connect(tmp_path / "audit.db") as conn: + conn.execute( + "UPDATE operation_attempts SET worker_id = 'pid:999999999:0' WHERE operation_id = ?", (operation_id,) + ) + conn.commit() + audit.recover_abandoned_attempts() + + audit.reconcile_attempt(operation_id, outcome="applied", domain_receipt_ref="receipt:reconciled") + + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute( + "SELECT status, affected_count, unknown_count FROM operation_runs WHERE operation_id = ?", (operation_id,) + ).fetchone() == ("completed", 2, 0) + assert conn.execute( + "SELECT state, domain_receipt_ref FROM operation_targets WHERE operation_id = ? ORDER BY ordinal", + (operation_id,), + ).fetchall() == [("applied", "receipt:reconciled"), ("applied", "receipt:reconciled")] + + def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "token") preview = executor.prepare_bound( @@ -160,7 +1130,7 @@ def test_invalid_capability_and_stale_preview_refuse_before_apply(tmp_path: Path def test_crash_after_intent_is_queryable_unknown_and_never_completed(tmp_path: Path) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator(crash=True) executor = OperationExecutor(audit=audit, token_factory=lambda: "crash-token") preview = executor.prepare_bound( @@ -193,7 +1163,7 @@ def test_crash_after_intent_is_queryable_unknown_and_never_completed(tmp_path: P def test_token_consumption_and_initial_attempt_roll_back_together( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - audit = AuditRepository(tmp_path / "audit.db") + audit = _audit(tmp_path) actuator = _Actuator() executor = OperationExecutor(audit=audit, token_factory=lambda: "rollback-token") preview = executor.prepare_bound( diff --git a/tests/unit/operations/test_operation_bindings.py b/tests/unit/operations/test_operation_bindings.py index 903b870eb3..7b14a233c4 100644 --- a/tests/unit/operations/test_operation_bindings.py +++ b/tests/unit/operations/test_operation_bindings.py @@ -18,7 +18,7 @@ TargetAuthorityPolicy, build_plan, ) -from polylogue.operations.specs import OperationKind, OperationSpec +from polylogue.operations.specs import OperationKind, OperationSpec, build_runtime_operation_catalog @dataclass @@ -119,3 +119,18 @@ def test_catalog_requires_every_executor_routed_spec_and_resolves_only_registere def test_catalog_rejects_missing_binding() -> None: with pytest.raises(BindingValidationError, match="missing"): validate_operation_bindings((_spec(),), ()) + + +def test_runtime_executor_routes_have_specific_capabilities_and_surfaces() -> None: + specs = build_runtime_operation_catalog().by_name().values() + routed = [spec for spec in specs if spec.executor_status == "executor-routed"] + + assert routed + assert all(spec.target_authority for spec in routed) + assert all(spec.allowed_surfaces for spec in routed) + assert all( + capability != "archive.legacy_runtime" + for spec in routed + for policy in spec.target_authority + for capability in policy.required_capabilities + ) diff --git a/tests/unit/operations/test_specs.py b/tests/unit/operations/test_specs.py index 79e1909b74..e663d3704f 100644 --- a/tests/unit/operations/test_specs.py +++ b/tests/unit/operations/test_specs.py @@ -1,5 +1,6 @@ from __future__ import annotations +from polylogue.core.user_state_targets import TARGET_KIND_NAMES from polylogue.operations import ( OperationKind, build_declared_operation_catalog, @@ -68,6 +69,7 @@ def test_runtime_operation_catalog_covers_the_current_runtime_paths() -> None: "mutate-delete-session", "mutate-bulk-tag-sessions", "mutate-session-excision", + "mutate-session-lifecycle-request", "mutate-identity-reset", } assert specs["acquire-raw-sessions"].kind is OperationKind.MATERIALIZATION @@ -152,6 +154,17 @@ def test_raw_authority_recovery_specs_declare_their_exact_target_kinds() -> None ] +def test_user_mutation_policy_tracks_the_supported_target_registry_and_identity_recovery() -> None: + """Bound facade writes accept every user-state target the core registry admits.""" + + specs = build_runtime_operation_catalog().by_name() + add_mark_policy = specs["mutate-add-mark"].target_authority[0] + identity_reset_policy = specs["mutate-identity-reset"].target_authority[0] + + assert set(TARGET_KIND_NAMES).issubset(add_mark_policy.target_kinds) + assert identity_reset_policy.allowed_recovery == ("reconcile_required",) + + def test_declared_operation_catalog_contains_runtime_and_control_plane_operations() -> None: catalog = build_declared_operation_catalog() diff --git a/tests/unit/storage/test_audit_continuity.py b/tests/unit/storage/test_audit_continuity.py new file mode 100644 index 0000000000..48aef3eb41 --- /dev/null +++ b/tests/unit/storage/test_audit_continuity.py @@ -0,0 +1,369 @@ +"""Crash-window and rollback proofs for the source-backed audit head.""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest + +from polylogue.storage.sqlite.archive_tiers.audit import AUDIT_DDL +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root +from polylogue.storage.sqlite.archive_tiers.source import SOURCE_DDL +from polylogue.storage.sqlite.audit_continuity import ( + AUDIT_CONTINUITY_GENESIS_HEAD_SHA256, + AuditContinuityCoordinator, + AuditContinuityError, + AuditMutation, + audit_semantic_sha256, +) + + +def test_genesis_head_is_shared_by_fresh_ddl_and_additive_migrations() -> None: + migration_paths = ( + Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql"), + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql"), + ) + + assert AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in AUDIT_DDL + assert AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in SOURCE_DDL + assert all(AUDIT_CONTINUITY_GENESIS_HEAD_SHA256 in path.read_text(encoding="utf-8") for path in migration_paths) + + +def _mutation(number: int) -> AuditMutation: + return AuditMutation( + kind="test-audit-write", + mutation_id=f"mutation:{number}", + created_at_ms=number, + payload={"number": number}, + ) + + +def _apply(conn: sqlite3.Connection, mutation: AuditMutation) -> str: + conn.execute( + "INSERT OR IGNORE INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", + (f"archive:{mutation.mutation_id}", mutation.created_at_ms), + ) + return mutation.mutation_id + + +def test_same_inode_stale_audit_copy_is_rejected(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + coordinator = AuditContinuityCoordinator(tmp_path) + coordinator.execute(_mutation(1), _apply) + stale_bytes = (tmp_path / "audit.db").read_bytes() + coordinator.execute(_mutation(2), _apply) + + # Deliberately overwrite rather than replace the path. The inode remains + # stable, so this proves the source/audit head catches the rollback that + # the former st_dev/st_ino receipt accepted. + audit_path = tmp_path / "audit.db" + inode = audit_path.stat().st_ino + audit_path.write_bytes(stale_bytes) + assert audit_path.stat().st_ino == inode + + with pytest.raises(AuditContinuityError, match="regressed|replaced"): + coordinator.reconcile(_apply) + + +def test_crash_before_source_prepare_leaves_no_command(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "before_source_prepare": + raise RuntimeError("crash before source prepare") + + with pytest.raises(RuntimeError, match="crash before source"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with closing(sqlite3.connect(tmp_path / "source.db")) as source: + assert source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone() == (None,) + + +def test_pending_command_replays_after_audit_rollback(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("crash before audit commit") + + with pytest.raises(RuntimeError, match="crash before"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "audit.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM archive_authority").fetchone()[0] == 1 + + +def test_pending_command_promotes_after_audit_commit(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_audit_commit": + raise RuntimeError("crash before source promotion") + + with pytest.raises(RuntimeError, match="crash before"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "source.db") as conn: + assert conn.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone()[0] is None + + +@pytest.mark.parametrize( + ("dropped_table", "error"), + [ + ("audit_continuity_control", "current source schema"), + ("audit_continuity_head", "current audit schema"), + ], +) +def test_current_schema_missing_a_continuity_table_is_damage(tmp_path: Path, dropped_table: str, error: str) -> None: + initialize_active_archive_root(tmp_path) + path = tmp_path / ("source.db" if dropped_table.endswith("control") else "audit.db") + with sqlite3.connect(path) as connection: + connection.execute(f"DROP TABLE {dropped_table}") + connection.commit() + + with pytest.raises(AuditContinuityError, match=error): + AuditContinuityCoordinator(tmp_path).is_available() + + +@pytest.mark.parametrize( + ("path_name", "table", "legacy_version"), + [("source.db", "audit_continuity_control", 31), ("audit.db", "audit_continuity_head", 1)], +) +def test_legitimate_one_sided_precontinuity_schema_window_stays_in_standby( + tmp_path: Path, path_name: str, table: str, legacy_version: int +) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / path_name) as connection: + connection.execute(f"DROP TABLE {table}") + connection.execute(f"PRAGMA user_version = {legacy_version}") + connection.commit() + + assert not AuditContinuityCoordinator(tmp_path).is_available() + + +@pytest.mark.parametrize( + ("path_name", "entry_kind"), + [ + ("source.db", "external_symlink"), + ("audit.db", "directory"), + ("audit.db", "dangling_symlink"), + ], +) +def test_invalid_present_continuity_tier_never_enters_standby(tmp_path: Path, path_name: str, entry_kind: str) -> None: + """Only literal absence can disable continuity; invalid entries fail closed.""" + + initialize_active_archive_root(tmp_path) + path = tmp_path / path_name + if entry_kind == "external_symlink": + external = tmp_path.parent / "external-source.db" + external.write_bytes(path.read_bytes()) + path.unlink() + path.symlink_to(external) + elif entry_kind == "directory": + path.unlink() + path.mkdir() + else: + path.unlink() + path.symlink_to(tmp_path / "missing-audit.db") + + with pytest.raises(AuditContinuityError, match="regular file|safely"): + AuditContinuityCoordinator(tmp_path).is_available() + + +def test_empty_fresh_archive_can_use_the_genesis_continuity_head(tmp_path: Path) -> None: + """Genesis is valid only when it describes an empty freshly-created audit journal.""" + + initialize_active_archive_root(tmp_path) + + assert AuditContinuityCoordinator(tmp_path).is_available() + + +def test_semantic_hash_reads_audit_without_creating_backup_sidecars(tmp_path: Path) -> None: + """Read-only continuity validation never changes an immutable backup image.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + for suffix in ("-wal", "-shm", "-journal"): + audit_path.with_name(f"audit.db{suffix}").unlink(missing_ok=True) + + assert len(audit_semantic_sha256(audit_path)) == 64 + assert not any(audit_path.with_name(f"audit.db{suffix}").exists() for suffix in ("-wal", "-shm", "-journal")) + + +def test_second_mutation_refuses_while_first_command_is_pending(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + + def interrupt(phase: str, _mutation: AuditMutation) -> None: + if phase == "after_source_prepare": + raise RuntimeError("leave pending") + + with pytest.raises(RuntimeError, match="leave pending"): + AuditContinuityCoordinator(tmp_path, phase_hook=interrupt).execute(_mutation(1), _apply) + with pytest.raises(AuditContinuityError, match="already pending"): + AuditContinuityCoordinator(tmp_path).execute(_mutation(2), _apply) + + +def test_rejected_audit_transaction_aborts_its_prepared_command(tmp_path: Path) -> None: + """A deterministic reject cannot leave the source WAL blocking later work.""" + + initialize_active_archive_root(tmp_path) + + def reject(_conn: sqlite3.Connection, _mutation: AuditMutation) -> object: + raise ValueError("already consumed") + + coordinator = AuditContinuityCoordinator(tmp_path) + with pytest.raises(ValueError, match="already consumed"): + coordinator.execute(_mutation(1), reject) + + with closing(sqlite3.connect(tmp_path / "source.db")) as source: + assert source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone() == (None,) + assert coordinator.execute(_mutation(2), _apply) == "mutation:2" + with closing(sqlite3.connect(tmp_path / "audit.db")) as audit: + assert audit.execute("SELECT generation, mutation_id FROM audit_continuity_head").fetchone() == ( + 1, + "mutation:2", + ) + + +@pytest.mark.parametrize( + ("crash_phase", "error"), + [ + ("after_source_prepare", "crash after rebind prepare"), + ("after_audit_commit", "crash after rebind audit commit"), + ], +) +def test_rebind_replays_from_its_bound_image_after_each_wal_crash_window( + tmp_path: Path, crash_phase: str, error: str +) -> None: + """A rebind WAL command can complete after either replayable crash window.""" + + initialize_active_archive_root(tmp_path) + audit_sha256 = hashlib.sha256((tmp_path / "audit.db").read_bytes()).hexdigest() + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_prepare(self: AuditContinuityCoordinator, phase: str, mutation: AuditMutation) -> None: + if mutation.kind == "rebind" and phase == crash_phase: + raise RuntimeError(error) + original_phase(self, phase, mutation) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_prepare) + with pytest.raises(RuntimeError, match=error): + AuditContinuityCoordinator(tmp_path).seed_or_rebind( + mutation_id="rebind:crash", + now_ms=1, + evidence={"audit_image_sha256": audit_sha256}, + ) + + AuditContinuityCoordinator(tmp_path).reconcile(_apply) + with sqlite3.connect(tmp_path / "source.db") as source, sqlite3.connect(tmp_path / "audit.db") as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + +def test_rebind_rejects_a_stale_in_place_image_before_blessing_it(tmp_path: Path) -> None: + """Rebinding checks image bytes, not only a stable path or inode.""" + + initialize_active_archive_root(tmp_path) + audit_path = tmp_path / "audit.db" + stale_bytes = audit_path.read_bytes() + inode = audit_path.stat().st_ino + with closing(sqlite3.connect(audit_path)) as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, 1)", + ("newer-audit-image", 1), + ) + audit.commit() + expected_image_sha256 = hashlib.sha256(audit_path.read_bytes()).hexdigest() + audit_path.write_bytes(stale_bytes) + assert audit_path.stat().st_ino == inode + + with pytest.raises(AuditContinuityError, match="image changed before continuity rebind"): + AuditContinuityCoordinator(tmp_path).seed_or_rebind( + mutation_id="rebind:stale-image", + now_ms=1, + evidence={"audit_image_sha256": expected_image_sha256}, + ) + + +def test_populated_precontinuity_audit_is_bound_before_normal_coordination(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.executescript(Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql").read_text()) + audit.execute("PRAGMA user_version = 2") + audit.commit() + with sqlite3.connect(tmp_path / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.executescript( + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql").read_text() + ) + source.execute("PRAGMA user_version = 32") + source.commit() + expected = audit_semantic_sha256(tmp_path / "audit.db") + coordinator = AuditContinuityCoordinator(tmp_path) + + with pytest.raises(AuditContinuityError, match="post-migration binding"): + coordinator.is_available() + coordinator.bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", now_ms=1, audit_semantic_sha256=expected + ) + + assert coordinator.is_available() + with sqlite3.connect(tmp_path / "source.db") as source, sqlite3.connect(tmp_path / "audit.db") as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + +def test_precontinuity_binding_rejects_a_substituted_genesis_audit_image(tmp_path: Path) -> None: + initialize_active_archive_root(tmp_path) + with sqlite3.connect(tmp_path / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.executescript(Path("polylogue/storage/sqlite/migrations/audit/002_audit_continuity_head.sql").read_text()) + audit.execute("PRAGMA user_version = 2") + audit.commit() + expected = audit_semantic_sha256(tmp_path / "audit.db") + with sqlite3.connect(tmp_path / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.executescript( + Path("polylogue/storage/sqlite/migrations/source/032_audit_continuity_control.sql").read_text() + ) + source.execute("PRAGMA user_version = 32") + source.commit() + replacement_root = tmp_path / "replacement" + initialize_active_archive_root(replacement_root) + with sqlite3.connect(replacement_root / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('substituted:archive', 1, 1)" + ) + audit.commit() + (replacement_root / "audit.db").replace(tmp_path / "audit.db") + + with pytest.raises(AuditContinuityError, match="differs from its authenticated migration evidence"): + AuditContinuityCoordinator(tmp_path).bind_precontinuity_audit( + mutation_id=f"precontinuity-audit:{expected}", now_ms=1, audit_semantic_sha256=expected + ) diff --git a/tests/unit/storage/test_audit_tier.py b/tests/unit/storage/test_audit_tier.py index b67c4a3117..070e269310 100644 --- a/tests/unit/storage/test_audit_tier.py +++ b/tests/unit/storage/test_audit_tier.py @@ -11,12 +11,12 @@ from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS -def test_audit_tier_bootstrap_has_v1_authority_tables_and_is_durable(tmp_path: Path) -> None: +def test_audit_tier_bootstrap_has_current_authority_tables_and_is_durable(tmp_path: Path) -> None: path = tmp_path / "audit.db" initialize_archive_database(path, ArchiveTier.AUDIT) conn = sqlite3.connect(path) try: - assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + assert conn.execute("PRAGMA user_version").fetchone()[0] == ARCHIVE_TIER_SPECS[ArchiveTier.AUDIT].version tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} finally: conn.close() @@ -26,6 +26,7 @@ def test_audit_tier_bootstrap_has_v1_authority_tables_and_is_durable(tmp_path: P "operation_authorizations", "operation_runs", "operation_events", + "audit_continuity_head", } <= tables assert ArchiveTier.AUDIT in DURABLE_MIGRATION_TIERS assert ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] == ARCHIVE_TIER_SPECS[ArchiveTier.AUDIT].version diff --git a/tests/unit/storage/test_durable_change_train.py b/tests/unit/storage/test_durable_change_train.py index 7ff5b19eba..900ab0f02b 100644 --- a/tests/unit/storage/test_durable_change_train.py +++ b/tests/unit/storage/test_durable_change_train.py @@ -9,7 +9,7 @@ import sqlite3 import sys from collections.abc import Callable, Iterator -from contextlib import contextmanager +from contextlib import closing, contextmanager from dataclasses import replace from pathlib import Path from types import SimpleNamespace @@ -18,6 +18,15 @@ import pytest import polylogue.storage.sqlite.durable_change_train as durable_change_train_module +from polylogue.daemon.backup import backup_archive +from polylogue.operations.durable_change_train import ( + _audit_live_metadata, + _write_immutable_audit_adoption_receipt, + acquire_durable_archive_ownership, + adopt_missing_audit_tier, + audit_adoption_receipt_path, + restore_adopted_audit_tier, +) from polylogue.storage.sqlite import migration_runner from polylogue.storage.sqlite.archive_tiers import ARCHIVE_DDL_BY_TIER, ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier @@ -59,6 +68,7 @@ durable_migration_claim_for_sql, durable_migration_collision_report, load_durable_change_train_manifest, + migrate_archive_tier, prove_durable_change_train, prove_durable_fresh_ddl_parity, reconcile_interrupted_durable_change_train, @@ -1671,6 +1681,1339 @@ def test_fresh_archive_bootstrap_receipt_allows_repeat_startup(tmp_path: Path) - initialize_active_archive_root(tmp_path) +def test_audit_adoption_receipt_survives_startup_preflight( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """The storage startup route validates the receipt created by the real adopter.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + (archive_root / "audit.db").unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + fsynced_paths: set[Path] = set() + real_fsync = os.fsync + + def record_fsync(descriptor: int) -> None: + try: + fsynced_paths.add(Path(os.readlink(f"/proc/self/fd/{descriptor}"))) + except OSError: + pass + real_fsync(descriptor) + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.fsync", record_fsync) + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: + version, receipt = adopt_missing_audit_tier( + archive_root / "audit.db", + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + assert receipt == audit_adoption_receipt_path(archive_root) + assert { + archive_root, + archive_root / ".maintenance-state", + archive_root / ".maintenance-state" / "durable-change-trains", + }.issubset(fsynced_paths) + assert reconcile_durable_change_train_startup(archive_root) == () + receipt.write_text("tampered", encoding="utf-8") + with pytest.raises(MigrationError, match="invalid audit adoption receipt"): + reconcile_durable_change_train_startup(archive_root) + + +def test_audit_adoption_receipt_allows_a_mutated_audit_journal(workspace_env: dict[str, Path]) -> None: + """Startup accepts an adopted audit tier after normal SQLite journal writes.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-mutable-journal") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("adopted-audit-journal", 1, 1), + ) + connection.commit() + + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_audit_metadata_read_is_read_only_for_uri_metacharacter_paths(tmp_path: Path) -> None: + """Archive path punctuation cannot consume SQLite's read-only URI parameter.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = tmp_path / "archive?#uri" + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + + version, application_id, quick_check = _audit_live_metadata(audit_path) + + assert version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + assert application_id == 0 + assert quick_check == ("ok",) + assert not audit_path.with_name("audit.db-wal").exists() + assert not audit_path.with_name("audit.db-shm").exists() + + +def test_adopted_audit_restore_rebinds_continuity_from_verified_backup(workspace_env: dict[str, Path]) -> None: + """The real offline restore publishes a new immutable continuity generation.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "pre-adoption", profile="full_evidence", verify=True) + assert pre_adoption.ok, pre_adoption.error + assert pre_adoption.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-restore-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "post-adoption", profile="full_evidence", verify=True) + assert verified.ok, verified.error + assert verified.output_path is not None + with closing(sqlite3.connect(archive_root / "index.db")) as connection: + current_index_version = int(connection.execute("PRAGMA user_version").fetchone()[0] or 0) + connection.execute(f"PRAGMA user_version = {current_index_version + 1}") + connection.commit() + old_identity = (audit_path.stat().st_dev, audit_path.stat().st_ino) + audit_path.write_bytes(b"corrupted audit image") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-restore") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with closing(sqlite3.connect(audit_path)) as audit: + assert audit.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert audit.execute("SELECT generation FROM audit_continuity_head").fetchone() == (2,) + assert (audit_path.stat().st_dev, audit_path.stat().st_ino) != old_identity + assert receipt.name.endswith(".committed.json") + assert receipt.with_name(receipt.name.replace(".committed.json", ".prepared.json")).is_file() + with ( + closing(sqlite3.connect(archive_root / "source.db")) as source, + closing(sqlite3.connect(archive_root / "audit.db")) as audit, + ): + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_adopted_audit_restore_resumes_an_interrupted_continuity_commit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A prepared record blocks startup but the same verified backup can complete it.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "resume-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "resume-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + source_wal_reader: sqlite3.Connection | None = None + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + original_phase = AuditContinuityCoordinator._phase + + def interrupt_after_rebind_commit(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + nonlocal source_wal_reader + if getattr(mutation, "mutation_id", "").startswith("audit-restore:"): + if phase == "before_source_prepare": + with sqlite3.connect(archive_root / "source.db") as source: + assert source.execute("PRAGMA journal_mode = WAL").fetchone() == ("wal",) + source_wal_reader = sqlite3.connect(archive_root / "source.db") + source_wal_reader.execute("BEGIN") + source_wal_reader.execute("SELECT * FROM audit_continuity_control").fetchone() + if phase == "after_source_prepare": + raise RuntimeError("simulated continuity prepare interruption") + original_phase(self, phase, mutation) # type: ignore[arg-type] + + try: + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", interrupt_after_rebind_commit) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-interrupt") as owner: + with pytest.raises(RuntimeError, match="continuity prepare interruption"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + # The production source-prepare transition is durable only in a WAL; + # retry must validate this operation-owned pending command before it + # can republish/rebind the audit image. + assert (archive_root / "source.db-wal").stat().st_size > 0 + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert str( + source.execute("SELECT pending_mutation_id FROM audit_continuity_control").fetchone()[0] + ).startswith("audit-restore:") + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("CREATE TABLE restore_retry_tamper (value TEXT NOT NULL) STRICT") + source.commit() + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume-tamper") as owner: + with pytest.raises(MigrationError, match="backup is stale for source.db"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE restore_retry_tamper") + source.commit() + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-resume") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + finally: + if source_wal_reader is not None: + source_wal_reader.close() + + assert receipt.name.endswith(".committed.json") + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_adopted_audit_restore_republishes_before_reading_a_promoted_unreadable_audit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A retry restores its verified image before authenticating a promoted rebind head.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "promoted-missing-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive( + output_dir=archive_root.parent / "promoted-missing-post", profile="full_evidence", verify=True + ) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt before promoted crash") + original_phase = AuditContinuityCoordinator._phase + + def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + if getattr(mutation, "mutation_id", "").startswith("audit-restore:") and phase == "after_source_promotion": + raise RuntimeError("crash after restore source promotion") + original_phase(self, phase, mutation) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", crash_after_source_promotion) + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-crash") as owner: + with pytest.raises(RuntimeError, match="crash after restore source promotion"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source: + promoted_source_tuple = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + + # The source promotion survived while the live authority image became + # unreadable. The retry must publish the verified backup before reading + # its continuity head. + audit_path.write_bytes(b"unreadable after promoted restore crash") + with acquire_durable_archive_ownership(archive_root, owner_id="test:promoted-missing-retry") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert receipt.name.endswith(".committed.json") + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == promoted_source_tuple + ) + assert ( + source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + == audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + ) + + +def test_adopted_audit_restore_rejects_an_unrelated_higher_promoted_source_head( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A cleared pending row cannot authorize an arbitrary higher source generation.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "higher-head-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "higher-head-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt before forged higher promoted head") + original_phase = AuditContinuityCoordinator._phase + + def crash_after_source_promotion(self: AuditContinuityCoordinator, phase: str, mutation: object) -> None: + if getattr(mutation, "mutation_id", "").startswith("audit-restore:") and phase == "after_source_promotion": + raise RuntimeError("crash after restore source promotion") + original_phase(self, phase, mutation) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr(AuditContinuityCoordinator, "_phase", crash_after_source_promotion) + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-crash") as owner: + with pytest.raises(RuntimeError, match="crash after restore source promotion"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source: + source.execute( + "UPDATE audit_continuity_control SET committed_generation = committed_generation + 7, " + "committed_head_sha256 = ? WHERE singleton = 1", + ("f" * 64,), + ) + source.commit() + audit_path.write_bytes(b"unreadable after forged higher promoted head") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:higher-head-retry") as owner: + with pytest.raises(MigrationError, match="rebind is not operation-owned"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + +@pytest.mark.parametrize("order", ((ArchiveTier.AUDIT, ArchiveTier.SOURCE), (ArchiveTier.SOURCE, ArchiveTier.AUDIT))) +def test_continuity_migrations_have_a_deployable_cross_tier_compatibility_window( + workspace_env: dict[str, Path], order: tuple[ArchiveTier, ArchiveTier] +) -> None: + """Each numbered migration can ship first; coordination activates only after both.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + from polylogue.storage.sqlite.audit_continuity import AuditContinuityCoordinator + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + with sqlite3.connect(archive_root / "audit.db") as audit: + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.commit() + backup = backup_archive( + output_dir=archive_root.parent / f"continuity-{order[0].value}-first", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + + for position, tier in enumerate(order): + with sqlite3.connect(archive_root / f"{tier.value}.db") as connection: + result = migrate_archive_tier(connection, tier, backup_manifest=manifest) + assert result.applied_versions == (ARCHIVE_VERSION_BY_TIER[tier],) + sidecar = durable_migration_sidecar_for_slot(tier, ARCHIVE_VERSION_BY_TIER[tier]) + assert sidecar is not None + train = sidecar.train + results = _runtime_consumer_results(train, archive_root) + assert {result.consumer_id for result in results} == { + consumer.consumer_id for rider in train.riders for consumer in rider.runtime_consumers + } + probe = AuditContinuityCoordinator(archive_root) + if position == 0: + assert probe.runtime_probe().startswith("standby") + else: + assert probe.runtime_probe() == "reconciled matching source/audit continuity heads" + + +@pytest.mark.parametrize( + ("entry_name", "entry_kind"), + ( + ("audit.db", "directory"), + ("audit.db", "dangling_symlink"), + ("source.db", "symlink"), + ), +) +def test_precontinuity_binding_rejects_invalid_present_archive_entries( + tmp_path: Path, entry_name: str, entry_kind: str +) -> None: + """Only truly absent durable entries can leave pre-continuity binding in standby.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + initialize_active_archive_root(tmp_path) + source_path = tmp_path / "source.db" + entry_path = tmp_path / entry_name + with closing(sqlite3.connect(source_path)) as source: + if entry_kind == "directory": + entry_path.unlink() + entry_path.mkdir() + elif entry_kind == "dangling_symlink": + entry_path.unlink() + entry_path.symlink_to(tmp_path / "missing-audit.db") + else: + external = tmp_path.parent / "external-source.db" + external.write_bytes(entry_path.read_bytes()) + entry_path.unlink() + entry_path.symlink_to(external) + + with pytest.raises(MigrationError, match="invalid pre-continuity"): + migration_runner._bind_populated_precontinuity_audit(source, backup_manifest=None) + + +def test_populated_precontinuity_audit_upgrade_binds_authenticated_existing_content( + workspace_env: dict[str, Path], +) -> None: + """The real two-tier upgrade advances past genesis with the legacy journal's digest.""" + + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "audit.db") as audit: + audit.execute( + "INSERT INTO archive_authority(archive_instance_id, created_at_ms, authority_format) VALUES ('legacy:archive', 1, 1)" + ) + audit.execute("DROP TABLE audit_continuity_head") + audit.execute("PRAGMA user_version = 1") + audit.commit() + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + backup = backup_archive( + output_dir=archive_root.parent / "populated-precontinuity", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + + with sqlite3.connect(archive_root / "audit.db") as audit: + assert migrate_archive_tier(audit, ArchiveTier.AUDIT, backup_manifest=manifest).applied_versions == (2,) + with sqlite3.connect(archive_root / "source.db") as source: + assert migrate_archive_tier(source, ArchiveTier.SOURCE, backup_manifest=manifest).applied_versions == (32,) + + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(archive_root / "audit.db") as audit: + assert source.execute("SELECT committed_generation FROM audit_continuity_control").fetchone() == (1,) + assert audit.execute("SELECT generation, mutation_id FROM audit_continuity_head").fetchone()[0] == 1 + assert str(audit.execute("SELECT mutation_id FROM audit_continuity_head").fetchone()[0]).startswith( + "precontinuity-audit:" + ) + + +def test_adopted_audit_restore_replaces_stale_operation_staging_after_crash( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """One retry completes a prepared restore after a crash leaves its private image.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "staging-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "staging-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + real_unlink = os.unlink + + def interrupt_publication(*args: object, **kwargs: object) -> None: + raise OSError("simulated restore publication crash") + + def leave_staging(name: os.PathLike[str] | str, *, dir_fd: int | None = None) -> None: + if str(name).startswith(".audit.db.restore-"): + return + real_unlink(name, dir_fd=dir_fd) + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.replace", interrupt_publication) + interrupted.setattr("polylogue.operations.durable_change_train.os.unlink", leave_staging) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-interrupt") as owner: + with pytest.raises(OSError, match="simulated restore publication crash"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + stale = tuple(archive_root.glob(".audit.db.restore-*.tmp")) + assert len(stale) == 1 + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-staging-resume") as owner: + receipt = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert receipt.name.endswith(".committed.json") + assert not tuple(archive_root.glob(".audit.db.restore-*.tmp")) + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_adopted_audit_restore_record_survives_publication_temp_hardlink( + workspace_env: dict[str, Path], +) -> None: + """A crash after immutable publication may leave the valid record with two names.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "hardlink-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-hardlink-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "hardlink-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt") + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-hardlink-restore") as owner: + committed = restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + leftover = committed.with_name(f".{committed.name}.publication.tmp") + os.link(committed, leftover) + + assert committed.stat().st_nlink == 2 + assert reconcile_durable_change_train_startup(archive_root) == () + + +@pytest.mark.parametrize( + ("tamper", "expected_error"), + [ + ("artifact", "migration backup tier artifact"), + ("receipt", "adopted-audit restore"), + ("stale-source", "adopted-audit restore backup is stale for source.db"), + ], +) +def test_adopted_audit_restore_rejects_untrusted_or_stale_backup( + workspace_env: dict[str, Path], tamper: str, expected_error: str +) -> None: + """Mutation: bypassing receipt, artifact, or current-authority checks reaches the real restore call.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / f"pre-{tamper}", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id=f"test:audit-restore-adopt-{tamper}") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / f"post-{tamper}", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + if tamper == "artifact": + (backup_root / "audit.db").write_bytes(b"altered backup audit") + elif tamper == "receipt": + (backup_root / "verification-receipt.json").write_text("{}", encoding="utf-8") + else: + with closing(sqlite3.connect(archive_root / "source.db")) as connection: + connection.execute("PRAGMA user_version = 999") + connection.commit() + audit_path.write_bytes(b"corrupted audit image") + + with acquire_durable_archive_ownership(archive_root, owner_id=f"test:audit-restore-reject-{tamper}") as owner: + with pytest.raises(MigrationError, match=expected_error): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == b"corrupted audit image" + + +def test_adopted_audit_restore_rejects_an_authenticated_legacy_audit_schema( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Restore admission rejects a staged audit image that predates the continuity schema it needs.""" + + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive( + output_dir=archive_root.parent / "legacy-schema-pre", profile="full_evidence", verify=True + ) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:legacy-schema-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive( + output_dir=archive_root.parent / "legacy-schema-post", profile="full_evidence", verify=True + ) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + with sqlite3.connect(backup_root / "audit.db") as staged: + staged.execute("DROP TABLE audit_continuity_head") + staged.execute("PRAGMA user_version = 1") + receipt = archive_root.parent / "legacy-schema-receipt.json" + receipt.write_text( + json.dumps({"tier_artifacts": [{"tier": "audit", "sha256": "0" * 64, "size_bytes": 1, "user_version": 1}]}), + encoding="utf-8", + ) + monkeypatch.setattr( + operations_durable_change_train, + "validate_full_evidence_backup_for_adopted_audit_restore", + lambda *_args, **_kwargs: (backup_root / "manifest.json", receipt), + ) + + with acquire_durable_archive_ownership(archive_root, owner_id="test:legacy-schema-restore") as owner: + with pytest.raises(MigrationError, match="does not belong"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(audit_path) as audit: + assert audit.execute("PRAGMA user_version").fetchone() == (ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT],) + assert audit.execute("SELECT 1 FROM audit_continuity_head").fetchone() == (1,) + + +def test_adopted_audit_restore_rejects_wrong_archive_application_id( + workspace_env: dict[str, Path], +) -> None: + """A valid backup from different audit authority cannot replace the adopted journal.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "app-id-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-app-id-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + with closing(sqlite3.connect(audit_path)) as connection: + adopted_application_id = int(connection.execute("PRAGMA application_id").fetchone()[0]) + connection.execute(f"PRAGMA application_id = {adopted_application_id + 1}") + connection.commit() + wrong_authority = backup_archive( + output_dir=archive_root.parent / "app-id-wrong", profile="full_evidence", verify=True + ) + assert wrong_authority.ok and wrong_authority.output_path is not None, wrong_authority.error + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute(f"PRAGMA application_id = {adopted_application_id}") + connection.commit() + original = audit_path.read_bytes() + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-app-id-restore") as owner: + with pytest.raises(MigrationError, match="does not belong to this audit adoption"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(wrong_authority.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == original + + +def test_adopted_audit_restore_rejects_backup_swap_after_validation( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """The exact manifest and verification receipt stay fixed through publication.""" + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "swap-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-swap-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "swap-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + backup_root = Path(verified.output_path) + audit_path.write_bytes(b"corrupt-before-swap-test") + from polylogue.storage.sqlite.migration_runner import validate_full_evidence_backup_for_adopted_audit_restore + + real_validate = validate_full_evidence_backup_for_adopted_audit_restore + calls = 0 + + def swap_after_validation(path: Path, *, archive_root: Path, **kwargs: object) -> tuple[Path, Path]: + nonlocal calls + calls += 1 + manifest, receipt = real_validate(path, archive_root=archive_root, **kwargs) # type: ignore[arg-type] + if calls == 2: + receipt.write_bytes(receipt.read_bytes() + b"\n") + return manifest, receipt + + monkeypatch.setattr( + operations_durable_change_train, + "validate_full_evidence_backup_for_adopted_audit_restore", + swap_after_validation, + ) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-swap-restore") as owner: + with pytest.raises(MigrationError, match="backup changed during the operation"): + restore_adopted_audit_tier( + audit_path, + backup_manifest=backup_root / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert audit_path.read_bytes() == b"corrupt-before-swap-test" + + +def test_audit_adoption_binds_only_the_source_user_authority(workspace_env: dict[str, Path]) -> None: + """Routine replacement of rebuildable or disposable tiers leaves adoption valid.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-stable-authority") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + (archive_root / "index.db").unlink() + (archive_root / "ops.db").unlink() + initialize_active_archive_root(archive_root) + + assert (archive_root / "index.db").is_file() + assert (archive_root / "ops.db").is_file() + + +def test_audit_adoption_receipt_keeps_initial_schema_evidence_after_upgrade( + workspace_env: dict[str, Path], +) -> None: + """Receipt validation permits later audit schema versions for normal train handling.""" + from polylogue.operations.durable_change_train import validate_audit_adoption_receipt + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-schema-evidence") as owner: + _version, receipt = adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + initial_schema_digest = json.loads(receipt.read_text(encoding="utf-8"))["audit_schema_inventory_sha256"] + + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute("CREATE TABLE future_audit_schema (value TEXT)") + connection.execute("PRAGMA user_version = 2") + connection.commit() + + assert validate_audit_adoption_receipt(archive_root) == receipt + assert json.loads(receipt.read_text(encoding="utf-8"))["audit_schema_inventory_sha256"] == initial_schema_digest + + +def test_audit_adoption_rejects_a_stale_audit_file_clone(workspace_env: dict[str, Path]) -> None: + """The continuity record distinguishes in-place writes from a stale file replacement.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-stale-clone") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + stale_clone = archive_root / "stale-audit.db" + shutil.copy2(audit_path, stale_clone) + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("live-audit-after-clone", 2, 1), + ) + connection.commit() + os.replace(stale_clone, audit_path) + + with pytest.raises(MigrationError, match="continuity"): + reconcile_durable_change_train_startup(archive_root) + + +def test_adopted_audit_startup_runs_one_receipt_integrity_check( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Bootstrap delegates adopted-tier validation to startup reconciliation once.""" + from polylogue.operations import durable_change_train as operations_durable_change_train + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-single-quick-check") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + calls = 0 + real_validate = operations_durable_change_train.validate_audit_adoption_receipt + + def count_validate(root: Path, *, require_initial_image: bool = False) -> Path | None: + nonlocal calls + calls += 1 + return real_validate(root, require_initial_image=require_initial_image) + + monkeypatch.setattr(operations_durable_change_train, "validate_audit_adoption_receipt", count_validate) + initialize_active_archive_root(archive_root) + + assert calls == 1 + + +def test_audit_adoption_receipt_recovers_interrupted_publication_during_bootstrap( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """The normal bootstrap route completes the receipt-backed publication after a crash.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker_root = archive_root / ".maintenance-state" / "durable-change-trains" + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + monkeypatch.setitem( + durable_change_train_module.DURABLE_MIGRATION_ADOPTION_FLOORS, + ArchiveTier.SOURCE, + ARCHIVE_VERSION_BY_TIER[ArchiveTier.SOURCE] - 1, + ) + monkeypatch.setitem( + durable_change_train_module.DURABLE_MIGRATION_ADOPTION_FLOORS, + ArchiveTier.USER, + ARCHIVE_VERSION_BY_TIER[ArchiveTier.USER] - 1, + ) + real_link = os.link + + def fail_audit_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name == "audit.db": + raise OSError("simulated interruption") + real_link( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + with monkeypatch.context() as failed_publication: + failed_publication.setattr("polylogue.operations.durable_change_train.os.link", fail_audit_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-interrupted-publication") as owner: + with pytest.raises(MigrationError): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + initialize_active_archive_root(archive_root) + + assert audit_path.is_file() + assert (marker_root / ".bootstrap").is_file() + assert reconcile_durable_change_train_startup(archive_root) == () + + +def test_audit_adoption_retry_reports_recovered_audit_schema_version( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A receipt-backed retry reports the live audit schema, not a sentinel.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok and backup.output_path is not None, backup.error + manifest = Path(backup.output_path) / "manifest.json" + from polylogue.operations import durable_change_train as operations_durable_change_train + + real_validate = operations_durable_change_train.validate_audit_adoption_receipt + + def interrupt_after_publication(root: Path, *, require_initial_image: bool = False) -> Path | None: + if require_initial_image: + raise RuntimeError("simulated post-publication interruption") + return real_validate(root, require_initial_image=require_initial_image) + + with monkeypatch.context() as interrupted: + interrupted.setattr( + operations_durable_change_train, "validate_audit_adoption_receipt", interrupt_after_publication + ) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption") as owner: + with pytest.raises(RuntimeError, match="post-publication interruption"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=manifest, + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + assert audit_path.is_file() + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-adoption-retry") as owner: + recovered_version, _receipt = adopt_missing_audit_tier( + audit_path, + backup_manifest=manifest, + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert recovered_version == ARCHIVE_VERSION_BY_TIER[ArchiveTier.AUDIT] + + +def test_audit_adoption_bootstrap_rejects_stale_replacement_before_recording_continuity( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Startup does not bless a replaced audit image in the post-link crash window.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker_root = archive_root / ".maintenance-state" / "durable-change-trains" + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + real_link = os.link + + def interrupt_continuity_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + if Path(destination).name == "audit-continuity.json": + raise OSError("simulated crash after audit link") + real_link( + source, + destination, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + with monkeypatch.context() as interrupted_publication: + interrupted_publication.setattr("polylogue.operations.durable_change_train.os.link", interrupt_continuity_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-continuity-crash") as owner: + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + source_head = source.execute( + "SELECT committed_generation, committed_head_sha256 FROM audit_continuity_control" + ).fetchone() + audit_head = audit.execute("SELECT generation, head_sha256 FROM audit_continuity_head").fetchone() + assert source_head == audit_head + assert source_head[0] == 1 + + stale_clone = archive_root / "stale-audit.db" + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("audit-before-stale-clone", 1, 1), + ) + connection.commit() + shutil.copy2(audit_path, stale_clone) + with closing(sqlite3.connect(audit_path)) as connection: + connection.execute( + "INSERT INTO archive_authority (archive_instance_id, created_at_ms, authority_format) VALUES (?, ?, ?)", + ("audit-after-stale-clone", 2, 1), + ) + connection.commit() + os.replace(stale_clone, audit_path) + stale_image = audit_path.read_bytes() + + with pytest.raises(MigrationError, match="audit tier changed before recording adoption continuity"): + initialize_active_archive_root(archive_root) + + assert audit_path.read_bytes() == stale_image + assert not (marker_root / "audit-continuity.json").exists() + + +def test_audit_adoption_retries_seeded_machine_head_before_continuity_publication( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """A continuity-receipt crash resumes the already-seeded adoption head.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive( + output_dir=archive_root.parent / "seed-before-publish", profile="full_evidence", verify=True + ) + assert backup.ok and backup.output_path is not None, backup.error + real_link = os.link + + def interrupt_continuity_link( + source: os.PathLike[str] | str, + destination: os.PathLike[str] | str, + **kwargs: object, + ) -> None: + if Path(destination).name == "audit-continuity.json": + raise OSError("simulated crash after machine-head seed") + real_link(source, destination, **kwargs) # type: ignore[arg-type] + + with monkeypatch.context() as interrupted: + interrupted.setattr("polylogue.operations.durable_change_train.os.link", interrupt_continuity_link) + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-seed-before-publish") as owner: + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + with sqlite3.connect(archive_root / "source.db") as source, sqlite3.connect(audit_path) as audit: + assert source.execute("SELECT committed_generation FROM audit_continuity_control").fetchone() == (1,) + assert audit.execute("SELECT generation FROM audit_continuity_head").fetchone() == (1,) + initialize_active_archive_root(archive_root) + assert (archive_root / ".maintenance-state" / "durable-change-trains" / "audit-continuity.json").is_file() + + +def test_adopted_audit_restore_removes_owned_sidecars_before_publication(workspace_env: dict[str, Path]) -> None: + """The real restore cannot replay stale audit WAL, SHM, or rollback bytes.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + pre_adoption = backup_archive(output_dir=archive_root.parent / "sidecar-pre", profile="full_evidence", verify=True) + assert pre_adoption.ok and pre_adoption.output_path is not None, pre_adoption.error + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-sidecar-adopt") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(pre_adoption.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + verified = backup_archive(output_dir=archive_root.parent / "sidecar-post", profile="full_evidence", verify=True) + assert verified.ok and verified.output_path is not None, verified.error + audit_path.write_bytes(b"corrupt-audit-main") + for suffix in ("-wal", "-shm", "-journal"): + audit_path.with_name(f"audit.db{suffix}").write_bytes(b"stale-owned-sidecar") + + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-sidecar-restore") as owner: + restore_adopted_audit_tier( + audit_path, + backup_manifest=Path(verified.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + assert not any(audit_path.with_name(f"audit.db{suffix}").exists() for suffix in ("-wal", "-shm", "-journal")) + assert _audit_live_metadata(audit_path)[2] == ("ok",) + + +def test_audit_adoption_recovery_preserves_missing_tier_after_continuity( + workspace_env: dict[str, Path], +) -> None: + """Recovery requires restore, without recreating audit.db after completed adoption.""" + from polylogue.operations.durable_change_train import recover_pending_audit_adoption + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-missing-after-continuity") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + audit_path.unlink() + before = {path.relative_to(archive_root): path.read_bytes() for path in archive_root.rglob("*") if path.is_file()} + + with pytest.raises(MigrationError, match="missing after continuity"): + recover_pending_audit_adoption(archive_root) + + after = {path.relative_to(archive_root): path.read_bytes() for path in archive_root.rglob("*") if path.is_file()} + assert after == before + assert not audit_path.exists() + + +def test_audit_adoption_receipt_is_excluded_from_pre_marker_train_state(workspace_env: dict[str, Path]) -> None: + """The adoption receipt does not prevent legacy current-schema bootstrap adoption.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + marker = archive_root / ".maintenance-state" / "durable-change-trains" / ".bootstrap" + marker.unlink() + audit_path = archive_root / "audit.db" + audit_path.unlink() + backup = backup_archive(output_dir=archive_root.parent / "backup", profile="full_evidence", verify=True) + assert backup.ok, backup.error + assert backup.output_path is not None + with acquire_durable_archive_ownership(archive_root, owner_id="test:audit-pre-marker") as owner: + adopt_missing_audit_tier( + audit_path, + backup_manifest=Path(backup.output_path) / "manifest.json", + directory_fd=owner.directory_fd, + stopped_daemon_check=lambda: "proof:test-daemon-stopped", + ) + + initialize_active_archive_root(archive_root) + + assert marker.is_file() + + +def test_adoption_receipt_short_write_is_removed_for_a_safe_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed receipt write leaves no immutable-looking truncated publication behind.""" + archive_root = tmp_path / "archive" + archive_root.mkdir() + receipt_path = audit_adoption_receipt_path(archive_root) + write_calls = 0 + + def short_then_fail(descriptor: int, data: bytes) -> int: + nonlocal write_calls + write_calls += 1 + if write_calls == 1: + return min(1, len(data)) + raise OSError("simulated receipt write failure") + + monkeypatch.setattr("polylogue.operations.durable_change_train.os.write", short_then_fail) + + with pytest.raises(MigrationError, match="cannot publish immutable audit adoption receipt"): + _write_immutable_audit_adoption_receipt(receipt_path, {"format": "test"}, archive_root=archive_root) + + assert not receipt_path.exists() + + +def test_adoption_receipt_refuses_a_symlinked_maintenance_parent(tmp_path: Path) -> None: + """Receipt publication and loading stay beneath the owned archive descriptor.""" + archive_root = tmp_path / "archive" + archive_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (archive_root / ".maintenance-state").symlink_to(outside, target_is_directory=True) + receipt_path = audit_adoption_receipt_path(archive_root) + + with pytest.raises(MigrationError, match="must not traverse outside archive-owned directories"): + _write_immutable_audit_adoption_receipt(receipt_path, {"format": "test"}, archive_root=archive_root) + + assert not (outside / "durable-change-trains" / "audit-adoption.json").exists() + + +def test_runtime_bootstrap_refuses_an_established_archive_missing_audit( + workspace_env: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Ordinary writable startup cannot create audit.db without adoption evidence.""" + from polylogue.storage.sqlite.archive_tiers import bootstrap + + archive_root = workspace_env["archive_root"] + bootstrap.initialize_active_archive_root(archive_root) + (archive_root / "audit.db").unlink() + reconciled = False + + def observe_reconciliation(_root: Path) -> tuple[Path, ...]: + nonlocal reconciled + reconciled = True + return () + + monkeypatch.setattr(bootstrap, "reconcile_durable_change_trains_on_startup", observe_reconciliation) + + with pytest.raises(RuntimeError, match="adopt-established-audit"): + bootstrap.initialize_active_archive_root(archive_root) + + assert not reconciled + assert not (archive_root / "audit.db").exists() + + +def test_runtime_bootstrap_refuses_source_v31_archive_missing_audit(workspace_env: dict[str, Path]) -> None: + """Bootstrap evidence remains authoritative before source v32 exists.""" + from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root + + archive_root = workspace_env["archive_root"] + initialize_active_archive_root(archive_root) + with sqlite3.connect(archive_root / "source.db") as source: + source.execute("DROP TABLE audit_continuity_control") + source.execute("PRAGMA user_version = 31") + source.commit() + (archive_root / "audit.db").unlink() + + with pytest.raises(RuntimeError, match="adopt-established-audit"): + initialize_active_archive_root(archive_root) + + assert not (archive_root / "audit.db").exists() + + def test_fresh_bootstrap_intent_recovers_after_late_tier_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -2384,6 +3727,7 @@ def test_startup_proves_durable_continuity_before_initialization_or_release( db_path = tmp_path / f"{tier.value}.db" _create_current_database(db_path) + bootstrap.initialize_archive_database(tmp_path / "audit.db", ArchiveTier.AUDIT) _install_synthetic_migration(tmp_path, monkeypatch, tier) train = _admitted(tier, rider=_production_rider()) with sqlite3.connect(db_path) as conn: