lib

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

commit 7315ddd1c3d4cff883e5236d9f898884575607c2
parent ae259126fed72abdb069425a0da9920595681ed0
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 17:13:57 -0700

events_codec: add farm crdt codec

- add CRDT change tag builders and JSON wire encoding
- decode h, d, a, p, and t invariants with workspace address validation
- validate schema, base64url change payloads, author context, and business time
- cover valid task changes plus malformed envelope rejection paths

Diffstat:
Acrates/events_codec/src/farm_crdt/decode.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_crdt/encode.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_crdt/mod.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 1+
4 files changed, 498 insertions(+), 0 deletions(-)

diff --git a/crates/events_codec/src/farm_crdt/decode.rs b/crates/events_codec/src/farm_crdt/decode.rs @@ -0,0 +1,185 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + RadrootsNostrEvent, + farm_crdt::{ + KIND_FARM_CRDT_CHANGE, RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RADROOTS_FARM_CRDT_TAG, + RadrootsFarmCrdtChange, + }, + farm_workspace::KIND_FARM_WORKSPACE_MANIFEST, + tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, +}; + +use crate::d_tag::validate_d_tag_tag; +use crate::error::EventParseError; +use crate::farm_crdt::encode::validate_change; +use crate::field_helpers::{ + is_non_empty_base64url, optional_tag_value, parse_address_tag_with_kind, required_tag_value, + tag_values, validate_non_empty_tag_value, +}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_KIND: &str = "78"; + +pub fn farm_crdt_change_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsFarmCrdtChange, EventParseError> { + farm_crdt_change_from_event_inner(kind, tags, content, None) +} + +pub fn farm_crdt_change_from_event_with_author( + kind: u32, + tags: &[Vec<String>], + content: &str, + author_pubkey: &str, +) -> Result<RadrootsFarmCrdtChange, EventParseError> { + validate_non_empty_tag_value(author_pubkey, TAG_P)?; + farm_crdt_change_from_event_inner(kind, tags, content, Some(author_pubkey)) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsFarmCrdtChange>, EventParseError> { + let change = farm_crdt_change_from_event_with_author(kind, &tags, &content, &author)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + change, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsFarmCrdtChange>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +fn farm_crdt_change_from_event_inner( + kind: u32, + tags: &[Vec<String>], + content: &str, + author_pubkey: Option<&str>, +) -> Result<RadrootsFarmCrdtChange, EventParseError> { + if kind != KIND_FARM_CRDT_CHANGE { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + + let farm_group_id = required_tag_value(tags, TAG_H)?; + let document_id = required_tag_value(tags, TAG_D)?; + validate_d_tag_tag(&document_id, TAG_D)?; + let workspace_address = required_tag_value(tags, TAG_A)?; + let workspace = + parse_address_tag_with_kind(&workspace_address, KIND_FARM_WORKSPACE_MANIFEST, TAG_A)?; + let marker_tags = tag_values(tags, TAG_T)?; + if !marker_tags + .iter() + .any(|value| value == RADROOTS_FARM_CRDT_TAG) + { + return Err(EventParseError::MissingTag(TAG_T)); + } + if let Some(tag_author) = optional_tag_value(tags, TAG_P)? { + if let Some(author_pubkey) = author_pubkey { + if tag_author != author_pubkey { + return Err(EventParseError::InvalidTag(TAG_P)); + } + } + } + + let change: RadrootsFarmCrdtChange = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + validate_change_content(&change)?; + validate_change(&change).map_err(encode_error_to_parse_error)?; + if change.farm_group_id != farm_group_id { + return Err(EventParseError::InvalidTag(TAG_H)); + } + if change.document_id != document_id { + return Err(EventParseError::InvalidTag(TAG_D)); + } + if change.workspace.pubkey != workspace.pubkey || change.workspace.d_tag != workspace.d_tag { + return Err(EventParseError::InvalidTag(TAG_A)); + } + Ok(change) +} + +fn validate_change_content(change: &RadrootsFarmCrdtChange) -> Result<(), EventParseError> { + if change.schema != RADROOTS_FARM_CRDT_CHANGE_SCHEMA { + return Err(EventParseError::InvalidJson("schema")); + } + validate_non_empty_tag_value(&change.farm_group_id, TAG_H)?; + validate_d_tag_tag(&change.document_id, TAG_D)?; + validate_non_empty_tag_value(&change.workspace.pubkey, TAG_A)?; + validate_d_tag_tag(&change.workspace.d_tag, TAG_A)?; + if !is_non_empty_base64url(&change.encoded_change) { + return Err(EventParseError::InvalidJson("encoded_change")); + } + if change.change_hash.trim().is_empty() { + return Err(EventParseError::InvalidJson("change_hash")); + } + if change.business_time_ms == 0 { + return Err(EventParseError::InvalidJson("business_time_ms")); + } + Ok(()) +} + +fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError { + match error { + crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }, + crate::error::EventEncodeError::EmptyRequiredField(field) + | crate::error::EventEncodeError::InvalidField(field) => match field { + "farm_group_id" => EventParseError::InvalidTag(TAG_H), + "document_id" => EventParseError::InvalidTag(TAG_D), + "workspace.pubkey" | "workspace.d_tag" => EventParseError::InvalidTag(TAG_A), + _ => EventParseError::InvalidJson(field), + }, + crate::error::EventEncodeError::Json => EventParseError::InvalidJson("content"), + } +} diff --git a/crates/events_codec/src/farm_crdt/encode.rs b/crates/events_codec/src/farm_crdt/encode.rs @@ -0,0 +1,120 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + farm_crdt::{ + KIND_FARM_CRDT_CHANGE, RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RADROOTS_FARM_CRDT_TAG, + RadrootsFarmCrdtChange, + }, + farm_workspace::KIND_FARM_WORKSPACE_MANIFEST, + tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, +}; + +use crate::d_tag::validate_d_tag; +use crate::error::EventEncodeError; +use crate::field_helpers::{ + address_string, push_optional_tag, push_tag, validate_non_empty_base64url, + validate_non_empty_field, +}; +#[cfg(feature = "serde_json")] +use crate::wire::WireEventParts; + +pub fn farm_crdt_change_build_tags( + change: &RadrootsFarmCrdtChange, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + farm_crdt_change_build_tags_with_author(change, None) +} + +pub fn farm_crdt_change_build_tags_with_author( + change: &RadrootsFarmCrdtChange, + author_pubkey: Option<&str>, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_change(change)?; + if let Some(author_pubkey) = author_pubkey { + validate_non_empty_field(author_pubkey, "author_pubkey")?; + } + let workspace = address_string( + KIND_FARM_WORKSPACE_MANIFEST, + &change.workspace.pubkey, + &change.workspace.d_tag, + "workspace", + )?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_H, change.farm_group_id.as_str()); + push_tag(&mut tags, TAG_D, change.document_id.as_str()); + push_tag(&mut tags, TAG_A, workspace); + push_optional_tag(&mut tags, TAG_P, author_pubkey); + push_tag(&mut tags, TAG_T, RADROOTS_FARM_CRDT_TAG); + Ok(tags) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts(change: &RadrootsFarmCrdtChange) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(change, KIND_FARM_CRDT_CHANGE) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_author( + change: &RadrootsFarmCrdtChange, + author_pubkey: &str, +) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind_and_author(change, KIND_FARM_CRDT_CHANGE, Some(author_pubkey)) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind( + change: &RadrootsFarmCrdtChange, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind_and_author(change, kind, None) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind_and_author( + change: &RadrootsFarmCrdtChange, + kind: u32, + author_pubkey: Option<&str>, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_FARM_CRDT_CHANGE { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = farm_crdt_change_build_tags_with_author(change, author_pubkey)?; + let content = serde_json::to_string(change).map_err(|_| EventEncodeError::Json)?; + Ok(WireEventParts { + kind, + content, + tags, + }) +} + +pub(crate) fn validate_change(change: &RadrootsFarmCrdtChange) -> Result<(), EventEncodeError> { + if change.schema != RADROOTS_FARM_CRDT_CHANGE_SCHEMA { + return Err(EventEncodeError::InvalidField("schema")); + } + validate_non_empty_field(&change.farm_group_id, "farm_group_id")?; + validate_d_tag(&change.document_id, "document_id")?; + validate_non_empty_field(&change.workspace.pubkey, "workspace.pubkey")?; + validate_d_tag(&change.workspace.d_tag, "workspace.d_tag")?; + validate_non_empty_field(&change.actor_id, "actor_id")?; + validate_non_empty_field(&change.change_hash, "change_hash")?; + for dependency in &change.dependencies { + validate_non_empty_field(dependency, "dependencies")?; + } + validate_non_empty_base64url(&change.encoded_change, "encoded_change")?; + if change.business_time_ms == 0 { + return Err(EventEncodeError::InvalidField("business_time_ms")); + } + if let Some(version) = change.crdt_backend_version.as_deref() { + validate_non_empty_field(version, "crdt_backend_version")?; + } + if let Some(member_id) = change.author_member_id.as_deref() { + validate_non_empty_field(member_id, "author_member_id")?; + } + if let Some(app_version) = change.app_version.as_deref() { + validate_non_empty_field(app_version, "app_version")?; + } + Ok(()) +} diff --git a/crates/events_codec/src/farm_crdt/mod.rs b/crates/events_codec/src/farm_crdt/mod.rs @@ -0,0 +1,192 @@ +pub mod encode; + +#[cfg(feature = "serde_json")] +pub mod decode; + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use radroots_events::{ + farm_crdt::{ + KIND_FARM_CRDT_CHANGE, RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RADROOTS_FARM_CRDT_TAG, + RadrootsCrdtBackend, RadrootsFarmCrdtChange, RadrootsFarmCrdtDocumentKind, + RadrootsFarmSemanticKind, + }, + farm_workspace::{KIND_FARM_WORKSPACE_MANIFEST, RadrootsFarmWorkspaceRef}, + kinds::KIND_POST, + }; + + use crate::error::{EventEncodeError, EventParseError}; + use crate::farm_crdt::decode::{ + farm_crdt_change_from_event, farm_crdt_change_from_event_with_author, + }; + use crate::farm_crdt::encode::{ + farm_crdt_change_build_tags, to_wire_parts, to_wire_parts_with_author, + to_wire_parts_with_kind, + }; + + const WORKSPACE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + const DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg"; + const GROUP_ID: &str = "field-group"; + const AUTHOR: &str = "author_pubkey"; + + #[test] + fn farm_crdt_change_encodes_and_decodes_task_change() { + let change = sample_change(); + let parts = to_wire_parts_with_author(&change, AUTHOR).expect("crdt wire parts"); + + assert_eq!(parts.kind, KIND_FARM_CRDT_CHANGE); + assert!(parts.tags.contains(&tag("h", GROUP_ID))); + assert!(parts.tags.contains(&tag("d", DOCUMENT_ID))); + assert!( + parts + .tags + .contains(&tag("a", "30078:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA")) + ); + assert!(parts.tags.contains(&tag("p", AUTHOR))); + assert!(parts.tags.contains(&tag("t", RADROOTS_FARM_CRDT_TAG))); + + let decoded = farm_crdt_change_from_event_with_author( + parts.kind, + &parts.tags, + &parts.content, + AUTHOR, + ) + .expect("crdt decode"); + assert_eq!(decoded.schema, RADROOTS_FARM_CRDT_CHANGE_SCHEMA); + assert_eq!(decoded.document_id, DOCUMENT_ID); + assert_eq!(decoded.workspace.d_tag, WORKSPACE_D_TAG); + assert_eq!(decoded.business_time_ms, 1_780_000_000_000); + } + + #[test] + fn farm_crdt_change_rejects_missing_t_and_d_mismatch() { + let parts = to_wire_parts(&sample_change()).expect("crdt wire parts"); + let without_t = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("t")) + .cloned() + .collect::<Vec<_>>(); + + let missing_t = + farm_crdt_change_from_event(parts.kind, &without_t, &parts.content).unwrap_err(); + assert!(matches!(missing_t, EventParseError::MissingTag("t"))); + + let mut mismatched_d = parts.tags.clone(); + for tag in mismatched_d.iter_mut() { + if tag.first().map(|value| value.as_str()) == Some("d") { + tag[1] = WORKSPACE_D_TAG.to_string(); + } + } + let mismatch = + farm_crdt_change_from_event(parts.kind, &mismatched_d, &parts.content).unwrap_err(); + assert!(matches!(mismatch, EventParseError::InvalidTag("d"))); + } + + #[test] + fn farm_crdt_change_rejects_bad_workspace_address_and_author() { + let parts = to_wire_parts_with_author(&sample_change(), AUTHOR).expect("crdt wire parts"); + let mut bad_workspace = parts.tags.clone(); + for tag in bad_workspace.iter_mut() { + if tag.first().map(|value| value.as_str()) == Some("a") { + tag[1] = format!("{KIND_FARM_WORKSPACE_MANIFEST}:workspace_pubkey:bad"); + } + } + let workspace_err = + farm_crdt_change_from_event(parts.kind, &bad_workspace, &parts.content).unwrap_err(); + assert!(matches!(workspace_err, EventParseError::InvalidTag("a"))); + + let author_err = farm_crdt_change_from_event_with_author( + parts.kind, + &parts.tags, + &parts.content, + "other_author", + ) + .unwrap_err(); + assert!(matches!(author_err, EventParseError::InvalidTag("p"))); + } + + #[test] + fn farm_crdt_change_rejects_bad_encoded_change_missing_h_and_kind() { + let mut bad_change = sample_change(); + bad_change.encoded_change = "abc/def".to_string(); + let encode_err = farm_crdt_change_build_tags(&bad_change).unwrap_err(); + assert!(matches!( + encode_err, + EventEncodeError::InvalidField("encoded_change") + )); + + let parts = to_wire_parts(&sample_change()).expect("crdt wire parts"); + let without_h = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("h")) + .cloned() + .collect::<Vec<_>>(); + let missing_h = + farm_crdt_change_from_event(parts.kind, &without_h, &parts.content).unwrap_err(); + assert!(matches!(missing_h, EventParseError::MissingTag("h"))); + + let wrong_kind = to_wire_parts_with_kind(&sample_change(), KIND_POST).unwrap_err(); + assert!(matches!( + wrong_kind, + EventEncodeError::InvalidKind(KIND_POST) + )); + + let decode_wrong_kind = + farm_crdt_change_from_event(KIND_POST, &parts.tags, &parts.content).unwrap_err(); + assert!(matches!( + decode_wrong_kind, + EventParseError::InvalidKind { + expected: "78", + got: KIND_POST + } + )); + } + + #[test] + fn farm_crdt_change_rejects_zero_business_time_and_schema_mismatch() { + let mut zero_time = sample_change(); + zero_time.business_time_ms = 0; + let zero_err = to_wire_parts(&zero_time).unwrap_err(); + assert!(matches!( + zero_err, + EventEncodeError::InvalidField("business_time_ms") + )); + + let parts = to_wire_parts(&sample_change()).expect("crdt wire parts"); + let mut bad_schema = sample_change(); + bad_schema.schema = "radroots.farm.crdt.invalid".to_string(); + let content = serde_json::to_string(&bad_schema).expect("bad schema content"); + let schema_err = + farm_crdt_change_from_event(parts.kind, &parts.tags, &content).unwrap_err(); + assert!(matches!(schema_err, EventParseError::InvalidJson("schema"))); + } + + fn sample_change() -> RadrootsFarmCrdtChange { + RadrootsFarmCrdtChange { + schema: RADROOTS_FARM_CRDT_CHANGE_SCHEMA.to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: WORKSPACE_D_TAG.to_string(), + }, + farm_group_id: GROUP_ID.to_string(), + document_id: DOCUMENT_ID.to_string(), + document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + crdt_backend: RadrootsCrdtBackend::Automerge, + crdt_backend_version: Some("0.x".to_string()), + actor_id: "actor_abc".to_string(), + change_hash: "crdt_hash_abc".to_string(), + dependencies: Vec::new(), + encoded_change: "abc-DEF_012".to_string(), + semantic_kind: RadrootsFarmSemanticKind::FarmTaskCreate, + business_time_ms: 1_780_000_000_000, + author_member_id: Some("member_abc".to_string()), + app_version: Some("0.1.0".to_string()), + } + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -18,6 +18,7 @@ pub mod comment; pub mod coop; pub mod document; pub mod farm; +pub mod farm_crdt; pub mod farm_workspace; pub mod follow; pub mod geochat;