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:
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(®istry)?;
+ let schema_bytes = canonical_json_bytes(&manifest_schema())?;
+ let manifest = expected_manifest(
+ workspace_root,
+ ®istry,
+ &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(®istry)?;
+ 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(®istry.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(®istry.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 ®istry.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 ®istry.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(®istry).expect("contiguous successor");
+ registry.migrations[1].version = 3;
+ assert!(
+ validate_registry_shape(®istry)
+ .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(®istry).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(®istry).expect("generated runtime");
+ for migration in ®istry.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()])