lib

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

knowledge.rs (53093B)


      1 #![cfg(feature = "knowledge")]
      2 
      3 use radroots_event::contract::validate_event_contract_shape;
      4 use radroots_event::envelope::kind::{
      5     KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_REVIEW, KIND_KNOWLEDGE_SOURCE, KIND_WIKI_ARTICLE,
      6 };
      7 use radroots_event::knowledge::{
      8     AddressableRef, ContributionAttestation, EvidenceBounty, KnowledgeChangeProposal,
      9     KnowledgeCitationSpan, KnowledgeClaim, KnowledgeFieldContext, KnowledgeFieldReport,
     10     KnowledgeLocation, KnowledgeLocationPrecision, KnowledgeNodeRef, KnowledgeObservation,
     11     KnowledgeObservationValue, KnowledgeRelation, KnowledgeReview, KnowledgeReviewScope,
     12     KnowledgeReviewScore, KnowledgeReviewTarget, KnowledgeSource,
     13     RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
     14     RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
     15     RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
     16     RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
     17     RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, WikiArticle, WikiArticleVersionRef, WikiMergeRequest,
     18     WikiRedirect,
     19 };
     20 use radroots_event::wire::Nip01EventWireParts;
     21 use radroots_event::{envelope::EventEnvelope, envelope::EventEnvelopeParts};
     22 use radroots_event_codec::decode::EventParseError;
     23 use radroots_event_codec::decode::knowledge::{
     24     contribution_attestation_from_event, evidence_bounty_from_event,
     25     knowledge_change_proposal_from_event, knowledge_claim_from_event,
     26     knowledge_field_report_from_event, knowledge_relation_from_event, knowledge_review_from_event,
     27     knowledge_source_from_event, wiki_article_from_event, wiki_merge_request_from_event,
     28     wiki_redirect_from_event,
     29 };
     30 use radroots_event_codec::encode::EventEncodeError;
     31 use radroots_event_codec::encode::knowledge::{
     32     contribution_attestation_to_wire_parts, evidence_bounty_to_wire_parts,
     33     knowledge_change_proposal_to_wire_parts, knowledge_claim_to_wire_parts,
     34     knowledge_field_report_to_wire_parts, knowledge_relation_to_wire_parts,
     35     knowledge_review_to_wire_parts, knowledge_source_to_wire_parts, wiki_article_to_wire_parts,
     36     wiki_merge_request_to_wire_parts, wiki_redirect_to_wire_parts,
     37 };
     38 use radroots_event_codec::verify::{
     39     RadrootsDecodeError, RadrootsDecodedEvent, RadrootsNip01VerificationError,
     40     verify_and_decode_radroots_event,
     41 };
     42 
     43 fn hex_64(character: char) -> String {
     44     core::iter::repeat_n(character, 64).collect()
     45 }
     46 
     47 fn event_ref(character: char, kind: u32) -> radroots_event::tag::EventRef {
     48     radroots_event::tag::EventRef {
     49         id: hex_64(character),
     50         author: radroots_identity::PublicKey::from_hex(
     51             "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
     52         )
     53         .expect("fixture public key"),
     54         kind,
     55         d_tag: None,
     56         relays: Some(vec!["wss://relay.radroots.example".to_string()]),
     57     }
     58 }
     59 
     60 fn address_ref() -> AddressableRef {
     61     AddressableRef {
     62         kind: KIND_WIKI_ARTICLE,
     63         pubkey: hex_64('a'),
     64         d_tag: "soil-health".to_string(),
     65         relays: vec!["wss://relay.radroots.example".to_string()],
     66     }
     67 }
     68 
     69 fn article_version_ref() -> WikiArticleVersionRef {
     70     article_version_ref_for('b', "soil-health")
     71 }
     72 
     73 fn article_version_ref_for(event_id_character: char, d_tag: &str) -> WikiArticleVersionRef {
     74     let mut address_ref = address_ref();
     75     address_ref.d_tag = d_tag.to_string();
     76     WikiArticleVersionRef {
     77         event_id: hex_64(event_id_character),
     78         address_ref,
     79     }
     80 }
     81 
     82 fn event_from_parts(parts: Nip01EventWireParts) -> EventEnvelope {
     83     EventEnvelope::new(EventEnvelopeParts {
     84         id: hex_64('0'),
     85         author: hex_64('a'),
     86         created_at: 1_800_000_000,
     87         kind: parts.kind,
     88         tags: parts.tags,
     89         content: parts.content,
     90         sig: "1".repeat(128),
     91     })
     92     .unwrap()
     93 }
     94 
     95 fn event_with_parts(
     96     event: &EventEnvelope,
     97     tags: Vec<Vec<String>>,
     98     content: String,
     99 ) -> EventEnvelope {
    100     EventEnvelope::new(EventEnvelopeParts {
    101         id: event.id_hex().to_string(),
    102         author: event.author().to_hex().to_string(),
    103         created_at: event.created_at_u64(),
    104         kind: event.kind_u32(),
    105         tags,
    106         content,
    107         sig: event.signature_hex().to_string(),
    108     })
    109     .unwrap()
    110 }
    111 
    112 fn mutate_tags(event: &mut EventEnvelope, update: impl FnOnce(&mut Vec<Vec<String>>)) {
    113     let mut tags = event.tags_as_vec();
    114     update(&mut tags);
    115     *event = event_with_parts(event, tags, event.content().to_string());
    116 }
    117 
    118 fn replace_content(event: &mut EventEnvelope, content: String) {
    119     *event = event_with_parts(event, event.tags_as_vec(), content);
    120 }
    121 
    122 fn replace_first_tag_value(event: &mut EventEnvelope, name: &str, value: String) {
    123     mutate_tags(event, |tags| {
    124         let tag = tags
    125             .iter_mut()
    126             .find(|tag| tag.first().map(String::as_str) == Some(name))
    127             .expect("tag");
    128         let tag_value = tag.get_mut(1).expect("tag value");
    129         *tag_value = value;
    130     });
    131 }
    132 
    133 fn marked_tag(tag: &[String], name: &str, marker: &str) -> bool {
    134     tag.first().map(String::as_str) == Some(name) && tag.last().map(String::as_str) == Some(marker)
    135 }
    136 
    137 fn marked_tag_index(event: &EventEnvelope, name: &str, marker: &str) -> usize {
    138     event
    139         .tags_as_vec()
    140         .iter()
    141         .position(|tag| marked_tag(tag, name, marker))
    142         .expect("marked tag")
    143 }
    144 
    145 fn assert_parse_error(actual: EventParseError, expected: EventParseError) {
    146     match (actual, expected) {
    147         (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
    148         | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected))
    149         | (EventParseError::InvalidJson(actual), EventParseError::InvalidJson(expected)) => {
    150             assert_eq!(actual, expected);
    151         }
    152         (
    153             EventParseError::InvalidKind {
    154                 expected: actual_expected,
    155                 got: actual_got,
    156             },
    157             EventParseError::InvalidKind { expected, got },
    158         ) => {
    159             assert_eq!(actual_expected, expected);
    160             assert_eq!(actual_got, got);
    161         }
    162         (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
    163     }
    164 }
    165 
    166 fn assert_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
    167     match (actual, expected) {
    168         (
    169             EventEncodeError::EmptyRequiredField(actual),
    170             EventEncodeError::EmptyRequiredField(expected),
    171         )
    172         | (EventEncodeError::InvalidField(actual), EventEncodeError::InvalidField(expected)) => {
    173             assert_eq!(actual, expected);
    174         }
    175         (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
    176     }
    177 }
    178 
    179 fn invalid_relay() -> String {
    180     "http://relay.radroots.example".to_string()
    181 }
    182 
    183 fn sign_parts(parts: Nip01EventWireParts) -> EventEnvelope {
    184     let tags = parts
    185         .tags
    186         .into_iter()
    187         .map(nostr::Tag::parse)
    188         .collect::<Result<Vec<_>, _>>()
    189         .expect("tags");
    190     let keys =
    191         nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101")
    192             .expect("keys");
    193     let kind = u16::try_from(parts.kind).expect("knowledge event kind must fit NIP-01");
    194     let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), parts.content)
    195         .tags(tags)
    196         .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000))
    197         .sign_with_keys(&keys)
    198         .expect("signed event");
    199     EventEnvelope::new(EventEnvelopeParts {
    200         id: event.id.to_hex(),
    201         author: event.pubkey.to_hex(),
    202         created_at: event.created_at.as_secs(),
    203         kind: u32::from(event.kind.as_u16()),
    204         tags: event
    205             .tags
    206             .as_slice()
    207             .iter()
    208             .map(|tag| tag.as_slice().to_vec())
    209             .collect(),
    210         content: event.content,
    211         sig: event.sig.to_string(),
    212     })
    213     .unwrap()
    214 }
    215 
    216 fn wiki_article() -> WikiArticle {
    217     WikiArticle {
    218         d_tag: "soil-health".to_string(),
    219         title: Some("Soil health".to_string()),
    220         content_djot: "# Soil health".to_string(),
    221         summary: Some("Living soil basics".to_string()),
    222         topics: vec!["soil".to_string(), "health".to_string()],
    223         references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)],
    224         forked_from: vec![article_version_ref()],
    225         deferred_to: Some(article_version_ref()),
    226     }
    227 }
    228 
    229 fn source() -> KnowledgeSource {
    230     KnowledgeSource {
    231         schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
    232         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    233         d_tag: "soil-source".to_string(),
    234         title: "Soil Source".to_string(),
    235         source_type: "book".to_string(),
    236         authors: vec!["A. Example".to_string()],
    237         publisher: Some("Radroots Synthetic Press".to_string()),
    238         publication_year: Some(2026),
    239         edition: None,
    240         canonical_url: None,
    241         artifact_refs: vec![event_ref('3', 1063)],
    242         author_asserted_rights: None,
    243         topics: vec!["soil".to_string()],
    244         summary: Some("Synthetic source".to_string()),
    245     }
    246 }
    247 
    248 fn claim() -> KnowledgeClaim {
    249     KnowledgeClaim {
    250         schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(),
    251         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    252         claim_type: "practice_effect".to_string(),
    253         text: "Cover crops improve soil structure.".to_string(),
    254         citation_spans: vec![KnowledgeCitationSpan {
    255             source_ref: event_ref('4', KIND_KNOWLEDGE_SOURCE),
    256             artifact_ref: None,
    257             page_start: Some(12),
    258             page_end: Some(13),
    259             section_path: vec!["chapter-1".to_string()],
    260             quote_hash: Some(hex_64('5')),
    261             chunk_id: Some("chunk-1".to_string()),
    262         }],
    263         topics: vec!["cover-crops".to_string()],
    264         applies_to: vec!["local-food".to_string()],
    265         author_asserted_confidence: Some("medium".to_string()),
    266         supersedes: Vec::new(),
    267     }
    268 }
    269 
    270 fn node_ref(label: &str) -> KnowledgeNodeRef {
    271     KnowledgeNodeRef {
    272         node_type: "event".to_string(),
    273         event_ref: Some(event_ref('6', KIND_KNOWLEDGE_CLAIM)),
    274         address_ref: None,
    275         external_id: None,
    276         label: Some(label.to_string()),
    277     }
    278 }
    279 
    280 fn relation() -> KnowledgeRelation {
    281     KnowledgeRelation {
    282         schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(),
    283         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    284         subject: node_ref("cover crops"),
    285         predicate: "supports".to_string(),
    286         object: node_ref("soil structure"),
    287         support_refs: vec![event_ref('7', KIND_KNOWLEDGE_CLAIM)],
    288         author_asserted_confidence: Some("medium".to_string()),
    289         supersedes: Vec::new(),
    290     }
    291 }
    292 
    293 fn review() -> KnowledgeReview {
    294     KnowledgeReview {
    295         schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(),
    296         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    297         target: KnowledgeReviewTarget {
    298             event_id: hex_64('8'),
    299             author_pubkey: hex_64('a'),
    300             kind: KIND_KNOWLEDGE_CLAIM,
    301             address: None,
    302             relays: vec!["wss://relay.radroots.example".to_string()],
    303             review_scope: KnowledgeReviewScope::SpecificVersion,
    304         },
    305         reviewer_role: "peer".to_string(),
    306         verdict: "needs_more_evidence".to_string(),
    307         scores: vec![KnowledgeReviewScore {
    308             dimension: "evidence".to_string(),
    309             value: "partial".to_string(),
    310             note: None,
    311         }],
    312         notes: Some("Synthetic review".to_string()),
    313         evidence_refs: vec![event_ref('9', KIND_KNOWLEDGE_SOURCE)],
    314     }
    315 }
    316 
    317 fn field_report() -> KnowledgeFieldReport {
    318     KnowledgeFieldReport {
    319         schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(),
    320         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    321         report_type: "observation".to_string(),
    322         title: "Field observation".to_string(),
    323         summary: Some("Observed cover crop residue.".to_string()),
    324         context: KnowledgeFieldContext {
    325             location_precision: KnowledgeLocationPrecision::CoarseGeohash,
    326             public_location: Some(KnowledgeLocation {
    327                 label: Some("watershed".to_string()),
    328                 region: Some("synthetic-region".to_string()),
    329                 locality: None,
    330                 geohash: Some("c23".to_string()),
    331             }),
    332             private_location_ref: None,
    333             topics: vec!["field".to_string()],
    334             context_tags: vec!["observation".to_string()],
    335         },
    336         observations: vec![KnowledgeObservation {
    337             observation_type: "residue".to_string(),
    338             text: "Residue was visible across beds.".to_string(),
    339             observed_at: Some("2026-07-05".to_string()),
    340             values: vec![KnowledgeObservationValue {
    341                 key: "coverage".to_string(),
    342                 value: "medium".to_string(),
    343                 unit: None,
    344             }],
    345         }],
    346         artifact_refs: vec![event_ref('c', 1063)],
    347         related_refs: vec![event_ref('d', KIND_KNOWLEDGE_CLAIM)],
    348         limitations: vec!["single observer".to_string()],
    349     }
    350 }
    351 
    352 fn bounty() -> EvidenceBounty {
    353     EvidenceBounty {
    354         schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
    355         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    356         d_tag: "soil-bounty".to_string(),
    357         title: "Soil bounty".to_string(),
    358         summary: None,
    359         topics: vec!["soil".to_string()],
    360         target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)],
    361         reward_note: None,
    362         closes_at: None,
    363     }
    364 }
    365 
    366 fn proposal() -> KnowledgeChangeProposal {
    367     KnowledgeChangeProposal {
    368         schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
    369         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    370         target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
    371         proposal_type: "amend".to_string(),
    372         summary: "Clarify scope".to_string(),
    373         rationale: None,
    374         evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
    375         supersedes: Vec::new(),
    376     }
    377 }
    378 
    379 fn attestation() -> ContributionAttestation {
    380     ContributionAttestation {
    381         schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
    382         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    383         contributor_pubkey: hex_64('a'),
    384         contribution_type: "review".to_string(),
    385         subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)],
    386         summary: "Reviewed synthetic claim".to_string(),
    387         evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
    388     }
    389 }
    390 
    391 #[test]
    392 fn knowledge_codecs_roundtrip_all_contracts() {
    393     let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    394     validate_event_contract_shape(&article_event, "radroots.wiki.article.v1").unwrap();
    395     assert!(article_event.tags_as_vec().iter().any(|tag| tag
    396         == &vec![
    397             "a".to_string(),
    398             format!("30818:{}:soil-health", hex_64('a')),
    399             "wss://relay.radroots.example".to_string(),
    400             "fork".to_string()
    401         ]));
    402     assert!(article_event.tags_as_vec().iter().any(|tag| tag
    403         == &vec![
    404             "e".to_string(),
    405             hex_64('b'),
    406             "wss://relay.radroots.example".to_string(),
    407             "fork".to_string()
    408         ]));
    409     assert!(article_event.tags_as_vec().iter().any(|tag| tag
    410         == &vec![
    411             "a".to_string(),
    412             format!("30818:{}:soil-health", hex_64('a')),
    413             "wss://relay.radroots.example".to_string(),
    414             "defer".to_string()
    415         ]));
    416     assert!(article_event.tags_as_vec().iter().any(|tag| tag
    417         == &vec![
    418             "e".to_string(),
    419             hex_64('b'),
    420             "wss://relay.radroots.example".to_string(),
    421             "defer".to_string()
    422         ]));
    423     assert_eq!(
    424         wiki_article_from_event(article_event)
    425             .unwrap()
    426             .data
    427             .data
    428             .title,
    429         Some("Soil health".to_string())
    430     );
    431 
    432     let redirect = WikiRedirect {
    433         d_tag: "soil".to_string(),
    434         target: address_ref(),
    435     };
    436     let redirect_event = event_from_parts(wiki_redirect_to_wire_parts(&redirect).unwrap());
    437     validate_event_contract_shape(&redirect_event, "radroots.wiki.redirect.v1").unwrap();
    438     assert!(redirect_event.tags_as_vec().iter().any(|tag| tag
    439         == &vec![
    440             "a".to_string(),
    441             format!("30818:{}:soil-health", hex_64('a')),
    442             "wss://relay.radroots.example".to_string()
    443         ]));
    444     assert_eq!(
    445         wiki_redirect_from_event(redirect_event)
    446             .unwrap()
    447             .data
    448             .data
    449             .target
    450             .d_tag,
    451         "soil-health"
    452     );
    453 
    454     let merge = WikiMergeRequest {
    455         target_article: address_ref(),
    456         destination_pubkey: hex_64('a'),
    457         base_version_event_id: Some(hex_64('e')),
    458         source_version_event_id: hex_64('f'),
    459         explanation: Some("Merge synthetic source".to_string()),
    460     };
    461     let merge_event = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    462     validate_event_contract_shape(&merge_event, "radroots.wiki.merge_request.v1").unwrap();
    463     assert_eq!(merge_event.content(), "Merge synthetic source");
    464     assert!(
    465         merge_event
    466             .tags_as_vec()
    467             .iter()
    468             .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()])
    469     );
    470     assert!(merge_event.tags_as_vec().iter().any(|tag| tag
    471         == &vec![
    472             "e".to_string(),
    473             hex_64('f'),
    474             String::new(),
    475             "source".to_string()
    476         ]));
    477     assert_eq!(
    478         wiki_merge_request_from_event(merge_event)
    479             .unwrap()
    480             .data
    481             .data
    482             .source_version_event_id,
    483         hex_64('f')
    484     );
    485 
    486     let source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
    487     validate_event_contract_shape(&source_event, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA).unwrap();
    488     assert_eq!(
    489         knowledge_source_from_event(source_event)
    490             .unwrap()
    491             .data
    492             .data
    493             .source_type,
    494         "book"
    495     );
    496 
    497     let claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
    498     validate_event_contract_shape(&claim_event, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA).unwrap();
    499     assert_eq!(
    500         knowledge_claim_from_event(claim_event)
    501             .unwrap()
    502             .data
    503             .data
    504             .claim_type,
    505         "practice_effect"
    506     );
    507 
    508     let relation_event = event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap());
    509     validate_event_contract_shape(&relation_event, RADROOTS_KNOWLEDGE_RELATION_SCHEMA).unwrap();
    510     assert_eq!(
    511         knowledge_relation_from_event(relation_event)
    512             .unwrap()
    513             .data
    514             .data
    515             .predicate,
    516         "supports"
    517     );
    518 
    519     let review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
    520     validate_event_contract_shape(&review_event, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA).unwrap();
    521     assert_eq!(
    522         knowledge_review_from_event(review_event)
    523             .unwrap()
    524             .data
    525             .data
    526             .verdict,
    527         "needs_more_evidence"
    528     );
    529 
    530     let report_event =
    531         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
    532     validate_event_contract_shape(&report_event, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA).unwrap();
    533     assert_eq!(
    534         knowledge_field_report_from_event(report_event)
    535             .unwrap()
    536             .data
    537             .data
    538             .report_type,
    539         "observation"
    540     );
    541 
    542     let bounty = EvidenceBounty {
    543         schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
    544         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    545         d_tag: "soil-bounty".to_string(),
    546         title: "Soil bounty".to_string(),
    547         summary: None,
    548         topics: vec!["soil".to_string()],
    549         target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)],
    550         reward_note: None,
    551         closes_at: None,
    552     };
    553     let bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty).unwrap());
    554     validate_event_contract_shape(&bounty_event, RADROOTS_EVIDENCE_BOUNTY_SCHEMA).unwrap();
    555     assert_eq!(
    556         evidence_bounty_from_event(bounty_event)
    557             .unwrap()
    558             .data
    559             .data
    560             .title,
    561         "Soil bounty"
    562     );
    563 
    564     let proposal = KnowledgeChangeProposal {
    565         schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
    566         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    567         target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
    568         proposal_type: "amend".to_string(),
    569         summary: "Clarify scope".to_string(),
    570         rationale: None,
    571         evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
    572         supersedes: Vec::new(),
    573     };
    574     let proposal_event =
    575         event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal).unwrap());
    576     validate_event_contract_shape(&proposal_event, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA)
    577         .unwrap();
    578     assert_eq!(
    579         knowledge_change_proposal_from_event(proposal_event)
    580             .unwrap()
    581             .data
    582             .data
    583             .proposal_type,
    584         "amend"
    585     );
    586 
    587     let attestation = ContributionAttestation {
    588         schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
    589         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    590         contributor_pubkey: hex_64('a'),
    591         contribution_type: "review".to_string(),
    592         subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)],
    593         summary: "Reviewed synthetic claim".to_string(),
    594         evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
    595     };
    596     let attestation_event =
    597         event_from_parts(contribution_attestation_to_wire_parts(&attestation).unwrap());
    598     validate_event_contract_shape(&attestation_event, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA)
    599         .unwrap();
    600     assert_eq!(
    601         contribution_attestation_from_event(attestation_event)
    602             .unwrap()
    603             .data
    604             .data
    605             .contribution_type,
    606         "review"
    607     );
    608 }
    609 
    610 #[test]
    611 fn knowledge_decode_rejects_mirrored_tag_content_drift() {
    612     let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
    613     replace_first_tag_value(&mut source_event, "source", hex_64('4'));
    614     assert_parse_error(
    615         knowledge_source_from_event(source_event).unwrap_err(),
    616         EventParseError::InvalidTag("source"),
    617     );
    618 
    619     let mut bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty()).unwrap());
    620     replace_first_tag_value(&mut bounty_event, "evidence", hex_64('b'));
    621     assert_parse_error(
    622         evidence_bounty_from_event(bounty_event).unwrap_err(),
    623         EventParseError::InvalidTag("evidence"),
    624     );
    625 
    626     let mut claim_source_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
    627     replace_first_tag_value(&mut claim_source_event, "source", hex_64('6'));
    628     assert_parse_error(
    629         knowledge_claim_from_event(claim_source_event).unwrap_err(),
    630         EventParseError::InvalidTag("source"),
    631     );
    632 
    633     let mut claim_citation_event =
    634         event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
    635     replace_first_tag_value(&mut claim_citation_event, "citation", hex_64('6'));
    636     assert_parse_error(
    637         knowledge_claim_from_event(claim_citation_event).unwrap_err(),
    638         EventParseError::InvalidTag("citation"),
    639     );
    640 
    641     let mut relation_event =
    642         event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap());
    643     replace_first_tag_value(&mut relation_event, "source", hex_64('8'));
    644     assert_parse_error(
    645         knowledge_relation_from_event(relation_event).unwrap_err(),
    646         EventParseError::InvalidTag("source"),
    647     );
    648 
    649     let mut review_target_event =
    650         event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
    651     replace_first_tag_value(&mut review_target_event, "review_target", hex_64('9'));
    652     assert_parse_error(
    653         knowledge_review_from_event(review_target_event).unwrap_err(),
    654         EventParseError::InvalidTag("review_target"),
    655     );
    656 
    657     let mut report_geohash_event =
    658         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
    659     replace_first_tag_value(&mut report_geohash_event, "g", "c24".to_string());
    660     assert_parse_error(
    661         knowledge_field_report_from_event(report_geohash_event).unwrap_err(),
    662         EventParseError::InvalidTag("g"),
    663     );
    664 
    665     let mut report_topic_event =
    666         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
    667     replace_first_tag_value(&mut report_topic_event, "t", "soil".to_string());
    668     assert_parse_error(
    669         knowledge_field_report_from_event(report_topic_event).unwrap_err(),
    670         EventParseError::InvalidTag("t"),
    671     );
    672 
    673     let mut report_evidence_event =
    674         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
    675     replace_first_tag_value(&mut report_evidence_event, "evidence", hex_64('e'));
    676     assert_parse_error(
    677         knowledge_field_report_from_event(report_evidence_event).unwrap_err(),
    678         EventParseError::InvalidTag("evidence"),
    679     );
    680 
    681     let mut proposal_event =
    682         event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal()).unwrap());
    683     replace_first_tag_value(&mut proposal_event, "evidence", hex_64('d'));
    684     assert_parse_error(
    685         knowledge_change_proposal_from_event(proposal_event).unwrap_err(),
    686         EventParseError::InvalidTag("evidence"),
    687     );
    688 
    689     let mut attestation_event =
    690         event_from_parts(contribution_attestation_to_wire_parts(&attestation()).unwrap());
    691     replace_first_tag_value(&mut attestation_event, "evidence", hex_64('f'));
    692     assert_parse_error(
    693         contribution_attestation_from_event(attestation_event).unwrap_err(),
    694         EventParseError::InvalidTag("evidence"),
    695     );
    696 }
    697 
    698 #[test]
    699 fn verified_decode_accepts_signed_claim_and_rejects_mutation() {
    700     let signed = sign_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
    701     let decoded = verify_and_decode_radroots_event(signed.clone()).unwrap();
    702     assert!(matches!(decoded, RadrootsDecodedEvent::KnowledgeClaim(_)));
    703 
    704     let mut mutated = signed;
    705     let mutated_content = mutated.content().replace("Cover crops", "Compost");
    706     replace_content(&mut mutated, mutated_content);
    707     let err = verify_and_decode_radroots_event(mutated).unwrap_err();
    708     assert_eq!(err.code(), "nip01_verification");
    709     assert!(matches!(
    710         err,
    711         RadrootsDecodeError::Nip01Verification(RadrootsNip01VerificationError::IdMismatch { .. })
    712     ));
    713 }
    714 
    715 #[test]
    716 fn malformed_knowledge_events_return_stable_decode_codes() {
    717     let mut missing_contract = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
    718     mutate_tags(&mut missing_contract, |tags| {
    719         tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some("contract"));
    720     });
    721     let signed = sign_parts(Nip01EventWireParts {
    722         kind: missing_contract.kind_u32(),
    723         content: missing_contract.content().to_string(),
    724         tags: missing_contract.tags_as_vec(),
    725     });
    726     let error = verify_and_decode_radroots_event(signed).unwrap_err();
    727     assert_eq!(error.code(), "contract_validation");
    728 
    729     let mut report =
    730         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
    731     let mut value: serde_json::Value = serde_json::from_str(report.content()).unwrap();
    732     value["context"]["latitude"] = serde_json::Value::from("45.0000");
    733     replace_content(&mut report, serde_json::to_string(&value).unwrap());
    734     let parsed_error = knowledge_field_report_from_event(report).unwrap_err();
    735     assert_eq!(parsed_error.code(), "invalid_json");
    736 }
    737 
    738 #[test]
    739 fn malformed_nip54_wiki_shapes_are_rejected() {
    740     let mut redirect = event_from_parts(
    741         wiki_redirect_to_wire_parts(&WikiRedirect {
    742             d_tag: "soil".to_string(),
    743             target: address_ref(),
    744         })
    745         .unwrap(),
    746     );
    747     mutate_tags(&mut redirect, |tags| {
    748         for tag in tags {
    749             if tag.first().map(|value| value.as_str()) == Some("a") {
    750                 tag[1] = format!("30023:{}:soil-health", hex_64('a'));
    751             }
    752         }
    753     });
    754     assert_parse_error(
    755         wiki_redirect_from_event(redirect).unwrap_err(),
    756         EventParseError::InvalidTag("a"),
    757     );
    758 
    759     let merge = WikiMergeRequest {
    760         target_article: address_ref(),
    761         destination_pubkey: hex_64('a'),
    762         base_version_event_id: Some(hex_64('e')),
    763         source_version_event_id: hex_64('f'),
    764         explanation: Some("Merge synthetic source".to_string()),
    765     };
    766     let mut missing_target = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    767     mutate_tags(&mut missing_target, |tags| {
    768         tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some("a"));
    769     });
    770     assert_parse_error(
    771         wiki_merge_request_from_event(missing_target).unwrap_err(),
    772         EventParseError::MissingTag("a"),
    773     );
    774 
    775     let mut missing_destination =
    776         event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    777     mutate_tags(&mut missing_destination, |tags| {
    778         tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some("p"));
    779     });
    780     assert_parse_error(
    781         wiki_merge_request_from_event(missing_destination).unwrap_err(),
    782         EventParseError::MissingTag("p"),
    783     );
    784 
    785     let mut missing_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    786     mutate_tags(&mut missing_source, |tags| {
    787         tags.retain(|tag| {
    788             !(tag.first().map(|value| value.as_str()) == Some("e")
    789                 && tag.last().map(|value| value.as_str()) == Some("source"))
    790         });
    791     });
    792     assert_parse_error(
    793         wiki_merge_request_from_event(missing_source).unwrap_err(),
    794         EventParseError::InvalidTag("e"),
    795     );
    796 
    797     let mut duplicate_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    798     mutate_tags(&mut duplicate_source, |tags| {
    799         tags.push(vec![
    800             "e".to_string(),
    801             hex_64('a'),
    802             String::new(),
    803             "source".to_string(),
    804         ]);
    805     });
    806     assert_parse_error(
    807         wiki_merge_request_from_event(duplicate_source).unwrap_err(),
    808         EventParseError::InvalidTag("e"),
    809     );
    810 
    811     let mut wrong_merge_target =
    812         event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
    813     mutate_tags(&mut wrong_merge_target, |tags| {
    814         for tag in tags {
    815             if tag.first().map(|value| value.as_str()) == Some("a") {
    816                 tag[1] = format!("30023:{}:soil-health", hex_64('a'));
    817             }
    818         }
    819     });
    820     assert_parse_error(
    821         wiki_merge_request_from_event(wrong_merge_target).unwrap_err(),
    822         EventParseError::InvalidTag("a"),
    823     );
    824 
    825     let mut orphan_fork = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    826     let mut removed_fork_event = false;
    827     mutate_tags(&mut orphan_fork, |tags| {
    828         tags.retain(|tag| {
    829             if !removed_fork_event
    830                 && tag.first().map(|value| value.as_str()) == Some("e")
    831                 && tag.last().map(|value| value.as_str()) == Some("fork")
    832             {
    833                 removed_fork_event = true;
    834                 false
    835             } else {
    836                 true
    837             }
    838         });
    839     });
    840     assert_parse_error(
    841         wiki_article_from_event(orphan_fork).unwrap_err(),
    842         EventParseError::InvalidTag("a"),
    843     );
    844 
    845     let mut duplicate_defer =
    846         event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    847     mutate_tags(&mut duplicate_defer, |tags| {
    848         tags.extend([
    849             vec![
    850                 "a".to_string(),
    851                 format!("30818:{}:compost", hex_64('a')),
    852                 String::new(),
    853                 "defer".to_string(),
    854             ],
    855             vec![
    856                 "e".to_string(),
    857                 hex_64('c'),
    858                 String::new(),
    859                 "defer".to_string(),
    860             ],
    861         ]);
    862     });
    863     assert_parse_error(
    864         wiki_article_from_event(duplicate_defer).unwrap_err(),
    865         EventParseError::InvalidTag("a"),
    866     );
    867 }
    868 
    869 #[test]
    870 fn wiki_article_version_refs_require_adjacent_marked_pairs() {
    871     let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    872     let decoded = wiki_article_from_event(article_event).unwrap();
    873     assert_eq!(decoded.data.data.forked_from[0], article_version_ref());
    874     assert_eq!(decoded.data.data.deferred_to, Some(article_version_ref()));
    875 
    876     let mut two_forks = wiki_article();
    877     two_forks.forked_from = vec![
    878         article_version_ref_for('b', "soil-health"),
    879         article_version_ref_for('c', "compost"),
    880     ];
    881     two_forks.deferred_to = None;
    882     let two_forks_event = event_from_parts(wiki_article_to_wire_parts(&two_forks).unwrap());
    883     let decoded = wiki_article_from_event(two_forks_event.clone()).unwrap();
    884     assert_eq!(
    885         decoded.data.data.forked_from,
    886         vec![
    887             article_version_ref_for('b', "soil-health"),
    888             article_version_ref_for('c', "compost")
    889         ]
    890     );
    891 
    892     let mut grouped = two_forks_event;
    893     let fork_addresses = grouped
    894         .tags_as_vec()
    895         .iter()
    896         .filter(|tag| marked_tag(tag, "a", "fork"))
    897         .cloned()
    898         .collect::<Vec<_>>();
    899     let fork_events = grouped
    900         .tags_as_vec()
    901         .iter()
    902         .filter(|tag| marked_tag(tag, "e", "fork"))
    903         .cloned()
    904         .collect::<Vec<_>>();
    905     mutate_tags(&mut grouped, |tags| {
    906         tags.retain(|tag| !marked_tag(tag, "a", "fork") && !marked_tag(tag, "e", "fork"));
    907         tags.extend(fork_addresses);
    908         tags.extend(fork_events);
    909     });
    910     assert_parse_error(
    911         wiki_article_from_event(grouped).unwrap_err(),
    912         EventParseError::InvalidTag("a"),
    913     );
    914 
    915     let mut reversed = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    916     let fork_address_index = marked_tag_index(&reversed, "a", "fork");
    917     let fork_event_index = marked_tag_index(&reversed, "e", "fork");
    918     mutate_tags(&mut reversed, |tags| {
    919         tags.swap(fork_address_index, fork_event_index);
    920     });
    921     assert_parse_error(
    922         wiki_article_from_event(reversed).unwrap_err(),
    923         EventParseError::InvalidTag("e"),
    924     );
    925 
    926     let mut relay_mismatch = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    927     let fork_event_index = marked_tag_index(&relay_mismatch, "e", "fork");
    928     mutate_tags(&mut relay_mismatch, |tags| {
    929         tags[fork_event_index][2] = "wss://other.radroots.example".to_string();
    930     });
    931     assert_parse_error(
    932         wiki_article_from_event(relay_mismatch).unwrap_err(),
    933         EventParseError::InvalidTag("e"),
    934     );
    935 
    936     let mut missing_partner =
    937         event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    938     let fork_event_index = marked_tag_index(&missing_partner, "e", "fork");
    939     mutate_tags(&mut missing_partner, |tags| {
    940         tags.remove(fork_event_index);
    941     });
    942     assert_parse_error(
    943         wiki_article_from_event(missing_partner).unwrap_err(),
    944         EventParseError::InvalidTag("a"),
    945     );
    946 
    947     let mut misplaced_marker =
    948         event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    949     let fork_address_index = marked_tag_index(&misplaced_marker, "a", "fork");
    950     mutate_tags(&mut misplaced_marker, |tags| {
    951         tags[fork_address_index].insert(2, "fork".to_string());
    952     });
    953     assert_parse_error(
    954         wiki_article_from_event(misplaced_marker).unwrap_err(),
    955         EventParseError::InvalidTag("a"),
    956     );
    957 
    958     let mut wrong_kind = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
    959     let fork_address_index = marked_tag_index(&wrong_kind, "a", "fork");
    960     mutate_tags(&mut wrong_kind, |tags| {
    961         tags[fork_address_index][1] = format!("30023:{}:soil-health", hex_64('a'));
    962     });
    963     assert_parse_error(
    964         wiki_article_from_event(wrong_kind).unwrap_err(),
    965         EventParseError::InvalidTag("a"),
    966     );
    967 }
    968 
    969 #[test]
    970 fn wiki_article_codec_accepts_missing_title_tag() {
    971     let mut article = wiki_article();
    972     article.title = None;
    973     let article_event = event_from_parts(wiki_article_to_wire_parts(&article).unwrap());
    974     assert!(
    975         !article_event
    976             .tags_as_vec()
    977             .iter()
    978             .any(|tag| tag.first().map(String::as_str) == Some("title"))
    979     );
    980     let decoded = wiki_article_from_event(article_event).unwrap();
    981     assert_eq!(decoded.data.data.title, None);
    982 }
    983 
    984 #[test]
    985 fn semantic_validation_rejects_invalid_encode_models() {
    986     let mut article = wiki_article();
    987     article.content_djot = String::new();
    988     assert_encode_error(
    989         wiki_article_to_wire_parts(&article).unwrap_err(),
    990         EventEncodeError::EmptyRequiredField("content_djot"),
    991     );
    992 
    993     let mut redirect = WikiRedirect {
    994         d_tag: "soil".to_string(),
    995         target: address_ref(),
    996     };
    997     redirect.target.kind = 30023;
    998     assert_encode_error(
    999         wiki_redirect_to_wire_parts(&redirect).unwrap_err(),
   1000         EventEncodeError::InvalidField("wiki_redirect.target"),
   1001     );
   1002 
   1003     let mut merge = WikiMergeRequest {
   1004         target_article: address_ref(),
   1005         destination_pubkey: "bad".to_string(),
   1006         base_version_event_id: Some(hex_64('e')),
   1007         source_version_event_id: hex_64('f'),
   1008         explanation: None,
   1009     };
   1010     assert_encode_error(
   1011         wiki_merge_request_to_wire_parts(&merge).unwrap_err(),
   1012         EventEncodeError::InvalidField("destination_pubkey"),
   1013     );
   1014     merge.destination_pubkey = hex_64('a');
   1015     merge.base_version_event_id = Some("bad".to_string());
   1016     assert_encode_error(
   1017         wiki_merge_request_to_wire_parts(&merge).unwrap_err(),
   1018         EventEncodeError::InvalidField("base_version_event_id"),
   1019     );
   1020 
   1021     let mut source = source();
   1022     source.title = " ".to_string();
   1023     assert_encode_error(
   1024         knowledge_source_to_wire_parts(&source).unwrap_err(),
   1025         EventEncodeError::EmptyRequiredField("title"),
   1026     );
   1027 
   1028     let mut claim = claim();
   1029     claim.citation_spans[0].quote_hash = Some("bad".to_string());
   1030     assert_encode_error(
   1031         knowledge_claim_to_wire_parts(&claim).unwrap_err(),
   1032         EventEncodeError::InvalidField("citation_spans"),
   1033     );
   1034 
   1035     let mut relation = relation();
   1036     relation.subject.external_id = Some("cover-crops".to_string());
   1037     assert_encode_error(
   1038         knowledge_relation_to_wire_parts(&relation).unwrap_err(),
   1039         EventEncodeError::InvalidField("subject"),
   1040     );
   1041 
   1042     let mut review = review();
   1043     review.scores[0].value = String::new();
   1044     assert_encode_error(
   1045         knowledge_review_to_wire_parts(&review).unwrap_err(),
   1046         EventEncodeError::EmptyRequiredField("scores"),
   1047     );
   1048 
   1049     let mut report = field_report();
   1050     report.observations.clear();
   1051     assert_encode_error(
   1052         knowledge_field_report_to_wire_parts(&report).unwrap_err(),
   1053         EventEncodeError::EmptyRequiredField("observations"),
   1054     );
   1055 
   1056     let bounty = EvidenceBounty {
   1057         schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
   1058         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1059         d_tag: "soil-bounty".to_string(),
   1060         title: "Soil bounty".to_string(),
   1061         summary: None,
   1062         topics: vec!["soil".to_string()],
   1063         target_refs: Vec::new(),
   1064         reward_note: None,
   1065         closes_at: None,
   1066     };
   1067     assert_encode_error(
   1068         evidence_bounty_to_wire_parts(&bounty).unwrap_err(),
   1069         EventEncodeError::EmptyRequiredField("target_refs"),
   1070     );
   1071 
   1072     let proposal = KnowledgeChangeProposal {
   1073         schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
   1074         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1075         target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
   1076         proposal_type: "amend".to_string(),
   1077         summary: String::new(),
   1078         rationale: None,
   1079         evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
   1080         supersedes: Vec::new(),
   1081     };
   1082     assert_encode_error(
   1083         knowledge_change_proposal_to_wire_parts(&proposal).unwrap_err(),
   1084         EventEncodeError::EmptyRequiredField("summary"),
   1085     );
   1086 
   1087     let attestation = ContributionAttestation {
   1088         schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
   1089         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1090         contributor_pubkey: hex_64('a'),
   1091         contribution_type: "review".to_string(),
   1092         subject_refs: Vec::new(),
   1093         summary: "Reviewed synthetic claim".to_string(),
   1094         evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
   1095     };
   1096     assert_encode_error(
   1097         contribution_attestation_to_wire_parts(&attestation).unwrap_err(),
   1098         EventEncodeError::EmptyRequiredField("subject_refs"),
   1099     );
   1100 }
   1101 
   1102 #[test]
   1103 fn semantic_validation_rejects_invalid_relay_models() {
   1104     let mut source = source();
   1105     source.artifact_refs[0].relays = Some(vec![invalid_relay()]);
   1106     assert_encode_error(
   1107         knowledge_source_to_wire_parts(&source).unwrap_err(),
   1108         EventEncodeError::InvalidField("artifact_refs"),
   1109     );
   1110 
   1111     let mut redirect = WikiRedirect {
   1112         d_tag: "soil".to_string(),
   1113         target: address_ref(),
   1114     };
   1115     redirect.target.relays = vec![invalid_relay()];
   1116     assert_encode_error(
   1117         wiki_redirect_to_wire_parts(&redirect).unwrap_err(),
   1118         EventEncodeError::InvalidField("wiki_redirect.target"),
   1119     );
   1120 
   1121     let mut review = review();
   1122     review.target.relays = vec![invalid_relay()];
   1123     assert_encode_error(
   1124         knowledge_review_to_wire_parts(&review).unwrap_err(),
   1125         EventEncodeError::InvalidField("review_target"),
   1126     );
   1127 
   1128     let mut report = field_report();
   1129     report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference;
   1130     let mut private_location_ref = event_ref('f', KIND_KNOWLEDGE_SOURCE);
   1131     private_location_ref.relays = Some(vec![invalid_relay()]);
   1132     report.context.private_location_ref = Some(private_location_ref);
   1133     assert_encode_error(
   1134         knowledge_field_report_to_wire_parts(&report).unwrap_err(),
   1135         EventEncodeError::InvalidField("private_location_ref"),
   1136     );
   1137 }
   1138 
   1139 #[test]
   1140 fn knowledge_claim_encode_enforces_citation_rules() {
   1141     let mut model = claim();
   1142     model.citation_spans.clear();
   1143     assert_encode_error(
   1144         knowledge_claim_to_wire_parts(&model).unwrap_err(),
   1145         EventEncodeError::EmptyRequiredField("citation_spans"),
   1146     );
   1147 
   1148     assert!(knowledge_claim_to_wire_parts(&claim()).is_ok());
   1149 
   1150     for claim_type in ["hypothesis", "observation", "question"] {
   1151         let mut uncited = claim();
   1152         uncited.claim_type = claim_type.to_string();
   1153         uncited.citation_spans.clear();
   1154         assert!(knowledge_claim_to_wire_parts(&uncited).is_ok());
   1155     }
   1156 
   1157     let mut capitalized = claim();
   1158     capitalized.claim_type = "Hypothesis".to_string();
   1159     capitalized.citation_spans.clear();
   1160     assert_encode_error(
   1161         knowledge_claim_to_wire_parts(&capitalized).unwrap_err(),
   1162         EventEncodeError::EmptyRequiredField("citation_spans"),
   1163     );
   1164 }
   1165 
   1166 #[test]
   1167 fn semantic_validation_rejects_invalid_decoded_content() {
   1168     let mut article = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
   1169     replace_content(&mut article, String::new());
   1170     assert_parse_error(
   1171         wiki_article_from_event(article).unwrap_err(),
   1172         EventParseError::InvalidJson("content_djot"),
   1173     );
   1174 
   1175     let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
   1176     let mut value: serde_json::Value = serde_json::from_str(source_event.content()).unwrap();
   1177     value["title"] = serde_json::Value::String(String::new());
   1178     replace_content(&mut source_event, serde_json::to_string(&value).unwrap());
   1179     assert_parse_error(
   1180         knowledge_source_from_event(source_event).unwrap_err(),
   1181         EventParseError::InvalidJson("title"),
   1182     );
   1183 
   1184     let mut claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
   1185     let mut value: serde_json::Value = serde_json::from_str(claim_event.content()).unwrap();
   1186     value["citation_spans"][0]["quote_hash"] = serde_json::Value::String("bad".to_string());
   1187     replace_content(&mut claim_event, serde_json::to_string(&value).unwrap());
   1188     assert_parse_error(
   1189         knowledge_claim_from_event(claim_event).unwrap_err(),
   1190         EventParseError::InvalidJson("citation_spans"),
   1191     );
   1192 
   1193     let mut relation_event =
   1194         event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap());
   1195     let mut value: serde_json::Value = serde_json::from_str(relation_event.content()).unwrap();
   1196     value["subject"]["external_id"] = serde_json::Value::String("cover-crops".to_string());
   1197     replace_content(&mut relation_event, serde_json::to_string(&value).unwrap());
   1198     assert_parse_error(
   1199         knowledge_relation_from_event(relation_event).unwrap_err(),
   1200         EventParseError::InvalidJson("subject"),
   1201     );
   1202 
   1203     let mut review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
   1204     let mut value: serde_json::Value = serde_json::from_str(review_event.content()).unwrap();
   1205     value["target"]["kind"] = serde_json::Value::from(0);
   1206     replace_content(&mut review_event, serde_json::to_string(&value).unwrap());
   1207     assert_parse_error(
   1208         knowledge_review_from_event(review_event).unwrap_err(),
   1209         EventParseError::InvalidJson("review_target"),
   1210     );
   1211 
   1212     let mut report_event =
   1213         event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
   1214     let mut value: serde_json::Value = serde_json::from_str(report_event.content()).unwrap();
   1215     value["observations"] = serde_json::Value::Array(Vec::new());
   1216     replace_content(&mut report_event, serde_json::to_string(&value).unwrap());
   1217     assert_parse_error(
   1218         knowledge_field_report_from_event(report_event).unwrap_err(),
   1219         EventParseError::InvalidJson("observations"),
   1220     );
   1221 
   1222     let bounty = EvidenceBounty {
   1223         schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
   1224         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1225         d_tag: "soil-bounty".to_string(),
   1226         title: "Soil bounty".to_string(),
   1227         summary: None,
   1228         topics: vec!["soil".to_string()],
   1229         target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)],
   1230         reward_note: None,
   1231         closes_at: None,
   1232     };
   1233     let mut bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty).unwrap());
   1234     let mut value: serde_json::Value = serde_json::from_str(bounty_event.content()).unwrap();
   1235     value["target_refs"] = serde_json::Value::Array(Vec::new());
   1236     replace_content(&mut bounty_event, serde_json::to_string(&value).unwrap());
   1237     assert_parse_error(
   1238         evidence_bounty_from_event(bounty_event).unwrap_err(),
   1239         EventParseError::InvalidJson("target_refs"),
   1240     );
   1241 
   1242     let proposal = KnowledgeChangeProposal {
   1243         schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
   1244         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1245         target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
   1246         proposal_type: "amend".to_string(),
   1247         summary: "Clarify scope".to_string(),
   1248         rationale: None,
   1249         evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
   1250         supersedes: Vec::new(),
   1251     };
   1252     let mut proposal_event =
   1253         event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal).unwrap());
   1254     let mut value: serde_json::Value = serde_json::from_str(proposal_event.content()).unwrap();
   1255     value["summary"] = serde_json::Value::String(String::new());
   1256     replace_content(&mut proposal_event, serde_json::to_string(&value).unwrap());
   1257     assert_parse_error(
   1258         knowledge_change_proposal_from_event(proposal_event).unwrap_err(),
   1259         EventParseError::InvalidJson("summary"),
   1260     );
   1261 
   1262     let attestation = ContributionAttestation {
   1263         schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
   1264         schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1265         contributor_pubkey: hex_64('a'),
   1266         contribution_type: "review".to_string(),
   1267         subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)],
   1268         summary: "Reviewed synthetic claim".to_string(),
   1269         evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
   1270     };
   1271     let mut attestation_event =
   1272         event_from_parts(contribution_attestation_to_wire_parts(&attestation).unwrap());
   1273     let mut value: serde_json::Value = serde_json::from_str(attestation_event.content()).unwrap();
   1274     value["subject_refs"] = serde_json::Value::Array(Vec::new());
   1275     replace_content(
   1276         &mut attestation_event,
   1277         serde_json::to_string(&value).unwrap(),
   1278     );
   1279     assert_parse_error(
   1280         contribution_attestation_from_event(attestation_event).unwrap_err(),
   1281         EventParseError::InvalidJson("subject_refs"),
   1282     );
   1283 }
   1284 
   1285 #[test]
   1286 fn knowledge_decode_rejects_invalid_relay_values() {
   1287     let mut article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
   1288     mutate_tags(&mut article_event, |tags| {
   1289         let source_tag = tags
   1290             .iter_mut()
   1291             .find(|tag| tag.first().map(String::as_str) == Some("source"))
   1292             .expect("source tag");
   1293         source_tag[5] = invalid_relay();
   1294     });
   1295     assert_parse_error(
   1296         wiki_article_from_event(article_event).unwrap_err(),
   1297         EventParseError::InvalidTag("source"),
   1298     );
   1299 
   1300     let mut redirect_event = event_from_parts(
   1301         wiki_redirect_to_wire_parts(&WikiRedirect {
   1302             d_tag: "soil".to_string(),
   1303             target: address_ref(),
   1304         })
   1305         .unwrap(),
   1306     );
   1307     mutate_tags(&mut redirect_event, |tags| {
   1308         let target_tag = tags
   1309             .iter_mut()
   1310             .find(|tag| tag.first().map(String::as_str) == Some("a"))
   1311             .expect("target tag");
   1312         target_tag[2] = invalid_relay();
   1313     });
   1314     assert_parse_error(
   1315         wiki_redirect_from_event(redirect_event).unwrap_err(),
   1316         EventParseError::InvalidTag("a"),
   1317     );
   1318 
   1319     let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
   1320     let mut value: serde_json::Value = serde_json::from_str(source_event.content()).unwrap();
   1321     value["artifact_refs"][0]["relays"] =
   1322         serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]);
   1323     replace_content(&mut source_event, serde_json::to_string(&value).unwrap());
   1324     assert_parse_error(
   1325         knowledge_source_from_event(source_event).unwrap_err(),
   1326         EventParseError::InvalidJson("artifact_refs"),
   1327     );
   1328 
   1329     let mut review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
   1330     let mut value: serde_json::Value = serde_json::from_str(review_event.content()).unwrap();
   1331     value["target"]["relays"] =
   1332         serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]);
   1333     replace_content(&mut review_event, serde_json::to_string(&value).unwrap());
   1334     assert_parse_error(
   1335         knowledge_review_from_event(review_event).unwrap_err(),
   1336         EventParseError::InvalidJson("review_target"),
   1337     );
   1338 
   1339     let mut report = field_report();
   1340     report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference;
   1341     report.context.private_location_ref = Some(event_ref('f', KIND_KNOWLEDGE_SOURCE));
   1342     let mut report_event = event_from_parts(knowledge_field_report_to_wire_parts(&report).unwrap());
   1343     let mut value: serde_json::Value = serde_json::from_str(report_event.content()).unwrap();
   1344     value["context"]["private_location_ref"]["relays"] =
   1345         serde_json::Value::Array(vec![serde_json::Value::String(invalid_relay())]);
   1346     replace_content(&mut report_event, serde_json::to_string(&value).unwrap());
   1347     assert_parse_error(
   1348         knowledge_field_report_from_event(report_event).unwrap_err(),
   1349         EventParseError::InvalidJson("private_location_ref"),
   1350     );
   1351 }
   1352 
   1353 #[test]
   1354 fn knowledge_claim_decode_enforces_citation_rules() {
   1355     let mut claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
   1356     let mut value: serde_json::Value = serde_json::from_str(claim_event.content()).unwrap();
   1357     value["citation_spans"] = serde_json::Value::Array(Vec::new());
   1358     replace_content(&mut claim_event, serde_json::to_string(&value).unwrap());
   1359     assert_parse_error(
   1360         knowledge_claim_from_event(claim_event).unwrap_err(),
   1361         EventParseError::InvalidJson("citation_spans"),
   1362     );
   1363 
   1364     for claim_type in ["hypothesis", "observation", "question"] {
   1365         let mut uncited = claim();
   1366         uncited.claim_type = claim_type.to_string();
   1367         uncited.citation_spans.clear();
   1368         let decoded = knowledge_claim_from_event(event_from_parts(
   1369             knowledge_claim_to_wire_parts(&uncited).unwrap(),
   1370         ))
   1371         .unwrap();
   1372         assert_eq!(decoded.data.data.claim_type, claim_type);
   1373         assert!(decoded.data.data.citation_spans.is_empty());
   1374     }
   1375 }