commit 5ba3d4d817e8fd17e20b791dcaae9a6869d483e9
parent e9d7d77cc105113afc209cc80949bcfb93ef4aa9
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 04:38:37 +0000
sdk: validate knowledge builders with core models
- route SDK knowledge builder outputs through matching core validators
- map core empty and invalid fields onto existing builder errors
- cover invalid nested redirect, merge, source, relation, review, and field report models
- keep knowledge public API tests, SDK feature tests, and local smoke green
Diffstat:
2 files changed, 187 insertions(+), 28 deletions(-)
diff --git a/crates/sdk/src/knowledge.rs b/crates/sdk/src/knowledge.rs
@@ -52,6 +52,10 @@ pub use radroots_events_codec::{
wire::WireEventParts,
};
+use radroots_events::knowledge::{
+ validate_knowledge_field_report, validate_knowledge_relation, validate_knowledge_review,
+ validate_knowledge_source, validate_wiki_merge_request, validate_wiki_redirect,
+};
use radroots_events_codec::{
contract_manifest_json as codec_contract_manifest_json,
contract_manifest_sha256 as codec_contract_manifest_sha256,
@@ -256,7 +260,6 @@ impl RadrootsWikiArticleBuilder {
}
pub fn build(self) -> Result<RadrootsWikiArticle, RadrootsKnowledgeBuilderError> {
- validate_builder_wiki_d_tag(&self.d_tag)?;
let article = RadrootsWikiArticle {
d_tag: self.d_tag,
title: self.title,
@@ -267,8 +270,7 @@ impl RadrootsWikiArticleBuilder {
forked_from: self.forked_from,
deferred_to: self.deferred_to,
};
- validate_wiki_article(&article).map_err(builder_validation_error)?;
- Ok(article)
+ builder_validated(article, validate_wiki_article)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -308,11 +310,11 @@ impl RadrootsWikiRedirectBuilder {
}
pub fn build(self) -> Result<RadrootsWikiRedirect, RadrootsKnowledgeBuilderError> {
- validate_builder_wiki_d_tag(&self.d_tag)?;
- Ok(RadrootsWikiRedirect {
+ let redirect = RadrootsWikiRedirect {
d_tag: self.d_tag,
target: builder_required(self.target, "target")?,
- })
+ };
+ builder_validated(redirect, validate_wiki_redirect)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -378,7 +380,7 @@ impl RadrootsWikiMergeRequestBuilder {
}
pub fn build(self) -> Result<RadrootsWikiMergeRequest, RadrootsKnowledgeBuilderError> {
- Ok(RadrootsWikiMergeRequest {
+ let request = RadrootsWikiMergeRequest {
target_article: builder_required(self.target_article, "target_article")?,
destination_pubkey: builder_required_string(
self.destination_pubkey,
@@ -390,7 +392,8 @@ impl RadrootsWikiMergeRequestBuilder {
"source_version_event_id",
)?,
explanation: self.explanation,
- })
+ };
+ builder_validated(request, validate_wiki_merge_request)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -490,8 +493,7 @@ impl RadrootsKnowledgeSourceBuilder {
}
pub fn build(self) -> Result<RadrootsKnowledgeSource, RadrootsKnowledgeBuilderError> {
- validate_builder_wiki_d_tag(&self.d_tag)?;
- Ok(RadrootsKnowledgeSource {
+ let source = RadrootsKnowledgeSource {
schema: RADROOTS_KNOWLEDGE_SOURCE_SCHEMA.to_string(),
schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
d_tag: self.d_tag,
@@ -506,7 +508,8 @@ impl RadrootsKnowledgeSourceBuilder {
author_asserted_rights: self.author_asserted_rights,
topics: self.topics,
summary: self.summary,
- })
+ };
+ builder_validated(source, validate_knowledge_source)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -600,8 +603,7 @@ impl RadrootsKnowledgeClaimBuilder {
author_asserted_confidence: self.author_asserted_confidence,
supersedes: self.supersedes,
};
- validate_knowledge_claim(&claim).map_err(builder_validation_error)?;
- Ok(claim)
+ builder_validated(claim, validate_knowledge_claim)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -677,7 +679,7 @@ impl RadrootsKnowledgeRelationBuilder {
}
pub fn build(self) -> Result<RadrootsKnowledgeRelation, RadrootsKnowledgeBuilderError> {
- Ok(RadrootsKnowledgeRelation {
+ let relation = RadrootsKnowledgeRelation {
schema: RADROOTS_KNOWLEDGE_RELATION_SCHEMA.to_string(),
schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
subject: builder_required(self.subject, "subject")?,
@@ -686,7 +688,8 @@ impl RadrootsKnowledgeRelationBuilder {
support_refs: self.support_refs,
author_asserted_confidence: self.author_asserted_confidence,
supersedes: self.supersedes,
- })
+ };
+ builder_validated(relation, validate_knowledge_relation)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -759,7 +762,7 @@ impl RadrootsKnowledgeReviewBuilder {
}
pub fn build(self) -> Result<RadrootsKnowledgeReview, RadrootsKnowledgeBuilderError> {
- Ok(RadrootsKnowledgeReview {
+ let review = RadrootsKnowledgeReview {
schema: RADROOTS_KNOWLEDGE_REVIEW_SCHEMA.to_string(),
schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
target: builder_required(self.target, "target")?,
@@ -768,7 +771,8 @@ impl RadrootsKnowledgeReviewBuilder {
scores: self.scores,
notes: self.notes,
evidence_refs: self.evidence_refs,
- })
+ };
+ builder_validated(review, validate_knowledge_review)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -855,10 +859,7 @@ impl RadrootsKnowledgeFieldReportBuilder {
}
pub fn build(self) -> Result<RadrootsKnowledgeFieldReport, RadrootsKnowledgeBuilderError> {
- if self.observations.is_empty() {
- return Err(RadrootsKnowledgeBuilderError::MissingField("observations"));
- }
- Ok(RadrootsKnowledgeFieldReport {
+ let report = RadrootsKnowledgeFieldReport {
schema: RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA.to_string(),
schema_version: RADROOTS_KNOWLEDGE_SCHEMA_VERSION,
report_type: builder_required_string(self.report_type, "report_type")?,
@@ -869,7 +870,8 @@ impl RadrootsKnowledgeFieldReportBuilder {
artifact_refs: self.artifact_refs,
related_refs: self.related_refs,
limitations: self.limitations,
- })
+ };
+ builder_validated(report, validate_knowledge_field_report)
}
pub fn build_event(self) -> Result<WireEventParts, RadrootsSdkKnowledgeError> {
@@ -1290,12 +1292,6 @@ fn builder_non_empty_string(
}
}
-fn validate_builder_wiki_d_tag(value: &str) -> Result<(), RadrootsKnowledgeBuilderError> {
- validate_wiki_d_tag(value)
- .map(|_| ())
- .map_err(|_| RadrootsKnowledgeBuilderError::InvalidField("d_tag"))
-}
-
fn builder_validation_error(
error: RadrootsKnowledgeValidationError,
) -> RadrootsKnowledgeBuilderError {
@@ -1309,6 +1305,14 @@ fn builder_validation_error(
}
}
+fn builder_validated<T>(
+ value: T,
+ validate: fn(&T) -> Result<(), RadrootsKnowledgeValidationError>,
+) -> Result<T, RadrootsKnowledgeBuilderError> {
+ validate(&value).map_err(builder_validation_error)?;
+ Ok(value)
+}
+
pub mod prelude {
pub use super::{
KIND_FILE_METADATA, KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_FIELD_REPORT,
diff --git a/crates/sdk/tests/knowledge_public_api.rs b/crates/sdk/tests/knowledge_public_api.rs
@@ -87,6 +87,155 @@ fn fluent_builders_reject_missing_and_invalid_required_fields() {
}
#[test]
+fn fluent_builders_reject_core_invalid_nested_models() {
+ let mut non_wiki_target = address_ref();
+ non_wiki_target.kind = KIND_KNOWLEDGE_SOURCE;
+ let redirect_error = RadrootsWikiRedirectBuilder::new("soil")
+ .target(non_wiki_target)
+ .build()
+ .expect_err("non wiki redirect target");
+ assert_eq!(
+ redirect_error,
+ RadrootsKnowledgeBuilderError::InvalidField("wiki_redirect.target")
+ );
+
+ let destination_error = merge_request_builder()
+ .destination_pubkey("bad")
+ .build()
+ .expect_err("invalid destination pubkey");
+ assert_eq!(
+ destination_error,
+ RadrootsKnowledgeBuilderError::InvalidField("destination_pubkey")
+ );
+
+ let source_version_error = merge_request_builder()
+ .source_version_event_id("bad")
+ .build()
+ .expect_err("invalid source version id");
+ assert_eq!(
+ source_version_error,
+ RadrootsKnowledgeBuilderError::InvalidField("source_version_event_id")
+ );
+
+ let blank_author_error = source_builder()
+ .author(" ")
+ .build()
+ .expect_err("blank author");
+ assert_eq!(
+ blank_author_error,
+ RadrootsKnowledgeBuilderError::MissingField("authors")
+ );
+
+ let malformed_artifact_error = source_builder()
+ .artifact_ref(malformed_event_ref(KIND_FILE_METADATA))
+ .build()
+ .expect_err("malformed artifact ref");
+ assert_eq!(
+ malformed_artifact_error,
+ RadrootsKnowledgeBuilderError::InvalidField("artifact_refs")
+ );
+
+ let mut invalid_subject = knowledge_node_ref("cover crops");
+ invalid_subject.external_id = Some("duplicate".to_owned());
+ let invalid_node_error = RadrootsKnowledgeRelationBuilder::new()
+ .subject(invalid_subject)
+ .predicate("supports")
+ .object(knowledge_node_ref("soil structure"))
+ .support_ref(event_ref('7', KIND_KNOWLEDGE_CLAIM))
+ .build()
+ .expect_err("invalid relation node");
+ assert_eq!(
+ invalid_node_error,
+ RadrootsKnowledgeBuilderError::InvalidField("subject")
+ );
+
+ let malformed_support_error = relation_builder()
+ .support_ref(malformed_event_ref(KIND_KNOWLEDGE_CLAIM))
+ .build()
+ .expect_err("malformed support ref");
+ assert_eq!(
+ malformed_support_error,
+ RadrootsKnowledgeBuilderError::InvalidField("support_refs")
+ );
+
+ let zero_kind_target = RadrootsKnowledgeReviewTarget {
+ event_id: hex_64('8'),
+ author_pubkey: hex_64('a'),
+ kind: 0,
+ address: None,
+ relays: vec![RELAY.to_owned()],
+ review_scope: RadrootsKnowledgeReviewScope::SpecificVersion,
+ };
+ let zero_kind_error = review_builder()
+ .target(zero_kind_target)
+ .build()
+ .expect_err("zero target kind");
+ assert_eq!(
+ zero_kind_error,
+ RadrootsKnowledgeBuilderError::InvalidField("review_target")
+ );
+
+ let malformed_pubkey_target = RadrootsKnowledgeReviewTarget {
+ event_id: hex_64('8'),
+ author_pubkey: "bad".to_owned(),
+ kind: KIND_KNOWLEDGE_CLAIM,
+ address: None,
+ relays: vec![RELAY.to_owned()],
+ review_scope: RadrootsKnowledgeReviewScope::SpecificVersion,
+ };
+ let malformed_pubkey_error = review_builder()
+ .target(malformed_pubkey_target)
+ .build()
+ .expect_err("malformed target pubkey");
+ assert_eq!(
+ malformed_pubkey_error,
+ RadrootsKnowledgeBuilderError::InvalidField("review_target")
+ );
+
+ let blank_score_dimension_error = review_builder()
+ .score(RadrootsKnowledgeReviewScore {
+ dimension: " ".to_owned(),
+ value: "partial".to_owned(),
+ note: None,
+ })
+ .build()
+ .expect_err("blank score dimension");
+ assert_eq!(
+ blank_score_dimension_error,
+ RadrootsKnowledgeBuilderError::MissingField("scores")
+ );
+
+ let exact_private_without_ref_error = field_report_builder()
+ .context(RadrootsKnowledgeFieldContext {
+ location_precision: RadrootsKnowledgeLocationPrecision::ExactPrivateReference,
+ public_location: None,
+ private_location_ref: None,
+ topics: vec!["field".to_owned()],
+ context_tags: vec!["observation".to_owned()],
+ })
+ .build()
+ .expect_err("missing private location ref");
+ assert_eq!(
+ exact_private_without_ref_error,
+ RadrootsKnowledgeBuilderError::MissingField("private_location_ref")
+ );
+
+ let blank_observation_error = field_report_builder()
+ .observation(RadrootsKnowledgeObservation {
+ observation_type: " ".to_owned(),
+ text: "Residue was visible across beds.".to_owned(),
+ observed_at: Some("2026-07-05".to_owned()),
+ values: Vec::new(),
+ })
+ .build()
+ .expect_err("blank observation data");
+ assert_eq!(
+ blank_observation_error,
+ RadrootsKnowledgeBuilderError::MissingField("observations")
+ );
+}
+
+#[test]
fn wiki_article_builder_accepts_missing_title_but_rejects_blank_title() {
let article = RadrootsWikiArticleBuilder::new("soil-health")
.content_djot("# Soil health")
@@ -296,6 +445,12 @@ fn event_ref(character: char, kind: u32) -> RadrootsNostrEventRef {
}
}
+fn malformed_event_ref(kind: u32) -> RadrootsNostrEventRef {
+ let mut reference = event_ref('f', kind);
+ reference.id = "bad".to_owned();
+ reference
+}
+
fn address_ref() -> RadrootsAddressableRef {
RadrootsAddressableRef {
kind: KIND_WIKI_ARTICLE,