commit eb9246e86c9a72afde35ecb2fd994572250fcf07
parent cfd552f8b23a9cef8f70a865aa2f07b1216c5563
Author: triesap <tyson@radroots.org>
Date: Sun, 5 Jul 2026 19:33:03 +0000
events: validate knowledge event semantics
- Add shared pure validators for wiki, MVP knowledge, and beta knowledge event models.
- Route knowledge codec encode and decode through the shared validation API with stable existing error mappings.
- Add model-level and codec-level semantic rejection coverage for valid and malformed knowledge payloads.
- Validation: cargo extbuild run -- cargo fmt --all -- --check; cargo extbuild run -- cargo check -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo check -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54.
Diffstat:
4 files changed, 1079 insertions(+), 138 deletions(-)
diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs
@@ -6,6 +6,8 @@ use alloc::{string::String, vec::Vec};
use core::fmt;
use crate::RadrootsNostrEventRef;
+use crate::ids::{RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey};
+use crate::kinds::KIND_WIKI_ARTICLE;
pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1;
pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512;
@@ -101,6 +103,414 @@ pub fn validate_wiki_d_tag(value: &str) -> Result<(), RadrootsWikiDTagError> {
Ok(())
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsKnowledgeValidationError {
+ EmptyField(&'static str),
+ InvalidField(&'static str),
+}
+
+impl RadrootsKnowledgeValidationError {
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::EmptyField(_) => "empty_field",
+ Self::InvalidField(_) => "invalid_field",
+ }
+ }
+
+ pub const fn field(self) -> &'static str {
+ match self {
+ Self::EmptyField(field) | Self::InvalidField(field) => field,
+ }
+ }
+}
+
+impl fmt::Display for RadrootsKnowledgeValidationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::EmptyField(field) => write!(formatter, "empty knowledge field: {field}"),
+ Self::InvalidField(field) => write!(formatter, "invalid knowledge field: {field}"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsKnowledgeValidationError {}
+
+fn require_non_empty(
+ value: &str,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if value.trim().is_empty() {
+ Err(RadrootsKnowledgeValidationError::EmptyField(field))
+ } else {
+ Ok(())
+ }
+}
+
+fn require_schema(
+ schema: &str,
+ schema_version: u16,
+ expected_schema: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if schema != expected_schema {
+ return Err(RadrootsKnowledgeValidationError::InvalidField("schema"));
+ }
+ if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(
+ "schema_version",
+ ));
+ }
+ Ok(())
+}
+
+fn validate_event_id(
+ value: &str,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if value.trim().is_empty() {
+ return Err(RadrootsKnowledgeValidationError::EmptyField(field));
+ }
+ RadrootsEventId::parse(value)
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))
+}
+
+fn validate_pubkey(
+ value: &str,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if value.trim().is_empty() {
+ return Err(RadrootsKnowledgeValidationError::EmptyField(field));
+ }
+ RadrootsPublicKey::parse(value)
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))
+}
+
+fn validate_relays(
+ relays: &[String],
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ for relay in relays {
+ if relay.is_empty()
+ || relay.trim() != relay
+ || relay.chars().any(|character| character.is_control())
+ {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(field));
+ }
+ }
+ Ok(())
+}
+
+fn validate_event_ref(
+ event_ref: &RadrootsNostrEventRef,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ validate_event_id(&event_ref.id, field)?;
+ validate_pubkey(&event_ref.author, field)?;
+ if let Some(d_tag) = event_ref.d_tag.as_deref() {
+ RadrootsDTag::parse(d_tag)
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?;
+ }
+ if let Some(relays) = event_ref.relays.as_deref() {
+ validate_relays(relays, field)?;
+ }
+ Ok(())
+}
+
+fn validate_event_refs(
+ refs: &[RadrootsNostrEventRef],
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ for event_ref in refs {
+ validate_event_ref(event_ref, field)?;
+ }
+ Ok(())
+}
+
+fn validate_address_ref(
+ address: &RadrootsAddressableRef,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if address.kind == 0 {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(field));
+ }
+ validate_pubkey(&address.pubkey, field)?;
+ RadrootsDTag::parse(address.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?;
+ validate_relays(&address.relays, field)
+}
+
+fn validate_wiki_article_address_ref(
+ address: &RadrootsAddressableRef,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ if address.kind != KIND_WIKI_ARTICLE {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(field));
+ }
+ validate_pubkey(&address.pubkey, field)?;
+ validate_wiki_d_tag(address.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField(field))?;
+ validate_relays(&address.relays, field)
+}
+
+fn validate_wiki_article_version_ref(
+ version_ref: &RadrootsWikiArticleVersionRef,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ validate_event_id(&version_ref.event_id, field)?;
+ validate_wiki_article_address_ref(&version_ref.address_ref, field)
+}
+
+fn validate_node_ref(
+ node_ref: &RadrootsKnowledgeNodeRef,
+ field: &'static str,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ let mut populated = 0u8;
+ if let Some(event_ref) = &node_ref.event_ref {
+ populated += 1;
+ validate_event_ref(event_ref, field)?;
+ }
+ if let Some(address_ref) = &node_ref.address_ref {
+ populated += 1;
+ validate_address_ref(address_ref, field)?;
+ }
+ if let Some(external_id) = node_ref.external_id.as_deref() {
+ populated += 1;
+ require_non_empty(external_id, field)?;
+ }
+ if populated == 1 {
+ Ok(())
+ } else {
+ Err(RadrootsKnowledgeValidationError::InvalidField(field))
+ }
+}
+
+pub fn validate_wiki_article(
+ article: &RadrootsWikiArticle,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ validate_wiki_d_tag(article.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?;
+ require_non_empty(article.title.as_str(), "title")?;
+ require_non_empty(article.content_djot.as_str(), "content_djot")?;
+ validate_event_refs(&article.references, "references")?;
+ for version_ref in &article.forked_from {
+ validate_wiki_article_version_ref(version_ref, "forked_from")?;
+ }
+ if let Some(version_ref) = &article.deferred_to {
+ validate_wiki_article_version_ref(version_ref, "deferred_to")?;
+ }
+ Ok(())
+}
+
+pub fn validate_wiki_redirect(
+ redirect: &RadrootsWikiRedirect,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ validate_wiki_d_tag(redirect.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?;
+ validate_wiki_article_address_ref(&redirect.target, "wiki_redirect.target")
+}
+
+pub fn validate_wiki_merge_request(
+ request: &RadrootsWikiMergeRequest,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ validate_wiki_article_address_ref(&request.target_article, "target_article")?;
+ validate_pubkey(&request.destination_pubkey, "destination_pubkey")?;
+ if let Some(base) = request.base_version_event_id.as_deref() {
+ validate_event_id(base, "base_version_event_id")?;
+ }
+ validate_event_id(
+ request.source_version_event_id.as_str(),
+ "source_version_event_id",
+ )
+}
+
+pub fn validate_knowledge_source(
+ source: &RadrootsKnowledgeSource,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ source.schema.as_str(),
+ source.schema_version,
+ RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
+ )?;
+ validate_wiki_d_tag(source.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?;
+ require_non_empty(source.title.as_str(), "title")?;
+ require_non_empty(source.source_type.as_str(), "source_type")?;
+ for author in &source.authors {
+ require_non_empty(author.as_str(), "authors")?;
+ }
+ validate_event_refs(&source.artifact_refs, "artifact_refs")
+}
+
+pub fn validate_knowledge_claim(
+ claim: &RadrootsKnowledgeClaim,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ claim.schema.as_str(),
+ claim.schema_version,
+ RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
+ )?;
+ require_non_empty(claim.claim_type.as_str(), "claim_type")?;
+ require_non_empty(claim.text.as_str(), "text")?;
+ for citation in &claim.citation_spans {
+ if citation.source_ref.kind != crate::kinds::KIND_KNOWLEDGE_SOURCE {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(
+ "citation_spans",
+ ));
+ }
+ validate_event_ref(&citation.source_ref, "citation_spans")?;
+ if let Some(artifact_ref) = &citation.artifact_ref {
+ validate_event_ref(artifact_ref, "citation_spans")?;
+ }
+ if let Some(quote_hash) = citation.quote_hash.as_deref() {
+ validate_event_id(quote_hash, "citation_spans")?;
+ }
+ }
+ validate_event_refs(&claim.supersedes, "supersedes")
+}
+
+pub fn validate_knowledge_relation(
+ relation: &RadrootsKnowledgeRelation,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ relation.schema.as_str(),
+ relation.schema_version,
+ RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
+ )?;
+ validate_node_ref(&relation.subject, "subject")?;
+ require_non_empty(relation.predicate.as_str(), "predicate")?;
+ validate_node_ref(&relation.object, "object")?;
+ validate_event_refs(&relation.support_refs, "support_refs")?;
+ validate_event_refs(&relation.supersedes, "supersedes")
+}
+
+pub fn validate_knowledge_review(
+ review: &RadrootsKnowledgeReview,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ review.schema.as_str(),
+ review.schema_version,
+ RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
+ )?;
+ validate_event_id(review.target.event_id.as_str(), "review_target")?;
+ validate_pubkey(review.target.author_pubkey.as_str(), "review_target")?;
+ if review.target.kind == 0 {
+ return Err(RadrootsKnowledgeValidationError::InvalidField(
+ "review_target",
+ ));
+ }
+ if let Some(address) = review.target.address.as_deref() {
+ RadrootsAddressableCoordinate::parse(address)
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("review_target"))?;
+ }
+ validate_relays(&review.target.relays, "review_target")?;
+ require_non_empty(review.reviewer_role.as_str(), "reviewer_role")?;
+ require_non_empty(review.verdict.as_str(), "verdict")?;
+ for score in &review.scores {
+ require_non_empty(score.dimension.as_str(), "scores")?;
+ require_non_empty(score.value.as_str(), "scores")?;
+ }
+ validate_event_refs(&review.evidence_refs, "evidence_refs")
+}
+
+pub fn validate_knowledge_field_report(
+ report: &RadrootsKnowledgeFieldReport,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ report.schema.as_str(),
+ report.schema_version,
+ RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
+ )?;
+ require_non_empty(report.report_type.as_str(), "report_type")?;
+ require_non_empty(report.title.as_str(), "title")?;
+ if report.context.location_precision
+ == RadrootsKnowledgeLocationPrecision::ExactPrivateReference
+ && report.context.private_location_ref.is_none()
+ {
+ return Err(RadrootsKnowledgeValidationError::EmptyField(
+ "private_location_ref",
+ ));
+ }
+ if let Some(private_location_ref) = &report.context.private_location_ref {
+ validate_event_ref(private_location_ref, "private_location_ref")?;
+ }
+ if report.observations.is_empty() {
+ return Err(RadrootsKnowledgeValidationError::EmptyField("observations"));
+ }
+ for observation in &report.observations {
+ require_non_empty(observation.observation_type.as_str(), "observations")?;
+ require_non_empty(observation.text.as_str(), "observations")?;
+ for value in &observation.values {
+ require_non_empty(value.key.as_str(), "observation_values")?;
+ require_non_empty(value.value.as_str(), "observation_values")?;
+ }
+ }
+ validate_event_refs(&report.artifact_refs, "artifact_refs")?;
+ validate_event_refs(&report.related_refs, "related_refs")
+}
+
+pub fn validate_evidence_bounty(
+ bounty: &RadrootsEvidenceBounty,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ bounty.schema.as_str(),
+ bounty.schema_version,
+ RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
+ )?;
+ validate_wiki_d_tag(bounty.d_tag.as_str())
+ .map(|_| ())
+ .map_err(|_| RadrootsKnowledgeValidationError::InvalidField("d_tag"))?;
+ require_non_empty(bounty.title.as_str(), "title")?;
+ if bounty.target_refs.is_empty() {
+ return Err(RadrootsKnowledgeValidationError::EmptyField("target_refs"));
+ }
+ validate_event_refs(&bounty.target_refs, "target_refs")
+}
+
+pub fn validate_knowledge_change_proposal(
+ proposal: &RadrootsKnowledgeChangeProposal,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ proposal.schema.as_str(),
+ proposal.schema_version,
+ RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
+ )?;
+ validate_event_ref(&proposal.target, "target")?;
+ require_non_empty(proposal.proposal_type.as_str(), "proposal_type")?;
+ require_non_empty(proposal.summary.as_str(), "summary")?;
+ validate_event_refs(&proposal.evidence_refs, "evidence_refs")?;
+ validate_event_refs(&proposal.supersedes, "supersedes")
+}
+
+pub fn validate_contribution_attestation(
+ attestation: &RadrootsContributionAttestation,
+) -> Result<(), RadrootsKnowledgeValidationError> {
+ require_schema(
+ attestation.schema.as_str(),
+ attestation.schema_version,
+ RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
+ )?;
+ validate_pubkey(
+ attestation.contributor_pubkey.as_str(),
+ "contributor_pubkey",
+ )?;
+ require_non_empty(attestation.contribution_type.as_str(), "contribution_type")?;
+ require_non_empty(attestation.summary.as_str(), "summary")?;
+ if attestation.subject_refs.is_empty() {
+ return Err(RadrootsKnowledgeValidationError::EmptyField("subject_refs"));
+ }
+ validate_event_refs(&attestation.subject_refs, "subject_refs")?;
+ validate_event_refs(&attestation.evidence_refs, "evidence_refs")
+}
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "dto-bindgen", dto(export))]
@@ -419,16 +829,208 @@ pub struct RadrootsContributionAttestation {
mod tests {
use super::*;
+ fn hex_64(character: char) -> String {
+ core::iter::repeat_n(character, 64).collect()
+ }
+
fn event_ref() -> RadrootsNostrEventRef {
+ event_ref_with_kind(1)
+ }
+
+ fn event_ref_with_kind(kind: u32) -> RadrootsNostrEventRef {
RadrootsNostrEventRef {
id: "0".repeat(64),
author: "1".repeat(64),
- kind: 1,
+ kind,
d_tag: None,
relays: None,
}
}
+ fn article_address_ref() -> RadrootsAddressableRef {
+ RadrootsAddressableRef {
+ kind: KIND_WIKI_ARTICLE,
+ pubkey: hex_64('a'),
+ d_tag: "soil-health".to_string(),
+ relays: Vec::new(),
+ }
+ }
+
+ fn article_version_ref() -> RadrootsWikiArticleVersionRef {
+ RadrootsWikiArticleVersionRef {
+ event_id: hex_64('b'),
+ address_ref: article_address_ref(),
+ }
+ }
+
+ fn knowledge_source() -> RadrootsKnowledgeSource {
+ RadrootsKnowledgeSource {
+ schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ d_tag: "soil-source".to_string(),
+ title: "Soil Source".to_string(),
+ source_type: "book".to_string(),
+ authors: vec!["Ada Example".to_string()],
+ publisher: None,
+ publication_year: Some(2026),
+ edition: None,
+ canonical_url: None,
+ artifact_refs: vec![event_ref()],
+ author_asserted_rights: None,
+ topics: vec!["soil".to_string()],
+ summary: None,
+ }
+ }
+
+ fn citation_span() -> RadrootsKnowledgeCitationSpan {
+ RadrootsKnowledgeCitationSpan {
+ source_ref: event_ref_with_kind(crate::kinds::KIND_KNOWLEDGE_SOURCE),
+ artifact_ref: Some(event_ref()),
+ page_start: None,
+ page_end: None,
+ section_path: Vec::new(),
+ quote_hash: Some(hex_64('c')),
+ chunk_id: None,
+ }
+ }
+
+ fn knowledge_claim() -> RadrootsKnowledgeClaim {
+ RadrootsKnowledgeClaim {
+ schema: RADROOTS_KNOWLEDGE_CLAIM_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ claim_type: "practice_effect".to_string(),
+ text: "Cover crops improve soil structure.".to_string(),
+ citation_spans: vec![citation_span()],
+ topics: Vec::new(),
+ applies_to: Vec::new(),
+ author_asserted_confidence: None,
+ supersedes: vec![event_ref()],
+ }
+ }
+
+ fn node_ref() -> RadrootsKnowledgeNodeRef {
+ RadrootsKnowledgeNodeRef {
+ node_type: "event".to_string(),
+ event_ref: Some(event_ref()),
+ address_ref: None,
+ external_id: None,
+ label: None,
+ }
+ }
+
+ fn knowledge_relation() -> RadrootsKnowledgeRelation {
+ RadrootsKnowledgeRelation {
+ schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ subject: node_ref(),
+ predicate: "supports".to_string(),
+ object: node_ref(),
+ support_refs: vec![event_ref()],
+ author_asserted_confidence: None,
+ supersedes: Vec::new(),
+ }
+ }
+
+ fn knowledge_review() -> RadrootsKnowledgeReview {
+ RadrootsKnowledgeReview {
+ schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ target: RadrootsKnowledgeReviewTarget {
+ event_id: hex_64('d'),
+ author_pubkey: hex_64('a'),
+ kind: crate::kinds::KIND_KNOWLEDGE_CLAIM,
+ address: None,
+ relays: Vec::new(),
+ review_scope: RadrootsKnowledgeReviewScope::SpecificVersion,
+ },
+ reviewer_role: "peer".to_string(),
+ verdict: "needs_more_evidence".to_string(),
+ scores: vec![RadrootsKnowledgeReviewScore {
+ dimension: "evidence".to_string(),
+ value: "partial".to_string(),
+ note: None,
+ }],
+ notes: None,
+ evidence_refs: vec![event_ref()],
+ }
+ }
+
+ fn field_report() -> RadrootsKnowledgeFieldReport {
+ RadrootsKnowledgeFieldReport {
+ schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ report_type: "observation".to_string(),
+ title: "Field observation".to_string(),
+ summary: None,
+ context: RadrootsKnowledgeFieldContext {
+ location_precision: RadrootsKnowledgeLocationPrecision::CoarseGeohash,
+ public_location: None,
+ private_location_ref: None,
+ topics: Vec::new(),
+ context_tags: Vec::new(),
+ },
+ observations: vec![RadrootsKnowledgeObservation {
+ observation_type: "residue".to_string(),
+ text: "Residue was visible.".to_string(),
+ observed_at: None,
+ values: vec![RadrootsKnowledgeObservationValue {
+ key: "coverage".to_string(),
+ value: "medium".to_string(),
+ unit: None,
+ }],
+ }],
+ artifact_refs: vec![event_ref()],
+ related_refs: vec![event_ref()],
+ limitations: Vec::new(),
+ }
+ }
+
+ fn evidence_bounty() -> RadrootsEvidenceBounty {
+ RadrootsEvidenceBounty {
+ schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ d_tag: "soil-bounty".to_string(),
+ title: "Soil bounty".to_string(),
+ summary: None,
+ topics: Vec::new(),
+ target_refs: vec![event_ref()],
+ reward_note: None,
+ closes_at: None,
+ }
+ }
+
+ fn knowledge_change_proposal() -> RadrootsKnowledgeChangeProposal {
+ RadrootsKnowledgeChangeProposal {
+ schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ target: event_ref(),
+ proposal_type: "amend".to_string(),
+ summary: "Clarify scope".to_string(),
+ rationale: None,
+ evidence_refs: vec![event_ref()],
+ supersedes: Vec::new(),
+ }
+ }
+
+ fn contribution_attestation() -> RadrootsContributionAttestation {
+ RadrootsContributionAttestation {
+ schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ contributor_pubkey: hex_64('a'),
+ contribution_type: "review".to_string(),
+ subject_refs: vec![event_ref()],
+ summary: "Reviewed claim".to_string(),
+ evidence_refs: vec![event_ref()],
+ }
+ }
+
+ fn assert_validation_error(
+ result: Result<(), RadrootsKnowledgeValidationError>,
+ expected: RadrootsKnowledgeValidationError,
+ ) {
+ assert_eq!(result, Err(expected));
+ }
+
#[test]
fn normalizes_wiki_d_tags() {
assert_eq!(
@@ -599,4 +1201,168 @@ mod tests {
Some("c23".to_string())
);
}
+
+ #[test]
+ fn knowledge_validators_accept_valid_models() {
+ let article = RadrootsWikiArticle {
+ d_tag: "soil-health".to_string(),
+ title: "Soil health".to_string(),
+ content_djot: "# Soil health".to_string(),
+ summary: None,
+ topics: Vec::new(),
+ references: vec![event_ref()],
+ forked_from: vec![article_version_ref()],
+ deferred_to: Some(article_version_ref()),
+ };
+ assert_eq!(validate_wiki_article(&article), Ok(()));
+ assert_eq!(
+ validate_wiki_redirect(&RadrootsWikiRedirect {
+ d_tag: "soil".to_string(),
+ target: article_address_ref(),
+ }),
+ Ok(())
+ );
+ assert_eq!(
+ validate_wiki_merge_request(&RadrootsWikiMergeRequest {
+ target_article: article_address_ref(),
+ destination_pubkey: hex_64('a'),
+ base_version_event_id: Some(hex_64('e')),
+ source_version_event_id: hex_64('f'),
+ explanation: None,
+ }),
+ Ok(())
+ );
+ assert_eq!(validate_knowledge_source(&knowledge_source()), Ok(()));
+ assert_eq!(validate_knowledge_claim(&knowledge_claim()), Ok(()));
+ assert_eq!(validate_knowledge_relation(&knowledge_relation()), Ok(()));
+ assert_eq!(validate_knowledge_review(&knowledge_review()), Ok(()));
+ assert_eq!(validate_knowledge_field_report(&field_report()), Ok(()));
+ assert_eq!(validate_evidence_bounty(&evidence_bounty()), Ok(()));
+ assert_eq!(
+ validate_knowledge_change_proposal(&knowledge_change_proposal()),
+ Ok(())
+ );
+ assert_eq!(
+ validate_contribution_attestation(&contribution_attestation()),
+ Ok(())
+ );
+ }
+
+ #[test]
+ fn knowledge_validators_reject_representative_invalid_models() {
+ let mut article = RadrootsWikiArticle {
+ d_tag: "soil-health".to_string(),
+ title: "Soil health".to_string(),
+ content_djot: " ".to_string(),
+ summary: None,
+ topics: Vec::new(),
+ references: Vec::new(),
+ forked_from: Vec::new(),
+ deferred_to: None,
+ };
+ assert_validation_error(
+ validate_wiki_article(&article),
+ RadrootsKnowledgeValidationError::EmptyField("content_djot"),
+ );
+ article.content_djot = "# Soil health".to_string();
+ article.forked_from.push(RadrootsWikiArticleVersionRef {
+ event_id: "bad".to_string(),
+ address_ref: article_address_ref(),
+ });
+ assert_validation_error(
+ validate_wiki_article(&article),
+ RadrootsKnowledgeValidationError::InvalidField("forked_from"),
+ );
+
+ let mut redirect = RadrootsWikiRedirect {
+ d_tag: "soil".to_string(),
+ target: article_address_ref(),
+ };
+ redirect.target.kind = 30023;
+ assert_validation_error(
+ validate_wiki_redirect(&redirect),
+ RadrootsKnowledgeValidationError::InvalidField("wiki_redirect.target"),
+ );
+
+ let mut merge = RadrootsWikiMergeRequest {
+ target_article: article_address_ref(),
+ destination_pubkey: "bad".to_string(),
+ base_version_event_id: None,
+ source_version_event_id: hex_64('f'),
+ explanation: None,
+ };
+ assert_validation_error(
+ validate_wiki_merge_request(&merge),
+ RadrootsKnowledgeValidationError::InvalidField("destination_pubkey"),
+ );
+ merge.destination_pubkey = hex_64('a');
+ merge.source_version_event_id = String::new();
+ assert_validation_error(
+ validate_wiki_merge_request(&merge),
+ RadrootsKnowledgeValidationError::EmptyField("source_version_event_id"),
+ );
+
+ let mut source = knowledge_source();
+ source.authors.push(" ".to_string());
+ assert_validation_error(
+ validate_knowledge_source(&source),
+ RadrootsKnowledgeValidationError::EmptyField("authors"),
+ );
+
+ let mut claim = knowledge_claim();
+ claim.citation_spans[0].quote_hash = Some("bad".to_string());
+ assert_validation_error(
+ validate_knowledge_claim(&claim),
+ RadrootsKnowledgeValidationError::InvalidField("citation_spans"),
+ );
+
+ let mut relation = knowledge_relation();
+ relation.subject.external_id = Some("external".to_string());
+ assert_validation_error(
+ validate_knowledge_relation(&relation),
+ RadrootsKnowledgeValidationError::InvalidField("subject"),
+ );
+
+ let mut review = knowledge_review();
+ review.target.kind = 0;
+ assert_validation_error(
+ validate_knowledge_review(&review),
+ RadrootsKnowledgeValidationError::InvalidField("review_target"),
+ );
+
+ let mut report = field_report();
+ report.context.location_precision =
+ RadrootsKnowledgeLocationPrecision::ExactPrivateReference;
+ assert_validation_error(
+ validate_knowledge_field_report(&report),
+ RadrootsKnowledgeValidationError::EmptyField("private_location_ref"),
+ );
+ report.context.location_precision = RadrootsKnowledgeLocationPrecision::CoarseGeohash;
+ report.observations[0].values[0].value = String::new();
+ assert_validation_error(
+ validate_knowledge_field_report(&report),
+ RadrootsKnowledgeValidationError::EmptyField("observation_values"),
+ );
+
+ let mut bounty = evidence_bounty();
+ bounty.target_refs.clear();
+ assert_validation_error(
+ validate_evidence_bounty(&bounty),
+ RadrootsKnowledgeValidationError::EmptyField("target_refs"),
+ );
+
+ let mut proposal = knowledge_change_proposal();
+ proposal.summary = " ".to_string();
+ assert_validation_error(
+ validate_knowledge_change_proposal(&proposal),
+ RadrootsKnowledgeValidationError::EmptyField("summary"),
+ );
+
+ let mut attestation = contribution_attestation();
+ attestation.contributor_pubkey = "bad".to_string();
+ assert_validation_error(
+ validate_contribution_attestation(&attestation),
+ RadrootsKnowledgeValidationError::InvalidField("contributor_pubkey"),
+ );
+ }
}
diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs
@@ -14,12 +14,16 @@ use radroots_events::knowledge::{
RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
- RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
- RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation,
- RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim,
- RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview,
- RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiArticleVersionRef,
- RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag,
+ RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef,
+ RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal,
+ RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation,
+ RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsKnowledgeValidationError,
+ RadrootsWikiArticle, RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest,
+ RadrootsWikiRedirect, validate_contribution_attestation, validate_evidence_bounty,
+ validate_knowledge_change_proposal, validate_knowledge_claim, validate_knowledge_field_report,
+ validate_knowledge_relation, validate_knowledge_review, validate_knowledge_source,
+ validate_wiki_article, validate_wiki_d_tag, validate_wiki_merge_request,
+ validate_wiki_redirect,
};
use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_P, TAG_SUMMARY, TAG_T};
use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef};
@@ -264,18 +268,17 @@ fn require_contract_tag(
}
}
-fn require_schema(
- schema: &str,
- schema_version: u16,
- expected: &'static str,
-) -> Result<(), EventParseError> {
- if schema != expected {
- return Err(EventParseError::InvalidJson("schema"));
- }
- if schema_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION {
- return Err(EventParseError::InvalidJson("schema_version"));
+fn parse_validation_error(error: RadrootsKnowledgeValidationError) -> EventParseError {
+ match error.field() {
+ "d_tag" => EventParseError::InvalidTag(TAG_D),
+ "references" => EventParseError::InvalidTag(TAG_SOURCE),
+ "forked_from" | "deferred_to" | "wiki_redirect.target" | "target_article" => {
+ EventParseError::InvalidTag(TAG_A)
+ }
+ "destination_pubkey" => EventParseError::InvalidTag(TAG_P),
+ "base_version_event_id" | "source_version_event_id" => EventParseError::InvalidTag(TAG_E),
+ field => EventParseError::InvalidJson(field),
}
- Ok(())
}
fn reject_private_coordinate_keys(content: &str) -> Result<(), EventParseError> {
@@ -334,6 +337,7 @@ pub fn wiki_article_from_event(
forked_from,
deferred_to,
};
+ validate_wiki_article(&article).map_err(parse_validation_error)?;
Ok(parsed(event, article))
}
@@ -350,7 +354,9 @@ pub fn wiki_redirect_from_event(
if target.kind != KIND_WIKI_ARTICLE {
return Err(EventParseError::InvalidTag(TAG_A));
}
- Ok(parsed(event, RadrootsWikiRedirect { d_tag, target }))
+ let redirect = RadrootsWikiRedirect { d_tag, target };
+ validate_wiki_redirect(&redirect).map_err(parse_validation_error)?;
+ Ok(parsed(event, redirect))
}
pub fn wiki_merge_request_from_event(
@@ -376,6 +382,7 @@ pub fn wiki_merge_request_from_event(
source_version_event_id,
explanation,
};
+ validate_wiki_merge_request(&request).map_err(parse_validation_error)?;
Ok(parsed(event, request))
}
@@ -385,15 +392,11 @@ pub fn knowledge_source_from_event(
ensure_kind(event.kind, KIND_KNOWLEDGE_SOURCE, "knowledge source")?;
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA)?;
let source: RadrootsKnowledgeSource = json_content(&event.content)?;
- require_schema(
- &source.schema,
- source.schema_version,
- RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
- )?;
let d_tag = required_one_value(&event.tags, TAG_D)?;
if d_tag != source.d_tag {
return Err(EventParseError::InvalidTag(TAG_D));
}
+ validate_knowledge_source(&source).map_err(parse_validation_error)?;
Ok(parsed(event, source))
}
@@ -403,15 +406,11 @@ pub fn evidence_bounty_from_event(
ensure_kind(event.kind, KIND_EVIDENCE_BOUNTY, "evidence bounty")?;
require_contract_tag(&event.tags, RADROOTS_EVIDENCE_BOUNTY_SCHEMA)?;
let bounty: RadrootsEvidenceBounty = json_content(&event.content)?;
- require_schema(
- &bounty.schema,
- bounty.schema_version,
- RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
- )?;
let d_tag = required_one_value(&event.tags, TAG_D)?;
if d_tag != bounty.d_tag {
return Err(EventParseError::InvalidTag(TAG_D));
}
+ validate_evidence_bounty(&bounty).map_err(parse_validation_error)?;
Ok(parsed(event, bounty))
}
@@ -421,11 +420,7 @@ pub fn knowledge_claim_from_event(
ensure_kind(event.kind, KIND_KNOWLEDGE_CLAIM, "knowledge claim")?;
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)?;
let claim: RadrootsKnowledgeClaim = json_content(&event.content)?;
- require_schema(
- &claim.schema,
- claim.schema_version,
- RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
- )?;
+ validate_knowledge_claim(&claim).map_err(parse_validation_error)?;
Ok(parsed(event, claim))
}
@@ -435,11 +430,7 @@ pub fn knowledge_relation_from_event(
ensure_kind(event.kind, KIND_KNOWLEDGE_RELATION, "knowledge relation")?;
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_RELATION_SCHEMA)?;
let relation: RadrootsKnowledgeRelation = json_content(&event.content)?;
- require_schema(
- &relation.schema,
- relation.schema_version,
- RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
- )?;
+ validate_knowledge_relation(&relation).map_err(parse_validation_error)?;
Ok(parsed(event, relation))
}
@@ -450,11 +441,7 @@ pub fn knowledge_review_from_event(
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_REVIEW_SCHEMA)?;
required_one_value(&event.tags, TAG_REVIEW_TARGET)?;
let review: RadrootsKnowledgeReview = json_content(&event.content)?;
- require_schema(
- &review.schema,
- review.schema_version,
- RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
- )?;
+ validate_knowledge_review(&review).map_err(parse_validation_error)?;
Ok(parsed(event, review))
}
@@ -469,11 +456,7 @@ pub fn knowledge_field_report_from_event(
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA)?;
reject_private_coordinate_keys(&event.content)?;
let report: RadrootsKnowledgeFieldReport = json_content(&event.content)?;
- require_schema(
- &report.schema,
- report.schema_version,
- RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
- )?;
+ validate_knowledge_field_report(&report).map_err(parse_validation_error)?;
Ok(parsed(event, report))
}
@@ -487,11 +470,7 @@ pub fn knowledge_change_proposal_from_event(
)?;
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA)?;
let proposal: RadrootsKnowledgeChangeProposal = json_content(&event.content)?;
- require_schema(
- &proposal.schema,
- proposal.schema_version,
- RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
- )?;
+ validate_knowledge_change_proposal(&proposal).map_err(parse_validation_error)?;
Ok(parsed(event, proposal))
}
@@ -505,10 +484,6 @@ pub fn contribution_attestation_from_event(
)?;
require_contract_tag(&event.tags, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA)?;
let attestation: RadrootsContributionAttestation = json_content(&event.content)?;
- require_schema(
- &attestation.schema,
- attestation.schema_version,
- RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
- )?;
+ validate_contribution_attestation(&attestation).map_err(parse_validation_error)?;
Ok(parsed(event, attestation))
}
diff --git a/crates/events_codec/src/knowledge/encode.rs b/crates/events_codec/src/knowledge/encode.rs
@@ -17,13 +17,16 @@ use radroots_events::knowledge::{
RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA, RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA, RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
- RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
- RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation,
- RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim,
- RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview,
- RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle,
- RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
- validate_wiki_d_tag,
+ RADROOTS_KNOWLEDGE_REVIEW_SCHEMA, RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef,
+ RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal,
+ RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation,
+ RadrootsKnowledgeReview, RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource,
+ RadrootsKnowledgeValidationError, RadrootsWikiArticle, RadrootsWikiArticleVersionRef,
+ RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_contribution_attestation,
+ validate_evidence_bounty, validate_knowledge_change_proposal, validate_knowledge_claim,
+ validate_knowledge_field_report, validate_knowledge_relation, validate_knowledge_review,
+ validate_knowledge_source, validate_wiki_article, validate_wiki_merge_request,
+ validate_wiki_redirect,
};
use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T};
use serde::Serialize;
@@ -136,18 +139,15 @@ fn json_content<T: Serialize>(value: &T) -> Result<String, EventEncodeError> {
serde_json::to_string(value).map_err(|_| EventEncodeError::Json)
}
-fn require_schema(
- actual_schema: &str,
- actual_version: u16,
- expected_schema: &'static str,
-) -> Result<(), EventEncodeError> {
- if actual_schema != expected_schema {
- return Err(EventEncodeError::InvalidField("schema"));
- }
- if actual_version != RADROOTS_KNOWLEDGE_SCHEMA_VERSION {
- return Err(EventEncodeError::InvalidField("schema_version"));
+fn encode_validation_error(error: RadrootsKnowledgeValidationError) -> EventEncodeError {
+ match error {
+ RadrootsKnowledgeValidationError::EmptyField(field) => {
+ EventEncodeError::EmptyRequiredField(field)
+ }
+ RadrootsKnowledgeValidationError::InvalidField(field) => {
+ EventEncodeError::InvalidField(field)
+ }
}
- Ok(())
}
fn custom_tags(contract_id: &'static str) -> Vec<Vec<String>> {
@@ -157,10 +157,7 @@ fn custom_tags(contract_id: &'static str) -> Vec<Vec<String>> {
pub fn wiki_article_build_tags(
article: &RadrootsWikiArticle,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_wiki_d_tag(&article.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?;
- if article.title.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("title"));
- }
+ validate_wiki_article(article).map_err(encode_validation_error)?;
let mut tags = Vec::new();
tags.push(vec![TAG_D.to_string(), article.d_tag.clone()]);
push_value(&mut tags, TAG_TITLE, &article.title);
@@ -189,7 +186,7 @@ pub fn wiki_article_to_wire_parts(
pub fn wiki_redirect_build_tags(
redirect: &RadrootsWikiRedirect,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_wiki_d_tag(&redirect.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?;
+ validate_wiki_redirect(redirect).map_err(encode_validation_error)?;
let mut tags = Vec::new();
tags.push(vec![TAG_D.to_string(), redirect.d_tag.clone()]);
tags.push(address_tag(TAG_A, &redirect.target));
@@ -209,14 +206,7 @@ pub fn wiki_redirect_to_wire_parts(
pub fn wiki_merge_request_build_tags(
request: &RadrootsWikiMergeRequest,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- if request.destination_pubkey.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("destination_pubkey"));
- }
- if request.source_version_event_id.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField(
- "source_version_event_id",
- ));
- }
+ validate_wiki_merge_request(request).map_err(encode_validation_error)?;
let mut tags = Vec::new();
tags.push(address_tag(TAG_A, &request.target_article));
tags.push(vec![TAG_P.to_string(), request.destination_pubkey.clone()]);
@@ -249,14 +239,7 @@ pub fn wiki_merge_request_to_wire_parts(
pub fn knowledge_source_build_tags(
source: &RadrootsKnowledgeSource,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &source.schema,
- source.schema_version,
- RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
- )?;
- if source.d_tag.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("d_tag"));
- }
+ validate_knowledge_source(source).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_SOURCE_SCHEMA);
tags.push(vec![TAG_D.to_string(), source.d_tag.clone()]);
push_topics(&mut tags, &source.topics);
@@ -277,14 +260,7 @@ pub fn knowledge_source_to_wire_parts(
pub fn evidence_bounty_build_tags(
bounty: &RadrootsEvidenceBounty,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &bounty.schema,
- bounty.schema_version,
- RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
- )?;
- if bounty.d_tag.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("d_tag"));
- }
+ validate_evidence_bounty(bounty).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_EVIDENCE_BOUNTY_SCHEMA);
tags.push(vec![TAG_D.to_string(), bounty.d_tag.clone()]);
push_topics(&mut tags, &bounty.topics);
@@ -305,11 +281,7 @@ pub fn evidence_bounty_to_wire_parts(
pub fn knowledge_claim_build_tags(
claim: &RadrootsKnowledgeClaim,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &claim.schema,
- claim.schema_version,
- RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
- )?;
+ validate_knowledge_claim(claim).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CLAIM_SCHEMA);
push_topics(&mut tags, &claim.topics);
for citation in &claim.citation_spans {
@@ -334,11 +306,7 @@ pub fn knowledge_claim_to_wire_parts(
pub fn knowledge_relation_build_tags(
relation: &RadrootsKnowledgeRelation,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &relation.schema,
- relation.schema_version,
- RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
- )?;
+ validate_knowledge_relation(relation).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_RELATION_SCHEMA);
push_event_refs(&mut tags, TAG_SOURCE, &relation.support_refs);
Ok(tags)
@@ -357,11 +325,7 @@ pub fn knowledge_relation_to_wire_parts(
pub fn knowledge_review_build_tags(
review: &RadrootsKnowledgeReview,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &review.schema,
- review.schema_version,
- RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
- )?;
+ validate_knowledge_review(review).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_REVIEW_SCHEMA);
tags.push(build_event_ref_tag(
TAG_REVIEW_TARGET,
@@ -384,11 +348,7 @@ pub fn knowledge_review_to_wire_parts(
pub fn knowledge_field_report_build_tags(
report: &RadrootsKnowledgeFieldReport,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &report.schema,
- report.schema_version,
- RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
- )?;
+ validate_knowledge_field_report(report).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA);
push_topics(&mut tags, &report.context.topics);
if let Some(location) = &report.context.public_location
@@ -414,11 +374,7 @@ pub fn knowledge_field_report_to_wire_parts(
pub fn knowledge_change_proposal_build_tags(
proposal: &RadrootsKnowledgeChangeProposal,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &proposal.schema,
- proposal.schema_version,
- RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
- )?;
+ validate_knowledge_change_proposal(proposal).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA);
push_event_refs(&mut tags, TAG_EVIDENCE, &proposal.evidence_refs);
Ok(tags)
@@ -437,11 +393,7 @@ pub fn knowledge_change_proposal_to_wire_parts(
pub fn contribution_attestation_build_tags(
attestation: &RadrootsContributionAttestation,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- require_schema(
- &attestation.schema,
- attestation.schema_version,
- RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
- )?;
+ validate_contribution_attestation(attestation).map_err(encode_validation_error)?;
let mut tags = custom_tags(RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA);
push_event_refs(&mut tags, TAG_EVIDENCE, &attestation.evidence_refs);
Ok(tags)
diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs
@@ -19,7 +19,7 @@ use radroots_events::knowledge::{
RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle,
RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
};
-use radroots_events_codec::error::EventParseError;
+use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::knowledge::{
contribution_attestation_from_event, contribution_attestation_to_wire_parts,
evidence_bounty_from_event, evidence_bounty_to_wire_parts,
@@ -82,7 +82,8 @@ fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent {
fn assert_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
- | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected)) => {
+ | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected))
+ | (EventParseError::InvalidJson(actual), EventParseError::InvalidJson(expected)) => {
assert_eq!(actual, expected);
}
(
@@ -99,6 +100,19 @@ fn assert_parse_error(actual: EventParseError, expected: EventParseError) {
}
}
+fn assert_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
+ match (actual, expected) {
+ (
+ EventEncodeError::EmptyRequiredField(actual),
+ EventEncodeError::EmptyRequiredField(expected),
+ )
+ | (EventEncodeError::InvalidField(actual), EventEncodeError::InvalidField(expected)) => {
+ assert_eq!(actual, expected);
+ }
+ (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
+ }
+}
+
fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent {
let tags = parts
.tags
@@ -642,3 +656,237 @@ fn malformed_nip54_wiki_shapes_are_rejected() {
EventParseError::InvalidTag("a"),
);
}
+
+#[test]
+fn semantic_validation_rejects_invalid_encode_models() {
+ let mut article = wiki_article();
+ article.content_djot = String::new();
+ assert_encode_error(
+ wiki_article_to_wire_parts(&article).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("content_djot"),
+ );
+
+ let mut redirect = RadrootsWikiRedirect {
+ d_tag: "soil".to_string(),
+ target: address_ref(),
+ };
+ redirect.target.kind = 30023;
+ assert_encode_error(
+ wiki_redirect_to_wire_parts(&redirect).unwrap_err(),
+ EventEncodeError::InvalidField("wiki_redirect.target"),
+ );
+
+ let mut merge = RadrootsWikiMergeRequest {
+ target_article: address_ref(),
+ destination_pubkey: "bad".to_string(),
+ base_version_event_id: Some(hex_64('e')),
+ source_version_event_id: hex_64('f'),
+ explanation: None,
+ };
+ assert_encode_error(
+ wiki_merge_request_to_wire_parts(&merge).unwrap_err(),
+ EventEncodeError::InvalidField("destination_pubkey"),
+ );
+ merge.destination_pubkey = hex_64('a');
+ merge.base_version_event_id = Some("bad".to_string());
+ assert_encode_error(
+ wiki_merge_request_to_wire_parts(&merge).unwrap_err(),
+ EventEncodeError::InvalidField("base_version_event_id"),
+ );
+
+ let mut source = source();
+ source.title = " ".to_string();
+ assert_encode_error(
+ knowledge_source_to_wire_parts(&source).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("title"),
+ );
+
+ let mut claim = claim();
+ claim.citation_spans[0].quote_hash = Some("bad".to_string());
+ assert_encode_error(
+ knowledge_claim_to_wire_parts(&claim).unwrap_err(),
+ EventEncodeError::InvalidField("citation_spans"),
+ );
+
+ let mut relation = relation();
+ relation.subject.external_id = Some("cover-crops".to_string());
+ assert_encode_error(
+ knowledge_relation_to_wire_parts(&relation).unwrap_err(),
+ EventEncodeError::InvalidField("subject"),
+ );
+
+ let mut review = review();
+ review.scores[0].value = String::new();
+ assert_encode_error(
+ knowledge_review_to_wire_parts(&review).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("scores"),
+ );
+
+ let mut report = field_report();
+ report.observations.clear();
+ assert_encode_error(
+ knowledge_field_report_to_wire_parts(&report).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("observations"),
+ );
+
+ let bounty = RadrootsEvidenceBounty {
+ schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ d_tag: "soil-bounty".to_string(),
+ title: "Soil bounty".to_string(),
+ summary: None,
+ topics: vec!["soil".to_string()],
+ target_refs: Vec::new(),
+ reward_note: None,
+ closes_at: None,
+ };
+ assert_encode_error(
+ evidence_bounty_to_wire_parts(&bounty).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("target_refs"),
+ );
+
+ let proposal = RadrootsKnowledgeChangeProposal {
+ schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
+ proposal_type: "amend".to_string(),
+ summary: String::new(),
+ rationale: None,
+ evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
+ supersedes: Vec::new(),
+ };
+ assert_encode_error(
+ knowledge_change_proposal_to_wire_parts(&proposal).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("summary"),
+ );
+
+ let attestation = RadrootsContributionAttestation {
+ schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ contributor_pubkey: hex_64('a'),
+ contribution_type: "review".to_string(),
+ subject_refs: Vec::new(),
+ summary: "Reviewed synthetic claim".to_string(),
+ evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
+ };
+ assert_encode_error(
+ contribution_attestation_to_wire_parts(&attestation).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("subject_refs"),
+ );
+}
+
+#[test]
+fn semantic_validation_rejects_invalid_decoded_content() {
+ let mut article = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
+ article.content = String::new();
+ assert_parse_error(
+ wiki_article_from_event(article).unwrap_err(),
+ EventParseError::InvalidJson("content_djot"),
+ );
+
+ let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&source_event.content).unwrap();
+ value["title"] = serde_json::Value::String(String::new());
+ source_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_source_from_event(source_event).unwrap_err(),
+ EventParseError::InvalidJson("title"),
+ );
+
+ let mut claim_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&claim_event.content).unwrap();
+ value["citation_spans"][0]["quote_hash"] = serde_json::Value::String("bad".to_string());
+ claim_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_claim_from_event(claim_event).unwrap_err(),
+ EventParseError::InvalidJson("citation_spans"),
+ );
+
+ let mut relation_event =
+ event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&relation_event.content).unwrap();
+ value["subject"]["external_id"] = serde_json::Value::String("cover-crops".to_string());
+ relation_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_relation_from_event(relation_event).unwrap_err(),
+ EventParseError::InvalidJson("subject"),
+ );
+
+ let mut review_event = event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&review_event.content).unwrap();
+ value["target"]["kind"] = serde_json::Value::from(0);
+ review_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_review_from_event(review_event).unwrap_err(),
+ EventParseError::InvalidJson("review_target"),
+ );
+
+ let mut report_event =
+ event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&report_event.content).unwrap();
+ value["observations"] = serde_json::Value::Array(Vec::new());
+ report_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_field_report_from_event(report_event).unwrap_err(),
+ EventParseError::InvalidJson("observations"),
+ );
+
+ let bounty = RadrootsEvidenceBounty {
+ schema: RADROOTS_EVIDENCE_BOUNTY_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ d_tag: "soil-bounty".to_string(),
+ title: "Soil bounty".to_string(),
+ summary: None,
+ topics: vec!["soil".to_string()],
+ target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)],
+ reward_note: None,
+ closes_at: None,
+ };
+ let mut bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&bounty_event.content).unwrap();
+ value["target_refs"] = serde_json::Value::Array(Vec::new());
+ bounty_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ evidence_bounty_from_event(bounty_event).unwrap_err(),
+ EventParseError::InvalidJson("target_refs"),
+ );
+
+ let proposal = RadrootsKnowledgeChangeProposal {
+ schema: RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ target: event_ref('b', KIND_KNOWLEDGE_CLAIM),
+ proposal_type: "amend".to_string(),
+ summary: "Clarify scope".to_string(),
+ rationale: None,
+ evidence_refs: vec![event_ref('c', KIND_KNOWLEDGE_SOURCE)],
+ supersedes: Vec::new(),
+ };
+ let mut proposal_event =
+ event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&proposal_event.content).unwrap();
+ value["summary"] = serde_json::Value::String(String::new());
+ proposal_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_change_proposal_from_event(proposal_event).unwrap_err(),
+ EventParseError::InvalidJson("summary"),
+ );
+
+ let attestation = RadrootsContributionAttestation {
+ schema: RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA.to_string(),
+ schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
+ contributor_pubkey: hex_64('a'),
+ contribution_type: "review".to_string(),
+ subject_refs: vec![event_ref('d', KIND_KNOWLEDGE_REVIEW)],
+ summary: "Reviewed synthetic claim".to_string(),
+ evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
+ };
+ let mut attestation_event =
+ event_from_parts(contribution_attestation_to_wire_parts(&attestation).unwrap());
+ let mut value: serde_json::Value = serde_json::from_str(&attestation_event.content).unwrap();
+ value["subject_refs"] = serde_json::Value::Array(Vec::new());
+ attestation_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ contribution_attestation_from_event(attestation_event).unwrap_err(),
+ EventParseError::InvalidJson("subject_refs"),
+ );
+}