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 8ba3ec7eb57212cdee0839f2c69069ce0951df1e
parent 240d7b6891379ebd53482f2c3ec7d39d4e843cd2
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 16:35:42 +0000

feat: own add queue and upload policy

- derive draft identifiers and policy timestamps inside mobile core
- select writable relays and settlement from the active typed profile
- expose intent-only save queue recovery upload and cancellation calls
- verify all Add variants and the UniFFI delegation boundary

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 1+
Mcore/crates/tera_core/src/runtime/product_surface.rs | 10++++++----
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 427++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 433 insertions(+), 5 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -49,6 +49,7 @@ serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true, features = ["sync"] } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] nostr = { workspace = true, features = ["std"] } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -36,10 +36,12 @@ pub use model::{ }; #[cfg(feature = "mobile-social")] pub use outbox::{ - Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, Phase1DraftFormSnapshot, - Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1MediaOrphanRecord, - Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, - Phase1RelaySatisfaction, + Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, + Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, + Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, + Phase1OutboxState, Phase1QueueIntent, Phase1QueuePolicy, Phase1RelaySatisfaction, + Phase1UploadIntent, Phase1UploadPlan, phase1_new_addressable_identifier, + phase1_operation_now_unix_ms, }; pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -1,4 +1,8 @@ -use std::collections::BTreeSet; +use std::{ + collections::BTreeSet, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use radroots_blossom::{ BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, @@ -47,6 +51,11 @@ const DRAFT_MEDIA_MAX: usize = 20; const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096; const DRAFT_FAILURE_CODE_MAX_BYTES: usize = 96; const DRAFT_OPERATION_DOMAIN: &[u8] = b"radroots.mobile.phase1-draft-operation.v1\0"; +const ADD_DELIVERY_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000; +const BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS: u64 = 5; +const BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS: u64 = 5 * 60; +const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000; +const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Radroots image"; /// Product intent represented by one durable draft/outbox item. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -427,6 +436,151 @@ impl Phase1QueuePolicy { } } +/// Existing durable draft selected for an optimistic replacement. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1ExistingDraft { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1ExistingDraft { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// One typed Add save intent. Rust supplies the creation identifier and all +/// policy timestamps; a caller can only name an existing revision to replace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1AddIntent { + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, +} + +impl Phase1AddIntent { + pub fn new( + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, + ) -> Result<Self, Phase1DraftError> { + if media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::InvalidMedia); + } + form.validate(command.command_type(), &media)?; + Ok(Self { + command, + media, + form, + existing, + }) + } +} + +/// Minimal queue intent. Relay selection, settlement, deadline, and +/// cancellation are derived from the active typed Rust transport profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1QueueIntent { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1QueueIntent { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// Bounded exact-byte input for one Rust-planned Blossom upload attempt. +#[derive(Clone)] +pub struct Phase1UploadIntent { + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + dimensions: radroots_sdk::transport::BlossomImageDimensions, +} + +impl Phase1UploadIntent { + pub fn new( + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + width: u32, + height: u32, + ) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + if bytes.is_empty() { + return Err(Phase1DraftError::InvalidMedia); + } + let dimensions = radroots_sdk::transport::BlossomImageDimensions::new(width, height) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + Ok(Self { + draft_id, + expected_revision, + bytes, + media_type, + dimensions, + }) + } +} + +/// Immutable Rust-derived policy for one upload attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1UploadPlan { + pub authorization_content: String, + pub authorization_created_at_unix_s: u64, + pub authorization_lifetime_seconds: u64, + pub operation_id: [u8; 16], + pub artifact_id: [u8; 16], + pub signing_deadline_unix_ms: u64, + pub cancellation: Phase1CancellationPolicy, + pub updated_at_unix_ms: u64, +} + +impl Phase1UploadPlan { + fn derive( + now_unix_ms: u64, + operation_id: [u8; 16], + artifact_id: [u8; 16], + ) -> Result<Self, Phase1DraftError> { + let now_unix_s = now_unix_ms / 1_000; + if now_unix_s == 0 { + return Err(Phase1DraftError::ClockUnavailable); + } + Ok(Self { + authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), + authorization_created_at_unix_s: now_unix_s + .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), + authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, + operation_id, + artifact_id, + signing_deadline_unix_ms: now_unix_ms + .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?, + cancellation: Phase1CancellationPolicy::LocalCooperative, + updated_at_unix_ms: now_unix_ms, + }) + } +} + /// Honest aggregate state for a local draft and its durable authored operation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Phase1OutboxState { @@ -533,6 +687,12 @@ pub enum Phase1DraftError { Operation, #[error("phase 1 Today overlay failed")] Overlay, + #[error("phase 1 operation clock is unavailable")] + ClockUnavailable, + #[error("phase 1 operation deadline overflowed")] + DeadlineOverflow, + #[error("no configured relay authorizes publication")] + NoWritableRelay, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -653,6 +813,123 @@ impl Phase1DraftPayload { } impl RadrootsRuntime { + /// Persists one complete Add intent with Rust-owned identity and time. + pub async fn phase1_save_add_intent( + &self, + intent: Phase1AddIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let authored_at_unix_s = now_unix_ms / 1_000; + let (draft_id, expected_revision) = match intent.existing { + Some(existing) => (existing.draft_id, Some(existing.expected_revision)), + None => (phase1_random_id()?, None), + }; + self.phase1_save_draft_with_form( + draft_id, + intent.command, + authored_at_unix_s, + intent.media, + intent.form, + expected_revision, + now_unix_ms, + ) + .await + } + + /// Freezes the canonical Rust-owned queue policy for the active relay + /// profile before preparing the durable outbox operation. + pub async fn phase1_queue_add_intent( + &self, + intent: Phase1QueueIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let report = self + .client + .nostr_status() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let relay_urls = report + .relays() + .iter() + .filter(|relay| relay.endpoint().access().can_write()) + .map(|relay| relay.endpoint().url().as_str().to_owned()) + .collect::<Vec<_>>(); + if relay_urls.is_empty() { + return Err(Phase1DraftError::NoWritableRelay); + } + let deadline = now_unix_ms + .checked_add(ADD_DELIVERY_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?; + let policy = Phase1QueuePolicy::new( + relay_urls, + Phase1RelaySatisfaction::AllAccepted, + deadline, + Phase1CancellationPolicy::LocalCooperative, + )?; + self.phase1_queue_draft( + intent.draft_id, + intent.expected_revision, + policy, + now_unix_ms, + ) + .await + } + + /// Resumes a durable queue checkpoint with a Rust-owned recovery time. + pub async fn phase1_recover_add_intent( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?) + .await + } + + /// Plans authorization, signing, and state timestamps in Rust, then runs + /// one complete exact-byte upload attempt. + pub async fn phase1_upload_add_media_intent( + &self, + intent: Phase1UploadIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; + let request = radroots_sdk::transport::BlossomUploadRequest::new( + intent.bytes, + intent.media_type, + intent.dimensions, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let content = radroots_blossom::authorization::AuthorizationContent::parse( + &plan.authorization_content, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + self.phase1_upload_draft_media( + intent.draft_id, + intent.expected_revision, + request, + content, + plan.authorization_created_at_unix_s, + plan.authorization_lifetime_seconds, + plan.operation_id, + plan.artifact_id, + plan.signing_deadline_unix_ms, + plan.cancellation, + radroots_sdk::transport::BlossomCancellation::default(), + plan.updated_at_unix_ms, + ) + .await + } + + /// Cancels local work with a Rust-owned transition timestamp. + pub async fn phase1_cancel_add_intent( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?) + .await + } + /// Creates or replaces the editable content of one immutable-revision draft. #[allow(clippy::too_many_arguments)] pub async fn phase1_save_draft( @@ -1830,6 +2107,29 @@ fn map_overlay_error(_: TodayError) -> Phase1DraftError { Phase1DraftError::Overlay } +/// Captures the canonical wall-clock input for Rust-owned Phase 1 policy. +pub fn phase1_operation_now_unix_ms() -> Result<u64, Phase1DraftError> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value >= 1_000) + .ok_or(Phase1DraftError::ClockUnavailable) +} + +/// Generates a canonical public identifier for an addressable Add form. +pub fn phase1_new_addressable_identifier() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> { + let value = *uuid::Uuid::new_v4().as_bytes(); + if value.iter().all(|byte| *byte == 0) { + return Err(Phase1DraftError::OperationUnavailable); + } + Ok(value) +} + #[cfg(test)] mod tests { use super::*; @@ -1864,6 +2164,17 @@ mod tests { .unwrap() } + fn profiled_runtime(profile: radroots_sdk::transport::RelayProfile) -> RadrootsRuntime { + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + Some(profile), + None, + ) + .unwrap() + } + fn signing_runtime() -> RadrootsRuntime { let signer = radroots_nostr::signing::LocalSigner::new( radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), @@ -1916,6 +2227,120 @@ mod tests { } } + #[test] + fn upload_policy_derivation_is_exact_and_bounded() { + let plan = Phase1UploadPlan::derive(1_800_000_000_000, [7; 16], [8; 16]).unwrap(); + assert_eq!(plan.authorization_content, BLOSSOM_AUTHORIZATION_CONTENT); + assert_eq!(plan.authorization_created_at_unix_s, 1_799_999_995); + assert_eq!(plan.authorization_lifetime_seconds, 300); + assert_eq!(plan.operation_id, [7; 16]); + assert_eq!(plan.artifact_id, [8; 16]); + assert_eq!(plan.signing_deadline_unix_ms, 1_800_000_060_000); + assert_eq!( + plan.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert_eq!(plan.updated_at_unix_ms, 1_800_000_000_000); + assert_eq!( + Phase1UploadPlan::derive(u64::MAX, [7; 16], [8; 16]).unwrap_err(), + Phase1DraftError::DeadlineOverflow + ); + } + + #[tokio::test] + async fn add_intent_owns_draft_identity_time_and_writable_relay_policy() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [ + ( + "wss://read.example", + radroots_sdk::transport::RelayAccess::ReadOnly, + ), + ( + "wss://write.example", + radroots_sdk::transport::RelayAccess::ReadWrite, + ), + ], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(saved.draft().draft_id().as_bytes(), &[0; 16]); + assert!(saved.draft().created_at_unix_ms() >= 1_700_000_000_000); + + let queued = runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap(); + let payload = Phase1DraftPayload::decode(queued.draft()).unwrap(); + let queue = payload.queue.unwrap(); + assert_eq!(queue.relay_urls, vec!["wss://write.example"]); + assert_eq!(queue.satisfaction, Phase1RelaySatisfaction::AllAccepted); + assert_eq!( + queue.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert!( + queue.delivery_deadline_unix_ms + >= queued.draft().updated_at_unix_ms() + ADD_DELIVERY_TIMEOUT_MS + ); + } + + #[tokio::test] + async fn add_queue_intent_fails_closed_without_a_writable_relay() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [( + "wss://read.example", + radroots_sdk::transport::RelayAccess::ReadOnly, + )], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap_err(), + Phase1DraftError::NoWritableRelay + ); + } + #[tokio::test] async fn form_snapshots_reopen_exactly_and_freeze_after_queue() { let runtime = runtime();