field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 76eebde6b6260c19282a2c64d4410a5c717aa86b
parent a5e09c07f6f773f1399913402741202b43314387
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 09:02:06 +0000

feat: add typed phase 1 authoring profiles

- Register strict NIP-52 date and time authoring contracts.
- Bind all five mobile Add commands to immutable authored plans.
- Add sealed calendar signing and external-host completion paths.
- Freeze exact wire vectors and adversarial historical validation.

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 4++++
Mcore/crates/tera_core/src/runtime/product_surface.rs | 5+++++
Acore/crates/tera_core/src/runtime/product_surface/authoring.rs | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 319 insertions(+), 0 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -39,4 +39,8 @@ thiserror = { workspace = true } [dev-dependencies] nostr = { workspace = true, features = ["std"] } +radroots_blossom = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -4,6 +4,7 @@ //! and admission remain in lower event crates; persistence and live query //! composition remain in the runtime slices that consume these types. +mod authoring; mod context; mod cursor; mod identity; @@ -11,6 +12,10 @@ mod model; mod projection; mod ranking; +pub use authoring::{ + CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, + Phase1AddCommand, Phase1ReplacementPolicy, phase1_retraction_plan, +}; pub use context::{ ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, LocalityEvidence, diff --git a/core/crates/tera_core/src/runtime/product_surface/authoring.rs b/core/crates/tera_core/src/runtime/product_surface/authoring.rs @@ -0,0 +1,310 @@ +use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}, + food::availability::FoodAvailabilityDetails, + post::{ + AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, + deletion::AuthoredNip09DeletionRequest, + }, +}; +use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError}; + +use super::AddCommandType; + +/// A strict `CreateUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateUpdate(AuthoredUpdate); + +impl CreateUpdate { + pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> { + AuthoredUpdate::new(content).map(Self) + } + + pub const fn authored(&self) -> &AuthoredUpdate { + &self.0 + } +} + +/// A strict `CreatePhotoUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatePhotoUpdate(AuthoredPhotoUpdate); + +impl CreatePhotoUpdate { + pub fn new( + content: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredPhotoUpdate::new(content, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredPhotoUpdate { + &self.0 + } +} + +/// A strict `CreateAsk` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateAsk(AuthoredAsk); + +impl CreateAsk { + pub fn new( + question: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredAsk::new(question, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredAsk { + &self.0 + } +} + +/// A strict `CreateEvent` command with an explicit all-day or timed profile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateEvent(Box<CreateEventProfile>); + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CreateEventProfile { + Date(AuthoredCalendarDateEvent), + Time(AuthoredCalendarTimeEvent), +} + +impl CreateEvent { + pub fn date(event: AuthoredCalendarDateEvent) -> Self { + Self(Box::new(CreateEventProfile::Date(event))) + } + + pub fn time(event: AuthoredCalendarTimeEvent) -> Self { + Self(Box::new(CreateEventProfile::Time(event))) + } +} + +/// A strict `CreateFoodAvailability` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateFoodAvailability(FoodAvailabilityDetails); + +impl CreateFoodAvailability { + pub const fn new(details: FoodAvailabilityDetails) -> Self { + Self(details) + } + + pub const fn authored(&self) -> &FoodAvailabilityDetails { + &self.0 + } +} + +/// The only five Phase 1 Add commands accepted by focused mobile authoring. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Phase1AddCommand { + CreateUpdate(CreateUpdate), + CreatePhotoUpdate(CreatePhotoUpdate), + CreateAsk(CreateAsk), + CreateEvent(CreateEvent), + CreateFoodAvailability(CreateFoodAvailability), +} + +/// Standard revision behavior for a Phase 1 authored profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase1ReplacementPolicy { + /// Regular kind-1 events have no edit convention; retract and create are + /// independent authored operations whose partial effects remain visible. + RetractThenCreate, + /// Addressable events replace the current head by reusing their stable `d`. + AddressableReplacement, +} + +impl Phase1AddCommand { + pub const fn command_type(&self) -> AddCommandType { + match self { + Self::CreateUpdate(_) => AddCommandType::CreateUpdate, + Self::CreatePhotoUpdate(_) => AddCommandType::CreatePhotoUpdate, + Self::CreateAsk(_) => AddCommandType::CreateAsk, + Self::CreateEvent(_) => AddCommandType::CreateEvent, + Self::CreateFoodAvailability(_) => AddCommandType::CreateFoodAvailability, + } + } + + pub const fn replacement_policy(&self) -> Phase1ReplacementPolicy { + match self { + Self::CreateUpdate(_) | Self::CreatePhotoUpdate(_) | Self::CreateAsk(_) => { + Phase1ReplacementPolicy::RetractThenCreate + } + Self::CreateEvent(_) | Self::CreateFoodAvailability(_) => { + Phase1ReplacementPolicy::AddressableReplacement + } + } + } + + /// Binds the validated command to one exact timestamp and expected author. + pub fn authored_plan( + &self, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<AuthoredEventPlan, AuthoredPlanError> { + let expected_author = expected_author.as_ref(); + match self { + Self::CreateUpdate(command) => { + AuthoredEventPlan::from_update(command.authored(), created_at, expected_author) + } + Self::CreatePhotoUpdate(command) => AuthoredEventPlan::from_photo_update( + command.authored(), + created_at, + expected_author, + ), + Self::CreateAsk(command) => { + AuthoredEventPlan::from_ask(command.authored(), created_at, expected_author) + } + Self::CreateEvent(command) => match command.0.as_ref() { + CreateEventProfile::Date(event) => { + AuthoredEventPlan::from_calendar_date_event(event, created_at, expected_author) + } + CreateEventProfile::Time(event) => { + AuthoredEventPlan::from_calendar_time_event(event, created_at, expected_author) + } + }, + Self::CreateFoodAvailability(command) => AuthoredEventPlan::from_food_availability( + command.authored(), + created_at, + expected_author, + ), + } + } +} + +/// Builds the independent strict NIP-09 plan used for retraction or withdrawal. +/// +/// This function intentionally does not combine retraction and replacement +/// into one purportedly atomic operation. +pub fn phase1_retraction_plan( + request: &AuthoredNip09DeletionRequest, + created_at: u64, + expected_author: impl AsRef<str>, +) -> Result<AuthoredEventPlan, AuthoredPlanError> { + AuthoredEventPlan::from_nip09_deletion_request(request, created_at, expected_author) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; + use radroots_event::{ + calendar::CalendarDate, + envelope::kind::{ + KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST, + }, + food::availability::{ + FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency, + FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit, + }, + media::AuthoredImage, + post::PostImageDimensions, + post::deletion::{AuthoredNip09DeletionRequest, Nip09DeletionEventTarget}, + }; + + const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn focused_commands_bind_only_locked_wire_profiles() { + let date = AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").unwrap(), + ) + .unwrap(); + let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(); + let image = post_image(); + let commands = [ + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new(format!("Harvest photo {}", image.url()), vec![image]) + .unwrap(), + ), + Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), + Phase1AddCommand::CreateEvent(CreateEvent::date(date)), + Phase1AddCommand::CreateEvent(CreateEvent::time(time)), + Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), + ]; + let expected = [ + (AddCommandType::CreateUpdate, KIND_POST), + (AddCommandType::CreatePhotoUpdate, KIND_POST), + (AddCommandType::CreateAsk, KIND_POST), + (AddCommandType::CreateEvent, KIND_CALENDAR_DATE_EVENT), + (AddCommandType::CreateEvent, KIND_CALENDAR_TIME_EVENT), + ( + AddCommandType::CreateFoodAvailability, + KIND_CLASSIFIED_LISTING, + ), + ]; + for (command, (command_type, kind)) in commands.iter().zip(expected) { + let plan = command.authored_plan(1_784_347_200, AUTHOR).unwrap(); + assert_eq!(command.command_type(), command_type); + assert_eq!(plan.body().kind(), kind); + assert_ne!(plan.body().kind(), 20); + } + } + + #[test] + fn revision_and_retraction_semantics_are_explicit() { + let update = Phase1AddCommand::CreateUpdate(CreateUpdate::new("new post").unwrap()); + assert_eq!( + update.replacement_policy(), + Phase1ReplacementPolicy::RetractThenCreate + ); + let event = Phase1AddCommand::CreateEvent(CreateEvent::time( + AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(), + )); + assert_eq!( + event.replacement_policy(), + Phase1ReplacementPolicy::AddressableReplacement + ); + + let request = AuthoredNip09DeletionRequest::new( + "retracted", + vec![Nip09DeletionEventTarget::parse("b".repeat(64), KIND_POST).unwrap()], + Vec::new(), + ) + .unwrap(); + let plan = phase1_retraction_plan(&request, 1_784_347_201, AUTHOR).unwrap(); + assert_eq!(plan.body().kind(), 5); + } + + fn food() -> FoodAvailabilityDetails { + FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available this week.").unwrap(), + identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), + title: FoodText::new("Nantes Carrots").unwrap(), + summary: FoodText::new("Fresh bunches").unwrap(), + published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), + location: FoodText::new("Central Saanich, BC").unwrap(), + price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) + .unwrap(), + quantity: None, + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .unwrap() + } + + fn post_image() -> AuthoredPostImage { + let bytes = b"harvest-photo"; + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_100, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + AuthoredPostImage::new( + AuthoredImage::try_from(descriptor).unwrap(), + PostImageDimensions::new(1200, 900).unwrap(), + "Harvest", + ) + .unwrap() + } +}