lib

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

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 }