commit 6aa22321792f6ed53e326d5a442d5598a64236f8
parent db0a8bef6b95befe80ac61d39179473077076fff
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 01:33:18 +0000
events: enforce claim citation rules
Diffstat:
2 files changed, 92 insertions(+), 0 deletions(-)
diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs
@@ -349,6 +349,10 @@ pub fn validate_knowledge_source(
validate_event_refs(&source.artifact_refs, "artifact_refs")
}
+pub fn is_uncited_knowledge_claim_type(claim_type: &str) -> bool {
+ matches!(claim_type, "hypothesis" | "observation" | "question")
+}
+
pub fn validate_knowledge_claim(
claim: &RadrootsKnowledgeClaim,
) -> Result<(), RadrootsKnowledgeValidationError> {
@@ -359,6 +363,13 @@ pub fn validate_knowledge_claim(
)?;
require_non_empty(claim.claim_type.as_str(), "claim_type")?;
require_non_empty(claim.text.as_str(), "text")?;
+ if claim.citation_spans.is_empty()
+ && !is_uncited_knowledge_claim_type(claim.claim_type.as_str())
+ {
+ return Err(RadrootsKnowledgeValidationError::EmptyField(
+ "citation_spans",
+ ));
+ }
for citation in &claim.citation_spans {
if citation.source_ref.kind != crate::kinds::KIND_KNOWLEDGE_SOURCE {
return Err(RadrootsKnowledgeValidationError::InvalidField(
@@ -1249,6 +1260,36 @@ mod tests {
}
#[test]
+ fn knowledge_claims_require_citations_except_exact_uncited_types() {
+ let mut claim = knowledge_claim();
+ claim.citation_spans.clear();
+ assert_validation_error(
+ validate_knowledge_claim(&claim),
+ RadrootsKnowledgeValidationError::EmptyField("citation_spans"),
+ );
+
+ claim.citation_spans.push(citation_span());
+ assert_eq!(validate_knowledge_claim(&claim), Ok(()));
+
+ for claim_type in ["hypothesis", "observation", "question"] {
+ let mut uncited = knowledge_claim();
+ uncited.claim_type = claim_type.to_string();
+ uncited.citation_spans.clear();
+ assert_eq!(validate_knowledge_claim(&uncited), Ok(()));
+ assert!(is_uncited_knowledge_claim_type(claim_type));
+ }
+
+ let mut capitalized = knowledge_claim();
+ capitalized.claim_type = "Hypothesis".to_string();
+ capitalized.citation_spans.clear();
+ assert_validation_error(
+ validate_knowledge_claim(&capitalized),
+ RadrootsKnowledgeValidationError::EmptyField("citation_spans"),
+ );
+ assert!(!is_uncited_knowledge_claim_type("Hypothesis"));
+ }
+
+ #[test]
fn knowledge_validators_reject_representative_invalid_models() {
let mut article = RadrootsWikiArticle {
d_tag: "soil-health".to_string(),
diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs
@@ -776,6 +776,33 @@ fn semantic_validation_rejects_invalid_encode_models() {
}
#[test]
+fn knowledge_claim_encode_enforces_citation_rules() {
+ let mut model = claim();
+ model.citation_spans.clear();
+ assert_encode_error(
+ knowledge_claim_to_wire_parts(&model).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("citation_spans"),
+ );
+
+ assert!(knowledge_claim_to_wire_parts(&claim()).is_ok());
+
+ for claim_type in ["hypothesis", "observation", "question"] {
+ let mut uncited = claim();
+ uncited.claim_type = claim_type.to_string();
+ uncited.citation_spans.clear();
+ assert!(knowledge_claim_to_wire_parts(&uncited).is_ok());
+ }
+
+ let mut capitalized = claim();
+ capitalized.claim_type = "Hypothesis".to_string();
+ capitalized.citation_spans.clear();
+ assert_encode_error(
+ knowledge_claim_to_wire_parts(&capitalized).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("citation_spans"),
+ );
+}
+
+#[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();
@@ -890,3 +917,27 @@ fn semantic_validation_rejects_invalid_decoded_content() {
EventParseError::InvalidJson("subject_refs"),
);
}
+
+#[test]
+fn knowledge_claim_decode_enforces_citation_rules() {
+ 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"] = serde_json::Value::Array(Vec::new());
+ claim_event.content = serde_json::to_string(&value).unwrap();
+ assert_parse_error(
+ knowledge_claim_from_event(claim_event).unwrap_err(),
+ EventParseError::InvalidJson("citation_spans"),
+ );
+
+ for claim_type in ["hypothesis", "observation", "question"] {
+ let mut uncited = claim();
+ uncited.claim_type = claim_type.to_string();
+ uncited.citation_spans.clear();
+ let decoded = knowledge_claim_from_event(event_from_parts(
+ knowledge_claim_to_wire_parts(&uncited).unwrap(),
+ ))
+ .unwrap();
+ assert_eq!(decoded.data.data.claim_type, claim_type);
+ assert!(decoded.data.data.citation_spans.is_empty());
+ }
+}