lib

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

commit adf4e3b820333242616351ecae46930b1cfa3470
parent 3e09cecc8afe7c312e4b9b2d62091728454aea14
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 03:15:03 +0000

outbox: govern append-only migrations

- generate runtime descriptors from one contiguous registry
- freeze exact 0001 bytes, inventory, and catalog fingerprint
- execute the governed five-profile feature matrix in Nix
- bind a closed manifest and executable migration vector

Diffstat:
MCHANGELOG.md | 11+++++++++++
Mbuild/nix/checks.nix | 11+++++++++++
Mbuild/nix/common.nix | 7+++++++
Acontracts/conformance/vectors/outbox/migration_authority.v1.json | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/outbox_feature_matrix.toml | 33+++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 15+++++++++++++++
Mcrates/outbox/README | 15+++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.json | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.schema.json | 337+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 1+
Acrates/outbox/contracts/migration_registry.v1.json | 39+++++++++++++++++++++++++++++++++++++++
Acrates/outbox/src/generated.rs | 3+++
Acrates/outbox/src/generated/outbox_migration_registry.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/src/lib.rs | 2++
Mcrates/outbox/src/migrations.rs | 65+++++++++++++++--------------------------------------------------
Acrates/outbox/tests/fixtures/migration_authority.v1.json | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/outbox/tests/migration_authority_v1_result_vector.rs | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 16++++++++++++++--
Atools/xtask/src/contract/outbox_migration.rs | 1191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 18++++++++++++++++++
20 files changed, 2592 insertions(+), 52 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -9,6 +9,17 @@ publish policy both pass for the same source revision. ### Changed +<!-- release-change: outbox-versioned-migration-authority --> +- Outbox schema initialization now uses an ordered, generated migration + registry with immutable source checksums, authenticated catalog fingerprints, + and a tamper-evident schema ledger. Existing unledgered databases are adopted + only when their complete governed catalog matches the frozen `0001_outbox` + identity; partial, changed, counterfeit, gapped, newer, or unknown outbox + state fails before governed mutation while unrelated caller tables remain + untouched. Raw migration SQL exports and live `migrate_down` were replaced by + authenticated schema status and migrate-to-current APIs. A machine-readable + matrix now governs no-default, SQLite, Tokio, event-store-adapter, and + all-feature builds. - 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/checks.nix b/build/nix/checks.nix @@ -40,6 +40,16 @@ let installPhaseCommand = "mkdir -p $out"; } ); + outboxFeatureMatrixCheck = common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + pname = "radroots-outbox-feature-matrix-check"; + doCheck = false; + buildPhaseCargoCommand = common.outboxFeatureMatrixCommand; + installPhaseCommand = "mkdir -p $out"; + } + ); blossomRasterDecodeTest = common.craneLib.mkCargoDerivation ( common.commonCraneArgs // { @@ -98,6 +108,7 @@ in cargo-test = cargoTest; blossom-no-default-check = blossomNoDefaultCheck; blossom-raster-decode-test = blossomRasterDecodeTest; + outbox-feature-matrix = outboxFeatureMatrixCheck; blossom-decoder-fuzz-smoke = common.mkRepoCheck { name = "radroots-blossom-decoder-fuzz-smoke"; runtimeInputs = common.runtimeInputs.decoderSecurityFuzz; diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -7,6 +7,7 @@ let root = ../..; cargoToml = builtins.fromTOML (builtins.readFile ../../Cargo.toml); + outboxFeatureMatrix = builtins.fromTOML (builtins.readFile ../../contracts/outbox_feature_matrix.toml); version = cargoToml.workspace.package.version; darwinBuildInputs = lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv @@ -132,6 +133,7 @@ let "radroots_nostr" "radroots_nostr_connect" "radroots_nostr_signer" + "radroots_outbox" ]; coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) @@ -225,6 +227,10 @@ let checkCommand = '' cargo check --workspace --all-targets ''; + outboxFeatureMatrixCommand = lib.concatMapStringsSep "\n" ( + profile: + "cargo check -q -p ${lib.escapeShellArg outboxFeatureMatrix.package} ${lib.escapeShellArgs profile.cargo_args}" + ) outboxFeatureMatrix.profiles; contractCommand = '' cargo run -q -p xtask -- hygiene forbidden-identifiers cargo check -q ${coreContractCargoArgs} @@ -576,6 +582,7 @@ in decoderSecurityStableCommand fuzzCargoDeps mkRepoCheck + outboxFeatureMatrixCommand releasePreflightCommand coreContractCargoArgs sharedEnv diff --git a/contracts/conformance/vectors/outbox/migration_authority.v1.json b/contracts/conformance/vectors/outbox/migration_authority.v1.json @@ -0,0 +1,107 @@ +{ + "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": "embedded_registry_and_frozen_baseline_are_exact", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "registry_shape_validation_rejects_every_structural_defect", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "additive_future_migration_advances_and_rolls_back_to_an_explicit_target", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_exact_unledgered_baseline_is_adopted_without_replaying_or_losing_caller_state", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_partial_changed_and_unknown_unledgered_catalogs_fail_before_adoption", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_counterfeit_ledger_shape_fails_before_any_schema_mutation", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_ledger_name_checksum_and_catalog_mutations_fail_closed", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_newer_history_and_governed_catalog_overflow_are_bounded_and_rejected", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_history_validation_rejects_gaps_and_unknown_versions", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_current_schema_reopen_is_idempotent_and_does_not_rewrite_history", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_rollback_wrapper_rejects_exactly_below_the_registry_floor", + "authority_path": "crates/outbox/src/schema.rs" + } + ] + }, + "cases": [ + { + "id": "fresh_initialization", + "execution": "direct_executor", + "expected_outcome": "managed_current", + "expected_error": null + }, + { + "id": "exact_unledgered_adoption", + "execution": "direct_executor", + "expected_outcome": "managed_current_without_replaying_0001", + "expected_error": null + }, + { + "id": "partial_unledgered_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_ledger_mutation", + "expected_error": "UnmanagedSchema" + }, + { + "id": "ledger_checksum_tamper_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_schema_mutation", + "expected_error": "MigrationHistoryChecksumDrift" + }, + { + "id": "newer_history_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_schema_mutation", + "expected_error": "SchemaTooNew" + }, + { + "id": "caller_state_preserved", + "execution": "direct_executor", + "expected_outcome": "managed_current_with_caller_state_preserved", + "expected_error": null + }, + { + "id": "current_reopen_no_history_write", + "execution": "direct_executor", + "expected_outcome": "managed_current_without_history_rewrite", + "expected_error": null + } + ] +} diff --git a/contracts/outbox_feature_matrix.toml b/contracts/outbox_feature_matrix.toml @@ -0,0 +1,33 @@ +schema_version = 1 +package = "radroots_outbox" + +[feature_edges] +default = ["event-store-adapter"] +sqlite = ["dep:sqlx", "sqlx/sqlite-bundled"] +runtime-tokio = ["sqlite", "sqlx/runtime-tokio"] +event-store-adapter = [ + "runtime-tokio", + "dep:radroots_event_store", + "radroots_event_store/sqlite", + "radroots_event_store/runtime-tokio", +] + +[[profiles]] +id = "no-default" +cargo_args = ["--no-default-features", "--all-targets"] + +[[profiles]] +id = "sqlite" +cargo_args = ["--no-default-features", "--features", "sqlite", "--all-targets"] + +[[profiles]] +id = "sqlite-runtime-tokio" +cargo_args = ["--no-default-features", "--features", "sqlite,runtime-tokio", "--all-targets"] + +[[profiles]] +id = "event-store-adapter" +cargo_args = ["--no-default-features", "--features", "event-store-adapter", "--all-targets"] + +[[profiles]] +id = "all-features" +cargo_args = ["--all-features", "--all-targets"] diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -532,3 +532,18 @@ semver_impacts = [ "add_conformance_vector", ] summary = "Add transport-neutral BUD-02 plus BUD-01 publication-readiness evidence with bounded complete-byte verification, canonical persisted reload, exact sequential JPEG entropy accounting plus pinned strict zune-jpeg decoding, and internally authoritative full decoding for static JPEG, PNG, and WebP rasters." + +[[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 live migrate-down with an append-only generated registry, immutable checksums, exact unledgered-0001 adoption, a tamper-evident ledger and catalog fingerprint, registry-derived version bounds, governed feature profiles, and an executable conformance contract while freezing the 0001 migration bytes." diff --git a/crates/outbox/README b/crates/outbox/README @@ -13,3 +13,18 @@ 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 identity is governed by an ordered, generated migration registry. The +original `0001_outbox` up and down files are immutable contract inputs. An +existing unledgered database is adopted only when its complete outbox catalog +matches the authenticated five-table, eight-index baseline fingerprint. The +managed ledger pins every migration name, source digest, and resulting catalog +fingerprint; unknown objects, counterfeit ledgers, history gaps, and newer +schema versions fail before governed mutation. Unrelated caller tables in a +shared SQLite database are outside the outbox fingerprint and remain untouched. + +`schema_status` authenticates the current catalog and history, while +`migrate_to_current_schema` serializes exact adoption or append-only migration. +Migration rollback descriptors remain executable in the migration-authority +test suite; the separately governed store-lifecycle checkpoint owns any future +offline production rollback surface. diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -0,0 +1,328 @@ +{ + "authority_id": "versioned_outbox_migration_authority_v1", + "contract_id": "radroots_outbox.migration_authority.v1", + "feature_matrix": { + "feature_edges": [ + { + "enables": [ + "event-store-adapter" + ], + "feature": "default" + }, + { + "enables": [ + "runtime-tokio", + "dep:radroots_event_store", + "radroots_event_store/sqlite", + "radroots_event_store/runtime-tokio" + ], + "feature": "event-store-adapter" + }, + { + "enables": [ + "sqlite", + "sqlx/runtime-tokio" + ], + "feature": "runtime-tokio" + }, + { + "enables": [ + "dep:sqlx", + "sqlx/sqlite-bundled" + ], + "feature": "sqlite" + } + ], + "package": "radroots_outbox", + "profiles": [ + { + "cargo_args": [ + "--no-default-features", + "--all-targets" + ], + "id": "no-default" + }, + { + "cargo_args": [ + "--no-default-features", + "--features", + "sqlite", + "--all-targets" + ], + "id": "sqlite" + }, + { + "cargo_args": [ + "--no-default-features", + "--features", + "sqlite,runtime-tokio", + "--all-targets" + ], + "id": "sqlite-runtime-tokio" + }, + { + "cargo_args": [ + "--no-default-features", + "--features", + "event-store-adapter", + "--all-targets" + ], + "id": "event-store-adapter" + }, + { + "cargo_args": [ + "--all-features", + "--all-targets" + ], + "id": "all-features" + } + ], + "source": { + "byte_length": 889, + "hash_algorithm": "sha256_bytes_v1", + "path": "contracts/outbox_feature_matrix.toml", + "sha256": "c18b8f1b3c17e732def304dd8273a006bef061ee8cf40223e6d65ddfa5ab8084" + } + }, + "generated_runtime": { + "byte_length": 1452, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/generated/outbox_migration_registry.rs", + "sha256": "616be98c5ba8054e08a6109357a713a39a0086040272a1f6165f54530b469a91" + }, + "ledger": { + "adoption": "exact_unledgered_0001_catalog_only_v1", + "catalog_fingerprint": "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1", + "migration_transaction": "begin_immediate_v1", + "name": "radroots_outbox_schema_migrations", + "reserved_prefix": "outbox_", + "rollback_transaction": "begin_exclusive_test_executor_v1" + }, + "manifest_schema": { + "byte_length": 7461, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/contracts/migration_authority_v1.manifest.schema.json", + "sha256": "65e6a2e5309d7f31b05b5c80d6e1b548aea82f32fa95488a79a0660f73b866ac" + }, + "migrations": [ + { + "down": { + "byte_length": 159, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/migrations/0001_outbox.down.sql", + "sha256": "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61" + }, + "name": "outbox", + "owned_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" + ], + "owned_tables": [ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations" + ], + "schema_sha256": "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293", + "up": { + "byte_length": 5470, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/migrations/0001_outbox.up.sql", + "sha256": "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa" + }, + "version": 1 + } + ], + "registry_source": { + "byte_length": 1329, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/contracts/migration_registry.v1.json", + "sha256": "c46e65d7274ea58d0574efaa6694e6f65702f9cf2cc49000af1278ae7c123e0e" + }, + "release": { + "change_id": "outbox-versioned-migration-authority", + "changelog": "CHANGELOG.md", + "record": "contracts/releases/1.0.0-alpha.1.toml" + }, + "result_vector": { + "canonical": { + "byte_length": 3778, + "hash_algorithm": "sha256_bytes_v1", + "path": "contracts/conformance/vectors/outbox/migration_authority.v1.json", + "sha256": "5240770a1298045de82c080a5d85d3222b5ff27d10ce6f8a8e901853daed0d16" + }, + "executor": { + "byte_length": 11241, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "sha256": "0767730a83695b3145b59bdb8b8297db4b44a5a6634cb216231d8d24a5de1022" + }, + "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" + }, + "schema_version": 1, + "source_files": [ + { + "file": { + "byte_length": 1444, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/Cargo.toml", + "sha256": "77f3c8e51aa2f3676c679803b4d7388dba67987a146169ba05c8d715b713d67d" + }, + "role": "outbox_package_manifest" + }, + { + "file": { + "byte_length": 1377, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/lib.rs", + "sha256": "ad57e5979036ba29daad640acf898af7544aa06320112a063ffbc642ad886130" + }, + "role": "outbox_public_surface" + }, + { + "file": { + "byte_length": 8502, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/error.rs", + "sha256": "7d10bfbf43dfbccf2433a3e67faac1cbab1fae530b6acb5d5ec06e161d6efe5b" + }, + "role": "outbox_error_surface" + }, + { + "file": { + "byte_length": 67, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/generated.rs", + "sha256": "e26a394f0b554f564f2b4213aa55f9f6c17e5b33930307aaae29db0c85277344" + }, + "role": "generated_module" + }, + { + "file": { + "byte_length": 22852, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/migrations.rs", + "sha256": "70614a806a7443e7077ba585f638483f44137a6ba3a1ade8e5571e10dc2c9da5" + }, + "role": "migration_registry_runtime" + }, + { + "file": { + "byte_length": 51902, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/schema.rs", + "sha256": "f2cb9a8f0f9416aa02b18eeb966d5dae54373c92ae621173e299a98c79b57191" + }, + "role": "schema_runtime" + }, + { + "file": { + "byte_length": 326431, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/src/store.rs", + "sha256": "47824d14cea2d00f0310f0676f3cd0b5825d642bea668b99c8194c53b0d76107" + }, + "role": "store_integration" + }, + { + "file": { + "byte_length": 11241, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/tests/migration_authority_v1_result_vector.rs", + "sha256": "0767730a83695b3145b59bdb8b8297db4b44a5a6634cb216231d8d24a5de1022" + }, + "role": "vector_executor" + }, + { + "file": { + "byte_length": 46017, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/contract/outbox_migration.rs", + "sha256": "f41a2ac2486210e9cd6733149378d89cb32278730e8c7188207e63bc2d949faa" + }, + "role": "contract_governance" + }, + { + "file": { + "byte_length": 485783, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/contract.rs", + "sha256": "cb3cbb45378551db7a3b1309dbc742d81f90af0c8a067065bb4e8698b744c5ef" + }, + "role": "contract_dispatch" + }, + { + "file": { + "byte_length": 23735, + "hash_algorithm": "sha256_bytes_v1", + "path": "tools/xtask/src/main.rs", + "sha256": "5f08b60c2d35a0a2a2b2743c9906538963282a5ed93a03a76087a9bfd282c07d" + }, + "role": "xtask_dispatch" + }, + { + "file": { + "byte_length": 21097, + "hash_algorithm": "sha256_bytes_v1", + "path": "build/nix/common.nix", + "sha256": "1018f1b95b91ad18f0664b2d44f0694a67e00253a3e93f7b9da4b3b75caa3a86" + }, + "role": "nix_feature_executor" + }, + { + "file": { + "byte_length": 4588, + "hash_algorithm": "sha256_bytes_v1", + "path": "build/nix/checks.nix", + "sha256": "830e46d81576372b3a7dd63c911948051defb6327db8212be8d134404544ef5e" + }, + "role": "nix_feature_check" + }, + { + "file": { + "byte_length": 1663, + "hash_algorithm": "sha256_bytes_v1", + "path": "crates/outbox/README", + "sha256": "4bcb2c1860e3c6b75436a6dce722e91ddcb4de70c624f3141cb8167f79dca822" + }, + "role": "outbox_readme" + }, + { + "file": { + "byte_length": 24634, + "hash_algorithm": "sha256_bytes_v1", + "path": "contracts/releases/1.0.0-alpha.1.toml", + "sha256": "755db4ea8775c1ad4c7faa223ebb3c06b127d00ccff3b51034fc6bb42d13d272" + }, + "role": "release_record" + }, + { + "file": { + "byte_length": 35643, + "hash_algorithm": "sha256_bytes_v1", + "path": "CHANGELOG.md", + "sha256": "0581d0f0e95a8a4f81b78b2810a231a9061bb4057ca699201355b3ae3257ff4f" + }, + "role": "release_notes" + } + ], + "version_bounds": { + "current": 1, + "derivation": "first_and_last_ordered_registry_entries_v1", + "minimum": 1 + } +} 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,337 @@ +{ + "$defs": { + "feature_edge": { + "additionalProperties": false, + "properties": { + "enables": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "feature": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "feature", + "enables" + ], + "type": "object" + }, + "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" + }, + "migration": { + "additionalProperties": false, + "properties": { + "down": { + "$ref": "#/$defs/file" + }, + "name": { + "pattern": "^[a-z0-9_]+$", + "type": "string" + }, + "owned_objects": { + "items": { + "pattern": "^outbox_[a-z0-9_]+$", + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "owned_tables": { + "items": { + "pattern": "^outbox_[a-z0-9_]+$", + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "schema_sha256": { + "$ref": "#/$defs/sha256" + }, + "up": { + "$ref": "#/$defs/file" + }, + "version": { + "maximum": 9999, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "name", + "up", + "down", + "schema_sha256", + "owned_objects", + "owned_tables" + ], + "type": "object" + }, + "profile": { + "additionalProperties": false, + "properties": { + "cargo_args": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "cargo_args" + ], + "type": "object" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "source_file": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/$defs/file" + }, + "role": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "role", + "file" + ], + "type": "object" + } + }, + "$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" + }, + "feature_matrix": { + "additionalProperties": false, + "properties": { + "feature_edges": { + "items": { + "$ref": "#/$defs/feature_edge" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "package": { + "const": "radroots_outbox" + }, + "profiles": { + "items": { + "$ref": "#/$defs/profile" + }, + "maxItems": 5, + "minItems": 5, + "type": "array" + }, + "source": { + "$ref": "#/$defs/file" + } + }, + "required": [ + "source", + "package", + "feature_edges", + "profiles" + ], + "type": "object" + }, + "generated_runtime": { + "$ref": "#/$defs/file" + }, + "ledger": { + "additionalProperties": false, + "properties": { + "adoption": { + "const": "exact_unledgered_0001_catalog_only_v1" + }, + "catalog_fingerprint": { + "const": "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1" + }, + "migration_transaction": { + "const": "begin_immediate_v1" + }, + "name": { + "const": "radroots_outbox_schema_migrations" + }, + "reserved_prefix": { + "const": "outbox_" + }, + "rollback_transaction": { + "const": "begin_exclusive_test_executor_v1" + } + }, + "required": [ + "name", + "reserved_prefix", + "catalog_fingerprint", + "migration_transaction", + "rollback_transaction", + "adoption" + ], + "type": "object" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "migrations": { + "items": { + "$ref": "#/$defs/migration" + }, + "maxItems": 9999, + "minItems": 1, + "type": "array" + }, + "registry_source": { + "$ref": "#/$defs/file" + }, + "release": { + "additionalProperties": false, + "properties": { + "change_id": { + "const": "outbox-versioned-migration-authority" + }, + "changelog": { + "const": "CHANGELOG.md" + }, + "record": { + "const": "contracts/releases/1.0.0-alpha.1.toml" + } + }, + "required": [ + "change_id", + "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" + }, + "schema_version": { + "const": 1 + }, + "source_files": { + "items": { + "$ref": "#/$defs/source_file" + }, + "maxItems": 16, + "minItems": 16, + "type": "array" + }, + "version_bounds": { + "additionalProperties": false, + "properties": { + "current": { + "minimum": 1, + "type": "integer" + }, + "derivation": { + "const": "first_and_last_ordered_registry_entries_v1" + }, + "minimum": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "minimum", + "current", + "derivation" + ], + "type": "object" + } + }, + "required": [ + "schema_version", + "contract_id", + "authority_id", + "manifest_schema", + "registry_source", + "version_bounds", + "ledger", + "migrations", + "feature_matrix", + "generated_runtime", + "result_vector", + "source_files", + "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 @@ +362f12c15cc5772cf4c31d70acd9911d767b3af734e3a99b721f3aff779a40ce diff --git a/crates/outbox/contracts/migration_registry.v1.json b/crates/outbox/contracts/migration_registry.v1.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "contract_id": "radroots_outbox.migration_authority.v1", + "migrations": [ + { + "version": 1, + "name": "outbox", + "up_path": "crates/outbox/migrations/0001_outbox.up.sql", + "down_path": "crates/outbox/migrations/0001_outbox.down.sql", + "up_byte_length": 5470, + "down_byte_length": 159, + "up_sha256": "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa", + "down_sha256": "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61", + "schema_sha256": "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293", + "owned_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" + ], + "owned_tables": [ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations" + ] + } + ] +} 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_registry; diff --git a/crates/outbox/src/generated/outbox_migration_registry.rs b/crates/outbox/src/generated/outbox_migration_registry.rs @@ -0,0 +1,39 @@ +// @generated by `cargo xtask contract outbox-migration-manifest --write`; do not edit. + +use crate::migrations::OutboxMigration; + +const OUTBOX_MIGRATION_0001: 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: 5470, + down_len: 159, + up_sha256: "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa", + down_sha256: "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61", + schema_sha256: "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293", + owned_object_names: &[ + "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", + ], + owned_table_names: &[ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations", + ], +}; + +pub(crate) const OUTBOX_MIGRATIONS: &[OutboxMigration] = &[OUTBOX_MIGRATION_0001]; diff --git a/crates/outbox/src/lib.rs b/crates/outbox/src/lib.rs @@ -3,6 +3,8 @@ mod error; #[cfg(feature = "sqlite")] +mod generated; +#[cfg(feature = "sqlite")] mod migrations; mod model; #[cfg(feature = "sqlite")] diff --git a/crates/outbox/src/migrations.rs b/crates/outbox/src/migrations.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] use crate::RadrootsOutboxError; +pub(crate) use crate::generated::outbox_migration_registry::OUTBOX_MIGRATIONS; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -30,30 +31,6 @@ pub(crate) const OUTBOX_LEDGER_CREATE_DDL: &str = 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, @@ -69,20 +46,6 @@ pub(crate) struct OutboxMigration { 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: 5_470, - down_len: 159, - up_sha256: "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa", - down_sha256: "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61", - schema_sha256: "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293", - owned_object_names: OUTBOX_BASELINE_OBJECT_NAMES, - owned_table_names: OUTBOX_BASELINE_TABLE_NAMES, -}]; - pub(crate) fn migration_for_version( registry: &[OutboxMigration], version: u32, @@ -391,16 +354,18 @@ mod tests { 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() - ); + for (discovered, embedded) in discovered.iter().zip(OUTBOX_MIGRATIONS) { + assert_eq!(discovered.version, embedded.version); + assert_eq!(discovered.name, embedded.name); + assert_eq!( + fs::read(&discovered.up).expect("up bytes"), + embedded.up_sql.as_bytes() + ); + assert_eq!( + fs::read(&discovered.down).expect("down bytes"), + embedded.down_sql.as_bytes() + ); + } let temp = tempfile::tempdir().expect("tempdir"); fs::create_dir(temp.path().join("migrations")).expect("migrations"); @@ -445,8 +410,8 @@ mod tests { OUTBOX_MIGRATIONS[0].down_sha256, "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61" ); - assert_eq!(OUTBOX_BASELINE_OBJECT_NAMES.len(), 13); - assert_eq!(OUTBOX_BASELINE_TABLE_NAMES.len(), 5); + assert_eq!(OUTBOX_MIGRATIONS[0].owned_object_names.len(), 13); + assert_eq!(OUTBOX_MIGRATIONS[0].owned_table_names.len(), 5); } fn assert_registry_defect(result: Result<(), RadrootsOutboxError>) -> String { diff --git a/crates/outbox/tests/fixtures/migration_authority.v1.json b/crates/outbox/tests/fixtures/migration_authority.v1.json @@ -0,0 +1,107 @@ +{ + "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": "embedded_registry_and_frozen_baseline_are_exact", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "registry_shape_validation_rejects_every_structural_defect", + "authority_path": "crates/outbox/src/migrations.rs" + }, + { + "authority": "additive_future_migration_advances_and_rolls_back_to_an_explicit_target", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_exact_unledgered_baseline_is_adopted_without_replaying_or_losing_caller_state", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_partial_changed_and_unknown_unledgered_catalogs_fail_before_adoption", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_counterfeit_ledger_shape_fails_before_any_schema_mutation", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_ledger_name_checksum_and_catalog_mutations_fail_closed", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_newer_history_and_governed_catalog_overflow_are_bounded_and_rejected", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_history_validation_rejects_gaps_and_unknown_versions", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_current_schema_reopen_is_idempotent_and_does_not_rewrite_history", + "authority_path": "crates/outbox/src/schema.rs" + }, + { + "authority": "migration_rollback_wrapper_rejects_exactly_below_the_registry_floor", + "authority_path": "crates/outbox/src/schema.rs" + } + ] + }, + "cases": [ + { + "id": "fresh_initialization", + "execution": "direct_executor", + "expected_outcome": "managed_current", + "expected_error": null + }, + { + "id": "exact_unledgered_adoption", + "execution": "direct_executor", + "expected_outcome": "managed_current_without_replaying_0001", + "expected_error": null + }, + { + "id": "partial_unledgered_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_ledger_mutation", + "expected_error": "UnmanagedSchema" + }, + { + "id": "ledger_checksum_tamper_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_schema_mutation", + "expected_error": "MigrationHistoryChecksumDrift" + }, + { + "id": "newer_history_rejected", + "execution": "direct_executor", + "expected_outcome": "rejected_before_schema_mutation", + "expected_error": "SchemaTooNew" + }, + { + "id": "caller_state_preserved", + "execution": "direct_executor", + "expected_outcome": "managed_current_with_caller_state_preserved", + "expected_error": null + }, + { + "id": "current_reopen_no_history_write", + "execution": "direct_executor", + "expected_outcome": "managed_current_without_history_rewrite", + "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,299 @@ +#![forbid(unsafe_code)] +#![cfg(feature = "sqlite")] + +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::path::{Path, PathBuf}; + +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"); + +#[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 direct_pool(path: &Path) -> SqlitePool { + SqlitePoolOptions::new() + .max_connections(1) + .connect_with( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("direct pool") +} + +fn database_path(case_id: &str) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join(format!("{case_id}.sqlite")); + (directory, path) +} + +async fn assert_managed_current(store: &RadrootsOutbox) { + assert_eq!( + store.schema_status().await.expect("schema status"), + RadrootsOutboxSchemaStatus::Managed { + version: RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT, + } + ); +} + +async fn execute_case(case: &Case) { + assert_eq!(case.execution, "direct_executor"); + let (_directory, path) = database_path(&case.id); + match case.id.as_str() { + "fresh_initialization" => { + let store = RadrootsOutbox::open_file(&path).await.expect("fresh store"); + assert_managed_current(&store).await; + assert_eq!(case.expected_outcome, "managed_current"); + } + "exact_unledgered_adoption" => { + let pool = direct_pool(&path).await; + sqlx::raw_sql(BASELINE_UP) + .execute(&pool) + .await + .expect("baseline"); + 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"); + pool.close().await; + + let store = RadrootsOutbox::open_file(&path).await.expect("adoption"); + assert_managed_current(&store).await; + drop(store); + let pool = direct_pool(&path).await; + let operations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox_operations") + .fetch_one(&pool) + .await + .expect("legacy row count"); + let history: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_outbox_schema_migrations") + .fetch_one(&pool) + .await + .expect("history count"); + assert_eq!(operations, 1); + assert_eq!(history, i64::from(RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT)); + assert_eq!( + case.expected_outcome, + "managed_current_without_replaying_0001" + ); + } + "partial_unledgered_rejected" => { + let pool = direct_pool(&path).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"); + pool.close().await; + assert!(matches!( + RadrootsOutbox::open_file(&path).await, + Err(RadrootsOutboxError::UnmanagedSchema { .. }) + )); + let pool = direct_pool(&path).await; + let ledgers: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'radroots_outbox_schema_migrations'", + ) + .fetch_one(&pool) + .await + .expect("ledger count"); + assert_eq!(ledgers, 0); + assert_eq!(case.expected_outcome, "rejected_before_ledger_mutation"); + assert_eq!(case.expected_error.as_deref(), Some("UnmanagedSchema")); + } + "ledger_checksum_tamper_rejected" => { + let store = RadrootsOutbox::open_file(&path).await.expect("store"); + drop(store); + let pool = direct_pool(&path).await; + sqlx::query( + "UPDATE radroots_outbox_schema_migrations SET up_sha256 = ? WHERE version = 1", + ) + .bind("b".repeat(64)) + .execute(&pool) + .await + .expect("tamper"); + pool.close().await; + assert!(matches!( + RadrootsOutbox::open_file(&path).await, + Err(RadrootsOutboxError::MigrationHistoryChecksumDrift { .. }) + )); + assert_eq!(case.expected_outcome, "rejected_before_schema_mutation"); + assert_eq!( + case.expected_error.as_deref(), + Some("MigrationHistoryChecksumDrift") + ); + } + "newer_history_rejected" => { + let store = RadrootsOutbox::open_file(&path).await.expect("store"); + drop(store); + let pool = direct_pool(&path).await; + let newer = i64::from(RADROOTS_OUTBOX_SCHEMA_VERSION_CURRENT) + 1; + sqlx::query( + "INSERT INTO radroots_outbox_schema_migrations(version, name, up_sha256, down_sha256, schema_sha256) VALUES (?, 'future', ?, ?, ?)", + ) + .bind(newer) + .bind("a".repeat(64)) + .bind("b".repeat(64)) + .bind("c".repeat(64)) + .execute(&pool) + .await + .expect("future row"); + pool.close().await; + assert!(matches!( + RadrootsOutbox::open_file(&path).await, + Err(RadrootsOutboxError::SchemaTooNew { database, .. }) if database == newer + )); + assert_eq!(case.expected_outcome, "rejected_before_schema_mutation"); + assert_eq!(case.expected_error.as_deref(), Some("SchemaTooNew")); + } + "caller_state_preserved" => { + let pool = direct_pool(&path).await; + sqlx::raw_sql( + "CREATE TABLE caller_state(value TEXT NOT NULL); INSERT INTO caller_state VALUES ('preserved');", + ) + .execute(&pool) + .await + .expect("caller state"); + pool.close().await; + let store = RadrootsOutbox::open_file(&path) + .await + .expect("fresh migration"); + assert_managed_current(&store).await; + drop(store); + let pool = direct_pool(&path).await; + let value: String = sqlx::query_scalar("SELECT value FROM caller_state") + .fetch_one(&pool) + .await + .expect("caller value"); + assert_eq!(value, "preserved"); + assert_eq!( + case.expected_outcome, + "managed_current_with_caller_state_preserved" + ); + } + "current_reopen_no_history_write" => { + let store = RadrootsOutbox::open_file(&path).await.expect("store"); + assert_managed_current(&store).await; + drop(store); + let before = read_history(&path).await; + let reopened = RadrootsOutbox::open_file(&path).await.expect("reopen"); + assert_managed_current(&reopened).await; + drop(reopened); + assert_eq!(read_history(&path).await, before); + assert_eq!( + case.expected_outcome, + "managed_current_without_history_rewrite" + ); + } + other => panic!("unknown direct vector case `{other}`"), + } + assert_eq!( + case.expected_error.is_some(), + case.expected_outcome.starts_with("rejected") + ); +} + +async fn read_history(path: &Path) -> Vec<(i64, String, String, String, String)> { + let pool = direct_pool(path).await; + let rows = sqlx::query_as( + "SELECT version, name, up_sha256, down_sha256, schema_sha256 FROM radroots_outbox_schema_migrations ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("history rows"); + pool.close().await; + rows +} + +#[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, + 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(), 7); +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -9,6 +9,7 @@ mod comment_authority; mod deletion_authority; mod food_availability_projection; mod nip09_reconciliation; +mod outbox_migration; #[allow(dead_code)] mod phase1_publication_allowlist; mod phase1_publication_media_readiness; @@ -34,6 +35,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 phase1_publication_media_readiness::{ validate_immutable_blossom_publication_readiness_predecessor, validate_immutable_phase1_publication_allowlist_predecessor, @@ -82,6 +86,7 @@ pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), S validate_release_provenance_schema(workspace_root)?; validate_phase1_publication_media_readiness_manifest(workspace_root)?; validate_blossom_raster_decoder_security_manifest(workspace_root)?; + validate_outbox_migration_manifest(workspace_root)?; validate_knowledge_contract_manifest(workspace_root) } @@ -143,11 +148,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"; @@ -169,7 +177,7 @@ const REPLICA_CONTRACT_RELATIVE: &str = "contracts/replica.toml"; const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 29] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 30] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -191,6 +199,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 29] = [ "crates/blossom/tests/fixtures/raster_decoder_security.v1.json", ), ( + OUTBOX_MIGRATION_CONFORMANCE_VECTOR_RELATIVE, + "crates/outbox/tests/fixtures/migration_authority.v1.json", + ), + ( "contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json", "crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json", ), diff --git a/tools/xtask/src/contract/outbox_migration.rs b/tools/xtask/src/contract/outbox_migration.rs @@ -0,0 +1,1191 @@ +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::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +const CONTRACT_ID: &str = "radroots_outbox.migration_authority.v1"; +const AUTHORITY_ID: &str = "versioned_outbox_migration_authority_v1"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const WRITE_COMMAND: &str = "cargo xtask contract outbox-migration-manifest --write"; +const REGISTRY_RELATIVE: &str = "crates/outbox/contracts/migration_registry.v1.json"; +const MIGRATION_DIRECTORY_RELATIVE: &str = "crates/outbox/migrations"; +const FEATURE_MATRIX_RELATIVE: &str = "contracts/outbox_feature_matrix.toml"; +const OUTBOX_CARGO_RELATIVE: &str = "crates/outbox/Cargo.toml"; +const GENERATED_RUNTIME_RELATIVE: &str = "crates/outbox/src/generated/outbox_migration_registry.rs"; +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 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 VECTOR_EXECUTOR_ID: &str = "radroots_outbox.migration_authority_v1.result_vector_executor.v1"; +const VECTOR_EXECUTOR_TEST: &str = "migration_authority_v1_result_vector"; +const RELEASE_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 FROZEN_UP_LENGTH: usize = 5_470; +const FROZEN_DOWN_LENGTH: usize = 159; +const FROZEN_UP_SHA256: &str = "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa"; +const FROZEN_DOWN_SHA256: &str = "5d56f978f9172dc5ecbc5043a6c286c75926974d8a2a9e44fffa7c134829af61"; +const FROZEN_SCHEMA_SHA256: &str = + "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293"; +const FROZEN_UP_PATH: &str = "crates/outbox/migrations/0001_outbox.up.sql"; +const FROZEN_DOWN_PATH: &str = "crates/outbox/migrations/0001_outbox.down.sql"; +const LEDGER_NAME: &str = "radroots_outbox_schema_migrations"; +const RESERVED_PREFIX: &str = "outbox_"; + +const FROZEN_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 FROZEN_TABLES: &[&str] = &[ + "outbox_delivery_attempt", + "outbox_delivery_plan", + "outbox_delivery_target", + "outbox_event", + "outbox_operations", +]; + +const SOURCE_FILES: &[(&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_module", "crates/outbox/src/generated.rs"), + ( + "migration_registry_runtime", + "crates/outbox/src/migrations.rs", + ), + ("schema_runtime", "crates/outbox/src/schema.rs"), + ("store_integration", "crates/outbox/src/store.rs"), + ("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_feature_executor", "build/nix/common.nix"), + ("nix_feature_check", "build/nix/checks.nix"), + ("outbox_readme", "crates/outbox/README"), + ("release_record", RELEASE_RELATIVE), + ("release_notes", CHANGELOG_RELATIVE), +]; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Registry { + schema_version: u32, + contract_id: String, + migrations: Vec<RegistryMigration>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct RegistryMigration { + version: u32, + name: String, + up_path: String, + down_path: String, + up_byte_length: usize, + down_byte_length: usize, + up_sha256: String, + down_sha256: String, + schema_sha256: String, + owned_objects: Vec<String>, + owned_tables: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct FeatureMatrix { + schema_version: u32, + package: String, + feature_edges: BTreeMap<String, Vec<String>>, + profiles: Vec<FeatureProfile>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct FeatureProfile { + id: String, + cargo_args: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Vector { + schema_version: u32, + contract_id: String, + executor: VectorExecutor, + delegated_suite: DelegatedSuite, + cases: Vec<VectorCase>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorExecutor { + id: String, + path: String, + test: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DelegatedSuite { + lane: String, + package: String, + authorities: Vec<DelegatedAuthority>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DelegatedAuthority { + authority: String, + authority_path: String, +} + +#[derive(Debug, Deserialize)] +#[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" + )); + } + Ok(()) +} + +fn expected_artifacts(workspace_root: &Path) -> Result<Vec<GeneratedArtifact>, String> { + let registry = load_and_validate_registry(workspace_root)?; + let matrix = load_and_validate_feature_matrix(workspace_root)?; + validate_vector(workspace_root)?; + validate_release_authority(workspace_root)?; + let generated_runtime = generated_runtime_registry(&registry)?; + let schema_bytes = canonical_json_bytes(&manifest_schema())?; + let manifest = expected_manifest( + workspace_root, + &registry, + &matrix, + &schema_bytes, + generated_runtime.as_bytes(), + )?; + let manifest_bytes = canonical_json_bytes(&manifest)?; + let manifest_sha256 = sha256_hex(&manifest_bytes); + let vector = read_regular_file(workspace_root, VECTOR_RELATIVE)?; + Ok(vec![ + GeneratedArtifact { + relative: GENERATED_RUNTIME_RELATIVE, + contents: generated_runtime.into_bytes(), + }, + 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: VECTOR_MIRROR_RELATIVE, + contents: vector, + }, + ]) +} + +fn load_and_validate_registry(workspace_root: &Path) -> Result<Registry, String> { + let bytes = read_regular_file(workspace_root, REGISTRY_RELATIVE)?; + let registry: Registry = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {REGISTRY_RELATIVE}: {error}"))?; + validate_registry_shape(&registry)?; + let discovered = discover_migrations(workspace_root)?; + if discovered.len() != registry.migrations.len() { + return Err(format!( + "outbox migration discovery found {} pairs but registry declares {}", + discovered.len(), + registry.migrations.len() + )); + } + for (discovered, migration) in discovered.iter().zip(&registry.migrations) { + if discovered.version != migration.version + || discovered.name != migration.name + || discovered.up_relative != migration.up_path + || discovered.down_relative != migration.down_path + { + return Err(format!( + "outbox migration discovery does not match registry version {}", + migration.version + )); + } + validate_declared_file( + workspace_root, + migration.version, + "up", + &migration.up_path, + migration.up_byte_length, + &migration.up_sha256, + )?; + validate_declared_file( + workspace_root, + migration.version, + "down", + &migration.down_path, + migration.down_byte_length, + &migration.down_sha256, + )?; + } + Ok(registry) +} + +fn validate_registry_shape(registry: &Registry) -> Result<(), String> { + if registry.schema_version != 1 || registry.contract_id != CONTRACT_ID { + return Err("outbox migration registry identity must remain v1".to_owned()); + } + if registry.migrations.is_empty() { + return Err("outbox migration registry must not be empty".to_owned()); + } + let mut names = BTreeSet::new(); + let mut objects = BTreeSet::new(); + let mut tables = BTreeSet::new(); + for (index, migration) in registry.migrations.iter().enumerate() { + let expected_version = u32::try_from(index) + .map_err(|_| "outbox migration registry is too large".to_owned())? + .checked_add(1) + .ok_or_else(|| "outbox migration version overflow".to_owned())?; + if migration.version != expected_version { + return Err(format!( + "outbox migration registry gap: expected {expected_version}, found {}", + migration.version + )); + } + validate_migration_name(&migration.name)?; + if !names.insert(migration.name.as_str()) { + return Err(format!( + "outbox migration name `{}` is duplicated", + migration.name + )); + } + validate_sha256(&migration.up_sha256, migration.version, "up")?; + validate_sha256(&migration.down_sha256, migration.version, "down")?; + validate_sha256(&migration.schema_sha256, migration.version, "schema")?; + if migration.owned_objects.is_empty() || migration.owned_tables.is_empty() { + return Err(format!( + "outbox migration {} must own objects and tables", + migration.version + )); + } + validate_sorted_unique_names( + migration.version, + "object", + &migration.owned_objects, + &mut objects, + )?; + validate_sorted_unique_names( + migration.version, + "table", + &migration.owned_tables, + &mut tables, + )?; + if migration + .owned_tables + .iter() + .any(|table| !migration.owned_objects.contains(table)) + { + return Err(format!( + "outbox migration {} table inventory is not contained in its object inventory", + migration.version + )); + } + } + validate_frozen_baseline(&registry.migrations[0]) +} + +fn validate_frozen_baseline(migration: &RegistryMigration) -> Result<(), String> { + let objects = migration + .owned_objects + .iter() + .map(String::as_str) + .collect::<Vec<_>>(); + let tables = migration + .owned_tables + .iter() + .map(String::as_str) + .collect::<Vec<_>>(); + if migration.version != 1 + || migration.name != "outbox" + || migration.up_path != FROZEN_UP_PATH + || migration.down_path != FROZEN_DOWN_PATH + || migration.up_byte_length != FROZEN_UP_LENGTH + || migration.down_byte_length != FROZEN_DOWN_LENGTH + || migration.up_sha256 != FROZEN_UP_SHA256 + || migration.down_sha256 != FROZEN_DOWN_SHA256 + || migration.schema_sha256 != FROZEN_SCHEMA_SHA256 + || objects != FROZEN_OBJECTS + || tables != FROZEN_TABLES + { + return Err("frozen outbox migration 0001 authority changed".to_owned()); + } + Ok(()) +} + +fn validate_migration_name(name: &str) -> Result<(), String> { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("invalid outbox migration name `{name}`")); + } + Ok(()) +} + +fn validate_sorted_unique_names( + version: u32, + kind: &str, + names: &[String], + aggregate: &mut BTreeSet<String>, +) -> Result<(), String> { + if names.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(format!( + "outbox migration {version} {kind} inventory must be sorted and unique" + )); + } + for name in names { + if !name.starts_with(RESERVED_PREFIX) + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + || !aggregate.insert(name.clone()) + { + return Err(format!( + "outbox migration {version} has invalid or repeated {kind} `{name}`" + )); + } + } + Ok(()) +} + +fn validate_declared_file( + workspace_root: &Path, + version: u32, + direction: &str, + 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!( + "outbox migration {version} {direction} bytes do not match the registry" + )); + } + Ok(()) +} + +fn validate_sha256(value: &str, version: u32, field: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "outbox migration {version} has invalid {field} SHA-256" + )); + } + Ok(()) +} + +fn discover_migrations(workspace_root: &Path) -> Result<Vec<DiscoveredMigration>, String> { + let directory = workspace_root.join(MIGRATION_DIRECTORY_RELATIVE); + let mut entries = fs::read_dir(&directory) + .map_err(|error| format!("read {}: {error}", directory.display()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|error| format!("read outbox migration entry: {error}"))?; + entries.sort_by_key(fs::DirEntry::file_name); + let mut pairs = BTreeMap::<(u32, String), (Option<String>, Option<String>)>::new(); + for entry in entries { + let file_type = entry + .file_type() + .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?; + if !file_type.is_file() || file_type.is_symlink() { + return Err(format!( + "outbox migration input must be a regular file: {}", + entry.path().display() + )); + } + let filename = entry + .file_name() + .into_string() + .map_err(|_| "outbox migration filename is not 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 outbox migration file `{filename}`")); + }; + let (version, name) = stem + .split_once('_') + .ok_or_else(|| format!("invalid outbox migration filename `{filename}`"))?; + if version.len() != 4 || !version.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("invalid outbox migration filename `{filename}`")); + } + validate_migration_name(name)?; + let version = version + .parse::<u32>() + .map_err(|error| format!("parse outbox migration version: {error}"))?; + let relative = format!("{MIGRATION_DIRECTORY_RELATIVE}/{filename}"); + 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(relative).is_some() { + return Err(format!( + "duplicate outbox migration {version} {direction} file" + )); + } + } + pairs + .into_iter() + .map(|((version, name), (up, down))| { + Ok(DiscoveredMigration { + version, + name, + up_relative: up + .ok_or_else(|| format!("outbox migration {version} is missing up SQL"))?, + down_relative: down + .ok_or_else(|| format!("outbox migration {version} is missing down SQL"))?, + }) + }) + .collect() +} + +fn load_and_validate_feature_matrix(workspace_root: &Path) -> Result<FeatureMatrix, String> { + let bytes = read_regular_file(workspace_root, FEATURE_MATRIX_RELATIVE)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("decode {FEATURE_MATRIX_RELATIVE}: {error}"))?; + let matrix: FeatureMatrix = toml::from_str(source) + .map_err(|error| format!("parse {FEATURE_MATRIX_RELATIVE}: {error}"))?; + if matrix.schema_version != 1 || matrix.package != "radroots_outbox" { + return Err("outbox feature matrix identity must remain v1".to_owned()); + } + let expected_profiles = [ + ( + "no-default", + ["--no-default-features", "--all-targets"].as_slice(), + ), + ( + "sqlite", + [ + "--no-default-features", + "--features", + "sqlite", + "--all-targets", + ] + .as_slice(), + ), + ( + "sqlite-runtime-tokio", + [ + "--no-default-features", + "--features", + "sqlite,runtime-tokio", + "--all-targets", + ] + .as_slice(), + ), + ( + "event-store-adapter", + [ + "--no-default-features", + "--features", + "event-store-adapter", + "--all-targets", + ] + .as_slice(), + ), + ( + "all-features", + ["--all-features", "--all-targets"].as_slice(), + ), + ]; + if matrix.profiles.len() != expected_profiles.len() { + return Err("outbox feature matrix must declare exactly five profiles".to_owned()); + } + for (profile, (expected_id, expected_args)) in matrix.profiles.iter().zip(expected_profiles) { + if profile.id != expected_id + || profile + .cargo_args + .iter() + .map(String::as_str) + .ne(expected_args.iter().copied()) + { + return Err(format!( + "outbox feature profile `{}` does not match governed arguments", + profile.id + )); + } + } + + let cargo_bytes = read_regular_file(workspace_root, OUTBOX_CARGO_RELATIVE)?; + let cargo_source = std::str::from_utf8(&cargo_bytes) + .map_err(|error| format!("decode {OUTBOX_CARGO_RELATIVE}: {error}"))?; + let cargo: toml::Value = toml::from_str(cargo_source) + .map_err(|error| format!("parse {OUTBOX_CARGO_RELATIVE}: {error}"))?; + let cargo_features = cargo + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| "outbox Cargo manifest is missing [features]".to_owned())?; + let mut actual_edges = BTreeMap::new(); + for (feature, value) in cargo_features { + let edges = value + .as_array() + .ok_or_else(|| format!("outbox Cargo feature `{feature}` must be an array"))? + .iter() + .map(|edge| { + edge.as_str().map(str::to_owned).ok_or_else(|| { + format!("outbox Cargo feature `{feature}` has a non-string edge") + }) + }) + .collect::<Result<Vec<_>, _>>()?; + actual_edges.insert(feature.clone(), edges); + } + if actual_edges != matrix.feature_edges { + return Err( + "outbox Cargo feature graph contains an undeclared, missing, or reordered edge" + .to_owned(), + ); + } + Ok(matrix) +} + +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 vector.schema_version != 1 + || vector.contract_id != CONTRACT_ID + || vector.executor.id != VECTOR_EXECUTOR_ID + || vector.executor.path != VECTOR_EXECUTOR_RELATIVE + || vector.executor.test != VECTOR_EXECUTOR_TEST + || vector.delegated_suite.lane != "nix run .#contract" + || vector.delegated_suite.package != "radroots_outbox" + { + return Err("outbox migration vector identity is invalid".to_owned()); + } + let expected_cases = BTreeSet::from([ + "fresh_initialization", + "exact_unledgered_adoption", + "partial_unledgered_rejected", + "ledger_checksum_tamper_rejected", + "newer_history_rejected", + "caller_state_preserved", + "current_reopen_no_history_write", + ]); + let mut actual_cases = BTreeSet::new(); + for case in &vector.cases { + if case.execution != "direct_executor" + || case.expected_outcome.is_empty() + || !actual_cases.insert(case.id.as_str()) + || case.expected_error.is_some() != case.expected_outcome.starts_with("rejected") + { + return Err(format!( + "invalid outbox migration vector case `{}`", + case.id + )); + } + } + if actual_cases != expected_cases { + return Err("outbox migration vector case inventory is incomplete".to_owned()); + } + let mut authorities = BTreeSet::new(); + for authority in &vector.delegated_suite.authorities { + if authority.authority.is_empty() || !authorities.insert(authority.authority.as_str()) { + return Err("outbox migration delegated authorities must be unique".to_owned()); + } + let source = read_regular_file(workspace_root, &authority.authority_path)?; + let source = std::str::from_utf8(&source) + .map_err(|error| format!("decode {}: {error}", authority.authority_path))?; + if !source.contains(&authority.authority) { + return Err(format!( + "outbox migration delegated authority `{}` is not present in {}", + authority.authority, authority.authority_path + )); + } + } + if authorities.len() != 12 { + return Err("outbox migration delegated authority inventory is incomplete".to_owned()); + } + Ok(()) +} + +fn validate_release_authority(workspace_root: &Path) -> Result<(), String> { + let release_bytes = read_regular_file(workspace_root, RELEASE_RELATIVE)?; + let release_source = std::str::from_utf8(&release_bytes) + .map_err(|error| format!("decode {RELEASE_RELATIVE}: {error}"))?; + let release: toml::Value = toml::from_str(release_source) + .map_err(|error| format!("parse {RELEASE_RELATIVE}: {error}"))?; + let changes = release + .get("changes") + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("{RELEASE_RELATIVE} must define changes"))?; + let matching = changes + .iter() + .filter(|change| change.get("id").and_then(toml::Value::as_str) == Some(RELEASE_CHANGE_ID)) + .collect::<Vec<_>>(); + let [change] = matching.as_slice() else { + return Err(format!( + "{RELEASE_RELATIVE} must define exactly one `{RELEASE_CHANGE_ID}` change" + )); + }; + let impacts = change + .get("semver_impacts") + .and_then(toml::Value::as_array) + .ok_or_else(|| format!("release change `{RELEASE_CHANGE_ID}` has no semver impacts"))? + .iter() + .map(|impact| { + impact + .as_str() + .ok_or_else(|| "release semver impacts must be strings".to_owned()) + }) + .collect::<Result<Vec<_>, _>>()?; + let expected_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", + ]; + if change.get("classification").and_then(toml::Value::as_str) != Some("breaking") + || impacts != expected_impacts + || change + .get("summary") + .and_then(toml::Value::as_str) + .is_none_or(str::is_empty) + { + return Err(format!( + "release change `{RELEASE_CHANGE_ID}` has invalid classification, impacts, or summary" + )); + } + let changelog = read_regular_file(workspace_root, CHANGELOG_RELATIVE)?; + let changelog = std::str::from_utf8(&changelog) + .map_err(|error| format!("decode {CHANGELOG_RELATIVE}: {error}"))?; + let marker = format!("<!-- release-change: {RELEASE_CHANGE_ID} -->"); + if changelog.matches(&marker).count() != 1 { + return Err(format!( + "{CHANGELOG_RELATIVE} must contain exactly one `{marker}`" + )); + } + Ok(()) +} + +fn generated_runtime_registry(registry: &Registry) -> Result<String, String> { + let mut generated = String::from( + "// @generated by `cargo xtask contract outbox-migration-manifest --write`; do not edit.\n\nuse crate::migrations::OutboxMigration;\n\n", + ); + for migration in &registry.migrations { + let up_filename = migration_filename(&migration.up_path)?; + let down_filename = migration_filename(&migration.down_path)?; + generated.push_str(&format!( + "const OUTBOX_MIGRATION_{:04}: OutboxMigration = OutboxMigration {{\n", + migration.version + )); + generated.push_str(&format!(" version: {},\n", migration.version)); + generated.push_str(&format!(" name: {:?},\n", migration.name)); + generated.push_str(&format!( + " up_sql: include_str!(\"../../migrations/{up_filename}\"),\n" + )); + generated.push_str(&format!( + " down_sql: include_str!(\"../../migrations/{down_filename}\"),\n" + )); + generated.push_str(&format!( + " up_len: {},\n down_len: {},\n", + migration.up_byte_length, migration.down_byte_length + )); + generated.push_str(&format!( + " up_sha256: {:?},\n down_sha256: {:?},\n schema_sha256: {:?},\n", + migration.up_sha256, migration.down_sha256, migration.schema_sha256 + )); + generated.push_str(" owned_object_names: &[\n"); + for name in &migration.owned_objects { + generated.push_str(&format!(" {name:?},\n")); + } + generated.push_str(" ],\n owned_table_names: &[\n"); + for name in &migration.owned_tables { + generated.push_str(&format!(" {name:?},\n")); + } + generated.push_str(" ],\n};\n\n"); + } + if registry.migrations.len() == 1 { + generated.push_str(&format!( + "pub(crate) const OUTBOX_MIGRATIONS: &[OutboxMigration] = &[OUTBOX_MIGRATION_{:04}];\n", + registry.migrations[0].version + )); + } else { + generated.push_str("pub(crate) const OUTBOX_MIGRATIONS: &[OutboxMigration] = &[\n"); + for migration in &registry.migrations { + generated.push_str(&format!(" OUTBOX_MIGRATION_{:04},\n", migration.version)); + } + generated.push_str("];\n"); + } + Ok(generated) +} + +fn migration_filename(relative: &str) -> Result<&str, String> { + let prefix = format!("{MIGRATION_DIRECTORY_RELATIVE}/"); + let filename = relative + .strip_prefix(&prefix) + .ok_or_else(|| format!("migration path `{relative}` is outside the governed directory"))?; + if filename.is_empty() || filename.contains('/') || filename.contains('\\') { + return Err(format!("invalid migration path `{relative}`")); + } + Ok(filename) +} + +fn expected_manifest( + workspace_root: &Path, + registry: &Registry, + matrix: &FeatureMatrix, + schema_bytes: &[u8], + generated_runtime: &[u8], +) -> Result<Value, String> { + let minimum = registry + .migrations + .first() + .ok_or_else(|| "outbox migration registry must not be empty".to_owned())? + .version; + let current = registry + .migrations + .last() + .ok_or_else(|| "outbox migration registry must not be empty".to_owned())? + .version; + let migrations = registry + .migrations + .iter() + .map(|migration| { + Ok(json!({ + "version": migration.version, + "name": migration.name, + "up": descriptor_for_file(workspace_root, &migration.up_path)?, + "down": descriptor_for_file(workspace_root, &migration.down_path)?, + "schema_sha256": migration.schema_sha256, + "owned_objects": migration.owned_objects, + "owned_tables": migration.owned_tables, + })) + }) + .collect::<Result<Vec<_>, String>>()?; + let feature_edges = matrix + .feature_edges + .iter() + .map(|(feature, enables)| json!({ "feature": feature, "enables": enables })) + .collect::<Vec<_>>(); + let profiles = matrix + .profiles + .iter() + .map(|profile| json!({ "id": profile.id, "cargo_args": profile.cargo_args })) + .collect::<Vec<_>>(); + let sources = SOURCE_FILES + .iter() + .map(|(role, relative)| { + Ok(json!({ + "role": role, + "file": descriptor_for_file(workspace_root, relative)?, + })) + }) + .collect::<Result<Vec<_>, String>>()?; + Ok(json!({ + "schema_version": 1, + "contract_id": CONTRACT_ID, + "authority_id": AUTHORITY_ID, + "manifest_schema": descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, schema_bytes), + "registry_source": descriptor_for_file(workspace_root, REGISTRY_RELATIVE)?, + "version_bounds": { + "minimum": minimum, + "current": current, + "derivation": "first_and_last_ordered_registry_entries_v1", + }, + "ledger": { + "name": LEDGER_NAME, + "reserved_prefix": RESERVED_PREFIX, + "catalog_fingerprint": "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1", + "migration_transaction": "begin_immediate_v1", + "rollback_transaction": "begin_exclusive_test_executor_v1", + "adoption": "exact_unledgered_0001_catalog_only_v1", + }, + "migrations": migrations, + "feature_matrix": { + "source": descriptor_for_file(workspace_root, FEATURE_MATRIX_RELATIVE)?, + "package": matrix.package, + "feature_edges": feature_edges, + "profiles": profiles, + }, + "generated_runtime": descriptor_for_bytes(GENERATED_RUNTIME_RELATIVE, generated_runtime), + "result_vector": { + "canonical": descriptor_for_file(workspace_root, VECTOR_RELATIVE)?, + "mirror_path": VECTOR_MIRROR_RELATIVE, + "executor": descriptor_for_file(workspace_root, VECTOR_EXECUTOR_RELATIVE)?, + "executor_id": VECTOR_EXECUTOR_ID, + "executor_test": VECTOR_EXECUTOR_TEST, + }, + "source_files": sources, + "release": { + "change_id": RELEASE_CHANGE_ID, + "record": RELEASE_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", + "registry_source", "version_bounds", "ledger", "migrations", + "feature_matrix", "generated_runtime", "result_vector", "source_files", "release" + ], + "properties": { + "schema_version": { "const": 1 }, + "contract_id": { "const": CONTRACT_ID }, + "authority_id": { "const": AUTHORITY_ID }, + "manifest_schema": { "$ref": "#/$defs/file" }, + "registry_source": { "$ref": "#/$defs/file" }, + "version_bounds": { + "type": "object", + "additionalProperties": false, + "required": ["minimum", "current", "derivation"], + "properties": { + "minimum": { "type": "integer", "minimum": 1 }, + "current": { "type": "integer", "minimum": 1 }, + "derivation": { "const": "first_and_last_ordered_registry_entries_v1" } + } + }, + "ledger": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", "reserved_prefix", "catalog_fingerprint", "migration_transaction", + "rollback_transaction", "adoption" + ], + "properties": { + "name": { "const": LEDGER_NAME }, + "reserved_prefix": { "const": RESERVED_PREFIX }, + "catalog_fingerprint": { "const": "sha256_type_nul_name_nul_table_name_nul_sql_nul_sorted_v1" }, + "migration_transaction": { "const": "begin_immediate_v1" }, + "rollback_transaction": { "const": "begin_exclusive_test_executor_v1" }, + "adoption": { "const": "exact_unledgered_0001_catalog_only_v1" } + } + }, + "migrations": { + "type": "array", + "minItems": 1, + "maxItems": 9999, + "items": { "$ref": "#/$defs/migration" } + }, + "feature_matrix": { + "type": "object", + "additionalProperties": false, + "required": ["source", "package", "feature_edges", "profiles"], + "properties": { + "source": { "$ref": "#/$defs/file" }, + "package": { "const": "radroots_outbox" }, + "feature_edges": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "$ref": "#/$defs/feature_edge" } + }, + "profiles": { + "type": "array", "minItems": 5, "maxItems": 5, + "items": { "$ref": "#/$defs/profile" } + } + } + }, + "generated_runtime": { "$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": VECTOR_EXECUTOR_ID }, + "executor_test": { "const": VECTOR_EXECUTOR_TEST } + } + }, + "source_files": { + "type": "array", "minItems": 16, "maxItems": 16, + "items": { "$ref": "#/$defs/source_file" } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": ["change_id", "record", "changelog"], + "properties": { + "change_id": { "const": RELEASE_CHANGE_ID }, + "record": { "const": RELEASE_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 } + } + }, + "migration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "name", "up", "down", "schema_sha256", "owned_objects", "owned_tables"], + "properties": { + "version": { "type": "integer", "minimum": 1, "maximum": 9999 }, + "name": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "up": { "$ref": "#/$defs/file" }, + "down": { "$ref": "#/$defs/file" }, + "schema_sha256": { "$ref": "#/$defs/sha256" }, + "owned_objects": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "type": "string", "pattern": "^outbox_[a-z0-9_]+$" } + }, + "owned_tables": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "type": "string", "pattern": "^outbox_[a-z0-9_]+$" } + } + } + }, + "feature_edge": { + "type": "object", + "additionalProperties": false, + "required": ["feature", "enables"], + "properties": { + "feature": { "type": "string", "minLength": 1 }, + "enables": { + "type": "array", "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": ["id", "cargo_args"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "cargo_args": { + "type": "array", "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "source_file": { + "type": "object", + "additionalProperties": false, + "required": ["role", "file"], + "properties": { + "role": { "type": "string", "minLength": 1 }, + "file": { "$ref": "#/$defs/file" } + } + } + } + }) +} + +fn descriptor_for_file(workspace_root: &Path, relative: &str) -> Result<Value, String> { + let bytes = read_regular_file(workspace_root, relative)?; + Ok(descriptor_for_bytes(relative, &bytes)) +} + +fn descriptor_for_bytes(relative: &str, bytes: &[u8]) -> Value { + json!({ + "path": relative, + "byte_length": bytes.len(), + "sha256": sha256_hex(bytes), + "hash_algorithm": HASH_ALGORITHM, + }) +} + +fn canonical_json_bytes(value: &Value) -> Result<Vec<u8>, String> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| format!("serialize outbox migration artifact: {error}"))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("xtask workspace root") + .to_path_buf() + } + + #[test] + fn outbox_registry_accepts_an_appended_successor_and_rejects_a_gap() { + let mut registry = load_and_validate_registry(&workspace_root()).expect("live registry"); + let mut successor = registry.migrations[0].clone(); + successor.version = 2; + successor.name = "future".to_owned(); + successor.up_path = "crates/outbox/migrations/0002_future.up.sql".to_owned(); + successor.down_path = "crates/outbox/migrations/0002_future.down.sql".to_owned(); + successor.owned_objects = vec!["outbox_future".to_owned()]; + successor.owned_tables = vec!["outbox_future".to_owned()]; + registry.migrations.push(successor); + validate_registry_shape(&registry).expect("contiguous successor"); + registry.migrations[1].version = 3; + assert!( + validate_registry_shape(&registry) + .expect_err("gap") + .contains("expected 2") + ); + } + + #[test] + fn outbox_registry_rejects_any_frozen_baseline_mutation() { + let mut registry = load_and_validate_registry(&workspace_root()).expect("live registry"); + registry.migrations[0].up_byte_length += 1; + assert_eq!( + validate_registry_shape(&registry).expect_err("frozen mutation"), + "frozen outbox migration 0001 authority changed" + ); + } + + #[test] + fn outbox_feature_matrix_matches_declared_edges() { + let matrix = load_and_validate_feature_matrix(&workspace_root()).expect("feature matrix"); + assert_eq!(matrix.profiles.len(), 5); + assert_eq!(matrix.feature_edges.len(), 4); + } + + #[test] + fn outbox_manifest_schema_closes_every_object() { + fn visit(value: &Value) { + if value.get("type").and_then(Value::as_str) == Some("object") { + assert_eq!( + value.get("additionalProperties"), + Some(&Value::Bool(false)), + "object schema must be closed: {value}" + ); + } + match value { + Value::Array(values) => values.iter().for_each(visit), + Value::Object(values) => values.values().for_each(visit), + _ => {} + } + } + visit(&manifest_schema()); + } + + #[test] + fn outbox_generated_runtime_derives_every_registry_entry() { + let registry = load_and_validate_registry(&workspace_root()).expect("live registry"); + let generated = generated_runtime_registry(&registry).expect("generated runtime"); + for migration in &registry.migrations { + assert!(generated.contains(&format!("version: {}", migration.version))); + assert!(generated.contains(&migration.up_sha256)); + assert!(generated.contains(&migration.down_sha256)); + assert!(generated.contains(&migration.schema_sha256)); + } + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -26,6 +26,7 @@ fn usage() { eprintln!(" cargo xtask contract phase1-publication-allowlist-manifest [--write]"); eprintln!(" cargo xtask contract blossom-publication-readiness-manifest [--write]"); eprintln!(" cargo xtask contract blossom-raster-decoder-security-manifest [--write]"); + eprintln!(" cargo xtask contract outbox-migration-manifest [--write]"); eprintln!(" cargo xtask contract phase1-publication-media-readiness-manifest [--write]"); eprintln!(" cargo xtask contract release-provenance-schema [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); @@ -217,6 +218,15 @@ fn run_contract(args: &[String]) -> Result<(), String> { .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("phase1-publication-media-readiness-manifest") => match &args[1..] { [] => contract::validate_phase1_publication_media_readiness_manifest(&workspace_root()), [flag] if flag == "--write" => { @@ -386,6 +396,12 @@ mod tests { ]) .expect_err("invalid Blossom raster decoder security manifest mode"); assert!(invalid_raster_decoder_security.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 invalid_release_provenance_schema = run_contract(&[ "release-provenance-schema".to_string(), "--invalid".to_string(), @@ -509,6 +525,8 @@ mod tests { .expect("contract Blossom publication-readiness manifest"); run_contract(&["blossom-raster-decoder-security-manifest".to_string()]) .expect("contract Blossom raster decoder security manifest"); + run_contract(&["outbox-migration-manifest".to_string()]) + .expect("contract outbox migration manifest"); run_contract(&["phase1-publication-media-readiness-manifest".to_string()]) .expect("contract Phase 1 publication media-readiness manifest"); run_contract(&["release-provenance-schema".to_string()])