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:
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;