lib

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

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:
Mcrates/events_codec/src/knowledge/decode.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/tests/knowledge.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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();