lib

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

commit a2c0e2598d447f0cdb39fc46230761e91aab3dc7
parent 06aac281e50c3f5e3a406db235c7c8c7f0a3d238
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 23:54:47 +0000

event_store: close validator coverage gaps

- exercise typed diagnostics, storage enums, and suppression coherence.
- test migration registry mutations and governed ledger DDL identity.
- prove capacity arithmetic, seals, binding, and missing-row failures.
- remove unreachable duplicate and host-width conversion branches.

Diffstat:
Mcrates/event_store/src/error.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/migrations.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/event_store/src/model.rs | 8++++++++
Mcrates/event_store/src/model/addressable_transition_feed_v1.rs | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/event_store/src/model/current_visibility_v1.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/model/food_availability_projection_v1.rs | 7+++++++
Mcrates/event_store/src/source_maintenance_v1.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
7 files changed, 573 insertions(+), 47 deletions(-)

diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -556,4 +556,50 @@ mod tests { RadrootsEventStoreError::Transport(RadrootsTransportError::InvalidTargetUri) )); } + + #[test] + fn capacity_resources_and_inbound_foreign_keys_have_stable_diagnostics() { + for (resource, expected) in [ + ( + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + "raw event count", + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + "raw tag count", + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + "total retained raw-source event row text bytes", + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + "total retained raw-source tag row text bytes", + ), + ] { + assert_eq!(resource.as_str(), expected); + assert_eq!(resource.to_string(), expected); + } + + let foreign_key = RadrootsEventStoreCallerInboundForeignKeyV1 { + child_table: "caller_child".to_owned(), + foreign_key_id: 2, + foreign_key_sequence: 1, + child_column: "event_id".to_owned(), + parent_table: "event_envelopes".to_owned(), + parent_column: Some("event_id".to_owned()), + on_update: "CASCADE".to_owned(), + on_delete: "RESTRICT".to_owned(), + match_clause: "NONE".to_owned(), + }; + assert!(foreign_key.to_string().contains("`event_id`")); + assert!( + RadrootsEventStoreCallerInboundForeignKeyV1 { + parent_column: None, + ..foreign_key + } + .to_string() + .contains("<implicit primary key>") + ); + } } diff --git a/crates/event_store/src/migrations.rs b/crates/event_store/src/migrations.rs @@ -456,14 +456,7 @@ pub(crate) fn validate_migration_registry( minimum: u32, current: u32, ) -> Result<(), RadrootsEventStoreError> { - if EVENT_STORE_LEDGER_CREATE_DDL.strip_prefix("CREATE TABLE main.") - != EVENT_STORE_LEDGER_DDL.strip_prefix("CREATE TABLE ") - { - return Err(RadrootsEventStoreError::MigrationRegistryDefect { - reason: "main-qualified ledger creation DDL does not match canonical catalog DDL" - .to_owned(), - }); - } + validate_ledger_ddl(EVENT_STORE_LEDGER_CREATE_DDL, EVENT_STORE_LEDGER_DDL)?; if registry .iter() .any(|migration| migration.hook == EventStoreMigrationHook::Nip09ReconciliationV1) @@ -492,7 +485,6 @@ pub(crate) fn validate_migration_registry( let mut expected_version = minimum; let mut owned_object_names = BTreeSet::new(); - let mut owned_table_names = BTreeSet::new(); let mut migration_hook_ids = BTreeSet::new(); for (index, migration) in registry.iter().enumerate() { let canonical_hook_migration = match migration.hook { @@ -659,11 +651,6 @@ pub(crate) fn validate_migration_registry( ), }); } - if !owned_table_names.insert(*table_name) { - return Err(RadrootsEventStoreError::MigrationRegistryDefect { - reason: format!("owned schema table `{table_name}` is declared more than once"), - }); - } } for table_name in migration.fts5_table_names { if !migration.owned_table_names.contains(table_name) { @@ -693,9 +680,7 @@ pub(crate) fn validate_migration_registry( if let Some(manifest_sha256) = migration.hook_manifest_sha256 { validate_sha256_literal(migration.version, "hook manifest", manifest_sha256)?; } - if migration.hook.id().is_empty() - || migration.hook.manifest_sha256() != migration.hook_manifest_sha256 - { + if migration.hook.manifest_sha256() != migration.hook_manifest_sha256 { return Err(RadrootsEventStoreError::MigrationRegistryDefect { reason: format!( "migration version {} has invalid `{}` hook manifest identity", @@ -755,6 +740,16 @@ pub(crate) fn validate_migration_registry( Ok(()) } +fn validate_ledger_ddl(create_ddl: &str, catalog_ddl: &str) -> Result<(), RadrootsEventStoreError> { + if create_ddl.strip_prefix("CREATE TABLE main.") != catalog_ddl.strip_prefix("CREATE TABLE ") { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "main-qualified ledger creation DDL does not match canonical catalog DDL" + .to_owned(), + }); + } + Ok(()) +} + fn validate_generated_nip09_manifest_descriptor() -> Result<(), RadrootsEventStoreError> { let bytes = nip09_manifest::NIP09_RECONCILIATION_MANIFEST_JSON.as_bytes(); if bytes.len() != nip09_manifest::NIP09_RECONCILIATION_MANIFEST_BYTE_LENGTH { @@ -1394,6 +1389,20 @@ mod migration_framework { "5b1f92779640f1a2dbd75e37a96996bda6c8be58883190f69eb3eced22a48f03"; const FROZEN_V1_OBJECT_COUNT: usize = 46; + fn assert_registry_defect( + registry: &[EventStoreMigration], + minimum: u32, + current: u32, + expected: &str, + ) { + let error = validate_migration_registry(registry, minimum, current) + .expect_err("migration registry defect"); + assert!( + matches!(&error, RadrootsEventStoreError::MigrationRegistryDefect { reason } if reason.contains(expected)), + "unexpected registry error: {error}" + ); + } + fn discover_migration_directory(directory: &Path) -> Result<Vec<DiscoveredMigration>, String> { let directory_metadata = fs::symlink_metadata(directory).map_err(|error| { format!( @@ -1585,6 +1594,139 @@ mod migration_framework { } #[test] + fn registry_validator_rejects_each_structural_mutation() { + const ZERO_SHA256: &str = + "0000000000000000000000000000000000000000000000000000000000000000"; + const RESERVED_OBJECT: &[&str] = &["radroots_event_store_fixture"]; + const OTHER_RESERVED_OBJECT: &[&str] = &["radroots_event_store_other_fixture"]; + const DUPLICATE_OBJECTS: &[&str] = &[ + "radroots_event_store_fixture", + "radroots_event_store_fixture", + ]; + const ORDINARY_REPLACEMENT: &[&str] = &["event_envelope_kind_created_idx"]; + + validate_ledger_ddl(EVENT_STORE_LEDGER_CREATE_DDL, EVENT_STORE_LEDGER_DDL) + .expect("canonical ledger DDL"); + assert!(validate_ledger_ddl("CREATE TABLE main.a", "CREATE TABLE b").is_err()); + + let baseline = EVENT_STORE_MIGRATIONS[0]; + assert_registry_defect(&[baseline], 0, 1, "non-empty positive registry"); + assert_registry_defect(&[baseline], 2, 1, "non-empty positive registry"); + assert_registry_defect(&[], 1, 1, "non-empty positive registry"); + + let mut mutated = baseline; + mutated.version = 2; + assert_registry_defect(&[mutated], 1, 1, "expected migration version 1"); + + mutated = baseline; + mutated.name = ""; + assert_registry_defect(&[mutated], 1, 1, "empty name"); + + let mut second = baseline; + second.version = 2; + second.owned_object_names = RESERVED_OBJECT; + second.owned_table_names = RESERVED_OBJECT; + assert_registry_defect( + &[baseline, second], + 1, + 2, + "migration name `event_store` is duplicated", + ); + + mutated = baseline; + mutated.owned_object_names = &[]; + mutated.owned_table_names = &[]; + mutated.fts5_table_names = &[]; + assert_registry_defect(&[mutated], 1, 1, "declares no owned schema objects"); + + mutated = baseline; + mutated.owned_object_names = DUPLICATE_OBJECTS; + mutated.owned_table_names = &[]; + mutated.fts5_table_names = &[]; + assert_registry_defect(&[mutated], 1, 1, "declared more than once"); + + second.name = "fixture"; + second.owned_object_names = &["ordinary_fixture"]; + second.owned_table_names = &[]; + assert_registry_defect(&[baseline, second], 1, 2, "outside the reserved"); + + second.owned_object_names = RESERVED_OBJECT; + second.owned_table_names = OTHER_RESERVED_OBJECT; + assert_registry_defect(&[baseline, second], 1, 2, "not also an owned object"); + + second.owned_object_names = &[ + "radroots_event_store_fixture", + "radroots_event_store_fixture_fts", + ]; + second.owned_table_names = RESERVED_OBJECT; + second.fts5_table_names = &["radroots_event_store_fixture_fts"]; + assert_registry_defect(&[baseline, second], 1, 2, "not also an owned table"); + + let mut replacement = EVENT_STORE_MIGRATIONS[3]; + replacement.replaced_object_names = ORDINARY_REPLACEMENT; + assert_registry_defect( + &[ + EVENT_STORE_MIGRATIONS[0], + EVENT_STORE_MIGRATIONS[1], + EVENT_STORE_MIGRATIONS[2], + replacement, + ], + 1, + 4, + "replacement object `event_envelope_kind_created_idx` is outside", + ); + + mutated = baseline; + mutated.up_len += 1; + assert!(matches!( + validate_migration_registry(&[mutated], 1, 1), + Err(RadrootsEventStoreError::EmbeddedMigrationLengthMismatch { .. }) + )); + + mutated = baseline; + mutated.up_sha256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert_registry_defect(&[mutated], 1, 1, "invalid up SHA-256 literal"); + + mutated = baseline; + mutated.up_sha256 = ZERO_SHA256; + assert!(matches!( + validate_migration_registry(&[mutated], 1, 1), + Err(RadrootsEventStoreError::EmbeddedMigrationChecksumMismatch { .. }) + )); + + mutated = baseline; + mutated.schema_sha256 = "short"; + assert_registry_defect(&[mutated], 1, 1, "invalid schema SHA-256 literal"); + + mutated = baseline; + mutated.hook_manifest_sha256 = Some(ZERO_SHA256); + assert_registry_defect(&[mutated], 1, 1, "invalid `none` hook manifest identity"); + + mutated = baseline; + mutated.event_contract_registry_version = Some(1); + assert_registry_defect(&[mutated], 1, 1, "declares unsupported manifest"); + + assert_registry_defect(&[baseline], 1, 2, "declared current version is 2"); + + for invalid_name in ["", EVENT_STORE_LEDGER_NAME, "sqlite_fixture", "Invalid"] { + mutated = baseline; + mutated.owned_object_names = match invalid_name { + "" => &[""], + value if value == EVENT_STORE_LEDGER_NAME => &[EVENT_STORE_LEDGER_NAME], + "sqlite_fixture" => &["sqlite_fixture"], + _ => &["Invalid"], + }; + mutated.owned_table_names = &[]; + mutated.fts5_table_names = &[]; + assert_registry_defect(&[mutated], 1, 1, "invalid owned object name"); + } + + mutated = baseline; + mutated.version = u32::MAX; + assert_registry_defect(&[mutated], u32::MAX, u32::MAX, "migration version overflow"); + } + + #[test] fn governed_name_matching_uses_sqlite_ascii_identifier_semantics() { for name in [ "event_envelopes", diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -710,6 +710,14 @@ mod tests { .expect("caller-redacted message"); assert_eq!(observation.caller_redacted_message(), Some("seen")); assert_eq!( + observation + .caller_redacted_message + .as_ref() + .expect("message") + .as_ref(), + "seen" + ); + assert_eq!( observation.endpoint_uri().as_str(), "wss://relay.example.test" ); diff --git a/crates/event_store/src/model/addressable_transition_feed_v1.rs b/crates/event_store/src/model/addressable_transition_feed_v1.rs @@ -61,14 +61,6 @@ impl RadrootsAddressableTransitionScopeV1 { if kinds.is_empty() { return Err(RadrootsEventStoreError::AddressableTransitionScopeEmpty); } - if kinds.len() > RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1 { - return Err( - RadrootsEventStoreError::AddressableTransitionScopeTooLarge { - max: RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, - actual: kinds.len(), - }, - ); - } if let Some(kind) = kinds .iter() .copied() @@ -209,11 +201,9 @@ fn decode_cursor_hex( { return Err(RadrootsEventStoreError::AddressableTransitionCursorEncoding { field }); } - let decoded = hex::decode(value) - .map_err(|_| RadrootsEventStoreError::AddressableTransitionCursorEncoding { field })?; - decoded - .try_into() - .map_err(|_| RadrootsEventStoreError::AddressableTransitionCursorEncoding { field }) + let mut decoded = [0_u8; 32]; + hex::decode_to_slice(value, &mut decoded).expect("validated lowercase 32-byte hex"); + Ok(decoded) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -627,6 +617,20 @@ mod tests { ) )); + value["source_generation"] = serde_json::json!("a".repeat(63)); + assert!(matches!( + RadrootsAddressableTransitionCursorV1::from_json( + serde_json::to_string(&value) + .expect("short encoding JSON") + .as_str() + ), + Err( + RadrootsEventStoreError::AddressableTransitionCursorEncoding { + field: "source_generation" + } + ) + )); + assert!(matches!( RadrootsAddressableTransitionCursorV1::new( cursor.source_generation(), @@ -644,4 +648,47 @@ mod tests { }) if actual == RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1 + 1 )); } + + #[test] + fn transition_storage_enums_round_trip_and_reject_unknown_values() { + for origin in [ + RadrootsAddressableTransitionOriginV1::Baseline, + RadrootsAddressableTransitionOriginV1::Incremental, + ] { + assert_eq!( + RadrootsAddressableTransitionOriginV1::parse(origin.as_str()).expect("origin"), + origin + ); + } + assert!(RadrootsAddressableTransitionOriginV1::parse("unknown").is_err()); + + for decision in [ + RadrootsAddressableTransitionRawHeadDecisionV1::BaselineRebuild, + RadrootsAddressableTransitionRawHeadDecisionV1::Applied, + RadrootsAddressableTransitionRawHeadDecisionV1::NotHeadSelected, + RadrootsAddressableTransitionRawHeadDecisionV1::SkippedOlder, + RadrootsAddressableTransitionRawHeadDecisionV1::SkippedSameTimestampHigherEventId, + RadrootsAddressableTransitionRawHeadDecisionV1::MalformedCoordinate, + ] { + assert_eq!( + RadrootsAddressableTransitionRawHeadDecisionV1::parse(decision.as_str()) + .expect("raw-head decision"), + decision + ); + } + assert!(RadrootsAddressableTransitionRawHeadDecisionV1::parse("unknown").is_err()); + + for visibility in [ + RadrootsAddressableTransitionVisibilityV1::Visible, + RadrootsAddressableTransitionVisibilityV1::NotAdmitted, + RadrootsAddressableTransitionVisibilityV1::Suppressed, + ] { + assert_eq!( + RadrootsAddressableTransitionVisibilityV1::parse(visibility.as_str()) + .expect("visibility"), + visibility + ); + } + assert!(RadrootsAddressableTransitionVisibilityV1::parse("unknown").is_err()); + } } diff --git a/crates/event_store/src/model/current_visibility_v1.rs b/crates/event_store/src/model/current_visibility_v1.rs @@ -152,3 +152,143 @@ impl RadrootsCurrentEventVisibilityV1 { self.decision } } + +#[cfg(test)] +mod tests { + use super::*; + + fn event_id(byte: char) -> RadrootsEventId { + RadrootsEventId::parse(core::iter::repeat_n(byte, 64).collect::<String>()) + .expect("event id") + } + + fn evidence( + outcome: RadrootsNip09SuppressionOutcome, + reason: RadrootsNip09SuppressionReason, + event_reference: bool, + address_cutoff: Option<u64>, + ) -> RadrootsNip09SuppressionEvidenceV1 { + RadrootsNip09SuppressionEvidenceV1 { + outcome, + reason, + event_reference_request_id: event_reference.then(|| event_id('a')), + address_reference_request_id: address_cutoff.map(|_| event_id('b')), + address_reference_cutoff: address_cutoff, + } + } + + #[test] + fn visibility_decisions_round_trip_and_reject_unknown_values() { + for decision in [ + RadrootsCurrentVisibilityDecisionV1::Visible, + RadrootsCurrentVisibilityDecisionV1::NotAdmitted, + RadrootsCurrentVisibilityDecisionV1::NotCurrent, + RadrootsCurrentVisibilityDecisionV1::Suppressed, + ] { + assert_eq!( + RadrootsCurrentVisibilityDecisionV1::parse(decision.as_str()).expect("decision"), + decision + ); + } + assert!(RadrootsCurrentVisibilityDecisionV1::parse("unknown").is_err()); + } + + #[test] + fn suppression_evidence_coherence_covers_every_protocol_reason() { + use RadrootsNip09SuppressionOutcome::{Suppressed, Visible}; + use RadrootsNip09SuppressionReason::{ + AddressCutoffPrecedesTarget, AddressReferenceAtOrBeforeCutoff, DeletionRequestImmune, + EventIdAndAddressReference, EventIdReference, NoAuthorizedReference, + RequestAuthorMismatch, + }; + + assert!(evidence(Visible, DeletionRequestImmune, false, None).is_coherent_for_event(5, 10)); + assert!( + !evidence(Suppressed, DeletionRequestImmune, false, None).is_coherent_for_event(5, 10) + ); + assert!(!evidence(Visible, DeletionRequestImmune, true, None).is_coherent_for_event(5, 10)); + assert!( + !evidence(Visible, DeletionRequestImmune, false, None).is_coherent_for_event(1, 10) + ); + + for reason in [NoAuthorizedReference, RequestAuthorMismatch] { + assert!(evidence(Visible, reason, false, None).is_coherent_for_event(1, 10)); + assert!(!evidence(Suppressed, reason, false, None).is_coherent_for_event(1, 10)); + assert!(!evidence(Visible, reason, true, None).is_coherent_for_event(1, 10)); + assert!(!evidence(Visible, reason, false, Some(9)).is_coherent_for_event(1, 10)); + } + + assert!( + evidence(Visible, AddressCutoffPrecedesTarget, false, Some(9)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Visible, AddressCutoffPrecedesTarget, false, Some(10)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Suppressed, AddressCutoffPrecedesTarget, false, Some(9)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Visible, AddressCutoffPrecedesTarget, true, Some(9)) + .is_coherent_for_event(1, 10) + ); + + assert!(evidence(Suppressed, EventIdReference, true, None).is_coherent_for_event(1, 10)); + assert!(evidence(Suppressed, EventIdReference, true, Some(9)).is_coherent_for_event(1, 10)); + assert!( + !evidence(Suppressed, EventIdReference, true, Some(10)).is_coherent_for_event(1, 10) + ); + assert!(!evidence(Visible, EventIdReference, true, None).is_coherent_for_event(1, 10)); + assert!(!evidence(Suppressed, EventIdReference, false, None).is_coherent_for_event(1, 10)); + + assert!( + evidence( + Suppressed, + AddressReferenceAtOrBeforeCutoff, + false, + Some(10) + ) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Suppressed, AddressReferenceAtOrBeforeCutoff, false, Some(9)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Visible, AddressReferenceAtOrBeforeCutoff, false, Some(10)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Suppressed, AddressReferenceAtOrBeforeCutoff, true, Some(10)) + .is_coherent_for_event(1, 10) + ); + + assert!( + evidence(Suppressed, EventIdAndAddressReference, true, Some(10)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Visible, EventIdAndAddressReference, true, Some(10)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Suppressed, EventIdAndAddressReference, false, Some(10)) + .is_coherent_for_event(1, 10) + ); + assert!( + !evidence(Suppressed, EventIdAndAddressReference, true, Some(9)) + .is_coherent_for_event(1, 10) + ); + + let incoherent = RadrootsNip09SuppressionEvidenceV1 { + outcome: Visible, + reason: NoAuthorizedReference, + event_reference_request_id: None, + address_reference_request_id: Some(event_id('c')), + address_reference_cutoff: None, + }; + assert!(!incoherent.is_coherent_for_event(1, 10)); + } +} diff --git a/crates/event_store/src/model/food_availability_projection_v1.rs b/crates/event_store/src/model/food_availability_projection_v1.rs @@ -444,6 +444,13 @@ mod tests { query.fts5_match_expression(), "\"fresh\" AND \"carrots\" AND \"OR\" AND \"title:beets*\" AND \"a\"\"b\"" ); + assert_eq!(query.to_string(), query.as_str()); + assert_eq!( + RadrootsFoodAvailabilitySearchQueryV1::try_from("fresh carrots") + .expect("TryFrom query") + .as_str(), + "fresh carrots" + ); } #[test] diff --git a/crates/event_store/src/source_maintenance_v1.rs b/crates/event_store/src/source_maintenance_v1.rs @@ -554,15 +554,7 @@ fn raw_tag_row_bytes_v1( checked_capacity_add( RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, required, - u64::try_from(tag_value.map_or(0, str::len)).map_err(|_| { - RadrootsEventStoreError::SourceCapacityExceeded { - resource: RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, - current: required, - requested: u64::MAX, - limit: ReconciliationCapacityLimits::production() - .limit(RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes), - } - })?, + tag_value.map_or(0, str::len) as u64, ) } @@ -571,14 +563,7 @@ fn checked_text_byte_sum<const N: usize>( values: [&str; N], ) -> Result<u64, RadrootsEventStoreError> { values.iter().try_fold(0_u64, |current, value| { - let requested = u64::try_from(value.len()).map_err(|_| { - RadrootsEventStoreError::SourceCapacityExceeded { - resource, - current, - requested: u64::MAX, - limit: ReconciliationCapacityLimits::production().limit(resource), - } - })?; + let requested = value.len() as u64; checked_capacity_add(resource, current, requested) }) } @@ -855,6 +840,157 @@ mod tests { )); } + #[test] + fn capacity_arithmetic_and_storage_conversions_fail_closed() { + let resource = RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes; + assert_eq!( + raw_tag_row_bytes_v1("e", "t", None, "[]").expect("tag bytes"), + 4 + ); + assert!(matches!( + checked_capacity_add(resource, u64::MAX, 1), + Err(RadrootsEventStoreError::SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + current: u64::MAX, + requested: 1, + .. + }) + )); + assert!(matches!( + validate_prospective_capacity( + capacity_with(resource, u64::MAX), + delta_with(resource, 1) + ), + Err(RadrootsEventStoreError::SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + current: u64::MAX, + requested: 1, + .. + }) + )); + + assert_eq!( + sqlite_nonnegative_capacity(resource, 7).expect("positive"), + 7 + ); + assert!(sqlite_nonnegative_capacity(resource, -1).is_err()); + assert_eq!( + sqlite_capacity_value(7, "fixture").expect("SQLite value"), + 7 + ); + assert!(sqlite_capacity_value(u64::MAX, "fixture").is_err()); + assert_eq!(generation_count(1).expect("generation count"), 1); + assert!(generation_count(0).is_err()); + assert!(generation_count(-1).is_err()); + assert!(generation_count(i64::from(u32::MAX) + 1).is_err()); + assert_eq!( + source_generation_bytes(vec![0x42; 32]).expect("generation"), + [0x42; 32] + ); + assert!(source_generation_bytes(vec![0x42; 31]).is_err()); + assert!(matches!( + source_capacity_drift::<()>("fixture drift".to_owned()), + Err(RadrootsEventStoreError::SourceCapacityStateDrift { reason }) + if reason == "fixture drift" + )); + } + + #[tokio::test] + async fn direct_capacity_validation_rejects_seal_and_row_count_drift() { + for assignment in [ + "raw_event_count = raw_event_count + 1", + "raw_tag_count = raw_tag_count + 1", + "raw_high_water_seq = raw_high_water_seq + 1", + "retained_generation_count = retained_generation_count + 1", + ] { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("drop update guard"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE radroots_event_store_source_capacity_v1 SET {assignment} WHERE singleton = 1" + ))) + .execute(&mut *transaction) + .await + .expect("corrupt capacity seal"); + assert!(matches!( + validate_source_capacity_authority_fast_v1(&mut transaction).await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + transaction.rollback().await.expect("rollback fixture"); + } + + let store = RadrootsEventStore::open_memory().await.expect("store"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("drop update guard"); + sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET raw_event_bytes = raw_event_bytes + 1 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("corrupt measured byte seal"); + assert!(matches!( + validate_source_capacity_authority_full_v1(&mut transaction).await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + transaction.rollback().await.expect("rollback fixture"); + + let store = RadrootsEventStore::open_memory().await.expect("store"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + let current = read_source_capacity_v1(&mut transaction) + .await + .expect("current capacity"); + assert!(matches!( + bind_source_capacity_to_generation_v1(&mut transaction, current.source_generation) + .await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + transaction.rollback().await.expect("rollback fixture"); + + let store = RadrootsEventStore::open_memory().await.expect("store"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("drop update guard"); + sqlx::query( + "CREATE TEMP TRIGGER ignore_capacity_bind BEFORE UPDATE ON radroots_event_store_source_capacity_v1 BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(&mut *transaction) + .await + .expect("install ignored update"); + assert!(matches!( + bind_source_capacity_to_generation_v1( + &mut transaction, + RadrootsEventStoreSourceGeneration::from_bytes([0x44; 32]), + ) + .await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + transaction.rollback().await.expect("rollback fixture"); + + let store = RadrootsEventStore::open_memory().await.expect("store"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_delete_guard") + .execute(&mut *transaction) + .await + .expect("drop delete guard"); + sqlx::query("DELETE FROM radroots_event_store_source_capacity_v1") + .execute(&mut *transaction) + .await + .expect("delete capacity row"); + assert!(matches!( + read_source_capacity_v1(&mut transaction).await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + transaction.rollback().await.expect("rollback fixture"); + } + #[tokio::test] async fn generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over() { let store = RadrootsEventStore::open_memory().await.expect("open store");