lib

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

knowledge.rs (54669B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{string::String, vec::Vec};
      5 
      6 use core::fmt;
      7 
      8 use crate::envelope::kind::KIND_WIKI_ARTICLE;
      9 use crate::id::{AddressableCoordinate, DTag, EventId, RelayUrl, parse_public_key};
     10 use crate::tag::EventRef;
     11 
     12 pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1;
     13 pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512;
     14 pub const RADROOTS_KNOWLEDGE_SOURCE_SCHEMA: &str = "radroots.knowledge.source.v1";
     15 pub const RADROOTS_KNOWLEDGE_CLAIM_SCHEMA: &str = "radroots.knowledge.claim.v1";
     16 pub const RADROOTS_KNOWLEDGE_RELATION_SCHEMA: &str = "radroots.knowledge.relation.v1";
     17 pub const RADROOTS_KNOWLEDGE_REVIEW_SCHEMA: &str = "radroots.knowledge.review.v1";
     18 pub const RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA: &str = "radroots.knowledge.field_report.v1";
     19 pub const RADROOTS_EVIDENCE_BOUNTY_SCHEMA: &str = "radroots.knowledge.evidence_bounty.v1";
     20 pub const RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA: &str = "radroots.knowledge.change_proposal.v1";
     21 pub const RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA: &str =
     22     "radroots.knowledge.contribution_attestation.v1";
     23 
     24 #[derive(Clone, Debug, PartialEq, Eq)]
     25 pub enum WikiDTagError {
     26     Empty,
     27     TooLong { max: usize, actual: usize },
     28     NotNormalized { normalized: String },
     29 }
     30 
     31 impl WikiDTagError {
     32     pub const fn code(&self) -> &'static str {
     33         match self {
     34             Self::Empty => "empty",
     35             Self::TooLong { .. } => "too_long",
     36             Self::NotNormalized { .. } => "not_normalized",
     37         }
     38     }
     39 }
     40 
     41 impl fmt::Display for WikiDTagError {
     42     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     43         match self {
     44             Self::Empty => formatter.write_str("wiki d tag must not be empty"),
     45             Self::TooLong { max, actual } => {
     46                 write!(
     47                     formatter,
     48                     "wiki d tag length {actual} exceeds maximum length {max}"
     49                 )
     50             }
     51             Self::NotNormalized { normalized } => {
     52                 write!(
     53                     formatter,
     54                     "wiki d tag must match normalized value {normalized}"
     55                 )
     56             }
     57         }
     58     }
     59 }
     60 
     61 #[cfg(feature = "std")]
     62 impl std::error::Error for WikiDTagError {}
     63 
     64 pub fn normalize_wiki_d_tag(input: &str) -> String {
     65     let mut normalized = String::new();
     66     let mut pending_separator = false;
     67 
     68     for raw in input.trim().chars() {
     69         for character in raw.to_lowercase() {
     70             if character.is_alphanumeric() {
     71                 if pending_separator && !normalized.is_empty() {
     72                     normalized.push('-');
     73                 }
     74                 normalized.push(character);
     75                 pending_separator = false;
     76             } else if character.is_whitespace() || character == '-' {
     77                 pending_separator = true;
     78             }
     79         }
     80     }
     81 
     82     normalized
     83 }
     84 
     85 pub fn validate_wiki_d_tag(value: &str) -> Result<(), WikiDTagError> {
     86     if value.is_empty() {
     87         return Err(WikiDTagError::Empty);
     88     }
     89 
     90     let actual = value.chars().count();
     91     if actual > RADROOTS_WIKI_D_TAG_MAX_LEN {
     92         return Err(WikiDTagError::TooLong {
     93             max: RADROOTS_WIKI_D_TAG_MAX_LEN,
     94             actual,
     95         });
     96     }
     97 
     98     let normalized = normalize_wiki_d_tag(value);
     99     if normalized != value {
    100         return Err(WikiDTagError::NotNormalized { normalized });
    101     }
    102 
    103     Ok(())
    104 }
    105 
    106 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    107 pub enum KnowledgeValidationError {
    108     EmptyField(&'static str),
    109     InvalidField(&'static str),
    110 }
    111 
    112 impl KnowledgeValidationError {
    113     pub const fn code(self) -> &'static str {
    114         match self {
    115             Self::EmptyField(_) => "empty_field",
    116             Self::InvalidField(_) => "invalid_field",
    117         }
    118     }
    119 
    120     pub const fn field(self) -> &'static str {
    121         match self {
    122             Self::EmptyField(field) | Self::InvalidField(field) => field,
    123         }
    124     }
    125 }
    126 
    127 impl fmt::Display for KnowledgeValidationError {
    128     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    129         match self {
    130             Self::EmptyField(field) => write!(formatter, "empty knowledge field: {field}"),
    131             Self::InvalidField(field) => write!(formatter, "invalid knowledge field: {field}"),
    132         }
    133     }
    134 }
    135 
    136 #[cfg(feature = "std")]
    137 impl std::error::Error for KnowledgeValidationError {}
    138 
    139 fn require_non_empty(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> {
    140     if value.trim().is_empty() {
    141         Err(KnowledgeValidationError::EmptyField(field))
    142     } else {
    143         Ok(())
    144     }
    145 }
    146 
    147 fn require_schema(
    148     schema: &str,
    149     schema_version: u16,
    150     expected_schema: &'static str,
    151 ) -> Result<(), KnowledgeValidationError> {
    152     if schema != expected_schema {
    153         return Err(KnowledgeValidationError::InvalidField("schema"));
    154     }
    155     if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION {
    156         return Err(KnowledgeValidationError::InvalidField("schema_version"));
    157     }
    158     Ok(())
    159 }
    160 
    161 fn validate_event_id(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> {
    162     if value.trim().is_empty() {
    163         return Err(KnowledgeValidationError::EmptyField(field));
    164     }
    165     EventId::parse(value)
    166         .map(|_| ())
    167         .map_err(|_| KnowledgeValidationError::InvalidField(field))
    168 }
    169 
    170 fn validate_pubkey(value: &str, field: &'static str) -> Result<(), KnowledgeValidationError> {
    171     if value.trim().is_empty() {
    172         return Err(KnowledgeValidationError::EmptyField(field));
    173     }
    174     parse_public_key(value)
    175         .map(|_| ())
    176         .map_err(|_| KnowledgeValidationError::InvalidField(field))
    177 }
    178 
    179 fn validate_relays(relays: &[String], field: &'static str) -> Result<(), KnowledgeValidationError> {
    180     for relay in relays {
    181         RelayUrl::parse(relay)
    182             .map(|_| ())
    183             .map_err(|_| KnowledgeValidationError::InvalidField(field))?;
    184     }
    185     Ok(())
    186 }
    187 
    188 fn validate_event_ref(
    189     event_ref: &EventRef,
    190     field: &'static str,
    191 ) -> Result<(), KnowledgeValidationError> {
    192     validate_event_id(&event_ref.id, field)?;
    193     if let Some(d_tag) = event_ref.d_tag.as_deref() {
    194         DTag::parse(d_tag)
    195             .map(|_| ())
    196             .map_err(|_| KnowledgeValidationError::InvalidField(field))?;
    197     }
    198     if let Some(relays) = event_ref.relays.as_deref() {
    199         validate_relays(relays, field)?;
    200     }
    201     Ok(())
    202 }
    203 
    204 fn validate_event_refs(
    205     refs: &[EventRef],
    206     field: &'static str,
    207 ) -> Result<(), KnowledgeValidationError> {
    208     for event_ref in refs {
    209         validate_event_ref(event_ref, field)?;
    210     }
    211     Ok(())
    212 }
    213 
    214 fn validate_address_ref(
    215     address: &AddressableRef,
    216     field: &'static str,
    217 ) -> Result<(), KnowledgeValidationError> {
    218     if address.kind == 0 {
    219         return Err(KnowledgeValidationError::InvalidField(field));
    220     }
    221     validate_pubkey(&address.pubkey, field)?;
    222     DTag::parse(address.d_tag.as_str())
    223         .map(|_| ())
    224         .map_err(|_| KnowledgeValidationError::InvalidField(field))?;
    225     validate_relays(&address.relays, field)
    226 }
    227 
    228 fn validate_wiki_article_address_ref(
    229     address: &AddressableRef,
    230     field: &'static str,
    231 ) -> Result<(), KnowledgeValidationError> {
    232     if address.kind != KIND_WIKI_ARTICLE {
    233         return Err(KnowledgeValidationError::InvalidField(field));
    234     }
    235     validate_pubkey(&address.pubkey, field)?;
    236     validate_wiki_d_tag(address.d_tag.as_str())
    237         .map(|_| ())
    238         .map_err(|_| KnowledgeValidationError::InvalidField(field))?;
    239     validate_relays(&address.relays, field)
    240 }
    241 
    242 fn validate_wiki_article_version_ref(
    243     version_ref: &WikiArticleVersionRef,
    244     field: &'static str,
    245 ) -> Result<(), KnowledgeValidationError> {
    246     validate_event_id(&version_ref.event_id, field)?;
    247     validate_wiki_article_address_ref(&version_ref.address_ref, field)
    248 }
    249 
    250 fn validate_node_ref(
    251     node_ref: &KnowledgeNodeRef,
    252     field: &'static str,
    253 ) -> Result<(), KnowledgeValidationError> {
    254     let mut populated = 0u8;
    255     if let Some(event_ref) = &node_ref.event_ref {
    256         populated += 1;
    257         validate_event_ref(event_ref, field)?;
    258     }
    259     if let Some(address_ref) = &node_ref.address_ref {
    260         populated += 1;
    261         validate_address_ref(address_ref, field)?;
    262     }
    263     if let Some(external_id) = node_ref.external_id.as_deref() {
    264         populated += 1;
    265         require_non_empty(external_id, field)?;
    266     }
    267     if populated == 1 {
    268         Ok(())
    269     } else {
    270         Err(KnowledgeValidationError::InvalidField(field))
    271     }
    272 }
    273 
    274 pub fn validate_wiki_article(article: &WikiArticle) -> Result<(), KnowledgeValidationError> {
    275     validate_wiki_d_tag(article.d_tag.as_str())
    276         .map(|_| ())
    277         .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?;
    278     if let Some(title) = article.title.as_deref() {
    279         require_non_empty(title, "title")?;
    280     }
    281     require_non_empty(article.content_djot.as_str(), "content_djot")?;
    282     validate_event_refs(&article.references, "references")?;
    283     for version_ref in &article.forked_from {
    284         validate_wiki_article_version_ref(version_ref, "forked_from")?;
    285     }
    286     if let Some(version_ref) = &article.deferred_to {
    287         validate_wiki_article_version_ref(version_ref, "deferred_to")?;
    288     }
    289     Ok(())
    290 }
    291 
    292 pub fn validate_wiki_redirect(redirect: &WikiRedirect) -> Result<(), KnowledgeValidationError> {
    293     validate_wiki_d_tag(redirect.d_tag.as_str())
    294         .map(|_| ())
    295         .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?;
    296     validate_wiki_article_address_ref(&redirect.target, "wiki_redirect.target")
    297 }
    298 
    299 pub fn validate_wiki_merge_request(
    300     request: &WikiMergeRequest,
    301 ) -> Result<(), KnowledgeValidationError> {
    302     validate_wiki_article_address_ref(&request.target_article, "target_article")?;
    303     validate_pubkey(&request.destination_pubkey, "destination_pubkey")?;
    304     if let Some(base) = request.base_version_event_id.as_deref() {
    305         validate_event_id(base, "base_version_event_id")?;
    306     }
    307     validate_event_id(
    308         request.source_version_event_id.as_str(),
    309         "source_version_event_id",
    310     )
    311 }
    312 
    313 pub fn validate_knowledge_source(source: &KnowledgeSource) -> Result<(), KnowledgeValidationError> {
    314     require_schema(
    315         source.schema.as_str(),
    316         source.schema_version,
    317         RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
    318     )?;
    319     validate_wiki_d_tag(source.d_tag.as_str())
    320         .map(|_| ())
    321         .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?;
    322     require_non_empty(source.title.as_str(), "title")?;
    323     require_non_empty(source.source_type.as_str(), "source_type")?;
    324     for author in &source.authors {
    325         require_non_empty(author.as_str(), "authors")?;
    326     }
    327     validate_event_refs(&source.artifact_refs, "artifact_refs")
    328 }
    329 
    330 pub fn is_uncited_knowledge_claim_type(claim_type: &str) -> bool {
    331     matches!(claim_type, "hypothesis" | "observation" | "question")
    332 }
    333 
    334 pub fn validate_knowledge_claim(claim: &KnowledgeClaim) -> Result<(), KnowledgeValidationError> {
    335     require_schema(
    336         claim.schema.as_str(),
    337         claim.schema_version,
    338         RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
    339     )?;
    340     require_non_empty(claim.claim_type.as_str(), "claim_type")?;
    341     require_non_empty(claim.text.as_str(), "text")?;
    342     if claim.citation_spans.is_empty()
    343         && !is_uncited_knowledge_claim_type(claim.claim_type.as_str())
    344     {
    345         return Err(KnowledgeValidationError::EmptyField("citation_spans"));
    346     }
    347     for citation in &claim.citation_spans {
    348         if citation.source_ref.kind != crate::envelope::kind::KIND_KNOWLEDGE_SOURCE {
    349             return Err(KnowledgeValidationError::InvalidField("citation_spans"));
    350         }
    351         validate_event_ref(&citation.source_ref, "citation_spans")?;
    352         if let Some(artifact_ref) = &citation.artifact_ref {
    353             validate_event_ref(artifact_ref, "citation_spans")?;
    354         }
    355         if let Some(quote_hash) = citation.quote_hash.as_deref() {
    356             validate_event_id(quote_hash, "citation_spans")?;
    357         }
    358     }
    359     validate_event_refs(&claim.supersedes, "supersedes")
    360 }
    361 
    362 pub fn validate_knowledge_relation(
    363     relation: &KnowledgeRelation,
    364 ) -> Result<(), KnowledgeValidationError> {
    365     require_schema(
    366         relation.schema.as_str(),
    367         relation.schema_version,
    368         RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
    369     )?;
    370     validate_node_ref(&relation.subject, "subject")?;
    371     require_non_empty(relation.predicate.as_str(), "predicate")?;
    372     validate_node_ref(&relation.object, "object")?;
    373     validate_event_refs(&relation.support_refs, "support_refs")?;
    374     validate_event_refs(&relation.supersedes, "supersedes")
    375 }
    376 
    377 pub fn validate_knowledge_review(review: &KnowledgeReview) -> Result<(), KnowledgeValidationError> {
    378     require_schema(
    379         review.schema.as_str(),
    380         review.schema_version,
    381         RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
    382     )?;
    383     validate_event_id(review.target.event_id.as_str(), "review_target")?;
    384     validate_pubkey(review.target.author_pubkey.as_str(), "review_target")?;
    385     if review.target.kind == 0 {
    386         return Err(KnowledgeValidationError::InvalidField("review_target"));
    387     }
    388     if let Some(address) = review.target.address.as_deref() {
    389         AddressableCoordinate::parse(address)
    390             .map(|_| ())
    391             .map_err(|_| KnowledgeValidationError::InvalidField("review_target"))?;
    392     }
    393     validate_relays(&review.target.relays, "review_target")?;
    394     require_non_empty(review.reviewer_role.as_str(), "reviewer_role")?;
    395     require_non_empty(review.verdict.as_str(), "verdict")?;
    396     for score in &review.scores {
    397         require_non_empty(score.dimension.as_str(), "scores")?;
    398         require_non_empty(score.value.as_str(), "scores")?;
    399     }
    400     validate_event_refs(&review.evidence_refs, "evidence_refs")
    401 }
    402 
    403 pub fn validate_knowledge_field_report(
    404     report: &KnowledgeFieldReport,
    405 ) -> Result<(), KnowledgeValidationError> {
    406     require_schema(
    407         report.schema.as_str(),
    408         report.schema_version,
    409         RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
    410     )?;
    411     require_non_empty(report.report_type.as_str(), "report_type")?;
    412     require_non_empty(report.title.as_str(), "title")?;
    413     if report.context.location_precision == KnowledgeLocationPrecision::ExactPrivateReference
    414         && report.context.private_location_ref.is_none()
    415     {
    416         return Err(KnowledgeValidationError::EmptyField("private_location_ref"));
    417     }
    418     if let Some(private_location_ref) = &report.context.private_location_ref {
    419         validate_event_ref(private_location_ref, "private_location_ref")?;
    420     }
    421     if report.observations.is_empty() {
    422         return Err(KnowledgeValidationError::EmptyField("observations"));
    423     }
    424     for observation in &report.observations {
    425         require_non_empty(observation.observation_type.as_str(), "observations")?;
    426         require_non_empty(observation.text.as_str(), "observations")?;
    427         for value in &observation.values {
    428             require_non_empty(value.key.as_str(), "observation_values")?;
    429             require_non_empty(value.value.as_str(), "observation_values")?;
    430         }
    431     }
    432     validate_event_refs(&report.artifact_refs, "artifact_refs")?;
    433     validate_event_refs(&report.related_refs, "related_refs")
    434 }
    435 
    436 pub fn validate_evidence_bounty(bounty: &EvidenceBounty) -> Result<(), KnowledgeValidationError> {
    437     require_schema(
    438         bounty.schema.as_str(),
    439         bounty.schema_version,
    440         RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
    441     )?;
    442     validate_wiki_d_tag(bounty.d_tag.as_str())
    443         .map(|_| ())
    444         .map_err(|_| KnowledgeValidationError::InvalidField("d_tag"))?;
    445     require_non_empty(bounty.title.as_str(), "title")?;
    446     if bounty.target_refs.is_empty() {
    447         return Err(KnowledgeValidationError::EmptyField("target_refs"));
    448     }
    449     validate_event_refs(&bounty.target_refs, "target_refs")
    450 }
    451 
    452 pub fn validate_knowledge_change_proposal(
    453     proposal: &KnowledgeChangeProposal,
    454 ) -> Result<(), KnowledgeValidationError> {
    455     require_schema(
    456         proposal.schema.as_str(),
    457         proposal.schema_version,
    458         RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
    459     )?;
    460     validate_event_ref(&proposal.target, "target")?;
    461     require_non_empty(proposal.proposal_type.as_str(), "proposal_type")?;
    462     require_non_empty(proposal.summary.as_str(), "summary")?;
    463     validate_event_refs(&proposal.evidence_refs, "evidence_refs")?;
    464     validate_event_refs(&proposal.supersedes, "supersedes")
    465 }
    466 
    467 pub fn validate_contribution_attestation(
    468     attestation: &ContributionAttestation,
    469 ) -> Result<(), KnowledgeValidationError> {
    470     require_schema(
    471         attestation.schema.as_str(),
    472         attestation.schema_version,
    473         RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
    474     )?;
    475     validate_pubkey(
    476         attestation.contributor_pubkey.as_str(),
    477         "contributor_pubkey",
    478     )?;
    479     require_non_empty(attestation.contribution_type.as_str(), "contribution_type")?;
    480     require_non_empty(attestation.summary.as_str(), "summary")?;
    481     if attestation.subject_refs.is_empty() {
    482         return Err(KnowledgeValidationError::EmptyField("subject_refs"));
    483     }
    484     validate_event_refs(&attestation.subject_refs, "subject_refs")?;
    485     validate_event_refs(&attestation.evidence_refs, "evidence_refs")
    486 }
    487 
    488 #[cfg_attr(
    489     any(feature = "serde", test),
    490     derive(serde::Serialize, serde::Deserialize)
    491 )]
    492 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    493 #[cfg_attr(all(test, feature = "std"), dto(export))]
    494 #[derive(Clone, Debug, PartialEq, Eq)]
    495 pub struct AddressableRef {
    496     pub kind: u32,
    497     pub pubkey: String,
    498     pub d_tag: String,
    499     pub relays: Vec<String>,
    500 }
    501 
    502 #[cfg_attr(
    503     any(feature = "serde", test),
    504     derive(serde::Serialize, serde::Deserialize)
    505 )]
    506 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    507 #[cfg_attr(all(test, feature = "std"), dto(export))]
    508 #[derive(Clone, Debug, PartialEq, Eq)]
    509 pub struct RightsAssertion {
    510     pub assertion: String,
    511     pub holder: Option<String>,
    512     pub license: Option<String>,
    513     pub url: Option<String>,
    514 }
    515 
    516 #[cfg_attr(
    517     any(feature = "serde", test),
    518     derive(serde::Serialize, serde::Deserialize)
    519 )]
    520 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    521 #[cfg_attr(all(test, feature = "std"), dto(export))]
    522 #[derive(Clone, Debug, PartialEq, Eq)]
    523 pub struct WikiArticle {
    524     pub d_tag: String,
    525     pub title: Option<String>,
    526     pub content_djot: String,
    527     pub summary: Option<String>,
    528     pub topics: Vec<String>,
    529     pub references: Vec<EventRef>,
    530     pub forked_from: Vec<WikiArticleVersionRef>,
    531     pub deferred_to: Option<WikiArticleVersionRef>,
    532 }
    533 
    534 #[cfg_attr(
    535     any(feature = "serde", test),
    536     derive(serde::Serialize, serde::Deserialize)
    537 )]
    538 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    539 #[cfg_attr(all(test, feature = "std"), dto(export))]
    540 #[derive(Clone, Debug, PartialEq, Eq)]
    541 pub struct WikiArticleVersionRef {
    542     pub event_id: String,
    543     pub address_ref: AddressableRef,
    544 }
    545 
    546 #[cfg_attr(
    547     any(feature = "serde", test),
    548     derive(serde::Serialize, serde::Deserialize)
    549 )]
    550 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    551 #[cfg_attr(all(test, feature = "std"), dto(export))]
    552 #[derive(Clone, Debug, PartialEq, Eq)]
    553 pub struct WikiRedirect {
    554     pub d_tag: String,
    555     pub target: AddressableRef,
    556 }
    557 
    558 #[cfg_attr(
    559     any(feature = "serde", test),
    560     derive(serde::Serialize, serde::Deserialize)
    561 )]
    562 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    563 #[cfg_attr(all(test, feature = "std"), dto(export))]
    564 #[derive(Clone, Debug, PartialEq, Eq)]
    565 pub struct WikiMergeRequest {
    566     pub target_article: AddressableRef,
    567     pub destination_pubkey: String,
    568     pub base_version_event_id: Option<String>,
    569     pub source_version_event_id: String,
    570     pub explanation: Option<String>,
    571 }
    572 
    573 #[cfg_attr(
    574     any(feature = "serde", test),
    575     derive(serde::Serialize, serde::Deserialize)
    576 )]
    577 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    578 #[cfg_attr(all(test, feature = "std"), dto(export))]
    579 #[derive(Clone, Debug, PartialEq, Eq)]
    580 pub struct KnowledgeSource {
    581     pub schema: String,
    582     pub schema_version: u16,
    583     pub d_tag: String,
    584     pub title: String,
    585     pub source_type: String,
    586     pub authors: Vec<String>,
    587     pub publisher: Option<String>,
    588     pub publication_year: Option<u16>,
    589     pub edition: Option<String>,
    590     pub canonical_url: Option<String>,
    591     pub artifact_refs: Vec<EventRef>,
    592     pub author_asserted_rights: Option<RightsAssertion>,
    593     pub topics: Vec<String>,
    594     pub summary: Option<String>,
    595 }
    596 
    597 #[cfg_attr(
    598     any(feature = "serde", test),
    599     derive(serde::Serialize, serde::Deserialize)
    600 )]
    601 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    602 #[cfg_attr(all(test, feature = "std"), dto(export))]
    603 #[derive(Clone, Debug, PartialEq, Eq)]
    604 pub struct KnowledgeClaim {
    605     pub schema: String,
    606     pub schema_version: u16,
    607     pub claim_type: String,
    608     pub text: String,
    609     pub citation_spans: Vec<KnowledgeCitationSpan>,
    610     pub topics: Vec<String>,
    611     pub applies_to: Vec<String>,
    612     pub author_asserted_confidence: Option<String>,
    613     pub supersedes: Vec<EventRef>,
    614 }
    615 
    616 #[cfg_attr(
    617     any(feature = "serde", test),
    618     derive(serde::Serialize, serde::Deserialize)
    619 )]
    620 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    621 #[cfg_attr(all(test, feature = "std"), dto(export))]
    622 #[derive(Clone, Debug, PartialEq, Eq)]
    623 pub struct KnowledgeCitationSpan {
    624     pub source_ref: EventRef,
    625     pub artifact_ref: Option<EventRef>,
    626     pub page_start: Option<u32>,
    627     pub page_end: Option<u32>,
    628     pub section_path: Vec<String>,
    629     pub quote_hash: Option<String>,
    630     pub chunk_id: Option<String>,
    631 }
    632 
    633 #[cfg_attr(
    634     any(feature = "serde", test),
    635     derive(serde::Serialize, serde::Deserialize)
    636 )]
    637 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    638 #[cfg_attr(all(test, feature = "std"), dto(export))]
    639 #[derive(Clone, Debug, PartialEq, Eq)]
    640 pub struct KnowledgeNodeRef {
    641     pub node_type: String,
    642     pub event_ref: Option<EventRef>,
    643     pub address_ref: Option<AddressableRef>,
    644     pub external_id: Option<String>,
    645     pub label: Option<String>,
    646 }
    647 
    648 #[cfg_attr(
    649     any(feature = "serde", test),
    650     derive(serde::Serialize, serde::Deserialize)
    651 )]
    652 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    653 #[cfg_attr(all(test, feature = "std"), dto(export))]
    654 #[derive(Clone, Debug, PartialEq, Eq)]
    655 pub struct KnowledgeRelation {
    656     pub schema: String,
    657     pub schema_version: u16,
    658     pub subject: KnowledgeNodeRef,
    659     pub predicate: String,
    660     pub object: KnowledgeNodeRef,
    661     pub support_refs: Vec<EventRef>,
    662     pub author_asserted_confidence: Option<String>,
    663     pub supersedes: Vec<EventRef>,
    664 }
    665 
    666 #[cfg_attr(
    667     any(feature = "serde", test),
    668     derive(serde::Serialize, serde::Deserialize)
    669 )]
    670 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    671 #[cfg_attr(all(test, feature = "std"), dto(export))]
    672 #[derive(Clone, Debug, PartialEq, Eq)]
    673 pub struct KnowledgeReview {
    674     pub schema: String,
    675     pub schema_version: u16,
    676     pub target: KnowledgeReviewTarget,
    677     pub reviewer_role: String,
    678     pub verdict: String,
    679     pub scores: Vec<KnowledgeReviewScore>,
    680     pub notes: Option<String>,
    681     pub evidence_refs: Vec<EventRef>,
    682 }
    683 
    684 #[cfg_attr(
    685     any(feature = "serde", test),
    686     derive(serde::Serialize, serde::Deserialize)
    687 )]
    688 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    689 #[cfg_attr(all(test, feature = "std"), dto(export))]
    690 #[derive(Clone, Debug, PartialEq, Eq)]
    691 pub struct KnowledgeReviewTarget {
    692     pub event_id: String,
    693     pub author_pubkey: String,
    694     pub kind: u32,
    695     pub address: Option<String>,
    696     pub relays: Vec<String>,
    697     pub review_scope: KnowledgeReviewScope,
    698 }
    699 
    700 #[cfg_attr(
    701     any(feature = "serde", test),
    702     derive(serde::Serialize, serde::Deserialize)
    703 )]
    704 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    705 #[cfg_attr(all(test, feature = "std"), dto(export))]
    706 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
    707 #[derive(Clone, Debug, PartialEq, Eq)]
    708 pub enum KnowledgeReviewScope {
    709     SpecificVersion,
    710     AddressableCoordinateAtPublishedAt,
    711     PolicyLatest,
    712 }
    713 
    714 #[cfg_attr(
    715     any(feature = "serde", test),
    716     derive(serde::Serialize, serde::Deserialize)
    717 )]
    718 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    719 #[cfg_attr(all(test, feature = "std"), dto(export))]
    720 #[derive(Clone, Debug, PartialEq, Eq)]
    721 pub struct KnowledgeReviewScore {
    722     pub dimension: String,
    723     pub value: String,
    724     pub note: Option<String>,
    725 }
    726 
    727 #[cfg_attr(
    728     any(feature = "serde", test),
    729     derive(serde::Serialize, serde::Deserialize)
    730 )]
    731 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    732 #[cfg_attr(all(test, feature = "std"), dto(export))]
    733 #[derive(Clone, Debug, PartialEq, Eq)]
    734 pub struct KnowledgeFieldReport {
    735     pub schema: String,
    736     pub schema_version: u16,
    737     pub report_type: String,
    738     pub title: String,
    739     pub summary: Option<String>,
    740     pub context: KnowledgeFieldContext,
    741     pub observations: Vec<KnowledgeObservation>,
    742     pub artifact_refs: Vec<EventRef>,
    743     pub related_refs: Vec<EventRef>,
    744     pub limitations: Vec<String>,
    745 }
    746 
    747 #[cfg_attr(
    748     any(feature = "serde", test),
    749     derive(serde::Serialize, serde::Deserialize)
    750 )]
    751 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    752 #[cfg_attr(all(test, feature = "std"), dto(export))]
    753 #[derive(Clone, Debug, PartialEq, Eq)]
    754 pub struct KnowledgeFieldContext {
    755     pub location_precision: KnowledgeLocationPrecision,
    756     pub public_location: Option<KnowledgeLocation>,
    757     pub private_location_ref: Option<EventRef>,
    758     pub topics: Vec<String>,
    759     pub context_tags: Vec<String>,
    760 }
    761 
    762 #[cfg_attr(
    763     any(feature = "serde", test),
    764     derive(serde::Serialize, serde::Deserialize)
    765 )]
    766 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    767 #[cfg_attr(all(test, feature = "std"), dto(export))]
    768 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
    769 #[derive(Clone, Debug, PartialEq, Eq)]
    770 pub enum KnowledgeLocationPrecision {
    771     None,
    772     Region,
    773     Locality,
    774     CoarseGeohash,
    775     ExactPublic,
    776     ExactPrivateReference,
    777 }
    778 
    779 #[cfg_attr(
    780     any(feature = "serde", test),
    781     derive(serde::Serialize, serde::Deserialize)
    782 )]
    783 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    784 #[cfg_attr(all(test, feature = "std"), dto(export))]
    785 #[derive(Clone, Debug, PartialEq, Eq)]
    786 pub struct KnowledgeLocation {
    787     pub label: Option<String>,
    788     pub region: Option<String>,
    789     pub locality: Option<String>,
    790     pub geohash: Option<String>,
    791 }
    792 
    793 #[cfg_attr(
    794     any(feature = "serde", test),
    795     derive(serde::Serialize, serde::Deserialize)
    796 )]
    797 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    798 #[cfg_attr(all(test, feature = "std"), dto(export))]
    799 #[derive(Clone, Debug, PartialEq, Eq)]
    800 pub struct KnowledgeObservation {
    801     pub observation_type: String,
    802     pub text: String,
    803     pub observed_at: Option<String>,
    804     pub values: Vec<KnowledgeObservationValue>,
    805 }
    806 
    807 #[cfg_attr(
    808     any(feature = "serde", test),
    809     derive(serde::Serialize, serde::Deserialize)
    810 )]
    811 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    812 #[cfg_attr(all(test, feature = "std"), dto(export))]
    813 #[derive(Clone, Debug, PartialEq, Eq)]
    814 pub struct KnowledgeObservationValue {
    815     pub key: String,
    816     pub value: String,
    817     pub unit: Option<String>,
    818 }
    819 
    820 #[cfg_attr(
    821     any(feature = "serde", test),
    822     derive(serde::Serialize, serde::Deserialize)
    823 )]
    824 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    825 #[cfg_attr(all(test, feature = "std"), dto(export))]
    826 #[derive(Clone, Debug, PartialEq, Eq)]
    827 pub struct EvidenceBounty {
    828     pub schema: String,
    829     pub schema_version: u16,
    830     pub d_tag: String,
    831     pub title: String,
    832     pub summary: Option<String>,
    833     pub topics: Vec<String>,
    834     pub target_refs: Vec<EventRef>,
    835     pub reward_note: Option<String>,
    836     pub closes_at: Option<String>,
    837 }
    838 
    839 #[cfg_attr(
    840     any(feature = "serde", test),
    841     derive(serde::Serialize, serde::Deserialize)
    842 )]
    843 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    844 #[cfg_attr(all(test, feature = "std"), dto(export))]
    845 #[derive(Clone, Debug, PartialEq, Eq)]
    846 pub struct KnowledgeChangeProposal {
    847     pub schema: String,
    848     pub schema_version: u16,
    849     pub target: EventRef,
    850     pub proposal_type: String,
    851     pub summary: String,
    852     pub rationale: Option<String>,
    853     pub evidence_refs: Vec<EventRef>,
    854     pub supersedes: Vec<EventRef>,
    855 }
    856 
    857 #[cfg_attr(
    858     any(feature = "serde", test),
    859     derive(serde::Serialize, serde::Deserialize)
    860 )]
    861 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    862 #[cfg_attr(all(test, feature = "std"), dto(export))]
    863 #[derive(Clone, Debug, PartialEq, Eq)]
    864 pub struct ContributionAttestation {
    865     pub schema: String,
    866     pub schema_version: u16,
    867     pub contributor_pubkey: String,
    868     pub contribution_type: String,
    869     pub subject_refs: Vec<EventRef>,
    870     pub summary: String,
    871     pub evidence_refs: Vec<EventRef>,
    872 }
    873 
    874 #[cfg(test)]
    875 #[cfg_attr(coverage_nightly, coverage(off))]
    876 mod tests {
    877     use super::*;
    878 
    879     fn hex_64(character: char) -> String {
    880         crate::test_valid_hex_64(character)
    881     }
    882 
    883     fn event_ref() -> EventRef {
    884         event_ref_with_kind(1)
    885     }
    886 
    887     fn event_ref_with_kind(kind: u32) -> EventRef {
    888         EventRef {
    889             id: "0".repeat(64),
    890             author: radroots_identity::PublicKey::from_hex(&crate::test_valid_hex_64('1'))
    891                 .expect("fixture public key"),
    892             kind,
    893             d_tag: None,
    894             relays: None,
    895         }
    896     }
    897 
    898     fn article_address_ref() -> AddressableRef {
    899         AddressableRef {
    900             kind: KIND_WIKI_ARTICLE,
    901             pubkey: hex_64('a'),
    902             d_tag: "soil-health".to_string(),
    903             relays: Vec::new(),
    904         }
    905     }
    906 
    907     fn article_version_ref() -> WikiArticleVersionRef {
    908         WikiArticleVersionRef {
    909             event_id: hex_64('b'),
    910             address_ref: article_address_ref(),
    911         }
    912     }
    913 
    914     fn wiki_article() -> WikiArticle {
    915         WikiArticle {
    916             d_tag: "soil-health".to_string(),
    917             title: Some("Soil health".to_string()),
    918             content_djot: "# Soil health".to_string(),
    919             summary: None,
    920             topics: Vec::new(),
    921             references: vec![event_ref()],
    922             forked_from: vec![article_version_ref()],
    923             deferred_to: Some(article_version_ref()),
    924         }
    925     }
    926 
    927     fn invalid_relay() -> String {
    928         "http://relay.radroots.example".to_string()
    929     }
    930 
    931     fn knowledge_source() -> KnowledgeSource {
    932         KnowledgeSource {
    933             schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
    934             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    935             d_tag: "soil-source".to_string(),
    936             title: "Soil Source".to_string(),
    937             source_type: "book".to_string(),
    938             authors: vec!["Ada Example".to_string()],
    939             publisher: None,
    940             publication_year: Some(2026),
    941             edition: None,
    942             canonical_url: None,
    943             artifact_refs: vec![event_ref()],
    944             author_asserted_rights: None,
    945             topics: vec!["soil".to_string()],
    946             summary: None,
    947         }
    948     }
    949 
    950     fn citation_span() -> KnowledgeCitationSpan {
    951         KnowledgeCitationSpan {
    952             source_ref: event_ref_with_kind(crate::envelope::kind::KIND_KNOWLEDGE_SOURCE),
    953             artifact_ref: Some(event_ref()),
    954             page_start: None,
    955             page_end: None,
    956             section_path: Vec::new(),
    957             quote_hash: Some(hex_64('c')),
    958             chunk_id: None,
    959         }
    960     }
    961 
    962     fn knowledge_claim() -> KnowledgeClaim {
    963         KnowledgeClaim {
    964             schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(),
    965             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    966             claim_type: "practice_effect".to_string(),
    967             text: "Cover crops improve soil structure.".to_string(),
    968             citation_spans: vec![citation_span()],
    969             topics: Vec::new(),
    970             applies_to: Vec::new(),
    971             author_asserted_confidence: None,
    972             supersedes: vec![event_ref()],
    973         }
    974     }
    975 
    976     fn node_ref() -> KnowledgeNodeRef {
    977         KnowledgeNodeRef {
    978             node_type: "event".to_string(),
    979             event_ref: Some(event_ref()),
    980             address_ref: None,
    981             external_id: None,
    982             label: None,
    983         }
    984     }
    985 
    986     fn knowledge_relation() -> KnowledgeRelation {
    987         KnowledgeRelation {
    988             schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(),
    989             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
    990             subject: node_ref(),
    991             predicate: "supports".to_string(),
    992             object: node_ref(),
    993             support_refs: vec![event_ref()],
    994             author_asserted_confidence: None,
    995             supersedes: Vec::new(),
    996         }
    997     }
    998 
    999     fn knowledge_review() -> KnowledgeReview {
   1000         KnowledgeReview {
   1001             schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(),
   1002             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1003             target: KnowledgeReviewTarget {
   1004                 event_id: hex_64('d'),
   1005                 author_pubkey: hex_64('a'),
   1006                 kind: crate::envelope::kind::KIND_KNOWLEDGE_CLAIM,
   1007                 address: None,
   1008                 relays: Vec::new(),
   1009                 review_scope: KnowledgeReviewScope::SpecificVersion,
   1010             },
   1011             reviewer_role: "peer".to_string(),
   1012             verdict: "needs_more_evidence".to_string(),
   1013             scores: vec![KnowledgeReviewScore {
   1014                 dimension: "evidence".to_string(),
   1015                 value: "partial".to_string(),
   1016                 note: None,
   1017             }],
   1018             notes: None,
   1019             evidence_refs: vec![event_ref()],
   1020         }
   1021     }
   1022 
   1023     fn field_report() -> KnowledgeFieldReport {
   1024         KnowledgeFieldReport {
   1025             schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(),
   1026             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1027             report_type: "observation".to_string(),
   1028             title: "Field observation".to_string(),
   1029             summary: None,
   1030             context: KnowledgeFieldContext {
   1031                 location_precision: KnowledgeLocationPrecision::CoarseGeohash,
   1032                 public_location: None,
   1033                 private_location_ref: None,
   1034                 topics: Vec::new(),
   1035                 context_tags: Vec::new(),
   1036             },
   1037             observations: vec![KnowledgeObservation {
   1038                 observation_type: "residue".to_string(),
   1039                 text: "Residue was visible.".to_string(),
   1040                 observed_at: None,
   1041                 values: vec![KnowledgeObservationValue {
   1042                     key: "coverage".to_string(),
   1043                     value: "medium".to_string(),
   1044                     unit: None,
   1045                 }],
   1046             }],
   1047             artifact_refs: vec![event_ref()],
   1048             related_refs: vec![event_ref()],
   1049             limitations: Vec::new(),
   1050         }
   1051     }
   1052 
   1053     fn evidence_bounty() -> EvidenceBounty {
   1054         EvidenceBounty {
   1055             schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
   1056             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1057             d_tag: "soil-bounty".to_string(),
   1058             title: "Soil bounty".to_string(),
   1059             summary: None,
   1060             topics: Vec::new(),
   1061             target_refs: vec![event_ref()],
   1062             reward_note: None,
   1063             closes_at: None,
   1064         }
   1065     }
   1066 
   1067     fn knowledge_change_proposal() -> KnowledgeChangeProposal {
   1068         KnowledgeChangeProposal {
   1069             schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
   1070             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1071             target: event_ref(),
   1072             proposal_type: "amend".to_string(),
   1073             summary: "Clarify scope".to_string(),
   1074             rationale: None,
   1075             evidence_refs: vec![event_ref()],
   1076             supersedes: Vec::new(),
   1077         }
   1078     }
   1079 
   1080     fn contribution_attestation() -> ContributionAttestation {
   1081         ContributionAttestation {
   1082             schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
   1083             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1084             contributor_pubkey: hex_64('a'),
   1085             contribution_type: "review".to_string(),
   1086             subject_refs: vec![event_ref()],
   1087             summary: "Reviewed claim".to_string(),
   1088             evidence_refs: vec![event_ref()],
   1089         }
   1090     }
   1091 
   1092     fn assert_validation_error(
   1093         result: Result<(), KnowledgeValidationError>,
   1094         expected: KnowledgeValidationError,
   1095     ) {
   1096         assert_eq!(result, Err(expected));
   1097     }
   1098 
   1099     #[test]
   1100     fn normalizes_wiki_d_tags() {
   1101         assert_eq!(
   1102             normalize_wiki_d_tag(" Soil Health  Basics "),
   1103             "soil-health-basics"
   1104         );
   1105         assert_eq!(
   1106             normalize_wiki_d_tag("Crème Brûlée 2026"),
   1107             "crème-brûlée-2026"
   1108         );
   1109         assert_eq!(normalize_wiki_d_tag("土壌 健康 101"), "土壌-健康-101");
   1110         assert_eq!(
   1111             normalize_wiki_d_tag("Почва Здоровье 101"),
   1112             "почва-здоровье-101"
   1113         );
   1114         assert_eq!(normalize_wiki_d_tag("El Niño y Suelo"), "el-niño-y-suelo");
   1115         assert_eq!(
   1116             normalize_wiki_d_tag("Field: Water & Soil!"),
   1117             "field-water-soil"
   1118         );
   1119         assert_eq!(normalize_wiki_d_tag("--Field---Notes--"), "field-notes");
   1120         assert_eq!(normalize_wiki_d_tag("中文 農業 101"), "中文-農業-101");
   1121         assert_eq!(normalize_wiki_d_tag("soil 🌱 health"), "soil-health");
   1122         assert_eq!(normalize_wiki_d_tag("!!! 🌱 !!!"), "");
   1123         assert_eq!(
   1124             normalize_wiki_d_tag(&normalize_wiki_d_tag("Soil Health Basics")),
   1125             "soil-health-basics"
   1126         );
   1127     }
   1128 
   1129     #[test]
   1130     fn validates_normalized_wiki_d_tags() {
   1131         assert_eq!(validate_wiki_d_tag("soil-health-basics"), Ok(()));
   1132         assert_eq!(validate_wiki_d_tag(""), Err(WikiDTagError::Empty));
   1133         assert_eq!(
   1134             validate_wiki_d_tag("Soil Health"),
   1135             Err(WikiDTagError::NotNormalized {
   1136                 normalized: "soil-health".to_string()
   1137             })
   1138         );
   1139     }
   1140 
   1141     #[test]
   1142     fn rejects_oversized_wiki_d_tags() {
   1143         let value = "a".repeat(RADROOTS_WIKI_D_TAG_MAX_LEN + 1);
   1144 
   1145         assert_eq!(
   1146             validate_wiki_d_tag(&value),
   1147             Err(WikiDTagError::TooLong {
   1148                 max: RADROOTS_WIKI_D_TAG_MAX_LEN,
   1149                 actual: RADROOTS_WIKI_D_TAG_MAX_LEN + 1,
   1150             })
   1151         );
   1152     }
   1153 
   1154     #[test]
   1155     fn exposes_knowledge_schema_ids() {
   1156         assert_eq!(RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1);
   1157         assert_eq!(
   1158             RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
   1159             "radroots.knowledge.source.v1"
   1160         );
   1161         assert_eq!(
   1162             RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
   1163             "radroots.knowledge.claim.v1"
   1164         );
   1165         assert_eq!(
   1166             RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
   1167             "radroots.knowledge.relation.v1"
   1168         );
   1169         assert_eq!(
   1170             RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
   1171             "radroots.knowledge.review.v1"
   1172         );
   1173         assert_eq!(
   1174             RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
   1175             "radroots.knowledge.field_report.v1"
   1176         );
   1177         assert_eq!(
   1178             RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
   1179             "radroots.knowledge.evidence_bounty.v1"
   1180         );
   1181         assert_eq!(
   1182             RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
   1183             "radroots.knowledge.change_proposal.v1"
   1184         );
   1185         assert_eq!(
   1186             RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
   1187             "radroots.knowledge.contribution_attestation.v1"
   1188         );
   1189     }
   1190 
   1191     #[test]
   1192     fn models_wiki_article_as_djot_payload() {
   1193         let article = WikiArticle {
   1194             d_tag: "soil-health".to_string(),
   1195             title: Some("Soil health".to_string()),
   1196             content_djot: "# Soil health".to_string(),
   1197             summary: Some("Living soil basics".to_string()),
   1198             topics: vec!["soil".to_string()],
   1199             references: vec![event_ref()],
   1200             forked_from: Vec::new(),
   1201             deferred_to: None,
   1202         };
   1203 
   1204         assert_eq!(article.content_djot, "# Soil health");
   1205         assert_eq!(article.topics, vec!["soil"]);
   1206         assert_eq!(article.references.len(), 1);
   1207     }
   1208 
   1209     #[test]
   1210     fn models_author_asserted_rights_without_trusted_status() {
   1211         let source = KnowledgeSource {
   1212             schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
   1213             schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
   1214             d_tag: "soil-source".to_string(),
   1215             title: "Soil Source".to_string(),
   1216             source_type: "book".to_string(),
   1217             authors: vec!["Ada Example".to_string()],
   1218             publisher: None,
   1219             publication_year: Some(2026),
   1220             edition: None,
   1221             canonical_url: None,
   1222             artifact_refs: Vec::new(),
   1223             author_asserted_rights: Some(RightsAssertion {
   1224                 assertion: "author_asserted_public_domain".to_string(),
   1225                 holder: None,
   1226                 license: None,
   1227                 url: None,
   1228             }),
   1229             topics: vec!["soil".to_string()],
   1230             summary: None,
   1231         };
   1232 
   1233         assert_eq!(source.schema_version, 1);
   1234         assert_eq!(
   1235             source
   1236                 .author_asserted_rights
   1237                 .as_ref()
   1238                 .expect("rights")
   1239                 .assertion,
   1240             "author_asserted_public_domain"
   1241         );
   1242     }
   1243 
   1244     #[test]
   1245     fn models_field_context_without_exact_private_coordinates() {
   1246         let context = KnowledgeFieldContext {
   1247             location_precision: KnowledgeLocationPrecision::ExactPrivateReference,
   1248             public_location: Some(KnowledgeLocation {
   1249                 label: Some("watershed edge".to_string()),
   1250                 region: Some("sample-region".to_string()),
   1251                 locality: None,
   1252                 geohash: Some("c23".to_string()),
   1253             }),
   1254             private_location_ref: Some(event_ref()),
   1255             topics: vec!["water".to_string()],
   1256             context_tags: vec!["field".to_string()],
   1257         };
   1258 
   1259         assert_eq!(
   1260             context.location_precision,
   1261             KnowledgeLocationPrecision::ExactPrivateReference
   1262         );
   1263         assert!(context.private_location_ref.is_some());
   1264         assert_eq!(
   1265             context.public_location.as_ref().expect("location").geohash,
   1266             Some("c23".to_string())
   1267         );
   1268     }
   1269 
   1270     #[test]
   1271     fn knowledge_validators_accept_valid_models() {
   1272         assert_eq!(validate_wiki_article(&wiki_article()), Ok(()));
   1273         assert_eq!(
   1274             validate_wiki_redirect(&WikiRedirect {
   1275                 d_tag: "soil".to_string(),
   1276                 target: article_address_ref(),
   1277             }),
   1278             Ok(())
   1279         );
   1280         assert_eq!(
   1281             validate_wiki_merge_request(&WikiMergeRequest {
   1282                 target_article: article_address_ref(),
   1283                 destination_pubkey: hex_64('a'),
   1284                 base_version_event_id: Some(hex_64('e')),
   1285                 source_version_event_id: hex_64('f'),
   1286                 explanation: None,
   1287             }),
   1288             Ok(())
   1289         );
   1290         assert_eq!(validate_knowledge_source(&knowledge_source()), Ok(()));
   1291         assert_eq!(validate_knowledge_claim(&knowledge_claim()), Ok(()));
   1292         assert_eq!(validate_knowledge_relation(&knowledge_relation()), Ok(()));
   1293         assert_eq!(validate_knowledge_review(&knowledge_review()), Ok(()));
   1294         assert_eq!(validate_knowledge_field_report(&field_report()), Ok(()));
   1295         assert_eq!(validate_evidence_bounty(&evidence_bounty()), Ok(()));
   1296         assert_eq!(
   1297             validate_knowledge_change_proposal(&knowledge_change_proposal()),
   1298             Ok(())
   1299         );
   1300         assert_eq!(
   1301             validate_contribution_attestation(&contribution_attestation()),
   1302             Ok(())
   1303         );
   1304     }
   1305 
   1306     #[test]
   1307     fn knowledge_validators_reject_noncanonical_relay_values() {
   1308         let mut article = wiki_article();
   1309         article.references[0].relays = Some(vec![invalid_relay()]);
   1310         assert_validation_error(
   1311             validate_wiki_article(&article),
   1312             KnowledgeValidationError::InvalidField("references"),
   1313         );
   1314 
   1315         let mut article = wiki_article();
   1316         article.forked_from[0].address_ref.relays = vec![invalid_relay()];
   1317         assert_validation_error(
   1318             validate_wiki_article(&article),
   1319             KnowledgeValidationError::InvalidField("forked_from"),
   1320         );
   1321 
   1322         let mut article = wiki_article();
   1323         article
   1324             .deferred_to
   1325             .as_mut()
   1326             .expect("deferred")
   1327             .address_ref
   1328             .relays = vec![invalid_relay()];
   1329         assert_validation_error(
   1330             validate_wiki_article(&article),
   1331             KnowledgeValidationError::InvalidField("deferred_to"),
   1332         );
   1333 
   1334         let mut redirect = WikiRedirect {
   1335             d_tag: "soil".to_string(),
   1336             target: article_address_ref(),
   1337         };
   1338         redirect.target.relays = vec![invalid_relay()];
   1339         assert_validation_error(
   1340             validate_wiki_redirect(&redirect),
   1341             KnowledgeValidationError::InvalidField("wiki_redirect.target"),
   1342         );
   1343 
   1344         let mut merge = WikiMergeRequest {
   1345             target_article: article_address_ref(),
   1346             destination_pubkey: hex_64('a'),
   1347             base_version_event_id: Some(hex_64('e')),
   1348             source_version_event_id: hex_64('f'),
   1349             explanation: None,
   1350         };
   1351         merge.target_article.relays = vec![invalid_relay()];
   1352         assert_validation_error(
   1353             validate_wiki_merge_request(&merge),
   1354             KnowledgeValidationError::InvalidField("target_article"),
   1355         );
   1356 
   1357         let mut source = knowledge_source();
   1358         source.artifact_refs[0].relays = Some(vec![invalid_relay()]);
   1359         assert_validation_error(
   1360             validate_knowledge_source(&source),
   1361             KnowledgeValidationError::InvalidField("artifact_refs"),
   1362         );
   1363 
   1364         let mut claim = knowledge_claim();
   1365         claim.citation_spans[0].source_ref.relays = Some(vec![invalid_relay()]);
   1366         assert_validation_error(
   1367             validate_knowledge_claim(&claim),
   1368             KnowledgeValidationError::InvalidField("citation_spans"),
   1369         );
   1370 
   1371         let mut relation = knowledge_relation();
   1372         relation.support_refs[0].relays = Some(vec![invalid_relay()]);
   1373         assert_validation_error(
   1374             validate_knowledge_relation(&relation),
   1375             KnowledgeValidationError::InvalidField("support_refs"),
   1376         );
   1377 
   1378         let mut review = knowledge_review();
   1379         review.target.relays = vec![invalid_relay()];
   1380         assert_validation_error(
   1381             validate_knowledge_review(&review),
   1382             KnowledgeValidationError::InvalidField("review_target"),
   1383         );
   1384 
   1385         let mut report = field_report();
   1386         report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference;
   1387         let mut private_location_ref = event_ref();
   1388         private_location_ref.relays = Some(vec![invalid_relay()]);
   1389         report.context.private_location_ref = Some(private_location_ref);
   1390         assert_validation_error(
   1391             validate_knowledge_field_report(&report),
   1392             KnowledgeValidationError::InvalidField("private_location_ref"),
   1393         );
   1394 
   1395         let mut bounty = evidence_bounty();
   1396         bounty.target_refs[0].relays = Some(vec![invalid_relay()]);
   1397         assert_validation_error(
   1398             validate_evidence_bounty(&bounty),
   1399             KnowledgeValidationError::InvalidField("target_refs"),
   1400         );
   1401 
   1402         let mut proposal = knowledge_change_proposal();
   1403         proposal.target.relays = Some(vec![invalid_relay()]);
   1404         assert_validation_error(
   1405             validate_knowledge_change_proposal(&proposal),
   1406             KnowledgeValidationError::InvalidField("target"),
   1407         );
   1408 
   1409         let mut attestation = contribution_attestation();
   1410         attestation.subject_refs[0].relays = Some(vec![invalid_relay()]);
   1411         assert_validation_error(
   1412             validate_contribution_attestation(&attestation),
   1413             KnowledgeValidationError::InvalidField("subject_refs"),
   1414         );
   1415     }
   1416 
   1417     #[test]
   1418     fn wiki_article_title_is_optional_but_not_blank() {
   1419         let mut article = WikiArticle {
   1420             d_tag: "soil-health".to_string(),
   1421             title: None,
   1422             content_djot: "# Soil health".to_string(),
   1423             summary: None,
   1424             topics: Vec::new(),
   1425             references: Vec::new(),
   1426             forked_from: Vec::new(),
   1427             deferred_to: None,
   1428         };
   1429         assert_eq!(validate_wiki_article(&article), Ok(()));
   1430 
   1431         article.title = Some(" ".to_string());
   1432         assert_validation_error(
   1433             validate_wiki_article(&article),
   1434             KnowledgeValidationError::EmptyField("title"),
   1435         );
   1436     }
   1437 
   1438     #[test]
   1439     fn knowledge_claims_require_citations_except_exact_uncited_types() {
   1440         let mut claim = knowledge_claim();
   1441         claim.citation_spans.clear();
   1442         assert_validation_error(
   1443             validate_knowledge_claim(&claim),
   1444             KnowledgeValidationError::EmptyField("citation_spans"),
   1445         );
   1446 
   1447         claim.citation_spans.push(citation_span());
   1448         assert_eq!(validate_knowledge_claim(&claim), Ok(()));
   1449 
   1450         for claim_type in ["hypothesis", "observation", "question"] {
   1451             let mut uncited = knowledge_claim();
   1452             uncited.claim_type = claim_type.to_string();
   1453             uncited.citation_spans.clear();
   1454             assert_eq!(validate_knowledge_claim(&uncited), Ok(()));
   1455             assert!(is_uncited_knowledge_claim_type(claim_type));
   1456         }
   1457 
   1458         let mut capitalized = knowledge_claim();
   1459         capitalized.claim_type = "Hypothesis".to_string();
   1460         capitalized.citation_spans.clear();
   1461         assert_validation_error(
   1462             validate_knowledge_claim(&capitalized),
   1463             KnowledgeValidationError::EmptyField("citation_spans"),
   1464         );
   1465         assert!(!is_uncited_knowledge_claim_type("Hypothesis"));
   1466     }
   1467 
   1468     #[test]
   1469     fn knowledge_validators_reject_representative_invalid_models() {
   1470         let mut article = WikiArticle {
   1471             d_tag: "soil-health".to_string(),
   1472             title: Some("Soil health".to_string()),
   1473             content_djot: " ".to_string(),
   1474             summary: None,
   1475             topics: Vec::new(),
   1476             references: Vec::new(),
   1477             forked_from: Vec::new(),
   1478             deferred_to: None,
   1479         };
   1480         assert_validation_error(
   1481             validate_wiki_article(&article),
   1482             KnowledgeValidationError::EmptyField("content_djot"),
   1483         );
   1484         article.content_djot = "# Soil health".to_string();
   1485         article.forked_from.push(WikiArticleVersionRef {
   1486             event_id: "bad".to_string(),
   1487             address_ref: article_address_ref(),
   1488         });
   1489         assert_validation_error(
   1490             validate_wiki_article(&article),
   1491             KnowledgeValidationError::InvalidField("forked_from"),
   1492         );
   1493 
   1494         let mut redirect = WikiRedirect {
   1495             d_tag: "soil".to_string(),
   1496             target: article_address_ref(),
   1497         };
   1498         redirect.target.kind = 30023;
   1499         assert_validation_error(
   1500             validate_wiki_redirect(&redirect),
   1501             KnowledgeValidationError::InvalidField("wiki_redirect.target"),
   1502         );
   1503 
   1504         let mut merge = WikiMergeRequest {
   1505             target_article: article_address_ref(),
   1506             destination_pubkey: "bad".to_string(),
   1507             base_version_event_id: None,
   1508             source_version_event_id: hex_64('f'),
   1509             explanation: None,
   1510         };
   1511         assert_validation_error(
   1512             validate_wiki_merge_request(&merge),
   1513             KnowledgeValidationError::InvalidField("destination_pubkey"),
   1514         );
   1515         merge.destination_pubkey = hex_64('a');
   1516         merge.source_version_event_id = String::new();
   1517         assert_validation_error(
   1518             validate_wiki_merge_request(&merge),
   1519             KnowledgeValidationError::EmptyField("source_version_event_id"),
   1520         );
   1521 
   1522         let mut source = knowledge_source();
   1523         source.authors.push(" ".to_string());
   1524         assert_validation_error(
   1525             validate_knowledge_source(&source),
   1526             KnowledgeValidationError::EmptyField("authors"),
   1527         );
   1528 
   1529         let mut claim = knowledge_claim();
   1530         claim.citation_spans[0].quote_hash = Some("bad".to_string());
   1531         assert_validation_error(
   1532             validate_knowledge_claim(&claim),
   1533             KnowledgeValidationError::InvalidField("citation_spans"),
   1534         );
   1535 
   1536         let mut relation = knowledge_relation();
   1537         relation.subject.external_id = Some("external".to_string());
   1538         assert_validation_error(
   1539             validate_knowledge_relation(&relation),
   1540             KnowledgeValidationError::InvalidField("subject"),
   1541         );
   1542 
   1543         let mut review = knowledge_review();
   1544         review.target.kind = 0;
   1545         assert_validation_error(
   1546             validate_knowledge_review(&review),
   1547             KnowledgeValidationError::InvalidField("review_target"),
   1548         );
   1549 
   1550         let mut report = field_report();
   1551         report.context.location_precision = KnowledgeLocationPrecision::ExactPrivateReference;
   1552         assert_validation_error(
   1553             validate_knowledge_field_report(&report),
   1554             KnowledgeValidationError::EmptyField("private_location_ref"),
   1555         );
   1556         report.context.location_precision = KnowledgeLocationPrecision::CoarseGeohash;
   1557         report.observations[0].values[0].value = String::new();
   1558         assert_validation_error(
   1559             validate_knowledge_field_report(&report),
   1560             KnowledgeValidationError::EmptyField("observation_values"),
   1561         );
   1562 
   1563         let mut bounty = evidence_bounty();
   1564         bounty.target_refs.clear();
   1565         assert_validation_error(
   1566             validate_evidence_bounty(&bounty),
   1567             KnowledgeValidationError::EmptyField("target_refs"),
   1568         );
   1569 
   1570         let mut proposal = knowledge_change_proposal();
   1571         proposal.summary = " ".to_string();
   1572         assert_validation_error(
   1573             validate_knowledge_change_proposal(&proposal),
   1574             KnowledgeValidationError::EmptyField("summary"),
   1575         );
   1576 
   1577         let mut attestation = contribution_attestation();
   1578         attestation.contributor_pubkey = "bad".to_string();
   1579         assert_validation_error(
   1580             validate_contribution_attestation(&attestation),
   1581             KnowledgeValidationError::InvalidField("contributor_pubkey"),
   1582         );
   1583     }
   1584 }