manifest.rs (22240B)
1 //! Governed event-contract manifest generation and validation. 2 //! 3 //! Manifest operations derive deterministic JSON and SHA-256 evidence from 4 //! versioned contract authority. They perform no filesystem writes or release 5 //! publication themselves. 6 7 #[cfg(not(feature = "std"))] 8 use alloc::{ 9 string::{String, ToString}, 10 vec, 11 vec::Vec, 12 }; 13 14 use radroots_event::contract::VERSION; 15 use radroots_event::contract::{ 16 ContentSchema, ContractFamily, EventClass, EventContract, EventDiscriminator, EventPrivacy, 17 EventStability, NostrStandard, RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, Reducer, 18 TagCardinality, TagContract, TagSemantic, TagValueType, all_event_contracts_registry_v7, 19 event_contract_family, kind_contract_registry_v7, 20 }; 21 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 22 use sha2::{Digest, Sha256}; 23 24 pub mod registry_v7; 25 26 pub const RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION: u32 = 2; 27 28 const HISTORICAL_KNOWLEDGE_CONTRACT_INTRODUCTIONS: [(&str, &str); 11] = [ 29 ("radroots.knowledge.change_proposal.v1", "0.1.0-alpha.2"), 30 ("radroots.knowledge.claim.v1", "0.1.0-alpha.2"), 31 ( 32 "radroots.knowledge.contribution_attestation.v1", 33 "0.1.0-alpha.2", 34 ), 35 ("radroots.knowledge.evidence_bounty.v1", "0.1.0-alpha.2"), 36 ("radroots.knowledge.field_report.v1", "0.1.0-alpha.2"), 37 ("radroots.knowledge.relation.v1", "0.1.0-alpha.2"), 38 ("radroots.knowledge.review.v1", "0.1.0-alpha.2"), 39 ("radroots.knowledge.source.v1", "0.1.0-alpha.2"), 40 ("radroots.wiki.article.v1", "0.1.0-alpha.2"), 41 ("radroots.wiki.merge_request.v1", "0.1.0-alpha.2"), 42 ("radroots.wiki.redirect.v1", "0.1.0-alpha.2"), 43 ]; 44 45 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 46 #[serde(deny_unknown_fields)] 47 pub struct RadrootsKnowledgeContractManifest { 48 pub schema_version: u32, 49 pub registry_version: u32, 50 pub radroots_event_version: String, 51 pub radroots_event_codec_version: String, 52 pub contract_count: usize, 53 pub contracts: Vec<RadrootsKnowledgeContractManifestEntry>, 54 } 55 56 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 57 #[serde(deny_unknown_fields)] 58 pub struct RadrootsKnowledgeContractManifestEntry { 59 pub contract_id: String, 60 pub kind: u32, 61 pub class: String, 62 pub standard: String, 63 pub stability: String, 64 pub privacy: String, 65 pub author_role: String, 66 pub content_schema: String, 67 pub payload_type: String, 68 pub discriminators: Vec<RadrootsKnowledgeManifestDiscriminator>, 69 pub tag_contracts: Vec<RadrootsKnowledgeManifestTagContract>, 70 pub reducers: Vec<String>, 71 pub codec_support: RadrootsKnowledgeManifestCodecSupport, 72 pub sdk_builder_support: bool, 73 pub sdk_draft_support: bool, 74 pub wasm_tag_builder_support: bool, 75 pub wasm_verified_decode_support: bool, 76 pub deprecated: bool, 77 pub replaced_by: Option<String>, 78 pub introduced_at: String, 79 } 80 81 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 82 #[serde(tag = "type", rename_all = "snake_case")] 83 pub enum RadrootsKnowledgeManifestDiscriminator { 84 KindOnly, 85 AdmissionOnly, 86 ClassifiedListingPartition { 87 value: String, 88 }, 89 DTagExact { 90 value: String, 91 }, 92 DTagPrefix { 93 prefix: String, 94 }, 95 DTagSuffix { 96 suffix: String, 97 }, 98 TagEquals { 99 name: String, 100 value: String, 101 }, 102 ContentJsonFieldEquals { 103 field: String, 104 value: String, 105 }, 106 EnvelopeType { 107 value: String, 108 }, 109 Composite { 110 parts: Vec<RadrootsKnowledgeManifestDiscriminator>, 111 }, 112 } 113 114 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 115 #[serde(deny_unknown_fields)] 116 pub struct RadrootsKnowledgeManifestTagContract { 117 pub name: String, 118 pub cardinality: String, 119 pub semantic: String, 120 pub value_type: String, 121 pub relay_indexed: bool, 122 } 123 124 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 125 #[serde(deny_unknown_fields)] 126 pub struct RadrootsKnowledgeManifestCodecSupport { 127 pub encode: bool, 128 pub decode: bool, 129 pub contract_validation: bool, 130 pub verified_decode: bool, 131 pub verified_decode_requires_nostr: bool, 132 } 133 134 pub fn knowledge_contract_manifest() -> RadrootsKnowledgeContractManifest { 135 let mut contracts = all_event_contracts_registry_v7() 136 .iter() 137 .filter(|contract| event_contract_family(contract) == Some(ContractFamily::Knowledge)) 138 .map(manifest_entry) 139 .collect::<Vec<_>>(); 140 contracts.sort_by(|left, right| left.contract_id.cmp(&right.contract_id)); 141 142 RadrootsKnowledgeContractManifest { 143 schema_version: RADROOTS_KNOWLEDGE_CONTRACT_MANIFEST_SCHEMA_VERSION, 144 registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 145 radroots_event_version: VERSION.to_string(), 146 radroots_event_codec_version: env!("CARGO_PKG_VERSION").to_string(), 147 contract_count: contracts.len(), 148 contracts, 149 } 150 } 151 152 pub fn contract_manifest_json() -> Result<String, serde_json::Error> { 153 canonical_manifest_json(&knowledge_contract_manifest()) 154 } 155 156 pub fn contract_manifest_sha256() -> Result<String, serde_json::Error> { 157 let json = contract_manifest_json()?; 158 Ok(manifest_sha256(&json)) 159 } 160 161 pub fn parse_knowledge_contract_manifest_json( 162 json: &str, 163 ) -> Result<RadrootsKnowledgeContractManifest, serde_json::Error> { 164 serde_json::from_str(json) 165 } 166 167 pub(super) fn canonical_manifest_json<T>(manifest: &T) -> Result<String, serde_json::Error> 168 where 169 T: Serialize, 170 { 171 let mut json = serde_json::to_string_pretty(manifest)?; 172 json.push('\n'); 173 Ok(json) 174 } 175 176 pub(super) fn parse_manifest_json<T>(json: &str) -> Result<T, serde_json::Error> 177 where 178 T: DeserializeOwned, 179 { 180 serde_json::from_str(json) 181 } 182 183 pub(super) fn manifest_sha256(json: &str) -> String { 184 hex::encode(Sha256::digest(json.as_bytes())) 185 } 186 187 fn manifest_entry(contract: &EventContract) -> RadrootsKnowledgeContractManifestEntry { 188 let standard = kind_contract_registry_v7(contract.kind) 189 .map(|contract| standard_label(contract.standard)) 190 .unwrap_or("unknown"); 191 let mvp_support = mvp_sdk_and_wasm_tag_support(contract.id); 192 193 RadrootsKnowledgeContractManifestEntry { 194 contract_id: contract.id.to_string(), 195 kind: contract.kind, 196 class: class_label(contract.class).to_string(), 197 standard: standard.to_string(), 198 stability: stability_label(contract.stability).to_string(), 199 privacy: privacy_label(contract.privacy).to_string(), 200 author_role: contract.required_author_role().as_str().to_string(), 201 content_schema: content_schema_label(contract.content_schema).to_string(), 202 payload_type: contract.payload_type.to_string(), 203 discriminators: vec![discriminator_manifest(&contract.discriminator)], 204 tag_contracts: contract.tags.iter().map(tag_contract_manifest).collect(), 205 reducers: contract 206 .reducers 207 .iter() 208 .copied() 209 .map(reducer_label) 210 .map(ToString::to_string) 211 .collect(), 212 codec_support: RadrootsKnowledgeManifestCodecSupport { 213 encode: true, 214 decode: true, 215 contract_validation: true, 216 verified_decode: true, 217 verified_decode_requires_nostr: true, 218 }, 219 sdk_builder_support: mvp_support, 220 sdk_draft_support: mvp_support, 221 wasm_tag_builder_support: mvp_support, 222 wasm_verified_decode_support: true, 223 deprecated: false, 224 replaced_by: None, 225 introduced_at: knowledge_contract_introduced_at(contract.id).to_string(), 226 } 227 } 228 229 fn knowledge_contract_introduced_at(contract_id: &str) -> &'static str { 230 HISTORICAL_KNOWLEDGE_CONTRACT_INTRODUCTIONS 231 .iter() 232 .find_map(|(historical_id, version)| (*historical_id == contract_id).then_some(*version)) 233 .unwrap_or(env!("CARGO_PKG_VERSION")) 234 } 235 236 fn mvp_sdk_and_wasm_tag_support(contract_id: &str) -> bool { 237 matches!( 238 contract_id, 239 "radroots.wiki.article.v1" 240 | "radroots.wiki.redirect.v1" 241 | "radroots.wiki.merge_request.v1" 242 | "radroots.knowledge.source.v1" 243 | "radroots.knowledge.claim.v1" 244 | "radroots.knowledge.relation.v1" 245 | "radroots.knowledge.review.v1" 246 | "radroots.knowledge.field_report.v1" 247 ) 248 } 249 250 fn discriminator_manifest( 251 discriminator: &EventDiscriminator, 252 ) -> RadrootsKnowledgeManifestDiscriminator { 253 match discriminator { 254 EventDiscriminator::KindOnly => RadrootsKnowledgeManifestDiscriminator::KindOnly, 255 EventDiscriminator::AdmissionOnly => RadrootsKnowledgeManifestDiscriminator::AdmissionOnly, 256 EventDiscriminator::ClassifiedListingPartition(value) => { 257 RadrootsKnowledgeManifestDiscriminator::ClassifiedListingPartition { 258 value: classified_listing_partition_label(*value).to_string(), 259 } 260 } 261 EventDiscriminator::DTagExact(value) => RadrootsKnowledgeManifestDiscriminator::DTagExact { 262 value: (*value).to_string(), 263 }, 264 EventDiscriminator::DTagPrefix(prefix) => { 265 RadrootsKnowledgeManifestDiscriminator::DTagPrefix { 266 prefix: (*prefix).to_string(), 267 } 268 } 269 EventDiscriminator::DTagSuffix(suffix) => { 270 RadrootsKnowledgeManifestDiscriminator::DTagSuffix { 271 suffix: (*suffix).to_string(), 272 } 273 } 274 EventDiscriminator::TagEquals { name, value } => { 275 RadrootsKnowledgeManifestDiscriminator::TagEquals { 276 name: (*name).to_string(), 277 value: (*value).to_string(), 278 } 279 } 280 EventDiscriminator::ContentJsonFieldEquals { field, value } => { 281 RadrootsKnowledgeManifestDiscriminator::ContentJsonFieldEquals { 282 field: (*field).to_string(), 283 value: (*value).to_string(), 284 } 285 } 286 EventDiscriminator::EnvelopeType(value) => { 287 RadrootsKnowledgeManifestDiscriminator::EnvelopeType { 288 value: (*value).to_string(), 289 } 290 } 291 EventDiscriminator::Composite(parts) => RadrootsKnowledgeManifestDiscriminator::Composite { 292 parts: parts.iter().map(discriminator_manifest).collect(), 293 }, 294 } 295 } 296 297 fn classified_listing_partition_label( 298 value: radroots_event::listing::classified::ClassifiedListingPartition, 299 ) -> &'static str { 300 use radroots_event::listing::classified::ClassifiedListingPartition; 301 302 match value { 303 ClassifiedListingPartition::FocusedFoodAvailability => "focused_food_availability", 304 ClassifiedListingPartition::OperationalListing => "operational_listing", 305 ClassifiedListingPartition::GenericNip99 => "generic_nip99", 306 ClassifiedListingPartition::Ambiguous => "ambiguous", 307 } 308 } 309 310 fn tag_contract_manifest(contract: &TagContract) -> RadrootsKnowledgeManifestTagContract { 311 RadrootsKnowledgeManifestTagContract { 312 name: contract.name.to_string(), 313 cardinality: tag_cardinality_label(contract.cardinality).to_string(), 314 semantic: tag_semantic_label(contract.semantic).to_string(), 315 value_type: tag_value_type_label(contract.value_type).to_string(), 316 relay_indexed: contract.relay_indexed, 317 } 318 } 319 320 fn class_label(value: EventClass) -> &'static str { 321 match value { 322 EventClass::Regular => "regular", 323 EventClass::Replaceable => "replaceable", 324 EventClass::Addressable => "addressable", 325 EventClass::Ephemeral => "ephemeral", 326 } 327 } 328 329 fn standard_label(value: NostrStandard) -> &'static str { 330 match value { 331 NostrStandard::Nip01 => "nip01", 332 NostrStandard::Nip09 => "nip09", 333 NostrStandard::Nip17 => "nip17", 334 NostrStandard::Nip18 => "nip18", 335 NostrStandard::Nip22 => "nip22", 336 NostrStandard::Nip23 => "nip23", 337 NostrStandard::Nip25 => "nip25", 338 NostrStandard::Nip28 => "nip28", 339 NostrStandard::Nip29 => "nip29", 340 NostrStandard::Nip42 => "nip42", 341 NostrStandard::Nip51 => "nip51", 342 NostrStandard::Nip52 => "nip52", 343 NostrStandard::Nip53 => "nip53", 344 NostrStandard::Nip54 => "nip54", 345 NostrStandard::Nip56 => "nip56", 346 NostrStandard::Nip57 => "nip57", 347 NostrStandard::Nip78 => "nip78", 348 NostrStandard::Nip90 => "nip90", 349 NostrStandard::Nip94 => "nip94", 350 NostrStandard::Nip98 => "nip98", 351 NostrStandard::Nip99 => "nip99", 352 NostrStandard::Radroots => "radroots", 353 } 354 } 355 356 fn stability_label(value: EventStability) -> &'static str { 357 match value { 358 EventStability::Stable => "stable", 359 EventStability::Experimental => "experimental", 360 } 361 } 362 363 fn privacy_label(value: EventPrivacy) -> &'static str { 364 match value { 365 EventPrivacy::Public => "public", 366 EventPrivacy::Encrypted => "encrypted", 367 EventPrivacy::LocalOnly => "local_only", 368 EventPrivacy::Secret => "secret", 369 } 370 } 371 372 fn content_schema_label(value: ContentSchema) -> &'static str { 373 match value { 374 ContentSchema::Empty => "empty", 375 ContentSchema::JsonObject => "json_object", 376 ContentSchema::PlainText => "plain_text", 377 ContentSchema::Markdown => "markdown", 378 ContentSchema::Djot => "djot", 379 ContentSchema::Encrypted => "encrypted", 380 ContentSchema::BinaryReference => "binary_reference", 381 } 382 } 383 384 fn tag_cardinality_label(value: TagCardinality) -> &'static str { 385 match value { 386 TagCardinality::RequiredOne => "required_one", 387 TagCardinality::OptionalOne => "optional_one", 388 TagCardinality::OptionalMany => "optional_many", 389 TagCardinality::RequiredMany => "required_many", 390 } 391 } 392 393 fn tag_semantic_label(value: TagSemantic) -> &'static str { 394 match value { 395 TagSemantic::AddressableCoordinate => "addressable_coordinate", 396 TagSemantic::CalendarEventAuthor => "calendar_event_author", 397 TagSemantic::CalendarEventReference => "calendar_event_reference", 398 TagSemantic::CalendarEventRevision => "calendar_event_revision", 399 TagSemantic::CalendarInclusionRequest => "calendar_inclusion_request", 400 TagSemantic::CalendarEnd => "calendar_end", 401 TagSemantic::CalendarStart => "calendar_start", 402 TagSemantic::Category => "category", 403 TagSemantic::Citation => "citation", 404 TagSemantic::Contract => "contract", 405 TagSemantic::Counterparty => "counterparty", 406 TagSemantic::Evidence => "evidence", 407 TagSemantic::EventPointer => "event_pointer", 408 TagSemantic::FreeBusy => "free_busy", 409 TagSemantic::Geohash => "geohash", 410 TagSemantic::GroupId => "group_id", 411 TagSemantic::Identifier => "identifier", 412 TagSemantic::Image => "image", 413 TagSemantic::Kind => "kind", 414 TagSemantic::ClassifiedListingAddress => "listing_address", 415 TagSemantic::OperationalListingSnapshot => "listing_snapshot", 416 TagSemantic::ListDescription => "list_description", 417 TagSemantic::Location => "location", 418 TagSemantic::Nip01Coordinate => "nip01_coordinate", 419 TagSemantic::Participant => "participant", 420 TagSemantic::PreviousEvent => "previous_event", 421 TagSemantic::Price => "price", 422 TagSemantic::PublishedAt => "published_at", 423 TagSemantic::Relay => "relay", 424 TagSemantic::Reference => "reference", 425 TagSemantic::ReviewTarget => "review_target", 426 TagSemantic::RootEvent => "root_event", 427 TagSemantic::ServiceInput => "service_input", 428 TagSemantic::ServiceOutput => "service_output", 429 TagSemantic::Source => "source", 430 TagSemantic::Status => "status", 431 TagSemantic::Summary => "summary", 432 TagSemantic::Title => "title", 433 TagSemantic::Topic => "topic", 434 TagSemantic::TimeZone => "time_zone", 435 TagSemantic::TradeMutation => "trade_mutation", 436 TagSemantic::Url => "url", 437 TagSemantic::UtcDayCoverage => "utc_day_coverage", 438 } 439 } 440 441 fn tag_value_type_label(value: TagValueType) -> &'static str { 442 match value { 443 TagValueType::AddressableCoordinate => "addressable_coordinate", 444 TagValueType::CalendarDate => "calendar_date", 445 TagValueType::CalendarEventCoordinate => "calendar_event_coordinate", 446 TagValueType::CalendarFreeBusy => "calendar_free_busy", 447 TagValueType::CalendarRsvpStatus => "calendar_rsvp_status", 448 TagValueType::CalendarUid => "calendar_uid", 449 TagValueType::ContractId => "contract_id", 450 TagValueType::DTag => "d_tag", 451 TagValueType::EventId => "event_id", 452 TagValueType::EventPointer => "event_pointer", 453 TagValueType::Geohash => "geohash", 454 TagValueType::IanaTimeZoneId => "iana_time_zone_id", 455 TagValueType::Kind => "kind", 456 TagValueType::MutationId => "mutation_id", 457 TagValueType::Nip01Coordinate => "nip01_coordinate", 458 TagValueType::PublicKey => "public_key", 459 TagValueType::RelayUrl => "relay_url", 460 TagValueType::Sha256 => "sha256", 461 TagValueType::Text => "text", 462 TagValueType::UnixTimestamp => "unix_timestamp", 463 TagValueType::Uri => "uri", 464 TagValueType::Url => "url", 465 TagValueType::UtcDayIndex => "utc_day_index", 466 TagValueType::Uuid => "uuid", 467 } 468 } 469 470 fn reducer_label(value: Reducer) -> &'static str { 471 match value { 472 Reducer::CalendarProjection => "calendar_projection", 473 Reducer::FarmOpsProjection => "farm_ops_projection", 474 Reducer::GroupProjection => "group_projection", 475 Reducer::KnowledgeProjection => "knowledge_projection", 476 Reducer::OperationalListingInventoryAccounting => { 477 "operational_listing_inventory_accounting" 478 } 479 Reducer::OperationalListingProjection => "operational_listing_projection", 480 Reducer::MarketProjection => "market_projection", 481 Reducer::OrderProjection => "order_projection", 482 Reducer::ProfileProjection => "profile_projection", 483 Reducer::NostrRelayPolicyProjection => "nostr_relay_policy_projection", 484 Reducer::SocialProjection => "social_projection", 485 Reducer::TradeProjection => "trade_projection", 486 Reducer::TradeValidation => "trade_validation", 487 } 488 } 489 490 #[cfg(test)] 491 mod tests { 492 use super::{ 493 contract_manifest_json, contract_manifest_sha256, discriminator_manifest, 494 knowledge_contract_manifest, parse_knowledge_contract_manifest_json, reducer_label, 495 standard_label, 496 }; 497 use radroots_event::{ 498 contract::{ 499 ContractFamily, EventDiscriminator, NostrStandard, Reducer, 500 all_event_contracts_registry_v7, event_contract_family, 501 }, 502 listing::classified::ClassifiedListingPartition, 503 }; 504 505 #[test] 506 fn knowledge_manifest_is_derived_from_authority_with_stable_order_and_count() { 507 let manifest = knowledge_contract_manifest(); 508 let authority_count = all_event_contracts_registry_v7() 509 .iter() 510 .filter(|contract| event_contract_family(contract) == Some(ContractFamily::Knowledge)) 511 .count(); 512 513 assert_eq!(manifest.contract_count, authority_count); 514 assert_eq!(manifest.contract_count, manifest.contracts.len()); 515 assert!( 516 manifest 517 .contracts 518 .windows(2) 519 .all(|pair| pair[0].contract_id < pair[1].contract_id) 520 ); 521 } 522 523 #[test] 524 fn knowledge_manifest_json_is_canonical_repeatable_and_strictly_parsed() { 525 let first = contract_manifest_json().expect("serialize manifest"); 526 let second = contract_manifest_json().expect("serialize manifest again"); 527 528 assert_eq!(first, second); 529 assert!(first.ends_with('\n')); 530 assert!(!first.ends_with("\n\n")); 531 assert!(!first.contains("\r\n")); 532 assert_eq!( 533 parse_knowledge_contract_manifest_json(&first).expect("parse manifest"), 534 knowledge_contract_manifest() 535 ); 536 assert_eq!( 537 contract_manifest_sha256().expect("hash manifest"), 538 contract_manifest_sha256().expect("hash manifest again") 539 ); 540 541 let mut unknown = 542 serde_json::to_value(knowledge_contract_manifest()).expect("manifest value"); 543 unknown 544 .as_object_mut() 545 .expect("manifest object") 546 .insert("unknown".to_string(), serde_json::Value::Bool(true)); 547 assert!( 548 serde_json::from_value::<super::RadrootsKnowledgeContractManifest>(unknown).is_err() 549 ); 550 } 551 552 #[test] 553 fn classified_listing_standard_label_is_nip99() { 554 assert_eq!(standard_label(NostrStandard::Nip99), "nip99"); 555 } 556 557 #[test] 558 fn operational_listing_reducer_labels_are_unambiguous() { 559 assert_eq!( 560 reducer_label(Reducer::OperationalListingProjection), 561 "operational_listing_projection" 562 ); 563 assert_eq!( 564 reducer_label(Reducer::OperationalListingInventoryAccounting), 565 "operational_listing_inventory_accounting" 566 ); 567 } 568 569 #[test] 570 fn classified_listing_partition_discriminators_render_exactly() { 571 for (partition, expected) in [ 572 ( 573 ClassifiedListingPartition::FocusedFoodAvailability, 574 "focused_food_availability", 575 ), 576 ( 577 ClassifiedListingPartition::OperationalListing, 578 "operational_listing", 579 ), 580 (ClassifiedListingPartition::GenericNip99, "generic_nip99"), 581 (ClassifiedListingPartition::Ambiguous, "ambiguous"), 582 ] { 583 let manifest = 584 discriminator_manifest(&EventDiscriminator::ClassifiedListingPartition(partition)); 585 assert_eq!( 586 serde_json::to_value(manifest).expect("serialized discriminator"), 587 serde_json::json!({ 588 "type": "classified_listing_partition", 589 "value": expected, 590 }) 591 ); 592 } 593 } 594 }