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 1da8765f528ee17ad6c67a65f216058685419efb
parent 6e0e0ec524d53b3396357f18211d5af266889c6d
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 13:54:26 +0000

feat(mobile): persist phase 1 drafts and outbox

- add immutable integrity-bound draft revisions to memory and SQLite storage
- queue all five Add flows offline with frozen relay and media prerequisites
- preserve signed bytes and delivery evidence during phase-aware cancellation
- verify crash recovery conflicts bounds and release coverage gates

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 12+++++++-----
Mcore/crates/tera_core/src/runtime/product_surface.rs | 7+++++++
Acore/crates/tera_core/src/runtime/product_surface/outbox.rs | 1432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1446 insertions(+), 5 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -27,11 +27,18 @@ mobile-social = [ ] [dependencies] +radroots_blossom = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } radroots_sdk = { workspace = true, features = ["sqlite"] } radroots_event = { workspace = true, default-features = false, features = ["std"] } radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] } radroots_identity = { workspace = true, default-features = false, features = ["std"] } +radroots_signing = { workspace = true, default-features = false, features = ["std"] } radroots_storage = { workspace = true, default-features = false } +radroots_sync = { workspace = true, default-features = false } +radroots_transport = { workspace = true, default-features = false, features = ["std"] } chrono = { workspace = true } hex = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -41,11 +48,6 @@ thiserror = { workspace = true } [dev-dependencies] nostr = { workspace = true, features = ["std"] } -radroots_blossom = { workspace = true, default-features = false, features = [ - "serde", - "std", -] } radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } -radroots_transport = { workspace = true, default-features = false, features = ["std"] } tempfile = { workspace = true } 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 @@ -9,6 +9,8 @@ mod context; mod cursor; mod identity; mod model; +#[cfg(feature = "mobile-social")] +mod outbox; mod projection; mod ranking; mod today; @@ -30,6 +32,11 @@ pub use model::{ SearchResult, SearchResultType, SupportingProfile, ThreadEntry, ThreadReference, TodayCard, TodayCardType, TodayPage, }; +#[cfg(feature = "mobile-social")] +pub use outbox::{ + Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaPrerequisite, + Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, Phase1RelaySatisfaction, +}; pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; pub use today::{ diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -0,0 +1,1432 @@ +use std::collections::BTreeSet; + +use radroots_blossom::{BlobUrl, MediaType}; +use radroots_event::contract::AuthorRole; +use radroots_event_codec::authoring::PlanWireV1; +use radroots_identity::PublicKey; +use radroots_signing::{Actor, actor::ActorSource, request::CancellationPolicy}; +use radroots_storage::{ + authored::{AdmissionState, SigningState}, + authored_delivery::{AuthoredDeliveryState, DeliveryAttemptOutcome}, + authored_draft::{ + AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, AuthoredDraftStage, + AuthoredDraftStore, + }, + journal::{IdempotencyKey, OperationInstanceId}, +}; +use radroots_sync::{PushRequest, PushStatus, policy::SyncId}; +use radroots_transport::{ + Target, TargetSet, + outcome::DeliveryOutcomeKind, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand, + TodayCardType, TodayError, +}; +use crate::runtime::RadrootsRuntime; + +const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1"; +const DRAFT_SCHEMA_VERSION: u16 = 1; +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"; + +/// Durable state of one media prerequisite referenced by an Add command. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1MediaStage { + Pending, + Preparing, + Uploading, + Verified, + Failed, + Orphaned, +} + +/// Exact local and remote identity of one Add media prerequisite. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1MediaPrerequisite { + local_reference: String, + url: String, + sha256: String, + media_type: String, + byte_size: u64, + stage: Phase1MediaStage, + failure_code: Option<String>, +} + +impl Phase1MediaPrerequisite { + pub fn new( + local_reference: impl Into<String>, + url: impl Into<String>, + sha256: impl Into<String>, + media_type: impl Into<String>, + byte_size: u64, + stage: Phase1MediaStage, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + local_reference: local_reference.into(), + url: url.into(), + sha256: sha256.into(), + media_type: media_type.into(), + byte_size, + stage, + failure_code: None, + }; + value.validate()?; + Ok(value) + } + + pub fn with_failure_code(mut self, code: impl Into<String>) -> Result<Self, Phase1DraftError> { + self.stage = Phase1MediaStage::Failed; + self.failure_code = Some(code.into()); + self.validate()?; + Ok(self) + } + + fn validate(&self) -> Result<(), Phase1DraftError> { + let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?; + let hash = blob.hash_path().hash().to_string(); + if self.local_reference.is_empty() + || self.local_reference.len() > DRAFT_LOCAL_REFERENCE_MAX_BYTES + || self.local_reference != self.local_reference.trim() + || self.local_reference.chars().any(char::is_control) + || self.sha256 != hash + || MediaType::parse(self.media_type.as_str()).is_err() + || self.byte_size == 0 + || self.failure_code.as_ref().is_some_and(|code| { + code.is_empty() + || code.len() > DRAFT_FAILURE_CODE_MAX_BYTES + || code != code.trim() + || code.chars().any(char::is_control) + }) + || (self.stage == Phase1MediaStage::Failed) != self.failure_code.is_some() + { + return Err(Phase1DraftError::InvalidMedia); + } + Ok(()) + } + + pub fn url(&self) -> &str { + self.url.as_str() + } + pub const fn stage(&self) -> Phase1MediaStage { + self.stage + } +} + +/// Closed delivery-satisfaction profiles available to Phase 1 Add. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1RelaySatisfaction { + AnyAccepted, + AllAccepted, + AnyDelivered, + AllDelivered, +} + +/// Stable product-level cancellation policy persisted with queue intent. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1CancellationPolicy { + PreservePublishedRequest, + LocalCooperative, +} + +/// Exact relay and deadline intent frozen before an operation is prepared. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1QueuePolicy { + relay_urls: Vec<String>, + satisfaction: Phase1RelaySatisfaction, + delivery_deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, +} + +impl Phase1QueuePolicy { + pub fn new( + relay_urls: Vec<String>, + satisfaction: Phase1RelaySatisfaction, + delivery_deadline_unix_ms: u64, + cancellation: Phase1CancellationPolicy, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + relay_urls, + satisfaction, + delivery_deadline_unix_ms, + cancellation, + }; + value.materialize()?; + Ok(value) + } + + fn materialize( + &self, + ) -> Result<(TargetSet, SatisfactionPolicy, CancellationPolicy), Phase1DraftError> { + if self.delivery_deadline_unix_ms == 0 || self.relay_urls.is_empty() { + return Err(Phase1DraftError::InvalidQueuePolicy); + } + let mut canonical = BTreeSet::new(); + let targets = self + .relay_urls + .iter() + .map(|url| { + let target = + Target::nostr_relay(url).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; + if target.uri().as_str() != url || !canonical.insert(url.as_str()) { + return Err(Phase1DraftError::InvalidQueuePolicy); + } + Ok(target) + }) + .collect::<Result<Vec<_>, _>>()?; + let targets = TargetSet::new(targets).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?; + let (class, target_policy) = match self.satisfaction { + Phase1RelaySatisfaction::AnyAccepted => { + (SatisfactionClass::Accepted, TargetPolicy::any()) + } + Phase1RelaySatisfaction::AllAccepted => { + (SatisfactionClass::Accepted, TargetPolicy::all()) + } + Phase1RelaySatisfaction::AnyDelivered => { + (SatisfactionClass::Delivered, TargetPolicy::any()) + } + Phase1RelaySatisfaction::AllDelivered => { + (SatisfactionClass::Delivered, TargetPolicy::all()) + } + }; + let cancellation = match self.cancellation { + Phase1CancellationPolicy::PreservePublishedRequest => { + CancellationPolicy::PreservePublishedRequest + } + Phase1CancellationPolicy::LocalCooperative => CancellationPolicy::LocalCooperative, + }; + Ok(( + targets, + SatisfactionPolicy::new(class, target_policy), + cancellation, + )) + } +} + +/// Honest aggregate state for a local draft and its durable authored operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Phase1OutboxState { + Draft, + MediaPreparing, + MediaUploading, + ReadyToSign, + Signing, + Signed, + Queued, + Delivering, + PartiallyDelivered, + Retryable, + Terminal, + Cancelled, + Complete, +} + +impl Phase1OutboxState { + pub const fn label(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::MediaPreparing => "media_preparing", + Self::MediaUploading => "media_uploading", + Self::ReadyToSign => "ready_to_sign", + Self::Signing => "signing", + Self::Signed => "signed", + Self::Queued => "queued", + Self::Delivering => "delivering", + Self::PartiallyDelivered => "partially_delivered", + Self::Retryable => "retryable", + Self::Terminal => "terminal", + Self::Cancelled => "cancelled", + Self::Complete => "complete", + } + } +} + +/// Current reconstructable product view of one Add draft. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1DraftStatus { + draft: AuthoredDraft, + command_type: AddCommandType, + media: Vec<Phase1MediaPrerequisite>, + state: Phase1OutboxState, + card_id: CardId, + push: Option<PushStatus>, +} + +impl Phase1DraftStatus { + pub const fn draft(&self) -> &AuthoredDraft { + &self.draft + } + pub const fn command_type(&self) -> AddCommandType { + self.command_type + } + pub fn media(&self) -> &[Phase1MediaPrerequisite] { + self.media.as_slice() + } + pub const fn state(&self) -> Phase1OutboxState { + self.state + } + pub const fn card_id(&self) -> CardId { + self.card_id + } + pub const fn push(&self) -> Option<&PushStatus> { + self.push.as_ref() + } +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum Phase1DraftError { + #[error("authenticated draft identity is unavailable")] + IdentityUnavailable, + #[error("phase 1 draft input is invalid")] + InvalidDraft, + #[error("phase 1 media prerequisite is invalid")] + InvalidMedia, + #[error("phase 1 queue policy is invalid")] + InvalidQueuePolicy, + #[error("phase 1 draft revision conflicts with durable state")] + RevisionConflict, + #[error("phase 1 draft is not found")] + NotFound, + #[error("phase 1 draft is terminal")] + Terminal, + #[error("phase 1 draft media is not ready")] + MediaNotReady, + #[error("phase 1 authored operation is unavailable")] + OperationUnavailable, + #[error("phase 1 draft persistence failed")] + Storage, + #[error("phase 1 draft payload is corrupt")] + Corrupt, + #[error("phase 1 authored operation failed")] + Operation, + #[error("phase 1 Today overlay failed")] + Overlay, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Phase1DraftPayload { + schema_version: u16, + command_type: AddCommandType, + plan_wire_json: Vec<u8>, + media: Vec<Phase1MediaPrerequisite>, + queue: Option<Phase1QueuePolicy>, +} + +impl Phase1DraftPayload { + fn new( + command: &Phase1AddCommand, + plan_wire_json: Vec<u8>, + media: Vec<Phase1MediaPrerequisite>, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + command_type: command.command_type(), + plan_wire_json, + media, + queue: None, + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), Phase1DraftError> { + if self.schema_version != DRAFT_SCHEMA_VERSION || self.media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::Corrupt); + } + let integrity = PlanWireV1::from_json(self.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + let plan = integrity.plan(); + let expected_media = media_urls(plan.body().tags())?; + let actual_media = self + .media + .iter() + .map(|media| { + media.validate()?; + Ok(media.url.as_str()) + }) + .collect::<Result<BTreeSet<_>, Phase1DraftError>>()?; + if expected_media != actual_media || actual_media.len() != self.media.len() { + return Err(Phase1DraftError::InvalidMedia); + } + if let Some(queue) = &self.queue { + queue.materialize()?; + } + Ok(()) + } + + fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { + if draft.payload_schema() != DRAFT_PAYLOAD_SCHEMA { + return Err(Phase1DraftError::Corrupt); + } + let value = serde_json::from_slice::<Self>(draft.payload()) + .map_err(|_| Phase1DraftError::Corrupt)?; + value.validate()?; + let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().author().as_bytes() != draft.author() { + return Err(Phase1DraftError::Corrupt); + } + Ok(value) + } + + fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { + self.validate()?; + serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) + } +} + +impl RadrootsRuntime { + /// Creates or replaces the editable content of one immutable-revision draft. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_draft( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let author = self.draft_author()?; + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = command + .authored_plan(authored_at_unix_s, hex::encode(author)) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1DraftPayload::new(&command, wire, media)?; + let bytes = payload.encode()?; + let storage = self.storage()?; + let expected = expected_revision + .map(AuthoredDraftRevision::new) + .transpose() + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let draft = if let Some(expected) = expected { + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected + || head.stage().is_terminal() + || matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + { + return Err(Phase1DraftError::RevisionConflict); + } + head.successor( + bytes, + draft_stage_for_media(&payload.media), + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)? + } else { + AuthoredDraft::initial( + draft_id, + author, + DRAFT_PAYLOAD_SCHEMA, + bytes, + draft_stage_for_media(&payload.media), + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)? + }; + let receipt = storage + .append_authored_draft(draft, expected) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Advances one media prerequisite without mutating any prior revision. + pub async fn phase1_update_draft_media( + &self, + draft_id: [u8; 16], + expected_revision: u64, + url: &str, + stage: Phase1MediaStage, + failure_code: Option<String>, + updated_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected + || head.stage().is_terminal() + || matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let media = payload + .media + .iter_mut() + .find(|media| media.url == url) + .ok_or(Phase1DraftError::InvalidMedia)?; + if !valid_media_transition(media.stage, stage) { + return Err(Phase1DraftError::InvalidMedia); + } + media.stage = stage; + media.failure_code = failure_code; + media.validate()?; + let next_stage = match stage { + Phase1MediaStage::Pending | Phase1MediaStage::Preparing => { + AuthoredDraftStage::MediaPreparing + } + Phase1MediaStage::Uploading + | Phase1MediaStage::Verified + | Phase1MediaStage::Failed + | Phase1MediaStage::Orphaned => AuthoredDraftStage::MediaUploading, + }; + let next = head + .successor(payload.encode()?, next_stage, None, updated_at_unix_ms) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Freezes queue intent and atomically prepares the canonical outbox before + /// returning `queued`. Network connectivity is neither read nor required. + pub async fn phase1_queue_draft( + &self, + draft_id: [u8; 16], + expected_revision: u64, + policy: Phase1QueuePolicy, + queued_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let ready = match head.stage() { + AuthoredDraftStage::ReadyToSign => { + if payload.queue.as_ref() != Some(&policy) { + return Err(Phase1DraftError::RevisionConflict); + } + head + } + AuthoredDraftStage::Queued => { + if payload.queue.as_ref() != Some(&policy) { + return Err(Phase1DraftError::RevisionConflict); + } + return self.draft_status_from(head).await; + } + AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading => { + if payload + .media + .iter() + .any(|media| media.stage != Phase1MediaStage::Verified) + { + return Err(Phase1DraftError::MediaNotReady); + } + policy.materialize()?; + payload.queue = Some(policy); + let bytes = payload.encode()?; + let operation_id = operation_id(draft_id, bytes.as_slice())?; + let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let ready = head + .successor( + bytes, + AuthoredDraftStage::ReadyToSign, + Some(operation_id), + queued_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + storage + .append_authored_draft(ready.clone(), Some(expected)) + .await + .map_err(map_draft_storage_error)?; + ready + } + }; + self.finish_queue(ready, queued_at_unix_ms).await + } + + /// Resumes a queue transition interrupted after its durable ready-to-sign + /// checkpoint, including the crash window after outbox preparation. + pub async fn phase1_recover_draft_queue( + &self, + draft_id: [u8; 16], + recovered_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + match head.stage() { + AuthoredDraftStage::ReadyToSign => self.finish_queue(head, recovered_at_unix_ms).await, + AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled => { + self.draft_status_from(head).await + } + AuthoredDraftStage::Draft + | AuthoredDraftStage::MediaPreparing + | AuthoredDraftStage::MediaUploading => Err(Phase1DraftError::InvalidDraft), + } + } + + /// Returns durable draft state composed with canonical authored-operation state. + pub async fn phase1_draft_status( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + self.draft_status_from(head).await + } + + /// Lists the newest immutable revision of each draft for the active author. + pub async fn phase1_draft_heads( + &self, + limit: u16, + ) -> Result<Vec<Phase1DraftStatus>, Phase1DraftError> { + let drafts = self + .storage()? + .authored_draft_heads(self.draft_author()?, limit) + .await + .map_err(map_draft_storage_error)?; + let mut statuses = Vec::with_capacity(drafts.len()); + for draft in drafts { + statuses.push(self.draft_status_from(draft).await?); + } + Ok(statuses) + } + + /// Cancels still-pending authored work and records uploaded-but-unreferenced + /// media as possible orphans without deleting any evidence. + pub async fn phase1_cancel_draft( + &self, + draft_id: [u8; 16], + expected_revision: u64, + cancelled_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.stage() == AuthoredDraftStage::Cancelled { + return self.draft_status_from(head).await; + } + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let push = self.push_status_for(&head).await?; + if let Some(status) = &push { + self.sync()? + .cancel_push(sync_id_for(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + if status.artifact().signed().is_none() { + mark_possible_orphans(&mut payload.media); + } + } else { + mark_possible_orphans(&mut payload.media); + } + let next = head + .successor( + payload.encode()?, + AuthoredDraftStage::Cancelled, + head.operation_id(), + cancelled_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Applies the current durable operation state to an already-projected + /// active-author card. The overlay remains local and never changes event truth. + pub async fn phase1_apply_draft_overlay( + &self, + context: &LocalNetwork, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let status = self.phase1_draft_status(draft_id).await?; + let operation_id = status + .draft + .operation_id() + .map(|id| hex::encode(id.as_bytes())) + .ok_or(Phase1DraftError::OperationUnavailable)?; + self.phase1_set_local_author_overlay( + context, + status.card_id, + Some(LocalAuthorOverlay { + operation_id, + state: status.state.label().to_owned(), + }), + ) + .await + .map_err(map_overlay_error)?; + Ok(status) + } + + async fn finish_queue( + &self, + ready: AuthoredDraft, + queued_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let request = push_request(&ready)?; + self.sync()? + .prepare_push(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + let queued = ready + .successor( + ready.payload().to_vec(), + AuthoredDraftStage::Queued, + ready.operation_id(), + queued_at_unix_ms.max(ready.updated_at_unix_ms()), + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = self + .storage()? + .append_authored_draft(queued, Some(ready.revision())) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + async fn draft_status_from( + &self, + draft: AuthoredDraft, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + if draft.author() != &self.draft_author()? { + return Err(Phase1DraftError::Corrupt); + } + let payload = Phase1DraftPayload::decode(&draft)?; + let integrity = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + let card_id = card_id(payload.command_type, integrity.plan())?; + let push = self.push_status_for(&draft).await?; + if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { + return Err(Phase1DraftError::Corrupt); + } + let state = aggregate_state(&draft, push.as_ref()); + Ok(Phase1DraftStatus { + draft, + command_type: payload.command_type, + media: payload.media, + state, + card_id, + push, + }) + } + + async fn push_status_for( + &self, + draft: &AuthoredDraft, + ) -> Result<Option<PushStatus>, Phase1DraftError> { + let Some(_) = draft.operation_id() else { + return Ok(None); + }; + self.sync()? + .push_status(sync_id_for(draft)?) + .await + .map_err(|_| Phase1DraftError::Operation) + } + + fn storage(&self) -> Result<&dyn AuthoredDraftStore, Phase1DraftError> { + self.client + .storage() + .map(|storage| storage as &dyn AuthoredDraftStore) + .map_err(|_| Phase1DraftError::Storage) + } + + fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> { + self.client + .sync() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable) + } + + fn draft_author(&self) -> Result<[u8; 32], Phase1DraftError> { + self.store_public_key + .map(|key| *key.as_bytes()) + .ok_or(Phase1DraftError::IdentityUnavailable) + } +} + +fn push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { + let payload = Phase1DraftPayload::decode(draft)?; + let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; + let (targets, satisfaction, cancellation) = policy.materialize()?; + let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)? + .into_plan(); + let public_key = PublicKey::from_bytes(*draft.author()) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let sync_id = sync_id_for(draft)?; + let idempotency = IdempotencyKey::parse(format!( + "phase1-draft-{}", + hex::encode(draft.draft_id().as_bytes()) + )) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + PushRequest::new( + sync_id, + idempotency, + actor, + plan, + targets, + satisfaction, + policy.delivery_deadline_unix_ms, + cancellation, + ) + .map_err(|_| Phase1DraftError::InvalidQueuePolicy) +} + +fn operation_id( + draft_id: AuthoredDraftId, + ready_payload: &[u8], +) -> Result<SyncId, Phase1DraftError> { + let mut hasher = Sha256::new(); + hasher.update(DRAFT_OPERATION_DOMAIN); + hasher.update(draft_id.as_bytes()); + hasher.update( + u64::try_from(ready_payload.len()) + .map_err(|_| Phase1DraftError::InvalidDraft)? + .to_be_bytes(), + ); + hasher.update(ready_payload); + let digest: [u8; 32] = hasher.finalize().into(); + let mut value = [0_u8; 16]; + value.copy_from_slice(&digest[..16]); + if value.iter().all(|byte| *byte == 0) { + value[15] = 1; + } + SyncId::new(value).map_err(|_| Phase1DraftError::InvalidDraft) +} + +fn sync_id_for(draft: &AuthoredDraft) -> Result<SyncId, Phase1DraftError> { + draft + .operation_id() + .ok_or(Phase1DraftError::OperationUnavailable) + .and_then(|id| SyncId::new(*id.as_bytes()).map_err(|_| Phase1DraftError::Corrupt)) +} + +fn media_urls(tags: &[Vec<String>]) -> Result<BTreeSet<&str>, Phase1DraftError> { + let mut urls = BTreeSet::new(); + for tag in tags { + let candidate = match tag.first().map(String::as_str) { + Some("imeta") => tag + .iter() + .skip(1) + .find_map(|value| value.strip_prefix("url ")), + Some("image") => tag.get(1).map(String::as_str), + _ => None, + }; + if let Some(candidate) = candidate + && BlobUrl::parse(candidate).is_ok() + && !urls.insert(candidate) + { + return Err(Phase1DraftError::InvalidMedia); + } + } + Ok(urls) +} + +fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStage { + if media.is_empty() { + AuthoredDraftStage::Draft + } else if media + .iter() + .any(|media| media.stage == Phase1MediaStage::Uploading) + { + AuthoredDraftStage::MediaUploading + } else { + AuthoredDraftStage::MediaPreparing + } +} + +fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite]) { + for media in media { + if media.stage == Phase1MediaStage::Verified { + media.stage = Phase1MediaStage::Orphaned; + } + } +} + +const fn valid_media_transition(previous: Phase1MediaStage, next: Phase1MediaStage) -> bool { + match previous { + Phase1MediaStage::Pending => matches!( + next, + Phase1MediaStage::Pending + | Phase1MediaStage::Preparing + | Phase1MediaStage::Uploading + | Phase1MediaStage::Failed + ), + Phase1MediaStage::Preparing => matches!( + next, + Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed + ), + Phase1MediaStage::Uploading => matches!( + next, + Phase1MediaStage::Uploading | Phase1MediaStage::Verified | Phase1MediaStage::Failed + ), + Phase1MediaStage::Failed => matches!( + next, + Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed + ), + Phase1MediaStage::Verified => matches!(next, Phase1MediaStage::Verified), + Phase1MediaStage::Orphaned => matches!(next, Phase1MediaStage::Orphaned), + } +} + +fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1OutboxState { + if draft.stage() == AuthoredDraftStage::Cancelled { + return Phase1OutboxState::Cancelled; + } + let Some(push) = push else { + return match draft.stage() { + AuthoredDraftStage::Draft => Phase1OutboxState::Draft, + AuthoredDraftStage::MediaPreparing => Phase1OutboxState::MediaPreparing, + AuthoredDraftStage::MediaUploading => Phase1OutboxState::MediaUploading, + AuthoredDraftStage::ReadyToSign => Phase1OutboxState::ReadyToSign, + AuthoredDraftStage::Queued => Phase1OutboxState::Queued, + AuthoredDraftStage::Cancelled => Phase1OutboxState::Cancelled, + }; + }; + if push.settlement().is_successful() { + return Phase1OutboxState::Complete; + } + if push.settlement().has_failures() { + return if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0 + { + Phase1OutboxState::Retryable + } else if push.settlement().cancelled() != 0 || push.settlement().delivery_cancelled() != 0 + { + Phase1OutboxState::Cancelled + } else { + Phase1OutboxState::Terminal + }; + } + match push.artifact().signing_state() { + SigningState::Planned if push.artifact().signing_claim().is_some() => { + Phase1OutboxState::Signing + } + SigningState::Planned => Phase1OutboxState::Queued, + SigningState::Retryable => Phase1OutboxState::Retryable, + SigningState::Indeterminate | SigningState::FailedTerminal => Phase1OutboxState::Terminal, + SigningState::Cancelled => Phase1OutboxState::Cancelled, + SigningState::Signed => match push.artifact().admission_state() { + AdmissionState::Pending => Phase1OutboxState::Signed, + AdmissionState::Retryable => Phase1OutboxState::Retryable, + AdmissionState::Rejected => Phase1OutboxState::Terminal, + AdmissionState::Cancelled => Phase1OutboxState::Cancelled, + AdmissionState::Inserted | AdmissionState::Duplicate => match push + .delivery_plan() + .state() + { + AuthoredDeliveryState::Pending + if push.delivery_plan().claim_evidence().is_some() => + { + Phase1OutboxState::Delivering + } + AuthoredDeliveryState::Pending if push.delivery_plan().attempts().is_empty() => { + Phase1OutboxState::Queued + } + AuthoredDeliveryState::Pending => Phase1OutboxState::PartiallyDelivered, + AuthoredDeliveryState::Retryable if has_delivery_success(push) => { + Phase1OutboxState::PartiallyDelivered + } + AuthoredDeliveryState::Retryable => Phase1OutboxState::Retryable, + AuthoredDeliveryState::Satisfied => Phase1OutboxState::Complete, + AuthoredDeliveryState::Exhausted | AuthoredDeliveryState::FailedTerminal => { + Phase1OutboxState::Terminal + } + AuthoredDeliveryState::Cancelled => Phase1OutboxState::Cancelled, + }, + }, + } +} + +fn has_delivery_success(push: &PushStatus) -> bool { + push.delivery_plan().attempts().iter().any(|attempt| { + let evidence = match attempt.outcome() { + DeliveryAttemptOutcome::Receipt(receipt) => receipt.target_receipts(), + DeliveryAttemptOutcome::SinkFailure(failure) => failure.partial_evidence(), + }; + evidence.iter().any(|receipt| { + matches!( + receipt.outcome().kind(), + DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered + ) + }) + }) +} + +fn card_id( + command_type: AddCommandType, + plan: &radroots_event_codec::authoring::AuthoredEventPlan, +) -> Result<CardId, Phase1DraftError> { + let card_type = match command_type { + AddCommandType::CreateUpdate => TodayCardType::Update, + AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate, + AddCommandType::CreateAsk => TodayCardType::Ask, + AddCommandType::CreateEvent => TodayCardType::Event, + AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability, + }; + let source = if (30_000..40_000).contains(&plan.body().kind()) { + let identifier = plan + .body() + .tags() + .iter() + .find(|tag| tag.first().map(String::as_str) == Some("d") && tag.len() == 2) + .and_then(|tag| tag.get(1)) + .ok_or(Phase1DraftError::Corrupt)?; + CardSourceIdentity::address( + plan.body().kind(), + plan.author().to_hex(), + identifier.clone(), + ) + .map_err(|_| Phase1DraftError::Corrupt)? + } else { + CardSourceIdentity::Event(*plan.expected_event_id()) + }; + Ok(CardId::derive(card_type, &source)) +} + +fn map_draft_storage_error(error: radroots_storage::Error) -> Phase1DraftError { + match error { + radroots_storage::Error::DraftRevisionConflict => Phase1DraftError::RevisionConflict, + radroots_storage::Error::DraftNotFound => Phase1DraftError::NotFound, + radroots_storage::Error::CorruptAuthoredDraft => Phase1DraftError::Corrupt, + _ => Phase1DraftError::Storage, + } +} + +fn map_overlay_error(_: TodayError) -> Phase1DraftError { + Phase1DraftError::Overlay +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::product_surface::{ + CANONICAL_ADD_COMMAND_TYPES, CreateAsk, CreateEvent, CreateFoodAvailability, + CreatePhotoUpdate, CreateUpdate, + }; + use radroots_blossom::{BlobDescriptor, Sha256 as BlossomSha256}; + use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, CalendarDate}, + food::availability::{ + FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, + FoodContent, FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, + FoodUnit, + }, + media::AuthoredImage, + post::{AuthoredPostImage, PostImageDimensions}, + }; + use radroots_sdk::ClientBuilder; + + const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn runtime() -> RadrootsRuntime { + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + ) + .unwrap() + } + + fn policy() -> Phase1QueuePolicy { + Phase1QueuePolicy::new( + vec![ + "wss://relay-one.example".to_owned(), + "wss://relay-two.example".to_owned(), + ], + Phase1RelaySatisfaction::AllAccepted, + 2_000_000_000_000, + Phase1CancellationPolicy::LocalCooperative, + ) + .unwrap() + } + + #[tokio::test] + async fn media_free_draft_queues_offline_and_recovers_exactly() { + let runtime = runtime(); + let id = [7; 16]; + let draft = runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest").unwrap()), + 1_900_000_000, + Vec::new(), + None, + 10, + ) + .await + .unwrap(); + assert_eq!(draft.state(), Phase1OutboxState::Draft); + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 11) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(queued.draft().revision().get(), 3); + assert!(queued.push().is_some()); + let recovered = runtime.phase1_recover_draft_queue(id, 12).await.unwrap(); + assert_eq!(recovered.draft(), queued.draft()); + assert_eq!(recovered.push(), queued.push()); + } + + #[tokio::test] + async fn queue_recovery_closes_both_preparation_crash_windows() { + let runtime = runtime(); + for (id_byte, prepare_before_recovery) in [(10, false), (11, true)] { + let id = [id_byte; 16]; + let saved = runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Recover").unwrap()), + 1_900_000_010, + Vec::new(), + None, + 40, + ) + .await + .unwrap(); + let mut payload = Phase1DraftPayload::decode(saved.draft()).unwrap(); + payload.queue = Some(policy()); + let bytes = payload.encode().unwrap(); + let draft_id = saved.draft().draft_id(); + let operation = operation_id(draft_id, bytes.as_slice()).unwrap(); + let operation = OperationInstanceId::new(*operation.as_bytes()).unwrap(); + let ready = saved + .draft() + .successor(bytes, AuthoredDraftStage::ReadyToSign, Some(operation), 41) + .unwrap(); + runtime + .storage() + .unwrap() + .append_authored_draft(ready.clone(), Some(saved.draft().revision())) + .await + .unwrap(); + if prepare_before_recovery { + runtime + .sync() + .unwrap() + .prepare_push(push_request(&ready).unwrap()) + .await + .unwrap(); + } + let recovered = runtime.phase1_recover_draft_queue(id, 42).await.unwrap(); + assert_eq!(recovered.draft().stage(), AuthoredDraftStage::Queued); + assert_eq!(recovered.draft().revision().get(), 3); + assert!(recovered.push().is_some()); + } + } + + #[tokio::test] + async fn all_five_add_flows_queue_without_network_access() { + let runtime = runtime(); + let (photo, media) = photo_command(); + let commands = [ + ( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + ), + (photo, vec![media]), + ( + Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), + Vec::new(), + ), + ( + Phase1AddCommand::CreateEvent(CreateEvent::date( + AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").unwrap(), + ) + .unwrap(), + )), + Vec::new(), + ), + ( + Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), + Vec::new(), + ), + ]; + for (index, (command, media)) in commands.into_iter().enumerate() { + let mut id = [30; 16]; + id[15] = u8::try_from(index + 1).unwrap(); + let saved = runtime + .phase1_save_draft( + id, + command, + 1_784_347_200, + media, + None, + 100 + u64::try_from(index).unwrap() * 10, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft( + id, + saved.draft().revision().get(), + policy(), + 101 + u64::try_from(index).unwrap() * 10, + ) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(queued.command_type(), CANONICAL_ADD_COMMAND_TYPES[index]); + } + } + + #[tokio::test] + async fn cancellation_is_terminal_and_preserves_operation_evidence() { + let runtime = runtime(); + let id = [8; 16]; + runtime + .phase1_save_draft( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Cancelled").unwrap()), + 1_900_000_001, + Vec::new(), + None, + 20, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 21) + .await + .unwrap(); + let cancelled = runtime + .phase1_cancel_draft(id, queued.draft().revision().get(), 22) + .await + .unwrap(); + assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); + let push = cancelled.push().expect("retained push evidence"); + assert_eq!(push.artifact().signing_state(), SigningState::Cancelled); + assert_eq!( + push.delivery_plan().state(), + AuthoredDeliveryState::Cancelled + ); + assert!(push.settlement().is_settled()); + } + + #[tokio::test] + async fn media_phase_revisions_gate_queue_and_record_possible_orphans() { + let runtime = runtime(); + let id = [9; 16]; + let (command, mut media) = photo_command(); + media.stage = Phase1MediaStage::Pending; + media.validate().unwrap(); + let saved = runtime + .phase1_save_draft(id, command, 1_784_347_200, vec![media], None, 30) + .await + .unwrap(); + assert_eq!(saved.state(), Phase1OutboxState::MediaPreparing); + assert_eq!( + runtime + .phase1_queue_draft(id, 1, policy(), 31) + .await + .unwrap_err(), + Phase1DraftError::MediaNotReady + ); + let preparing = runtime + .phase1_update_draft_media( + id, + 1, + saved.media()[0].url(), + Phase1MediaStage::Preparing, + None, + 31, + ) + .await + .unwrap(); + let uploading = runtime + .phase1_update_draft_media( + id, + 2, + preparing.media()[0].url(), + Phase1MediaStage::Uploading, + None, + 32, + ) + .await + .unwrap(); + let verified = runtime + .phase1_update_draft_media( + id, + 3, + uploading.media()[0].url(), + Phase1MediaStage::Verified, + None, + 33, + ) + .await + .unwrap(); + assert_eq!(verified.media()[0].stage(), Phase1MediaStage::Verified); + assert_eq!( + runtime + .phase1_update_draft_media( + id, + 4, + verified.media()[0].url(), + Phase1MediaStage::Uploading, + None, + 34, + ) + .await + .unwrap_err(), + Phase1DraftError::InvalidMedia + ); + let queued = runtime + .phase1_queue_draft(id, 4, policy(), 34) + .await + .unwrap(); + let cancelled = runtime + .phase1_cancel_draft(id, queued.draft().revision().get(), 35) + .await + .unwrap(); + assert_eq!(cancelled.media()[0].stage(), Phase1MediaStage::Orphaned); + assert_eq!(runtime.phase1_draft_heads(10).await.unwrap().len(), 1); + } + + #[test] + fn queue_policy_rejects_duplicates_and_noncanonical_relays() { + assert!( + Phase1QueuePolicy::new( + vec!["wss://relay.example".into(), "wss://relay.example".into()], + Phase1RelaySatisfaction::AnyAccepted, + 1, + Phase1CancellationPolicy::LocalCooperative, + ) + .is_err() + ); + assert!( + Phase1QueuePolicy::new( + vec!["WSS://relay.example".into()], + Phase1RelaySatisfaction::AnyAccepted, + 1, + Phase1CancellationPolicy::LocalCooperative, + ) + .is_err() + ); + } + + fn photo_command() -> (Phase1AddCommand, Phase1MediaPrerequisite) { + let bytes = b"harvest-photo"; + let hash = BlossomSha256::digest(bytes); + let url = format!("https://media.example/{hash}.webp"); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(url.as_str()).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_100, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + let image = AuthoredPostImage::new( + AuthoredImage::try_from(descriptor).unwrap(), + PostImageDimensions::new(1200, 900).unwrap(), + "Harvest", + ) + .unwrap(); + let command = Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).unwrap(), + ); + let prerequisite = Phase1MediaPrerequisite::new( + "protected://draft/photo-1", + url, + hash.to_hex(), + "image/webp", + bytes.len() as u64, + Phase1MediaStage::Verified, + ) + .unwrap(); + (command, prerequisite) + } + + 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() + } +}