commit bb9832fa4c33f68b4262140599c111abb5d5480d parent 13e3d81d9f58f4aa2a161e71cdd8b66dfdc164e0 Author: triesap <tyson@radroots.org> Date: Mon, 27 Jul 2026 12:15:06 +0000 architecture: detect public implementation leakage - enforce synchronized public API implementation boundaries - require exact ADR-backed migration exceptions - scan resolved public signatures, aliases, and reexports - cover leakage, adapter, privacy, and exception cases Diffstat:
15 files changed, 1769 insertions(+), 7 deletions(-)
diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -27,6 +27,7 @@ let ../../rust-toolchain.toml ../../contracts ../../crates + ../../docs/decisions ../../docs/implementation ../../docs/specs ../../tools diff --git a/contracts/releases/api_boundaries.toml b/contracts/releases/api_boundaries.toml @@ -0,0 +1,254 @@ +schema_version = 1 +spec_id = "radroots.crates.release.v1" +forbidden_public_paths = [ + "core_foundation", + "dispatch", + "jni", + "keyring", + "libc", + "mach2", + "nostr", + "nostr_sdk", + "objc", + "reqwest", + "security_framework", + "sqlx", + "std::os", + "tokio", + "wasm_bindgen", + "web_sys", + "windows", + "windows_core", + "windows_sys", +] + +[[package]] +name = "radroots-core" +allowed_public_paths = [] + +[[package]] +name = "radroots-identity" +allowed_public_paths = [] + +[[package]] +name = "radroots-blossom" +allowed_public_paths = [] + +[[package]] +name = "radroots-protocol" +allowed_public_paths = [] + +[[package]] +name = "radroots-event" +allowed_public_paths = [] + +[[package]] +name = "radroots-event-codec" +allowed_public_paths = [] + +[[package]] +name = "radroots-trade" +allowed_public_paths = [] + +[[package]] +name = "radroots-signing" +allowed_public_paths = [] + +[[package]] +name = "radroots-transport" +allowed_public_paths = [] + +[[package]] +name = "radroots-nostr" +allowed_public_paths = ["nostr"] + +[[package]] +name = "radroots-nostr-connect" +allowed_public_paths = [] + +[[package]] +name = "radroots-secrets" +allowed_public_paths = [] + +[[package]] +name = "radroots-storage" +allowed_public_paths = [] + +[[package]] +name = "radroots-storage-sqlite" +allowed_public_paths = [] + +[[package]] +name = "radroots-transport-nostr" +allowed_public_paths = [] + +[[package]] +name = "radroots-sync" +allowed_public_paths = [] + +[[package]] +name = "radroots-geonames" +allowed_public_paths = [] + +[[package]] +name = "radroots-sdk" +allowed_public_paths = [] + +[[package]] +name = "radroots" +allowed_public_paths = [] +[[exception]] +id = "RCRV1-API-001" +package = "radroots-identity" +source = "src/error.rs" +forbidden_path = "nostr" +items = ["error::IdentityError"] +observed_paths = ["nostr::key::Error"] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 42 +rationale = "The legacy identity error still exposes a Nostr key error until the identity package conformance refactor." + +[[exception]] +id = "RCRV1-API-002" +package = "radroots-identity" +source = "src/identity.rs" +forbidden_path = "nostr" +items = [ + "identity::RadrootsIdentity::from", + "identity::RadrootsIdentity::into_keys", + "identity::RadrootsIdentity::keys", + "identity::RadrootsIdentity::new", + "identity::RadrootsIdentity::public_key", + "identity::RadrootsIdentity::secret_key_bytes", + "identity::RadrootsIdentity::secret_key_bytes_zeroizing", + "identity::RadrootsIdentity::with_profile", + "identity::RadrootsIdentityFile", + "identity::RadrootsIdentityId::from", + "identity::RadrootsIdentityId::from_public_key", + "identity::RadrootsIdentityProfile", + "identity::RadrootsIdentityPublic::new", +] +observed_paths = [ + "nostr::Event", + "nostr::Keys", + "nostr::PublicKey", + "nostr::SecretKey::LEN", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 42 +rationale = "Legacy identity values retain exact Nostr and secret-key signatures only until the public-only identity refactor and conformance gate." + +[[exception]] +id = "RCRV1-API-003" +package = "radroots-nostr" +source = "src/client.rs" +forbidden_path = "nostr_sdk" +items = [ + "client::RadrootsNostrClient::from_inner", + "client::RadrootsNostrClient::into_inner", +] +observed_paths = ["nostr_sdk::Client"] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Legacy client ownership remains visible only until the Nostr package removes live relay-client dependencies." + +[[exception]] +id = "RCRV1-API-004" +package = "radroots-nostr" +source = "src/error.rs" +forbidden_path = "nostr_sdk" +items = ["error::RadrootsNostrError"] +observed_paths = [ + "nostr_sdk::client::Error", + "nostr_sdk::prelude::DatabaseError", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Legacy SDK error variants remain exact-scoped until normalized adapter errors replace them." + +[[exception]] +id = "RCRV1-API-005" +package = "radroots-nostr" +source = "src/types.rs" +forbidden_path = "nostr_sdk" +items = [ + "types::RadrootsNostrEventStream", + "types::RadrootsNostrMonitor", + "types::RadrootsNostrMonitorNotification", + "types::RadrootsNostrOutput", + "types::RadrootsNostrRelay", + "types::RadrootsNostrRelayPoolNotification", + "types::RadrootsNostrRelayStatus", + "types::RadrootsNostrSubscribeAutoCloseOptions", +] +observed_paths = [ + "nostr_sdk::Relay", + "nostr_sdk::RelayPoolNotification", + "nostr_sdk::RelayStatus", + "nostr_sdk::SubscribeAutoCloseOptions", + "nostr_sdk::pool::stream::BoxedStream", + "nostr_sdk::prelude::Monitor", + "nostr_sdk::prelude::MonitorNotification", + "nostr_sdk::prelude::Output", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 124 +rationale = "Broad live-client aliases are frozen and must be removed with the Nostr client dependency at Step 124." + +[[exception]] +id = "RCRV1-API-006" +package = "radroots-nostr-connect" +source = "src/client.rs" +forbidden_path = "nostr" +items = [ + "client::RadrootsNostrConnectClientTarget", + "client::RadrootsNostrConnectClientTarget::new", + "client::RadrootsNostrConnectClientTransport", + "client::build_request_event", + "client::execute_request_with_transport", + "client::parse_response_event", +] +observed_paths = [ + "nostr::Event", + "nostr::Keys", + "nostr::PublicKey", + "nostr::RelayUrl", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy client state-machine signatures retain exact protocol types until the Nostr Connect transport split and conformance gate." + +[[exception]] +id = "RCRV1-API-007" +package = "radroots-nostr-connect" +source = "src/message.rs" +forbidden_path = "nostr" +items = [ + "message::RadrootsNostrConnectPendingConnectionPollOutcome", + "message::RadrootsNostrConnectRemoteSessionCapability", + "message::RadrootsNostrConnectRequest", + "message::RadrootsNostrConnectResponse", +] +observed_paths = [ + "nostr::Event", + "nostr::PublicKey", + "nostr::RelayUrl", + "nostr::UnsignedEvent", +] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy request and response envelopes retain exact Nostr values until canonical package-owned envelopes are complete." + +[[exception]] +id = "RCRV1-API-008" +package = "radroots-nostr-connect" +source = "src/uri.rs" +forbidden_path = "nostr" +items = [ + "uri::RadrootsNostrConnectBunkerUri", + "uri::RadrootsNostrConnectClientUri", +] +observed_paths = ["nostr::PublicKey", "nostr::RelayUrl"] +adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" +removal_step = 140 +rationale = "Legacy URI wrappers retain exact upstream value types until URI normalization and the package conformance gate." diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.json b/crates/event_store/contracts/source_maintenance_v1.manifest.json @@ -251,7 +251,7 @@ "role": "source_maintenance_governance", "path": "tools/xtask/src/contract/source_maintenance.rs", "byte_length": 176811, - "sha256": "cff3fabf05989de7d6420720e493965ff7aa71d079babe7a9565492f54c76630", + "sha256": "b26ba1fd12bf6ec04be2291bf70e74bc2f3004e5888c78c30a093b66edfd937f", "hash_algorithm": "sha256_bytes_v1" }, { @@ -264,8 +264,8 @@ { "role": "xtask_dispatch_and_release_preflight", "path": "tools/xtask/src/main.rs", - "byte_length": 14578, - "sha256": "b33b9139db58a89adaf21448b0f0de1f5b23ce0af93378b05b59eb01d5069b41", + "byte_length": 14771, + "sha256": "a365501cb01dfe423df801f4d2eb7fe00e843770aa24b0dd49af73ae97364163", "hash_algorithm": "sha256_bytes_v1" } ], diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 b/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 @@ -1 +1 @@ -a3e74030495447a44574a0e688ae3dbb4261d97be9b5e6913fbf807c33147643 +137f70416f99c33e9b455a25f1d15dd0a532841161de18e55de84abf57565b88 diff --git a/crates/event_store/src/generated/source_maintenance_manifest.rs b/crates/event_store/src/generated/source_maintenance_manifest.rs @@ -1,8 +1,8 @@ // @generated by `cargo xtask contract source-maintenance-manifest --write`; do not edit. -pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 11546,\n \"sha256\": \"0378afe281a46e02e83e128324efefc3da950abf3e1d3d06c75b628f69810bd2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19421,\n \"sha256\": \"4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3844,\n \"sha256\": \"3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33617,\n \"sha256\": \"79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184407,\n \"sha256\": \"c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 394574,\n \"sha256\": \"db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 38279,\n \"sha256\": \"f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 195036,\n \"sha256\": \"d3c567ea3b4ea709723af2d5b6a2dc47993c6ba42c1d62895bd26fa101ebf829\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 835585,\n \"sha256\": \"51d524cb00d2d96dc0da35fc9133c54f2e5328a830d56b8d5e83a7f1e9654ddc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 176811,\n \"sha256\": \"cff3fabf05989de7d6420720e493965ff7aa71d079babe7a9565492f54c76630\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 500480,\n \"sha256\": \"84739ad8b78bae49ea1f992672101bd83135415042cb22a85dbcbf5c878fc097\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 14578,\n \"sha256\": \"b33b9139db58a89adaf21448b0f0de1f5b23ce0af93378b05b59eb01d5069b41\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 11546,\n \"sha256\": \"0378afe281a46e02e83e128324efefc3da950abf3e1d3d06c75b628f69810bd2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19421,\n \"sha256\": \"4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3844,\n \"sha256\": \"3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33617,\n \"sha256\": \"79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184407,\n \"sha256\": \"c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 394574,\n \"sha256\": \"db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 38279,\n \"sha256\": \"f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 195036,\n \"sha256\": \"d3c567ea3b4ea709723af2d5b6a2dc47993c6ba42c1d62895bd26fa101ebf829\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 835585,\n \"sha256\": \"51d524cb00d2d96dc0da35fc9133c54f2e5328a830d56b8d5e83a7f1e9654ddc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 176811,\n \"sha256\": \"b26ba1fd12bf6ec04be2291bf70e74bc2f3004e5888c78c30a093b66edfd937f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 500480,\n \"sha256\": \"84739ad8b78bae49ea1f992672101bd83135415042cb22a85dbcbf5c878fc097\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 14771,\n \"sha256\": \"a365501cb01dfe423df801f4d2eb7fe00e843770aa24b0dd49af73ae97364163\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = 14459; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SHA256: &str = - "a3e74030495447a44574a0e688ae3dbb4261d97be9b5e6913fbf807c33147643"; + "137f70416f99c33e9b455a25f1d15dd0a532841161de18e55de84abf57565b88"; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION: u32 = 1; pub(crate) const SOURCE_MAINTENANCE_CONTRACT_ID: &str = "radroots_event_store.source_maintenance_v1"; diff --git a/docs/decisions/0001-public-api-leakage-migration-baseline.md b/docs/decisions/0001-public-api-leakage-migration-baseline.md @@ -0,0 +1,36 @@ +# ADR 0001: Public API leakage migration baseline + +Status: accepted for the crates release V1 migration +Date: 2026-07-27 + +## Context + +The Release V1 architecture forbids generic public packages from exposing +SQLx, Tokio, Reqwest, Nostr SDK, keyring, or platform-specific implementation +types. The existing identity, Nostr, and Nostr Connect packages predate that +boundary and still expose a finite set of upstream Nostr types while +publication remains frozen. + +## Decision + +The synchronized API-boundary contract records only the reviewed findings +under exception IDs RCRV1-API-001, RCRV1-API-002, RCRV1-API-003, +RCRV1-API-004, RCRV1-API-005, RCRV1-API-006, RCRV1-API-007, and +RCRV1-API-008. + +Every exception is package-, source-, item-, forbidden-root-, and +observed-path-specific. New items, new upstream paths, SQLx, Tokio, Reqwest, +keyring, platform-specific types, or broader aliases remain forbidden. The +exceptions authorize no publication. + +The identity exceptions must be removed by the Step 042 conformance gate, the +Nostr SDK exceptions by Step 124, and the Nostr Connect exceptions by Step +140. The owning package refactors may remove them earlier. + +## Consequences + +The architecture command fails closed when an ADR is missing, an exception is +expired or broadened, or a new public implementation type appears. Concrete +implementation crates may use third-party types internally, but those types do +not become public API unless the synchronized contract explicitly permits the +package and path. diff --git a/tools/xtask/src/architecture.rs b/tools/xtask/src/architecture.rs @@ -6,6 +6,7 @@ use std::{ use serde::Deserialize; +mod api_leakage; mod dependency_boundary; const DEVIATIONS_RELATIVE: &str = "docs/implementation/deviations.toml"; @@ -158,6 +159,7 @@ pub fn validate(workspace_root: &Path) -> Result<(), String> { .iter() .map(|package| package.name.clone()) .collect::<BTreeSet<_>>(); + api_leakage::validate_policy_catalog(workspace_root, &architecture_packages)?; dependency_boundary::validate_policy_catalog(workspace_root, &architecture_packages)?; validate_workspace_toolchain(workspace_root, &architecture)?; validate_public_package_metadata(workspace_root, &architecture)?; @@ -175,6 +177,11 @@ pub fn validate_dependency_boundaries(workspace_root: &Path) -> Result<(), Strin dependency_boundary::validate_resolved_boundaries(workspace_root) } +pub fn validate_api_boundaries(workspace_root: &Path) -> Result<(), String> { + validate(workspace_root)?; + api_leakage::validate_public_api(workspace_root) +} + fn validate_workspace_toolchain( workspace_root: &Path, architecture: &ArchitectureIdentity, diff --git a/tools/xtask/src/architecture/api_leakage.rs b/tools/xtask/src/architecture/api_leakage.rs @@ -0,0 +1,1433 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, + process::Command, +}; + +use serde::Deserialize; +use syn::{ + Expr, ExprLit, Fields, ForeignItem, ImplItem, Item, Lit, Meta, Path as SynPath, TraitItem, + Type, UseTree, Visibility, visit::Visit, +}; + +const API_BOUNDARIES_RELATIVE: &str = "contracts/releases/api_boundaries.toml"; +const SPEC_ID: &str = "radroots.crates.release.v1"; +const POLICY_SCHEMA_VERSION: u16 = 1; +const CURRENT_STEP: u16 = 25; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiBoundaryPolicy { + schema_version: u16, + spec_id: String, + forbidden_public_paths: Vec<String>, + package: Vec<ApiPackagePolicy>, + #[serde(default)] + exception: Vec<ApiException>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiPackagePolicy { + name: String, + allowed_public_paths: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApiException { + id: String, + package: String, + source: String, + forbidden_path: String, + items: Vec<String>, + observed_paths: Vec<String>, + adr: String, + removal_step: u16, + rationale: String, +} + +#[derive(Debug, Deserialize)] +struct CargoMetadata { + packages: Vec<CargoPackage>, + workspace_members: Vec<String>, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + id: String, + name: String, + manifest_path: String, + targets: Vec<CargoTarget>, +} + +#[derive(Debug, Deserialize)] +struct CargoTarget { + kind: Vec<String>, + src_path: String, +} + +struct ModuleUnit { + module: Vec<String>, + parent: Option<Vec<String>>, + declared_public: bool, + initially_effective: bool, + source_relative: String, + items: Vec<Item>, +} + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +struct ApiFinding { + package: String, + source: String, + item: String, + forbidden_path: String, + observed_path: String, +} + +#[derive(Debug)] +enum InternalExport { + Module(Vec<String>), + Item(Vec<String>, String), + External, +} + +pub(super) fn validate_policy_catalog( + workspace_root: &Path, + expected_packages: &BTreeSet<String>, +) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + validate_policy(workspace_root, &policy, expected_packages) +} + +pub(super) fn validate_public_api(workspace_root: &Path) -> Result<(), String> { + let policy = load_policy(workspace_root)?; + let expected_packages = policy + .package + .iter() + .map(|package| package.name.clone()) + .collect::<BTreeSet<_>>(); + validate_policy(workspace_root, &policy, &expected_packages)?; + let metadata = load_metadata(workspace_root)?; + let findings = scan_workspace(workspace_root, &policy, &metadata)?; + let unapproved = findings + .into_iter() + .filter(|finding| !exception_matches(&policy.exception, finding)) + .collect::<Vec<_>>(); + if unapproved.is_empty() { + Ok(()) + } else { + Err(format!( + "forbidden public implementation type leakage:\n{}", + unapproved + .iter() + .map(format_finding) + .collect::<Vec<_>>() + .join("\n") + )) + } +} + +fn load_policy(workspace_root: &Path) -> Result<ApiBoundaryPolicy, String> { + let path = workspace_root.join(API_BOUNDARIES_RELATIVE); + let raw = + fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn validate_policy( + workspace_root: &Path, + policy: &ApiBoundaryPolicy, + expected_packages: &BTreeSet<String>, +) -> Result<(), String> { + if policy.schema_version != POLICY_SCHEMA_VERSION { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" + )); + } + if policy.spec_id != SPEC_ID { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} spec_id must be {SPEC_ID}" + )); + } + + let forbidden = sorted_unique( + "forbidden_public_paths", + &policy.forbidden_public_paths, + false, + )?; + if forbidden.len() != policy.forbidden_public_paths.len() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} forbidden_public_paths must be unique" + )); + } + for required in [ + "keyring", + "nostr_sdk", + "reqwest", + "sqlx", + "std::os", + "tokio", + ] { + if !forbidden.contains(required) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} must forbid public {required} paths" + )); + } + } + + let mut package_names = BTreeSet::new(); + for package in &policy.package { + if !package_names.insert(package.name.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} package {} is declared more than once", + package.name + )); + } + let allowed = sorted_unique( + &format!("package {} allowed_public_paths", package.name), + &package.allowed_public_paths, + true, + )?; + for path in allowed { + if !forbidden.contains(path) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} package {} allows {path}, which is not forbidden globally", + package.name + )); + } + } + } + let actual_packages = package_names + .into_iter() + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + if &actual_packages != expected_packages { + return Err(package_set_mismatch(expected_packages, &actual_packages)); + } + + let package_policy = policy + .package + .iter() + .map(|package| (package.name.as_str(), package)) + .collect::<BTreeMap<_, _>>(); + let mut exception_ids = BTreeSet::new(); + let mut exception_keys = BTreeSet::new(); + for exception in &policy.exception { + if !exception_ids.insert(exception.id.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception id {} is duplicated", + exception.id + )); + } + if !valid_exception_id(&exception.id) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception id {} must match RCRV1-API-NNN", + exception.id + )); + } + let package = package_policy + .get(exception.package.as_str()) + .ok_or_else(|| { + format!( + "{API_BOUNDARIES_RELATIVE} exception {} names unknown package {}", + exception.id, exception.package + ) + })?; + if !forbidden.contains(exception.forbidden_path.as_str()) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} names unknown forbidden path {}", + exception.id, exception.forbidden_path + )); + } + if package + .allowed_public_paths + .iter() + .any(|allowed| allowed == &exception.forbidden_path) + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} is redundant with package allowance {}", + exception.id, exception.forbidden_path + )); + } + validate_relative_source(&exception.id, &exception.source)?; + let items = sorted_unique( + &format!("exception {} items", exception.id), + &exception.items, + false, + )?; + if items.len() != exception.items.len() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} items must be unique", + exception.id + )); + } + let observed_paths = sorted_unique( + &format!("exception {} observed_paths", exception.id), + &exception.observed_paths, + false, + )?; + if observed_paths.len() != exception.observed_paths.len() + || observed_paths.iter().any(|path| { + !(path == &exception.forbidden_path + || path + .strip_prefix(&exception.forbidden_path) + .is_some_and(|suffix| suffix.starts_with("::"))) + }) + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} observed_paths must be sorted, unique, and rooted at {}", + exception.id, exception.forbidden_path + )); + } + if exception.removal_step <= CURRENT_STEP { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} expired at Step {}", + exception.id, exception.removal_step + )); + } + if exception.rationale.trim().is_empty() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} must include a rationale", + exception.id + )); + } + validate_adr(workspace_root, exception)?; + for item in &exception.items { + let key = ( + exception.package.as_str(), + exception.source.as_str(), + item.as_str(), + exception.forbidden_path.as_str(), + ); + if !exception_keys.insert(key) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} duplicates an item-scoped allowance", + exception.id + )); + } + } + } + Ok(()) +} + +fn sorted_unique<'a>( + label: &str, + values: &'a [String], + allow_empty: bool, +) -> Result<BTreeSet<&'a str>, String> { + if !allow_empty && values.is_empty() { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} {label} must not be empty" + )); + } + let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); + if unique.iter().copied().ne(values.iter().map(String::as_str)) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} {label} must be sorted and unique" + )); + } + Ok(unique) +} + +fn valid_exception_id(id: &str) -> bool { + id.strip_prefix("RCRV1-API-") + .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn validate_relative_source(id: &str, source: &str) -> Result<(), String> { + let path = Path::new(source); + if source.is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("rs") + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {id} source must be a normalized relative Rust path" + )); + } + Ok(()) +} + +fn validate_adr(workspace_root: &Path, exception: &ApiException) -> Result<(), String> { + let path = Path::new(&exception.adr); + if path.is_absolute() + || !exception.adr.starts_with("docs/decisions/") + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR must be a normalized docs/decisions/*.md path", + exception.id + )); + } + let full = workspace_root.join(path); + let raw = fs::read_to_string(&full).map_err(|error| { + format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR {} is not readable: {error}", + exception.id, + full.display() + ) + })?; + if !raw.contains(&exception.id) { + return Err(format!( + "{API_BOUNDARIES_RELATIVE} exception {} ADR {} must cite the exception id", + exception.id, exception.adr + )); + } + Ok(()) +} + +fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { + let missing = expected + .difference(actual) + .cloned() + .collect::<Vec<_>>() + .join(", "); + let extra = actual + .difference(expected) + .cloned() + .collect::<Vec<_>>() + .join(", "); + format!( + "{API_BOUNDARIES_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}" + ) +} + +fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) + .current_dir(workspace_root) + .output() + .map_err(|error| format!("run cargo metadata: {error}"))?; + if !output.status.success() { + return Err(format!( + "cargo metadata failed while checking public API boundaries: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("parse cargo metadata for public API boundaries: {error}")) +} + +fn scan_workspace( + _workspace_root: &Path, + policy: &ApiBoundaryPolicy, + metadata: &CargoMetadata, +) -> Result<Vec<ApiFinding>, String> { + let workspace_members = metadata + .workspace_members + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let package_policy = policy + .package + .iter() + .map(|package| (package.name.as_str(), package)) + .collect::<BTreeMap<_, _>>(); + let forbidden = policy + .forbidden_public_paths + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let mut findings = BTreeSet::new(); + + for package in &metadata.packages { + if !workspace_members.contains(package.id.as_str()) { + continue; + } + let Some(package_policy) = package_policy.get(package.name.as_str()).copied() else { + continue; + }; + let manifest_path = Path::new(&package.manifest_path); + let package_root = manifest_path.parent().ok_or_else(|| { + format!( + "package {} manifest has no parent: {}", + package.name, package.manifest_path + ) + })?; + let allowed = package_policy + .allowed_public_paths + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + let library_targets = package + .targets + .iter() + .filter(|target| { + target + .kind + .iter() + .any(|kind| kind == "lib" || kind == "proc-macro") + }) + .collect::<Vec<_>>(); + if library_targets.len() != 1 { + return Err(format!( + "public package {} must expose exactly one library target for API leakage analysis", + package.name + )); + } + let source_path = Path::new(&library_targets[0].src_path); + let source_module_directory = module_directory(source_path)?; + let mut units = BTreeMap::new(); + collect_module( + package_root, + Vec::new(), + None, + true, + true, + source_path, + &source_module_directory, + None, + &mut units, + )?; + findings.extend(scan_package_units( + &package.name, + &units, + &forbidden, + &allowed, + )?); + } + + Ok(findings.into_iter().collect()) +} + +#[allow(clippy::too_many_arguments)] +fn collect_module( + package_root: &Path, + module: Vec<String>, + parent: Option<Vec<String>>, + declared_public: bool, + initially_effective: bool, + source_path: &Path, + module_directory: &Path, + inline_items: Option<Vec<Item>>, + units: &mut BTreeMap<Vec<String>, ModuleUnit>, +) -> Result<(), String> { + if units.contains_key(&module) { + return Ok(()); + } + let (items, source_relative) = if let Some(items) = inline_items { + let relative = source_path + .strip_prefix(package_root) + .map_err(|_| { + format!( + "module source {} escapes package {}", + source_path.display(), + package_root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + (items, relative) + } else { + let raw = fs::read_to_string(source_path) + .map_err(|error| format!("read {}: {error}", source_path.display()))?; + let file = syn::parse_file(&raw) + .map_err(|error| format!("parse {}: {error}", source_path.display()))?; + let relative = source_path + .strip_prefix(package_root) + .map_err(|_| { + format!( + "module source {} escapes package {}", + source_path.display(), + package_root.display() + ) + })? + .to_string_lossy() + .replace('\\', "/"); + (file.items, relative) + }; + + let children = items + .iter() + .filter_map(|item| match item { + Item::Mod(item_mod) => Some(item_mod.clone()), + _ => None, + }) + .collect::<Vec<_>>(); + units.insert( + module.clone(), + ModuleUnit { + module: module.clone(), + parent, + declared_public, + initially_effective, + source_relative, + items, + }, + ); + + for child in children { + let mut child_module = module.clone(); + child_module.push(child.ident.to_string()); + let child_public = is_public(&child.vis); + let child_effective = initially_effective && child_public; + if let Some((_, inline)) = child.content { + collect_module( + package_root, + child_module, + Some(module.clone()), + child_public, + child_effective, + source_path, + &module_directory.join(child.ident.to_string()), + Some(inline), + units, + )?; + } else { + let child_path = module_source_path(source_path, module_directory, &child)?; + collect_module( + package_root, + child_module, + Some(module.clone()), + child_public, + child_effective, + &child_path, + &module_directory.join(child.ident.to_string()), + None, + units, + )?; + } + } + Ok(()) +} + +fn module_directory(source_path: &Path) -> Result<PathBuf, String> { + let parent = source_path + .parent() + .ok_or_else(|| format!("module source has no parent: {}", source_path.display()))?; + let file_name = source_path.file_name().and_then(|name| name.to_str()); + if matches!(file_name, Some("lib.rs" | "main.rs" | "mod.rs")) { + Ok(parent.to_path_buf()) + } else { + Ok(parent.join( + source_path + .file_stem() + .ok_or_else(|| format!("module source has no stem: {}", source_path.display()))?, + )) + } +} + +fn module_source_path( + source_path: &Path, + module_directory: &Path, + item_mod: &syn::ItemMod, +) -> Result<PathBuf, String> { + if let Some(path) = path_attribute(item_mod) { + return Ok(source_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path)); + } + let name = item_mod.ident.to_string(); + let flat = module_directory.join(format!("{name}.rs")); + let nested = module_directory.join(&name).join("mod.rs"); + match (flat.is_file(), nested.is_file()) { + (true, false) => Ok(flat), + (false, true) => Ok(nested), + (true, true) => Err(format!( + "module {} is ambiguous between {} and {}", + item_mod.ident, + flat.display(), + nested.display() + )), + (false, false) => Err(format!( + "module {} source is missing under {}", + item_mod.ident, + module_directory.display() + )), + } +} + +fn path_attribute(item_mod: &syn::ItemMod) -> Option<String> { + item_mod.attrs.iter().find_map(|attribute| { + if !attribute.path().is_ident("path") { + return None; + } + let Meta::NameValue(value) = &attribute.meta else { + return None; + }; + let Expr::Lit(ExprLit { + lit: Lit::Str(path), + .. + }) = &value.value + else { + return None; + }; + Some(path.value()) + }) +} + +fn scan_package_units( + package: &str, + units: &BTreeMap<Vec<String>, ModuleUnit>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, +) -> Result<Vec<ApiFinding>, String> { + let mut effective = units + .values() + .filter(|unit| unit.initially_effective) + .map(|unit| unit.module.clone()) + .collect::<BTreeSet<_>>(); + let mut exported_items = BTreeSet::new(); + let mut changed = true; + while changed { + changed = propagate_public_modules(units, &mut effective); + let scopes = effective.iter().cloned().collect::<Vec<_>>(); + for scope in scopes { + let unit = units + .get(&scope) + .ok_or_else(|| format!("missing module unit {}", module_label(&scope)))?; + for item_use in unit.items.iter().filter_map(|item| match item { + Item::Use(item_use) if is_public(&item_use.vis) => Some(item_use), + _ => None, + }) { + for (segments, glob) in flatten_use_tree(&item_use.tree) { + match resolve_internal_export(&scope, &segments, glob, units) { + InternalExport::Module(module) => { + changed |= effective.insert(module); + } + InternalExport::Item(module, item) => { + changed |= exported_items.insert((module, item)); + } + InternalExport::External => {} + } + } + } + } + } + propagate_public_modules(units, &mut effective); + + let mut findings = BTreeSet::new(); + for unit in units.values() { + let imports = import_map(&unit.items); + let unit_effective = effective.contains(&unit.module); + let explicitly_exported = exported_items + .iter() + .filter(|(module, _)| module == &unit.module) + .map(|(_, item)| item.as_str()) + .collect::<BTreeSet<_>>(); + + for item in &unit.items { + if let Item::Use(item_use) = item { + if unit_effective && is_public(&item_use.vis) { + scan_public_use( + package, + unit, + item_use, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + continue; + } + if let Item::Impl(item_impl) = item { + let Some(self_name) = impl_self_name(&item_impl.self_ty) else { + continue; + }; + if unit_effective || explicitly_exported.contains(self_name.as_str()) { + scan_impl( + package, + unit, + item_impl, + &self_name, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + continue; + } + let Some(name) = item_name(item) else { + continue; + }; + if (unit_effective && item_is_public(item)) + || explicitly_exported.contains(name.as_str()) + { + scan_item( + package, + unit, + item, + &imports, + forbidden, + allowed, + &mut findings, + ); + } + } + } + Ok(findings.into_iter().collect()) +} + +fn propagate_public_modules( + units: &BTreeMap<Vec<String>, ModuleUnit>, + effective: &mut BTreeSet<Vec<String>>, +) -> bool { + let mut changed = false; + loop { + let before = effective.len(); + for unit in units.values() { + if unit.declared_public + && unit + .parent + .as_ref() + .is_some_and(|parent| effective.contains(parent)) + { + effective.insert(unit.module.clone()); + } + } + if effective.len() == before { + break; + } + changed = true; + } + changed +} + +fn flatten_use_tree(tree: &UseTree) -> Vec<(Vec<String>, bool)> { + fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut Vec<(Vec<String>, bool)>) { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + visit(&path.tree, prefix, output); + prefix.pop(); + } + UseTree::Name(name) => { + if name.ident == "self" { + output.push((prefix.clone(), false)); + } else { + let mut path = prefix.clone(); + path.push(name.ident.to_string()); + output.push((path, false)); + } + } + UseTree::Rename(rename) => { + let mut path = prefix.clone(); + path.push(rename.ident.to_string()); + output.push((path, false)); + } + UseTree::Glob(_) => output.push((prefix.clone(), true)), + UseTree::Group(group) => { + for item in &group.items { + visit(item, prefix, output); + } + } + } + } + + let mut output = Vec::new(); + visit(tree, &mut Vec::new(), &mut output); + output +} + +fn resolve_internal_export( + current: &[String], + segments: &[String], + glob: bool, + units: &BTreeMap<Vec<String>, ModuleUnit>, +) -> InternalExport { + if segments.is_empty() { + return InternalExport::External; + } + let candidates = internal_candidates(current, segments); + for candidate in candidates { + if glob && units.contains_key(&candidate) { + return InternalExport::Module(candidate); + } + if units.contains_key(&candidate) { + return InternalExport::Module(candidate); + } + if let Some((item, module)) = candidate.split_last() { + let module = module.to_vec(); + if units.contains_key(&module) { + return InternalExport::Item(module, item.clone()); + } + } + } + InternalExport::External +} + +fn internal_candidates(current: &[String], segments: &[String]) -> Vec<Vec<String>> { + let mut candidates = Vec::new(); + match segments.first().map(String::as_str) { + Some("crate") => candidates.push(segments[1..].to_vec()), + Some("self") => { + let mut path = current.to_vec(); + path.extend_from_slice(&segments[1..]); + candidates.push(path); + } + Some("super") => { + let mut base = current.to_vec(); + let mut index = 0; + while segments.get(index).map(String::as_str) == Some("super") { + base.pop(); + index += 1; + } + base.extend_from_slice(&segments[index..]); + candidates.push(base); + } + _ => { + candidates.push(segments.to_vec()); + let mut local = current.to_vec(); + local.extend_from_slice(segments); + if !candidates.contains(&local) { + candidates.push(local); + } + } + } + candidates +} + +fn import_map(items: &[Item]) -> BTreeMap<String, Vec<String>> { + fn visit(tree: &UseTree, prefix: &mut Vec<String>, output: &mut BTreeMap<String, Vec<String>>) { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + visit(&path.tree, prefix, output); + prefix.pop(); + } + UseTree::Name(name) => { + if name.ident == "self" { + if let Some(local) = prefix.last() { + output.insert(local.clone(), prefix.clone()); + } + } else { + let mut path = prefix.clone(); + path.push(name.ident.to_string()); + output.insert(name.ident.to_string(), path); + } + } + UseTree::Rename(rename) => { + let mut path = prefix.clone(); + path.push(rename.ident.to_string()); + output.insert(rename.rename.to_string(), path); + } + UseTree::Glob(_) => {} + UseTree::Group(group) => { + for item in &group.items { + visit(item, prefix, output); + } + } + } + } + + let mut output = BTreeMap::new(); + for item_use in items.iter().filter_map(|item| match item { + Item::Use(item_use) => Some(item_use), + _ => None, + }) { + visit(&item_use.tree, &mut Vec::new(), &mut output); + } + output +} + +#[allow(clippy::too_many_arguments)] +fn scan_item( + package: &str, + unit: &ModuleUnit, + item: &Item, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + let name = item_name(item).unwrap_or_else(|| "<item>".to_owned()); + let label = qualified_item(&unit.module, &name); + let mut visitor = + LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); + match item { + Item::Const(item) => visitor.visit_type(&item.ty), + Item::Enum(item) => { + visitor.visit_generics(&item.generics); + for variant in &item.variants { + visit_fields(&mut visitor, &variant.fields, true); + } + } + Item::Fn(item) => visitor.visit_signature(&item.sig), + Item::ForeignMod(item) => { + for foreign in &item.items { + if let ForeignItem::Fn(function) = foreign { + visitor.visit_signature(&function.sig); + } + } + } + Item::Static(item) => visitor.visit_type(&item.ty), + Item::Struct(item) => { + visitor.visit_generics(&item.generics); + visit_fields(&mut visitor, &item.fields, false); + } + Item::Trait(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.supertraits { + visitor.visit_type_param_bound(bound); + } + for trait_item in &item.items { + match trait_item { + TraitItem::Const(item) => visitor.visit_type(&item.ty), + TraitItem::Fn(item) => visitor.visit_signature(&item.sig), + TraitItem::Type(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.bounds { + visitor.visit_type_param_bound(bound); + } + if let Some((_, ty)) = &item.default { + visitor.visit_type(ty); + } + } + _ => {} + } + } + } + Item::TraitAlias(item) => { + visitor.visit_generics(&item.generics); + for bound in &item.bounds { + visitor.visit_type_param_bound(bound); + } + } + Item::Type(item) => { + visitor.visit_generics(&item.generics); + visitor.visit_type(&item.ty); + } + Item::Union(item) => { + visitor.visit_generics(&item.generics); + for field in &item.fields.named { + if is_public(&field.vis) { + visitor.visit_type(&field.ty); + } + } + } + _ => {} + } +} + +fn visit_fields(visitor: &mut LeakageVisitor<'_>, fields: &Fields, all_visible: bool) { + for field in fields { + if all_visible || is_public(&field.vis) { + visitor.visit_type(&field.ty); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn scan_impl( + package: &str, + unit: &ModuleUnit, + item_impl: &syn::ItemImpl, + self_name: &str, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + let trait_impl = item_impl.trait_.is_some(); + for item in &item_impl.items { + let (name, is_exposed) = match item { + ImplItem::Const(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), + ImplItem::Fn(item) => ( + item.sig.ident.to_string(), + trait_impl || is_public(&item.vis), + ), + ImplItem::Type(item) => (item.ident.to_string(), trait_impl || is_public(&item.vis)), + _ => continue, + }; + if !is_exposed { + continue; + } + let label = format!("{}::{name}", qualified_item(&unit.module, self_name)); + let mut visitor = + LeakageVisitor::new(package, unit, label, imports, forbidden, allowed, findings); + if let Some((_, trait_path, _)) = &item_impl.trait_ { + visitor.visit_path(trait_path); + } + match item { + ImplItem::Const(item) => visitor.visit_type(&item.ty), + ImplItem::Fn(item) => visitor.visit_signature(&item.sig), + ImplItem::Type(item) => { + visitor.visit_generics(&item.generics); + visitor.visit_type(&item.ty); + } + _ => {} + } + } +} + +#[allow(clippy::too_many_arguments)] +fn scan_public_use( + package: &str, + unit: &ModuleUnit, + item_use: &syn::ItemUse, + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + for (segments, _) in flatten_use_tree(&item_use.tree) { + if segments.is_empty() { + continue; + } + let label = format!("{}::pub use", module_label(&unit.module)); + record_path( + package, unit, &label, &segments, imports, forbidden, allowed, findings, + ); + } +} + +struct LeakageVisitor<'a> { + package: &'a str, + unit: &'a ModuleUnit, + item: String, + imports: &'a BTreeMap<String, Vec<String>>, + forbidden: &'a BTreeSet<&'a str>, + allowed: &'a BTreeSet<&'a str>, + findings: &'a mut BTreeSet<ApiFinding>, +} + +impl<'a> LeakageVisitor<'a> { + #[allow(clippy::too_many_arguments)] + fn new( + package: &'a str, + unit: &'a ModuleUnit, + item: String, + imports: &'a BTreeMap<String, Vec<String>>, + forbidden: &'a BTreeSet<&'a str>, + allowed: &'a BTreeSet<&'a str>, + findings: &'a mut BTreeSet<ApiFinding>, + ) -> Self { + Self { + package, + unit, + item, + imports, + forbidden, + allowed, + findings, + } + } +} + +impl<'ast> Visit<'ast> for LeakageVisitor<'_> { + fn visit_path(&mut self, path: &'ast SynPath) { + let segments = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>(); + record_path( + self.package, + self.unit, + &self.item, + &segments, + self.imports, + self.forbidden, + self.allowed, + self.findings, + ); + syn::visit::visit_path(self, path); + } +} + +#[allow(clippy::too_many_arguments)] +fn record_path( + package: &str, + unit: &ModuleUnit, + item: &str, + segments: &[String], + imports: &BTreeMap<String, Vec<String>>, + forbidden: &BTreeSet<&str>, + allowed: &BTreeSet<&str>, + findings: &mut BTreeSet<ApiFinding>, +) { + if segments.is_empty() { + return; + } + let resolved = if let Some(imported) = imports.get(&segments[0]) { + let mut resolved = imported.clone(); + resolved.extend_from_slice(&segments[1..]); + resolved + } else { + segments.to_vec() + }; + let observed = resolved.join("::"); + for forbidden_path in forbidden { + if allowed.contains(forbidden_path) + || !(observed == *forbidden_path + || observed + .strip_prefix(forbidden_path) + .is_some_and(|suffix| suffix.starts_with("::"))) + { + continue; + } + findings.insert(ApiFinding { + package: package.to_owned(), + source: unit.source_relative.clone(), + item: item.to_owned(), + forbidden_path: (*forbidden_path).to_owned(), + observed_path: observed.clone(), + }); + } +} + +fn exception_matches(exceptions: &[ApiException], finding: &ApiFinding) -> bool { + exceptions.iter().any(|exception| { + exception.package == finding.package + && exception.source == finding.source + && exception.forbidden_path == finding.forbidden_path + && exception.items.binary_search(&finding.item).is_ok() + && exception + .observed_paths + .binary_search(&finding.observed_path) + .is_ok() + }) +} + +fn format_finding(finding: &ApiFinding) -> String { + format!( + "package={} source={} item={} forbidden_path={} observed_path={}", + finding.package, + finding.source, + finding.item, + finding.forbidden_path, + finding.observed_path + ) +} + +fn item_is_public(item: &Item) -> bool { + match item { + Item::Const(item) => is_public(&item.vis), + Item::Enum(item) => is_public(&item.vis), + Item::Fn(item) => is_public(&item.vis), + Item::ForeignMod(_) => true, + Item::Static(item) => is_public(&item.vis), + Item::Struct(item) => is_public(&item.vis), + Item::Trait(item) => is_public(&item.vis), + Item::TraitAlias(item) => is_public(&item.vis), + Item::Type(item) => is_public(&item.vis), + Item::Union(item) => is_public(&item.vis), + _ => false, + } +} + +fn item_name(item: &Item) -> Option<String> { + match item { + Item::Const(item) => Some(item.ident.to_string()), + Item::Enum(item) => Some(item.ident.to_string()), + Item::Fn(item) => Some(item.sig.ident.to_string()), + Item::Static(item) => Some(item.ident.to_string()), + Item::Struct(item) => Some(item.ident.to_string()), + Item::Trait(item) => Some(item.ident.to_string()), + Item::TraitAlias(item) => Some(item.ident.to_string()), + Item::Type(item) => Some(item.ident.to_string()), + Item::Union(item) => Some(item.ident.to_string()), + _ => None, + } +} + +fn impl_self_name(self_ty: &Type) -> Option<String> { + let Type::Path(path) = self_ty else { + return None; + }; + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()) +} + +fn is_public(visibility: &Visibility) -> bool { + matches!(visibility, Visibility::Public(_)) +} + +fn qualified_item(module: &[String], item: &str) -> String { + if module.is_empty() { + item.to_owned() + } else { + format!("{}::{item}", module.join("::")) + } +} + +fn module_label(module: &[String]) -> String { + if module.is_empty() { + "crate".to_owned() + } else { + module.join("::") + } +} + +#[cfg(test)] +mod tests { + use super::{ + ApiBoundaryPolicy, ApiException, CargoMetadata, exception_matches, scan_workspace, + validate_policy, + }; + use serde::Deserialize; + use std::{collections::BTreeSet, fs}; + + const POLICY: &str = include_str!("../../../../contracts/releases/api_boundaries.toml"); + const ARCHITECTURE: &str = + include_str!("../../../../docs/specs/radroots_crates_release_v1.toml"); + const GENERIC_SQLX: &str = include_str!("../../tests/fixtures/api-leakage/generic-sqlx.rs"); + const GENERIC_RENAMED_TOKIO: &str = + include_str!("../../tests/fixtures/api-leakage/generic-renamed-tokio.rs"); + const ALLOWED_ADAPTER: &str = + include_str!("../../tests/fixtures/api-leakage/allowed-concrete-adapter.rs"); + const PRIVATE_IMPLEMENTATION: &str = + include_str!("../../tests/fixtures/api-leakage/private-implementation.rs"); + const ADR_EXCEPTION: &str = include_str!("../../tests/fixtures/api-leakage/adr-exception.rs"); + + #[derive(Deserialize)] + struct ArchitectureCatalog { + package: Vec<ArchitecturePackage>, + } + + #[derive(Deserialize)] + struct ArchitecturePackage { + name: String, + } + + fn policy() -> ApiBoundaryPolicy { + toml::from_str(POLICY).expect("API boundary policy") + } + + fn expected_packages() -> BTreeSet<String> { + toml::from_str::<ArchitectureCatalog>(ARCHITECTURE) + .expect("architecture") + .package + .into_iter() + .map(|package| package.name) + .collect() + } + + fn fixture_metadata(root: &std::path::Path, package: &str, source: &str) -> CargoMetadata { + let package_root = root.join(package); + fs::create_dir_all(package_root.join("src")).expect("create fixture source"); + fs::write( + package_root.join("Cargo.toml"), + "[package]\nname='fixture'\n", + ) + .expect("write fixture manifest"); + fs::write(package_root.join("src/lib.rs"), source).expect("write fixture source"); + let id = format!("fixture#{package}@0.1.0"); + CargoMetadata { + workspace_members: vec![id.clone()], + packages: vec![super::CargoPackage { + id, + name: package.to_owned(), + manifest_path: package_root + .join("Cargo.toml") + .to_string_lossy() + .into_owned(), + targets: vec![super::CargoTarget { + kind: vec!["lib".to_owned()], + src_path: package_root + .join("src/lib.rs") + .to_string_lossy() + .into_owned(), + }], + }], + } + } + + fn scan_fixture(package: &str, source: &str) -> Vec<super::ApiFinding> { + let root = tempfile::TempDir::new().expect("fixture root"); + scan_workspace( + root.path(), + &policy(), + &fixture_metadata(root.path(), package, source), + ) + .expect("scan fixture") + } + + fn write_baseline_adr(root: &std::path::Path) { + fs::create_dir_all(root.join("docs/decisions")).expect("baseline ADR directory"); + fs::write( + root.join("docs/decisions/0001-public-api-leakage-migration-baseline.md"), + "RCRV1-API-001 RCRV1-API-002 RCRV1-API-003 RCRV1-API-004 RCRV1-API-005 RCRV1-API-006 RCRV1-API-007 RCRV1-API-008\n", + ) + .expect("baseline ADR"); + } + + #[test] + fn policy_covers_exact_architecture_catalog() { + let root = tempfile::TempDir::new().expect("policy root"); + write_baseline_adr(root.path()); + validate_policy(root.path(), &policy(), &expected_packages()) + .expect("policy without exceptions"); + } + + #[test] + fn generic_sqlx_public_field_is_forbidden() { + let findings = scan_fixture("radroots-storage", GENERIC_SQLX); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].item, "StorageHandle"); + assert_eq!(findings[0].forbidden_path, "sqlx"); + assert_eq!(findings[0].observed_path, "sqlx::SqlitePool"); + } + + #[test] + fn renamed_tokio_return_type_is_resolved() { + let findings = scan_fixture("radroots-sync", GENERIC_RENAMED_TOKIO); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].item, "executor"); + assert_eq!(findings[0].forbidden_path, "tokio"); + assert_eq!(findings[0].observed_path, "tokio::runtime::Handle"); + } + + #[test] + fn specified_nostr_adapter_may_expose_protocol_types() { + let findings = scan_fixture("radroots-nostr", ALLOWED_ADAPTER); + assert!(findings.is_empty()); + } + + #[test] + fn private_implementation_types_are_not_public_api() { + let findings = scan_fixture("radroots-sdk", PRIVATE_IMPLEMENTATION); + assert!(findings.is_empty()); + } + + #[test] + fn exact_item_exception_requires_resolving_adr() { + let root = tempfile::TempDir::new().expect("exception root"); + write_baseline_adr(root.path()); + fs::create_dir_all(root.path().join("docs/decisions")).expect("ADR directory"); + fs::write( + root.path().join("docs/decisions/0001-test.md"), + "# Test\n\nRCRV1-API-999\n", + ) + .expect("ADR"); + let mut policy = policy(); + policy.exception.push(ApiException { + id: "RCRV1-API-999".to_owned(), + package: "radroots-sdk".to_owned(), + source: "src/lib.rs".to_owned(), + forbidden_path: "reqwest".to_owned(), + items: vec!["temporary_client".to_owned()], + observed_paths: vec!["reqwest::Client".to_owned()], + adr: "docs/decisions/0001-test.md".to_owned(), + removal_step: 226, + rationale: "test-only exception".to_owned(), + }); + validate_policy(root.path(), &policy, &expected_packages()).expect("resolving ADR"); + let finding = scan_workspace( + root.path(), + &policy, + &fixture_metadata(root.path(), "radroots-sdk", ADR_EXCEPTION), + ) + .expect("scan exception fixture") + .pop() + .expect("finding"); + assert!(exception_matches(&policy.exception, &finding)); + + fs::remove_file(root.path().join("docs/decisions/0001-test.md")).expect("remove ADR"); + let error = validate_policy(root.path(), &policy, &expected_packages()) + .expect_err("missing ADR must fail"); + assert!(error.contains("is not readable")); + } +} diff --git a/tools/xtask/src/contract/source_maintenance.rs b/tools/xtask/src/contract/source_maintenance.rs @@ -76,7 +76,7 @@ const RESULT_VECTOR_EXECUTOR_TEST: &str = "source_maintenance_v1_result_vector"; const CONTRACT_COMMAND_SOURCE_RELATIVE: &str = "tools/xtask/src/contract.rs"; const XTASK_MAIN_SOURCE_RELATIVE: &str = "tools/xtask/src/main.rs"; const XTASK_MAIN_FULL_AST_SHA256: &str = - "6caf782b040039992da219173a397e62653e5b155f836a2ebb42cff4e532d18a"; + "585b9221b4cf1e68bc637528972cfe016f2dfd78fee800ddc7a0593411781c03"; const RAW_EVENT_COLUMNS: &[&str] = &[ "event_id", diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -19,6 +19,7 @@ use std::process::ExitCode; fn usage() { eprintln!("usage:"); eprintln!(" cargo xtask architecture"); + eprintln!(" cargo xtask check-api-boundaries"); eprintln!(" cargo xtask check-dependency-boundaries"); eprintln!(" cargo xtask contract validate"); eprintln!(" cargo xtask contract event-contract-registry-v7 [--write]"); @@ -146,6 +147,9 @@ fn run_contract(args: &[String]) -> Result<(), String> { fn run(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("architecture") if args.len() == 1 => architecture::validate(&workspace_root()), + Some("check-api-boundaries") if args.len() == 1 => { + architecture::validate_api_boundaries(&workspace_root()) + } Some("check-dependency-boundaries") if args.len() == 1 => { architecture::validate_dependency_boundaries(&workspace_root()) } diff --git a/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs b/tools/xtask/tests/fixtures/api-leakage/adr-exception.rs @@ -0,0 +1,3 @@ +pub fn temporary_client() -> reqwest::Client { + unreachable!() +} diff --git a/tools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs b/tools/xtask/tests/fixtures/api-leakage/allowed-concrete-adapter.rs @@ -0,0 +1,3 @@ +pub fn protocol_event(event: nostr::Event) -> nostr::Event { + event +} diff --git a/tools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs b/tools/xtask/tests/fixtures/api-leakage/generic-renamed-tokio.rs @@ -0,0 +1,5 @@ +use tokio::runtime::Handle as Executor; + +pub fn executor() -> Executor { + unreachable!() +} diff --git a/tools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs b/tools/xtask/tests/fixtures/api-leakage/generic-sqlx.rs @@ -0,0 +1,5 @@ +use sqlx::SqlitePool; + +pub struct StorageHandle { + pub pool: SqlitePool, +} diff --git a/tools/xtask/tests/fixtures/api-leakage/private-implementation.rs b/tools/xtask/tests/fixtures/api-leakage/private-implementation.rs @@ -0,0 +1,11 @@ +use reqwest::Client; + +pub struct Adapter { + client: Client, +} + +impl Adapter { + fn client(&self) -> &Client { + &self.client + } +}