commit 53502e95d28bc3b553f7ccfb18a24bb120760bd0
parent 4fdc741c8f1a8d3e1831cdb311b2f64cdeb675a2
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:
4 files changed, 184 insertions(+), 47 deletions(-)
diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs
@@ -25,9 +25,10 @@ use radroots_mobile_core::runtime::{
MediaReference, MediaVerificationState, Phase1AddCommand, Phase1CancellationPolicy,
Phase1DraftEventTiming, Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot,
Phase1DraftStatus, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState,
- Phase1QueuePolicy, Phase1RelaySatisfaction, ProfileSummary, SearchResult, SearchResultType,
- SupportingProfile, ThreadEntry, TodayCard, TodayCardType, TodayPage, TodayProjectionUpdate,
- TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt,
+ Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary,
+ SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType,
+ TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState,
+ TodaySyncReceipt,
},
sdk::{
SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord,
@@ -902,6 +903,15 @@ pub struct FfiBlossomUploadInput {
pub updated_at_unix_ms: u64,
}
+/// Minimal host input for a Rust-planned exact-byte upload attempt.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomUploadIntent {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub expected_revision: u64,
+ pub media: FfiPreparedMediaInput,
+}
+
impl FfiAddDraftInput {
pub(crate) fn command_and_media(
self,
@@ -1154,6 +1164,22 @@ fn read_media_file_descriptor(
}
impl PreparedMedia {
+ pub(crate) fn into_upload_intent(
+ self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1UploadIntent, RadrootsAppError> {
+ Phase1UploadIntent::new(
+ draft_id,
+ expected_revision,
+ self.bytes,
+ self.media_type,
+ self.width,
+ self.height,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload"))
+ }
+
pub(crate) fn upload_request(
&self,
verified_at_unix_ms: u64,
diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs
@@ -191,6 +191,15 @@ impl From<Phase1DraftError> for RadrootsAppError {
("authoring_failed", true, &["retry", "inspect_outbox"][..])
}
Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]),
+ Phase1DraftError::ClockUnavailable => {
+ ("operation_clock_unavailable", true, &["retry"][..])
+ }
+ Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]),
+ Phase1DraftError::NoWritableRelay => (
+ "writable_relay_unavailable",
+ true,
+ &["configure_relay", "retry"][..],
+ ),
};
Self::failure(
code,
diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs
@@ -1,7 +1,9 @@
use std::sync::Arc;
-use radroots_mobile_core::runtime::product_surface::LocalNetworkRelayPolicy;
-use radroots_mobile_core::runtime::product_surface::TodayPageRequest;
+use radroots_mobile_core::runtime::product_surface::{
+ LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1QueueIntent,
+ TodayPageRequest, phase1_new_addressable_identifier, phase1_operation_now_unix_ms,
+};
use crate::dto::PreparedMedia;
use crate::signer::HostSignerAdapter;
@@ -9,13 +11,13 @@ use crate::subscription::SubscriptionHub;
use crate::{
FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord,
FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind,
- FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord,
- FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord,
- FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind,
- FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, FfiStorageStatusRecord,
- FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, FfiTodayRefreshRecord,
- FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas,
- decode_id,
+ FfiBlossomUploadInput, FfiBlossomUploadIntent, FfiCapabilityRecord, FfiCardAddParityRecord,
+ FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord,
+ FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput,
+ FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord,
+ FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate,
+ FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner,
+ RadrootsRuntimeObserver, add_schemas, decode_id,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
@@ -391,6 +393,55 @@ impl RadrootsRuntime {
.map(|_| ())
}
+ /// Saves one new or existing Add form while Rust owns all identity and
+ /// timestamp policy. Addressable identifiers are generated when omitted.
+ pub async fn phase1_save_add_intent(
+ &self,
+ mut input: FfiAddDraftInput,
+ existing_draft_id: Option<String>,
+ expected_revision: Option<u64>,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ if input.identifier.is_none()
+ && matches!(
+ input.command_type,
+ crate::FfiAddCommandType::CreateEvent
+ | crate::FfiAddCommandType::CreateFoodAvailability
+ )
+ {
+ input.identifier = Some(phase1_new_addressable_identifier());
+ }
+ let authored_at_unix_s =
+ phase1_operation_now_unix_ms().map_err(RadrootsAppError::from)? / 1_000;
+ let blossom = self
+ .inner
+ .sdk_blossom_slot()
+ .map_err(RadrootsAppError::from)?;
+ let (command, media, form) =
+ input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?;
+ let existing = match (existing_draft_id, expected_revision) {
+ (Some(draft_id), Some(revision)) => Some(
+ Phase1ExistingDraft::new(decode_id(&draft_id, "invalid_draft_id")?, revision)
+ .map_err(RadrootsAppError::from)?,
+ ),
+ (None, None) => None,
+ _ => {
+ return Err(RadrootsAppError::invalid_argument("invalid_existing_draft"));
+ }
+ };
+ let status = self
+ .inner
+ .phase1_save_add_intent(
+ Phase1AddIntent::new(command, media, form, existing)
+ .map_err(RadrootsAppError::from)?,
+ )
+ .await
+ .map_err(RadrootsAppError::from)?;
+ let draft_id = hex::encode(status.draft().draft_id().as_bytes());
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
#[allow(clippy::too_many_arguments)]
pub async fn phase1_save_draft(
&self,
@@ -523,6 +574,25 @@ impl RadrootsRuntime {
Ok(status.into())
}
+ /// Queues with the Rust-owned active relay and settlement policy.
+ pub async fn phase1_queue_add_intent(
+ &self,
+ draft_id: String,
+ expected_revision: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let intent = Phase1QueueIntent::new(decoded_id, expected_revision)
+ .map_err(RadrootsAppError::from)?;
+ let status = self
+ .inner
+ .phase1_queue_add_intent(intent)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
pub async fn phase1_recover_draft_queue(
&self,
draft_id: String,
@@ -539,6 +609,21 @@ impl RadrootsRuntime {
Ok(status.into())
}
+ pub async fn phase1_recover_add_intent(
+ &self,
+ draft_id: String,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_recover_add_intent(decoded_id)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
pub async fn phase1_sign_queued_draft(
&self,
draft_id: String,
@@ -614,6 +699,32 @@ impl RadrootsRuntime {
Ok(status.into())
}
+ /// Runs a Rust-planned BUD-11/BUD-02/BUD-01 upload attempt. The host
+ /// supplies only the selected bounded file handle and draft revision.
+ pub async fn phase1_upload_add_media_intent(
+ &self,
+ input: FfiBlossomUploadIntent,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
+ return Err(RadrootsAppError::invalid_argument(
+ "unsupported_schema_version",
+ ));
+ }
+ let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?;
+ let intent = PreparedMedia::try_from(input.media)?
+ .into_upload_intent(draft_id, input.expected_revision)?;
+ let status = self
+ .inner
+ .phase1_upload_add_media_intent(intent)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone()));
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id));
+ Ok(status.into())
+ }
+
pub async fn phase1_cancel_draft(
&self,
draft_id: String,
@@ -630,6 +741,22 @@ impl RadrootsRuntime {
.notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
Ok(status.into())
}
+
+ pub async fn phase1_cancel_add_intent(
+ &self,
+ draft_id: String,
+ expected_revision: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_cancel_add_intent(decoded_id, expected_revision)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
}
impl RadrootsRuntime {
diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs
@@ -1,6 +1,6 @@
use radroots_mobile_ffi::{
FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind,
- FfiBlossomUploadInput, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord,
+ FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord,
FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction,
FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION,
RadrootsAppError,
@@ -291,17 +291,12 @@ async fn native_boundary_delegates_the_complete_core_surface() {
runtime
.phase1_validate_add_draft(add.clone(), 1_800_000_001)
.expect("valid draft");
- let draft_id = "07".repeat(16);
let saved = runtime
- .phase1_save_draft(
- draft_id.clone(),
- add,
- 1_800_000_001,
- None,
- 1_800_000_001_000,
- )
+ .phase1_save_add_intent(add, None, None)
.await
.expect("saved draft");
+ let draft_id = saved.draft_id.clone();
+ assert_eq!(draft_id.len(), 32);
assert_eq!(saved.state, FfiOutboxState::Draft);
assert_eq!(saved.kind, FfiDraftKind::Add);
assert_eq!(
@@ -325,28 +320,17 @@ async fn native_boundary_delegates_the_complete_core_surface() {
1
);
let queued = runtime
- .phase1_queue_draft(
- draft_id.clone(),
- saved.revision,
- FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: vec!["wss://write.example".to_owned()],
- satisfaction: FfiRelaySatisfaction::AllAccepted,
- delivery_deadline_unix_ms: 1_800_100_000_000,
- cancellation: FfiCancellationPolicy::LocalCooperative,
- },
- 1_800_000_002_000,
- )
+ .phase1_queue_add_intent(draft_id.clone(), saved.revision)
.await
.expect("queued draft");
assert_eq!(queued.state, FfiOutboxState::Queued);
let recovered = runtime
- .phase1_recover_draft_queue(draft_id.clone(), 1_800_000_003_000)
+ .phase1_recover_add_intent(draft_id.clone())
.await
.expect("recovered queue");
assert_eq!(recovered.revision, queued.revision);
let cancelled = runtime
- .phase1_cancel_draft(draft_id.clone(), recovered.revision, 1_800_000_004_000)
+ .phase1_cancel_add_intent(draft_id.clone(), recovered.revision)
.await
.expect("cancelled draft");
assert_eq!(cancelled.state, FfiOutboxState::Cancelled);
@@ -393,7 +377,7 @@ async fn native_boundary_delegates_the_complete_core_surface() {
.expect("cancelled retraction");
assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled);
- let upload = FfiBlossomUploadInput {
+ let upload = FfiBlossomUploadIntent {
schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
draft_id,
expected_revision: cancelled.revision,
@@ -409,18 +393,9 @@ async fn native_boundary_delegates_the_complete_core_surface() {
alt: "unused".to_owned(),
prepared_at_unix_s: 1_800_000_000,
},
- authorization_content: "Upload exact image".to_owned(),
- authorization_created_at_unix_s: 1_800_000_000,
- authorization_lifetime_seconds: 60,
- operation_id: "08".repeat(16),
- artifact_id: "09".repeat(16),
- signing_deadline_unix_ms: 1_800_000_100_000,
- signing_cancellation: FfiCancellationPolicy::LocalCooperative,
- verified_at_unix_ms: 1_800_000_000_000,
- updated_at_unix_ms: 1_800_000_005_000,
};
let upload_error = runtime
- .phase1_upload_draft_media(upload.clone())
+ .phase1_upload_add_media_intent(upload.clone())
.await
.expect_err("unsupported upload schema");
assert_eq!(upload_error.report().code, "unsupported_schema_version");
@@ -429,7 +404,7 @@ async fn native_boundary_delegates_the_complete_core_surface() {
invalid_id_upload.draft_id = "not-a-draft-id".to_owned();
assert_eq!(
runtime
- .phase1_upload_draft_media(invalid_id_upload)
+ .phase1_upload_add_media_intent(invalid_id_upload)
.await
.expect_err("invalid draft id")
.report()