lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit ab24efd2d4887ca0f06118642776f43e3edfe626
parent 5e91441795b5a838631af610a42d0ae367045bea
Author: triesap <tyson@radroots.org>
Date:   Wed, 22 Jul 2026 02:54:46 +0000

outbox: add versioned migration authority

- freeze the existing outbox migration and authenticate its exact catalog
- adopt only exact unledgered baselines under serialized writer authority
- replace unrestricted destruction with versioned terminal rollback
- bind runtime behavior to generated contracts and executable conformance

Diffstat:
MCHANGELOG.md | 14++++++++++++++
Mbuild/nix/common.nix | 1+
Acontracts/conformance/vectors/outbox/migration_authority.v1.json | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 15+++++++++++++++
Mcrates/event_store/contracts/raw_source_rebuild_v1.manifest.json | 22+++++++++++-----------
Mcrates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256 | 2+-
Mcrates/event_store/src/generated/raw_source_rebuild_manifest.rs | 4++--
Mcrates/outbox/Cargo.toml | 5+++--
Mcrates/outbox/README | 22++++++++++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.json | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.schema.json | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 1+
Mcrates/outbox/src/error.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/src/generated.rs | 3+++
Acrates/outbox/src/generated/outbox_migration_manifest.rs | 15+++++++++++++++
Mcrates/outbox/src/lib.rs | 5++++-
Mcrates/outbox/src/migrations.rs | 665++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/outbox/src/schema.rs | 1601+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/src/store.rs | 265++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Acrates/outbox/tests/fixtures/migration_authority.v1.json | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/tests/migration_authority_v1_result_vector.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 16++++++++++++++--
Atools/xtask/src/contract/outbox_migration.rs | 1026+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract/raw_source_rebuild.rs | 2+-
Mtools/xtask/src/main.rs | 29+++++++++++++++++++++++++++++
25 files changed, 4877 insertions(+), 72 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -9,6 +9,20 @@ publish policy both pass for the same source revision. ### Changed +<!-- release-change: outbox-versioned-migration-authority --> +- Outbox schema initialization now uses an ordered, checksummed migration + registry and an exact authenticated catalog fingerprint. Existing + unledgered databases are adopted only when all five baseline tables and + eight indexes match the frozen `0001_outbox` identity; partial, changed, + counterfeit, gapped, newer, or otherwise unknown `outbox_*` state fails + before governed schema or history mutation while unrelated caller tables + remain untouched. Every open serializes under SQLite writer authority and + validates UTF-8 encoding, foreign keys, file WAL mode, bounded catalog and + history reads, integrity, and the tamper-evident ledger. Raw migration SQL + exports and unrestricted `migrate_down` were replaced by schema status, + migrate-to-current, and a terminal explicit-target rollback with a public + version floor. Complete destruction is available only through a separately + named migration-test helper. - Event-store schema initialization now uses a transactional, checksummed migration authority with exact legacy-baseline adoption, shared-database catalog scoping, tamper-evident fail-closed managed history, exact catalog diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -112,6 +112,7 @@ let "radroots_nostr" "radroots_nostr_connect" "radroots_nostr_signer" + "radroots_outbox" ]; coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) diff --git a/contracts/conformance/vectors/outbox/migration_authority.v1.json b/contracts/conformance/vectors/outbox/migration_authority.v1.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "contract_id": "radroots_outbox.migration_authority.v1", + "executor": { + "id": "radroots_outbox.migration_authority_v1.result_vector_executor.v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "test": "migration_authority_v1_result_vector" + }, + "delegated_suite": { + "lane": "nix run .#contract", + "package": "radroots_outbox", + "authorities": [ + { + "authority": "migration_source_discovery_is_exact_and_fail_closed", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "ledger_name_checksum_and_catalog_mutations_fail_closed", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "newer_history_and_governed_catalog_overflow_are_bounded_and_rejected", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "history_validation_rejects_gaps_and_unknown_versions", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "temporary_authority_collisions_are_rejected_before_migration", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "concurrent_file_initializers_serialize_to_one_exact_history", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "terminal_target_rollback_preserves_v1_rows_and_closes_every_clone", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "reopen_rejects_outbox_foreign_key_corruption", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "open_rejects_utf16_before_journal_or_schema_mutation", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "file_pool_rejects_successful_non_wal_journal_result", + "authority_path": "crates/outbox/src/store.rs" + } + ] + }, + "cases": [ + { + "id": "fresh_initialization", + "execution": "direct_executor", + "expected_outcome": "managed_v1", + "expected_error": null + }, + { + "id": "exact_unledgered_adoption", + "execution": "direct_executor", + "expected_outcome": "managed_v1_without_replay", + "expected_error": null + }, + { + "id": "partial_unledgered_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "UnmanagedSchema" + }, + { + "id": "ledger_checksum_tamper_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "MigrationHistoryChecksumDrift" + }, + { + "id": "newer_history_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "SchemaTooNew" + }, + { + "id": "rollback_below_floor_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_without_schema_change", + "expected_error": "RollbackBelowVersionFloor" + }, + { + "id": "caller_state_preserved", + "execution": "direct_executor", + "expected_outcome": "managed_v1_with_caller_state_preserved", + "expected_error": null + }, + { + "id": "current_reopen_no_history_write", + "execution": "direct_executor", + "expected_outcome": "managed_v1_without_history_write", + "expected_error": null + } + ] +} diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -310,6 +310,21 @@ semver_impacts = ["add_enum_variant", "change_exported_algorithm_behavior"] summary = "Reject every NIP-16 ephemeral event from all generic durable-outbox entry points, keep transient events inside their owning live transport exchanges, and validate and configure every externally supplied SQLite pool connection before migration or writes." [[changes]] +id = "outbox-versioned-migration-authority" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_enum_variant", + "add_conformance_vector", + "remove_exported_constant", + "remove_exported_function", + "change_exported_algorithm_behavior", +] +summary = "Replace raw outbox migration SQL and unrestricted destruction with a contiguous checksummed registry, exact unledgered-baseline adoption, a tamper-evident ledger and catalog fingerprint, bounded fail-closed open validation, serialized migration, explicit target rollback with a version floor, test-only destruction, and an executable conformance contract while freezing the 0001 migration bytes." + +[[changes]] id = "shared-read-only-owner-boundaries" classification = "feature" semver_impacts = ["add_exported_function"] diff --git a/crates/event_store/contracts/raw_source_rebuild_v1.manifest.json b/crates/event_store/contracts/raw_source_rebuild_v1.manifest.json @@ -877,8 +877,8 @@ { "role": "nix_contract_test_lane_authority", "path": "build/nix/common.nix", - "byte_length": 11127, - "sha256": "b3340e1b4973e6a1e02899d164ca74842757f22b6b1a03f90461532fcd844df5", + "byte_length": 11149, + "sha256": "738003cb1703baaf73a97c010c84731a42230782661c79c6a152fbb9e6fa9f6e", "hash_algorithm": "sha256_bytes_v1" }, { @@ -1193,35 +1193,35 @@ "role": "raw_source_rebuild_governance", "path": "tools/xtask/src/contract/raw_source_rebuild.rs", "byte_length": 294540, - "sha256": "543c21131346381a833f9e063fd535efd0d0e192419aefa1f60e9b0f68475866", + "sha256": "3f0df95aa892eda6139f1251b3522e26bfc4a617af18386fc76c98c696a6d3f7", "hash_algorithm": "sha256_bytes_v1" }, { "role": "contract_command_authority", "path": "tools/xtask/src/contract.rs", - "byte_length": 479703, - "sha256": "72fbd457b0cfdff1e30f07bf2452a0c71c23cef93bdaefffc114defa616d6342", + "byte_length": 480209, + "sha256": "b3a7c9486c2c2cc904d3877be7e7e6a48ef1e00445f07b9884b6778dbc55e416", "hash_algorithm": "sha256_bytes_v1" }, { "role": "xtask_dispatch_and_release_preflight", "path": "tools/xtask/src/main.rs", - "byte_length": 15018, - "sha256": "9aab8db1186b776ba8dcac90cc46c29d96928b610850b084be0e3a25d3e4cb0f", + "byte_length": 16291, + "sha256": "326c64082e406e278b097ae88131534b7aad283e3135aa6f1302f967d021ed3f", "hash_algorithm": "sha256_bytes_v1" }, { "role": "release_breaking_change_authority", "path": "contracts/releases/1.0.0-alpha.1.toml", - "byte_length": 19840, - "sha256": "946d90dacc9db522825898dbb5d9d424b020a22fe53b80ec15eb67c5f1d8cd2d", + "byte_length": 20581, + "sha256": "c7765df4fc7e217fbf6b30d5b0dd2902dbe276f14b2cb2dd34c9fb89c04749c8", "hash_algorithm": "sha256_bytes_v1" }, { "role": "release_note_authority", "path": "CHANGELOG.md", - "byte_length": 28939, - "sha256": "61b9d2a9050e4123bc5324aebf6e54393b9b1efdc2145a4a2cf6c009a4345b23", + "byte_length": 29929, + "sha256": "e6b645684a8ee0f48e4212ec99eb38142b2aeff02d35edbcbf79efb9030ec855", "hash_algorithm": "sha256_bytes_v1" } ], diff --git a/crates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256 b/crates/event_store/contracts/raw_source_rebuild_v1.manifest.sha256 @@ -1 +1 @@ -b8737a9c5836517114e7df6c2194c46e3c200093e12c4e6297165d2b9dae56a1 +b44b379b91f586e94ca2c3c581f07cef0a20d2279fbd2c687916390928fa79ba diff --git a/crates/event_store/src/generated/raw_source_rebuild_manifest.rs b/crates/event_store/src/generated/raw_source_rebuild_manifest.rs @@ -1,10 +1,10 @@ // @generated by `cargo xtask contract raw-source-rebuild-manifest --write`; do not edit. #![allow(dead_code)] -pub(crate) const RAW_SOURCE_REBUILD_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.raw_source_rebuild_v1\",\n \"authority_id\": \"raw_source_rebuild_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/raw_source_rebuild_v1.manifest.schema.json\",\n \"byte_length\": 17896,\n \"sha256\": \"f9d210967e54b66f39c8bb965d97b2001a0ebc0927e7c2c14edb8e474bfda695\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.json\",\n \"byte_length\": 14216,\n \"sha256\": \"e8911e6e5710278969cbd15557a5b856b1575dfd11a655711403598370b41221\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration_inventory\": [\n {\n \"path\": \"crates/event_store/migrations/0001_event_store.down.sql\",\n \"byte_length\": 522,\n \"sha256\": \"fa84d587f657f601947eaeb9cd239c962a48f6fcdce723588476e8d22f3c1f53\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0001_event_store.up.sql\",\n \"byte_length\": 10712,\n \"sha256\": \"4c03906a1cffd418a48d40907aa9a1ca51bb41766cff7250c4dfc7c2fd6eddde\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0002_nip09.down.sql\",\n \"byte_length\": 4807,\n \"sha256\": \"c51a099d9501f1e692c13d2226296a68ed9e6bfa5e8e46b2f12c6574dbe59e31\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0002_nip09.up.sql\",\n \"byte_length\": 81614,\n \"sha256\": \"0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.down.sql\",\n \"byte_length\": 1755,\n \"sha256\": \"29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.up.sql\",\n \"byte_length\": 23683,\n \"sha256\": \"4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"runtime\": {\n \"event_store_schema_version\": 4,\n \"event_contract_registry_version\": 7,\n \"transaction_mode\": \"begin_immediate_v1\",\n \"projection_cursor_count_limit\": 4096,\n \"projection_cursor_rejection_probe_limit\": 4097,\n \"caller_main_table_count_limit\": 4096,\n \"caller_foreign_key_row_count_limit\": 4096,\n \"caller_inbound_foreign_key_policy\": \"reject_all_rebuild_mutated_parent_dependencies_before_entropy_v1\",\n \"caller_inbound_foreign_key_parent_tables\": [\n \"event_envelopes\",\n \"event_envelope_tags\",\n \"event_envelope_head\",\n \"radroots_event_store_source_generation\",\n \"radroots_event_store_source_rebuild_commit_barrier\",\n \"radroots_event_store_source_rebuild_marker\",\n \"radroots_event_store_source_state\",\n \"radroots_event_store_write_lock\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_event_coordinate\",\n \"radroots_event_store_nip09_request\",\n \"radroots_event_store_nip09_event_target\",\n \"radroots_event_store_nip09_address_target\",\n \"radroots_event_store_addressable_head_state\",\n \"radroots_event_store_addressable_head_transition\",\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_image\",\n \"radroots_event_store_food_availability_search_fts\",\n \"radroots_event_store_food_availability_search_fts_config\",\n \"radroots_event_store_food_availability_search_fts_content\",\n \"radroots_event_store_food_availability_search_fts_data\",\n \"radroots_event_store_food_availability_search_fts_docsize\",\n \"radroots_event_store_food_availability_search_fts_idx\",\n \"sqlite_sequence\"\n ],\n \"cold_repair_mode\": \"canonical_file_only_single_connection_lock_domain_probe_v1\",\n \"immutable_raw_digest\": {\n \"algorithm\": \"sha256_domain_nul_typed_fields_v1\",\n \"domain_utf8\": \"radroots:event-store:immutable-raw-digest:v1\",\n \"domain_terminator\": \"nul_byte\",\n \"framing\": {\n \"section\": \"S_then_N_then_u64be_length_then_utf8_name\",\n \"row\": \"R\",\n \"signed_i64\": \"I_then_i64be\",\n \"boolean\": \"B_then_u8_0_or_1\",\n \"optional\": \"O_then_presence_u8_then_nested_value_when_present\",\n \"text\": \"T_then_u64be_length_then_utf8_bytes\",\n \"blob\": \"X_then_u64be_length_then_bytes\"\n },\n \"output_bytes\": 32,\n \"source_queries\": [\n {\n \"section\": \"event_envelopes\",\n \"sql\": \"SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, inserted_at_ms FROM event_envelopes ORDER BY seq\",\n \"fields\": [\n {\n \"name\": \"seq\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"tags_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"sig\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"inserted_at_ms\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"event_envelope_tags\",\n \"sql\": \"SELECT event.seq, tag.event_id, tag.tag_index, tag.tag_name, tag.tag_value, tag.tag_json FROM event_envelope_tags AS tag JOIN event_envelopes AS event ON event.event_id = tag.event_id ORDER BY event.seq, tag.tag_index\",\n \"fields\": [\n {\n \"name\": \"seq\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"tag_name\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_value\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"tag_json\",\n \"framing\": \"text\"\n }\n ]\n }\n ]\n },\n \"active_product_state_digest\": {\n \"algorithm\": \"sha256_domain_nul_typed_fields_v1\",\n \"domain_utf8\": \"radroots:event-store:active-product-state-digest:v1\",\n \"domain_terminator\": \"nul_byte\",\n \"framing\": {\n \"section\": \"S_then_N_then_u64be_length_then_utf8_name\",\n \"row\": \"R\",\n \"signed_i64\": \"I_then_i64be\",\n \"boolean\": \"B_then_u8_0_or_1\",\n \"optional\": \"O_then_presence_u8_then_nested_value_when_present\",\n \"text\": \"T_then_u64be_length_then_utf8_bytes\",\n \"blob\": \"X_then_u64be_length_then_bytes\"\n },\n \"output_bytes\": 32,\n \"components\": [\n \"logical_current_classifications\",\n \"raw_heads\",\n \"active_addressable_head_state\",\n \"active_nip09_facts\",\n \"current_visibility\",\n \"food_availability_rows\",\n \"food_availability_images\",\n \"logical_food_fts_rows\",\n \"stable_food_cursor_metadata\"\n ],\n \"exclusions\": [\n \"source_generation\",\n \"absolute_transition_sequence\",\n \"transition_history\",\n \"rebuild_origin\",\n \"rebuild_cause\",\n \"operational_timestamps\",\n \"generic_projection_cursors\",\n \"caller_owned_state\"\n ],\n \"component_queries\": [\n {\n \"section\": \"envelope_classification\",\n \"sql\": \"SELECT event_id, verification_status, contract_status, contract_id, event_class, projection_eligible FROM event_envelopes ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"verification_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_class\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"projection_eligible\",\n \"framing\": \"boolean\"\n }\n ]\n },\n {\n \"section\": \"tag_classification\",\n \"sql\": \"SELECT event_id, tag_index, contract_semantic, contract_value_type, relay_indexed FROM event_envelope_tags ORDER BY event_id, tag_index\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"contract_semantic\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_value_type\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"relay_indexed\",\n \"framing\": \"boolean\"\n }\n ]\n },\n {\n \"section\": \"raw_heads\",\n \"sql\": \"SELECT coordinate_type, kind, pubkey, d_tag, event_id, created_at FROM event_envelope_head ORDER BY coordinate_type, kind, pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"coordinate_type\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"event_coordinates\",\n \"sql\": \"SELECT event_id, coordinate_type, kind, pubkey, created_at, admission_status, admission_code, contract_id, raw_d_tag, nip09_matchable, nip09_d_tag FROM radroots_event_store_event_coordinate WHERE source_generation = ? ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"coordinate_type\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_code\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"raw_d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"nip09_matchable\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"nip09_d_tag\",\n \"framing\": \"optional_text\"\n }\n ]\n },\n {\n \"section\": \"nip09_requests\",\n \"sql\": \"SELECT request_event_id, request_pubkey, request_created_at FROM radroots_event_store_nip09_request WHERE source_generation = ? ORDER BY request_event_id\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"request_pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"request_created_at\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"nip09_event_targets\",\n \"sql\": \"SELECT request_event_id, target_event_id, source_tag_index, source_tag_value FROM radroots_event_store_nip09_event_target WHERE source_generation = ? ORDER BY request_event_id, target_event_id, source_tag_index\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_value\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"nip09_address_targets\",\n \"sql\": \"SELECT request_event_id, target_kind, target_pubkey, target_d_tag, inclusive_cutoff, source_tag_index, source_tag_value, source_kind_text, source_pubkey_text, source_d_tag FROM radroots_event_store_nip09_address_target WHERE source_generation = ? ORDER BY request_event_id, target_kind, target_pubkey, target_d_tag, source_tag_index\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"target_pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"inclusive_cutoff\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_value\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_kind_text\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_pubkey_text\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_d_tag\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"addressable_heads\",\n \"sql\": \"SELECT kind, pubkey, d_tag, raw_head_event_id, raw_head_created_at, admission_status, admission_code, contract_id, visibility, nip09_outcome, nip09_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff FROM radroots_event_store_addressable_head_state WHERE source_generation = ? ORDER BY kind, pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_head_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_head_created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_code\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"visibility\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"nip09_outcome\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"nip09_reason\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_cutoff\",\n \"framing\": \"optional_i64\"\n }\n ]\n },\n {\n \"section\": \"current_visibility\",\n \"sql\": \"SELECT event_id, admission_status, contract_id, event_class, raw_d_tag, is_raw_head, raw_head_event_id, suppression_outcome, suppression_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff, current_visibility FROM radroots_event_store_current_visibility_v1 WHERE source_generation = ? ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_class\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_d_tag\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"is_raw_head\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"raw_head_event_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"suppression_outcome\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"suppression_reason\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_cutoff\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"current_visibility\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_projection\",\n \"sql\": \"SELECT kind, pubkey, d_tag, event_id, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json FROM radroots_event_store_food_availability_projection WHERE source_generation = ? ORDER BY pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"title\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"summary\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"published_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"location\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_amount\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_currency\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_unit\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"quantity_amount\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"quantity_unit\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"diagnostic_codes_json\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_images\",\n \"sql\": \"SELECT pubkey, d_tag, image_index, raw_tag_json, url, width, height, blossom_sha256, qualifies, diagnostic_codes_json FROM radroots_event_store_food_availability_image WHERE source_generation = ? ORDER BY pubkey, d_tag, image_index\",\n \"fields\": [\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"image_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"raw_tag_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"url\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"width\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"height\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"blossom_sha256\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"qualifies\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"diagnostic_codes_json\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_search\",\n \"sql\": \"SELECT event_id, pubkey, d_tag, title, summary, content, location FROM radroots_event_store_food_availability_search_fts ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"title\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"summary\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"location\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_cursor\",\n \"sql\": \"SELECT feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1\",\n \"fields\": [\n {\n \"name\": \"feed_version\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"projection_version\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"scope_fingerprint\",\n \"framing\": \"blob\"\n },\n {\n \"name\": \"hook_manifest_sha256\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"projected_row_count\",\n \"framing\": \"i64\"\n }\n ]\n }\n ]\n },\n \"visibility_oracle\": \"pure_verified_raw_snapshot_direct_indexed_evidence_v1\",\n \"scoped_integrity_mode\": \"event_store_owned_tables_and_indices_v1\",\n \"scoped_integrity_tables\": [\n \"event_envelopes\",\n \"event_envelope_tags\",\n \"event_envelope_head\",\n \"radroots_event_store_source_generation\",\n \"radroots_event_store_source_rebuild_commit_barrier\",\n \"radroots_event_store_source_rebuild_marker\",\n \"radroots_event_store_source_state\",\n \"radroots_event_store_write_lock\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_event_coordinate\",\n \"radroots_event_store_nip09_request\",\n \"radroots_event_store_nip09_event_target\",\n \"radroots_event_store_nip09_address_target\",\n \"radroots_event_store_addressable_head_state\",\n \"radroots_event_store_addressable_head_transition\",\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_image\"\n ],\n \"sqlite_sequence_scope\": \"target_first_after_single_shared_sequence_scan_v1\",\n \"stages\": [\n \"after_marker_open\",\n \"after_generation_rotation\",\n \"after_core_replay\",\n \"after_visibility_audit\",\n \"after_food_reset_replay\",\n \"after_food_audit\",\n \"after_marker_close\"\n ],\n \"failpoints\": [\n \"after_marker_open\",\n \"after_generation_rotation\",\n \"after_core_replay\",\n \"after_visibility_audit\",\n \"after_food_reset_replay\",\n \"after_food_audit\",\n \"after_marker_close\"\n ],\n \"preserved_authorities\": [\n \"legacy_listing\",\n \"trade\",\n \"transport_observation\",\n \"generic_projection_cursor\",\n \"unrelated_caller_state_without_dependencies_on_rebuild_owned_tables\"\n ]\n },\n \"entry_points\": [\n {\n \"role\": \"live_rebuild\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::rebuild_from_raw_v1\"\n },\n {\n \"role\": \"cold_file_repair\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::repair_file_from_raw_v1\"\n },\n {\n \"role\": \"projection_cursor_insert_preflight\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::preflight_projection_cursor_insert_v1\"\n },\n {\n \"role\": \"serialized_rebuild_runtime\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::raw_source_rebuild::rebuild_from_raw_v1_on_pool\"\n },\n {\n \"role\": \"independent_visibility_oracle\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::visibility_oracle_v1::audit_current_visibility_from_raw_v1\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"raw_source_rebuild_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_manifest_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 10836,\n \"sha256\": \"285532dbb0894204843a832880f136ceac5ee312a3203ff951fb0551fac63ec4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"workspace_lockfile_authority\",\n \"path\": \"Cargo.lock\",\n \"byte_length\": 216965,\n \"sha256\": \"f26bf62f77e48914c89c15e689fdbc6799928e9603cab38f50c0c65a0c405edf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_flake_app_export_authority\",\n \"path\": \"flake.nix\",\n \"byte_length\": 1835,\n \"sha256\": \"0251b26040cf5338c12dc777a4deaadb8f63eb4e88bc05929dcec67db88ff2bf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_input_lock_authority\",\n \"path\": \"flake.lock\",\n \"byte_length\": 3031,\n \"sha256\": \"41b569739bfa0c488625326f4f0a874561601787951cdf7a3f171e60572fa20e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_contract_app_routing_authority\",\n \"path\": \"build/nix/apps.nix\",\n \"byte_length\": 2836,\n \"sha256\": \"41a185ac87379e24c1ede09c0f1aac820653dffc09f99cd803b145b44bed982c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_contract_test_lane_authority\",\n \"path\": \"build/nix/common.nix\",\n \"byte_length\": 11127,\n \"sha256\": \"b3340e1b4973e6a1e02899d164ca74842757f22b6b1a03f90461532fcd844df5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_toolchain_routing_authority\",\n \"path\": \"build/nix/toolchains.nix\",\n \"byte_length\": 178,\n \"sha256\": \"cd664be945e28bf6c25c7758182ff8d01e03248832dfc2c045c01b4f4aff960f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"rust_toolchain_authority\",\n \"path\": \"rust-toolchain.toml\",\n \"byte_length\": 132,\n \"sha256\": \"c33aa38292bab6513bf79ed2f69c1525b736dd738b15ca78af713b70b29265c9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_manifest_authority\",\n \"path\": \"tools/xtask/Cargo.toml\",\n \"byte_length\": 1097,\n \"sha256\": \"7e858f4f33913f986c565be2a31c41615ea0585c9e19572363ef5cae36cafdc9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_dependency_feature_authority\",\n \"path\": \"crates/event_store/Cargo.toml\",\n \"byte_length\": 1529,\n \"sha256\": \"4bddb3462a7543c9a7981ead5cf1027988fc381457432f4b04b0e9c43f6d51ca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_surface\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 24687,\n \"sha256\": \"404f3f91b1b4aed345faf23a2bfd8a59cdf475d5f411d416dd26418c71ea9a89\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 188,\n \"sha256\": \"05328d38ebb6f827f6986b384fefb834948652dfd77fc29c9633d8a1a0d5947e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/food_availability_projection_manifest.rs\",\n \"byte_length\": 21437,\n \"sha256\": \"90908da53ab9572f45f5916ccc2652736b7ea26ba6dd202a4f69af1e651b564b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/nip09_reconciliation_manifest.rs\",\n \"byte_length\": 586039,\n \"sha256\": \"406a760e9bed1e8fc89c8e7ae0976c7eff844de7427a3f473528c895439500b3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/source_maintenance_manifest.rs\",\n \"byte_length\": 18723,\n \"sha256\": \"5f988f800425cf36d4327c828b30943c2f79c1fa577ce80730dc13383a1466b1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 4133,\n \"sha256\": \"7cc60495cd26d1f3d8147b1c6b39db83a170f934f74226a617263c0c13c25ada\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_runtime_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"model_registration\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33818,\n \"sha256\": \"66d0b7b8d9966084c76d85aa7f79e9ec0d68cde464ea8b0a327404e61eadd8ff\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_model\",\n \"path\": \"crates/event_store/src/model/addressable_transition_feed_v1.rs\",\n \"byte_length\": 22314,\n \"sha256\": \"b1c6b0a68f34459f7e14bd63857596154c0aa3fd02dc6c1661d543bb681324a7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_model\",\n \"path\": \"crates/event_store/src/model/current_visibility_v1.rs\",\n \"byte_length\": 5691,\n \"sha256\": \"25ec92f45006e2f66f2e1c8b954a021334bb1595b849c4d8529f289d3f7aeb25\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_availability_projection_model\",\n \"path\": \"crates/event_store/src/model/food_availability_projection_v1.rs\",\n \"byte_length\": 17493,\n \"sha256\": \"1e5ff9c05a81fda223ed1a27ff18a1b08bcdeaec9047a13fdd577390b3e0fdb9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"ingest_reconciliation_model\",\n \"path\": \"crates/event_store/src/model/ingest_reconciliation_v1.rs\",\n \"byte_length\": 1626,\n \"sha256\": \"47bf13b3fc0f8a913a660f7d655413de0f6b90568bc4acc510aa6bd741bab47b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"rebuild_report_and_digest_models\",\n \"path\": \"crates/event_store/src/model/raw_source_rebuild_v1.rs\",\n \"byte_length\": 2804,\n \"sha256\": \"a59459b5566f4450576fc5412e3c8ac0153954b653be376ccd925b19fc647345\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"reconciliation_model\",\n \"path\": \"crates/event_store/src/model/reconciliation_v1.rs\",\n \"byte_length\": 11138,\n \"sha256\": \"8a26bc373035878ef9b41767ceea7b681896e17d88bedce118de1d622125e1d6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_module_registration\",\n \"path\": \"crates/event_store/src/nip09.rs\",\n \"byte_length\": 34,\n \"sha256\": \"fbd8a3b36d7f36e7b0d301aee0847d42c3908659f066cafcae3e247d67a75845\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"reconciliation_runtime_registration\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 194622,\n \"sha256\": \"4c14df2bd3af7bfefb002917dc7549f6ada155be3a748acb9cd6d78199ee6f76\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"serialized_raw_source_rebuild\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/raw_source_rebuild.rs\",\n \"byte_length\": 60973,\n \"sha256\": \"a8db92dfbfa420b545038502c2a04547bed41b76e283f09758faa248c597c781\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_result_vector_executor_input\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs\",\n \"byte_length\": 18446,\n \"sha256\": \"ca2a2bf54062aa6ddf2e553fd624c7217a01ad56309487ce73fa58c47c06c208\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"independent_raw_visibility_oracle\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs\",\n \"byte_length\": 36998,\n \"sha256\": \"48b60aba869d804ad7b3b120d7479c7ff45dd3758502a90b4fbce312d9848a99\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"managed_v4_validation_and_scoped_integrity\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 153682,\n \"sha256\": \"df92fc509b44e40dae5a48d03ad9bf5cc556c4319a78215460ca26b899c610a2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_capacity_rebuild_authority\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_rebuild_and_cold_repair_boundary\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 402744,\n \"sha256\": \"56a84cc05208a335cbb6bad41b024c20ed77db5000fa69e684611e196ae461f4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_storage\",\n \"path\": \"crates/event_store/src/store/addressable_transition_feed_v1.rs\",\n \"byte_length\": 40253,\n \"sha256\": \"fe23424aa1e6b39f9aba2dfa4470652b26b4990f91204a2bdfe379c03da9b610\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_storage\",\n \"path\": \"crates/event_store/src/store/current_visibility_v1.rs\",\n \"byte_length\": 15860,\n \"sha256\": \"8615086e674c30700305debcef11de5b3dbfe5aec735c0f58b4ac11caa518596\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_source_rebuild_focused_tests\",\n \"path\": \"crates/event_store/src/store/raw_source_rebuild_v1_tests.rs\",\n \"byte_length\": 103772,\n \"sha256\": \"383ca6f8aac6418d1d4460603d50746d224011c16367c2b86d8342818567ae2a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"signed_food_digest_fixture\",\n \"path\": \"crates/event_store/tests/fixtures/food_availability_projection.v1.json\",\n \"byte_length\": 103659,\n \"sha256\": \"fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_reset_and_replay\",\n \"path\": \"crates/event_store/src/store/food_availability_projection_v1.rs\",\n \"byte_length\": 50858,\n \"sha256\": \"adc8a3eb59f5bccb4c0d0ba4c5319dbf55cffb5e0c333db8235db63fd0df5f21\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extension_capabilities\",\n \"path\": \"crates/event_store/src/store/post_core_extension_capabilities.rs\",\n \"byte_length\": 1255,\n \"sha256\": \"cb434372156cb7ff31dac392d7095c2e5f44b128fae4e705516d6d000e2e2502\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extension_dispatcher\",\n \"path\": \"crates/event_store/src/store/post_core_extension_dispatcher.rs\",\n \"byte_length\": 576,\n \"sha256\": \"df62ee92e9f165502d5e533997a47f533129fd3cffab2d9b2012e2ed22405f48\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extensions_v1\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v1.rs\",\n \"byte_length\": 6935,\n \"sha256\": \"fb165704c64d982cf3be0a880c44985be6b375758451e94b2aaaf30881769f18\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extensions_v2\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v2.rs\",\n \"byte_length\": 294,\n \"sha256\": \"8dcbc503ed9ea6fb06ed9a2a83b0804d928f9590b5706de25a057ad72c0d38d2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_storage_v1\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v1.rs\",\n \"byte_length\": 16871,\n \"sha256\": \"a6dca0884762cec3c32e460d17662ced9d0335f30e79b3d5fdb3461259d3ec19\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_storage_v2\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v2.rs\",\n \"byte_length\": 632,\n \"sha256\": \"4b672770f3c34bf887e4cc949c068cb0c87396cb4af8efb6e13d39aa4e0d973a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"protocol_reconciliation_storage\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"protocol_storage_boundary\",\n \"path\": \"crates/event_store/src/store/protocol_storage_v1.rs\",\n \"byte_length\": 10975,\n \"sha256\": \"155c74d27eee5db1d6f0f844f9d319604eefbbf640f4b2371ac5d0e370816e50\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_package_readme\",\n \"path\": \"crates/event_store/README\",\n \"byte_length\": 22209,\n \"sha256\": \"9e1cf2ec9ba58c2028d78eb33e6355fc2dff3507837b6e8640941dccd5608dc7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"signed_nip09_reconciliation_fixture\",\n \"path\": \"crates/event_store/tests/fixtures/nip09_reconciliation.v1.json\",\n \"byte_length\": 10405,\n \"sha256\": \"31cd9507734ff3308436881622a626b9782b75b548d9f5e159e4125621855b9c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_food_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 194995,\n \"sha256\": \"02f8b70b3885267b09fd5241ec89bcf020d2975e1eb1c6c533656a036723395b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"immutable_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 123766,\n \"sha256\": \"f10962e0cc0fa44dc109d87b707f02be11fe6dad1113707eef820ff3c5ae97ee\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_nip09_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 850763,\n \"sha256\": \"852697eaaffcfe99391ffafd0c7c390c8eeb175377ac7e5cd5f490050b57ed58\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_source_rebuild_governance\",\n \"path\": \"tools/xtask/src/contract/raw_source_rebuild.rs\",\n \"byte_length\": 294540,\n \"sha256\": \"543c21131346381a833f9e063fd535efd0d0e192419aefa1f60e9b0f68475866\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 479703,\n \"sha256\": \"72fbd457b0cfdff1e30f07bf2452a0c71c23cef93bdaefffc114defa616d6342\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 15018,\n \"sha256\": \"9aab8db1186b776ba8dcac90cc46c29d96928b610850b084be0e3a25d3e4cb0f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"release_breaking_change_authority\",\n \"path\": \"contracts/releases/1.0.0-alpha.1.toml\",\n \"byte_length\": 19840,\n \"sha256\": \"946d90dacc9db522825898dbb5d9d424b020a22fe53b80ec15eb67c5f1d8cd2d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"release_note_authority\",\n \"path\": \"CHANGELOG.md\",\n \"byte_length\": 28939,\n \"sha256\": \"61b9d2a9050e4123bc5324aebf6e54393b9b1efdc2145a4a2cf6c009a4345b23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_PROJECTION_CURSOR_COUNT_LIMIT_V1\",\n \"RadrootsEventStoreCallerInboundForeignKeyV1\",\n \"RadrootsEventStoreActiveProductStateDigestV1\",\n \"RadrootsEventStoreImmutableRawDigestV1\",\n \"RadrootsEventStoreRawSourceRebuildDriftV1\",\n \"RadrootsEventStoreRawSourceRebuildReportV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::rebuild_from_raw_v1\",\n \"RadrootsEventStore::repair_file_from_raw_v1\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::prior_source_generation\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::new_source_generation\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::source_capacity\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::raw_high_water_seq\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::immutable_raw_digest\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::active_product_state_digest\",\n \"RadrootsEventStoreImmutableRawDigestV1::as_bytes\",\n \"RadrootsEventStoreActiveProductStateDigestV1::as_bytes\",\n \"RadrootsEventStoreRawSourceRebuildDriftV1::code\"\n ],\n \"error_variants\": [\n \"ProjectionCursorCapacityExceeded\",\n \"RawSourceRepairDatabaseIdentityMismatch\",\n \"RawSourceRepairCanonicalPathLockDomainMismatch\",\n \"RawSourceRepairMainDatabaseCanonicalizationFailed\",\n \"RawSourceRebuildCallerForeignKeyCapacityExceeded\",\n \"RawSourceRebuildCallerInboundForeignKeyUnsupported\",\n \"RawSourceRebuildCallerTableCapacityExceeded\",\n \"RawSourceRebuildStateDrift\",\n \"RawSourceRebuildTransactionRollbackFailed\"\n ],\n \"drift_kinds\": [\n {\n \"variant\": \"ManagedSchemaAuthority\",\n \"code\": \"managed_schema_authority\"\n },\n {\n \"variant\": \"ImmutableRawAuthority\",\n \"code\": \"immutable_raw_authority\"\n },\n {\n \"variant\": \"SourceGenerationLineage\",\n \"code\": \"source_generation_lineage\"\n },\n {\n \"variant\": \"AddressableTransitionAuthority\",\n \"code\": \"addressable_transition_authority\"\n },\n {\n \"variant\": \"DerivedProductStateAuthority\",\n \"code\": \"derived_product_state_authority\"\n },\n {\n \"variant\": \"RebuildPostcondition\",\n \"code\": \"rebuild_postcondition\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/raw_source_rebuild.v1.json\",\n \"byte_length\": 26833,\n \"sha256\": \"c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.raw_source_rebuild_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/raw_source_rebuild_v1_result_vector.rs\",\n \"executor_test\": \"raw_source_rebuild_v1_result_vector\",\n \"executor_byte_length\": 25542,\n \"executor_sha256\": \"51647259efdd0d99689ef1db0defb139c8d1f60f2ead69b793ddb2733a28e832\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const RAW_SOURCE_REBUILD_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.raw_source_rebuild_v1\",\n \"authority_id\": \"raw_source_rebuild_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/raw_source_rebuild_v1.manifest.schema.json\",\n \"byte_length\": 17896,\n \"sha256\": \"f9d210967e54b66f39c8bb965d97b2001a0ebc0927e7c2c14edb8e474bfda695\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.json\",\n \"byte_length\": 14216,\n \"sha256\": \"e8911e6e5710278969cbd15557a5b856b1575dfd11a655711403598370b41221\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration_inventory\": [\n {\n \"path\": \"crates/event_store/migrations/0001_event_store.down.sql\",\n \"byte_length\": 522,\n \"sha256\": \"fa84d587f657f601947eaeb9cd239c962a48f6fcdce723588476e8d22f3c1f53\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0001_event_store.up.sql\",\n \"byte_length\": 10712,\n \"sha256\": \"4c03906a1cffd418a48d40907aa9a1ca51bb41766cff7250c4dfc7c2fd6eddde\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0002_nip09.down.sql\",\n \"byte_length\": 4807,\n \"sha256\": \"c51a099d9501f1e692c13d2226296a68ed9e6bfa5e8e46b2f12c6574dbe59e31\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0002_nip09.up.sql\",\n \"byte_length\": 81614,\n \"sha256\": \"0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.down.sql\",\n \"byte_length\": 1755,\n \"sha256\": \"29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.up.sql\",\n \"byte_length\": 23683,\n \"sha256\": \"4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"runtime\": {\n \"event_store_schema_version\": 4,\n \"event_contract_registry_version\": 7,\n \"transaction_mode\": \"begin_immediate_v1\",\n \"projection_cursor_count_limit\": 4096,\n \"projection_cursor_rejection_probe_limit\": 4097,\n \"caller_main_table_count_limit\": 4096,\n \"caller_foreign_key_row_count_limit\": 4096,\n \"caller_inbound_foreign_key_policy\": \"reject_all_rebuild_mutated_parent_dependencies_before_entropy_v1\",\n \"caller_inbound_foreign_key_parent_tables\": [\n \"event_envelopes\",\n \"event_envelope_tags\",\n \"event_envelope_head\",\n \"radroots_event_store_source_generation\",\n \"radroots_event_store_source_rebuild_commit_barrier\",\n \"radroots_event_store_source_rebuild_marker\",\n \"radroots_event_store_source_state\",\n \"radroots_event_store_write_lock\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_event_coordinate\",\n \"radroots_event_store_nip09_request\",\n \"radroots_event_store_nip09_event_target\",\n \"radroots_event_store_nip09_address_target\",\n \"radroots_event_store_addressable_head_state\",\n \"radroots_event_store_addressable_head_transition\",\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_image\",\n \"radroots_event_store_food_availability_search_fts\",\n \"radroots_event_store_food_availability_search_fts_config\",\n \"radroots_event_store_food_availability_search_fts_content\",\n \"radroots_event_store_food_availability_search_fts_data\",\n \"radroots_event_store_food_availability_search_fts_docsize\",\n \"radroots_event_store_food_availability_search_fts_idx\",\n \"sqlite_sequence\"\n ],\n \"cold_repair_mode\": \"canonical_file_only_single_connection_lock_domain_probe_v1\",\n \"immutable_raw_digest\": {\n \"algorithm\": \"sha256_domain_nul_typed_fields_v1\",\n \"domain_utf8\": \"radroots:event-store:immutable-raw-digest:v1\",\n \"domain_terminator\": \"nul_byte\",\n \"framing\": {\n \"section\": \"S_then_N_then_u64be_length_then_utf8_name\",\n \"row\": \"R\",\n \"signed_i64\": \"I_then_i64be\",\n \"boolean\": \"B_then_u8_0_or_1\",\n \"optional\": \"O_then_presence_u8_then_nested_value_when_present\",\n \"text\": \"T_then_u64be_length_then_utf8_bytes\",\n \"blob\": \"X_then_u64be_length_then_bytes\"\n },\n \"output_bytes\": 32,\n \"source_queries\": [\n {\n \"section\": \"event_envelopes\",\n \"sql\": \"SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, inserted_at_ms FROM event_envelopes ORDER BY seq\",\n \"fields\": [\n {\n \"name\": \"seq\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"tags_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"sig\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"inserted_at_ms\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"event_envelope_tags\",\n \"sql\": \"SELECT event.seq, tag.event_id, tag.tag_index, tag.tag_name, tag.tag_value, tag.tag_json FROM event_envelope_tags AS tag JOIN event_envelopes AS event ON event.event_id = tag.event_id ORDER BY event.seq, tag.tag_index\",\n \"fields\": [\n {\n \"name\": \"seq\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"tag_name\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_value\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"tag_json\",\n \"framing\": \"text\"\n }\n ]\n }\n ]\n },\n \"active_product_state_digest\": {\n \"algorithm\": \"sha256_domain_nul_typed_fields_v1\",\n \"domain_utf8\": \"radroots:event-store:active-product-state-digest:v1\",\n \"domain_terminator\": \"nul_byte\",\n \"framing\": {\n \"section\": \"S_then_N_then_u64be_length_then_utf8_name\",\n \"row\": \"R\",\n \"signed_i64\": \"I_then_i64be\",\n \"boolean\": \"B_then_u8_0_or_1\",\n \"optional\": \"O_then_presence_u8_then_nested_value_when_present\",\n \"text\": \"T_then_u64be_length_then_utf8_bytes\",\n \"blob\": \"X_then_u64be_length_then_bytes\"\n },\n \"output_bytes\": 32,\n \"components\": [\n \"logical_current_classifications\",\n \"raw_heads\",\n \"active_addressable_head_state\",\n \"active_nip09_facts\",\n \"current_visibility\",\n \"food_availability_rows\",\n \"food_availability_images\",\n \"logical_food_fts_rows\",\n \"stable_food_cursor_metadata\"\n ],\n \"exclusions\": [\n \"source_generation\",\n \"absolute_transition_sequence\",\n \"transition_history\",\n \"rebuild_origin\",\n \"rebuild_cause\",\n \"operational_timestamps\",\n \"generic_projection_cursors\",\n \"caller_owned_state\"\n ],\n \"component_queries\": [\n {\n \"section\": \"envelope_classification\",\n \"sql\": \"SELECT event_id, verification_status, contract_status, contract_id, event_class, projection_eligible FROM event_envelopes ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"verification_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_class\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"projection_eligible\",\n \"framing\": \"boolean\"\n }\n ]\n },\n {\n \"section\": \"tag_classification\",\n \"sql\": \"SELECT event_id, tag_index, contract_semantic, contract_value_type, relay_indexed FROM event_envelope_tags ORDER BY event_id, tag_index\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"contract_semantic\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_value_type\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"relay_indexed\",\n \"framing\": \"boolean\"\n }\n ]\n },\n {\n \"section\": \"raw_heads\",\n \"sql\": \"SELECT coordinate_type, kind, pubkey, d_tag, event_id, created_at FROM event_envelope_head ORDER BY coordinate_type, kind, pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"coordinate_type\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"event_coordinates\",\n \"sql\": \"SELECT event_id, coordinate_type, kind, pubkey, created_at, admission_status, admission_code, contract_id, raw_d_tag, nip09_matchable, nip09_d_tag FROM radroots_event_store_event_coordinate WHERE source_generation = ? ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"coordinate_type\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_code\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"raw_d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"nip09_matchable\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"nip09_d_tag\",\n \"framing\": \"optional_text\"\n }\n ]\n },\n {\n \"section\": \"nip09_requests\",\n \"sql\": \"SELECT request_event_id, request_pubkey, request_created_at FROM radroots_event_store_nip09_request WHERE source_generation = ? ORDER BY request_event_id\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"request_pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"request_created_at\",\n \"framing\": \"i64\"\n }\n ]\n },\n {\n \"section\": \"nip09_event_targets\",\n \"sql\": \"SELECT request_event_id, target_event_id, source_tag_index, source_tag_value FROM radroots_event_store_nip09_event_target WHERE source_generation = ? ORDER BY request_event_id, target_event_id, source_tag_index\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_value\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"nip09_address_targets\",\n \"sql\": \"SELECT request_event_id, target_kind, target_pubkey, target_d_tag, inclusive_cutoff, source_tag_index, source_tag_value, source_kind_text, source_pubkey_text, source_d_tag FROM radroots_event_store_nip09_address_target WHERE source_generation = ? ORDER BY request_event_id, target_kind, target_pubkey, target_d_tag, source_tag_index\",\n \"fields\": [\n {\n \"name\": \"request_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"target_pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"target_d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"inclusive_cutoff\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"source_tag_value\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_kind_text\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_pubkey_text\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"source_d_tag\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"addressable_heads\",\n \"sql\": \"SELECT kind, pubkey, d_tag, raw_head_event_id, raw_head_created_at, admission_status, admission_code, contract_id, visibility, nip09_outcome, nip09_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff FROM radroots_event_store_addressable_head_state WHERE source_generation = ? ORDER BY kind, pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_head_event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_head_created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_code\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"visibility\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"nip09_outcome\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"nip09_reason\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_cutoff\",\n \"framing\": \"optional_i64\"\n }\n ]\n },\n {\n \"section\": \"current_visibility\",\n \"sql\": \"SELECT event_id, admission_status, contract_id, event_class, raw_d_tag, is_raw_head, raw_head_event_id, suppression_outcome, suppression_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff, current_visibility FROM radroots_event_store_current_visibility_v1 WHERE source_generation = ? ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"admission_status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_class\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"raw_d_tag\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"is_raw_head\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"raw_head_event_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"suppression_outcome\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"suppression_reason\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"event_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_request_id\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"address_reference_cutoff\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"current_visibility\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_projection\",\n \"sql\": \"SELECT kind, pubkey, d_tag, event_id, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json FROM radroots_event_store_food_availability_projection WHERE source_generation = ? ORDER BY pubkey, d_tag\",\n \"fields\": [\n {\n \"name\": \"kind\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"created_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"contract_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"title\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"summary\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"published_at\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"location\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_amount\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_currency\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"price_unit\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"quantity_amount\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"quantity_unit\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"status\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"diagnostic_codes_json\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_images\",\n \"sql\": \"SELECT pubkey, d_tag, image_index, raw_tag_json, url, width, height, blossom_sha256, qualifies, diagnostic_codes_json FROM radroots_event_store_food_availability_image WHERE source_generation = ? ORDER BY pubkey, d_tag, image_index\",\n \"fields\": [\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"image_index\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"raw_tag_json\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"url\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"width\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"height\",\n \"framing\": \"optional_i64\"\n },\n {\n \"name\": \"blossom_sha256\",\n \"framing\": \"optional_text\"\n },\n {\n \"name\": \"qualifies\",\n \"framing\": \"boolean\"\n },\n {\n \"name\": \"diagnostic_codes_json\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_search\",\n \"sql\": \"SELECT event_id, pubkey, d_tag, title, summary, content, location FROM radroots_event_store_food_availability_search_fts ORDER BY event_id\",\n \"fields\": [\n {\n \"name\": \"event_id\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"pubkey\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"d_tag\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"title\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"summary\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"content\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"location\",\n \"framing\": \"text\"\n }\n ]\n },\n {\n \"section\": \"food_cursor\",\n \"sql\": \"SELECT feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1\",\n \"fields\": [\n {\n \"name\": \"feed_version\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"projection_version\",\n \"framing\": \"i64\"\n },\n {\n \"name\": \"scope_fingerprint\",\n \"framing\": \"blob\"\n },\n {\n \"name\": \"hook_manifest_sha256\",\n \"framing\": \"text\"\n },\n {\n \"name\": \"projected_row_count\",\n \"framing\": \"i64\"\n }\n ]\n }\n ]\n },\n \"visibility_oracle\": \"pure_verified_raw_snapshot_direct_indexed_evidence_v1\",\n \"scoped_integrity_mode\": \"event_store_owned_tables_and_indices_v1\",\n \"scoped_integrity_tables\": [\n \"event_envelopes\",\n \"event_envelope_tags\",\n \"event_envelope_head\",\n \"radroots_event_store_source_generation\",\n \"radroots_event_store_source_rebuild_commit_barrier\",\n \"radroots_event_store_source_rebuild_marker\",\n \"radroots_event_store_source_state\",\n \"radroots_event_store_write_lock\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_event_coordinate\",\n \"radroots_event_store_nip09_request\",\n \"radroots_event_store_nip09_event_target\",\n \"radroots_event_store_nip09_address_target\",\n \"radroots_event_store_addressable_head_state\",\n \"radroots_event_store_addressable_head_transition\",\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_image\"\n ],\n \"sqlite_sequence_scope\": \"target_first_after_single_shared_sequence_scan_v1\",\n \"stages\": [\n \"after_marker_open\",\n \"after_generation_rotation\",\n \"after_core_replay\",\n \"after_visibility_audit\",\n \"after_food_reset_replay\",\n \"after_food_audit\",\n \"after_marker_close\"\n ],\n \"failpoints\": [\n \"after_marker_open\",\n \"after_generation_rotation\",\n \"after_core_replay\",\n \"after_visibility_audit\",\n \"after_food_reset_replay\",\n \"after_food_audit\",\n \"after_marker_close\"\n ],\n \"preserved_authorities\": [\n \"legacy_listing\",\n \"trade\",\n \"transport_observation\",\n \"generic_projection_cursor\",\n \"unrelated_caller_state_without_dependencies_on_rebuild_owned_tables\"\n ]\n },\n \"entry_points\": [\n {\n \"role\": \"live_rebuild\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::rebuild_from_raw_v1\"\n },\n {\n \"role\": \"cold_file_repair\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::repair_file_from_raw_v1\"\n },\n {\n \"role\": \"projection_cursor_insert_preflight\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::preflight_projection_cursor_insert_v1\"\n },\n {\n \"role\": \"serialized_rebuild_runtime\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::raw_source_rebuild::rebuild_from_raw_v1_on_pool\"\n },\n {\n \"role\": \"independent_visibility_oracle\",\n \"rust_path\": \"radroots_event_store::nip09::reconciliation_v1::visibility_oracle_v1::audit_current_visibility_from_raw_v1\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"raw_source_rebuild_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_manifest_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 10836,\n \"sha256\": \"285532dbb0894204843a832880f136ceac5ee312a3203ff951fb0551fac63ec4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"workspace_lockfile_authority\",\n \"path\": \"Cargo.lock\",\n \"byte_length\": 216965,\n \"sha256\": \"f26bf62f77e48914c89c15e689fdbc6799928e9603cab38f50c0c65a0c405edf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_flake_app_export_authority\",\n \"path\": \"flake.nix\",\n \"byte_length\": 1835,\n \"sha256\": \"0251b26040cf5338c12dc777a4deaadb8f63eb4e88bc05929dcec67db88ff2bf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_input_lock_authority\",\n \"path\": \"flake.lock\",\n \"byte_length\": 3031,\n \"sha256\": \"41b569739bfa0c488625326f4f0a874561601787951cdf7a3f171e60572fa20e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_contract_app_routing_authority\",\n \"path\": \"build/nix/apps.nix\",\n \"byte_length\": 2836,\n \"sha256\": \"41a185ac87379e24c1ede09c0f1aac820653dffc09f99cd803b145b44bed982c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_contract_test_lane_authority\",\n \"path\": \"build/nix/common.nix\",\n \"byte_length\": 11149,\n \"sha256\": \"738003cb1703baaf73a97c010c84731a42230782661c79c6a152fbb9e6fa9f6e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nix_toolchain_routing_authority\",\n \"path\": \"build/nix/toolchains.nix\",\n \"byte_length\": 178,\n \"sha256\": \"cd664be945e28bf6c25c7758182ff8d01e03248832dfc2c045c01b4f4aff960f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"rust_toolchain_authority\",\n \"path\": \"rust-toolchain.toml\",\n \"byte_length\": 132,\n \"sha256\": \"c33aa38292bab6513bf79ed2f69c1525b736dd738b15ca78af713b70b29265c9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_manifest_authority\",\n \"path\": \"tools/xtask/Cargo.toml\",\n \"byte_length\": 1097,\n \"sha256\": \"7e858f4f33913f986c565be2a31c41615ea0585c9e19572363ef5cae36cafdc9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_dependency_feature_authority\",\n \"path\": \"crates/event_store/Cargo.toml\",\n \"byte_length\": 1529,\n \"sha256\": \"4bddb3462a7543c9a7981ead5cf1027988fc381457432f4b04b0e9c43f6d51ca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_surface\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 24687,\n \"sha256\": \"404f3f91b1b4aed345faf23a2bfd8a59cdf475d5f411d416dd26418c71ea9a89\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 188,\n \"sha256\": \"05328d38ebb6f827f6986b384fefb834948652dfd77fc29c9633d8a1a0d5947e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/food_availability_projection_manifest.rs\",\n \"byte_length\": 21437,\n \"sha256\": \"90908da53ab9572f45f5916ccc2652736b7ea26ba6dd202a4f69af1e651b564b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/nip09_reconciliation_manifest.rs\",\n \"byte_length\": 586039,\n \"sha256\": \"406a760e9bed1e8fc89c8e7ae0976c7eff844de7427a3f473528c895439500b3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_generated_descriptor_input\",\n \"path\": \"crates/event_store/src/generated/source_maintenance_manifest.rs\",\n \"byte_length\": 18723,\n \"sha256\": \"5f988f800425cf36d4327c828b30943c2f79c1fa577ce80730dc13383a1466b1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 4133,\n \"sha256\": \"7cc60495cd26d1f3d8147b1c6b39db83a170f934f74226a617263c0c13c25ada\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_runtime_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"model_registration\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33818,\n \"sha256\": \"66d0b7b8d9966084c76d85aa7f79e9ec0d68cde464ea8b0a327404e61eadd8ff\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_model\",\n \"path\": \"crates/event_store/src/model/addressable_transition_feed_v1.rs\",\n \"byte_length\": 22314,\n \"sha256\": \"b1c6b0a68f34459f7e14bd63857596154c0aa3fd02dc6c1661d543bb681324a7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_model\",\n \"path\": \"crates/event_store/src/model/current_visibility_v1.rs\",\n \"byte_length\": 5691,\n \"sha256\": \"25ec92f45006e2f66f2e1c8b954a021334bb1595b849c4d8529f289d3f7aeb25\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_availability_projection_model\",\n \"path\": \"crates/event_store/src/model/food_availability_projection_v1.rs\",\n \"byte_length\": 17493,\n \"sha256\": \"1e5ff9c05a81fda223ed1a27ff18a1b08bcdeaec9047a13fdd577390b3e0fdb9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"ingest_reconciliation_model\",\n \"path\": \"crates/event_store/src/model/ingest_reconciliation_v1.rs\",\n \"byte_length\": 1626,\n \"sha256\": \"47bf13b3fc0f8a913a660f7d655413de0f6b90568bc4acc510aa6bd741bab47b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"rebuild_report_and_digest_models\",\n \"path\": \"crates/event_store/src/model/raw_source_rebuild_v1.rs\",\n \"byte_length\": 2804,\n \"sha256\": \"a59459b5566f4450576fc5412e3c8ac0153954b653be376ccd925b19fc647345\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"reconciliation_model\",\n \"path\": \"crates/event_store/src/model/reconciliation_v1.rs\",\n \"byte_length\": 11138,\n \"sha256\": \"8a26bc373035878ef9b41767ceea7b681896e17d88bedce118de1d622125e1d6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_module_registration\",\n \"path\": \"crates/event_store/src/nip09.rs\",\n \"byte_length\": 34,\n \"sha256\": \"fbd8a3b36d7f36e7b0d301aee0847d42c3908659f066cafcae3e247d67a75845\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"reconciliation_runtime_registration\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 194622,\n \"sha256\": \"4c14df2bd3af7bfefb002917dc7549f6ada155be3a748acb9cd6d78199ee6f76\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"serialized_raw_source_rebuild\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/raw_source_rebuild.rs\",\n \"byte_length\": 60973,\n \"sha256\": \"a8db92dfbfa420b545038502c2a04547bed41b76e283f09758faa248c597c781\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_result_vector_executor_input\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs\",\n \"byte_length\": 18446,\n \"sha256\": \"ca2a2bf54062aa6ddf2e553fd624c7217a01ad56309487ce73fa58c47c06c208\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"independent_raw_visibility_oracle\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1/visibility_oracle_v1.rs\",\n \"byte_length\": 36998,\n \"sha256\": \"48b60aba869d804ad7b3b120d7479c7ff45dd3758502a90b4fbce312d9848a99\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"managed_v4_validation_and_scoped_integrity\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 153682,\n \"sha256\": \"df92fc509b44e40dae5a48d03ad9bf5cc556c4319a78215460ca26b899c610a2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_capacity_rebuild_authority\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_rebuild_and_cold_repair_boundary\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 402744,\n \"sha256\": \"56a84cc05208a335cbb6bad41b024c20ed77db5000fa69e684611e196ae461f4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_storage\",\n \"path\": \"crates/event_store/src/store/addressable_transition_feed_v1.rs\",\n \"byte_length\": 40253,\n \"sha256\": \"fe23424aa1e6b39f9aba2dfa4470652b26b4990f91204a2bdfe379c03da9b610\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_storage\",\n \"path\": \"crates/event_store/src/store/current_visibility_v1.rs\",\n \"byte_length\": 15860,\n \"sha256\": \"8615086e674c30700305debcef11de5b3dbfe5aec735c0f58b4ac11caa518596\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_source_rebuild_focused_tests\",\n \"path\": \"crates/event_store/src/store/raw_source_rebuild_v1_tests.rs\",\n \"byte_length\": 103772,\n \"sha256\": \"383ca6f8aac6418d1d4460603d50746d224011c16367c2b86d8342818567ae2a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"signed_food_digest_fixture\",\n \"path\": \"crates/event_store/tests/fixtures/food_availability_projection.v1.json\",\n \"byte_length\": 103659,\n \"sha256\": \"fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_reset_and_replay\",\n \"path\": \"crates/event_store/src/store/food_availability_projection_v1.rs\",\n \"byte_length\": 50858,\n \"sha256\": \"adc8a3eb59f5bccb4c0d0ba4c5319dbf55cffb5e0c333db8235db63fd0df5f21\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extension_capabilities\",\n \"path\": \"crates/event_store/src/store/post_core_extension_capabilities.rs\",\n \"byte_length\": 1255,\n \"sha256\": \"cb434372156cb7ff31dac392d7095c2e5f44b128fae4e705516d6d000e2e2502\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extension_dispatcher\",\n \"path\": \"crates/event_store/src/store/post_core_extension_dispatcher.rs\",\n \"byte_length\": 576,\n \"sha256\": \"df62ee92e9f165502d5e533997a47f533129fd3cffab2d9b2012e2ed22405f48\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extensions_v1\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v1.rs\",\n \"byte_length\": 6935,\n \"sha256\": \"fb165704c64d982cf3be0a880c44985be6b375758451e94b2aaaf30881769f18\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_extensions_v2\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v2.rs\",\n \"byte_length\": 294,\n \"sha256\": \"8dcbc503ed9ea6fb06ed9a2a83b0804d928f9590b5706de25a057ad72c0d38d2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_storage_v1\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v1.rs\",\n \"byte_length\": 16871,\n \"sha256\": \"a6dca0884762cec3c32e460d17662ced9d0335f30e79b3d5fdb3461259d3ec19\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_storage_v2\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v2.rs\",\n \"byte_length\": 632,\n \"sha256\": \"4b672770f3c34bf887e4cc949c068cb0c87396cb4af8efb6e13d39aa4e0d973a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"protocol_reconciliation_storage\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"protocol_storage_boundary\",\n \"path\": \"crates/event_store/src/store/protocol_storage_v1.rs\",\n \"byte_length\": 10975,\n \"sha256\": \"155c74d27eee5db1d6f0f844f9d319604eefbbf640f4b2371ac5d0e370816e50\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_package_readme\",\n \"path\": \"crates/event_store/README\",\n \"byte_length\": 22209,\n \"sha256\": \"9e1cf2ec9ba58c2028d78eb33e6355fc2dff3507837b6e8640941dccd5608dc7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"signed_nip09_reconciliation_fixture\",\n \"path\": \"crates/event_store/tests/fixtures/nip09_reconciliation.v1.json\",\n \"byte_length\": 10405,\n \"sha256\": \"31cd9507734ff3308436881622a626b9782b75b548d9f5e159e4125621855b9c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_food_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 194995,\n \"sha256\": \"02f8b70b3885267b09fd5241ec89bcf020d2975e1eb1c6c533656a036723395b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"immutable_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 123766,\n \"sha256\": \"f10962e0cc0fa44dc109d87b707f02be11fe6dad1113707eef820ff3c5ae97ee\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_nip09_predecessor_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 850763,\n \"sha256\": \"852697eaaffcfe99391ffafd0c7c390c8eeb175377ac7e5cd5f490050b57ed58\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_source_rebuild_governance\",\n \"path\": \"tools/xtask/src/contract/raw_source_rebuild.rs\",\n \"byte_length\": 294540,\n \"sha256\": \"3f0df95aa892eda6139f1251b3522e26bfc4a617af18386fc76c98c696a6d3f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 480209,\n \"sha256\": \"b3a7c9486c2c2cc904d3877be7e7e6a48ef1e00445f07b9884b6778dbc55e416\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 16291,\n \"sha256\": \"326c64082e406e278b097ae88131534b7aad283e3135aa6f1302f967d021ed3f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"release_breaking_change_authority\",\n \"path\": \"contracts/releases/1.0.0-alpha.1.toml\",\n \"byte_length\": 20581,\n \"sha256\": \"c7765df4fc7e217fbf6b30d5b0dd2902dbe276f14b2cb2dd34c9fb89c04749c8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"release_note_authority\",\n \"path\": \"CHANGELOG.md\",\n \"byte_length\": 29929,\n \"sha256\": \"e6b645684a8ee0f48e4212ec99eb38142b2aeff02d35edbcbf79efb9030ec855\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_PROJECTION_CURSOR_COUNT_LIMIT_V1\",\n \"RadrootsEventStoreCallerInboundForeignKeyV1\",\n \"RadrootsEventStoreActiveProductStateDigestV1\",\n \"RadrootsEventStoreImmutableRawDigestV1\",\n \"RadrootsEventStoreRawSourceRebuildDriftV1\",\n \"RadrootsEventStoreRawSourceRebuildReportV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::rebuild_from_raw_v1\",\n \"RadrootsEventStore::repair_file_from_raw_v1\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::prior_source_generation\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::new_source_generation\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::source_capacity\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::raw_high_water_seq\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::immutable_raw_digest\",\n \"RadrootsEventStoreRawSourceRebuildReportV1::active_product_state_digest\",\n \"RadrootsEventStoreImmutableRawDigestV1::as_bytes\",\n \"RadrootsEventStoreActiveProductStateDigestV1::as_bytes\",\n \"RadrootsEventStoreRawSourceRebuildDriftV1::code\"\n ],\n \"error_variants\": [\n \"ProjectionCursorCapacityExceeded\",\n \"RawSourceRepairDatabaseIdentityMismatch\",\n \"RawSourceRepairCanonicalPathLockDomainMismatch\",\n \"RawSourceRepairMainDatabaseCanonicalizationFailed\",\n \"RawSourceRebuildCallerForeignKeyCapacityExceeded\",\n \"RawSourceRebuildCallerInboundForeignKeyUnsupported\",\n \"RawSourceRebuildCallerTableCapacityExceeded\",\n \"RawSourceRebuildStateDrift\",\n \"RawSourceRebuildTransactionRollbackFailed\"\n ],\n \"drift_kinds\": [\n {\n \"variant\": \"ManagedSchemaAuthority\",\n \"code\": \"managed_schema_authority\"\n },\n {\n \"variant\": \"ImmutableRawAuthority\",\n \"code\": \"immutable_raw_authority\"\n },\n {\n \"variant\": \"SourceGenerationLineage\",\n \"code\": \"source_generation_lineage\"\n },\n {\n \"variant\": \"AddressableTransitionAuthority\",\n \"code\": \"addressable_transition_authority\"\n },\n {\n \"variant\": \"DerivedProductStateAuthority\",\n \"code\": \"derived_product_state_authority\"\n },\n {\n \"variant\": \"RebuildPostcondition\",\n \"code\": \"rebuild_postcondition\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/raw_source_rebuild.v1.json\",\n \"byte_length\": 26833,\n \"sha256\": \"c37a2bf3714f53ab04fae8c5c9dbe2ad4b3f5310efa51f46bd8b116660f1fe15\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.raw_source_rebuild_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/raw_source_rebuild_v1_result_vector.rs\",\n \"executor_test\": \"raw_source_rebuild_v1_result_vector\",\n \"executor_byte_length\": 25542,\n \"executor_sha256\": \"51647259efdd0d99689ef1db0defb139c8d1f60f2ead69b793ddb2733a28e832\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; pub(crate) const RAW_SOURCE_REBUILD_MANIFEST_BYTE_LENGTH: usize = 45449; pub(crate) const RAW_SOURCE_REBUILD_MANIFEST_SHA256: &str = - "b8737a9c5836517114e7df6c2194c46e3c200093e12c4e6297165d2b9dae56a1"; + "b44b379b91f586e94ca2c3c581f07cef0a20d2279fbd2c687916390928fa79ba"; pub(crate) const RAW_SOURCE_REBUILD_CONTRACT_ID: &str = "radroots_event_store.raw_source_rebuild_v1"; pub(crate) const RAW_SOURCE_REBUILD_AUTHORITY_ID: &str = "raw_source_rebuild_v1"; diff --git a/crates/outbox/Cargo.toml b/crates/outbox/Cargo.toml @@ -14,7 +14,7 @@ readme = "README" [features] default = ["sqlite", "runtime-tokio"] sqlite = ["dep:sqlx", "sqlx/sqlite-bundled"] -runtime-tokio = ["sqlx/runtime-tokio"] +runtime-tokio = ["dep:tokio", "sqlx/runtime-tokio"] [dependencies] radroots_event = { workspace = true, default-features = false, features = [ @@ -32,6 +32,7 @@ serde_json = { workspace = true, features = ["std"] } sha2 = { workspace = true } sqlx = { workspace = true, optional = true, features = ["derive"] } thiserror = { workspace = true } +tokio = { workspace = true, optional = true, features = ["time"] } [dev-dependencies] radroots_nostr = { workspace = true, default-features = false, features = [ @@ -39,7 +40,7 @@ radroots_nostr = { workspace = true, default-features = false, features = [ "events", ] } tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "sync"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/outbox/README b/crates/outbox/README @@ -13,3 +13,25 @@ unambiguous for every queued event. multi-connection in-memory pools in every supported URL form, and configures every file-pool connection with foreign-key enforcement and the required busy timeout before migrations or writes. + +Schema lifecycle is governed by an ordered, checksummed migration registry. +The original `0001_outbox` up and down files are immutable contract inputs. An +existing unledgered database is adopted only when its complete managed catalog +matches the authenticated five-table, eight-index baseline fingerprint. The +managed ledger pins migration names, source digests, and catalog fingerprints; +unknown or drifted `outbox_*` objects, counterfeit ledgers, history gaps, and +newer schema versions fail before governed schema or history mutation. Catalog +and history reads are bounded, while unrelated caller tables in a shared SQLite +database are outside the outbox fingerprint and remain untouched. Registry +entries add and remove disjoint governed object sets; altering or replacing an +existing governed object requires an explicit migration-contract revision. + +Every open validates UTF-8 before journal or schema mutation, enables and +verifies foreign keys, verifies WAL for file databases, takes a serialized +writer transaction for migration/adoption, and runs SQLite integrity plus +outbox-scoped foreign-key checks before exposing the store. Use +`schema_status` for inspection and `migrate_to_current_schema` for an explicit +recheck. `rollback_to_schema_version_and_close` consumes the store, closes all +pool clones, and requires an explicit target at or above the public schema +floor. Full schema destruction is intentionally confined to the separately +named migration-test helper. diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -0,0 +1,298 @@ +{ + "authority_id": "versioned_outbox_migration_authority_v1", + "contract_id": "radroots_outbox.migration_authority.v1", + "manifest_schema": { + "byte_length": 8336, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/contracts/migration_authority_v1.manifest.schema.json", + "sha256": "2af281adfdc9b6d5dc16486db5a56e6e737dbfd9645adb486b28051e79c02f59" + }, + "migrations": [ + { + "catalog": { + "indexes": [ + "outbox_delivery_attempt_target_idx", + "outbox_delivery_plan_event_idx", + "outbox_delivery_target_ready_idx", + "outbox_event_event_id_idx", + "outbox_event_ready_idx", + "outbox_operation_idempotency_idx", + "outbox_operation_status_idx", + "outbox_operation_trade_mutation_idx" + ], + "objects": [ + "outbox_delivery_attempt", + "outbox_delivery_attempt_target_idx", + "outbox_delivery_plan", + "outbox_delivery_plan_event_idx", + "outbox_delivery_target", + "outbox_delivery_target_ready_idx", + "outbox_event", + "outbox_event_event_id_idx", + "outbox_event_ready_idx", + "outbox_operation_idempotency_idx", + "outbox_operation_status_idx", + "outbox_operation_trade_mutation_idx", + "outbox_operations" + ], + "tables": [ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations" + ] + }, + "down": { + "byte_length": 159, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/migrations/0001_outbox.down.sql", + "sha256": "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61" + }, + "name": "outbox", + "schema_sha256": "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293", + "up": { + "byte_length": 5470, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/migrations/0001_outbox.up.sql", + "sha256": "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa" + }, + "version": 1 + } + ], + "public_api": { + "added_symbols": [ + "RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT", + "RADROOTS_OUTBOX_SCHEMA_VERSION_MIN", + "RadrootsOutboxSchemaStatus", + "inspect_outbox_schema_status" + ], + "error_enum_non_exhaustive": true, + "error_variants": [ + "EmbeddedMigrationChecksumMismatch", + "EmbeddedMigrationLengthMismatch", + "ForeignKeyViolation", + "GovernedCatalogCapacityExceeded", + "IntegrityCheckFailed", + "MigrationCatalogDeltaMismatch", + "MigrationHistoryChecksumDrift", + "MigrationHistoryGap", + "MigrationHistoryNameDrift", + "MigrationLedgerDrift", + "MigrationRegistryDefect", + "MigrationTransactionRollbackFailed", + "RollbackAhead", + "RollbackBelowVersionFloor", + "RollbackUnmanaged", + "SchemaFingerprintMismatch", + "SchemaTooNew", + "SqliteForeignKeysNotEnabled", + "SqliteMainDatabaseEncodingNotUtf8", + "SqliteMainDatabaseUnavailable", + "TemporarySchemaCollision", + "UnknownMigration", + "UnmanagedSchema" + ], + "methods": [ + "RadrootsOutbox::migrate_to_current_schema", + "RadrootsOutbox::rollback_to_schema_version_and_close", + "RadrootsOutbox::schema_status" + ], + "removed_methods": [ + "RadrootsOutbox::migrate_down" + ], + "removed_symbols": [ + "OUTBOX_MIGRATION_DOWN", + "OUTBOX_MIGRATION_UP" + ] + }, + "release": { + "change_id": "outbox-versioned-migration-authority", + "changelog": "CHANGELOG.md", + "release_record": "contracts/releases/1.0.0-alpha.1.toml" + }, + "result_vector": { + "canonical": { + "byte_length": 3483, + "hash_algorithm": "sha256_bytes_v1", + "path": "contracts/conformance/vectors/outbox/migration_authority.v1.json", + "sha256": "90b82ac034784871627f1b662682203ee3d621b652b866a8b4cee3ba9937444e" + }, + "executor": { + "byte_length": 9491, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "sha256": "d86570baf8dfb9779eb238e851d4272e7404463de86bd7303cee799ca7eed1f3" + }, + "executor_id": "radroots_outbox.migration_authority_v1.result_vector_executor.v1", + "executor_test": "migration_authority_v1_result_vector", + "mirror_path": "crates/outbox/tests/fixtures/migration_authority.v1.json" + }, + "runtime": { + "adoption_policy": "exact_unledgered_0001_catalog_only_v1", + "catalog_fingerprint_algorithm": "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1", + "catalog_rejection_probe_limit": 15, + "catalog_row_limit": 14, + "current_version": 1, + "history_rejection_probe_limit": 2, + "history_row_limit": 1, + "ledger_ddl_sha256": "a23a4f0325ec5338dd9240573bac42a164c2376bcdce3fb306b554da574f5973", + "ledger_name": "radroots_outbox_schema_migrations", + "migration_transaction": "begin_immediate_v1", + "minimum_version": 1, + "open_validations": [ + "main_database_backing_identity", + "utf8_encoding_before_journal_or_schema_mutation", + "foreign_keys_enabled", + "file_wal_result", + "temporary_schema_authority", + "bounded_managed_catalog", + "tamper_evident_history", + "catalog_fingerprint", + "integrity_check_one", + "outbox_scoped_foreign_key_check" + ], + "reserved_prefix": "outbox_", + "rollback_floor": 1, + "rollback_transaction": "begin_exclusive_terminal_close_v1", + "test_destruction": "cfg_test_destroy_outbox_schema_for_migration_test" + }, + "schema_version": 1, + "source_files": [ + { + "file": { + "byte_length": 1408, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/Cargo.toml", + "sha256": "3846cb81a0638d2b3e2875073ba5b8262f63d46961dd5868ec4e287061f16d30" + }, + "role": "outbox_package_manifest" + }, + { + "file": { + "byte_length": 1188, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/lib.rs", + "sha256": "4e4ed499158216aed8654ef9fc239a6680636c0711826d657fb43d85e08a633b" + }, + "role": "outbox_public_surface" + }, + { + "file": { + "byte_length": 8631, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/error.rs", + "sha256": "43eb19df2e07b46fa969b8072df7cf901e562fd723714e44b2eedd356879024a" + }, + "role": "outbox_error_surface" + }, + { + "file": { + "byte_length": 67, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/generated.rs", + "sha256": "5a1f4d3257a07c88aef1c1b7b330ff78ada9cba8bcaa48405b4a86f37a7769fb" + }, + "role": "generated_descriptor_registration" + }, + { + "file": { + "byte_length": 25558, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/migrations.rs", + "sha256": "7fa22dfa26ffd8c5b6bc575ef6f193ffca8e883c019866f04a8125992d7e0ced" + }, + "role": "outbox_migration_registry" + }, + { + "file": { + "byte_length": 59368, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/schema.rs", + "sha256": "e6e467ca19e8b09bade67728d7025cb4be0fa87aa3bb88d5682c3182af6d3051" + }, + "role": "outbox_schema_runtime" + }, + { + "file": { + "byte_length": 332102, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/store.rs", + "sha256": "b271545c0bb5ae7121b4d7e2b9d01d07556729059dd1ce3e126f969940bc850f" + }, + "role": "outbox_store_runtime" + }, + { + "file": { + "byte_length": 2185, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/README", + "sha256": "1b55a3ff04c2c44b22dffee494e50711ce90089a56c51364d59ac9eb42beea7f" + }, + "role": "outbox_package_readme" + }, + { + "file": { + "byte_length": 9491, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "sha256": "d86570baf8dfb9779eb238e851d4272e7404463de86bd7303cee799ca7eed1f3" + }, + "role": "vector_executor" + }, + { + "file": { + "byte_length": 41824, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/contract/outbox_migration.rs", + "sha256": "62e1b2bc02c1a7c4c741c04020e21eb878918570d3ca9b6cb7008700c5f1a883" + }, + "role": "contract_governance" + }, + { + "file": { + "byte_length": 480209, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/contract.rs", + "sha256": "b3a7c9486c2c2cc904d3877be7e7e6a48ef1e00445f07b9884b6778dbc55e416" + }, + "role": "contract_dispatch" + }, + { + "file": { + "byte_length": 16291, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/main.rs", + "sha256": "326c64082e406e278b097ae88131534b7aad283e3135aa6f1302f967d021ed3f" + }, + "role": "xtask_dispatch" + }, + { + "file": { + "byte_length": 11149, + "hash_algorithm": "sha256_bytes_v1", + "path": "build/nix/common.nix", + "sha256": "738003cb1703baaf73a97c010c84731a42230782661c79c6a152fbb9e6fa9f6e" + }, + "role": "nix_contract_lane" + }, + { + "file": { + "byte_length": 20581, + "hash_algorithm": "sha256_bytes_v1", + "path": "contracts/releases/1.0.0-alpha.1.toml", + "sha256": "c7765df4fc7e217fbf6b30d5b0dd2902dbe276f14b2cb2dd34c9fb89c04749c8" + }, + "role": "release_record" + }, + { + "file": { + "byte_length": 29929, + "hash_algorithm": "sha256_bytes_v1", + "path": "CHANGELOG.md", + "sha256": "e6b645684a8ee0f48e4212ec99eb38142b2aeff02d35edbcbf79efb9030ec855" + }, + "role": "release_notes" + } + ] +} diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.schema.json b/crates/outbox/contracts/migration_authority_v1.manifest.schema.json @@ -0,0 +1,352 @@ +{ + "$defs": { + "file": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "path": { + "minLength": 1, + "type": "string" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "path", + "byte_length", + "sha256", + "hash_algorithm" + ], + "type": "object" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "$id": "https://radroots.org/contracts/outbox/migration_authority_v1.manifest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "authority_id": { + "const": "versioned_outbox_migration_authority_v1" + }, + "contract_id": { + "const": "radroots_outbox.migration_authority.v1" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "migrations": { + "items": { + "additionalProperties": false, + "properties": { + "catalog": { + "additionalProperties": false, + "properties": { + "indexes": { + "items": { + "type": "string" + }, + "maxItems": 8, + "minItems": 8, + "type": "array", + "uniqueItems": true + }, + "objects": { + "items": { + "type": "string" + }, + "maxItems": 13, + "minItems": 13, + "type": "array", + "uniqueItems": true + }, + "tables": { + "items": { + "type": "string" + }, + "maxItems": 5, + "minItems": 5, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "objects", + "tables", + "indexes" + ], + "type": "object" + }, + "down": { + "$ref": "#/$defs/file" + }, + "name": { + "const": "outbox" + }, + "schema_sha256": { + "$ref": "#/$defs/sha256" + }, + "up": { + "$ref": "#/$defs/file" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "name", + "up", + "down", + "schema_sha256", + "catalog" + ], + "type": "object" + }, + "maxItems": 1, + "minItems": 1, + "type": "array" + }, + "public_api": { + "additionalProperties": false, + "properties": { + "added_symbols": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 4, + "minItems": 4, + "type": "array", + "uniqueItems": true + }, + "error_enum_non_exhaustive": { + "const": true + }, + "error_variants": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 23, + "minItems": 23, + "type": "array", + "uniqueItems": true + }, + "methods": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 3, + "minItems": 3, + "type": "array", + "uniqueItems": true + }, + "removed_methods": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 1, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "removed_symbols": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 2, + "minItems": 2, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "added_symbols", + "methods", + "removed_symbols", + "removed_methods", + "error_variants", + "error_enum_non_exhaustive" + ], + "type": "object" + }, + "release": { + "additionalProperties": false, + "properties": { + "change_id": { + "const": "outbox-versioned-migration-authority" + }, + "changelog": { + "const": "CHANGELOG.md" + }, + "release_record": { + "const": "contracts/releases/1.0.0-alpha.1.toml" + } + }, + "required": [ + "change_id", + "release_record", + "changelog" + ], + "type": "object" + }, + "result_vector": { + "additionalProperties": false, + "properties": { + "canonical": { + "$ref": "#/$defs/file" + }, + "executor": { + "$ref": "#/$defs/file" + }, + "executor_id": { + "const": "radroots_outbox.migration_authority_v1.result_vector_executor.v1" + }, + "executor_test": { + "const": "migration_authority_v1_result_vector" + }, + "mirror_path": { + "const": "crates/outbox/tests/fixtures/migration_authority.v1.json" + } + }, + "required": [ + "canonical", + "mirror_path", + "executor", + "executor_id", + "executor_test" + ], + "type": "object" + }, + "runtime": { + "additionalProperties": false, + "properties": { + "adoption_policy": { + "const": "exact_unledgered_0001_catalog_only_v1" + }, + "catalog_fingerprint_algorithm": { + "minLength": 1, + "type": "string" + }, + "catalog_rejection_probe_limit": { + "const": 15 + }, + "catalog_row_limit": { + "const": 14 + }, + "current_version": { + "const": 1 + }, + "history_rejection_probe_limit": { + "const": 2 + }, + "history_row_limit": { + "const": 1 + }, + "ledger_ddl_sha256": { + "$ref": "#/$defs/sha256" + }, + "ledger_name": { + "const": "radroots_outbox_schema_migrations" + }, + "migration_transaction": { + "const": "begin_immediate_v1" + }, + "minimum_version": { + "const": 1 + }, + "open_validations": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 10, + "type": "array", + "uniqueItems": true + }, + "reserved_prefix": { + "const": "outbox_" + }, + "rollback_floor": { + "const": 1 + }, + "rollback_transaction": { + "const": "begin_exclusive_terminal_close_v1" + }, + "test_destruction": { + "const": "cfg_test_destroy_outbox_schema_for_migration_test" + } + }, + "required": [ + "minimum_version", + "current_version", + "reserved_prefix", + "ledger_name", + "ledger_ddl_sha256", + "catalog_fingerprint_algorithm", + "migration_transaction", + "rollback_transaction", + "catalog_row_limit", + "catalog_rejection_probe_limit", + "history_row_limit", + "history_rejection_probe_limit", + "open_validations", + "adoption_policy", + "rollback_floor", + "test_destruction" + ], + "type": "object" + }, + "schema_version": { + "const": 1 + }, + "source_files": { + "items": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/$defs/file" + }, + "role": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "role", + "file" + ], + "type": "object" + }, + "maxItems": 15, + "minItems": 15, + "type": "array" + } + }, + "required": [ + "schema_version", + "contract_id", + "authority_id", + "manifest_schema", + "runtime", + "migrations", + "public_api", + "source_files", + "result_vector", + "release" + ], + "type": "object" +} diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256 @@ -0,0 +1 @@ +1fb495009fd258380a02c0f5714f7280f832220aec251a7a29a5478ce64b7730 diff --git a/crates/outbox/src/error.rs b/crates/outbox/src/error.rs @@ -4,6 +4,7 @@ use radroots_transport::RadrootsTransportError; use thiserror::Error; #[derive(Debug, Error)] +#[non_exhaustive] pub enum RadrootsOutboxError { #[error("SQLx error: {0}")] Sqlx(#[from] sqlx::Error), @@ -46,6 +47,129 @@ pub enum RadrootsOutboxError { #[error("SQLite outbox file connection did not enter WAL journal mode; reported `{actual}`")] SqliteFileJournalModeNotWal { actual: String }, + #[error("SQLite outbox connection has no main database")] + SqliteMainDatabaseUnavailable, + + #[error("SQLite outbox main database must use UTF-8 encoding; reported `{actual}`")] + SqliteMainDatabaseEncodingNotUtf8 { actual: String }, + + #[error("SQLite outbox connection must enforce foreign keys; reported {actual}")] + SqliteForeignKeysNotEnabled { actual: i64 }, + + #[error( + "temporary schema object `{name}` ({object_type}, table `{table_name}`) collides with outbox authority" + )] + TemporarySchemaCollision { + object_type: String, + name: String, + table_name: String, + }, + + #[error("outbox migration registry defect: {reason}")] + MigrationRegistryDefect { reason: String }, + + #[error( + "embedded outbox migration {version} {direction} length mismatch: expected {expected}, found {actual}" + )] + EmbeddedMigrationLengthMismatch { + version: u32, + direction: &'static str, + expected: usize, + actual: usize, + }, + + #[error( + "embedded outbox migration {version} {direction} checksum mismatch: expected {expected}, found {actual}" + )] + EmbeddedMigrationChecksumMismatch { + version: u32, + direction: &'static str, + expected: &'static str, + actual: String, + }, + + #[error("outbox migration {version} {direction} catalog delta mismatch: {reason}")] + MigrationCatalogDeltaMismatch { + version: u32, + direction: &'static str, + reason: String, + }, + + #[error("outbox governed catalog exceeds the supported {max} rows")] + GovernedCatalogCapacityExceeded { max: usize }, + + #[error("unmanaged outbox schema has fingerprint {actual_schema_sha256}")] + UnmanagedSchema { actual_schema_sha256: String }, + + #[error("outbox migration ledger catalog is invalid: {reason}")] + MigrationLedgerDrift { reason: String }, + + #[error("outbox migration history gap: expected version {expected}, found {actual:?}")] + MigrationHistoryGap { expected: u32, actual: Option<u32> }, + + #[error("outbox migration history references unknown version {version}")] + UnknownMigration { version: u32 }, + + #[error("outbox schema version {database} is newer than supported version {current}")] + SchemaTooNew { current: u32, database: i64 }, + + #[error("outbox migration {version} name drift: expected `{expected}`, found `{actual}`")] + MigrationHistoryNameDrift { + version: u32, + expected: &'static str, + actual: String, + }, + + #[error( + "outbox migration {version} {field} checksum drift: expected {expected}, found {actual}" + )] + MigrationHistoryChecksumDrift { + version: u32, + field: &'static str, + expected: &'static str, + actual: String, + }, + + #[error( + "outbox schema fingerprint mismatch at version {version}: expected {expected}, found {actual}" + )] + SchemaFingerprintMismatch { + version: u32, + expected: &'static str, + actual: String, + }, + + #[error("outbox rollback target {target} is below the supported version floor {floor}")] + RollbackBelowVersionFloor { floor: u32, target: u32 }, + + #[error("outbox rollback target {target} is ahead of managed version {current}")] + RollbackAhead { current: u32, target: u32 }, + + #[error("outbox rollback requires a managed schema")] + RollbackUnmanaged, + + #[error( + "outbox schema operation failed: {primary}; transaction rollback also failed: {rollback}" + )] + MigrationTransactionRollbackFailed { + #[source] + primary: Box<RadrootsOutboxError>, + rollback: sqlx::Error, + }, + + #[error("outbox SQLite integrity check failed: {detail}")] + IntegrityCheckFailed { detail: String }, + + #[error( + "outbox foreign-key violation in `{table}` row {rowid:?} against `{parent}` constraint {foreign_key_id}" + )] + ForeignKeyViolation { + table: String, + rowid: Option<i64>, + parent: String, + foreign_key_id: i64, + }, + #[error( "trade mutation outbox metadata does not match the canonical mutation content: {field}" )] diff --git a/crates/outbox/src/generated.rs b/crates/outbox/src/generated.rs @@ -0,0 +1,3 @@ +#![forbid(unsafe_code)] + +pub(crate) mod outbox_migration_manifest; diff --git a/crates/outbox/src/generated/outbox_migration_manifest.rs b/crates/outbox/src/generated/outbox_migration_manifest.rs @@ -0,0 +1,15 @@ +// @generated by `cargo xtask contract outbox-migration-manifest --write`; do not edit. + +pub(crate) const OUTBOX_MIGRATION_CONTRACT_ID: &str = "radroots_outbox.migration_authority.v1"; +pub(crate) const OUTBOX_MIGRATION_SCHEMA_VERSION: u32 = 1; +pub(crate) const OUTBOX_MIGRATION_CURRENT_VERSION: u32 = 1; +pub(crate) const OUTBOX_MIGRATION_0001_UP_BYTE_LENGTH: usize = 5470; +pub(crate) const OUTBOX_MIGRATION_0001_DOWN_BYTE_LENGTH: usize = 159; +pub(crate) const OUTBOX_MIGRATION_0001_UP_SHA256: &str = + "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa"; +pub(crate) const OUTBOX_MIGRATION_0001_DOWN_SHA256: &str = + "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61"; +pub(crate) const OUTBOX_MIGRATION_0001_SCHEMA_SHA256: &str = + "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293"; +pub(crate) const OUTBOX_MIGRATION_MANIFEST_SHA256: &str = + "1fb495009fd258380a02c0f5714f7280f832220aec251a7a29a5478ce64b7730"; diff --git a/crates/outbox/src/lib.rs b/crates/outbox/src/lib.rs @@ -2,12 +2,14 @@ #![forbid(unsafe_code)] mod error; +mod generated; mod migrations; mod model; +mod schema; mod store; pub use error::RadrootsOutboxError; -pub use migrations::{OUTBOX_MIGRATION_DOWN, OUTBOX_MIGRATION_UP}; +pub use migrations::{RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, RADROOTS_OUTBOX_SCHEMA_VERSION_MIN}; pub use model::{ RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryAttemptRecord, RadrootsOutboxDeliveryPlanInput, RadrootsOutboxDeliveryPlanRecord, @@ -20,4 +22,5 @@ pub use model::{ RadrootsOutboxSignedTradeMutationInput, RadrootsOutboxStatusSummary, RadrootsOutboxTradeMutationInput, }; +pub use schema::{RadrootsOutboxSchemaStatus, inspect_outbox_schema_status}; pub use store::RadrootsOutbox; diff --git a/crates/outbox/src/migrations.rs b/crates/outbox/src/migrations.rs @@ -1,4 +1,665 @@ #![forbid(unsafe_code)] -pub const OUTBOX_MIGRATION_UP: &str = include_str!("../migrations/0001_outbox.up.sql"); -pub const OUTBOX_MIGRATION_DOWN: &str = include_str!("../migrations/0001_outbox.down.sql"); +use crate::RadrootsOutboxError; +use crate::generated::outbox_migration_manifest as manifest; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub(crate) const OUTBOX_LEDGER_NAME: &str = "radroots_outbox_schema_migrations"; +pub(crate) const OUTBOX_RESERVED_PREFIX: &str = "outbox_"; + +/// Oldest managed schema version that the runtime can preserve or target. +pub const RADROOTS_OUTBOX_SCHEMA_VERSION_MIN: u32 = 1; +/// Latest managed schema version understood by this runtime. +pub const RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT: u32 = 1; + +pub(crate) const OUTBOX_LEDGER_DDL: &str = "CREATE TABLE radroots_outbox_schema_migrations ( + version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), + name TEXT NOT NULL UNIQUE CHECK (length(name) > 0), + up_sha256 TEXT NOT NULL CHECK (length(up_sha256) = 64 AND up_sha256 NOT GLOB '*[^0-9a-f]*'), + down_sha256 TEXT NOT NULL CHECK (length(down_sha256) = 64 AND down_sha256 NOT GLOB '*[^0-9a-f]*'), + schema_sha256 TEXT NOT NULL CHECK (length(schema_sha256) = 64 AND schema_sha256 NOT GLOB '*[^0-9a-f]*') +) STRICT, WITHOUT ROWID"; + +pub(crate) const OUTBOX_LEDGER_CREATE_DDL: &str = + "CREATE TABLE main.radroots_outbox_schema_migrations ( + version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), + name TEXT NOT NULL UNIQUE CHECK (length(name) > 0), + up_sha256 TEXT NOT NULL CHECK (length(up_sha256) = 64 AND up_sha256 NOT GLOB '*[^0-9a-f]*'), + down_sha256 TEXT NOT NULL CHECK (length(down_sha256) = 64 AND down_sha256 NOT GLOB '*[^0-9a-f]*'), + schema_sha256 TEXT NOT NULL CHECK (length(schema_sha256) = 64 AND schema_sha256 NOT GLOB '*[^0-9a-f]*') +) STRICT, WITHOUT ROWID"; + +pub(crate) const OUTBOX_BASELINE_OBJECT_NAMES: &[&str] = &[ + "outbox_delivery_attempt", + "outbox_delivery_attempt_target_idx", + "outbox_delivery_plan", + "outbox_delivery_plan_event_idx", + "outbox_delivery_target", + "outbox_delivery_target_ready_idx", + "outbox_event", + "outbox_event_event_id_idx", + "outbox_event_ready_idx", + "outbox_operation_idempotency_idx", + "outbox_operation_status_idx", + "outbox_operation_trade_mutation_idx", + "outbox_operations", +]; + +pub(crate) const OUTBOX_BASELINE_TABLE_NAMES: &[&str] = &[ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations", +]; + +#[derive(Clone, Copy)] +pub(crate) struct OutboxMigration { + pub(crate) version: u32, + pub(crate) name: &'static str, + pub(crate) up_sql: &'static str, + pub(crate) down_sql: &'static str, + pub(crate) up_len: usize, + pub(crate) down_len: usize, + pub(crate) up_sha256: &'static str, + pub(crate) down_sha256: &'static str, + pub(crate) schema_sha256: &'static str, + pub(crate) owned_object_names: &'static [&'static str], + pub(crate) owned_table_names: &'static [&'static str], +} + +pub(crate) const OUTBOX_MIGRATIONS: &[OutboxMigration] = &[OutboxMigration { + version: 1, + name: "outbox", + up_sql: include_str!("../migrations/0001_outbox.up.sql"), + down_sql: include_str!("../migrations/0001_outbox.down.sql"), + up_len: manifest::OUTBOX_MIGRATION_0001_UP_BYTE_LENGTH, + down_len: manifest::OUTBOX_MIGRATION_0001_DOWN_BYTE_LENGTH, + up_sha256: manifest::OUTBOX_MIGRATION_0001_UP_SHA256, + down_sha256: manifest::OUTBOX_MIGRATION_0001_DOWN_SHA256, + schema_sha256: manifest::OUTBOX_MIGRATION_0001_SCHEMA_SHA256, + owned_object_names: OUTBOX_BASELINE_OBJECT_NAMES, + owned_table_names: OUTBOX_BASELINE_TABLE_NAMES, +}]; + +pub(crate) fn migration_for_version( + registry: &[OutboxMigration], + version: u32, +) -> Option<&OutboxMigration> { + registry + .iter() + .find(|migration| migration.version == version) +} + +pub(crate) fn is_outbox_owned_table_name(registry: &[OutboxMigration], name: &str) -> bool { + sqlite_identifier_starts_with(name, OUTBOX_RESERVED_PREFIX) + || registry + .iter() + .flat_map(|migration| migration.owned_table_names) + .any(|owned| name.eq_ignore_ascii_case(owned)) +} + +pub(crate) fn is_outbox_governed_schema_name(registry: &[OutboxMigration], name: &str) -> bool { + name.eq_ignore_ascii_case(OUTBOX_LEDGER_NAME) + || sqlite_identifier_starts_with(name, OUTBOX_RESERVED_PREFIX) + || registry + .iter() + .flat_map(|migration| migration.owned_object_names) + .any(|owned| name.eq_ignore_ascii_case(owned)) +} + +pub(crate) fn sqlite_identifier_starts_with(name: &str, prefix: &str) -> bool { + name.get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) +} + +pub(crate) fn validate_embedded_migration_registry() -> Result<(), RadrootsOutboxError> { + validate_generated_descriptor_identity( + manifest::OUTBOX_MIGRATION_CONTRACT_ID, + manifest::OUTBOX_MIGRATION_SCHEMA_VERSION, + manifest::OUTBOX_MIGRATION_CURRENT_VERSION, + )?; + validate_sha256_literal(0, "manifest", manifest::OUTBOX_MIGRATION_MANIFEST_SHA256)?; + validate_migration_registry( + OUTBOX_MIGRATIONS, + RADROOTS_OUTBOX_SCHEMA_VERSION_MIN, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ) +} + +fn validate_generated_descriptor_identity( + contract_id: &str, + schema_version: u32, + current_version: u32, +) -> Result<(), RadrootsOutboxError> { + if contract_id != "radroots_outbox.migration_authority.v1" + || schema_version != 1 + || current_version != RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT + { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: "generated outbox migration descriptor identity is invalid".to_owned(), + }); + } + Ok(()) +} + +pub(crate) fn validate_migration_registry( + registry: &[OutboxMigration], + minimum: u32, + current: u32, +) -> Result<(), RadrootsOutboxError> { + validate_ledger_ddl_identity(OUTBOX_LEDGER_DDL, OUTBOX_LEDGER_CREATE_DDL)?; + if minimum == 0 || current < minimum || registry.is_empty() { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "migration version range {minimum}..={current} requires a non-empty positive registry" + ), + }); + } + let mut expected_version = minimum; + let mut object_names = BTreeSet::new(); + let mut table_names = BTreeSet::new(); + for (index, migration) in registry.iter().enumerate() { + if migration.version != expected_version { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "expected migration version {expected_version}, found {}", + migration.version + ), + }); + } + if migration.name.is_empty() + || registry[..index] + .iter() + .any(|prior| prior.name == migration.name) + { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "migration version {} has an invalid or duplicate name", + migration.version + ), + }); + } + if migration.owned_object_names.is_empty() || migration.owned_table_names.is_empty() { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "migration version {} must own schema objects and tables", + migration.version + ), + }); + } + for name in migration.owned_object_names { + validate_owned_schema_name(migration.version, "object", name)?; + if !object_names.insert(*name) { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!("owned schema object `{name}` is declared more than once"), + }); + } + } + for name in migration.owned_table_names { + validate_owned_schema_name(migration.version, "table", name)?; + if !migration.owned_object_names.contains(name) || !table_names.insert(*name) { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "owned table `{name}` is missing from the object inventory or is duplicated" + ), + }); + } + } + validate_embedded_migration_input( + migration.version, + "up", + migration.up_sql, + migration.up_len, + migration.up_sha256, + )?; + validate_embedded_migration_input( + migration.version, + "down", + migration.down_sql, + migration.down_len, + migration.down_sha256, + )?; + validate_sha256_literal(migration.version, "schema", migration.schema_sha256)?; + expected_version = expected_version.checked_add(1).ok_or_else(|| { + RadrootsOutboxError::MigrationRegistryDefect { + reason: "migration version overflow".to_owned(), + } + })?; + } + if expected_version - 1 != current { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "migration registry ends at {}, expected {current}", + expected_version - 1 + ), + }); + } + Ok(()) +} + +fn validate_ledger_ddl_identity( + catalog_ddl: &str, + create_ddl: &str, +) -> Result<(), RadrootsOutboxError> { + let catalog_ddl = catalog_ddl.strip_prefix("CREATE TABLE "); + let create_ddl = create_ddl.strip_prefix("CREATE TABLE main."); + if catalog_ddl.is_none() || create_ddl.is_none() || create_ddl != catalog_ddl { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: "main-qualified ledger creation DDL does not match canonical catalog DDL" + .to_owned(), + }); + } + Ok(()) +} + +fn validate_owned_schema_name( + version: u32, + object_kind: &'static str, + name: &str, +) -> Result<(), RadrootsOutboxError> { + if name.is_empty() + || name == OUTBOX_LEDGER_NAME + || !sqlite_identifier_starts_with(name, OUTBOX_RESERVED_PREFIX) + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!( + "migration version {version} has invalid owned {object_kind} name `{name}`" + ), + }); + } + Ok(()) +} + +fn validate_embedded_migration_input( + version: u32, + direction: &'static str, + sql: &str, + expected_len: usize, + expected_sha256: &'static str, +) -> Result<(), RadrootsOutboxError> { + if sql.len() != expected_len { + return Err(RadrootsOutboxError::EmbeddedMigrationLengthMismatch { + version, + direction, + expected: expected_len, + actual: sql.len(), + }); + } + validate_sha256_literal(version, direction, expected_sha256)?; + let actual = sha256_hex(sql.as_bytes()); + if actual != expected_sha256 { + return Err(RadrootsOutboxError::EmbeddedMigrationChecksumMismatch { + version, + direction, + expected: expected_sha256, + actual, + }); + } + Ok(()) +} + +fn validate_sha256_literal( + version: u32, + field: &'static str, + value: &str, +) -> Result<(), RadrootsOutboxError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!("migration version {version} has an invalid {field} SHA-256 literal"), + }); + } + Ok(()) +} + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + #[derive(Debug, PartialEq, Eq)] + struct DiscoveredMigration { + version: u32, + name: String, + up: PathBuf, + down: PathBuf, + } + + fn discover(root: &Path) -> Result<Vec<DiscoveredMigration>, String> { + let directory = root.join("migrations"); + let mut entries = fs::read_dir(&directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("read migration entry: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut pairs = + std::collections::BTreeMap::<(u32, String), (Option<PathBuf>, Option<PathBuf>)>::new(); + for entry in entries { + let file_type = entry.file_type().map_err(|error| error.to_string())?; + if !file_type.is_file() || file_type.is_symlink() { + return Err(format!( + "migration input must be a regular file: {}", + entry.path().display() + )); + } + let name = entry + .file_name() + .into_string() + .map_err(|_| "migration filename is not UTF-8".to_owned())?; + let (stem, direction) = if let Some(stem) = name.strip_suffix(".up.sql") { + (stem, "up") + } else if let Some(stem) = name.strip_suffix(".down.sql") { + (stem, "down") + } else { + return Err(format!("unknown migration file `{name}`")); + }; + let (version, migration_name) = stem + .split_once('_') + .ok_or_else(|| format!("invalid migration filename `{name}`"))?; + if version.len() != 4 + || !version.bytes().all(|byte| byte.is_ascii_digit()) + || migration_name.is_empty() + || !migration_name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("invalid migration filename `{name}`")); + } + let version = version.parse::<u32>().map_err(|error| error.to_string())?; + let pair = pairs + .entry((version, migration_name.to_owned())) + .or_default(); + let slot = if direction == "up" { + &mut pair.0 + } else { + &mut pair.1 + }; + if slot.replace(entry.path()).is_some() { + return Err(format!( + "duplicate {direction} migration for version {version}" + )); + } + } + pairs + .into_iter() + .map(|((version, name), (up, down))| { + Ok(DiscoveredMigration { + version, + name, + up: up.ok_or_else(|| format!("migration {version} is missing up SQL"))?, + down: down.ok_or_else(|| format!("migration {version} is missing down SQL"))?, + }) + }) + .collect() + } + + #[test] + fn migration_source_discovery_is_exact_and_fail_closed() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let discovered = discover(root).expect("canonical migration discovery"); + assert_eq!(discovered.len(), OUTBOX_MIGRATIONS.len()); + assert_eq!(discovered[0].version, 1); + assert_eq!(discovered[0].name, "outbox"); + assert_eq!( + fs::read(&discovered[0].up).expect("up bytes"), + OUTBOX_MIGRATIONS[0].up_sql.as_bytes() + ); + assert_eq!( + fs::read(&discovered[0].down).expect("down bytes"), + OUTBOX_MIGRATIONS[0].down_sql.as_bytes() + ); + + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir(temp.path().join("migrations")).expect("migrations"); + for (name, bytes) in [ + ("0001_outbox.up.sql", OUTBOX_MIGRATIONS[0].up_sql.as_bytes()), + ( + "0001_outbox.down.sql", + OUTBOX_MIGRATIONS[0].down_sql.as_bytes(), + ), + ] { + fs::write(temp.path().join("migrations").join(name), bytes).expect("fixture"); + } + fs::write( + temp.path().join("migrations/0002_unknown.txt"), + b"SELECT 1;", + ) + .expect("unknown"); + assert!( + discover(temp.path()) + .expect_err("unknown file") + .contains("unknown migration file") + ); + fs::remove_file(temp.path().join("migrations/0002_unknown.txt")).expect("remove"); + fs::remove_file(temp.path().join("migrations/0001_outbox.down.sql")).expect("remove down"); + assert!( + discover(temp.path()) + .expect_err("missing pair") + .contains("missing down SQL") + ); + } + + #[test] + fn embedded_registry_and_frozen_baseline_are_exact() { + validate_embedded_migration_registry().expect("registry"); + assert_eq!(OUTBOX_MIGRATIONS[0].up_len, 5_470); + assert_eq!(OUTBOX_MIGRATIONS[0].down_len, 159); + assert_eq!( + OUTBOX_MIGRATIONS[0].up_sha256, + "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa" + ); + assert_eq!( + OUTBOX_MIGRATIONS[0].down_sha256, + "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61" + ); + assert_eq!(OUTBOX_BASELINE_OBJECT_NAMES.len(), 13); + assert_eq!(OUTBOX_BASELINE_TABLE_NAMES.len(), 5); + } + + fn assert_registry_defect(result: Result<(), RadrootsOutboxError>) -> String { + match result.expect_err("registry defect") { + RadrootsOutboxError::MigrationRegistryDefect { reason } => reason, + other => panic!("unexpected error: {other}"), + } + } + + fn future_migration() -> OutboxMigration { + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.version = 2; + migration.name = "future"; + migration.owned_object_names = &["outbox_future"]; + migration.owned_table_names = &["outbox_future"]; + migration + } + + #[test] + fn namespace_predicates_cover_reserved_ledger_registry_and_unrelated_names() { + let mut legacy = OUTBOX_MIGRATIONS[0]; + legacy.owned_object_names = &["legacy_object"]; + legacy.owned_table_names = &["legacy_table"]; + let registry = [legacy]; + + assert!(migration_for_version(OUTBOX_MIGRATIONS, 1).is_some()); + assert!(migration_for_version(OUTBOX_MIGRATIONS, 2).is_none()); + assert!(sqlite_identifier_starts_with("OUTBOX_EVENT", "outbox_")); + assert!(!sqlite_identifier_starts_with("short", "outbox_")); + assert!(is_outbox_owned_table_name(&registry, "outbox_new")); + assert!(is_outbox_owned_table_name(&registry, "LEGACY_TABLE")); + assert!(!is_outbox_owned_table_name(&registry, "caller_table")); + assert!(is_outbox_governed_schema_name( + &registry, + "RADROOTS_OUTBOX_SCHEMA_MIGRATIONS" + )); + assert!(is_outbox_governed_schema_name(&registry, "outbox_new")); + assert!(is_outbox_governed_schema_name(&registry, "LEGACY_OBJECT")); + assert!(!is_outbox_governed_schema_name(&registry, "caller_object")); + } + + #[test] + fn descriptor_and_ledger_identifiers_fail_closed() { + validate_generated_descriptor_identity( + "radroots_outbox.migration_authority.v1", + 1, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ) + .expect("descriptor"); + for (contract_id, schema_version, current_version) in [ + ("counterfeit", 1, RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT), + ( + "radroots_outbox.migration_authority.v1", + 2, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ), + ("radroots_outbox.migration_authority.v1", 1, 2), + ] { + assert_registry_defect(validate_generated_descriptor_identity( + contract_id, + schema_version, + current_version, + )); + } + + validate_ledger_ddl_identity(OUTBOX_LEDGER_DDL, OUTBOX_LEDGER_CREATE_DDL) + .expect("ledger DDL"); + for (catalog, create) in [ + (OUTBOX_LEDGER_DDL, "CREATE TABLE main.counterfeit"), + ("counterfeit", OUTBOX_LEDGER_CREATE_DDL), + (OUTBOX_LEDGER_DDL, "counterfeit"), + ("counterfeit", "counterfeit"), + ] { + assert_registry_defect(validate_ledger_ddl_identity(catalog, create)); + } + } + + #[test] + fn registry_shape_validation_rejects_every_structural_defect() { + assert_registry_defect(validate_migration_registry(OUTBOX_MIGRATIONS, 0, 1)); + assert_registry_defect(validate_migration_registry(OUTBOX_MIGRATIONS, 2, 1)); + assert_registry_defect(validate_migration_registry(&[], 1, 1)); + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.version = 2; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.name = ""; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + + let mut duplicate_name = future_migration(); + duplicate_name.name = OUTBOX_MIGRATIONS[0].name; + assert_registry_defect(validate_migration_registry( + &[OUTBOX_MIGRATIONS[0], duplicate_name], + 1, + 2, + )); + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.owned_object_names = &[]; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.owned_table_names = &[]; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + + for invalid_name in ["", OUTBOX_LEDGER_NAME, "caller_object", "outbox_UPPER"] { + assert_registry_defect(validate_owned_schema_name(1, "object", invalid_name)); + } + validate_owned_schema_name(1, "object", "outbox_123").expect("numeric identifier"); + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.owned_object_names = &["outbox_valid"]; + migration.owned_table_names = &["outbox_UPPER"]; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + + let mut duplicate_object = future_migration(); + duplicate_object.owned_object_names = &["outbox_event"]; + duplicate_object.owned_table_names = &["outbox_event"]; + assert_registry_defect(validate_migration_registry( + &[OUTBOX_MIGRATIONS[0], duplicate_object], + 1, + 2, + )); + + let mut missing_table = future_migration(); + missing_table.owned_object_names = &["outbox_future_index"]; + assert_registry_defect(validate_migration_registry( + &[OUTBOX_MIGRATIONS[0], missing_table], + 1, + 2, + )); + + let mut duplicate_table = future_migration(); + duplicate_table.owned_table_names = &["outbox_future", "outbox_future"]; + assert_registry_defect(validate_migration_registry( + &[OUTBOX_MIGRATIONS[0], duplicate_table], + 1, + 2, + )); + + assert_registry_defect(validate_migration_registry(OUTBOX_MIGRATIONS, 1, 2)); + validate_migration_registry(&[OUTBOX_MIGRATIONS[0], future_migration()], 1, 2) + .expect("contiguous synthetic registry"); + + let mut overflow = OUTBOX_MIGRATIONS[0]; + overflow.version = u32::MAX; + validate_migration_registry(&[overflow], u32::MAX, u32::MAX).expect_err("version overflow"); + } + + #[test] + fn registry_checksum_validation_rejects_lengths_literals_and_bytes() { + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.up_len += 1; + assert!(matches!( + validate_migration_registry(&[migration], 1, 1), + Err(RadrootsOutboxError::EmbeddedMigrationLengthMismatch { + direction: "up", + .. + }) + )); + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.down_len += 1; + assert!(matches!( + validate_migration_registry(&[migration], 1, 1), + Err(RadrootsOutboxError::EmbeddedMigrationLengthMismatch { + direction: "down", + .. + }) + )); + + for invalid_sha in [ + "short", + "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ] { + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.up_sha256 = invalid_sha; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + } + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.up_sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + assert!(matches!( + validate_migration_registry(&[migration], 1, 1), + Err(RadrootsOutboxError::EmbeddedMigrationChecksumMismatch { + direction: "up", + .. + }) + )); + + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.schema_sha256 = "short"; + assert_registry_defect(validate_migration_registry(&[migration], 1, 1)); + } +} diff --git a/crates/outbox/src/schema.rs b/crates/outbox/src/schema.rs @@ -0,0 +1,1601 @@ +#![forbid(unsafe_code)] + +use crate::RadrootsOutboxError; +use crate::migrations::{ + OUTBOX_LEDGER_CREATE_DDL, OUTBOX_LEDGER_DDL, OUTBOX_LEDGER_NAME, OUTBOX_MIGRATIONS, + OUTBOX_RESERVED_PREFIX, OutboxMigration, RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + RADROOTS_OUTBOX_SCHEMA_VERSION_MIN, is_outbox_governed_schema_name, is_outbox_owned_table_name, + migration_for_version, validate_embedded_migration_registry, validate_migration_registry, +}; +use sha2::{Digest, Sha256}; +use sqlx::{Row, Sqlite, SqliteConnection, SqlitePool, Transaction}; +use std::collections::{BTreeMap, BTreeSet}; + +#[cfg(test)] +const EMPTY_SCHEMA_SHA256: &str = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +/// Authenticated lifecycle state of the governed outbox schema. +pub enum RadrootsOutboxSchemaStatus { + /// No governed outbox schema objects exist. + Uninitialized, + /// The exact frozen baseline exists without its migration ledger. + UnledgeredBaseline, + /// The schema and ledger match a supported managed version. + Managed { version: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CatalogRow { + object_type: String, + name: String, + table_name: String, + sql: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AppliedMigration { + version: i64, + name: String, + up_sha256: String, + down_sha256: String, + schema_sha256: String, +} + +/// Inspects and authenticates schema state without adopting or migrating it. +pub async fn inspect_outbox_schema_status( + pool: &SqlitePool, +) -> Result<RadrootsOutboxSchemaStatus, RadrootsOutboxError> { + validate_embedded_migration_registry()?; + let mut transaction = pool.begin().await?; + let result = inspect_schema_on_connection( + &mut transaction, + OUTBOX_MIGRATIONS, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ) + .await; + finish_schema_transaction(transaction, result).await +} + +pub(crate) async fn migrate_outbox_schema(pool: &SqlitePool) -> Result<(), RadrootsOutboxError> { + validate_embedded_migration_registry()?; + migrate_outbox_schema_with_registry( + pool, + OUTBOX_MIGRATIONS, + RADROOTS_OUTBOX_SCHEMA_VERSION_MIN, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ) + .await +} + +async fn migrate_outbox_schema_with_registry( + pool: &SqlitePool, + registry: &[OutboxMigration], + minimum: u32, + supported_current: u32, +) -> Result<(), RadrootsOutboxError> { + validate_migration_registry(registry, minimum, supported_current)?; + let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?; + let result = migrate_schema_on_connection(&mut transaction, registry, supported_current).await; + finish_schema_transaction(transaction, result).await +} + +pub(crate) async fn rollback_outbox_schema_offline( + pool: &SqlitePool, + target: u32, +) -> Result<(), RadrootsOutboxError> { + if target < RADROOTS_OUTBOX_SCHEMA_VERSION_MIN { + return Err(RadrootsOutboxError::RollbackBelowVersionFloor { + floor: RADROOTS_OUTBOX_SCHEMA_VERSION_MIN, + target, + }); + } + validate_embedded_migration_registry()?; + let mut transaction = pool.begin_with("BEGIN EXCLUSIVE").await?; + let result = rollback_schema_on_connection( + &mut transaction, + OUTBOX_MIGRATIONS, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + target, + ) + .await; + finish_schema_transaction(transaction, result).await +} + +#[cfg(test)] +pub(crate) async fn destroy_outbox_schema_for_migration_test( + pool: &SqlitePool, +) -> Result<(), RadrootsOutboxError> { + validate_embedded_migration_registry()?; + let mut transaction = pool.begin_with("BEGIN EXCLUSIVE").await?; + let result = destroy_schema_on_connection(&mut transaction).await; + finish_schema_transaction(transaction, result).await +} + +async fn finish_schema_transaction<T>( + transaction: Transaction<'static, Sqlite>, + result: Result<T, RadrootsOutboxError>, +) -> Result<T, RadrootsOutboxError> { + match result { + Ok(value) => { + transaction.commit().await?; + Ok(value) + } + Err(primary) => match transaction.rollback().await { + Ok(()) => Err(primary), + Err(rollback) => Err(RadrootsOutboxError::MigrationTransactionRollbackFailed { + primary: Box::new(primary), + rollback, + }), + }, + } +} + +async fn migrate_schema_on_connection( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + supported_current: u32, +) -> Result<(), RadrootsOutboxError> { + let status = inspect_schema_on_connection(connection, registry, supported_current).await?; + let current_version = match status { + RadrootsOutboxSchemaStatus::Uninitialized => { + apply_migration_up(connection, registry, &registry[0]).await?; + create_ledger(connection).await?; + insert_ledger_row(connection, &registry[0]).await?; + registry[0].version + } + RadrootsOutboxSchemaStatus::UnledgeredBaseline => { + create_ledger(connection).await?; + insert_ledger_row(connection, &registry[0]).await?; + registry[0].version + } + RadrootsOutboxSchemaStatus::Managed { version } if version == supported_current => version, + RadrootsOutboxSchemaStatus::Managed { version } => version, + }; + + for migration in registry + .iter() + .filter(|migration| migration.version > current_version) + { + apply_migration_up(connection, registry, migration).await?; + insert_ledger_row(connection, migration).await?; + } + + validate_database_integrity(connection, registry).await?; + match inspect_schema_on_connection(connection, registry, supported_current).await? { + RadrootsOutboxSchemaStatus::Managed { version } if version == supported_current => Ok(()), + status => Err(RadrootsOutboxError::MigrationRegistryDefect { + reason: format!("migration completed in unexpected state {status:?}"), + }), + } +} + +async fn rollback_schema_on_connection( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + supported_current: u32, + target: u32, +) -> Result<(), RadrootsOutboxError> { + let RadrootsOutboxSchemaStatus::Managed { + version: current_version, + } = inspect_schema_on_connection(connection, registry, supported_current).await? + else { + return Err(RadrootsOutboxError::RollbackUnmanaged); + }; + if target > current_version { + return Err(RadrootsOutboxError::RollbackAhead { + current: current_version, + target, + }); + } + + for version in ((target + 1)..=current_version).rev() { + let migration = migration_for_version(registry, version) + .ok_or(RadrootsOutboxError::UnknownMigration { version })?; + apply_migration_down(connection, registry, migration).await?; + let prior = migration_for_version(registry, version - 1).ok_or( + RadrootsOutboxError::MigrationHistoryGap { + expected: version - 1, + actual: None, + }, + )?; + validate_schema_fingerprint(connection, registry, prior).await?; + let deleted = + sqlx::query("DELETE FROM main.radroots_outbox_schema_migrations WHERE version = ?") + .bind(i64::from(version)) + .execute(&mut *connection) + .await?; + if deleted.rows_affected() != 1 { + return Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: format!( + "rollback expected one ledger row for version {version}, deleted {}", + deleted.rows_affected() + ), + }); + } + } + + validate_database_integrity(connection, registry).await?; + match inspect_schema_on_connection(connection, registry, supported_current).await? { + RadrootsOutboxSchemaStatus::Managed { version } if version == target => Ok(()), + status => Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: format!("rollback completed in unexpected state {status:?}"), + }), + } +} + +#[cfg(test)] +async fn destroy_schema_on_connection( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsOutboxError> { + match inspect_schema_on_connection( + connection, + OUTBOX_MIGRATIONS, + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + ) + .await? + { + RadrootsOutboxSchemaStatus::Managed { version } => { + for version in (RADROOTS_OUTBOX_SCHEMA_VERSION_MIN..=version).rev() { + let migration = migration_for_version(OUTBOX_MIGRATIONS, version) + .ok_or(RadrootsOutboxError::UnknownMigration { version })?; + apply_migration_down(connection, OUTBOX_MIGRATIONS, migration).await?; + let deleted = sqlx::query( + "DELETE FROM main.radroots_outbox_schema_migrations WHERE version = ?", + ) + .bind(i64::from(version)) + .execute(&mut *connection) + .await?; + if deleted.rows_affected() != 1 { + return Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: format!( + "test destruction expected one ledger row for version {version}, deleted {}", + deleted.rows_affected() + ), + }); + } + } + validate_empty_governed_catalog(connection, OUTBOX_MIGRATIONS).await?; + sqlx::query("DROP TABLE main.radroots_outbox_schema_migrations") + .execute(&mut *connection) + .await?; + } + RadrootsOutboxSchemaStatus::UnledgeredBaseline => { + apply_migration_down(connection, OUTBOX_MIGRATIONS, &OUTBOX_MIGRATIONS[0]).await?; + validate_empty_governed_catalog(connection, OUTBOX_MIGRATIONS).await?; + } + RadrootsOutboxSchemaStatus::Uninitialized => {} + } + validate_database_integrity(connection, OUTBOX_MIGRATIONS).await +} + +async fn apply_migration_up( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + migration: &OutboxMigration, +) -> Result<(), RadrootsOutboxError> { + let before = read_catalog_bounded(connection, registry).await?; + sqlx::raw_sql(migration.up_sql) + .execute(&mut *connection) + .await?; + let after = read_catalog_bounded(connection, registry).await?; + validate_catalog_delta(&before, &after, migration, "up")?; + validate_schema_fingerprint(connection, registry, migration).await +} + +async fn apply_migration_down( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + migration: &OutboxMigration, +) -> Result<(), RadrootsOutboxError> { + let before = read_catalog_bounded(connection, registry).await?; + sqlx::raw_sql(migration.down_sql) + .execute(&mut *connection) + .await?; + let after = read_catalog_bounded(connection, registry).await?; + validate_catalog_delta(&before, &after, migration, "down") +} + +fn validate_catalog_delta( + before: &[CatalogRow], + after: &[CatalogRow], + migration: &OutboxMigration, + direction: &'static str, +) -> Result<(), RadrootsOutboxError> { + let before = before + .iter() + .map(|row| (row.name.as_str(), row)) + .collect::<BTreeMap<_, _>>(); + let after = after + .iter() + .map(|row| (row.name.as_str(), row)) + .collect::<BTreeMap<_, _>>(); + let added = after + .keys() + .filter(|name| !before.contains_key(**name)) + .copied() + .collect::<BTreeSet<_>>(); + let removed = before + .keys() + .filter(|name| !after.contains_key(**name)) + .copied() + .collect::<BTreeSet<_>>(); + let changed = before + .iter() + .filter_map(|(name, row)| { + after + .get(name) + .filter(|after_row| *after_row != row) + .map(|_| *name) + }) + .collect::<BTreeSet<_>>(); + let expected = migration + .owned_object_names + .iter() + .copied() + .collect::<BTreeSet<_>>(); + let valid = match direction { + "up" => added == expected && removed.is_empty() && changed.is_empty(), + "down" => removed == expected && added.is_empty() && changed.is_empty(), + _ => false, + }; + if !valid { + return Err(RadrootsOutboxError::MigrationCatalogDeltaMismatch { + version: migration.version, + direction, + reason: format!( + "expected {expected:?}; added {added:?}, removed {removed:?}, changed {changed:?}" + ), + }); + } + Ok(()) +} + +async fn create_ledger(connection: &mut SqliteConnection) -> Result<(), RadrootsOutboxError> { + sqlx::query(OUTBOX_LEDGER_CREATE_DDL) + .execute(&mut *connection) + .await?; + validate_ledger_catalog(&read_catalog_bounded(connection, OUTBOX_MIGRATIONS).await?)?; + Ok(()) +} + +async fn insert_ledger_row( + connection: &mut SqliteConnection, + migration: &OutboxMigration, +) -> Result<(), RadrootsOutboxError> { + sqlx::query( + "INSERT INTO main.radroots_outbox_schema_migrations(version, name, up_sha256, down_sha256, schema_sha256) VALUES (?, ?, ?, ?, ?)", + ) + .bind(i64::from(migration.version)) + .bind(migration.name) + .bind(migration.up_sha256) + .bind(migration.down_sha256) + .bind(migration.schema_sha256) + .execute(&mut *connection) + .await?; + Ok(()) +} + +async fn inspect_schema_on_connection( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + supported_current: u32, +) -> Result<RadrootsOutboxSchemaStatus, RadrootsOutboxError> { + validate_outbox_temp_schema_with_registry(connection, registry).await?; + let catalog = read_catalog_bounded(connection, registry).await?; + let has_ledger = validate_ledger_catalog(&catalog)?; + let governed = governed_catalog(&catalog, registry); + let actual_schema_sha256 = catalog_fingerprint(&governed); + + if !has_ledger { + if governed.is_empty() { + return Ok(RadrootsOutboxSchemaStatus::Uninitialized); + } + let baseline = &registry[0]; + if governed.len() == baseline.owned_object_names.len() + && actual_schema_sha256 == baseline.schema_sha256 + { + return Ok(RadrootsOutboxSchemaStatus::UnledgeredBaseline); + } + return Err(RadrootsOutboxError::UnmanagedSchema { + actual_schema_sha256, + }); + } + + let history = read_history_bounded(connection, supported_current).await?; + let current = validate_history_against_registry(&history, registry, supported_current)?; + let expected = migration_for_version(registry, current) + .ok_or(RadrootsOutboxError::UnknownMigration { version: current })?; + if actual_schema_sha256 != expected.schema_sha256 { + return Err(RadrootsOutboxError::SchemaFingerprintMismatch { + version: current, + expected: expected.schema_sha256, + actual: actual_schema_sha256, + }); + } + Ok(RadrootsOutboxSchemaStatus::Managed { version: current }) +} + +pub(crate) async fn validate_outbox_temp_schema( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsOutboxError> { + validate_outbox_temp_schema_with_registry(connection, OUTBOX_MIGRATIONS).await +} + +async fn validate_outbox_temp_schema_with_registry( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], +) -> Result<(), RadrootsOutboxError> { + let collision = sqlx::query( + "SELECT type, name, tbl_name FROM temp.sqlite_schema + WHERE type IN ('trigger', 'view') + OR lower(substr(name, 1, length(?))) = lower(?) + OR lower(substr(tbl_name, 1, length(?))) = lower(?) + OR name = ? COLLATE NOCASE + OR tbl_name = ? COLLATE NOCASE + ORDER BY type, name, tbl_name LIMIT 1", + ) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_LEDGER_NAME) + .bind(OUTBOX_LEDGER_NAME) + .fetch_optional(&mut *connection) + .await?; + if let Some(row) = collision { + let object_type: String = row.try_get("type")?; + let name: String = row.try_get("name")?; + let table_name: String = row.try_get("tbl_name")?; + if matches!(object_type.as_str(), "trigger" | "view") + || is_outbox_governed_schema_name(registry, &name) + || is_outbox_governed_schema_name(registry, &table_name) + { + return Err(RadrootsOutboxError::TemporarySchemaCollision { + object_type, + name, + table_name, + }); + } + } + Ok(()) +} + +fn catalog_row_limit(registry: &[OutboxMigration]) -> Result<i64, RadrootsOutboxError> { + let max = registry + .iter() + .flat_map(|migration| migration.owned_object_names.iter().copied()) + .collect::<BTreeSet<_>>() + .len() + .checked_add(1) + .ok_or_else(|| RadrootsOutboxError::MigrationRegistryDefect { + reason: "governed catalog object limit overflow".to_owned(), + })?; + i64::try_from(max.checked_add(1).ok_or_else(|| { + RadrootsOutboxError::MigrationRegistryDefect { + reason: "governed catalog collision limit overflow".to_owned(), + } + })?) + .map_err(|_| RadrootsOutboxError::MigrationRegistryDefect { + reason: "governed catalog object limit is outside SQLite range".to_owned(), + }) +} + +async fn read_catalog_bounded( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], +) -> Result<Vec<CatalogRow>, RadrootsOutboxError> { + let row_limit = catalog_row_limit(registry)?; + let rows = sqlx::query( + "SELECT type, name, tbl_name, sql FROM main.sqlite_schema + WHERE lower(substr(name, 1, 7)) != 'sqlite_' + AND (lower(substr(name, 1, length(?))) = lower(?) + OR lower(substr(tbl_name, 1, length(?))) = lower(?) + OR name = ? COLLATE NOCASE + OR tbl_name = ? COLLATE NOCASE) + ORDER BY type, name, tbl_name LIMIT ?", + ) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_LEDGER_NAME) + .bind(OUTBOX_LEDGER_NAME) + .bind(row_limit) + .fetch_all(&mut *connection) + .await?; + if i64::try_from(rows.len()).ok() == Some(row_limit) { + return Err(RadrootsOutboxError::GovernedCatalogCapacityExceeded { + max: usize::try_from(row_limit - 1).unwrap_or(usize::MAX), + }); + } + rows.into_iter() + .map(|row| { + Ok(CatalogRow { + object_type: row.try_get("type")?, + name: row.try_get("name")?, + table_name: row.try_get("tbl_name")?, + sql: row.try_get("sql")?, + }) + }) + .collect() +} + +fn validate_ledger_catalog(catalog: &[CatalogRow]) -> Result<bool, RadrootsOutboxError> { + let rows = catalog + .iter() + .filter(|row| { + row.name.eq_ignore_ascii_case(OUTBOX_LEDGER_NAME) + || row.table_name.eq_ignore_ascii_case(OUTBOX_LEDGER_NAME) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return Ok(false); + } + if rows.len() != 1 { + return Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: format!( + "expected exactly one non-internal ledger catalog object, found {}", + rows.len() + ), + }); + } + let row = rows[0]; + if row.object_type != "table" + || row.name != OUTBOX_LEDGER_NAME + || row.table_name != OUTBOX_LEDGER_NAME + || row.sql.as_deref() != Some(OUTBOX_LEDGER_DDL) + { + return Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: "ledger table definition does not match canonical catalog SQL".to_owned(), + }); + } + Ok(true) +} + +fn governed_catalog(catalog: &[CatalogRow], registry: &[OutboxMigration]) -> Vec<CatalogRow> { + catalog + .iter() + .filter(|row| !row.name.eq_ignore_ascii_case(OUTBOX_LEDGER_NAME)) + .filter(|row| { + is_outbox_governed_schema_name(registry, &row.name) + || is_outbox_governed_schema_name(registry, &row.table_name) + }) + .cloned() + .collect() +} + +fn catalog_fingerprint(catalog: &[CatalogRow]) -> String { + let mut rows = catalog.to_vec(); + rows.sort_by(|left, right| { + ( + left.object_type.as_bytes(), + left.name.as_bytes(), + left.table_name.as_bytes(), + left.sql.as_deref().unwrap_or("").as_bytes(), + ) + .cmp(&( + right.object_type.as_bytes(), + right.name.as_bytes(), + right.table_name.as_bytes(), + right.sql.as_deref().unwrap_or("").as_bytes(), + )) + }); + let mut digest = Sha256::new(); + for row in rows { + for field in [ + row.object_type.as_str(), + row.name.as_str(), + row.table_name.as_str(), + row.sql.as_deref().unwrap_or(""), + ] { + digest.update(field.as_bytes()); + digest.update([0]); + } + } + hex::encode(digest.finalize()) +} + +async fn read_history_bounded( + connection: &mut SqliteConnection, + supported_current: u32, +) -> Result<Vec<AppliedMigration>, RadrootsOutboxError> { + let row_limit = i64::from(supported_current).checked_add(1).ok_or_else(|| { + RadrootsOutboxError::MigrationRegistryDefect { + reason: "migration history row limit overflow".to_owned(), + } + })?; + let rows = sqlx::query( + "SELECT version, name, up_sha256, down_sha256, schema_sha256 + FROM main.radroots_outbox_schema_migrations + ORDER BY version LIMIT ?", + ) + .bind(row_limit) + .fetch_all(&mut *connection) + .await?; + rows.into_iter() + .map(|row| { + Ok(AppliedMigration { + version: row.try_get("version")?, + name: row.try_get("name")?, + up_sha256: row.try_get("up_sha256")?, + down_sha256: row.try_get("down_sha256")?, + schema_sha256: row.try_get("schema_sha256")?, + }) + }) + .collect() +} + +fn validate_history_against_registry( + history: &[AppliedMigration], + registry: &[OutboxMigration], + supported_current: u32, +) -> Result<u32, RadrootsOutboxError> { + if history.is_empty() { + return Err(RadrootsOutboxError::MigrationLedgerDrift { + reason: "ledger exists without migration history".to_owned(), + }); + } + let database_version = history + .iter() + .map(|row| row.version) + .max() + .unwrap_or_default(); + if database_version > i64::from(supported_current) { + return Err(RadrootsOutboxError::SchemaTooNew { + current: supported_current, + database: database_version, + }); + } + let mut expected_version = registry[0].version; + for row in history { + let version = + u32::try_from(row.version).map_err(|_| RadrootsOutboxError::MigrationLedgerDrift { + reason: format!( + "ledger version {} is outside the positive range", + row.version + ), + })?; + if version != expected_version { + return Err(RadrootsOutboxError::MigrationHistoryGap { + expected: expected_version, + actual: Some(version), + }); + } + let migration = migration_for_version(registry, version) + .ok_or(RadrootsOutboxError::UnknownMigration { version })?; + if row.name != migration.name { + return Err(RadrootsOutboxError::MigrationHistoryNameDrift { + version, + expected: migration.name, + actual: row.name.clone(), + }); + } + validate_history_checksum(version, "up_sha256", &row.up_sha256, migration.up_sha256)?; + validate_history_checksum( + version, + "down_sha256", + &row.down_sha256, + migration.down_sha256, + )?; + validate_history_checksum( + version, + "schema_sha256", + &row.schema_sha256, + migration.schema_sha256, + )?; + expected_version = expected_version.checked_add(1).ok_or_else(|| { + RadrootsOutboxError::MigrationLedgerDrift { + reason: "migration history version overflow".to_owned(), + } + })?; + } + Ok(expected_version - 1) +} + +fn validate_history_checksum( + version: u32, + field: &'static str, + actual: &str, + expected: &'static str, +) -> Result<(), RadrootsOutboxError> { + if actual != expected { + return Err(RadrootsOutboxError::MigrationHistoryChecksumDrift { + version, + field, + expected, + actual: actual.to_owned(), + }); + } + Ok(()) +} + +async fn validate_schema_fingerprint( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], + migration: &OutboxMigration, +) -> Result<(), RadrootsOutboxError> { + let catalog = read_catalog_bounded(connection, registry).await?; + let actual = catalog_fingerprint(&governed_catalog(&catalog, registry)); + if actual != migration.schema_sha256 { + return Err(RadrootsOutboxError::SchemaFingerprintMismatch { + version: migration.version, + expected: migration.schema_sha256, + actual, + }); + } + Ok(()) +} + +#[cfg(test)] +async fn validate_empty_governed_catalog( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], +) -> Result<(), RadrootsOutboxError> { + let catalog = read_catalog_bounded(connection, registry).await?; + let actual = catalog_fingerprint(&governed_catalog(&catalog, registry)); + if actual != EMPTY_SCHEMA_SHA256 { + return Err(RadrootsOutboxError::SchemaFingerprintMismatch { + version: 0, + expected: EMPTY_SCHEMA_SHA256, + actual, + }); + } + Ok(()) +} + +async fn validate_database_integrity( + connection: &mut SqliteConnection, + registry: &[OutboxMigration], +) -> Result<(), RadrootsOutboxError> { + let detail: String = sqlx::query_scalar("PRAGMA integrity_check(1)") + .fetch_one(&mut *connection) + .await?; + if detail != "ok" { + return Err(RadrootsOutboxError::IntegrityCheckFailed { detail }); + } + + let violation = sqlx::query( + "SELECT \"table\", rowid, parent, fkid FROM pragma_foreign_key_check + WHERE lower(substr(\"table\", 1, length(?))) = lower(?) LIMIT 1", + ) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .fetch_optional(&mut *connection) + .await?; + if let Some(row) = violation { + let table: String = row.try_get("table")?; + if is_outbox_owned_table_name(registry, &table) { + return Err(RadrootsOutboxError::ForeignKeyViolation { + table, + rowid: row.try_get("rowid")?, + parent: row.try_get("parent")?, + foreign_key_id: row.try_get("fkid")?, + }); + } + } + Ok(()) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::RadrootsOutbox; + use sqlx::Connection; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + use std::sync::Arc; + use tokio::sync::Barrier; + + async fn memory_pool() -> SqlitePool { + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::from_str("sqlite::memory:").expect("options")) + .await + .expect("pool") + } + + #[tokio::test] + async fn fresh_migration_reaches_the_authenticated_schema_fingerprint() { + let pool = memory_pool().await; + migrate_outbox_schema(&pool).await.expect("fresh migration"); + assert_eq!( + inspect_outbox_schema_status(&pool) + .await + .expect("managed schema"), + RadrootsOutboxSchemaStatus::Managed { version: 1 } + ); + } + + async fn apply_unledgered_baseline(pool: &SqlitePool) { + sqlx::raw_sql(OUTBOX_MIGRATIONS[0].up_sql) + .execute(pool) + .await + .expect("unledgered baseline"); + } + + async fn ledger_count(pool: &SqlitePool) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE type = 'table' AND name = ?", + ) + .bind(OUTBOX_LEDGER_NAME) + .fetch_one(pool) + .await + .expect("ledger count") + } + + async fn synthetic_v2_registry(pool: &SqlitePool) -> [OutboxMigration; 2] { + const UP_SQL: &str = "CREATE TABLE outbox_future (value TEXT NOT NULL) STRICT;\n"; + const DOWN_SQL: &str = "DROP TABLE outbox_future;\n"; + + migrate_outbox_schema(pool).await.expect("version 1"); + sqlx::raw_sql(UP_SQL) + .execute(pool) + .await + .expect("synthetic version 2 schema"); + let catalog = sqlx::query( + "SELECT type, name, tbl_name, sql FROM main.sqlite_schema + WHERE lower(substr(name, 1, 7)) != 'sqlite_' + AND (lower(substr(name, 1, length(?))) = lower(?) + OR lower(substr(tbl_name, 1, length(?))) = lower(?))", + ) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .bind(OUTBOX_RESERVED_PREFIX) + .fetch_all(pool) + .await + .expect("synthetic version 2 catalog") + .into_iter() + .map(|row| CatalogRow { + object_type: row.try_get("type").expect("catalog type"), + name: row.try_get("name").expect("catalog name"), + table_name: row.try_get("tbl_name").expect("catalog table name"), + sql: row.try_get("sql").expect("catalog SQL"), + }) + .collect::<Vec<_>>(); + let schema_sha256 = Box::leak(catalog_fingerprint(&catalog).into_boxed_str()); + sqlx::raw_sql(DOWN_SQL) + .execute(pool) + .await + .expect("remove synthetic version 2 schema"); + + let up_sha256 = + Box::leak(crate::migrations::sha256_hex(UP_SQL.as_bytes()).into_boxed_str()); + let down_sha256 = + Box::leak(crate::migrations::sha256_hex(DOWN_SQL.as_bytes()).into_boxed_str()); + [ + OUTBOX_MIGRATIONS[0], + OutboxMigration { + version: 2, + name: "future", + up_sql: UP_SQL, + down_sql: DOWN_SQL, + up_len: UP_SQL.len(), + down_len: DOWN_SQL.len(), + up_sha256, + down_sha256, + schema_sha256, + owned_object_names: &["outbox_future"], + owned_table_names: &["outbox_future"], + }, + ] + } + + async fn inspect_with_registry( + pool: &SqlitePool, + registry: &[OutboxMigration], + supported_current: u32, + ) -> Result<RadrootsOutboxSchemaStatus, RadrootsOutboxError> { + let mut transaction = pool.begin().await?; + let result = + inspect_schema_on_connection(&mut transaction, registry, supported_current).await; + finish_schema_transaction(transaction, result).await + } + + #[tokio::test] + async fn additive_future_migration_advances_and_rolls_back_to_an_explicit_target() { + let pool = memory_pool().await; + sqlx::raw_sql( + "CREATE TABLE caller_state (value TEXT NOT NULL); + INSERT INTO caller_state(value) VALUES ('preserved');", + ) + .execute(&pool) + .await + .expect("caller state"); + let registry = synthetic_v2_registry(&pool).await; + + migrate_outbox_schema_with_registry(&pool, &registry, 1, 2) + .await + .expect("advance to version 2"); + assert_eq!( + inspect_with_registry(&pool, &registry, 2) + .await + .expect("managed version 2"), + RadrootsOutboxSchemaStatus::Managed { version: 2 } + ); + let future_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'outbox_future'", + ) + .fetch_one(&pool) + .await + .expect("future object count"); + assert_eq!(future_objects, 1); + + let mut transaction = pool + .begin_with("BEGIN EXCLUSIVE") + .await + .expect("rollback transaction"); + let result = rollback_schema_on_connection(&mut transaction, &registry, 2, 1).await; + finish_schema_transaction(transaction, result) + .await + .expect("rollback to version 1"); + assert_eq!( + inspect_with_registry(&pool, &registry, 2) + .await + .expect("managed version 1"), + RadrootsOutboxSchemaStatus::Managed { version: 1 } + ); + let future_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'outbox_future'", + ) + .fetch_one(&pool) + .await + .expect("rolled-back object count"); + assert_eq!(future_objects, 0); + let caller_value: String = sqlx::query_scalar("SELECT value FROM caller_state") + .fetch_one(&pool) + .await + .expect("preserved caller state"); + assert_eq!(caller_value, "preserved"); + + let mut transaction = pool + .begin_with("BEGIN EXCLUSIVE") + .await + .expect("ahead transaction"); + let result = rollback_schema_on_connection(&mut transaction, &registry, 2, 2).await; + assert!(matches!( + result, + Err(RadrootsOutboxError::RollbackAhead { .. }) + )); + transaction + .rollback() + .await + .expect("rollback ahead fixture"); + + let unmanaged = memory_pool().await; + let mut transaction = unmanaged + .begin_with("BEGIN EXCLUSIVE") + .await + .expect("unmanaged transaction"); + let result = rollback_schema_on_connection(&mut transaction, &registry, 2, 1).await; + assert!(matches!( + result, + Err(RadrootsOutboxError::RollbackUnmanaged) + )); + transaction + .rollback() + .await + .expect("rollback unmanaged fixture"); + } + + #[tokio::test] + async fn post_up_authority_failure_rolls_back_catalog_ledger_and_caller_state() { + let pool = memory_pool().await; + sqlx::raw_sql( + "CREATE TABLE caller_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO caller_state(key, value) VALUES ('victoria', 'preserved');", + ) + .execute(&pool) + .await + .expect("caller state"); + let mut registry = synthetic_v2_registry(&pool).await; + + let mut connection = pool.acquire().await.expect("catalog connection"); + let before_catalog = read_catalog_bounded(&mut connection, &registry) + .await + .expect("before catalog"); + let before_fingerprint = catalog_fingerprint(&governed_catalog(&before_catalog, &registry)); + let before_history = read_history_bounded(&mut connection, 2) + .await + .expect("before history"); + drop(connection); + + registry[1].owned_object_names = &["outbox_expected"]; + registry[1].owned_table_names = &["outbox_expected"]; + let error = migrate_outbox_schema_with_registry(&pool, &registry, 1, 2) + .await + .expect_err("post-UP catalog delta must fail"); + assert!(matches!( + error, + RadrootsOutboxError::MigrationCatalogDeltaMismatch { + version: 2, + direction: "up", + .. + } + )); + + let mut connection = pool.acquire().await.expect("verification connection"); + let after_catalog = read_catalog_bounded(&mut connection, &registry) + .await + .expect("after catalog"); + let after_fingerprint = catalog_fingerprint(&governed_catalog(&after_catalog, &registry)); + let after_history = read_history_bounded(&mut connection, 2) + .await + .expect("after history"); + drop(connection); + assert_eq!(after_fingerprint, before_fingerprint); + assert_eq!(after_history, before_history); + assert_eq!( + inspect_outbox_schema_status(&pool) + .await + .expect("restored managed schema"), + RadrootsOutboxSchemaStatus::Managed { version: 1 } + ); + let future_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'outbox_future'", + ) + .fetch_one(&pool) + .await + .expect("rolled-back future object count"); + assert_eq!(future_objects, 0); + let caller_value: String = + sqlx::query_scalar("SELECT value FROM caller_state WHERE key = 'victoria'") + .fetch_one(&pool) + .await + .expect("preserved caller row"); + assert_eq!(caller_value, "preserved"); + } + + #[tokio::test] + async fn exact_unledgered_baseline_is_adopted_without_replaying_or_losing_caller_state() { + let pool = memory_pool().await; + sqlx::query("CREATE TABLE caller_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + .execute(&pool) + .await + .expect("caller table"); + sqlx::query("INSERT INTO caller_state(key, value) VALUES ('victoria', 'preserved')") + .execute(&pool) + .await + .expect("caller row"); + apply_unledgered_baseline(&pool).await; + sqlx::query( + "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) + VALUES ('post', 'author', 'generic_event', NULL, NULL, NULL, NULL, ?, 'queued', 1, 1)", + ) + .bind("a".repeat(64)) + .execute(&pool) + .await + .expect("legacy row"); + + assert_eq!( + inspect_outbox_schema_status(&pool) + .await + .expect("unledgered status"), + RadrootsOutboxSchemaStatus::UnledgeredBaseline + ); + migrate_outbox_schema(&pool).await.expect("adoption"); + assert_eq!(ledger_count(&pool).await, 1); + let caller: String = + sqlx::query_scalar("SELECT value FROM caller_state WHERE key = 'victoria'") + .fetch_one(&pool) + .await + .expect("caller row preserved"); + assert_eq!(caller, "preserved"); + let operations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox_operations") + .fetch_one(&pool) + .await + .expect("legacy row count"); + assert_eq!(operations, 1); + } + + #[tokio::test] + async fn test_only_destruction_handles_unledgered_and_uninitialized_schemas() { + let pool = memory_pool().await; + apply_unledgered_baseline(&pool).await; + destroy_outbox_schema_for_migration_test(&pool) + .await + .expect("destroy unledgered baseline"); + assert_eq!( + inspect_outbox_schema_status(&pool) + .await + .expect("uninitialized after destruction"), + RadrootsOutboxSchemaStatus::Uninitialized + ); + destroy_outbox_schema_for_migration_test(&pool) + .await + .expect("destroy uninitialized schema"); + } + + #[tokio::test] + async fn fresh_initialization_preserves_unrelated_caller_schema_and_rows() { + let pool = memory_pool().await; + sqlx::raw_sql( + "CREATE TABLE caller_table (value TEXT NOT NULL); + INSERT INTO caller_table(value) VALUES ('keep'); + CREATE INDEX caller_table_value_idx ON caller_table(value);", + ) + .execute(&pool) + .await + .expect("caller schema"); + migrate_outbox_schema(&pool).await.expect("migration"); + let value: String = sqlx::query_scalar("SELECT value FROM caller_table") + .fetch_one(&pool) + .await + .expect("caller row"); + assert_eq!(value, "keep"); + let index_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'caller_table_value_idx'", + ) + .fetch_one(&pool) + .await + .expect("caller index"); + assert_eq!(index_count, 1); + } + + #[tokio::test] + async fn partial_changed_and_unknown_unledgered_outbox_catalogs_fail_before_adoption() { + for mutation in [ + "DROP INDEX outbox_event_event_id_idx", + "DROP INDEX outbox_event_event_id_idx; CREATE INDEX outbox_event_event_id_idx ON outbox_event(expected_pubkey)", + "CREATE TABLE outbox_counterfeit (value TEXT NOT NULL)", + ] { + let pool = memory_pool().await; + apply_unledgered_baseline(&pool).await; + sqlx::raw_sql(mutation) + .execute(&pool) + .await + .expect("catalog mutation"); + assert!(matches!( + migrate_outbox_schema(&pool).await, + Err(RadrootsOutboxError::UnmanagedSchema { .. }) + )); + assert_eq!(ledger_count(&pool).await, 0); + } + } + + #[tokio::test] + async fn counterfeit_ledger_shape_fails_before_any_outbox_schema_mutation() { + let pool = memory_pool().await; + sqlx::query("CREATE TABLE radroots_outbox_schema_migrations (version INTEGER)") + .execute(&pool) + .await + .expect("counterfeit ledger"); + assert!(matches!( + migrate_outbox_schema(&pool).await, + Err(RadrootsOutboxError::MigrationLedgerDrift { .. }) + )); + let outbox_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE lower(substr(name, 1, 7)) = 'outbox_'", + ) + .fetch_one(&pool) + .await + .expect("outbox object count"); + assert_eq!(outbox_objects, 0); + } + + #[tokio::test] + async fn ledger_name_checksum_and_catalog_mutations_fail_closed() { + for statement in [ + "UPDATE radroots_outbox_schema_migrations SET name = 'counterfeit' WHERE version = 1", + "UPDATE radroots_outbox_schema_migrations SET up_sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' WHERE version = 1", + "UPDATE radroots_outbox_schema_migrations SET schema_sha256 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' WHERE version = 1", + "DROP INDEX outbox_event_event_id_idx; CREATE INDEX outbox_event_event_id_idx ON outbox_event(expected_pubkey)", + ] { + let pool = memory_pool().await; + migrate_outbox_schema(&pool).await.expect("migration"); + sqlx::raw_sql(statement) + .execute(&pool) + .await + .expect("managed mutation"); + assert!(migrate_outbox_schema(&pool).await.is_err(), "{statement}"); + let rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_outbox_schema_migrations") + .fetch_one(&pool) + .await + .expect("ledger rows"); + assert_eq!(rows, 1); + } + } + + #[tokio::test] + async fn newer_history_and_governed_catalog_overflow_are_bounded_and_rejected() { + let newer = memory_pool().await; + migrate_outbox_schema(&newer).await.expect("migration"); + sqlx::query( + "INSERT INTO radroots_outbox_schema_migrations(version, name, up_sha256, down_sha256, schema_sha256) + VALUES (2, 'future', ?, ?, ?)", + ) + .bind("a".repeat(64)) + .bind("b".repeat(64)) + .bind("c".repeat(64)) + .execute(&newer) + .await + .expect("future history"); + assert!(matches!( + inspect_outbox_schema_status(&newer).await, + Err(RadrootsOutboxError::SchemaTooNew { + current: 1, + database: 2 + }) + )); + + let overflow = memory_pool().await; + migrate_outbox_schema(&overflow).await.expect("migration"); + sqlx::query("CREATE TABLE outbox_unknown (value TEXT)") + .execute(&overflow) + .await + .expect("unknown governed object"); + assert!(matches!( + inspect_outbox_schema_status(&overflow).await, + Err(RadrootsOutboxError::GovernedCatalogCapacityExceeded { max: 14 }) + )); + } + + #[test] + fn history_validation_rejects_gaps_and_unknown_versions() { + let row = |version, migration: &OutboxMigration| AppliedMigration { + version, + name: migration.name.to_owned(), + up_sha256: migration.up_sha256.to_owned(), + down_sha256: migration.down_sha256.to_owned(), + schema_sha256: migration.schema_sha256.to_owned(), + }; + assert!(matches!( + validate_history_against_registry( + &[row(2, &OUTBOX_MIGRATIONS[0])], + OUTBOX_MIGRATIONS, + 3 + ), + Err(RadrootsOutboxError::MigrationHistoryGap { + expected: 1, + actual: Some(2) + }) + )); + assert!(matches!( + validate_history_against_registry( + &[row(1, &OUTBOX_MIGRATIONS[0]), row(2, &OUTBOX_MIGRATIONS[0])], + OUTBOX_MIGRATIONS, + 3, + ), + Err(RadrootsOutboxError::UnknownMigration { version: 2 }) + )); + + assert!(matches!( + validate_history_against_registry(&[], OUTBOX_MIGRATIONS, 1), + Err(RadrootsOutboxError::MigrationLedgerDrift { .. }) + )); + assert!(matches!( + validate_history_against_registry( + &[row(-1, &OUTBOX_MIGRATIONS[0])], + OUTBOX_MIGRATIONS, + 1 + ), + Err(RadrootsOutboxError::MigrationLedgerDrift { .. }) + )); + assert_eq!( + validate_history_against_registry( + &[row(1, &OUTBOX_MIGRATIONS[0])], + OUTBOX_MIGRATIONS, + 1 + ) + .expect("canonical history"), + 1 + ); + + let mut name_drift = row(1, &OUTBOX_MIGRATIONS[0]); + name_drift.name = "counterfeit".to_owned(); + assert!(matches!( + validate_history_against_registry(&[name_drift], OUTBOX_MIGRATIONS, 1), + Err(RadrootsOutboxError::MigrationHistoryNameDrift { .. }) + )); + for field in ["up_sha256", "down_sha256", "schema_sha256"] { + let mut checksum_drift = row(1, &OUTBOX_MIGRATIONS[0]); + match field { + "up_sha256" => checksum_drift.up_sha256 = "a".repeat(64), + "down_sha256" => checksum_drift.down_sha256 = "a".repeat(64), + "schema_sha256" => checksum_drift.schema_sha256 = "a".repeat(64), + _ => unreachable!(), + } + assert!(matches!( + validate_history_against_registry(&[checksum_drift], OUTBOX_MIGRATIONS, 1), + Err(RadrootsOutboxError::MigrationHistoryChecksumDrift { + field: actual_field, + .. + }) if actual_field == field + )); + } + } + + fn catalog_row( + object_type: &str, + name: &str, + table_name: &str, + sql: Option<&str>, + ) -> CatalogRow { + CatalogRow { + object_type: object_type.to_owned(), + name: name.to_owned(), + table_name: table_name.to_owned(), + sql: sql.map(str::to_owned), + } + } + + #[test] + fn catalog_delta_and_ledger_validators_fail_closed() { + let mut migration = OUTBOX_MIGRATIONS[0]; + migration.version = 2; + migration.owned_object_names = &["outbox_future"]; + migration.owned_table_names = &["outbox_future"]; + let future = catalog_row( + "table", + "outbox_future", + "outbox_future", + Some("CREATE TABLE outbox_future (value TEXT)"), + ); + validate_catalog_delta(&[], std::slice::from_ref(&future), &migration, "up") + .expect("additive delta"); + validate_catalog_delta(std::slice::from_ref(&future), &[], &migration, "down") + .expect("rollback delta"); + assert!(matches!( + validate_catalog_delta(&[], std::slice::from_ref(&future), &migration, "sideways"), + Err(RadrootsOutboxError::MigrationCatalogDeltaMismatch { .. }) + )); + let changed = catalog_row( + "table", + "outbox_future", + "outbox_future", + Some("CREATE TABLE outbox_future (changed TEXT)"), + ); + assert!(matches!( + validate_catalog_delta( + std::slice::from_ref(&future), + std::slice::from_ref(&changed), + &migration, + "up" + ), + Err(RadrootsOutboxError::MigrationCatalogDeltaMismatch { .. }) + )); + + assert!(!validate_ledger_catalog(&[]).expect("absent ledger")); + let canonical = catalog_row( + "table", + OUTBOX_LEDGER_NAME, + OUTBOX_LEDGER_NAME, + Some(OUTBOX_LEDGER_DDL), + ); + assert!(validate_ledger_catalog(std::slice::from_ref(&canonical)).expect("ledger")); + assert!(matches!( + validate_ledger_catalog(&[canonical.clone(), canonical.clone()]), + Err(RadrootsOutboxError::MigrationLedgerDrift { .. }) + )); + for counterfeit in [ + catalog_row( + "view", + OUTBOX_LEDGER_NAME, + OUTBOX_LEDGER_NAME, + Some(OUTBOX_LEDGER_DDL), + ), + catalog_row( + "table", + "counterfeit", + OUTBOX_LEDGER_NAME, + Some(OUTBOX_LEDGER_DDL), + ), + catalog_row( + "table", + OUTBOX_LEDGER_NAME, + "counterfeit", + Some(OUTBOX_LEDGER_DDL), + ), + catalog_row( + "table", + OUTBOX_LEDGER_NAME, + OUTBOX_LEDGER_NAME, + Some("counterfeit"), + ), + ] { + assert!(matches!( + validate_ledger_catalog(&[counterfeit]), + Err(RadrootsOutboxError::MigrationLedgerDrift { .. }) + )); + } + } + + #[tokio::test] + async fn temporary_authority_collisions_are_rejected_before_migration() { + for sql in [ + "CREATE TEMP TABLE outbox_event (value TEXT)", + "CREATE TEMP TABLE radroots_outbox_schema_migrations (value TEXT)", + "CREATE TEMP VIEW caller_view AS SELECT 1 AS value", + ] { + let pool = memory_pool().await; + sqlx::raw_sql(sql) + .execute(&pool) + .await + .expect("temp fixture"); + assert!(matches!( + migrate_outbox_schema(&pool).await, + Err(RadrootsOutboxError::TemporarySchemaCollision { .. }) + )); + assert_eq!(ledger_count(&pool).await, 0); + } + } + + #[tokio::test] + async fn current_schema_reopen_is_idempotent_and_does_not_rewrite_history() { + let pool = memory_pool().await; + migrate_outbox_schema(&pool).await.expect("first migration"); + let before_changes: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(&pool) + .await + .expect("before total changes"); + migrate_outbox_schema(&pool) + .await + .expect("current migration"); + let after_changes: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(&pool) + .await + .expect("after total changes"); + assert_eq!(before_changes, after_changes); + assert_eq!(ledger_count(&pool).await, 1); + } + + #[tokio::test] + async fn concurrent_file_initializers_serialize_to_one_exact_history() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("concurrent-outbox.sqlite"); + let participants = 6; + let barrier = Arc::new(Barrier::new(participants)); + let mut tasks = Vec::new(); + for _ in 0..participants { + let barrier = Arc::clone(&barrier); + let path = path.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let store = RadrootsOutbox::open_file(path).await?; + store.schema_status().await + })); + } + for task in tasks { + assert_eq!( + task.await.expect("initializer task").expect("initializer"), + RadrootsOutboxSchemaStatus::Managed { version: 1 } + ); + } + let store = RadrootsOutbox::open_file(&path) + .await + .expect("verification open"); + let history: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_outbox_schema_migrations") + .fetch_one(store.pool()) + .await + .expect("history count"); + assert_eq!(history, 1); + } + + #[tokio::test] + async fn terminal_target_rollback_preserves_v1_rows_and_closes_every_clone() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("rollback-outbox.sqlite"); + let store = RadrootsOutbox::open_file(&path).await.expect("open"); + sqlx::query( + "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) + VALUES ('post', 'author', 'generic_event', NULL, NULL, NULL, NULL, ?, 'queued', 1, 1)", + ) + .bind("a".repeat(64)) + .execute(store.pool()) + .await + .expect("queued row"); + let clone = store.clone(); + store + .rollback_to_schema_version_and_close(1) + .await + .expect("rollback to current floor"); + assert!(clone.pool().is_closed()); + + let reopened = RadrootsOutbox::open_file(&path).await.expect("reopen"); + let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox_operations") + .fetch_one(reopened.pool()) + .await + .expect("preserved queued rows"); + assert_eq!(rows, 1); + assert!(matches!( + rollback_outbox_schema_offline(reopened.pool(), 2).await, + Err(RadrootsOutboxError::RollbackAhead { + current: 1, + target: 2 + }) + )); + } + + #[tokio::test] + async fn reopen_rejects_outbox_foreign_key_corruption() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("foreign-key-outbox.sqlite"); + let store = RadrootsOutbox::open_file(&path).await.expect("open"); + store.pool().close().await; + + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .expect("corruption connection"); + sqlx::query( + "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, signed_event_json, raw_event_json, state, attempt_count, claim_token, claim_owner, claim_expires_at_ms, active_delivery_plan_id, next_attempt_after_ms, last_error, event_store_ingested, event_store_inserted, event_store_ingested_at_ms, created_at_ms, updated_at_ms) + VALUES (999, 'event', 'author', '{}', NULL, NULL, 'draft_queued', 0, NULL, NULL, NULL, NULL, 0, NULL, 0, 0, NULL, 0, 0)", + ) + .execute(&mut connection) + .await + .expect("invalid child row"); + connection + .close() + .await + .expect("close corruption connection"); + + assert!(matches!( + RadrootsOutbox::open_file(&path).await, + Err(RadrootsOutboxError::ForeignKeyViolation { table, .. }) + if table == "outbox_event" + )); + } + + #[tokio::test] + async fn open_rejects_utf16_before_journal_or_schema_mutation() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("utf16-outbox.sqlite"); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("UTF-16 fixture connection"); + sqlx::query("PRAGMA main.encoding = 'UTF-16le'") + .execute(&mut connection) + .await + .expect("set UTF-16"); + sqlx::query("CREATE TABLE encoding_anchor (value TEXT NOT NULL)") + .execute(&mut connection) + .await + .expect("materialize UTF-16"); + sqlx::query("DROP TABLE encoding_anchor") + .execute(&mut connection) + .await + .expect("empty UTF-16 catalog"); + connection.close().await.expect("close fixture"); + + assert!(matches!( + RadrootsOutbox::open_file(&path).await, + Err(RadrootsOutboxError::SqliteMainDatabaseEncodingNotUtf8 { actual }) + if actual == "UTF-16le" + )); + let mut verifier = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&path)) + .await + .expect("verifier"); + let encoding: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut verifier) + .await + .expect("encoding"); + let journal: String = sqlx::query_scalar("PRAGMA main.journal_mode") + .fetch_one(&mut verifier) + .await + .expect("journal"); + let objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE lower(substr(name, 1, 7)) = 'outbox_' OR name = ?", + ) + .bind(OUTBOX_LEDGER_NAME) + .fetch_one(&mut verifier) + .await + .expect("outbox objects"); + assert_eq!(encoding, "UTF-16le"); + assert_eq!(journal, "delete"); + assert_eq!(objects, 0); + } +} diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -1,7 +1,6 @@ #![forbid(unsafe_code)] use crate::RadrootsOutboxError; -use crate::migrations::{OUTBOX_MIGRATION_DOWN, OUTBOX_MIGRATION_UP}; use crate::model::{ RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryAttemptRecord, RadrootsOutboxDeliveryPlanInput, RadrootsOutboxDeliveryPlanRecord, @@ -14,6 +13,12 @@ use crate::model::{ RadrootsOutboxSignedTradeMutationInput, RadrootsOutboxStatusSummary, RadrootsOutboxTradeMutationInput, }; +#[cfg(test)] +use crate::schema::destroy_outbox_schema_for_migration_test; +use crate::schema::{ + RadrootsOutboxSchemaStatus, inspect_outbox_schema_status, migrate_outbox_schema, + rollback_outbox_schema_offline, +}; use radroots_event::RadrootsEventKindClass; use radroots_event::draft::{ RadrootsEventDraft, RadrootsSignedEvent, validate_signed_nostr_event_matches_draft, @@ -34,10 +39,10 @@ use radroots_transport::{ }; use serde::Serialize; use sha2::{Digest, Sha256}; -use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult}; #[cfg(test)] -use sqlx::{Connection, SqliteConnection}; -use sqlx::{Row, SqlitePool}; +use sqlx::Connection; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult}; +use sqlx::{Row, SqliteConnection, SqlitePool}; use std::collections::BTreeSet; use std::path::Path; use std::str::FromStr; @@ -56,7 +61,7 @@ impl RadrootsOutbox { .connect_with(options) .await?; configure_pool(&pool, false).await?; - apply_up(&pool).await?; + migrate_outbox_schema(&pool).await?; Ok(Self { pool }) } @@ -69,7 +74,7 @@ impl RadrootsOutbox { .connect_with(options) .await?; configure_pool(&pool, true).await?; - apply_up(&pool).await?; + migrate_outbox_schema(&pool).await?; Ok(Self { pool }) } @@ -78,16 +83,43 @@ impl RadrootsOutbox { file_backed: bool, ) -> Result<Self, RadrootsOutboxError> { configure_pool(&pool, file_backed).await?; - apply_up(&pool).await?; + migrate_outbox_schema(&pool).await?; Ok(Self { pool }) } + /// Returns the fully trusted database-authority escape hatch. + /// + /// Arbitrary SQL can invalidate the managed outbox schema. Prefer the + /// typed outbox methods for ordinary writes. pub fn pool(&self) -> &SqlitePool { &self.pool } - pub async fn migrate_down(&self) -> Result<(), RadrootsOutboxError> { - apply_down(&self.pool).await + /// Inspects and authenticates the current managed schema state. + pub async fn schema_status(&self) -> Result<RadrootsOutboxSchemaStatus, RadrootsOutboxError> { + inspect_outbox_schema_status(&self.pool).await + } + + /// Serializes schema adoption or migration to the current supported version. + pub async fn migrate_to_current_schema(&self) -> Result<(), RadrootsOutboxError> { + migrate_outbox_schema(&self.pool).await + } + + /// Closes every clone after attempting an exclusive, target-version rollback. + /// + /// Independent pools for the same file must be quiesced by the caller. + pub async fn rollback_to_schema_version_and_close( + self, + target: u32, + ) -> Result<(), RadrootsOutboxError> { + let result = rollback_outbox_schema_offline(&self.pool, target).await; + self.pool.close().await; + result + } + + #[cfg(test)] + async fn destroy_schema_for_migration_test(&self) -> Result<(), RadrootsOutboxError> { + destroy_outbox_schema_for_migration_test(&self.pool).await } pub async fn pragma_foreign_keys(&self) -> Result<i64, RadrootsOutboxError> { @@ -1805,55 +1837,124 @@ fn publish_lifecycle_from_plan_evaluation<'a>( async fn configure_pool(pool: &SqlitePool, file_backed: bool) -> Result<(), RadrootsOutboxError> { let max_connections = pool.options().get_max_connections(); - let existing_options = pool.connect_options(); - let main_filename: String = - sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main'") - .fetch_one(pool) - .await?; - let database_is_memory = main_filename.is_empty(); - if file_backed == database_is_memory { - return Err(RadrootsOutboxError::SqlitePoolBackingMismatch { - file_backed, - filename: main_filename, - }); - } if !file_backed && max_connections != 1 { return Err(RadrootsOutboxError::UnsafeInMemoryPoolConnectionCount { actual: max_connections, }); } - let mut connect_options = existing_options - .as_ref() - .clone() - .foreign_keys(true) - .busy_timeout(Duration::from_millis(5_000)); - if file_backed { - connect_options = connect_options.journal_mode(SqliteJournalMode::Wal); - } - pool.set_connect_options(connect_options); - let mut connections = Vec::with_capacity(max_connections as usize); for _ in 0..max_connections { connections.push(pool.acquire().await?); } for connection in &mut connections { + let main_filename = main_database_filename(connection).await?; + let database_is_memory = main_filename.is_empty(); + if file_backed == database_is_memory { + return Err(RadrootsOutboxError::SqlitePoolBackingMismatch { + file_backed, + filename: main_filename, + }); + } + validate_main_database_encoding(connection).await?; + crate::schema::validate_outbox_temp_schema(connection).await?; sqlx::query("PRAGMA foreign_keys = ON") .execute(&mut **connection) .await?; + let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&mut **connection) + .await?; + if foreign_keys != 1 { + return Err(RadrootsOutboxError::SqliteForeignKeysNotEnabled { + actual: foreign_keys, + }); + } sqlx::query("PRAGMA busy_timeout = 5000") .execute(&mut **connection) .await?; if file_backed { - let actual = sqlx::query_scalar::<_, String>("PRAGMA main.journal_mode = WAL") - .fetch_one(&mut **connection) - .await?; - if actual != "wal" { + configure_file_journal_mode(connection).await?; + } + } + let existing_options = pool.connect_options(); + let connect_options = existing_options + .as_ref() + .clone() + .foreign_keys(true) + .busy_timeout(Duration::from_millis(5_000)); + let connect_options = if file_backed { + connect_options.journal_mode(SqliteJournalMode::Wal) + } else { + connect_options + }; + pool.set_connect_options(connect_options); + Ok(()) +} + +async fn validate_main_database_encoding( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsOutboxError> { + let actual: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut *connection) + .await?; + if actual == "UTF-8" { + return Ok(()); + } + Err(RadrootsOutboxError::SqliteMainDatabaseEncodingNotUtf8 { actual }) +} + +async fn configure_file_journal_mode( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsOutboxError> { + const RETRY_ATTEMPTS: usize = 100; + const RETRY_DELAY: Duration = Duration::from_millis(50); + + for attempt in 0..RETRY_ATTEMPTS { + match sqlx::query_scalar::<_, String>("PRAGMA main.journal_mode = WAL") + .fetch_one(&mut *connection) + .await + { + Ok(actual) if actual == "wal" => return Ok(()), + Ok(actual) => { return Err(RadrootsOutboxError::SqliteFileJournalModeNotWal { actual }); } + Err(error) if attempt + 1 < RETRY_ATTEMPTS && is_sqlite_lock_contention(&error) => { + tokio::time::sleep(RETRY_DELAY).await; + } + Err(error) => return Err(error.into()), } } - Ok(()) + unreachable!("bounded journal-mode retry exits from every terminal branch") +} + +fn is_sqlite_lock_contention(error: &sqlx::Error) -> bool { + matches!( + error, + sqlx::Error::Database(database) + if database + .code() + .as_deref() + .is_some_and(sqlite_code_is_lock_contention) + ) +} + +fn sqlite_code_is_lock_contention(code: &str) -> bool { + code.parse::<u32>() + .is_ok_and(|code| matches!(code & 0xff, 5 | 6)) +} + +async fn main_database_filename( + connection: &mut SqliteConnection, +) -> Result<String, RadrootsOutboxError> { + let rows = sqlx::query("PRAGMA database_list") + .fetch_all(&mut *connection) + .await?; + for row in rows { + if row.try_get::<String, _>("name")? == "main" { + return Ok(row.try_get("file")?); + } + } + Err(RadrootsOutboxError::SqliteMainDatabaseUnavailable) } #[cfg(test)] @@ -1880,18 +1981,6 @@ async fn file_pool_with_immutable_delete_journal(path: &Path) -> SqlitePool { } #[cfg_attr(coverage_nightly, coverage(off))] -async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsOutboxError> { - sqlx::raw_sql(OUTBOX_MIGRATION_UP).execute(pool).await?; - Ok(()) -} - -#[cfg_attr(coverage_nightly, coverage(off))] -async fn apply_down(pool: &SqlitePool) -> Result<(), RadrootsOutboxError> { - sqlx::raw_sql(OUTBOX_MIGRATION_DOWN).execute(pool).await?; - Ok(()) -} - -#[cfg_attr(coverage_nightly, coverage(off))] async fn query_i64(pool: &SqlitePool, sql: &'static str) -> Result<i64, RadrootsOutboxError> { let row = sqlx::query(sql).fetch_one(pool).await?; Ok(row.try_get(0)?) @@ -4179,7 +4268,7 @@ mod tests { } #[tokio::test] - async fn migration_applies_delivery_plan_schema_and_migrates_down() { + async fn migration_applies_delivery_plan_schema_and_uses_explicit_test_destruction() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); assert_eq!(outbox.pragma_foreign_keys().await.expect("foreign keys"), 1); @@ -4236,7 +4325,23 @@ mod tests { "outcome_kind" ); - outbox.migrate_down().await.expect("migrate down"); + assert_eq!( + outbox.schema_status().await.expect("managed schema"), + RadrootsOutboxSchemaStatus::Managed { + version: crate::RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + } + ); + assert!(matches!( + rollback_outbox_schema_offline(outbox.pool(), 0).await, + Err(RadrootsOutboxError::RollbackBelowVersionFloor { + floor: 1, + target: 0 + }) + )); + outbox + .destroy_schema_for_migration_test() + .await + .expect("test destruction"); let row = sqlx::query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'outbox_event'", ) @@ -4244,6 +4349,12 @@ mod tests { .await .expect("table query"); assert!(row.is_none()); + assert_eq!( + inspect_outbox_schema_status(outbox.pool()) + .await + .expect("uninitialized schema"), + RadrootsOutboxSchemaStatus::Uninitialized + ); } #[tokio::test] @@ -4260,6 +4371,60 @@ mod tests { } #[tokio::test] + async fn file_wal_configuration_retries_exclusive_lock_contention() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("wal-contention.sqlite"); + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true); + let mut blocker = SqliteConnection::connect_with(&options) + .await + .expect("blocker connection"); + sqlx::query("CREATE TABLE lock_anchor (value TEXT NOT NULL)") + .execute(&mut blocker) + .await + .expect("materialize database"); + let mut contender = SqliteConnection::connect_with(&options) + .await + .expect("contender connection"); + sqlx::query("PRAGMA busy_timeout = 0") + .execute(&mut contender) + .await + .expect("disable SQLite busy wait"); + sqlx::query("BEGIN EXCLUSIVE") + .execute(&mut blocker) + .await + .expect("exclusive blocker"); + + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + sqlx::query("COMMIT") + .execute(&mut blocker) + .await + .expect("release exclusive blocker"); + }); + configure_file_journal_mode(&mut contender) + .await + .expect("retry WAL transition"); + release.await.expect("blocker task"); + let actual: String = sqlx::query_scalar("PRAGMA main.journal_mode") + .fetch_one(&mut contender) + .await + .expect("journal mode"); + assert_eq!(actual, "wal"); + } + + #[test] + fn sqlite_lock_contention_recognizes_primary_and_extended_codes() { + for code in ["5", "6", "261", "262", "517", "773"] { + assert!(sqlite_code_is_lock_contention(code), "{code}"); + } + for code in ["", "not-a-code", "0", "1", "7", "260"] { + assert!(!sqlite_code_is_lock_contention(code), "{code}"); + } + } + + #[tokio::test] async fn constructors_preflight_and_transactional_enqueue_cover_public_storage_surfaces() { let directory = tempfile::tempdir().expect("tempdir"); let file_outbox = RadrootsOutbox::open_file(directory.path().join("outbox.sqlite")) diff --git a/crates/outbox/tests/fixtures/migration_authority.v1.json b/crates/outbox/tests/fixtures/migration_authority.v1.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "contract_id": "radroots_outbox.migration_authority.v1", + "executor": { + "id": "radroots_outbox.migration_authority_v1.result_vector_executor.v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "test": "migration_authority_v1_result_vector" + }, + "delegated_suite": { + "lane": "nix run .#contract", + "package": "radroots_outbox", + "authorities": [ + { + "authority": "migration_source_discovery_is_exact_and_fail_closed", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "ledger_name_checksum_and_catalog_mutations_fail_closed", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "newer_history_and_governed_catalog_overflow_are_bounded_and_rejected", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "history_validation_rejects_gaps_and_unknown_versions", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "temporary_authority_collisions_are_rejected_before_migration", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "concurrent_file_initializers_serialize_to_one_exact_history", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "terminal_target_rollback_preserves_v1_rows_and_closes_every_clone", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "reopen_rejects_outbox_foreign_key_corruption", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "open_rejects_utf16_before_journal_or_schema_mutation", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "file_pool_rejects_successful_non_wal_journal_result", + "authority_path": "crates/outbox/src/store.rs" + } + ] + }, + "cases": [ + { + "id": "fresh_initialization", + "execution": "direct_executor", + "expected_outcome": "managed_v1", + "expected_error": null + }, + { + "id": "exact_unledgered_adoption", + "execution": "direct_executor", + "expected_outcome": "managed_v1_without_replay", + "expected_error": null + }, + { + "id": "partial_unledgered_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "UnmanagedSchema" + }, + { + "id": "ledger_checksum_tamper_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "MigrationHistoryChecksumDrift" + }, + { + "id": "newer_history_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_mutation", + "expected_error": "SchemaTooNew" + }, + { + "id": "rollback_below_floor_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_without_schema_change", + "expected_error": "RollbackBelowVersionFloor" + }, + { + "id": "caller_state_preserved", + "execution": "direct_executor", + "expected_outcome": "managed_v1_with_caller_state_preserved", + "expected_error": null + }, + { + "id": "current_reopen_no_history_write", + "execution": "direct_executor", + "expected_outcome": "managed_v1_without_history_write", + "expected_error": null + } + ] +} diff --git a/crates/outbox/tests/migration_authority_v1_result_vector.rs b/crates/outbox/tests/migration_authority_v1_result_vector.rs @@ -0,0 +1,252 @@ +#![forbid(unsafe_code)] + +use radroots_outbox::{ + RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, RadrootsOutbox, RadrootsOutboxError, + RadrootsOutboxSchemaStatus, +}; +use serde::Deserialize; +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::collections::BTreeSet; +use std::str::FromStr; + +const VECTOR_BYTES: &[u8] = include_bytes!("fixtures/migration_authority.v1.json"); +const BASELINE_UP: &str = include_str!("../migrations/0001_outbox.up.sql"); +const SCHEMA_SOURCE: &str = include_str!("../src/schema.rs"); +const MIGRATIONS_SOURCE: &str = include_str!("../src/migrations.rs"); +const STORE_SOURCE: &str = include_str!("../src/store.rs"); + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Vector { + schema_version: u32, + contract_id: String, + executor: Executor, + delegated_suite: DelegatedSuite, + cases: Vec<Case>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Executor { + id: String, + path: String, + test: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DelegatedSuite { + lane: String, + package: String, + authorities: Vec<Authority>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Authority { + authority: String, + authority_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + execution: String, + expected_outcome: String, + expected_error: Option<String>, +} + +async fn memory_pool() -> SqlitePool { + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::from_str("sqlite::memory:").expect("options")) + .await + .expect("pool") +} + +async fn execute_case(case: &Case) { + assert_eq!(case.execution, "direct_executor"); + match case.id.as_str() { + "fresh_initialization" => { + let store = RadrootsOutbox::open_memory().await.expect("fresh store"); + assert_eq!( + store.schema_status().await.expect("status"), + RadrootsOutboxSchemaStatus::Managed { + version: RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + } + ); + assert_eq!(case.expected_outcome, "managed_v1"); + } + "exact_unledgered_adoption" => { + let pool = memory_pool().await; + sqlx::raw_sql(BASELINE_UP) + .execute(&pool) + .await + .expect("baseline"); + let changes: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(&pool) + .await + .expect("changes"); + let store = RadrootsOutbox::open_pool(pool, false) + .await + .expect("adoption"); + let after: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(store.pool()) + .await + .expect("after changes"); + assert_eq!(after - changes, 1, "only the ledger row is inserted"); + assert_eq!(case.expected_outcome, "managed_v1_without_replay"); + } + "partial_unledgered_rejected" => { + let pool = memory_pool().await; + sqlx::raw_sql(BASELINE_UP) + .execute(&pool) + .await + .expect("baseline"); + sqlx::query("DROP INDEX outbox_event_event_id_idx") + .execute(&pool) + .await + .expect("partial schema"); + assert!(matches!( + RadrootsOutbox::open_pool(pool, false).await, + Err(RadrootsOutboxError::UnmanagedSchema { .. }) + )); + assert_eq!(case.expected_outcome, "rejected_before_mutation"); + assert_eq!(case.expected_error.as_deref(), Some("UnmanagedSchema")); + } + "ledger_checksum_tamper_rejected" => { + let store = RadrootsOutbox::open_memory().await.expect("store"); + sqlx::query( + "UPDATE radroots_outbox_schema_migrations SET up_sha256 = ? WHERE version = 1", + ) + .bind("b".repeat(64)) + .execute(store.pool()) + .await + .expect("tamper"); + assert!(matches!( + store.schema_status().await, + Err(RadrootsOutboxError::MigrationHistoryChecksumDrift { .. }) + )); + assert_eq!(case.expected_outcome, "rejected_before_mutation"); + assert_eq!( + case.expected_error.as_deref(), + Some("MigrationHistoryChecksumDrift") + ); + } + "newer_history_rejected" => { + let store = RadrootsOutbox::open_memory().await.expect("store"); + sqlx::query( + "INSERT INTO radroots_outbox_schema_migrations(version, name, up_sha256, down_sha256, schema_sha256) VALUES (2, 'future', ?, ?, ?)", + ) + .bind("a".repeat(64)) + .bind("b".repeat(64)) + .bind("c".repeat(64)) + .execute(store.pool()) + .await + .expect("future row"); + assert!(matches!( + store.schema_status().await, + Err(RadrootsOutboxError::SchemaTooNew { database: 2, .. }) + )); + assert_eq!(case.expected_outcome, "rejected_before_mutation"); + assert_eq!(case.expected_error.as_deref(), Some("SchemaTooNew")); + } + "rollback_below_floor_rejected" => { + let store = RadrootsOutbox::open_memory().await.expect("store"); + assert!(matches!( + store.rollback_to_schema_version_and_close(0).await, + Err(RadrootsOutboxError::RollbackBelowVersionFloor { + floor: 1, + target: 0 + }) + )); + assert_eq!(case.expected_outcome, "rejected_without_schema_change"); + assert_eq!( + case.expected_error.as_deref(), + Some("RollbackBelowVersionFloor") + ); + } + "caller_state_preserved" => { + let pool = memory_pool().await; + sqlx::raw_sql( + "CREATE TABLE caller_state(value TEXT NOT NULL); INSERT INTO caller_state VALUES ('preserved');", + ) + .execute(&pool) + .await + .expect("caller state"); + let store = RadrootsOutbox::open_pool(pool, false) + .await + .expect("fresh migration"); + let value: String = sqlx::query_scalar("SELECT value FROM caller_state") + .fetch_one(store.pool()) + .await + .expect("caller value"); + assert_eq!(value, "preserved"); + assert_eq!( + case.expected_outcome, + "managed_v1_with_caller_state_preserved" + ); + } + "current_reopen_no_history_write" => { + let store = RadrootsOutbox::open_memory().await.expect("store"); + let before: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(store.pool()) + .await + .expect("before"); + store + .migrate_to_current_schema() + .await + .expect("current reopen"); + let after: i64 = sqlx::query_scalar("SELECT total_changes()") + .fetch_one(store.pool()) + .await + .expect("after"); + assert_eq!(before, after); + assert_eq!(case.expected_outcome, "managed_v1_without_history_write"); + } + other => panic!("unknown direct vector case `{other}`"), + } + assert_eq!(case.expected_error.is_some(), case.id.contains("rejected")); +} + +#[tokio::test] +async fn migration_authority_v1_result_vector() { + let canonical = + include_bytes!("../../../contracts/conformance/vectors/outbox/migration_authority.v1.json"); + assert_eq!(VECTOR_BYTES, canonical, "packaged vector mirror drift"); + let vector: Vector = serde_json::from_slice(VECTOR_BYTES).expect("vector JSON"); + assert_eq!(vector.schema_version, 1); + assert_eq!(vector.contract_id, "radroots_outbox.migration_authority.v1"); + assert_eq!( + vector.executor.id, + "radroots_outbox.migration_authority_v1.result_vector_executor.v1" + ); + assert_eq!( + vector.executor.path, + "crates/outbox/tests/migration_authority_v1_result_vector.rs" + ); + assert_eq!(vector.executor.test, "migration_authority_v1_result_vector"); + assert_eq!(vector.delegated_suite.lane, "nix run .#contract"); + assert_eq!(vector.delegated_suite.package, "radroots_outbox"); + + let mut authorities = BTreeSet::new(); + for authority in vector.delegated_suite.authorities { + assert!(authorities.insert(authority.authority.clone())); + let source = match authority.authority_path.as_str() { + "crates/outbox/src/schema.rs" => SCHEMA_SOURCE, + "crates/outbox/src/migrations.rs" => MIGRATIONS_SOURCE, + "crates/outbox/src/store.rs" => STORE_SOURCE, + other => panic!("unknown delegated authority path `{other}`"), + }; + assert!(source.contains(authority.authority.as_str())); + } + + let mut case_ids = BTreeSet::new(); + for case in &vector.cases { + assert!(case_ids.insert(case.id.clone()), "duplicate case id"); + execute_case(case).await; + } + assert_eq!(case_ids.len(), 8); +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -6,6 +6,7 @@ mod comment_authority; mod deletion_authority; mod food_availability_projection; mod nip09_reconciliation; +mod outbox_migration; mod raw_source_rebuild; mod registry_v7; mod source_maintenance; @@ -16,6 +17,9 @@ pub(crate) use food_availability_projection::{ pub(crate) use nip09_reconciliation::{ validate_nip09_reconciliation_manifest, write_nip09_reconciliation_manifest, }; +pub(crate) use outbox_migration::{ + validate_outbox_migration_manifest, write_outbox_migration_manifest, +}; pub(crate) use raw_source_rebuild::{ validate_raw_source_rebuild_manifest, write_raw_source_rebuild_manifest, }; @@ -54,6 +58,7 @@ pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), S validate_food_availability_projection_manifest(workspace_root)?; validate_source_maintenance_manifest(workspace_root)?; validate_raw_source_rebuild_manifest(workspace_root)?; + validate_outbox_migration_manifest(workspace_root)?; validate_knowledge_contract_manifest(workspace_root) } @@ -69,11 +74,14 @@ const SOURCE_MAINTENANCE_CONFORMANCE_VECTOR_RELATIVE: &str = "contracts/conformance/vectors/event_store/source_maintenance.v1.json"; const RAW_SOURCE_REBUILD_CONFORMANCE_VECTOR_RELATIVE: &str = "contracts/conformance/vectors/event_store/raw_source_rebuild.v1.json"; -const SPECIALIZED_CONFORMANCE_VECTOR_RELATIVES: [&str; 4] = [ +const OUTBOX_MIGRATION_CONFORMANCE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/outbox/migration_authority.v1.json"; +const SPECIALIZED_CONFORMANCE_VECTOR_RELATIVES: [&str; 5] = [ NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE, FOOD_AVAILABILITY_PROJECTION_CONFORMANCE_VECTOR_RELATIVE, SOURCE_MAINTENANCE_CONFORMANCE_VECTOR_RELATIVE, RAW_SOURCE_REBUILD_CONFORMANCE_VECTOR_RELATIVE, + OUTBOX_MIGRATION_CONFORMANCE_VECTOR_RELATIVE, ]; const KNOWLEDGE_MANIFEST_RELATIVE: &str = "contracts/knowledge/knowledge_event_contract_manifest.v2.json"; @@ -94,7 +102,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 23] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 24] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -148,6 +156,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 23] = [ "crates/event_store/tests/fixtures/raw_source_rebuild.v1.json", ), ( + OUTBOX_MIGRATION_CONFORMANCE_VECTOR_RELATIVE, + "crates/outbox/tests/fixtures/migration_authority.v1.json", + ), + ( "contracts/conformance/vectors/events/operational_listing_tags_full.v1.json", "crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json", ), diff --git a/tools/xtask/src/contract/outbox_migration.rs b/tools/xtask/src/contract/outbox_migration.rs @@ -0,0 +1,1026 @@ +use super::artifact_bundle::{ + GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::{Expr, Item}; + +const CONTRACT_ID: &str = "radroots_outbox.migration_authority.v1"; +const AUTHORITY_ID: &str = "versioned_outbox_migration_authority_v1"; +const SCHEMA_VERSION: u32 = 1; +const MINIMUM_VERSION: u32 = 1; +const CURRENT_VERSION: u32 = 1; +const LEDGER_NAME: &str = "radroots_outbox_schema_migrations"; +const RESERVED_PREFIX: &str = "outbox_"; +const CATALOG_ROW_LIMIT: u32 = 14; +const CATALOG_REJECTION_PROBE_LIMIT: u32 = 15; +const HISTORY_ROW_LIMIT: u32 = 1; +const HISTORY_REJECTION_PROBE_LIMIT: u32 = 2; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const CATALOG_FINGERPRINT_ALGORITHM: &str = + "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1"; +const WRITE_COMMAND: &str = "cargo xtask contract outbox-migration-manifest --write"; + +const UP_RELATIVE: &str = "crates/outbox/migrations/0001_outbox.up.sql"; +const DOWN_RELATIVE: &str = "crates/outbox/migrations/0001_outbox.down.sql"; +const UP_BYTE_LENGTH: usize = 5_470; +const DOWN_BYTE_LENGTH: usize = 159; +const UP_SHA256: &str = "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa"; +const DOWN_SHA256: &str = "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61"; +const SCHEMA_SHA256: &str = "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293"; + +const MANIFEST_RELATIVE: &str = "crates/outbox/contracts/migration_authority_v1.manifest.json"; +const MANIFEST_SCHEMA_RELATIVE: &str = + "crates/outbox/contracts/migration_authority_v1.manifest.schema.json"; +const MANIFEST_SHA256_RELATIVE: &str = + "crates/outbox/contracts/migration_authority_v1.manifest.sha256"; +const GENERATED_DESCRIPTOR_RELATIVE: &str = + "crates/outbox/src/generated/outbox_migration_manifest.rs"; +const VECTOR_RELATIVE: &str = "contracts/conformance/vectors/outbox/migration_authority.v1.json"; +const VECTOR_MIRROR_RELATIVE: &str = "crates/outbox/tests/fixtures/migration_authority.v1.json"; +const VECTOR_EXECUTOR_RELATIVE: &str = + "crates/outbox/tests/migration_authority_v1_result_vector.rs"; +const RELEASE_RECORD_RELATIVE: &str = "contracts/releases/1.0.0-alpha.1.toml"; +const CHANGELOG_RELATIVE: &str = "CHANGELOG.md"; +const RELEASE_CHANGE_ID: &str = "outbox-versioned-migration-authority"; +const CHANGELOG_MARKER: &str = "<!-- release-change: outbox-versioned-migration-authority -->"; + +const OBJECTS: &[&str] = &[ + "outbox_delivery_attempt", + "outbox_delivery_attempt_target_idx", + "outbox_delivery_plan", + "outbox_delivery_plan_event_idx", + "outbox_delivery_target", + "outbox_delivery_target_ready_idx", + "outbox_event", + "outbox_event_event_id_idx", + "outbox_event_ready_idx", + "outbox_operation_idempotency_idx", + "outbox_operation_status_idx", + "outbox_operation_trade_mutation_idx", + "outbox_operations", +]; +const TABLES: &[&str] = &[ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations", +]; +const INDEXES: &[&str] = &[ + "outbox_delivery_attempt_target_idx", + "outbox_delivery_plan_event_idx", + "outbox_delivery_target_ready_idx", + "outbox_event_event_id_idx", + "outbox_event_ready_idx", + "outbox_operation_idempotency_idx", + "outbox_operation_status_idx", + "outbox_operation_trade_mutation_idx", +]; + +const PUBLIC_SYMBOLS: &[&str] = &[ + "RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT", + "RADROOTS_OUTBOX_SCHEMA_VERSION_MIN", + "RadrootsOutboxSchemaStatus", + "inspect_outbox_schema_status", +]; +const PUBLIC_METHODS: &[&str] = &[ + "RadrootsOutbox::migrate_to_current_schema", + "RadrootsOutbox::rollback_to_schema_version_and_close", + "RadrootsOutbox::schema_status", +]; +const REMOVED_SYMBOLS: &[&str] = &["OUTBOX_MIGRATION_DOWN", "OUTBOX_MIGRATION_UP"]; +const REMOVED_METHODS: &[&str] = &["RadrootsOutbox::migrate_down"]; + +const ERROR_VARIANTS: &[&str] = &[ + "EmbeddedMigrationChecksumMismatch", + "EmbeddedMigrationLengthMismatch", + "ForeignKeyViolation", + "GovernedCatalogCapacityExceeded", + "IntegrityCheckFailed", + "MigrationCatalogDeltaMismatch", + "MigrationHistoryChecksumDrift", + "MigrationHistoryGap", + "MigrationHistoryNameDrift", + "MigrationLedgerDrift", + "MigrationRegistryDefect", + "MigrationTransactionRollbackFailed", + "RollbackAhead", + "RollbackBelowVersionFloor", + "RollbackUnmanaged", + "SchemaFingerprintMismatch", + "SchemaTooNew", + "SqliteForeignKeysNotEnabled", + "SqliteMainDatabaseEncodingNotUtf8", + "SqliteMainDatabaseUnavailable", + "TemporarySchemaCollision", + "UnknownMigration", + "UnmanagedSchema", +]; + +const SOURCE_SPECS: &[(&str, &str)] = &[ + ("outbox_package_manifest", "crates/outbox/Cargo.toml"), + ("outbox_public_surface", "crates/outbox/src/lib.rs"), + ("outbox_error_surface", "crates/outbox/src/error.rs"), + ( + "generated_descriptor_registration", + "crates/outbox/src/generated.rs", + ), + ( + "outbox_migration_registry", + "crates/outbox/src/migrations.rs", + ), + ("outbox_schema_runtime", "crates/outbox/src/schema.rs"), + ("outbox_store_runtime", "crates/outbox/src/store.rs"), + ("outbox_package_readme", "crates/outbox/README"), + ("vector_executor", VECTOR_EXECUTOR_RELATIVE), + ( + "contract_governance", + "tools/xtask/src/contract/outbox_migration.rs", + ), + ("contract_dispatch", "tools/xtask/src/contract.rs"), + ("xtask_dispatch", "tools/xtask/src/main.rs"), + ("nix_contract_lane", "build/nix/common.nix"), + ("release_record", RELEASE_RECORD_RELATIVE), + ("release_notes", CHANGELOG_RELATIVE), +]; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Vector { + schema_version: u32, + contract_id: String, + executor: VectorExecutor, + delegated_suite: DelegatedSuite, + cases: Vec<VectorCase>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorExecutor { + id: String, + path: String, + test: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct DelegatedSuite { + lane: String, + package: String, + authorities: Vec<DelegatedAuthority>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct DelegatedAuthority { + authority: String, + authority_path: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + execution: String, + expected_outcome: String, + expected_error: Option<String>, +} + +#[derive(Debug, Eq, PartialEq)] +struct DiscoveredMigration { + version: u32, + name: String, + up_relative: String, + down_relative: String, +} + +pub(crate) fn write_outbox_migration_manifest(workspace_root: &Path) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |transaction| { + transaction.write(expected_artifacts(workspace_root)?)?; + validate_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_outbox_migration_manifest(workspace_root: &Path) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| validate_under_lock(workspace_root)) +} + +fn validate_under_lock(workspace_root: &Path) -> Result<(), String> { + for artifact in expected_artifacts(workspace_root)? { + let actual = read_regular_file(workspace_root, artifact.relative)?; + if actual != artifact.contents { + return Err(format!( + "generated outbox migration artifact {} is stale; run `{WRITE_COMMAND}`", + artifact.relative + )); + } + } + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: Value = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + let schema_bytes = read_regular_file(workspace_root, MANIFEST_SCHEMA_RELATIVE)?; + let schema: Value = serde_json::from_slice(&schema_bytes) + .map_err(|error| format!("parse {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; + let validator = jsonschema::validator_for(&schema) + .map_err(|error| format!("compile {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; + let errors = validator + .iter_errors(&manifest) + .map(|error| error.to_string()) + .collect::<Vec<_>>(); + if !errors.is_empty() { + return Err(format!( + "{MANIFEST_RELATIVE} violates its schema: {}", + errors.join("; ") + )); + } + let sidecar = read_regular_file(workspace_root, MANIFEST_SHA256_RELATIVE)?; + if sidecar != format!("{}\n", sha256_hex(&manifest_bytes)).as_bytes() { + return Err(format!( + "{MANIFEST_SHA256_RELATIVE} must authenticate the exact manifest bytes" + )); + } + validate_source_authority(workspace_root)?; + validate_vector(workspace_root)?; + validate_release_authority(workspace_root) +} + +fn expected_artifacts(workspace_root: &Path) -> Result<Vec<GeneratedArtifact>, String> { + validate_source_authority(workspace_root)?; + validate_vector(workspace_root)?; + validate_release_authority(workspace_root)?; + let schema_bytes = canonical_json_bytes(&manifest_schema())?; + let manifest = expected_manifest(workspace_root, &schema_bytes)?; + let manifest_bytes = canonical_json_bytes(&manifest)?; + let manifest_sha256 = sha256_hex(&manifest_bytes); + let descriptor = generated_descriptor(&manifest_sha256); + let vector = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + Ok(vec![ + GeneratedArtifact { + relative: MANIFEST_RELATIVE, + contents: manifest_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SCHEMA_RELATIVE, + contents: schema_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SHA256_RELATIVE, + contents: format!("{manifest_sha256}\n").into_bytes(), + }, + GeneratedArtifact { + relative: GENERATED_DESCRIPTOR_RELATIVE, + contents: descriptor.into_bytes(), + }, + GeneratedArtifact { + relative: VECTOR_MIRROR_RELATIVE, + contents: vector, + }, + ]) +} + +fn expected_manifest(workspace_root: &Path, schema_bytes: &[u8]) -> Result<Value, String> { + let vector_bytes = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + let executor_bytes = read_regular_file(workspace_root, VECTOR_EXECUTOR_RELATIVE)?; + let ledger_ddl = extract_string_const( + &parse_rust(workspace_root, "crates/outbox/src/migrations.rs")?, + "OUTBOX_LEDGER_DDL", + )?; + Ok(json!({ + "schema_version": SCHEMA_VERSION, + "contract_id": CONTRACT_ID, + "authority_id": AUTHORITY_ID, + "manifest_schema": descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, schema_bytes)?, + "runtime": { + "minimum_version": MINIMUM_VERSION, + "current_version": CURRENT_VERSION, + "reserved_prefix": RESERVED_PREFIX, + "ledger_name": LEDGER_NAME, + "ledger_ddl_sha256": sha256_hex(ledger_ddl.as_bytes()), + "catalog_fingerprint_algorithm": CATALOG_FINGERPRINT_ALGORITHM, + "migration_transaction": "begin_immediate_v1", + "rollback_transaction": "begin_exclusive_terminal_close_v1", + "catalog_row_limit": CATALOG_ROW_LIMIT, + "catalog_rejection_probe_limit": CATALOG_REJECTION_PROBE_LIMIT, + "history_row_limit": HISTORY_ROW_LIMIT, + "history_rejection_probe_limit": HISTORY_REJECTION_PROBE_LIMIT, + "open_validations": [ + "main_database_backing_identity", + "utf8_encoding_before_journal_or_schema_mutation", + "foreign_keys_enabled", + "file_wal_result", + "temporary_schema_authority", + "bounded_managed_catalog", + "tamper_evident_history", + "catalog_fingerprint", + "integrity_check_one", + "outbox_scoped_foreign_key_check" + ], + "adoption_policy": "exact_unledgered_0001_catalog_only_v1", + "rollback_floor": 1, + "test_destruction": "cfg_test_destroy_outbox_schema_for_migration_test" + }, + "migrations": [{ + "version": 1, + "name": "outbox", + "up": descriptor_for_file(workspace_root, UP_RELATIVE)?, + "down": descriptor_for_file(workspace_root, DOWN_RELATIVE)?, + "schema_sha256": SCHEMA_SHA256, + "catalog": { + "objects": OBJECTS, + "tables": TABLES, + "indexes": INDEXES + } + }], + "public_api": { + "added_symbols": PUBLIC_SYMBOLS, + "methods": PUBLIC_METHODS, + "removed_symbols": REMOVED_SYMBOLS, + "removed_methods": REMOVED_METHODS, + "error_variants": ERROR_VARIANTS, + "error_enum_non_exhaustive": true + }, + "source_files": SOURCE_SPECS.iter().map(|(role, path)| { + Ok(json!({ + "role": role, + "file": descriptor_for_file(workspace_root, path)? + })) + }).collect::<Result<Vec<Value>, String>>()?, + "result_vector": { + "canonical": descriptor_for_bytes(VECTOR_RELATIVE, &vector_bytes)?, + "mirror_path": VECTOR_MIRROR_RELATIVE, + "executor": descriptor_for_bytes(VECTOR_EXECUTOR_RELATIVE, &executor_bytes)?, + "executor_id": "radroots_outbox.migration_authority_v1.result_vector_executor.v1", + "executor_test": "migration_authority_v1_result_vector" + }, + "release": { + "change_id": RELEASE_CHANGE_ID, + "release_record": RELEASE_RECORD_RELATIVE, + "changelog": CHANGELOG_RELATIVE + } + })) +} + +fn manifest_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/contracts/outbox/migration_authority_v1.manifest.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "contract_id", "authority_id", "manifest_schema", "runtime", + "migrations", "public_api", "source_files", "result_vector", "release" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract_id": { "const": CONTRACT_ID }, + "authority_id": { "const": AUTHORITY_ID }, + "manifest_schema": { "$ref": "#/$defs/file" }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": [ + "minimum_version", "current_version", "reserved_prefix", "ledger_name", + "ledger_ddl_sha256", "catalog_fingerprint_algorithm", "migration_transaction", + "rollback_transaction", "catalog_row_limit", "catalog_rejection_probe_limit", + "history_row_limit", "history_rejection_probe_limit", "open_validations", + "adoption_policy", "rollback_floor", "test_destruction" + ], + "properties": { + "minimum_version": { "const": 1 }, + "current_version": { "const": 1 }, + "reserved_prefix": { "const": "outbox_" }, + "ledger_name": { "const": LEDGER_NAME }, + "ledger_ddl_sha256": { "$ref": "#/$defs/sha256" }, + "catalog_fingerprint_algorithm": { "type": "string", "minLength": 1 }, + "migration_transaction": { "const": "begin_immediate_v1" }, + "rollback_transaction": { "const": "begin_exclusive_terminal_close_v1" }, + "catalog_row_limit": { "const": 14 }, + "catalog_rejection_probe_limit": { "const": 15 }, + "history_row_limit": { "const": 1 }, + "history_rejection_probe_limit": { "const": 2 }, + "open_validations": { "type": "array", "minItems": 10, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "adoption_policy": { "const": "exact_unledgered_0001_catalog_only_v1" }, + "rollback_floor": { "const": 1 }, + "test_destruction": { "const": "cfg_test_destroy_outbox_schema_for_migration_test" } + } + }, + "migrations": { + "type": "array", "minItems": 1, "maxItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["version", "name", "up", "down", "schema_sha256", "catalog"], + "properties": { + "version": { "const": 1 }, "name": { "const": "outbox" }, + "up": { "$ref": "#/$defs/file" }, "down": { "$ref": "#/$defs/file" }, + "schema_sha256": { "$ref": "#/$defs/sha256" }, + "catalog": { + "type": "object", "additionalProperties": false, + "required": ["objects", "tables", "indexes"], + "properties": { + "objects": { "type": "array", "minItems": 13, "maxItems": 13, "uniqueItems": true, "items": { "type": "string" } }, + "tables": { "type": "array", "minItems": 5, "maxItems": 5, "uniqueItems": true, "items": { "type": "string" } }, + "indexes": { "type": "array", "minItems": 8, "maxItems": 8, "uniqueItems": true, "items": { "type": "string" } } + } + } + } + } + }, + "public_api": { + "type": "object", "additionalProperties": false, + "required": ["added_symbols", "methods", "removed_symbols", "removed_methods", "error_variants", "error_enum_non_exhaustive"], + "properties": { + "added_symbols": { "type": "array", "minItems": 4, "maxItems": 4, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "methods": { "type": "array", "minItems": 3, "maxItems": 3, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "removed_symbols": { "type": "array", "minItems": 2, "maxItems": 2, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "removed_methods": { "type": "array", "minItems": 1, "maxItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "error_variants": { "type": "array", "minItems": 23, "maxItems": 23, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "error_enum_non_exhaustive": { "const": true } + } + }, + "source_files": { + "type": "array", "minItems": 15, "maxItems": 15, + "items": { + "type": "object", "additionalProperties": false, + "required": ["role", "file"], + "properties": { + "role": { "type": "string", "minLength": 1 }, + "file": { "$ref": "#/$defs/file" } + } + } + }, + "result_vector": { + "type": "object", "additionalProperties": false, + "required": ["canonical", "mirror_path", "executor", "executor_id", "executor_test"], + "properties": { + "canonical": { "$ref": "#/$defs/file" }, + "mirror_path": { "const": VECTOR_MIRROR_RELATIVE }, + "executor": { "$ref": "#/$defs/file" }, + "executor_id": { "const": "radroots_outbox.migration_authority_v1.result_vector_executor.v1" }, + "executor_test": { "const": "migration_authority_v1_result_vector" } + } + }, + "release": { + "type": "object", "additionalProperties": false, + "required": ["change_id", "release_record", "changelog"], + "properties": { + "change_id": { "const": RELEASE_CHANGE_ID }, + "release_record": { "const": RELEASE_RECORD_RELATIVE }, + "changelog": { "const": CHANGELOG_RELATIVE } + } + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "file": { + "type": "object", "additionalProperties": false, + "required": ["path", "byte_length", "sha256", "hash_algorithm"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "byte_length": { "type": "integer", "minimum": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "hash_algorithm": { "const": HASH_ALGORITHM } + } + } + } + }) +} + +fn generated_descriptor(manifest_sha256: &str) -> String { + format!( + "// @generated by `cargo xtask contract outbox-migration-manifest --write`; do not edit.\n\n\ +pub(crate) const OUTBOX_MIGRATION_CONTRACT_ID: &str = \"{CONTRACT_ID}\";\n\ +pub(crate) const OUTBOX_MIGRATION_SCHEMA_VERSION: u32 = {SCHEMA_VERSION};\n\ +pub(crate) const OUTBOX_MIGRATION_CURRENT_VERSION: u32 = {CURRENT_VERSION};\n\ +pub(crate) const OUTBOX_MIGRATION_0001_UP_BYTE_LENGTH: usize = {UP_BYTE_LENGTH};\n\ +pub(crate) const OUTBOX_MIGRATION_0001_DOWN_BYTE_LENGTH: usize = {DOWN_BYTE_LENGTH};\n\ +pub(crate) const OUTBOX_MIGRATION_0001_UP_SHA256: &str =\n \"{UP_SHA256}\";\n\ +pub(crate) const OUTBOX_MIGRATION_0001_DOWN_SHA256: &str =\n \"{DOWN_SHA256}\";\n\ +pub(crate) const OUTBOX_MIGRATION_0001_SCHEMA_SHA256: &str =\n \"{SCHEMA_SHA256}\";\n\ +pub(crate) const OUTBOX_MIGRATION_MANIFEST_SHA256: &str =\n \"{manifest_sha256}\";\n" + ) +} + +fn validate_source_authority(workspace_root: &Path) -> Result<(), String> { + let source_roles = SOURCE_SPECS + .iter() + .map(|(role, _)| *role) + .collect::<BTreeSet<_>>(); + let source_paths = SOURCE_SPECS + .iter() + .map(|(_, path)| *path) + .collect::<BTreeSet<_>>(); + if source_roles.len() != SOURCE_SPECS.len() || source_paths.len() != SOURCE_SPECS.len() { + return Err("outbox migration source roles and paths must be unique".to_owned()); + } + let objects = OBJECTS.iter().copied().collect::<BTreeSet<_>>(); + let tables = TABLES.iter().copied().collect::<BTreeSet<_>>(); + let indexes = INDEXES.iter().copied().collect::<BTreeSet<_>>(); + if objects.len() != 13 + || tables.len() != 5 + || indexes.len() != 8 + || !tables.is_disjoint(&indexes) + || tables.union(&indexes).copied().collect::<BTreeSet<_>>() != objects + { + return Err( + "outbox catalog contract must contain exactly five tables and eight indexes".to_owned(), + ); + } + let discovered = discover_migrations(workspace_root)?; + if discovered + != [DiscoveredMigration { + version: 1, + name: "outbox".to_owned(), + up_relative: UP_RELATIVE.to_owned(), + down_relative: DOWN_RELATIVE.to_owned(), + }] + { + return Err( + "outbox migration discovery must contain exactly the 0001 outbox pair".to_owned(), + ); + } + validate_frozen_file(workspace_root, UP_RELATIVE, UP_BYTE_LENGTH, UP_SHA256)?; + validate_frozen_file(workspace_root, DOWN_RELATIVE, DOWN_BYTE_LENGTH, DOWN_SHA256)?; + + let migrations = parse_rust(workspace_root, "crates/outbox/src/migrations.rs")?; + if extract_string_array_const(&migrations, "OUTBOX_BASELINE_OBJECT_NAMES")? != OBJECTS + || extract_string_array_const(&migrations, "OUTBOX_BASELINE_TABLE_NAMES")? != TABLES + { + return Err("outbox migration source catalog inventory is not exact".to_owned()); + } + let migration_source = read_utf8(workspace_root, "crates/outbox/src/migrations.rs")?; + for marker in [ + "include_str!(\"../migrations/0001_outbox.up.sql\")", + "include_str!(\"../migrations/0001_outbox.down.sql\")", + "validate_embedded_migration_input", + "validate_migration_registry", + "RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT", + ] { + if !migration_source.contains(marker) { + return Err(format!( + "outbox migration registry is missing `{marker}` authority" + )); + } + } + let schema = read_utf8(workspace_root, "crates/outbox/src/schema.rs")?; + for marker in [ + "begin_with(\"BEGIN IMMEDIATE\")", + "begin_with(\"BEGIN EXCLUSIVE\")", + "main.sqlite_schema", + "PRAGMA integrity_check(1)", + "pragma_foreign_key_check", + "LIMIT ?", + "UnledgeredBaseline", + "destroy_outbox_schema_for_migration_test", + ] { + if !schema.contains(marker) { + return Err(format!( + "outbox schema runtime is missing `{marker}` authority" + )); + } + } + let store = read_utf8(workspace_root, "crates/outbox/src/store.rs")?; + for marker in [ + "rollback_to_schema_version_and_close", + "migrate_to_current_schema", + "validate_main_database_encoding", + "PRAGMA foreign_keys", + "PRAGMA main.journal_mode = WAL", + "PRAGMA database_list", + ] { + if !store.contains(marker) { + return Err(format!( + "outbox store runtime is missing `{marker}` authority" + )); + } + } + if store.contains("pub async fn migrate_down") { + return Err("unrestricted public outbox migrate_down remains reachable".to_owned()); + } + let store_ast = parse_rust(workspace_root, "crates/outbox/src/store.rs")?; + let public_async_methods = store_ast + .items + .iter() + .filter_map(|item| match item { + Item::Impl(item) if item.trait_.is_none() => Some(item), + _ => None, + }) + .filter(|item| match item.self_ty.as_ref() { + syn::Type::Path(path) => path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "RadrootsOutbox"), + _ => false, + }) + .flat_map(|item| item.items.iter()) + .filter_map(|item| match item { + syn::ImplItem::Fn(method) + if matches!(method.vis, syn::Visibility::Public(_)) + && method.sig.asyncness.is_some() => + { + Some(method.sig.ident.to_string()) + } + _ => None, + }) + .collect::<BTreeSet<_>>(); + for qualified in PUBLIC_METHODS { + let method = qualified + .rsplit_once("::") + .map(|(_, method)| method) + .ok_or_else(|| format!("invalid public outbox method identity `{qualified}`"))?; + if !public_async_methods.contains(method) { + return Err(format!( + "outbox public method `{qualified}` is not reachable" + )); + } + } + for qualified in REMOVED_METHODS { + let method = qualified + .rsplit_once("::") + .map(|(_, method)| method) + .ok_or_else(|| format!("invalid removed outbox method identity `{qualified}`"))?; + if public_async_methods.contains(method) { + return Err(format!( + "removed outbox method `{qualified}` remains reachable" + )); + } + } + + let errors = parse_rust(workspace_root, "crates/outbox/src/error.rs")?; + let error_enum = errors + .items + .iter() + .find_map(|item| match item { + Item::Enum(item) if item.ident == "RadrootsOutboxError" => Some(item), + _ => None, + }) + .ok_or_else(|| "RadrootsOutboxError enum is missing".to_owned())?; + if !matches!(error_enum.vis, syn::Visibility::Public(_)) + || !error_enum + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("non_exhaustive")) + { + return Err("outbox error authority must be non-exhaustive".to_owned()); + } + let error_variants = error_enum + .variants + .iter() + .map(|variant| variant.ident.to_string()) + .collect::<BTreeSet<_>>(); + for variant in ERROR_VARIANTS { + if !error_variants.contains(*variant) { + return Err(format!( + "outbox error authority is missing typed variant `{variant}`" + )); + } + } + let public = read_utf8(workspace_root, "crates/outbox/src/lib.rs")?; + for symbol in PUBLIC_SYMBOLS { + if !public.contains(symbol) { + return Err(format!("outbox public surface is missing `{symbol}`")); + } + } + for removed in REMOVED_SYMBOLS { + if public.contains(removed) { + return Err(format!( + "removed raw outbox migration symbol `{removed}` remains public" + )); + } + } + let generated = read_utf8(workspace_root, "crates/outbox/src/generated.rs")?; + if !generated.contains("mod outbox_migration_manifest") { + return Err("outbox generated descriptor is not registered".to_owned()); + } + Ok(()) +} + +fn validate_vector(workspace_root: &Path) -> Result<(), String> { + let bytes = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + let vector: Vector = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {VECTOR_RELATIVE}: {error}"))?; + if bytes != canonical_json_bytes(&vector)? { + return Err(format!("{VECTOR_RELATIVE} must be canonical pretty JSON")); + } + if vector.schema_version != 1 + || vector.contract_id != CONTRACT_ID + || vector.executor.id != "radroots_outbox.migration_authority_v1.result_vector_executor.v1" + || vector.executor.path != VECTOR_EXECUTOR_RELATIVE + || vector.executor.test != "migration_authority_v1_result_vector" + || vector.delegated_suite.lane != "nix run .#contract" + || vector.delegated_suite.package != "radroots_outbox" + { + return Err(format!( + "{VECTOR_RELATIVE} has invalid executor or contract identity" + )); + } + let direct = [ + "fresh_initialization", + "exact_unledgered_adoption", + "partial_unledgered_rejected", + "ledger_checksum_tamper_rejected", + "newer_history_rejected", + "rollback_below_floor_rejected", + "caller_state_preserved", + "current_reopen_no_history_write", + ]; + if vector.cases.len() != direct.len() + || vector + .cases + .iter() + .map(|case| case.id.as_str()) + .collect::<Vec<_>>() + != direct + || vector + .cases + .iter() + .any(|case| case.execution != "direct_executor") + { + return Err(format!( + "{VECTOR_RELATIVE} direct case inventory is not exact" + )); + } + let mut authorities = BTreeSet::new(); + for authority in &vector.delegated_suite.authorities { + if !authorities.insert(authority.authority.as_str()) { + return Err(format!( + "{VECTOR_RELATIVE} contains duplicate delegated authority" + )); + } + let source = read_utf8(workspace_root, &authority.authority_path)?; + if !source.contains(&authority.authority) { + return Err(format!( + "delegated authority `{}` is absent from {}", + authority.authority, authority.authority_path + )); + } + } + if authorities.len() != 10 { + return Err(format!( + "{VECTOR_RELATIVE} must bind ten delegated authorities" + )); + } + let executor = read_utf8(workspace_root, VECTOR_EXECUTOR_RELATIVE)?; + for case in direct { + if !executor.contains(case) { + return Err(format!("vector executor does not execute `{case}`")); + } + } + Ok(()) +} + +fn validate_release_authority(workspace_root: &Path) -> Result<(), String> { + let release = read_utf8(workspace_root, RELEASE_RECORD_RELATIVE)?; + if !release.contains(&format!("id = \"{RELEASE_CHANGE_ID}\"")) { + return Err(format!( + "{RELEASE_RECORD_RELATIVE} is missing `{RELEASE_CHANGE_ID}`" + )); + } + let changelog = read_utf8(workspace_root, CHANGELOG_RELATIVE)?; + if !changelog.contains(CHANGELOG_MARKER) { + return Err(format!( + "{CHANGELOG_RELATIVE} is missing `{CHANGELOG_MARKER}`" + )); + } + Ok(()) +} + +fn discover_migrations(workspace_root: &Path) -> Result<Vec<DiscoveredMigration>, String> { + discover_migrations_in( + &workspace_root.join("crates/outbox/migrations"), + workspace_root, + ) +} + +fn discover_migrations_in( + directory: &Path, + relative_root: &Path, +) -> Result<Vec<DiscoveredMigration>, String> { + let mut entries = fs::read_dir(directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("read migration directory entry: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut pairs = + std::collections::BTreeMap::<(u32, String), (Option<PathBuf>, Option<PathBuf>)>::new(); + for entry in entries { + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(format!( + "migration input must be a regular file: {}", + entry.path().display() + )); + } + let filename = entry + .file_name() + .into_string() + .map_err(|_| "migration filename must be UTF-8".to_owned())?; + let (stem, direction) = if let Some(stem) = filename.strip_suffix(".up.sql") { + (stem, "up") + } else if let Some(stem) = filename.strip_suffix(".down.sql") { + (stem, "down") + } else { + return Err(format!("unknown migration file `{filename}`")); + }; + let (version, name) = stem + .split_once('_') + .ok_or_else(|| format!("invalid migration filename `{filename}`"))?; + if version.len() != 4 + || !version.bytes().all(|byte| byte.is_ascii_digit()) + || name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("invalid migration filename `{filename}`")); + } + let version = version.parse::<u32>().map_err(|error| error.to_string())?; + let pair = pairs.entry((version, name.to_owned())).or_default(); + let slot = if direction == "up" { + &mut pair.0 + } else { + &mut pair.1 + }; + if slot.replace(entry.path()).is_some() { + return Err(format!( + "duplicate {direction} migration for version {version}" + )); + } + } + pairs + .into_iter() + .map(|((version, name), (up, down))| { + let relative = |path: PathBuf| { + path.strip_prefix(relative_root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .map_err(|_| format!("migration is outside {}", relative_root.display())) + }; + Ok(DiscoveredMigration { + version, + name, + up_relative: relative( + up.ok_or_else(|| format!("migration {version} is missing up SQL"))?, + )?, + down_relative: relative( + down.ok_or_else(|| format!("migration {version} is missing down SQL"))?, + )?, + }) + }) + .collect() +} + +fn validate_frozen_file( + workspace_root: &Path, + relative: &str, + expected_length: usize, + expected_sha256: &str, +) -> Result<(), String> { + let bytes = read_regular_file(workspace_root, relative)?; + if bytes.len() != expected_length || sha256_hex(&bytes) != expected_sha256 { + return Err(format!( + "frozen migration `{relative}` byte identity drifted" + )); + } + Ok(()) +} + +fn parse_rust(workspace_root: &Path, relative: &str) -> Result<syn::File, String> { + let source = read_utf8(workspace_root, relative)?; + syn::parse_file(&source).map_err(|error| format!("parse {relative}: {error}")) +} + +fn extract_string_const(source: &syn::File, name: &str) -> Result<String, String> { + source + .items + .iter() + .find_map(|item| { + let Item::Const(item) = item else { return None }; + (item.ident == name).then_some(item.expr.as_ref()) + }) + .and_then(|expr| match expr { + Expr::Lit(literal) => match &literal.lit { + syn::Lit::Str(value) => Some(value.value()), + _ => None, + }, + _ => None, + }) + .ok_or_else(|| format!("missing string const `{name}`")) +} + +fn extract_string_array_const(source: &syn::File, name: &str) -> Result<Vec<String>, String> { + let expression = source + .items + .iter() + .find_map(|item| { + let Item::Const(item) = item else { return None }; + (item.ident == name).then_some(item.expr.as_ref()) + }) + .ok_or_else(|| format!("missing array const `{name}`"))?; + let Expr::Reference(reference) = expression else { + return Err(format!("array const `{name}` must be a reference")); + }; + let Expr::Array(array) = reference.expr.as_ref() else { + return Err(format!("array const `{name}` must reference an array")); + }; + array + .elems + .iter() + .map(|element| { + let Expr::Lit(literal) = element else { + return Err(format!("array const `{name}` must contain string literals")); + }; + let syn::Lit::Str(value) = &literal.lit else { + return Err(format!("array const `{name}` must contain string literals")); + }; + Ok(value.value()) + }) + .collect() +} + +fn descriptor_for_file(workspace_root: &Path, relative: &str) -> Result<Value, String> { + descriptor_for_bytes(relative, &read_regular_file(workspace_root, relative)?) +} + +fn descriptor_for_bytes(relative: &str, bytes: &[u8]) -> Result<Value, String> { + let byte_length = + u64::try_from(bytes.len()).map_err(|_| format!("{relative} byte length is outside u64"))?; + Ok(json!({ + "path": relative, + "byte_length": byte_length, + "sha256": sha256_hex(bytes), + "hash_algorithm": HASH_ALGORITHM + })) +} + +fn read_utf8(workspace_root: &Path, relative: &str) -> Result<String, String> { + let bytes = read_regular_file(workspace_root, relative)?; + String::from_utf8(bytes).map_err(|error| format!("{relative} must be UTF-8: {error}")) +} + +fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| format!("serialize canonical JSON: {error}"))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_discovery_rejects_unknown_missing_and_non_regular_inputs() { + let root = tempfile::tempdir().expect("tempdir"); + let directory = root.path().join("migrations"); + fs::create_dir(&directory).expect("directory"); + fs::write(directory.join("0001_outbox.up.sql"), b"up").expect("up"); + fs::write(directory.join("0001_outbox.down.sql"), b"down").expect("down"); + assert_eq!( + discover_migrations_in(&directory, root.path()) + .expect("discovery") + .len(), + 1 + ); + fs::write(directory.join("README"), b"unknown").expect("unknown"); + assert!( + discover_migrations_in(&directory, root.path()) + .expect_err("unknown") + .contains("unknown migration file") + ); + fs::remove_file(directory.join("README")).expect("remove unknown"); + fs::remove_file(directory.join("0001_outbox.down.sql")).expect("remove down"); + assert!( + discover_migrations_in(&directory, root.path()) + .expect_err("missing down") + .contains("missing down SQL") + ); + fs::create_dir(directory.join("0002_future.up.sql")).expect("non-regular"); + assert!( + discover_migrations_in(&directory, root.path()) + .expect_err("non-regular") + .contains("regular file") + ); + } + + #[test] + fn canonical_workspace_authority_and_generated_artifacts_are_current() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root"); + validate_source_authority(root).expect("source authority"); + validate_vector(root).expect("vector"); + validate_release_authority(root).expect("release authority"); + validate_outbox_migration_manifest(root).expect("generated artifacts"); + } +} diff --git a/tools/xtask/src/contract/raw_source_rebuild.rs b/tools/xtask/src/contract/raw_source_rebuild.rs @@ -593,7 +593,7 @@ const DELEGATED_COMPILER_SOURCE_PINS: &[(&str, &str)] = &[ ), ( CONTRACT_LANE_SOURCE_RELATIVE, - "b3340e1b4973e6a1e02899d164ca74842757f22b6b1a03f90461532fcd844df5", + "738003cb1703baaf73a97c010c84731a42230782661c79c6a152fbb9e6fa9f6e", ), ( TOOLCHAIN_ROUTING_SOURCE_RELATIVE, diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -22,6 +22,7 @@ fn usage() { eprintln!(" cargo xtask contract food-availability-projection-manifest [--write]"); eprintln!(" cargo xtask contract source-maintenance-manifest [--write]"); eprintln!(" cargo xtask contract raw-source-rebuild-manifest [--write]"); + eprintln!(" cargo xtask contract outbox-migration-manifest [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); eprintln!(" cargo xtask dto-roots --check|--write"); eprintln!(" cargo xtask release preflight"); @@ -138,6 +139,15 @@ fn run_contract(args: &[String]) -> Result<(), String> { "raw-source-rebuild-manifest accepts no arguments or exactly --write".to_string(), ), }, + Some("outbox-migration-manifest") => match &args[1..] { + [] => contract::validate_outbox_migration_manifest(&workspace_root()), + [flag] if flag == "--write" => { + contract::write_outbox_migration_manifest(&workspace_root()) + } + _ => { + Err("outbox-migration-manifest accepts no arguments or exactly --write".to_string()) + } + }, Some("knowledge-manifest") => { if args.get(1).map(String::as_str) == Some("--write") { contract::write_knowledge_contract_manifest(&workspace_root()) @@ -264,6 +274,12 @@ mod tests { ]) .expect_err("invalid raw-source rebuild manifest mode"); assert!(invalid_raw_source_rebuild.contains("exactly --write")); + let invalid_outbox_migration = run_contract(&[ + "outbox-migration-manifest".to_string(), + "--invalid".to_string(), + ]) + .expect_err("invalid outbox migration manifest mode"); + assert!(invalid_outbox_migration.contains("exactly --write")); let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); assert!(unknown_root.contains("unknown command")); @@ -286,6 +302,17 @@ mod tests { } #[test] + fn release_preflight_reaches_contract_authorities_after_dto_roots() { + let _guard = lock_workspace(); + if let Err(error) = release_preflight_at(&workspace_root()) { + assert!( + !error.trim().is_empty(), + "release preflight blockers must be actionable" + ); + } + } + + #[test] fn lock_workspace_recovers_from_poisoned_mutex() { let handle = std::thread::spawn(|| { let _guard = workspace_lock().lock().expect("lock workspace"); @@ -365,6 +392,8 @@ mod tests { .expect("contract SourceMaintenance manifest"); run_contract(&["raw-source-rebuild-manifest".to_string()]) .expect("contract raw-source rebuild manifest"); + run_contract(&["outbox-migration-manifest".to_string()]) + .expect("contract outbox migration manifest"); run_contract(&["knowledge-manifest".to_string()]).expect("contract knowledge manifest"); } }