commit 462565184d37737bbe608afc26e6ba3b4518b8fa
parent 2d419433ca8e2ed56aa5539eb4b7d5d2d3a80a2c
Author: triesap <tyson@radroots.org>
Date: Sun, 5 Jul 2026 05:12:39 +0000
events: add knowledge event models
- add knowledge and NIP-54 feature gates with kind classifiers
- add knowledge event structs and wiki d-tag normalization
- expose contract tag metadata and DTO roots for knowledge models
- validate default, no-std, dto-bindgen, and contract lanes
Diffstat:
8 files changed, 815 insertions(+), 2 deletions(-)
diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml
@@ -13,7 +13,15 @@ readme = "README"
[features]
default = ["std", "serde"]
-dto-bindgen = ["std", "serde", "dep:dto_bindgen", "radroots_core/dto-bindgen"]
+dto-bindgen = [
+ "std",
+ "serde",
+ "knowledge-nip54",
+ "dep:dto_bindgen",
+ "radroots_core/dto-bindgen",
+]
+knowledge = []
+knowledge-nip54 = ["knowledge"]
std = ["radroots_core/std"]
serde = ["dep:serde", "radroots_core/serde"]
diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs
@@ -30,6 +30,7 @@ pub enum RadrootsNostrStandard {
Nip51,
Nip52,
Nip53,
+ Nip54,
Nip56,
Nip57,
Nip78,
@@ -87,6 +88,7 @@ pub enum RadrootsContentSchema {
JsonObject,
PlainText,
Markdown,
+ Djot,
Encrypted,
BinaryReference,
}
diff --git a/crates/events/src/generated/dto_roots.rs b/crates/events/src/generated/dto_roots.rs
@@ -33,6 +33,44 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip
::dto_bindgen::export::RootDescriptor::new::<crate::job_request::RadrootsJobParam>(),
::dto_bindgen::export::RootDescriptor::new::<crate::job_request::RadrootsJobRequest>(),
::dto_bindgen::export::RootDescriptor::new::<crate::job_result::RadrootsJobResult>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsAddressableRef>(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::knowledge::RadrootsContributionAttestation,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsEvidenceBounty>(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::knowledge::RadrootsKnowledgeChangeProposal,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeCitationSpan>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeClaim>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeFieldContext>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeFieldReport>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeLocation>(),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::knowledge::RadrootsKnowledgeLocationPrecision,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeNodeRef>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeObservation>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<
+ crate::knowledge::RadrootsKnowledgeObservationValue,
+ >(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeRelation>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReview>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewScope>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewScore>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeReviewTarget>(
+ ),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeSource>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsRightsAssertion>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticle>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiMergeRequest>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiRedirect>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsListEntry>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list_set::RadrootsListSet>(),
diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs
@@ -43,6 +43,9 @@ pub const KIND_LIST_EMOJIS: u32 = 10030;
pub const KIND_LIST_DM_RELAYS: u32 = 10050;
pub const KIND_LIST_GOOD_WIKI_AUTHORS: u32 = 10101;
pub const KIND_LIST_GOOD_WIKI_RELAYS: u32 = 10102;
+pub const KIND_WIKI_MERGE_REQUEST: u32 = 818;
+pub const KIND_WIKI_ARTICLE: u32 = 30818;
+pub const KIND_WIKI_REDIRECT: u32 = 30819;
pub const KIND_LIST_SET_FOLLOW: u32 = 30000;
pub const KIND_LIST_SET_GENERIC: u32 = 30001;
pub const KIND_LIST_SET_RELAY: u32 = 30002;
@@ -98,9 +101,71 @@ pub const KIND_TRADE_FORBIDDEN_3431: u32 = 3431;
pub const KIND_TRADE_FULFILLMENT_UPDATE: u32 = 3433;
pub const KIND_TRADE_RECEIPT: u32 = 3434;
pub const KIND_TRADE_VALIDATION_RECEIPT: u32 = 3440;
+pub const KIND_KNOWLEDGE_CLAIM: u32 = 3460;
+pub const KIND_KNOWLEDGE_RELATION: u32 = 3461;
+pub const KIND_KNOWLEDGE_REVIEW: u32 = 3462;
+pub const KIND_KNOWLEDGE_FIELD_REPORT: u32 = 3463;
+pub const KIND_KNOWLEDGE_CHANGE_PROPOSAL: u32 = 3464;
+pub const KIND_CONTRIBUTION_ATTESTATION: u32 = 3465;
+pub const KIND_KNOWLEDGE_SOURCE: u32 = 30450;
+pub const KIND_EVIDENCE_BOUNTY: u32 = 30451;
pub const LISTING_EVENT_KINDS: [u32; 2] = [KIND_LISTING, KIND_LISTING_DRAFT];
+pub const WIKI_EVENT_KINDS: [u32; 3] = [
+ KIND_WIKI_MERGE_REQUEST,
+ KIND_WIKI_ARTICLE,
+ KIND_WIKI_REDIRECT,
+];
+
+pub const KNOWLEDGE_MVP_EVENT_KINDS: [u32; 8] = [
+ KIND_WIKI_MERGE_REQUEST,
+ KIND_WIKI_ARTICLE,
+ KIND_WIKI_REDIRECT,
+ KIND_KNOWLEDGE_SOURCE,
+ KIND_KNOWLEDGE_CLAIM,
+ KIND_KNOWLEDGE_RELATION,
+ KIND_KNOWLEDGE_REVIEW,
+ KIND_KNOWLEDGE_FIELD_REPORT,
+];
+
+pub const KNOWLEDGE_BETA_EVENT_KINDS: [u32; 3] = [
+ KIND_EVIDENCE_BOUNTY,
+ KIND_KNOWLEDGE_CHANGE_PROPOSAL,
+ KIND_CONTRIBUTION_ATTESTATION,
+];
+
+pub const KNOWLEDGE_EVENT_KINDS: [u32; 11] = [
+ KIND_WIKI_MERGE_REQUEST,
+ KIND_WIKI_ARTICLE,
+ KIND_WIKI_REDIRECT,
+ KIND_KNOWLEDGE_SOURCE,
+ KIND_KNOWLEDGE_CLAIM,
+ KIND_KNOWLEDGE_RELATION,
+ KIND_KNOWLEDGE_REVIEW,
+ KIND_KNOWLEDGE_FIELD_REPORT,
+ KIND_EVIDENCE_BOUNTY,
+ KIND_KNOWLEDGE_CHANGE_PROPOSAL,
+ KIND_CONTRIBUTION_ATTESTATION,
+];
+
+pub const KNOWLEDGE_ADDRESSABLE_EVENT_KINDS: [u32; 4] = [
+ KIND_WIKI_ARTICLE,
+ KIND_WIKI_REDIRECT,
+ KIND_KNOWLEDGE_SOURCE,
+ KIND_EVIDENCE_BOUNTY,
+];
+
+pub const KNOWLEDGE_REGULAR_EVENT_KINDS: [u32; 7] = [
+ KIND_WIKI_MERGE_REQUEST,
+ KIND_KNOWLEDGE_CLAIM,
+ KIND_KNOWLEDGE_RELATION,
+ KIND_KNOWLEDGE_REVIEW,
+ KIND_KNOWLEDGE_FIELD_REPORT,
+ KIND_KNOWLEDGE_CHANGE_PROPOSAL,
+ KIND_CONTRIBUTION_ATTESTATION,
+];
+
pub const ORDER_EVENT_KINDS: [u32; 5] = [
KIND_ORDER_REQUEST,
KIND_ORDER_DECISION,
@@ -331,6 +396,64 @@ pub const fn is_production_social_kind(kind: u32) -> bool {
}
#[inline]
+pub const fn is_wiki_event_kind(kind: u32) -> bool {
+ matches!(
+ kind,
+ KIND_WIKI_MERGE_REQUEST | KIND_WIKI_ARTICLE | KIND_WIKI_REDIRECT
+ )
+}
+
+#[inline]
+pub const fn is_knowledge_mvp_event_kind(kind: u32) -> bool {
+ matches!(
+ kind,
+ KIND_WIKI_MERGE_REQUEST
+ | KIND_WIKI_ARTICLE
+ | KIND_WIKI_REDIRECT
+ | KIND_KNOWLEDGE_SOURCE
+ | KIND_KNOWLEDGE_CLAIM
+ | KIND_KNOWLEDGE_RELATION
+ | KIND_KNOWLEDGE_REVIEW
+ | KIND_KNOWLEDGE_FIELD_REPORT
+ )
+}
+
+#[inline]
+pub const fn is_knowledge_beta_event_kind(kind: u32) -> bool {
+ matches!(
+ kind,
+ KIND_EVIDENCE_BOUNTY | KIND_KNOWLEDGE_CHANGE_PROPOSAL | KIND_CONTRIBUTION_ATTESTATION
+ )
+}
+
+#[inline]
+pub const fn is_knowledge_event_kind(kind: u32) -> bool {
+ is_knowledge_mvp_event_kind(kind) || is_knowledge_beta_event_kind(kind)
+}
+
+#[inline]
+pub const fn is_knowledge_addressable_event_kind(kind: u32) -> bool {
+ matches!(
+ kind,
+ KIND_WIKI_ARTICLE | KIND_WIKI_REDIRECT | KIND_KNOWLEDGE_SOURCE | KIND_EVIDENCE_BOUNTY
+ )
+}
+
+#[inline]
+pub const fn is_knowledge_regular_event_kind(kind: u32) -> bool {
+ matches!(
+ kind,
+ KIND_WIKI_MERGE_REQUEST
+ | KIND_KNOWLEDGE_CLAIM
+ | KIND_KNOWLEDGE_RELATION
+ | KIND_KNOWLEDGE_REVIEW
+ | KIND_KNOWLEDGE_FIELD_REPORT
+ | KIND_KNOWLEDGE_CHANGE_PROPOSAL
+ | KIND_CONTRIBUTION_ATTESTATION
+ )
+}
+
+#[inline]
pub const fn is_home_feed_candidate_kind(kind: u32) -> bool {
matches!(
kind,
@@ -669,6 +792,50 @@ mod tests {
}
#[test]
+ fn classifies_knowledge_event_kinds() {
+ assert_eq!(
+ WIKI_EVENT_KINDS,
+ [
+ KIND_WIKI_MERGE_REQUEST,
+ KIND_WIKI_ARTICLE,
+ KIND_WIKI_REDIRECT
+ ]
+ );
+ assert_eq!(KNOWLEDGE_MVP_EVENT_KINDS.len(), 8);
+ assert_eq!(KNOWLEDGE_BETA_EVENT_KINDS.len(), 3);
+ assert_eq!(KNOWLEDGE_EVENT_KINDS.len(), 11);
+ assert_eq!(KNOWLEDGE_ADDRESSABLE_EVENT_KINDS.len(), 4);
+ assert_eq!(KNOWLEDGE_REGULAR_EVENT_KINDS.len(), 7);
+
+ assert_eq!(KIND_WIKI_MERGE_REQUEST, 818);
+ assert_eq!(KIND_WIKI_ARTICLE, 30818);
+ assert_eq!(KIND_WIKI_REDIRECT, 30819);
+ assert_eq!(KIND_KNOWLEDGE_SOURCE, 30450);
+ assert_eq!(KIND_EVIDENCE_BOUNTY, 30451);
+ assert_eq!(KIND_KNOWLEDGE_CLAIM, 3460);
+ assert_eq!(KIND_KNOWLEDGE_RELATION, 3461);
+ assert_eq!(KIND_KNOWLEDGE_REVIEW, 3462);
+ assert_eq!(KIND_KNOWLEDGE_FIELD_REPORT, 3463);
+ assert_eq!(KIND_KNOWLEDGE_CHANGE_PROPOSAL, 3464);
+ assert_eq!(KIND_CONTRIBUTION_ATTESTATION, 3465);
+
+ assert!(is_wiki_event_kind(KIND_WIKI_ARTICLE));
+ assert!(is_knowledge_event_kind(KIND_WIKI_ARTICLE));
+ assert!(is_knowledge_event_kind(KIND_KNOWLEDGE_CLAIM));
+ assert!(is_knowledge_mvp_event_kind(KIND_KNOWLEDGE_FIELD_REPORT));
+ assert!(is_knowledge_beta_event_kind(KIND_EVIDENCE_BOUNTY));
+ assert!(is_knowledge_addressable_event_kind(KIND_KNOWLEDGE_SOURCE));
+ assert!(is_knowledge_addressable_event_kind(KIND_EVIDENCE_BOUNTY));
+ assert!(is_knowledge_regular_event_kind(KIND_WIKI_MERGE_REQUEST));
+ assert!(is_knowledge_regular_event_kind(
+ KIND_CONTRIBUTION_ATTESTATION
+ ));
+ assert!(!is_knowledge_regular_event_kind(KIND_KNOWLEDGE_SOURCE));
+ assert!(!is_knowledge_addressable_event_kind(KIND_KNOWLEDGE_CLAIM));
+ assert!(!is_knowledge_event_kind(KIND_ARTICLE));
+ }
+
+ #[test]
fn classifies_product_surface_candidate_kinds() {
assert_eq!(HOME_FEED_CANDIDATE_KINDS.len(), 9);
assert_eq!(EVENTS_CANDIDATE_KINDS.len(), 4);
diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs
@@ -0,0 +1,593 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+use core::fmt;
+
+use crate::RadrootsNostrEventRef;
+
+pub const RADROOTS_KNOWLEDGE_SCHEMA_VERSION: u16 = 1;
+pub const RADROOTS_WIKI_D_TAG_MAX_LEN: usize = 512;
+pub const RADROOTS_KNOWLEDGE_SOURCE_SCHEMA: &str = "radroots.knowledge.source.v1";
+pub const RADROOTS_KNOWLEDGE_CLAIM_SCHEMA: &str = "radroots.knowledge.claim.v1";
+pub const RADROOTS_KNOWLEDGE_RELATION_SCHEMA: &str = "radroots.knowledge.relation.v1";
+pub const RADROOTS_KNOWLEDGE_REVIEW_SCHEMA: &str = "radroots.knowledge.review.v1";
+pub const RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA: &str = "radroots.knowledge.field_report.v1";
+pub const RADROOTS_EVIDENCE_BOUNTY_SCHEMA: &str = "radroots.knowledge.evidence_bounty.v1";
+pub const RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA: &str = "radroots.knowledge.change_proposal.v1";
+pub const RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA: &str =
+ "radroots.knowledge.contribution_attestation.v1";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsWikiDTagError {
+ Empty,
+ TooLong { max: usize, actual: usize },
+ NotNormalized { normalized: String },
+}
+
+impl RadrootsWikiDTagError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::Empty => "empty",
+ Self::TooLong { .. } => "too_long",
+ Self::NotNormalized { .. } => "not_normalized",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsWikiDTagError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Empty => formatter.write_str("wiki d tag must not be empty"),
+ Self::TooLong { max, actual } => {
+ write!(
+ formatter,
+ "wiki d tag length {actual} exceeds maximum length {max}"
+ )
+ }
+ Self::NotNormalized { normalized } => {
+ write!(
+ formatter,
+ "wiki d tag must match normalized value {normalized}"
+ )
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsWikiDTagError {}
+
+pub fn normalize_wiki_d_tag(input: &str) -> String {
+ let mut normalized = String::new();
+ let mut pending_separator = false;
+
+ for raw in input.trim().chars() {
+ for character in raw.to_lowercase() {
+ if character.is_alphanumeric() {
+ if pending_separator && !normalized.is_empty() {
+ normalized.push('-');
+ }
+ normalized.push(character);
+ pending_separator = false;
+ } else if character.is_whitespace() || character == '-' {
+ pending_separator = true;
+ }
+ }
+ }
+
+ normalized
+}
+
+pub fn validate_wiki_d_tag(value: &str) -> Result<(), RadrootsWikiDTagError> {
+ if value.is_empty() {
+ return Err(RadrootsWikiDTagError::Empty);
+ }
+
+ let actual = value.chars().count();
+ if actual > RADROOTS_WIKI_D_TAG_MAX_LEN {
+ return Err(RadrootsWikiDTagError::TooLong {
+ max: RADROOTS_WIKI_D_TAG_MAX_LEN,
+ actual,
+ });
+ }
+
+ let normalized = normalize_wiki_d_tag(value);
+ if normalized != value {
+ return Err(RadrootsWikiDTagError::NotNormalized { normalized });
+ }
+
+ Ok(())
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAddressableRef {
+ pub kind: u32,
+ pub pubkey: String,
+ pub d_tag: String,
+ pub relays: Vec<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsRightsAssertion {
+ pub assertion: String,
+ pub holder: Option<String>,
+ pub license: Option<String>,
+ pub url: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsWikiArticle {
+ pub d_tag: String,
+ pub title: String,
+ pub content_djot: String,
+ pub summary: Option<String>,
+ pub topics: Vec<String>,
+ pub references: Vec<RadrootsNostrEventRef>,
+ pub forked_from: Vec<RadrootsNostrEventRef>,
+ pub deferred_to: Option<RadrootsNostrEventRef>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsWikiRedirect {
+ pub d_tag: String,
+ pub target: RadrootsNostrEventRef,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsWikiMergeRequest {
+ pub target_article: RadrootsAddressableRef,
+ pub destination_pubkey: String,
+ pub base_version_event_id: Option<String>,
+ pub source_version_event_id: String,
+ pub explanation: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeSource {
+ pub schema: String,
+ pub schema_version: u16,
+ pub d_tag: String,
+ pub title: String,
+ pub source_type: String,
+ pub authors: Vec<String>,
+ pub publisher: Option<String>,
+ pub publication_year: Option<u16>,
+ pub edition: Option<String>,
+ pub canonical_url: Option<String>,
+ pub artifact_refs: Vec<RadrootsNostrEventRef>,
+ pub author_asserted_rights: Option<RadrootsRightsAssertion>,
+ pub topics: Vec<String>,
+ pub summary: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeClaim {
+ pub schema: String,
+ pub schema_version: u16,
+ pub claim_type: String,
+ pub text: String,
+ pub citation_spans: Vec<RadrootsKnowledgeCitationSpan>,
+ pub topics: Vec<String>,
+ pub applies_to: Vec<String>,
+ pub author_asserted_confidence: Option<String>,
+ pub supersedes: Vec<RadrootsNostrEventRef>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeCitationSpan {
+ pub source_ref: RadrootsNostrEventRef,
+ pub artifact_ref: Option<RadrootsNostrEventRef>,
+ pub page_start: Option<u32>,
+ pub page_end: Option<u32>,
+ pub section_path: Vec<String>,
+ pub quote_hash: Option<String>,
+ pub chunk_id: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeNodeRef {
+ pub node_type: String,
+ pub event_ref: Option<RadrootsNostrEventRef>,
+ pub address_ref: Option<RadrootsAddressableRef>,
+ pub external_id: Option<String>,
+ pub label: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeRelation {
+ pub schema: String,
+ pub schema_version: u16,
+ pub subject: RadrootsKnowledgeNodeRef,
+ pub predicate: String,
+ pub object: RadrootsKnowledgeNodeRef,
+ pub support_refs: Vec<RadrootsNostrEventRef>,
+ pub author_asserted_confidence: Option<String>,
+ pub supersedes: Vec<RadrootsNostrEventRef>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeReview {
+ pub schema: String,
+ pub schema_version: u16,
+ pub target: RadrootsKnowledgeReviewTarget,
+ pub reviewer_role: String,
+ pub verdict: String,
+ pub scores: Vec<RadrootsKnowledgeReviewScore>,
+ pub notes: Option<String>,
+ pub evidence_refs: Vec<RadrootsNostrEventRef>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeReviewTarget {
+ pub event_id: String,
+ pub author_pubkey: String,
+ pub kind: u32,
+ pub address: Option<String>,
+ pub relays: Vec<String>,
+ pub review_scope: RadrootsKnowledgeReviewScope,
+}
+
+#[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))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsKnowledgeReviewScope {
+ SpecificVersion,
+ AddressableCoordinateAtPublishedAt,
+ PolicyLatest,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeReviewScore {
+ pub dimension: String,
+ pub value: String,
+ pub note: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeFieldReport {
+ pub schema: String,
+ pub schema_version: u16,
+ pub report_type: String,
+ pub title: String,
+ pub summary: Option<String>,
+ pub context: RadrootsKnowledgeFieldContext,
+ pub observations: Vec<RadrootsKnowledgeObservation>,
+ pub artifact_refs: Vec<RadrootsNostrEventRef>,
+ pub related_refs: Vec<RadrootsNostrEventRef>,
+ pub limitations: Vec<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeFieldContext {
+ pub location_precision: RadrootsKnowledgeLocationPrecision,
+ pub public_location: Option<RadrootsKnowledgeLocation>,
+ pub private_location_ref: Option<RadrootsNostrEventRef>,
+ pub topics: Vec<String>,
+ pub context_tags: Vec<String>,
+}
+
+#[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))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsKnowledgeLocationPrecision {
+ None,
+ Region,
+ Locality,
+ CoarseGeohash,
+ ExactPublic,
+ ExactPrivateReference,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeLocation {
+ pub label: Option<String>,
+ pub region: Option<String>,
+ pub locality: Option<String>,
+ pub geohash: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeObservation {
+ pub observation_type: String,
+ pub text: String,
+ pub observed_at: Option<String>,
+ pub values: Vec<RadrootsKnowledgeObservationValue>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeObservationValue {
+ pub key: String,
+ pub value: String,
+ pub unit: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsEvidenceBounty {
+ pub schema: String,
+ pub schema_version: u16,
+ pub d_tag: String,
+ pub title: String,
+ pub summary: Option<String>,
+ pub topics: Vec<String>,
+ pub target_refs: Vec<RadrootsNostrEventRef>,
+ pub reward_note: Option<String>,
+ pub closes_at: Option<String>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsKnowledgeChangeProposal {
+ pub schema: String,
+ pub schema_version: u16,
+ pub target: RadrootsNostrEventRef,
+ pub proposal_type: String,
+ pub summary: String,
+ pub rationale: Option<String>,
+ pub evidence_refs: Vec<RadrootsNostrEventRef>,
+ pub supersedes: Vec<RadrootsNostrEventRef>,
+}
+
+#[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))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsContributionAttestation {
+ pub schema: String,
+ pub schema_version: u16,
+ pub contributor_pubkey: String,
+ pub contribution_type: String,
+ pub subject_refs: Vec<RadrootsNostrEventRef>,
+ pub summary: String,
+ pub evidence_refs: Vec<RadrootsNostrEventRef>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn event_ref() -> RadrootsNostrEventRef {
+ RadrootsNostrEventRef {
+ id: "0".repeat(64),
+ author: "1".repeat(64),
+ kind: 1,
+ d_tag: None,
+ relays: None,
+ }
+ }
+
+ #[test]
+ fn normalizes_wiki_d_tags() {
+ assert_eq!(
+ normalize_wiki_d_tag(" Soil Health Basics "),
+ "soil-health-basics"
+ );
+ assert_eq!(
+ normalize_wiki_d_tag("Crème Brûlée 2026"),
+ "crème-brûlée-2026"
+ );
+ assert_eq!(normalize_wiki_d_tag("土壌 健康 101"), "土壌-健康-101");
+ assert_eq!(
+ normalize_wiki_d_tag("Почва Здоровье 101"),
+ "почва-здоровье-101"
+ );
+ assert_eq!(normalize_wiki_d_tag("El Niño y Suelo"), "el-niño-y-suelo");
+ assert_eq!(
+ normalize_wiki_d_tag("Field: Water & Soil!"),
+ "field-water-soil"
+ );
+ assert_eq!(normalize_wiki_d_tag("--Field---Notes--"), "field-notes");
+ assert_eq!(normalize_wiki_d_tag("中文 農業 101"), "中文-農業-101");
+ assert_eq!(normalize_wiki_d_tag("soil 🌱 health"), "soil-health");
+ assert_eq!(normalize_wiki_d_tag("!!! 🌱 !!!"), "");
+ assert_eq!(
+ normalize_wiki_d_tag(&normalize_wiki_d_tag("Soil Health Basics")),
+ "soil-health-basics"
+ );
+ }
+
+ #[test]
+ fn validates_normalized_wiki_d_tags() {
+ assert_eq!(validate_wiki_d_tag("soil-health-basics"), Ok(()));
+ assert_eq!(validate_wiki_d_tag(""), Err(RadrootsWikiDTagError::Empty));
+ assert_eq!(
+ validate_wiki_d_tag("Soil Health"),
+ Err(RadrootsWikiDTagError::NotNormalized {
+ normalized: "soil-health".to_string()
+ })
+ );
+ }
+
+ #[test]
+ fn rejects_oversized_wiki_d_tags() {
+ let value = "a".repeat(RADROOTS_WIKI_D_TAG_MAX_LEN + 1);
+
+ assert_eq!(
+ validate_wiki_d_tag(&value),
+ Err(RadrootsWikiDTagError::TooLong {
+ max: RADROOTS_WIKI_D_TAG_MAX_LEN,
+ actual: RADROOTS_WIKI_D_TAG_MAX_LEN + 1,
+ })
+ );
+ }
+
+ #[test]
+ fn exposes_knowledge_schema_ids() {
+ assert_eq!(RADROOTS_KNOWLEDGE_SCHEMA_VERSION, 1);
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_SOURCE_SCHEMA,
+ "radroots.knowledge.source.v1"
+ );
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_CLAIM_SCHEMA,
+ "radroots.knowledge.claim.v1"
+ );
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_RELATION_SCHEMA,
+ "radroots.knowledge.relation.v1"
+ );
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_REVIEW_SCHEMA,
+ "radroots.knowledge.review.v1"
+ );
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_FIELD_REPORT_SCHEMA,
+ "radroots.knowledge.field_report.v1"
+ );
+ assert_eq!(
+ RADROOTS_EVIDENCE_BOUNTY_SCHEMA,
+ "radroots.knowledge.evidence_bounty.v1"
+ );
+ assert_eq!(
+ RADROOTS_KNOWLEDGE_CHANGE_PROPOSAL_SCHEMA,
+ "radroots.knowledge.change_proposal.v1"
+ );
+ assert_eq!(
+ RADROOTS_CONTRIBUTION_ATTESTATION_SCHEMA,
+ "radroots.knowledge.contribution_attestation.v1"
+ );
+ }
+
+ #[test]
+ fn models_wiki_article_as_djot_payload() {
+ let article = RadrootsWikiArticle {
+ d_tag: "soil-health".to_string(),
+ title: "Soil health".to_string(),
+ content_djot: "# Soil health".to_string(),
+ summary: Some("Living soil basics".to_string()),
+ topics: vec!["soil".to_string()],
+ references: vec![event_ref()],
+ forked_from: Vec::new(),
+ deferred_to: None,
+ };
+
+ assert_eq!(article.content_djot, "# Soil health");
+ assert_eq!(article.topics, vec!["soil"]);
+ assert_eq!(article.references.len(), 1);
+ }
+
+ #[test]
+ fn models_author_asserted_rights_without_trusted_status() {
+ let source = 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::new(),
+ author_asserted_rights: Some(RadrootsRightsAssertion {
+ assertion: "author_asserted_public_domain".to_string(),
+ holder: None,
+ license: None,
+ url: None,
+ }),
+ topics: vec!["soil".to_string()],
+ summary: None,
+ };
+
+ assert_eq!(source.schema_version, 1);
+ assert_eq!(
+ source
+ .author_asserted_rights
+ .as_ref()
+ .expect("rights")
+ .assertion,
+ "author_asserted_public_domain"
+ );
+ }
+
+ #[test]
+ fn models_field_context_without_exact_private_coordinates() {
+ let context = RadrootsKnowledgeFieldContext {
+ location_precision: RadrootsKnowledgeLocationPrecision::ExactPrivateReference,
+ public_location: Some(RadrootsKnowledgeLocation {
+ label: Some("watershed edge".to_string()),
+ region: Some("sample-region".to_string()),
+ locality: None,
+ geohash: Some("c23".to_string()),
+ }),
+ private_location_ref: Some(event_ref()),
+ topics: vec!["water".to_string()],
+ context_tags: vec!["field".to_string()],
+ };
+
+ assert_eq!(
+ context.location_precision,
+ RadrootsKnowledgeLocationPrecision::ExactPrivateReference
+ );
+ assert!(context.private_location_ref.is_some());
+ assert_eq!(
+ context.public_location.as_ref().expect("location").geohash,
+ Some("c23".to_string())
+ );
+ }
+}
diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs
@@ -35,6 +35,8 @@ pub mod job_feedback;
pub mod job_request;
pub mod job_result;
pub mod kinds;
+#[cfg(feature = "knowledge")]
+pub mod knowledge;
pub mod list;
pub mod list_set;
pub mod listing;
@@ -75,7 +77,7 @@ pub struct RadrootsNostrEvent {
#[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))]
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsNostrEventRef {
pub id: String,
pub author: String,
diff --git a/crates/events/src/tags.rs b/crates/events/src/tags.rs
@@ -38,6 +38,7 @@ pub const TAG_MAGNET: &str = "magnet";
pub const TAG_SERVICE: &str = "service";
pub const TAG_RELAY: &str = "relay";
pub const TAG_CHALLENGE: &str = "challenge";
+pub const TAG_CONTRACT: &str = "contract";
pub const TAG_TITLE: &str = "title";
pub const TAG_PUBLISHED_AT: &str = "published_at";
pub const TAG_START: &str = "start";
@@ -103,6 +104,7 @@ mod tests {
assert_eq!(TAG_SERVICE, "service");
assert_eq!(TAG_RELAY, "relay");
assert_eq!(TAG_CHALLENGE, "challenge");
+ assert_eq!(TAG_CONTRACT, "contract");
}
#[test]
diff --git a/dto_bindgen.toml b/dto_bindgen.toml
@@ -69,6 +69,7 @@ source_files = [
"crates/events/src/job_feedback.rs",
"crates/events/src/job_request.rs",
"crates/events/src/job_result.rs",
+ "crates/events/src/knowledge.rs",
"crates/events/src/lib.rs",
"crates/events/src/list.rs",
"crates/events/src/list_set.rs",