diff --git a/docs/release/generation-retention-review-2026-10-31.json b/docs/release/generation-retention-review-2026-10-31.json new file mode 100644 index 0000000..8d3aef7 --- /dev/null +++ b/docs/release/generation-retention-review-2026-10-31.json @@ -0,0 +1,165 @@ +{ + "age_window_counterexample": { + "fixed_age_window_preserves_generation_policy": false, + "input_generations": 15, + "oldest_selected_age_days": 48, + "selected_generations_older_than_30_days": 14, + "synthetic_source_gap": true + }, + "archive_bodies_refetched_by_observer": false, + "blockers": [], + "bucket_bytes": { + "railiance-coulomb-social-archives": 8669342, + "railiance-coulomb-social-recovery": 832889296, + "railiance-platform-pg-backup": 8665936 + }, + "bucket_version_counts": { + "railiance-coulomb-social-archives": 8, + "railiance-coulomb-social-recovery": 6, + "railiance-platform-pg-backup": 4 + }, + "candidate_versions": [ + { + "associated_receipt": { + "receipt_bytes": 1664, + "receipt_object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age.cbbf4c0b9cab6829293e00435f225e9cc3c0167930307c289428c1f9ded2a6f7.receipt.json", + "receipt_sha256": "cbbf4c0b9cab6829293e00435f225e9cc3c0167930307c289428c1f9ded2a6f7", + "receipt_version_id": "1793408541632331" + }, + "bucket": "railiance-coulomb-social-archives", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "f7be502c7e122283e70b10d2fd8a2cbf9550d2261192ba11f94d945a9f39af51", + "logical_primary_identity": { + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "f7be502c7e122283e70b10d2fd8a2cbf9550d2261192ba11f94d945a9f39af51", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/31/20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age", + "version_id": "1793406532815253" + }, + "object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T002756Z-c31b84b48b3874bdaa87cbf5.tar.age", + "version_id": "1793408525635522" + } + ], + "credential_values_printed": false, + "delete_marker_counts": { + "railiance-coulomb-social-archives": 0, + "railiance-coulomb-social-recovery": 0, + "railiance-platform-pg-backup": 0 + }, + "deletion_capability": false, + "exact_version_heads": 12, + "expiration_candidates": 1, + "generation_selection_valid": true, + "head_observed_at": "2026-10-31T02:47:11.870754+00:00", + "independent_public_receipts_fetched_verified": 2, + "keep": [ + { + "bucket": "railiance-coulomb-social-archives", + "ciphertext_bytes": 2120392, + "ciphertext_sha256": "acbf92eda5074761bb6d00170e6e459e787359afcd377f2f18324ba17496510a", + "logical_primary_identity": { + "ciphertext_bytes": 2120392, + "ciphertext_sha256": "acbf92eda5074761bb6d00170e6e459e787359afcd377f2f18324ba17496510a", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/29/20261029T212246Z-fd128164c7bb22a3a6c42acf.tar.age", + "version_id": "1793309020705518" + }, + "object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/29/20261029T212246Z-fd128164c7bb22a3a6c42acf.tar.age", + "version_id": "1793309156692541" + }, + { + "bucket": "railiance-coulomb-social-archives", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "59c2724399f067fabaa53550ffe52205e4752c91a5064e173b59859f9dea4744", + "logical_primary_identity": { + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "59c2724399f067fabaa53550ffe52205e4752c91a5064e173b59859f9dea4744", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/30/20261030T235040Z-7f5732d82580c7ff97e3f13e.tar.age", + "version_id": "1793404296527269" + }, + "object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/30/20261030T235040Z-7f5732d82580c7ff97e3f13e.tar.age", + "version_id": "1793404632402018" + }, + { + "bucket": "railiance-coulomb-social-archives", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "ac9fe6711c967a1c79caeca961c24ebf0ce795fb98e018852da00a1795341709", + "logical_primary_identity": { + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "ac9fe6711c967a1c79caeca961c24ebf0ce795fb98e018852da00a1795341709", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/31/20261031T020721Z-4aa7a5d4146abf20f0b23e66.tar.age", + "version_id": "1793412500034233" + }, + "object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T020721Z-4aa7a5d4146abf20f0b23e66.tar.age", + "version_id": "1793414655337141" + } + ], + "keep_versions": 3, + "known_logical_points": 4, + "native_lifecycle_rules": { + "railiance-coulomb-social-archives": [], + "railiance-coulomb-social-recovery": [], + "railiance-platform-pg-backup": [ + { + "Expiration": { + "Days": 30 + }, + "Filter": { + "Prefix": "" + }, + "ID": "retain-30-days", + "NoncurrentVersionExpiration": { + "NoncurrentDays": 30 + }, + "Status": "Enabled" + } + ] + }, + "observed_at": "2026-10-31T02:47:09.500259+00:00", + "owner_source_sha256": { + "application_archive_retention_plan.py": "08bc57308f53bbfd0d58fdbb9b5671dd4285e31c5b5137e201524df606aa3f9f", + "primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "personal_session_used": false, + "private_age_identity_read": false, + "private_review_sha256": "7c9a7a2035e16c60833c04fcd5d10e8a075338241bc12e26728792ad86c4fd87", + "protected_kit": { + "automatic_expiry": false, + "bucket": "railiance-coulomb-social-recovery", + "bytes": 277647360, + "object_key": "kits/91d8cec1b6b4409aa4e38e7840482b6bb9ca05b26452eac265beabe27048d464/recovery-kit.tar", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "schema": "platform.coulomb-protected-recovery-kit.v1", + "sha256": "91d8cec1b6b4409aa4e38e7840482b6bb9ca05b26452eac265beabe27048d464", + "version_id": "1793405649619843" + }, + "protected_kit_and_proof_versions_verified": true, + "protected_kit_body_refetched": false, + "protected_recovery_proof": { + "automatic_expiry": false, + "bucket": "railiance-coulomb-social-recovery", + "bytes": 4372, + "independent_readback_verified": true, + "object_key": "kits/receipts/368b7872d2c0186858d93c3bb9db2943c576942e1db131caaf0e643c433db2a5/recovery-proof.json", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "sha256": "368b7872d2c0186858d93c3bb9db2943c576942e1db131caaf0e643c433db2a5", + "version_id": "1793405751488728" + }, + "provider_mutations": false, + "provider_observation_sha256": "b1c4248a9b525d57213068fe31bdd571733462468a7d654ff0866715b79fe42e", + "ready_to_execute": false, + "routine_api_credentials_used": true, + "schema": "coulomb.native-generation-retention-review.v1", + "source_sha256": { + "inventory": "1ebf6ee3eecf4adc32400c45df2500b876d5747c0e0327aeb3c180655c9d955d", + "observation": "b1c4248a9b525d57213068fe31bdd571733462468a7d654ff0866715b79fe42e", + "recovery": "25c12e2f682940d6fb7887773efa4d042a909d02305a480dcc846425cf66a205" + }, + "sources_manifest_sha256": "6038db92589c9547a930c21bd50c3c00d3d2ec2801d283c6a88b0768e231f551", + "status": "selection_reviewed_execution_not_admitted", + "timers_activated": false, + "unknown_objects_preserved": true, + "validation": { + "browser_journeys_included": true, + "make_check_passed": 415, + "skipped": 0 + } +} diff --git a/docs/release/generation-retention-review-2026-10-31.md b/docs/release/generation-retention-review-2026-10-31.md new file mode 100644 index 0000000..567fad1 --- /dev/null +++ b/docs/release/generation-retention-review-2026-10-31.md @@ -0,0 +1,83 @@ +# Current generation retention and mirror coverage + +The existing backup taken at 02:07:21 UTC before the founder's date correction is +now independently mirrored and retrieved. The unchanged mirror worker ran twice +against a private copy of that exact node archive. Both runs retained the same +ciphertext and filtered public receipt versions; the separate reader verified +their complete bytes, hashes and sizes. This is a pre-correction recovery point, +not a newly captured post-correction point or an additional application restore. + +Evidence: [native mirror proof](mirror-date-point-native-2026-10-31.json) and its +[source manifest](mirror-date-point-native-2026-10-31.sources.txt). No installed +node worker, node mirror state, unit or timer was changed. No decryption key was +read and no retained object was deleted. + +## Repeatable provider observation + +`scripts/observe-backup-retention.py` replaces the one-off observation procedure +with a read-only command. Its default preview contacts no provider. Explicit +execution uses the existing publisher only for primary metadata reads and the +separate reader for mirror/protected-kit reads. It loads the four hash-pinned +platform helpers, never uses the custodian or a personal Console session, and +writes only a fresh private observation outside Git. + +The observer requires enabled versioning, explicit bounded pagination completion, +unique in-prefix versions and exact immutable-version HEAD identity/hash/size. +Only the provider's specific `NoSuchLifecycleConfiguration` response establishes +absent lifecycle rules; an unavailable or missing bucket is not accepted as +nonexpiring custody. Retained public receipts are independently fetched and +compared byte-for-byte with the worker's explicit public-field filter. Archive +and protected-kit bodies are not fetched again merely to refresh metadata; +their previous independent readback and recovery proofs remain separate evidence. + +```sh +/path/to/locked-sdk/python scripts/observe-backup-retention.py --execute \ + --inventory /private/mirrored-inventory.json \ + --recovery docs/release/provider-kit-recovery-2026-10-31.json \ + --credentials-directory /private/existing-coulomb-recovery-custody \ + --platform-scripts /private/reviewed-platform/scripts \ + --runtime-manifest deploy/backup/mirror-platform-runtime.json \ + --output /private/new-observation.json + +uv run python scripts/review-backup-retention.py \ + --inventory /private/mirrored-inventory.json \ + --recovery docs/release/provider-kit-recovery-2026-10-31.json \ + --observation /private/new-observation.json \ + --platform-scripts /private/reviewed-platform/scripts \ + --output /private/new-generation-review.json +``` + +## Native result + +Four logical points are represented in the current reviewed inventory. Complete +provider listings contain four primary versions, eight mirror versions and six +protected-kit/proof/fixture versions, with no delete markers. Twelve exact HEADs +match the known point, receipt and protected-custody bindings. Both retained +worker public receipts were independently fetched and verified. Mirror and kit +buckets still have no lifecycle rules; the primary's existing global 30-day +current/noncurrent expiry remains unchanged. + +The pinned 14-daily/four-weekly selector keeps three points: October 29, the +protected October 30 recovered point, and the newest October 31 point at 02:07. +It identifies the earlier October 31 00:27 point as one expiration candidate, +including its exact independently retained receipt. The candidate is still +retained. Unknown objects and admission fixtures remain preserved. These three +days of available history do not establish a full 14-day/four-week history. + +The [current review proof](generation-retention-review-2026-10-31.json) records +counts, exact selected versions, source/input hashes and the native observation. +The private inputs and full observation remain under +`~/.config/railiance-platform/coulomb-social-recovery/retention-review-20261031/`. + +## Remaining operating requirements + +This closes fresh observation and current mirror coverage, not retention +execution. The separately admitted mirror-only expiry caller/executor, native +deletion boundaries, partial-outcome recovery and immediate keep-set revalidation +are still required. Existing publisher/reader deletion denials must remain intact. +No primary or protected-kit deletion belongs in that executor. Historical local +copies, failed-drill kits and boundary fixtures still require owner disposition. + +Node operating delivery, capacity admission, received failure/stale alerts and +recurring activation remain open, as do WP-0012's service-contract requirements. +The already approved daily schedule and independent custody are not reopened. diff --git a/docs/release/generation-retention-review-2026-10-31.sources.txt b/docs/release/generation-retention-review-2026-10-31.sources.txt new file mode 100644 index 0000000..f93cc65 --- /dev/null +++ b/docs/release/generation-retention-review-2026-10-31.sources.txt @@ -0,0 +1,7 @@ +/home/worsch/coulomb-social/deploy/backup/mirror-platform-runtime.json: 9253c5f6e956b67f499d3e12d2851be7fabd3bc56b00496b8810bbd0be8209f1 +/home/worsch/coulomb-social/scripts/check-paired-backup-health.py: c003871fe91c5c31788fd9bfc50aef3b2a275c06c5a42c2c3165b92d5ea80db9 +/home/worsch/coulomb-social/scripts/mirror-paired-backup.py: 9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c +/home/worsch/coulomb-social/scripts/observe-backup-retention.py: 9dc7e862ef2888998744d47cb458f0401b870e96cf283df5dd375b627b3f176f +/home/worsch/coulomb-social/scripts/review-backup-retention.py: 929c0416add6bfddee5bce9e6aaeb436d2d9de8084db48d061f66186f7127794 +/home/worsch/coulomb-social/tests/test_backup_retention_observation.py: 65292156128f914336e05a00c72fbb23c53587d79a8df93a9a3e0976b38b5a6b +/home/worsch/railiance-platform/scripts/application_archive_retention_plan.py: 08bc57308f53bbfd0d58fdbb9b5671dd4285e31c5b5137e201524df606aa3f9f diff --git a/docs/release/mirror-date-point-native-2026-10-31.json b/docs/release/mirror-date-point-native-2026-10-31.json new file mode 100644 index 0000000..59d1543 --- /dev/null +++ b/docs/release/mirror-date-point-native-2026-10-31.json @@ -0,0 +1,72 @@ +{ + "schema": "coulomb.native-mirror-worker-proof.v1", + "status": "native_worker_and_independent_receipt_verified", + "completed_at": "2026-10-31T02:44:16.363553+00:00", + "primary": { + "application": "coulomb-social", + "captured_at": "2026-10-31T02:07:21Z", + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/31/20261031T020721Z-4aa7a5d4146abf20f0b23e66.tar.age", + "version_id": "1793412500034233", + "ciphertext_sha256": "ac9fe6711c967a1c79caeca961c24ebf0ce795fb98e018852da00a1795341709", + "ciphertext_bytes": 2181848, + "age_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd", + "database_sha256": "6968b759e476b65c1911107315fec269d11dd489af995d6a9d4b4da8b18b25f2", + "content_sha256": "631e2ebc3e7b114f83945d889b133fef2491030509dfb11024a24c485fe80ec8" + }, + "recovery_mirror": { + "bucket": "railiance-coulomb-social-archives", + "ciphertext_bytes": 2181848, + "ciphertext_sha256": "ac9fe6711c967a1c79caeca961c24ebf0ce795fb98e018852da00a1795341709", + "hash_readback_verified": true, + "object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T020721Z-4aa7a5d4146abf20f0b23e66.tar.age", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "receipt_bytes": 1664, + "receipt_object_key": "archives/platform-pg/application-archives/coulomb-social/2026/10/31/20261031T020721Z-4aa7a5d4146abf20f0b23e66.tar.age.85383a8d70268aab71902e8dbb1e103749b43f017a965319dbd1c9152db64210.receipt.json", + "receipt_readback_verified": true, + "receipt_sha256": "85383a8d70268aab71902e8dbb1e103749b43f017a965319dbd1c9152db64210", + "receipt_version_id": "1793414656525890", + "version_id": "1793414655337141" + }, + "worker_results": [ + { + "status": "mirrored_with_retained_receipt", + "primary_recaptured": false, + "ciphertext_readback_verified": true, + "receipt_readback_verified": true, + "recovery_key_read": false, + "independent_reader_used": false, + "retained_objects_deleted": false, + "application_restore_verified": false + }, + { + "status": "mirrored_with_retained_receipt", + "primary_recaptured": false, + "ciphertext_readback_verified": true, + "receipt_readback_verified": true, + "recovery_key_read": false, + "independent_reader_used": false, + "retained_objects_deleted": false, + "application_restore_verified": false + } + ], + "native_versions_before": 0, + "native_versions_after": 2, + "one_ciphertext_and_one_receipt_version_after_retry": true, + "independent_reader_ciphertext_and_receipt_verified": true, + "retained_receipt_equals_exact_public_field_filter": true, + "local_primary_receipt_and_ciphertext_unchanged": true, + "retry_last_mirror_bytes_unchanged": true, + "runtime_source_commit": "09f4fd33cbeefecb833998246cf53848bb1e5d8d", + "private_state_copy_used": true, + "production_application_contact": false, + "node_helpers_or_state_modified": false, + "node_timers_changed": false, + "native_capture_performed": false, + "decryption_identity_read": false, + "retained_objects_deleted": false, + "operating_capacity_profile_admitted": false, + "alert_reception_verified": false, + "scheduled_service_execution_verified": false, + "sources_manifest_sha256": "11486b9080e29d0a37e7e6f8af8fe14602ab8d87db1871e500ce492721ef9eef" +} diff --git a/docs/release/mirror-date-point-native-2026-10-31.sources.txt b/docs/release/mirror-date-point-native-2026-10-31.sources.txt new file mode 100644 index 0000000..17ee441 --- /dev/null +++ b/docs/release/mirror-date-point-native-2026-10-31.sources.txt @@ -0,0 +1,9 @@ +/home/worsch/coulomb-social/deploy/backup/mirror-platform-runtime.json: 9253c5f6e956b67f499d3e12d2851be7fabd3bc56b00496b8810bbd0be8209f1 +/home/worsch/coulomb-social/scripts/backup_capacity.py: cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893 +/home/worsch/coulomb-social/scripts/check-paired-backup-health.py: c003871fe91c5c31788fd9bfc50aef3b2a275c06c5a42c2c3165b92d5ea80db9 +/home/worsch/coulomb-social/scripts/mirror-paired-backup.py: 9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c +/home/worsch/railiance-platform/scripts/application_recovery_identities.py: 20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d +/home/worsch/railiance-platform/scripts/application_recovery_mirror.py: d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178 +/home/worsch/railiance-platform/scripts/application_recovery_resources.py: ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498 +/home/worsch/railiance-platform/scripts/primary_application_archive.py: a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d +/tmp/csoc-native-mirror-date-point-20261031.py: 282cd40b470554294afa2bf98c60455480a02e4e14e409207e6fb029db306a18 diff --git a/scripts/observe-backup-retention.py b/scripts/observe-backup-retention.py new file mode 100644 index 0000000..f70b4e4 --- /dev/null +++ b/scripts/observe-backup-retention.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Read current version/custody evidence for the offline generation reviewer. + +Preview is offline. --execute performs only scoped provider reads and publishes +one new private observation. It never captures, decrypts, deletes or changes IAM. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +from datetime import UTC, datetime +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "retention_mirror", Path(__file__).with_name("mirror-paired-backup.py") +) +worker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(worker) +health = worker.health +Refused = health.Refused +PRIMARY = "railiance-platform-pg-backup" +KIT = "railiance-coulomb-social-recovery" +SCOPES = {PRIMARY: health.PREFIX, health.MIRROR_BUCKET: "archives/", KIT: "kits/"} + + +def status(error): + return getattr(error, "response", {}).get("ResponseMetadata", {}).get("HTTPStatusCode") + + +def version_id(value): + if ( + not isinstance(value, str) + or not value + or value == "null" + or any(c in value for c in "\r\n") + ): + raise Refused("immutable_version_required") + return value + + +def observe_bucket(client, bucket): + prefix = SCOPES[bucket] + versioning = client.get_bucket_versioning(Bucket=bucket) + if versioning.get("Status") != "Enabled": + raise Refused("enabled_versioning_required") + try: + lifecycle = client.get_bucket_lifecycle_configuration(Bucket=bucket) + rules = lifecycle["Rules"] + except Exception as error: + # A bucket itself being missing or inaccessible is not "no lifecycle". + code = getattr(error, "response", {}).get("Error", {}).get("Code") + if status(error) != 404 or code != "NoSuchLifecycleConfiguration": + raise + rules = [] + if not isinstance(rules, list): + raise Refused("lifecycle_rules_required") + versions, markers, seen, identities, arguments = [], [], set(), set(), {} + for _ in range(100): + page = client.list_object_versions(Bucket=bucket, Prefix=prefix, MaxKeys=1000, **arguments) + for field, destination in [("Versions", versions), ("DeleteMarkers", markers)]: + for row in page.get(field, []): + key, version = row["Key"], version_id(row["VersionId"]) + if not isinstance(key, str) or not key.startswith(prefix): + raise Refused("version_outside_application_prefix") + if (key, version) in identities: + raise Refused("duplicate_or_changing_version_inventory") + identities.add((key, version)) + item = {"Key": key, "VersionId": version} + if field == "Versions": + if type(row.get("Size")) is not int or row["Size"] < 0: + raise Refused("version_size_required") + item["Size"] = row["Size"] + if "IsLatest" in row: + item["IsLatest"] = row["IsLatest"] + destination.append(item) + if page.get("IsTruncated") is False: + return { + "bucket": bucket, + "prefix": prefix, + "inventory_complete": True, + "versioning": {"Status": "Enabled"}, + "lifecycle": {"status": "observed", "Rules": rules}, + "versions": versions, + "delete_markers": markers, + } + if page.get("IsTruncated") is not True: + raise Refused("explicit_pagination_completion_required") + cursor = page.get("NextKeyMarker"), page.get("NextVersionIdMarker") + if not isinstance(cursor[0], str) or not cursor[0].startswith(prefix) or cursor in seen: + raise Refused("invalid_or_repeated_version_cursor") + seen.add(cursor) + arguments = {"KeyMarker": cursor[0]} + if cursor[1] is not None: + arguments["VersionIdMarker"] = version_id(cursor[1]) + raise Refused("version_inventory_limit_exceeded") + + +def observe(primary_client, reader, inventory, recovery, now=None): + now = now or datetime.now(UTC) + if not isinstance(inventory, list) or not inventory: + raise Refused("nonempty_verified_inventory_required") + expected = {} + + def add(bucket, key, version, digest, size): + version_id(version) + if ( + bucket not in SCOPES + or not isinstance(key, str) + or not key.startswith(SCOPES[bucket]) + or not isinstance(digest, str) + or len(digest) != 64 + or any(c not in "0123456789abcdef" for c in digest) + or type(size) is not int + or size <= 0 + ): + raise Refused("exact_version_binding_required") + identity = bucket, key, version + if identity in expected and expected[identity] != (digest, size): + raise Refused("conflicting_version_binding") + expected[identity] = digest, size + + for receipt in inventory: + health.validate_mirror(receipt, receipt) + add( + PRIMARY, + receipt["object_key"], + receipt["version_id"], + receipt["ciphertext_sha256"], + receipt["ciphertext_bytes"], + ) + ref = receipt["recovery_mirror"] + add( + health.MIRROR_BUCKET, + ref["object_key"], + ref["version_id"], + ref["ciphertext_sha256"], + ref["ciphertext_bytes"], + ) + if "receipt_object_key" in ref: + health.validate_mirror(receipt, receipt, require_retained_receipt=True) + add( + health.MIRROR_BUCKET, + ref["receipt_object_key"], + ref["receipt_version_id"], + ref["receipt_sha256"], + ref["receipt_bytes"], + ) + if ( + recovery.get("status") != "application_restored_verified" + or recovery.get("application") != "coulomb-social" + or recovery.get("application_restore_verified") is not True + ): + raise Refused("verified_application_recovery_required") + for name in ["protected_kit", "protected_recovery_proof"]: + ref = recovery[name] + suffix = "/recovery-kit.tar" if name == "protected_kit" else "/recovery-proof.json" + prefix = "kits/" if name == "protected_kit" else "kits/receipts/" + if ( + ref.get("bucket") != KIT + or ref.get("project_id") != health.PROJECT + or ref.get("automatic_expiry") is not False + or ref.get("object_key") != prefix + ref["sha256"] + suffix + ): + raise Refused("protected_custody_binding_required") + add(KIT, ref["object_key"], ref["version_id"], ref["sha256"], ref["bytes"]) + + clients = {PRIMARY: primary_client, health.MIRROR_BUCKET: reader, KIT: reader} + observations = [observe_bucket(clients[bucket], bucket) for bucket in SCOPES] + versions = { + (b["bucket"], v["Key"], v["VersionId"]): v for b in observations for v in b["versions"] + } + heads = [] + for (bucket, key, version), (digest, size) in sorted(expected.items()): + listed = versions.get((bucket, key, version)) + if listed is None and bucket == PRIMARY: + continue # Primary age expiry never removes an independent mirror generation. + if listed is None or listed["Size"] != size: + raise Refused("retained_version_missing_or_changed") + value = clients[bucket].head_object(Bucket=bucket, Key=key, VersionId=version) + if ( + value.get("VersionId") != version + or value.get("ContentLength") != size + or value.get("Metadata", {}).get("sha256") != digest + or value.get("DeleteMarker") + ): + raise Refused("exact_version_head_differs") + heads.append( + { + "bucket": bucket, + "object_key": key, + "version_id": version, + "sha256": digest, + "bytes": size, + } + ) + + sidecars = 0 + for receipt in inventory: + ref = receipt["recovery_mirror"] + if "receipt_object_key" not in ref: + continue + response = reader.get_object( + Bucket=health.MIRROR_BUCKET, + Key=ref["receipt_object_key"], + VersionId=ref["receipt_version_id"], + ) + with response["Body"] as stream: + payload = stream.read(ref["receipt_bytes"] + 1) + if ( + response.get("VersionId") != ref["receipt_version_id"] + or response.get("ContentLength") != ref["receipt_bytes"] + or len(payload) != ref["receipt_bytes"] + or hashlib.sha256(payload).hexdigest() != ref["receipt_sha256"] + or payload != worker.canonical(worker.public_receipt(receipt, receipt)) + ): + raise Refused("independent_public_receipt_readback_differs") + sidecars += 1 + return { + "schema": "coulomb.provider-retention-observation.v1", + "observed_at": now.isoformat(), + "head_observed_at": datetime.now(UTC).isoformat(), + "observations": observations, + "version_heads": heads, + "independent_public_receipts_fetched_verified": sidecars, + "archive_bodies_refetched": False, + "protected_kit_body_refetched": False, + "provider_mutations": False, + "credentials_printed": False, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--execute", action="store_true", help="Read provider metadata and receipts" + ) + for name in [ + "inventory", + "recovery", + "credentials-directory", + "platform-scripts", + "runtime-manifest", + "output", + ]: + parser.add_argument("--" + name, type=Path) + args = parser.parse_args() + if not args.execute: + print(json.dumps({"status": "preview", "provider_contacted": False})) + return + if any( + getattr(args, name) is None + for name in [ + "inventory", + "recovery", + "credentials_directory", + "platform_scripts", + "runtime_manifest", + "output", + ] + ): + raise Refused("explicit_observation_inputs_required") + worker.load_runtime(args.platform_scripts, args.runtime_manifest) + from application_recovery_identities import private_directory, private_json + from application_recovery_resources import s3 + + private_directory(args.credentials_directory) + private_directory(args.output.parent) + if args.output.exists() or args.output.is_symlink(): + raise Refused("fresh_observation_output_required") + inputs = {name: getattr(args, name).read_bytes() for name in ["inventory", "recovery"]} + # Use explicit static credentials: no SDK ambient identity or metadata fallback. + import boto3 + from botocore.config import Config + + pair = private_json(args.credentials_directory / "publisher-pair.json") + if not all( + isinstance(pair.get(k), str) and pair[k].strip() + for k in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] + ): + raise Refused("own_publisher_pair_required") + primary = boto3.client( + "s3", + endpoint_url="https://s3.nl-ams.scw.cloud", + region_name="nl-ams", + aws_access_key_id=pair["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=pair["AWS_SECRET_ACCESS_KEY"], + config=Config( + signature_version="s3v4", + connect_timeout=15, + read_timeout=30, + retries={"max_attempts": 0}, + s3={"addressing_style": "path"}, + ), + ) + reader = None + try: + reader = s3(private_json(args.credentials_directory / "reader-pair.json"), health.PROJECT) + result = observe( + primary, reader, json.loads(inputs["inventory"]), json.loads(inputs["recovery"]) + ) + finally: + primary.close() + if reader is not None: + reader.close() + result["source_sha256"] = { + name: hashlib.sha256(value).hexdigest() for name, value in inputs.items() + } + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(result, stream, indent=2, sort_keys=True) + stream.write("\n") + print( + json.dumps( + { + "status": "provider_observation_completed", + "provider_mutations": False, + "exact_version_heads": len(result["version_heads"]), + "independent_receipts_verified": result[ + "independent_public_receipts_fetched_verified" + ], + } + ) + ) + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(json.dumps({"status": "observation_refused", "failure_type": type(error).__name__})) + raise SystemExit(1) diff --git a/tests/test_backup_retention_observation.py b/tests/test_backup_retention_observation.py new file mode 100644 index 0000000..f7627e3 --- /dev/null +++ b/tests/test_backup_retention_observation.py @@ -0,0 +1,217 @@ +"""Provider observation must distinguish absence, uncertainty and exact versions.""" + +import copy +import hashlib +import importlib.util +import io +from pathlib import Path + +import pytest + +from tests.test_backup_health import NOW +from tests.test_backup_health import health_state as health_state +from tests.test_backup_health import monitor as monitor +from tests.test_backup_retention_review import evidence as evidence +from tests.test_backup_retention_review import reviewer as reviewer + + +@pytest.fixture +def observer(monkeypatch): + scripts = Path(__file__).resolve().parents[1] / "scripts" + monkeypatch.syspath_prepend(str(scripts)) + spec = importlib.util.spec_from_file_location( + "retention_observer", scripts / "observe-backup-retention.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ProviderError(Exception): + def __init__(self, status, code): + self.response = {"ResponseMetadata": {"HTTPStatusCode": status}, "Error": {"Code": code}} + + +class ReadOnlyStore: + def __init__(self, evidence): + self.buckets = {b["bucket"]: copy.deepcopy(b) for b in evidence[2]["observations"]} + self.heads = { + (h["bucket"], h["object_key"], h["version_id"]): { + "VersionId": h["version_id"], + "ContentLength": h["bytes"], + "Metadata": {"sha256": h["sha256"]}, + } + for h in evidence[2]["version_heads"] + } + self.payloads = {} + self.calls = [] + + def get_bucket_versioning(self, Bucket): + self.calls.append(("versioning", Bucket)) + return self.buckets[Bucket]["versioning"] + + def get_bucket_lifecycle_configuration(self, Bucket): + self.calls.append(("lifecycle", Bucket)) + return self.buckets[Bucket]["lifecycle"] + + def list_object_versions(self, Bucket, Prefix, MaxKeys, **kwargs): + self.calls.append(("versions", Bucket, Prefix)) + assert Prefix == self.buckets[Bucket]["prefix"] and MaxKeys == 1000 + return {"Versions": copy.deepcopy(self.buckets[Bucket]["versions"]), "IsTruncated": False} + + def head_object(self, Bucket, Key, VersionId): + self.calls.append(("head", Bucket, Key, VersionId)) + return self.heads[Bucket, Key, VersionId] + + def get_object(self, Bucket, Key, VersionId): + self.calls.append(("get", Bucket, Key, VersionId)) + return { + "VersionId": VersionId, + "ContentLength": self.heads[Bucket, Key, VersionId]["ContentLength"], + "Body": io.BytesIO(self.payloads[Bucket, Key, VersionId]), + } + + +def without_sidecar(evidence): + ref = evidence[0][0]["recovery_mirror"] + for key in list(ref): + if key.startswith("receipt_"): + del ref[key] + + +def test_observation_rechecks_current_versions_and_passes_offline_review( + observer, reviewer, evidence +): + without_sidecar(evidence) + store = ReadOnlyStore(evidence) + before = copy.deepcopy(evidence) + result = observer.observe(store, store, evidence[0], evidence[1], NOW) + result["head_observed_at"] = NOW.isoformat() + plan = reviewer.review(evidence[0], evidence[1], result, evidence[3], NOW) + assert len(result["version_heads"]) == 4 and plan["generation_selection_valid"] + assert not result["provider_mutations"] and not result["archive_bodies_refetched"] + assert not result["protected_kit_body_refetched"] + assert evidence == before + + +def test_missing_primary_is_allowed_but_missing_mirror_is_not(observer, evidence): + without_sidecar(evidence) + store = ReadOnlyStore(evidence) + store.buckets[observer.PRIMARY]["versions"] = [] + result = observer.observe(store, store, evidence[0], evidence[1], NOW) + assert len(result["version_heads"]) == 3 + store.buckets[observer.health.MIRROR_BUCKET]["versions"] = [] + with pytest.raises(ValueError, match="retained_version_missing"): + observer.observe(store, store, evidence[0], evidence[1], NOW) + + +@pytest.mark.parametrize("change", ["version", "hash", "size", "delete_marker"]) +def test_changed_exact_head_refuses_observation(observer, evidence, change): + without_sidecar(evidence) + store = ReadOnlyStore(evidence) + head = next(iter(store.heads.values())) + if change == "version": + head["VersionId"] = "another-version" + elif change == "hash": + head["Metadata"]["sha256"] = "0" * 64 + elif change == "size": + head["ContentLength"] += 1 + else: + head["DeleteMarker"] = True + with pytest.raises(ValueError, match="exact_version_head_differs"): + observer.observe(store, store, evidence[0], evidence[1], NOW) + + +@pytest.mark.parametrize( + "status,code,accepted", + [ + (404, "NoSuchLifecycleConfiguration", True), + (404, "NoSuchBucket", False), + (403, "AccessDenied", False), + (500, "InternalError", False), + ], +) +def test_only_explicit_lifecycle_absence_is_accepted(observer, evidence, status, code, accepted): + store = ReadOnlyStore(evidence) + + def fail(**kwargs): + raise ProviderError(status, code) + + store.get_bucket_lifecycle_configuration = fail + if accepted: + assert observer.observe_bucket(store, observer.KIT)["lifecycle"]["Rules"] == [] + else: + with pytest.raises(ProviderError): + observer.observe_bucket(store, observer.KIT) + + +@pytest.mark.parametrize( + "failure", + ["foreign", "duplicate", "null_version", "boolean_size", "no_completion", "cursor_cycle"], +) +def test_ambiguous_or_incomplete_inventory_refuses(observer, evidence, failure): + store = ReadOnlyStore(evidence) + bucket = observer.health.MIRROR_BUCKET + rows = store.buckets[bucket]["versions"] + if failure == "foreign": + rows[0]["Key"] = "foreign/object" + elif failure == "duplicate": + rows.append(dict(rows[0])) + elif failure == "null_version": + rows[0]["VersionId"] = "null" + elif failure == "boolean_size": + rows[0]["Size"] = True + elif failure == "no_completion": + store.list_object_versions = lambda **kwargs: {"Versions": []} + else: + store.list_object_versions = lambda **kwargs: { + "Versions": [], + "IsTruncated": True, + "NextKeyMarker": "archives/synthetic", + "NextVersionIdMarker": "cursor", + } + with pytest.raises(ValueError): + observer.observe_bucket(store, bucket) + + +@pytest.mark.parametrize("corrupt", [False, True]) +def test_retained_receipt_is_fetched_and_compared_with_exact_public_filter( + observer, evidence, corrupt +): + receipt = evidence[0][0] + ref = receipt["recovery_mirror"] + payload = observer.worker.canonical(observer.worker.public_receipt(receipt, receipt)) + digest = hashlib.sha256(payload).hexdigest() + ref.update( + receipt_object_key=ref["object_key"] + "." + digest + ".receipt.json", + receipt_sha256=digest, + receipt_bytes=len(payload), + receipt_version_id="sidecar", + ) + store = ReadOnlyStore(evidence) + bucket = observer.health.MIRROR_BUCKET + store.buckets[bucket]["versions"].append( + {"Key": ref["receipt_object_key"], "VersionId": "sidecar", "Size": len(payload)} + ) + key = bucket, ref["receipt_object_key"], "sidecar" + store.heads[key] = { + "VersionId": "sidecar", + "ContentLength": len(payload), + "Metadata": {"sha256": digest}, + } + store.payloads[key] = b"bad" if corrupt else payload + if corrupt: + with pytest.raises(ValueError, match="independent_public_receipt_readback_differs"): + observer.observe(store, store, evidence[0], evidence[1], NOW) + else: + result = observer.observe(store, store, evidence[0], evidence[1], NOW) + assert result["independent_public_receipts_fetched_verified"] == 1 + + +def test_foreign_protected_reference_refuses_before_provider_contact(observer, evidence): + without_sidecar(evidence) + store = ReadOnlyStore(evidence) + evidence[1]["protected_kit"]["bucket"] = "foreign" + with pytest.raises(ValueError, match="protected_custody_binding"): + observer.observe(store, store, evidence[0], evidence[1], NOW) + assert store.calls == [] diff --git a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md index e852d24..bf49345 100644 --- a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md +++ b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md @@ -133,6 +133,18 @@ expiry executor remains needed, with publisher/reader deletion denials intact. Review/proof: docs/release/generation-retention-review-2026-10-30.{md,json}. This is not recurring retention enforcement or additional expiry admission. +2026-10-31 current-inventory return: the date-operation 02:07 point is now +mirrored with its immutable public receipt and independent reader verification. +A repeatable read-only observer refreshes complete prefix listings, lifecycle, +12 exact-version HEADs and two public-receipt GET comparisons. Four logical +points are mapped; the pinned selector keeps three and identifies the earlier +October 31 00:27 point as one candidate with its exact receipt. Nothing was +deleted. Protected kit/proof custody remains unchanged, all unknown versions and +fixtures are preserved, and no age-only mirror expiry was added. Source and native +proof: docs/release/generation-retention-review-2026-10-31.{md,json}. Separate +expiry executor/caller admission, native boundaries and historical-copy +disposition remain open; the fresh review is not recurring retention enforcement. + ## T02 — Activate and prove the first encrypted paired recovery point ```task @@ -332,6 +344,16 @@ service-contract returns remain separate; no timer activation is inferred. The new root date journal now exists and remains included in the already tracked operator offboarding/expiry obligation. No journal or historical pair was deleted. +2026-10-31 latest-existing-point mirror: unchanged worker executed twice from a +private copy of the native 02:07 date-operation archive. Independent reader GETs +verify the single ciphertext and public receipt version; retry changes neither. +The node's durable receipt and encrypted bytes were checked before copying. +Proof: docs/release/mirror-date-point-native-2026-10-31.{json,sources.txt}. +This point predates the date correction and is not a new capture or app restore. +No node worker/state/unit/timer changed. Fresh generation observation/selection is +recorded under T01; node operating execution, received alerts and the full service +contract remain required under T03. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records.