commit 7b4339e1942cdd4bb0482a88dd79c2e57d604c6d
parent 321b19e8739f66adfbe00278ed23657b3109c3c5
Author: triesap <tyson@radroots.org>
Date: Mon, 6 Jul 2026 04:34:20 +0000
events-codec: enforce knowledge mirrored tag parity
- compare decoded knowledge tags against encoder-owned canonical projections
- reject source, citation, review target, topic, geohash, and evidence drift
- preserve valid knowledge round trips while ignoring unrelated extra tags
- cover mirrored tag drift across source, bounty, claim, relation, review, report, proposal, and attestation events
Diffstat:
2 files changed, 245 insertions(+), 1 deletion(-)
diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs
@@ -25,17 +25,25 @@ use radroots_events::knowledge::{
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::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T};
use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef};
use serde::de::DeserializeOwned;
use crate::error::EventParseError;
use crate::event_ref::parse_event_ref_tag;
+use crate::knowledge::encode::{
+ contribution_attestation_build_tags, evidence_bounty_build_tags,
+ knowledge_change_proposal_build_tags, knowledge_claim_build_tags,
+ knowledge_field_report_build_tags, knowledge_relation_build_tags, knowledge_review_build_tags,
+ knowledge_source_build_tags,
+};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
const TAG_TITLE: &str = "title";
const TAG_SOURCE: &str = "source";
+const TAG_CITATION: &str = "citation";
const TAG_REVIEW_TARGET: &str = "review_target";
+const TAG_EVIDENCE: &str = "evidence";
const MARKER_FORK: &str = "fork";
const MARKER_DEFER: &str = "defer";
const E_MARKER_SOURCE: &str = "source";
@@ -96,6 +104,73 @@ fn values(tags: &[Vec<String>], name: &'static str) -> Vec<String> {
.collect()
}
+fn tag_matches_names(tag: &[String], names: &[&'static str]) -> bool {
+ tag.first()
+ .map(|value| names.contains(&value.as_str()))
+ .unwrap_or(false)
+}
+
+fn mirrored_tags<'a>(tags: &'a [Vec<String>], names: &[&'static str]) -> Vec<&'a [String]> {
+ tags.iter()
+ .map(Vec::as_slice)
+ .filter(|tag| tag_matches_names(tag, names))
+ .collect()
+}
+
+fn tag_name(tag: &[String]) -> Option<&str> {
+ tag.first().map(String::as_str)
+}
+
+fn first_mirrored_difference(
+ actual: &[&[String]],
+ expected: &[&[String]],
+ names: &[&'static str],
+) -> &'static str {
+ for (actual_tag, expected_tag) in actual.iter().zip(expected.iter()) {
+ if actual_tag != expected_tag {
+ if let Some(name) = tag_name(actual_tag)
+ && let Some(expected_name) = names.iter().find(|candidate| **candidate == name)
+ {
+ return *expected_name;
+ }
+ if let Some(name) = tag_name(expected_tag)
+ && let Some(expected_name) = names.iter().find(|candidate| **candidate == name)
+ {
+ return *expected_name;
+ }
+ }
+ }
+ if actual.len() > expected.len()
+ && let Some(name) = tag_name(actual[expected.len()])
+ && let Some(expected_name) = names.iter().find(|candidate| **candidate == name)
+ {
+ return *expected_name;
+ }
+ if expected.len() > actual.len()
+ && let Some(name) = tag_name(expected[actual.len()])
+ && let Some(expected_name) = names.iter().find(|candidate| **candidate == name)
+ {
+ return *expected_name;
+ }
+ names[0]
+}
+
+fn ensure_mirrored_tags(
+ actual_tags: &[Vec<String>],
+ expected_tags: &[Vec<String>],
+ names: &[&'static str],
+) -> Result<(), EventParseError> {
+ let actual = mirrored_tags(actual_tags, names);
+ let expected = mirrored_tags(expected_tags, names);
+ if actual == expected {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidTag(first_mirrored_difference(
+ &actual, &expected, names,
+ )))
+ }
+}
+
fn event_refs(
tags: &[Vec<String>],
name: &'static str,
@@ -397,6 +472,9 @@ pub fn knowledge_source_from_event(
return Err(EventParseError::InvalidTag(TAG_D));
}
validate_knowledge_source(&source).map_err(parse_validation_error)?;
+ let expected_tags =
+ knowledge_source_build_tags(&source).map_err(|_| EventParseError::InvalidTag(TAG_D))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_D, TAG_T, TAG_SOURCE])?;
Ok(parsed(event, source))
}
@@ -411,6 +489,9 @@ pub fn evidence_bounty_from_event(
return Err(EventParseError::InvalidTag(TAG_D));
}
validate_evidence_bounty(&bounty).map_err(parse_validation_error)?;
+ let expected_tags =
+ evidence_bounty_build_tags(&bounty).map_err(|_| EventParseError::InvalidTag(TAG_D))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_D, TAG_T, TAG_EVIDENCE])?;
Ok(parsed(event, bounty))
}
@@ -421,6 +502,13 @@ pub fn knowledge_claim_from_event(
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CLAIM_SCHEMA)?;
let claim: RadrootsKnowledgeClaim = json_content(&event.content)?;
validate_knowledge_claim(&claim).map_err(parse_validation_error)?;
+ let expected_tags =
+ knowledge_claim_build_tags(&claim).map_err(|_| EventParseError::InvalidTag(TAG_SOURCE))?;
+ ensure_mirrored_tags(
+ &event.tags,
+ &expected_tags,
+ &[TAG_T, TAG_SOURCE, TAG_CITATION],
+ )?;
Ok(parsed(event, claim))
}
@@ -431,6 +519,9 @@ pub fn knowledge_relation_from_event(
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_RELATION_SCHEMA)?;
let relation: RadrootsKnowledgeRelation = json_content(&event.content)?;
validate_knowledge_relation(&relation).map_err(parse_validation_error)?;
+ let expected_tags = knowledge_relation_build_tags(&relation)
+ .map_err(|_| EventParseError::InvalidTag(TAG_SOURCE))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_SOURCE])?;
Ok(parsed(event, relation))
}
@@ -442,6 +533,13 @@ pub fn knowledge_review_from_event(
required_one_value(&event.tags, TAG_REVIEW_TARGET)?;
let review: RadrootsKnowledgeReview = json_content(&event.content)?;
validate_knowledge_review(&review).map_err(parse_validation_error)?;
+ let expected_tags = knowledge_review_build_tags(&review)
+ .map_err(|_| EventParseError::InvalidTag(TAG_REVIEW_TARGET))?;
+ ensure_mirrored_tags(
+ &event.tags,
+ &expected_tags,
+ &[TAG_REVIEW_TARGET, TAG_EVIDENCE],
+ )?;
Ok(parsed(event, review))
}
@@ -457,6 +555,9 @@ pub fn knowledge_field_report_from_event(
reject_private_coordinate_keys(&event.content)?;
let report: RadrootsKnowledgeFieldReport = json_content(&event.content)?;
validate_knowledge_field_report(&report).map_err(parse_validation_error)?;
+ let expected_tags = knowledge_field_report_build_tags(&report)
+ .map_err(|_| EventParseError::InvalidTag(TAG_EVIDENCE))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_T, TAG_G, TAG_EVIDENCE])?;
Ok(parsed(event, report))
}
@@ -471,6 +572,9 @@ pub fn knowledge_change_proposal_from_event(
require_contract_tag(&event.tags, RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA)?;
let proposal: RadrootsKnowledgeChangeProposal = json_content(&event.content)?;
validate_knowledge_change_proposal(&proposal).map_err(parse_validation_error)?;
+ let expected_tags = knowledge_change_proposal_build_tags(&proposal)
+ .map_err(|_| EventParseError::InvalidTag(TAG_EVIDENCE))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_EVIDENCE])?;
Ok(parsed(event, proposal))
}
@@ -485,5 +589,8 @@ pub fn contribution_attestation_from_event(
require_contract_tag(&event.tags, RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA)?;
let attestation: RadrootsContributionAttestation = json_content(&event.content)?;
validate_contribution_attestation(&attestation).map_err(parse_validation_error)?;
+ let expected_tags = contribution_attestation_build_tags(&attestation)
+ .map_err(|_| EventParseError::InvalidTag(TAG_EVIDENCE))?;
+ ensure_mirrored_tags(&event.tags, &expected_tags, &[TAG_EVIDENCE])?;
Ok(parsed(event, attestation))
}
diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs
@@ -79,6 +79,16 @@ fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent {
}
}
+fn replace_first_tag_value(event: &mut RadrootsNostrEvent, name: &str, value: String) {
+ let tag = event
+ .tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(name))
+ .expect("tag");
+ let tag_value = tag.get_mut(1).expect("tag value");
+ *tag_value = value;
+}
+
fn assert_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
@@ -280,6 +290,45 @@ fn field_report() -> RadrootsKnowledgeFieldReport {
}
}
+fn 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!["soil".to_string()],
+ target_refs: vec![event_ref('a', KIND_KNOWLEDGE_CLAIM)],
+ reward_note: None,
+ closes_at: None,
+ }
+}
+
+fn proposal() -> RadrootsKnowledgeChangeProposal {
+ 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(),
+ }
+}
+
+fn 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('d', KIND_KNOWLEDGE_REVIEW)],
+ summary: "Reviewed synthetic claim".to_string(),
+ evidence_refs: vec![event_ref('e', KIND_KNOWLEDGE_REVIEW)],
+ }
+}
+
#[test]
fn knowledge_codecs_roundtrip_all_contracts() {
let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
@@ -500,6 +549,94 @@ fn knowledge_codecs_roundtrip_all_contracts() {
}
#[test]
+fn knowledge_decode_rejects_mirrored_tag_content_drift() {
+ let mut source_event = event_from_parts(knowledge_source_to_wire_parts(&source()).unwrap());
+ replace_first_tag_value(&mut source_event, "source", hex_64('4'));
+ assert_parse_error(
+ knowledge_source_from_event(source_event).unwrap_err(),
+ EventParseError::InvalidTag("source"),
+ );
+
+ let mut bounty_event = event_from_parts(evidence_bounty_to_wire_parts(&bounty()).unwrap());
+ replace_first_tag_value(&mut bounty_event, "evidence", hex_64('b'));
+ assert_parse_error(
+ evidence_bounty_from_event(bounty_event).unwrap_err(),
+ EventParseError::InvalidTag("evidence"),
+ );
+
+ let mut claim_source_event = event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
+ replace_first_tag_value(&mut claim_source_event, "source", hex_64('6'));
+ assert_parse_error(
+ knowledge_claim_from_event(claim_source_event).unwrap_err(),
+ EventParseError::InvalidTag("source"),
+ );
+
+ let mut claim_citation_event =
+ event_from_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
+ replace_first_tag_value(&mut claim_citation_event, "citation", hex_64('6'));
+ assert_parse_error(
+ knowledge_claim_from_event(claim_citation_event).unwrap_err(),
+ EventParseError::InvalidTag("citation"),
+ );
+
+ let mut relation_event =
+ event_from_parts(knowledge_relation_to_wire_parts(&relation()).unwrap());
+ replace_first_tag_value(&mut relation_event, "source", hex_64('8'));
+ assert_parse_error(
+ knowledge_relation_from_event(relation_event).unwrap_err(),
+ EventParseError::InvalidTag("source"),
+ );
+
+ let mut review_target_event =
+ event_from_parts(knowledge_review_to_wire_parts(&review()).unwrap());
+ replace_first_tag_value(&mut review_target_event, "review_target", hex_64('9'));
+ assert_parse_error(
+ knowledge_review_from_event(review_target_event).unwrap_err(),
+ EventParseError::InvalidTag("review_target"),
+ );
+
+ let mut report_geohash_event =
+ event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
+ replace_first_tag_value(&mut report_geohash_event, "g", "c24".to_string());
+ assert_parse_error(
+ knowledge_field_report_from_event(report_geohash_event).unwrap_err(),
+ EventParseError::InvalidTag("g"),
+ );
+
+ let mut report_topic_event =
+ event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
+ replace_first_tag_value(&mut report_topic_event, "t", "soil".to_string());
+ assert_parse_error(
+ knowledge_field_report_from_event(report_topic_event).unwrap_err(),
+ EventParseError::InvalidTag("t"),
+ );
+
+ let mut report_evidence_event =
+ event_from_parts(knowledge_field_report_to_wire_parts(&field_report()).unwrap());
+ replace_first_tag_value(&mut report_evidence_event, "evidence", hex_64('e'));
+ assert_parse_error(
+ knowledge_field_report_from_event(report_evidence_event).unwrap_err(),
+ EventParseError::InvalidTag("evidence"),
+ );
+
+ let mut proposal_event =
+ event_from_parts(knowledge_change_proposal_to_wire_parts(&proposal()).unwrap());
+ replace_first_tag_value(&mut proposal_event, "evidence", hex_64('d'));
+ assert_parse_error(
+ knowledge_change_proposal_from_event(proposal_event).unwrap_err(),
+ EventParseError::InvalidTag("evidence"),
+ );
+
+ let mut attestation_event =
+ event_from_parts(contribution_attestation_to_wire_parts(&attestation()).unwrap());
+ replace_first_tag_value(&mut attestation_event, "evidence", hex_64('f'));
+ assert_parse_error(
+ contribution_attestation_from_event(attestation_event).unwrap_err(),
+ EventParseError::InvalidTag("evidence"),
+ );
+}
+
+#[test]
fn verified_decode_accepts_signed_claim_and_rejects_mutation() {
let signed = sign_parts(knowledge_claim_to_wire_parts(&claim()).unwrap());
let decoded = verify_and_decode_radroots_event(signed.clone()).unwrap();