commit 1138c7fb317a2365ac56453d356ae21c7e4e78b9
parent ead4c3f7b0a2069a49f9eaaa5fbbddbd7aefb686
Author: triesap <tyson@radroots.org>
Date: Tue, 8 Sep 2026 19:00:13 +0000
tera: own the application ffi producer
- preserve the verified ffi history and license provenance by forward merge
- bind the local producer to tera_core and matched Tera Swift module names
- retain conformance vectors and frozen queued and signed identity tests
- verify Rust behavior and scratch Swift bindings before native cutover
Diffstat:
29 files changed, 12266 insertions(+), 226 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1343,6 +1343,29 @@ dependencies = [
[[package]]
name = "nostr"
+version = "0.44.1"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "base64",
+ "bech32",
+ "bip39",
+ "bitcoin_hashes",
+ "cbc",
+ "chacha20 0.9.1",
+ "chacha20poly1305",
+ "getrandom 0.2.17",
+ "hex",
+ "instant",
+ "scrypt",
+ "secp256k1",
+ "serde",
+ "serde_json",
+ "unicode-normalization",
+ "url",
+]
+
+[[package]]
+name = "nostr"
version = "0.44.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b"
@@ -1375,7 +1398,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
"lru",
- "nostr",
+ "nostr 0.44.8",
+ "tokio",
+]
+
+[[package]]
+name = "nostr-database"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "lru",
+ "nostr 0.44.1",
"tokio",
]
@@ -1385,7 +1418,48 @@ version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
dependencies = [
- "nostr",
+ "nostr 0.44.8",
+]
+
+[[package]]
+name = "nostr-gossip"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "nostr 0.44.1",
+]
+
+[[package]]
+name = "nostr-relay-builder"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "async-utility",
+ "async-wsocket",
+ "atomic-destructor",
+ "hex",
+ "negentropy",
+ "nostr 0.44.1",
+ "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "nostr-relay-pool"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "async-utility",
+ "async-wsocket",
+ "atomic-destructor",
+ "hex",
+ "lru",
+ "negentropy",
+ "nostr 0.44.1",
+ "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
+ "tokio",
+ "tracing",
]
[[package]]
@@ -1400,8 +1474,22 @@ dependencies = [
"hex",
"lru",
"negentropy",
- "nostr",
- "nostr-database",
+ "nostr 0.44.8",
+ "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "nostr-sdk"
+version = "0.44.0"
+source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219"
+dependencies = [
+ "async-utility",
+ "nostr 0.44.1",
+ "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
+ "nostr-gossip 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)",
+ "nostr-relay-pool 0.44.0",
"tokio",
"tracing",
]
@@ -1413,10 +1501,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
dependencies = [
"async-utility",
- "nostr",
- "nostr-database",
- "nostr-gossip",
- "nostr-relay-pool",
+ "nostr 0.44.8",
+ "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "nostr-gossip 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "nostr-relay-pool 0.44.3",
"tokio",
"tracing",
]
@@ -1722,12 +1810,7 @@ dependencies = [
name = "radroots_ios_source_lock"
version = "0.1.0-alpha"
dependencies = [
- "async-trait",
"radroots_mobile_ffi",
- "secp256k1",
- "serde_json",
- "tempfile",
- "tokio",
]
[[package]]
@@ -1786,7 +1869,7 @@ version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb#ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb"
dependencies = [
"base64",
- "nostr",
+ "nostr 0.44.8",
"radroots_blossom",
"radroots_event",
"radroots_event_codec",
@@ -1934,8 +2017,8 @@ source = "git+https://github.com/radrootslabs/lib?rev=ad17b7d3455a7147cfa303d976
dependencies = [
"async-wsocket",
"futures",
- "nostr-relay-pool",
- "nostr-sdk",
+ "nostr-relay-pool 0.44.3",
+ "nostr-sdk 0.44.1",
"radroots_event_codec",
"radroots_nostr",
"radroots_protocol",
@@ -2588,7 +2671,7 @@ version = "0.1.0-alpha"
dependencies = [
"chrono",
"hex",
- "nostr",
+ "nostr 0.44.8",
"radroots_blossom",
"radroots_event",
"radroots_event_codec",
@@ -2612,6 +2695,34 @@ dependencies = [
]
[[package]]
+name = "tera_ffi"
+version = "0.1.0-alpha"
+dependencies = [
+ "async-trait",
+ "hex",
+ "libc",
+ "nostr 0.44.1",
+ "nostr-relay-builder",
+ "nostr-sdk 0.44.0",
+ "radroots_blossom",
+ "radroots_event",
+ "radroots_sdk",
+ "radroots_signing",
+ "radroots_storage",
+ "rustix",
+ "secp256k1",
+ "serde_json",
+ "tempfile",
+ "tera_core",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "uniffi",
+]
+
+[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["crates/source_lock", "core/crates/tera_core"]
+members = ["crates/source_lock", "core/crates/tera_core", "core/crates/tera_ffi"]
resolver = "3"
[workspace.package]
@@ -48,3 +48,12 @@ thiserror = { version = "1" }
tokio = { version = "1" }
url = { version = "2" }
uuid = { version = "1.22.0", features = ["v4", "v7"] }
+async-trait = { version = "0.1.89" }
+libc = { version = "0.2" }
+rustix = { version = "1", features = ["fs", "process", "std"] }
+secp256k1 = { version = "0.29.1", default-features = false, features = ["alloc"] }
+tera_core = { path = "core/crates/tera_core", version = "=0.1.0-alpha", default-features = false }
+tracing = { version = "0.1", default-features = false }
+tracing-appender = { version = "0.2" }
+tracing-subscriber = { version = "0.3" }
+uniffi = { version = "0.29.4" }
diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_ffi/Cargo.toml
@@ -0,0 +1,54 @@
+[package]
+name = "tera_ffi"
+description = "Native FFI bindings for Radroots mobile apps"
+version = "0.1.0-alpha"
+edition.workspace = true
+authors = ["Radroots Authors"]
+rust-version.workspace = true
+license = "GPL-3.0-or-later"
+repository.workspace = true
+homepage.workspace = true
+readme.workspace = true
+publish = false
+include = ["src/**", "uniffi.toml", "Cargo.toml"]
+
+[lib]
+crate-type = ["rlib", "staticlib", "cdylib"]
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
+
+[dependencies]
+async-trait = { workspace = true }
+hex = { workspace = true }
+radroots_blossom = { workspace = true, features = ["std"] }
+radroots_event = { workspace = true, features = ["serde", "std"] }
+tera_core = { workspace = true, features = ["mobile-social"] }
+radroots_sdk = { workspace = true, features = ["blossom"] }
+radroots_signing = { workspace = true, features = ["serde", "std"] }
+radroots_storage = { workspace = true }
+serde_json = { workspace = true, features = ["std"] }
+tracing = { workspace = true }
+tracing-appender = { workspace = true }
+tracing-subscriber = { workspace = true }
+thiserror = { workspace = true }
+uniffi = { workspace = true, features = ["tokio"] }
+
+[target.'cfg(unix)'.dependencies]
+libc = { workspace = true }
+rustix = { workspace = true }
+
+[dev-dependencies]
+nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
+nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" }
+nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" }
+secp256k1 = { workspace = true, features = ["rand-std"] }
+tempfile = { workspace = true }
+tokio = { workspace = true, features = [
+ "io-util",
+ "macros",
+ "net",
+ "rt-multi-thread",
+ "sync",
+ "time",
+] }
diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs
@@ -0,0 +1,2999 @@
+//! Focused, versioned value types owned by the native boundary.
+
+#[cfg(unix)]
+use std::os::fd::{FromRawFd, OwnedFd, RawFd};
+#[cfg(unix)]
+use std::os::unix::fs::FileExt;
+
+use radroots_blossom::{BlobDescriptor, MediaType, Sha256};
+use radroots_event::{
+ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate},
+ food::availability::{
+ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage,
+ FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions,
+ FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
+ },
+ media::AuthoredImage,
+ post::{AuthoredPostImage, PostImageDimensions},
+};
+use tera_core::runtime::{
+ app_info::AppInfoPlatform,
+ info::{AppInfo, RuntimeBuildInfo, RuntimeInfo},
+ product_surface::{
+ AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability,
+ CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot,
+ MediaReference, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming,
+ Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus,
+ Phase1InboundMediaState, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState,
+ Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary,
+ SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType,
+ TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState,
+ TodaySyncReceipt,
+ },
+ sdk::{
+ SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord,
+ SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord,
+ SdkStorageStatusRecord,
+ },
+};
+
+use crate::RadrootsAppError;
+
+pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1;
+const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
+const MEDIA_REFERENCE_MAX_BYTES: usize = 256;
+
+/// Final four-state trade-evidence coverage vocabulary.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTradeEvidenceCoverage {
+ Missing,
+ Partial,
+ ScopeSatisfied,
+ Unsupported,
+}
+
+/// Final three-state trade-evidence outcome vocabulary.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTradeEvidenceOutcome {
+ Valid,
+ Invalid,
+ Indeterminate,
+}
+
+/// Secret-free projection of one canonical evidence manifest.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTradeEvidenceManifestRecord {
+ pub schema_version: u16,
+ pub contract_id: String,
+ pub contract_version: u16,
+ pub trade_id: String,
+ pub trade_generation: String,
+ pub observed_at_unix_s: String,
+ pub coverage: FfiTradeEvidenceCoverage,
+ pub evidence_policy_digest: String,
+ pub manifest_digest: String,
+ pub canonical_bytes_hex: String,
+}
+
+/// Secret-free projection of one canonical RHI evidence report.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRhiEvidenceReportRecord {
+ pub schema_version: u16,
+ pub contract_id: String,
+ pub contract_version: u16,
+ pub issuer_pubkey: String,
+ pub trade_id: String,
+ pub claim_mutation_id: String,
+ pub outcome: FfiTradeEvidenceOutcome,
+ pub reason_codes: Vec<String>,
+ pub projection_digest: String,
+ pub evidence_manifest_digest: String,
+ pub evidence_policy_digest: String,
+ pub observed_at_unix_s: String,
+ pub trade_generation: String,
+ pub statement_digest: String,
+ pub supersedes_report_id: Option<String>,
+ pub supersedes_event_id: Option<String>,
+ pub canonical_content: String,
+}
+
+/// Unsigned typed event plan ready for host-owned signing.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTypedEvidenceEventPlanRecord {
+ pub schema_version: u16,
+ pub contract_id: String,
+ pub kind: u32,
+ pub author_pubkey: String,
+ pub created_at_unix_s: String,
+ pub expected_event_id: String,
+ pub tags: Vec<Vec<String>>,
+ pub content: String,
+}
+
+/// Signed NIP-01 event input for verified attestation admission.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiSignedEvidenceEventRecord {
+ pub id: String,
+ pub author_pubkey: String,
+ pub created_at_unix_s: u64,
+ pub kind: u32,
+ pub tags: Vec<Vec<String>>,
+ pub content: String,
+ pub signature: String,
+}
+
+/// Secret-free supersession projection from one verified attestation.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRhiEvidenceAttestationSupersessionRecord {
+ pub report_id: String,
+ pub event_id: String,
+}
+
+/// Verified final RHI evidence attestation.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRhiEvidenceAttestationRecord {
+ pub schema_version: u16,
+ pub issuer_pubkey: String,
+ pub trade_id: String,
+ pub claim_mutation_id: String,
+ pub outcome: FfiTradeEvidenceOutcome,
+ pub observed_at_unix_s: String,
+ pub trade_generation: String,
+ pub statement_digest: String,
+ pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>,
+ pub canonical_content: String,
+}
+
+#[uniffi::export]
+pub fn parse_trade_evidence_manifest(
+ canonical_bytes: Vec<u8>,
+) -> Result<FfiTradeEvidenceManifestRecord, RadrootsAppError> {
+ let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_manifest"))?;
+ Ok(FfiTradeEvidenceManifestRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ contract_id: manifest.contract_id().to_owned(),
+ contract_version: manifest.contract_version(),
+ trade_id: manifest.trade_id().to_string(),
+ trade_generation: manifest.trade_generation().get().to_string(),
+ observed_at_unix_s: manifest.observed_at_unix_s().to_string(),
+ coverage: manifest.coverage().into(),
+ evidence_policy_digest: manifest.evidence_policy_digest().to_hex(),
+ manifest_digest: manifest.digest().to_hex(),
+ canonical_bytes_hex: hex::encode(manifest.canonical_bytes()),
+ })
+}
+
+#[uniffi::export]
+pub fn parse_rhi_evidence_report(
+ canonical_content: String,
+) -> Result<FfiRhiEvidenceReportRecord, RadrootsAppError> {
+ let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?;
+ let supersession = report.supersession();
+ Ok(FfiRhiEvidenceReportRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ contract_id: report.contract_id().to_owned(),
+ contract_version: report.contract_version(),
+ issuer_pubkey: report.issuer_public_key().to_hex(),
+ trade_id: report.trade_id().to_string(),
+ claim_mutation_id: report.claim_mutation_id().to_string(),
+ outcome: report.outcome().into(),
+ reason_codes: report
+ .reason_codes()
+ .iter()
+ .map(|code| code.as_str().to_owned())
+ .collect(),
+ projection_digest: report.projection_digest().to_hex(),
+ evidence_manifest_digest: report.evidence_manifest_digest().to_hex(),
+ evidence_policy_digest: report.evidence_policy_digest().to_hex(),
+ observed_at_unix_s: report.observed_at_unix_s().to_string(),
+ trade_generation: report.trade_generation().get().to_string(),
+ statement_digest: report.statement_digest().to_hex(),
+ supersedes_report_id: supersession.map(|value| value.report_id().to_hex()),
+ supersedes_event_id: supersession.map(|value| value.event_id().to_hex()),
+ canonical_content: report.canonical_content().to_owned(),
+ })
+}
+
+#[uniffi::export]
+pub fn prepare_rhi_evidence_attestation(
+ canonical_content: String,
+ created_at_unix_s: u64,
+) -> Result<FfiTypedEvidenceEventPlanRecord, RadrootsAppError> {
+ let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?;
+ let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_attestation_plan"))?;
+ Ok(FfiTypedEvidenceEventPlanRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ contract_id: plan.body().contract().contract_id().as_str().to_owned(),
+ kind: plan.body().kind(),
+ author_pubkey: plan.author().to_hex(),
+ created_at_unix_s: plan.created_at().to_string(),
+ expected_event_id: plan.expected_event_id().to_hex(),
+ tags: plan.body().tags().to_vec(),
+ content: plan.body().content().to_owned(),
+ })
+}
+
+#[uniffi::export]
+pub fn validate_rhi_evidence_attestation(
+ event: FfiSignedEvidenceEventRecord,
+) -> Result<FfiRhiEvidenceAttestationRecord, RadrootsAppError> {
+ let event = radroots_event::envelope::EventEnvelope::new(
+ radroots_event::envelope::EventEnvelopeParts {
+ id: event.id,
+ author: event.author_pubkey,
+ created_at: event.created_at_unix_s,
+ kind: event.kind,
+ tags: event.tags,
+ content: event.content,
+ sig: event.signature,
+ },
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_signed_event"))?;
+ let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err(
+ |error| match error {
+ radroots_sdk::trade::EvidenceAttestationValidationError::Signature => {
+ RadrootsAppError::invalid_argument("invalid_event_signature")
+ }
+ radroots_sdk::trade::EvidenceAttestationValidationError::Contract => {
+ RadrootsAppError::invalid_argument("invalid_evidence_attestation")
+ }
+ },
+ )?;
+ Ok(FfiRhiEvidenceAttestationRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ issuer_pubkey: attestation.issuer().to_hex(),
+ trade_id: attestation.trade_id().to_string(),
+ claim_mutation_id: attestation.claim_mutation_id().to_string(),
+ outcome: match attestation.outcome() {
+ radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => {
+ FfiTradeEvidenceOutcome::Valid
+ }
+ radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => {
+ FfiTradeEvidenceOutcome::Invalid
+ }
+ radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => {
+ FfiTradeEvidenceOutcome::Indeterminate
+ }
+ },
+ observed_at_unix_s: attestation.observed_at_unix_s().to_string(),
+ trade_generation: attestation.trade_generation().get().to_string(),
+ statement_digest: hex::encode(attestation.statement_digest()),
+ supersession: attestation.supersession().map(|value| {
+ FfiRhiEvidenceAttestationSupersessionRecord {
+ report_id: hex::encode(value.report_id()),
+ event_id: value.event_id().to_hex(),
+ }
+ }),
+ canonical_content: attestation.canonical_content().to_owned(),
+ })
+}
+
+impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage {
+ fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self {
+ match value {
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing,
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial,
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => {
+ Self::ScopeSatisfied
+ }
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported,
+ }
+ }
+}
+
+impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome {
+ fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self {
+ match value {
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid,
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid,
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => {
+ Self::Indeterminate
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBuildInfoRecord {
+ pub schema_version: u16,
+ pub crate_name: String,
+ pub crate_version: String,
+ pub rustc: Option<String>,
+ pub profile: Option<String>,
+ pub lib_revision: Option<String>,
+ pub consumer_revision: Option<String>,
+ pub build_time_unix: Option<u64>,
+}
+
+// These exhaustive field-for-field adapters are verified by the generated API
+// snapshot and Swift compilation. Excluding the mechanical projection glue
+// keeps the coverage gate focused on validation and behavior.
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<RuntimeBuildInfo> for FfiBuildInfoRecord {
+ fn from(value: RuntimeBuildInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ crate_name: value.crate_name,
+ crate_version: value.crate_version,
+ rustc: value.rustc,
+ profile: value.profile,
+ lib_revision: value.lib_revision,
+ consumer_revision: value.consumer_revision,
+ build_time_unix: value.build_time_unix,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAppPlatformRecord {
+ pub schema_version: u16,
+ pub platform: Option<String>,
+ pub bundle_id: Option<String>,
+ pub version: Option<String>,
+ pub build_number: Option<String>,
+ pub build_sha: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AppInfoPlatform> for FfiAppPlatformRecord {
+ fn from(value: AppInfoPlatform) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ platform: value.platform,
+ bundle_id: value.bundle_id,
+ version: value.version,
+ build_number: value.build_number,
+ build_sha: value.build_sha,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAppInfoRecord {
+ pub schema_version: u16,
+ pub build: FfiBuildInfoRecord,
+ pub started_unix_ms: i64,
+ pub uptime_millis: i64,
+ pub shutting_down: bool,
+ pub platform: Option<FfiAppPlatformRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AppInfo> for FfiAppInfoRecord {
+ fn from(value: AppInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ build: value.build.into(),
+ started_unix_ms: value.started_unix_ms,
+ uptime_millis: value.uptime_millis,
+ shutting_down: value.shutting_down,
+ platform: value.platform.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRuntimeInfoRecord {
+ pub schema_version: u16,
+ pub app: FfiAppInfoRecord,
+ pub sdk: FfiBuildInfoRecord,
+ pub sdk_closed: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<RuntimeInfo> for FfiRuntimeInfoRecord {
+ fn from(value: RuntimeInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ app: value.app.into(),
+ sdk: value.sdk.into(),
+ sdk_closed: value.sdk_closed,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiIdentityStatusRecord {
+ pub schema_version: u16,
+ pub public_key: String,
+ pub host_signer_configured: bool,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTodayCardType {
+ Update,
+ PhotoUpdate,
+ Ask,
+ Event,
+ FoodAvailability,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayCardType> for FfiTodayCardType {
+ fn from(value: TodayCardType) -> Self {
+ match value {
+ TodayCardType::Update => Self::Update,
+ TodayCardType::PhotoUpdate => Self::PhotoUpdate,
+ TodayCardType::Ask => Self::Ask,
+ TodayCardType::Event => Self::Event,
+ TodayCardType::FoodAvailability => Self::FoodAvailability,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiAddCommandType {
+ CreateUpdate,
+ CreatePhotoUpdate,
+ CreateAsk,
+ CreateEvent,
+ CreateFoodAvailability,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AddCommandType> for FfiAddCommandType {
+ fn from(value: AddCommandType) -> Self {
+ match value {
+ AddCommandType::CreateUpdate => Self::CreateUpdate,
+ AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
+ AddCommandType::CreateAsk => Self::CreateAsk,
+ AddCommandType::CreateEvent => Self::CreateEvent,
+ AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiCardAddParityRecord {
+ pub schema_version: u16,
+ pub card_type: FfiTodayCardType,
+ pub command_type: FfiAddCommandType,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiLocalNetworkRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub label: String,
+ pub relay_urls: Vec<String>,
+ pub locality: Option<String>,
+ pub followed_authors: Vec<String>,
+ pub generation: u64,
+}
+
+impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> {
+ value.try_into_with_relay_policy(LocalNetworkRelayPolicy::Public)
+ }
+}
+
+impl FfiLocalNetworkRecord {
+ pub(crate) fn try_into_with_relay_policy(
+ self,
+ relay_policy: LocalNetworkRelayPolicy,
+ ) -> Result<LocalNetwork, RadrootsAppError> {
+ require_schema(self.schema_version)?;
+ LocalNetwork::new_for_relay_policy(
+ self.id,
+ self.label,
+ self.relay_urls,
+ self.locality,
+ self.followed_authors,
+ self.generation,
+ relay_policy,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_local_network"))
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<LocalNetwork> for FfiLocalNetworkRecord {
+ fn from(value: LocalNetwork) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: value.id,
+ label: value.label,
+ relay_urls: value.relay_urls,
+ locality: value.locality,
+ followed_authors: value.followed_authors,
+ generation: value.generation,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiMediaVerificationState {
+ Pending,
+ Verified,
+ Failed,
+ Unavailable,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<&Phase1InboundMediaState> for FfiMediaVerificationState {
+ fn from(value: &Phase1InboundMediaState) -> Self {
+ match value {
+ Phase1InboundMediaState::Pending(_) => Self::Pending,
+ Phase1InboundMediaState::Verified(_) => Self::Verified,
+ Phase1InboundMediaState::Failed(_) => Self::Failed,
+ Phase1InboundMediaState::Unavailable => Self::Unavailable,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMediaReferenceRecord {
+ pub schema_version: u16,
+ pub reference_fingerprint: String,
+ pub url: String,
+ pub sha256: Option<String>,
+ pub media_type: Option<String>,
+ pub width: Option<u32>,
+ pub height: Option<u32>,
+ pub byte_size: Option<u64>,
+ pub alt: Option<String>,
+ pub verification: FfiMediaVerificationState,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<MediaReference> for FfiMediaReferenceRecord {
+ fn from(value: MediaReference) -> Self {
+ let structural = value.structural();
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ reference_fingerprint: hex::encode(structural.fingerprint()),
+ url: structural.source_url().to_owned(),
+ sha256: structural.expected_sha256().map(str::to_owned),
+ media_type: structural.expected_media_type().map(str::to_owned),
+ width: structural.expected_width(),
+ height: structural.expected_height(),
+ byte_size: structural.expected_byte_size(),
+ alt: structural.alt().map(str::to_owned),
+ verification: value.retrieval().into(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiProfileRecord {
+ pub schema_version: u16,
+ pub author_public_key: String,
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<FfiMediaReferenceRecord>,
+ pub banner: Option<FfiMediaReferenceRecord>,
+ pub nip05: Option<String>,
+ pub website: Option<String>,
+ pub lightning_address: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<ProfileSummary> for FfiProfileRecord {
+ fn from(value: ProfileSummary) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ author_public_key: value.author_pubkey,
+ name: value.name,
+ display_name: value.display_name,
+ about: value.about,
+ picture: value.picture.map(Into::into),
+ banner: value.banner.map(Into::into),
+ nip05: value.nip05,
+ website: value.website,
+ lightning_address: value.lightning_address,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiThreadProfile {
+ Profile,
+ Reply,
+ Comment,
+ Deletion,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SupportingProfile> for FfiThreadProfile {
+ fn from(value: SupportingProfile) -> Self {
+ match value {
+ SupportingProfile::Profile => Self::Profile,
+ SupportingProfile::Reply => Self::Reply,
+ SupportingProfile::Comment => Self::Comment,
+ SupportingProfile::Deletion => Self::Deletion,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiThreadEntryRecord {
+ pub schema_version: u16,
+ pub event_id: String,
+ pub author_public_key: String,
+ pub content: String,
+ pub authored_at_unix_s: u64,
+ pub profile: FfiThreadProfile,
+ pub root: String,
+ pub parent_event_id: String,
+ pub author_profile: Option<FfiProfileRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<ThreadEntry> for FfiThreadEntryRecord {
+ fn from(value: ThreadEntry) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ event_id: value.event_id,
+ author_public_key: value.author_pubkey,
+ content: value.content,
+ authored_at_unix_s: value.authored_at,
+ profile: value.reference.profile.into(),
+ root: value.reference.root,
+ parent_event_id: value.reference.parent_event_id,
+ author_profile: value.author_profile.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiCardLifecycleState {
+ Active,
+ Sold,
+ Past,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<CardLifecycleState> for FfiCardLifecycleState {
+ fn from(value: CardLifecycleState) -> Self {
+ match value {
+ CardLifecycleState::Active => Self::Active,
+ CardLifecycleState::Sold => Self::Sold,
+ CardLifecycleState::Past => Self::Past,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayCardRecord {
+ pub schema_version: u16,
+ pub card_id: String,
+ pub card_type: FfiTodayCardType,
+ pub source_event_id: String,
+ pub source_address: Option<String>,
+ pub author_public_key: String,
+ pub contract_id: String,
+ pub title: Option<String>,
+ pub content: String,
+ pub authored_at_unix_s: u64,
+ pub effective_at_unix_s: u64,
+ pub event_start_unix_s: Option<u64>,
+ pub event_end_unix_s: Option<u64>,
+ pub location: Option<String>,
+ pub price_amount: Option<String>,
+ pub price_currency: Option<String>,
+ pub price_unit: Option<String>,
+ pub quantity: Option<String>,
+ pub food_summary: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
+ pub food_status: Option<String>,
+ pub context_rank: u8,
+ pub inclusion_reason: String,
+ pub media: Vec<FfiMediaReferenceRecord>,
+ pub lifecycle: FfiCardLifecycleState,
+ pub rank_digest: Option<String>,
+ pub author_profile: Option<FfiProfileRecord>,
+ pub thread: Vec<FfiThreadEntryRecord>,
+ pub local_operation_id: Option<String>,
+ pub local_operation_state: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayCard> for FfiTodayCardRecord {
+ fn from(value: TodayCard) -> Self {
+ let card = value.card;
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ card_id: card.card_id.to_hex(),
+ card_type: card.card_type.into(),
+ source_event_id: card.source_event_id,
+ source_address: card.source_address,
+ author_public_key: card.author_pubkey,
+ contract_id: card.contract_id,
+ title: card.title,
+ content: card.content,
+ authored_at_unix_s: card.authored_at,
+ effective_at_unix_s: card.effective_at,
+ event_start_unix_s: card.event_start,
+ event_end_unix_s: card.event_end,
+ location: card.location,
+ price_amount: card.price_amount,
+ price_currency: card.price_currency,
+ price_unit: card.price_unit,
+ quantity: card.quantity,
+ food_summary: card.food_summary,
+ food_published_at_unix_s: card.food_published_at,
+ food_status: card.food_status,
+ context_rank: card.context_rank.value(),
+ inclusion_reason: card.inclusion_reason,
+ media: card.media.into_iter().map(Into::into).collect(),
+ lifecycle: card.lifecycle.into(),
+ rank_digest: card.rank.map(|rank| rank.digest_hex()),
+ author_profile: value.author_profile.map(Into::into),
+ thread: value.thread.into_iter().map(Into::into).collect(),
+ local_operation_id: value
+ .local_overlay
+ .as_ref()
+ .map(|overlay| overlay.operation_id.clone()),
+ local_operation_state: value.local_overlay.map(|overlay| overlay.state),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayPageRecord {
+ pub schema_version: u16,
+ pub as_of_unix_s: u64,
+ pub items: Vec<FfiTodayCardRecord>,
+ pub next_cursor: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTodayProjectionUpdate {
+ Incremental,
+ Rebuild,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate {
+ fn from(value: FfiTodayProjectionUpdate) -> Self {
+ match value {
+ FfiTodayProjectionUpdate::Incremental => Self::Incremental,
+ FfiTodayProjectionUpdate::Rebuild => Self::Rebuild,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayRefreshRecord {
+ pub schema_version: u16,
+ pub update: FfiTodayProjectionUpdate,
+ pub source_events: u64,
+ pub visible_cards: u64,
+ pub profiles: u64,
+ pub thread_entries: u64,
+ pub content_generation: u64,
+ pub changed: bool,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTodayRelaySyncState {
+ Complete,
+ Partial,
+ Offline,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodaySyncRecord {
+ pub schema_version: u16,
+ pub relay_state: FfiTodayRelaySyncState,
+ pub pages_fetched: u16,
+ pub events_observed: u64,
+ pub events_admitted: u64,
+ pub events_rejected: u64,
+ pub projection: FfiTodayRefreshRecord,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodaySyncReceipt> for FfiTodaySyncRecord {
+ fn from(value: TodaySyncReceipt) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_state: match value.relay_state {
+ TodayRelaySyncState::Complete => FfiTodayRelaySyncState::Complete,
+ TodayRelaySyncState::Partial => FfiTodayRelaySyncState::Partial,
+ TodayRelaySyncState::Offline => FfiTodayRelaySyncState::Offline,
+ },
+ pages_fetched: value.pages_fetched,
+ events_observed: value.events_observed,
+ events_admitted: value.events_admitted,
+ events_rejected: value.events_rejected,
+ projection: value.projection.into(),
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord {
+ fn from(value: TodayRefreshReceipt) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ update: match value.update {
+ TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental,
+ TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild,
+ },
+ source_events: value.source_events,
+ visible_cards: value.visible_cards,
+ profiles: value.profiles,
+ thread_entries: value.thread_entries,
+ content_generation: value.content_generation,
+ changed: value.changed,
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayPage> for FfiTodayPageRecord {
+ fn from(value: TodayPage) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ as_of_unix_s: value.as_of,
+ items: value.items.into_iter().map(Into::into).collect(),
+ next_cursor: value.next_cursor,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiSearchResultType {
+ Card,
+ Profile,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiSearchResultRecord {
+ pub schema_version: u16,
+ pub result_type: FfiSearchResultType,
+ pub stable_id: String,
+ pub card: Option<FfiTodayCardRecord>,
+ pub profile: Option<FfiProfileRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SearchResult> for FfiSearchResultRecord {
+ fn from(value: SearchResult) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ result_type: match value.result_type {
+ SearchResultType::Card => FfiSearchResultType::Card,
+ SearchResultType::Profile => FfiSearchResultType::Profile,
+ },
+ stable_id: value.stable_id,
+ card: value.card.map(Into::into),
+ profile: value.profile.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMeRecord {
+ pub schema_version: u16,
+ pub public_key: String,
+ pub profile: Option<FfiProfileRecord>,
+ pub cards: Vec<FfiTodayCardRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<MeSnapshot> for FfiMeRecord {
+ fn from(value: MeSnapshot) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ public_key: value.public_key,
+ profile: value.profile.map(Into::into),
+ cards: value.cards.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiAddFieldKind {
+ Text,
+ MultilineText,
+ Date,
+ DateTime,
+ Decimal,
+ Choice,
+ Location,
+ Media,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddFieldRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub label: String,
+ pub kind: FfiAddFieldKind,
+ pub required: bool,
+ pub choices: Vec<String>,
+ pub max_bytes: Option<u64>,
+ pub max_items: Option<u16>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddSchemaRecord {
+ pub schema_version: u16,
+ pub command_type: FfiAddCommandType,
+ pub label: String,
+ pub fields: Vec<FfiAddFieldRecord>,
+}
+
+pub fn add_schemas() -> Vec<FfiAddSchemaRecord> {
+ use FfiAddCommandType as Command;
+ use FfiAddFieldKind as Kind;
+
+ let field =
+ |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: id.to_owned(),
+ label: label.to_owned(),
+ kind,
+ required,
+ choices: choices.iter().map(|value| (*value).to_owned()).collect(),
+ max_bytes,
+ max_items: None,
+ };
+ let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord {
+ max_items: Some(max_items),
+ ..field(
+ "media",
+ label,
+ Kind::Media,
+ required,
+ &[],
+ Some(MEDIA_FILE_MAX_BYTES),
+ )
+ };
+ vec![
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateUpdate,
+ label: "Update".to_owned(),
+ fields: vec![field(
+ "content",
+ "Update",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ )],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreatePhotoUpdate,
+ label: "Photo update".to_owned(),
+ fields: vec![
+ field(
+ "content",
+ "Update",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ media_field("Photos", true, 20),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateAsk,
+ label: "Ask".to_owned(),
+ fields: vec![
+ field(
+ "content",
+ "Question",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ media_field("Photos", false, 20),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateEvent,
+ label: "Event".to_owned(),
+ fields: vec![
+ field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
+ field("title", "Title", Kind::Text, true, &[], Some(256)),
+ field(
+ "content",
+ "Description",
+ Kind::MultilineText,
+ false,
+ &[],
+ Some(65_535),
+ ),
+ field("event_start", "Starts", Kind::DateTime, true, &[], None),
+ field("event_end", "Ends", Kind::DateTime, false, &[], None),
+ field(
+ "location",
+ "Location",
+ Kind::Location,
+ false,
+ &[],
+ Some(256),
+ ),
+ media_field("Photo", false, 1),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateFoodAvailability,
+ label: "Food availability".to_owned(),
+ fields: vec![
+ field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
+ field("title", "Food", Kind::Text, true, &[], Some(256)),
+ field("summary", "Summary", Kind::Text, true, &[], Some(256)),
+ field(
+ "content",
+ "Details",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ field(
+ "location",
+ "Pickup location",
+ Kind::Location,
+ true,
+ &[],
+ Some(256),
+ ),
+ field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)),
+ field("currency", "Currency", Kind::Choice, true, &[], Some(3)),
+ field(
+ "unit",
+ "Unit",
+ Kind::Choice,
+ true,
+ &[
+ "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket",
+ ],
+ None,
+ ),
+ field(
+ "quantity",
+ "Available quantity",
+ Kind::Decimal,
+ false,
+ &[],
+ Some(64),
+ ),
+ media_field("Photos", false, 20),
+ ],
+ },
+ ]
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiEventTimingKind {
+ AllDay,
+ Timed,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiPreparedMediaInput {
+ pub schema_version: u16,
+ pub opaque_reference: String,
+ pub file_descriptor: u64,
+ pub sha256: String,
+ pub media_type: String,
+ pub byte_size: u64,
+ pub width: u32,
+ pub height: u32,
+ pub alt: String,
+ pub prepared_at_unix_s: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddDraftInput {
+ pub schema_version: u16,
+ pub command_type: FfiAddCommandType,
+ pub content: String,
+ pub identifier: Option<String>,
+ pub title: Option<String>,
+ pub summary: Option<String>,
+ pub location: Option<String>,
+ pub event_timing: Option<FfiEventTimingKind>,
+ pub event_start_date: Option<String>,
+ pub event_end_date: Option<String>,
+ pub event_start_unix_s: Option<u64>,
+ pub event_end_unix_s: Option<u64>,
+ pub event_timezone: Option<String>,
+ pub price_amount: Option<String>,
+ pub currency: Option<String>,
+ pub unit: Option<String>,
+ pub quantity: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
+ pub food_status: Option<String>,
+ pub media: Vec<FfiPreparedMediaInput>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRetractionDraftInput {
+ pub schema_version: u16,
+ pub command_type: FfiAddCommandType,
+ pub target_card_id: String,
+ pub target_event_id: String,
+ pub target_kind: u32,
+ pub target_address: Option<String>,
+ pub reason: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomUploadInput {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub expected_revision: u64,
+ pub media: FfiPreparedMediaInput,
+ pub authorization_content: String,
+ pub authorization_created_at_unix_s: u64,
+ pub authorization_lifetime_seconds: u64,
+ pub operation_id: String,
+ pub artifact_id: String,
+ pub signing_deadline_unix_ms: u64,
+ pub signing_cancellation: FfiCancellationPolicy,
+ pub verified_at_unix_ms: u64,
+ 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,
+}
+
+/// Secret-bearing native job. Hosts may use the authorization header for the
+/// immediate OS request but must not persist it in application metadata.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiNativeUploadJobRecord {
+ pub schema_version: u16,
+ pub operation_id: String,
+ pub draft: FfiDraftStatusRecord,
+ pub remote_url: String,
+ pub authorization_header: String,
+ pub expected_sha256: String,
+ pub media_type: String,
+ pub byte_size: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiNativeUploadCompletionInput {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub expected_revision: u64,
+ pub media: FfiPreparedMediaInput,
+ pub status_code: u16,
+ pub response_media_type: Option<String>,
+ pub response_content_encoding: Option<String>,
+ pub response_body: Vec<u8>,
+}
+
+impl FfiAddDraftInput {
+ pub(crate) fn command_and_media(
+ self,
+ authored_at_unix_s: u64,
+ blossom: Option<&radroots_sdk::transport::BlossomSlot>,
+ ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> {
+ self.command_media_and_form(authored_at_unix_s, blossom)
+ .map(|(command, media, _)| (command, media))
+ }
+
+ pub(crate) fn command_media_and_form(
+ self,
+ authored_at_unix_s: u64,
+ blossom: Option<&radroots_sdk::transport::BlossomSlot>,
+ ) -> Result<
+ (
+ Phase1AddCommand,
+ Vec<Phase1MediaPrerequisite>,
+ Phase1DraftFormSnapshot,
+ ),
+ RadrootsAppError,
+ > {
+ require_schema(self.schema_version)?;
+ if authored_at_unix_s == 0 || self.media.len() > 20 {
+ return Err(RadrootsAppError::invalid_argument("invalid_add_draft"));
+ }
+ let prepared = self
+ .media
+ .iter()
+ .cloned()
+ .map(PreparedMedia::try_from)
+ .map(|media| {
+ media.and_then(|media| {
+ let blossom = blossom.ok_or_else(|| {
+ RadrootsAppError::invalid_argument("blossom_not_configured")
+ })?;
+ media.bind(blossom)
+ })
+ })
+ .collect::<Result<Vec<BoundPreparedMedia>, _>>()?;
+ let prerequisites = prepared
+ .iter()
+ .map(|value| {
+ Phase1MediaPrerequisite::new(
+ value.media.opaque_reference.clone(),
+ &value.descriptor,
+ )
+ })
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
+ let post_images = prepared
+ .iter()
+ .map(BoundPreparedMedia::post_image)
+ .collect::<Result<Vec<_>, _>>()?;
+ let form = self.form_snapshot(&prepared);
+ let command = match self.command_type {
+ FfiAddCommandType::CreateUpdate => {
+ reject_media(&prepared)?;
+ Phase1AddCommand::CreateUpdate(
+ CreateUpdate::new(self.content)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_update"))?,
+ )
+ }
+ FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate(
+ CreatePhotoUpdate::new(
+ content_with_media_references(self.content, &prepared)?,
+ post_images,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_photo_update"))?,
+ ),
+ FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk(
+ CreateAsk::new(
+ content_with_media_references(self.content, &prepared)?,
+ post_images,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_ask"))?,
+ ),
+ FfiAddCommandType::CreateEvent => {
+ Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?)
+ }
+ FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability(
+ food_command(self, authored_at_unix_s, &prepared)?,
+ ),
+ };
+ Ok((command, prerequisites, form))
+ }
+
+ fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot {
+ Phase1DraftFormSnapshot {
+ command_type: match self.command_type {
+ FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate,
+ FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate,
+ FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk,
+ FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent,
+ FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability,
+ },
+ content: self.content.clone(),
+ identifier: self.identifier.clone(),
+ title: self.title.clone(),
+ summary: self.summary.clone(),
+ location: self.location.clone(),
+ event_timing: self.event_timing.map(|value| match value {
+ FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay,
+ FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed,
+ }),
+ event_start_date: self.event_start_date.clone(),
+ event_end_date: self.event_end_date.clone(),
+ event_start_unix_s: self.event_start_unix_s,
+ event_end_unix_s: self.event_end_unix_s,
+ event_timezone: self.event_timezone.clone(),
+ price_amount: self.price_amount.clone(),
+ currency: self.currency.clone(),
+ unit: self.unit.clone(),
+ quantity: self.quantity.clone(),
+ food_published_at_unix_s: self.food_published_at_unix_s,
+ food_status: self.food_status.clone(),
+ media: prepared
+ .iter()
+ .map(|value| Phase1DraftMediaSnapshot {
+ opaque_reference: value.media.opaque_reference.clone(),
+ url: value.descriptor.url().as_str().to_owned(),
+ sha256: value.media.sha256.to_hex(),
+ media_type: value.media.media_type.as_str().to_owned(),
+ byte_size: value.media.byte_size,
+ width: value.media.width,
+ height: value.media.height,
+ alt: value.media.alt.clone(),
+ prepared_at_unix_s: value.media.prepared_at_unix_s,
+ })
+ .collect(),
+ }
+ }
+}
+
+pub(crate) struct PreparedMedia {
+ opaque_reference: String,
+ sha256: Sha256,
+ byte_size: u64,
+ prepared_at_unix_s: u64,
+ bytes: std::sync::Arc<[u8]>,
+ media_type: MediaType,
+ width: u32,
+ height: u32,
+ alt: String,
+}
+
+struct BoundPreparedMedia {
+ media: PreparedMedia,
+ descriptor: radroots_blossom::ByteVerifiedDescriptor,
+}
+
+impl TryFrom<FfiPreparedMediaInput> for PreparedMedia {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ if !opaque_media_reference_is_valid(&value.opaque_reference)
+ || value.byte_size == 0
+ || value.byte_size > MEDIA_FILE_MAX_BYTES
+ || value.width == 0
+ || value.height == 0
+ || value.prepared_at_unix_s == 0
+ || value.alt.trim().is_empty()
+ || value.alt.len() > 1_024
+ {
+ return Err(RadrootsAppError::invalid_argument(
+ "invalid_media_reference",
+ ));
+ }
+ let byte_size = usize::try_from(value.byte_size)
+ .map_err(|_| RadrootsAppError::invalid_argument("media_size_mismatch"))?;
+ let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?;
+ let media_type = MediaType::parse(&value.media_type)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?;
+ let sha256 = Sha256::from_hex(&value.sha256)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?;
+ if Sha256::digest(&bytes) != sha256 {
+ return Err(RadrootsAppError::invalid_argument(
+ "media_verification_failed",
+ ));
+ }
+ let dimensions =
+ radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?;
+ let verified_at_unix_ms = value
+ .prepared_at_unix_s
+ .checked_mul(1_000)
+ .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
+ radroots_sdk::transport::BlossomUploadRequest::new(
+ bytes.clone().into(),
+ media_type.clone(),
+ dimensions,
+ verified_at_unix_ms,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?;
+ Ok(Self {
+ opaque_reference: value.opaque_reference,
+ sha256,
+ byte_size: value.byte_size,
+ prepared_at_unix_s: value.prepared_at_unix_s,
+ bytes: bytes.into(),
+ media_type,
+ width: value.width,
+ height: value.height,
+ alt: value.alt,
+ })
+ }
+}
+
+#[cfg(unix)]
+fn read_media_file_descriptor(
+ file_descriptor: u64,
+ expected_size: u64,
+ byte_size: usize,
+) -> Result<Vec<u8>, RadrootsAppError> {
+ let raw_file_descriptor = RawFd::try_from(file_descriptor)
+ .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
+ // SAFETY: `fcntl(F_DUPFD_CLOEXEC)` accepts any in-range integer descriptor
+ // and reports EBADF for an unavailable one. No borrowed or owned Rust
+ // descriptor is constructed until the kernel has duplicated it.
+ let duplicated = unsafe { libc::fcntl(raw_file_descriptor, libc::F_DUPFD_CLOEXEC, 0) };
+ if duplicated < 0 {
+ return Err(RadrootsAppError::invalid_argument(
+ "media_handle_unavailable",
+ ));
+ }
+ // SAFETY: a nonnegative F_DUPFD_CLOEXEC result is a new descriptor owned by
+ // this call. The host's original descriptor remains independently owned.
+ let owned = unsafe { OwnedFd::from_raw_fd(duplicated) };
+ let file = std::fs::File::from(owned);
+ let metadata = file
+ .metadata()
+ .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
+ if !metadata.is_file() || metadata.len() != expected_size {
+ return Err(RadrootsAppError::invalid_argument("media_size_mismatch"));
+ }
+ let mut bytes = vec![0; byte_size];
+ file.read_exact_at(&mut bytes, 0)
+ .map_err(|_| RadrootsAppError::invalid_argument("media_read_failed"))?;
+ Ok(bytes)
+}
+
+#[cfg(not(unix))]
+fn read_media_file_descriptor(
+ _file_descriptor: u64,
+ _expected_size: u64,
+ _byte_size: usize,
+) -> Result<Vec<u8>, RadrootsAppError> {
+ Err(RadrootsAppError::failure(
+ "media_handle_unsupported",
+ "capability",
+ false,
+ &[],
+ "Protected media handles are unsupported on this platform.",
+ ))
+}
+
+impl PreparedMedia {
+ pub(crate) fn into_authored_image(
+ self,
+ blossom: &radroots_sdk::transport::BlossomSlot,
+ ) -> Result<AuthoredImage, RadrootsAppError> {
+ self.bind(blossom)?.authored_image()
+ }
+
+ 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,
+ ) -> Result<radroots_sdk::transport::BlossomUploadRequest, RadrootsAppError> {
+ let dimensions =
+ radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?;
+ radroots_sdk::transport::BlossomUploadRequest::new(
+ std::sync::Arc::clone(&self.bytes),
+ self.media_type.clone(),
+ dimensions,
+ verified_at_unix_ms,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload"))
+ }
+
+ fn bind(
+ self,
+ blossom: &radroots_sdk::transport::BlossomSlot,
+ ) -> Result<BoundPreparedMedia, RadrootsAppError> {
+ let verified_at_unix_ms = self
+ .prepared_at_unix_s
+ .checked_mul(1_000)
+ .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
+ let transaction = blossom
+ .prepare_upload(self.upload_request(verified_at_unix_ms)?)
+ .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?;
+ let descriptor = BlobDescriptor::new(
+ transaction.expected_url().clone(),
+ self.sha256,
+ self.byte_size,
+ self.media_type.clone(),
+ self.prepared_at_unix_s,
+ )
+ .and_then(BlobDescriptor::approve_reference)
+ .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type))
+ .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?;
+ Ok(BoundPreparedMedia {
+ media: self,
+ descriptor,
+ })
+ }
+}
+
+impl BoundPreparedMedia {
+ fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> {
+ AuthoredImage::try_from_verified_descriptor(self.descriptor.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media"))
+ }
+
+ fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> {
+ AuthoredPostImage::new(
+ self.authored_image()?,
+ PostImageDimensions::new(self.media.width, self.media.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
+ self.media.alt.clone(),
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image"))
+ }
+}
+
+fn event_command(
+ input: &FfiAddDraftInput,
+ image: Option<&BoundPreparedMedia>,
+) -> Result<CreateEvent, RadrootsAppError> {
+ if input.media.len() > 1 {
+ return Err(RadrootsAppError::invalid_argument("event_image_limit"));
+ }
+ let identifier = required(input.identifier.as_deref(), "event_identifier_required")?;
+ let title = required(input.title.as_deref(), "event_title_required")?;
+ let timing = input
+ .event_timing
+ .ok_or_else(|| RadrootsAppError::invalid_argument("event_timing_required"))?;
+ match timing {
+ FfiEventTimingKind::AllDay => {
+ let start = CalendarDate::parse(required(
+ input.event_start_date.as_deref(),
+ "event_start_date_required",
+ )?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_start_date"))?;
+ let mut event = AuthoredCalendarDateEvent::new(identifier, title, start)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
+ if let Some(end) = input.event_end_date.as_deref() {
+ event = event
+ .with_end(CalendarDate::parse(end).map_err(|_| {
+ RadrootsAppError::invalid_argument("invalid_event_end_date")
+ })?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
+ }
+ if !input.content.is_empty() {
+ event = event
+ .with_description(input.content.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
+ }
+ if let Some(location) = input.location.clone() {
+ event = event
+ .with_locations(vec![location])
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
+ }
+ if let Some(image) = image {
+ event = event
+ .with_image(image.authored_image()?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
+ }
+ Ok(CreateEvent::date(event))
+ }
+ FfiEventTimingKind::Timed => {
+ let start = input
+ .event_start_unix_s
+ .filter(|value| *value != 0)
+ .ok_or_else(|| RadrootsAppError::invalid_argument("event_start_required"))?;
+ let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
+ if let Some(end) = input.event_end_unix_s {
+ event = event
+ .with_end(end)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
+ }
+ if let Some(timezone) = input.event_timezone.as_deref() {
+ event = event
+ .with_start_tzid(timezone)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_timezone"))?;
+ }
+ if !input.content.is_empty() {
+ event = event
+ .with_description(input.content.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
+ }
+ if let Some(location) = input.location.clone() {
+ event = event
+ .with_locations(vec![location])
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
+ }
+ if let Some(image) = image {
+ event = event
+ .with_image(image.authored_image()?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
+ }
+ Ok(CreateEvent::time(event))
+ }
+ }
+}
+
+fn food_command(
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ media: &[BoundPreparedMedia],
+) -> Result<CreateFoodAvailability, RadrootsAppError> {
+ let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?;
+ let images = media
+ .iter()
+ .map(|image| {
+ Ok(FoodAvailabilityImage::new(
+ image.authored_image()?,
+ FoodImageDimensions::new(image.media.width, image.media.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
+ ))
+ })
+ .collect::<Result<Vec<_>, RadrootsAppError>>()?;
+ let status = input.food_status.as_deref().unwrap_or("active");
+ let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new(input.content)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_content"))?,
+ identifier: FoodIdentifier::parse(required(
+ input.identifier.as_deref(),
+ "food_identifier_required",
+ )?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_identifier"))?,
+ title: FoodText::new(required(input.title, "food_title_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?,
+ summary: FoodText::new(required(input.summary, "food_summary_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?,
+ published_at: FoodPublishedAt::new(
+ input.food_published_at_unix_s.unwrap_or(authored_at_unix_s),
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?,
+ location: FoodText::new(required(input.location, "food_location_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?,
+ price: FoodPrice::new(
+ required(input.price_amount, "food_price_required")?,
+ FoodCurrency::parse(required(input.currency, "food_currency_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_currency"))?,
+ unit,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_price"))?,
+ quantity: input
+ .quantity
+ .map(|quantity| FoodQuantity::new(quantity, unit))
+ .transpose()
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_quantity"))?,
+ status: FoodAvailabilityStatus::parse(status)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_status"))?,
+ images,
+ })
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_availability"))?;
+ Ok(CreateFoodAvailability::new(details))
+}
+
+fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> {
+ if media.is_empty() {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::invalid_argument("media_not_allowed"))
+ }
+}
+
+fn content_with_media_references(
+ mut content: String,
+ media: &[BoundPreparedMedia],
+) -> Result<String, RadrootsAppError> {
+ if content.trim().is_empty() {
+ return Err(RadrootsAppError::invalid_argument("content_required"));
+ }
+ for item in media {
+ let url = item.descriptor.url().as_str();
+ match content.match_indices(url).count() {
+ 0 => {
+ if !content.ends_with('\n') {
+ content.push('\n');
+ }
+ content.push_str(url);
+ }
+ 1 => {}
+ _ => {
+ return Err(RadrootsAppError::invalid_argument(
+ "duplicate_media_reference",
+ ));
+ }
+ }
+ }
+ Ok(content)
+}
+
+fn required<T>(value: Option<T>, code: &'static str) -> Result<T, RadrootsAppError> {
+ value.ok_or_else(|| RadrootsAppError::invalid_argument(code))
+}
+
+fn opaque_media_reference_is_valid(value: &str) -> bool {
+ value.len() > "media:".len()
+ && value.len() <= MEDIA_REFERENCE_MAX_BYTES
+ && value.starts_with("media:")
+ && value["media:".len()..].bytes().all(|byte| {
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
+ })
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiMediaStage {
+ Pending,
+ Preparing,
+ Uploading,
+ Verified,
+ Failed,
+ Orphaned,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1MediaStage> for FfiMediaStage {
+ fn from(value: Phase1MediaStage) -> Self {
+ match value {
+ Phase1MediaStage::Pending => Self::Pending,
+ Phase1MediaStage::Preparing => Self::Preparing,
+ Phase1MediaStage::Uploading => Self::Uploading,
+ Phase1MediaStage::Verified => Self::Verified,
+ Phase1MediaStage::Failed => Self::Failed,
+ Phase1MediaStage::Orphaned => Self::Orphaned,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftMediaRecord {
+ pub schema_version: u16,
+ pub url: String,
+ pub stage: FfiMediaStage,
+ pub upload_attempts: u8,
+ pub verified_at_unix_ms: Option<u64>,
+ pub possible_orphan: bool,
+ pub orphan_reason_code: Option<String>,
+ pub orphan_recorded_at_unix_ms: Option<u64>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord {
+ fn from(value: &Phase1MediaPrerequisite) -> Self {
+ let orphan = value.orphan();
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: value.url().to_owned(),
+ stage: value.stage().into(),
+ upload_attempts: value.upload_attempts(),
+ verified_at_unix_ms: value.verified_at_unix_ms(),
+ possible_orphan: orphan.is_some(),
+ orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()),
+ orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiOutboxState {
+ Draft,
+ MediaPreparing,
+ MediaUploading,
+ ReadyToSign,
+ Signing,
+ Signed,
+ Queued,
+ Delivering,
+ PartiallyDelivered,
+ Retryable,
+ Terminal,
+ Cancelled,
+ Complete,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiDraftKind {
+ Add,
+ Retraction,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1DraftKind> for FfiDraftKind {
+ fn from(value: Phase1DraftKind) -> Self {
+ match value {
+ Phase1DraftKind::Add => Self::Add,
+ Phase1DraftKind::Retraction => Self::Retraction,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftFormMediaRecord {
+ pub schema_version: u16,
+ pub opaque_reference: String,
+ pub url: String,
+ pub sha256: String,
+ pub media_type: String,
+ pub byte_size: u64,
+ pub width: u32,
+ pub height: u32,
+ pub alt: String,
+ pub prepared_at_unix_s: u64,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord {
+ fn from(value: &Phase1DraftMediaSnapshot) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: value.opaque_reference.clone(),
+ url: value.url.clone(),
+ sha256: value.sha256.clone(),
+ media_type: value.media_type.clone(),
+ byte_size: value.byte_size,
+ width: value.width,
+ height: value.height,
+ alt: value.alt.clone(),
+ prepared_at_unix_s: value.prepared_at_unix_s,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftFormRecord {
+ pub schema_version: u16,
+ pub command_type: FfiAddCommandType,
+ pub content: String,
+ pub identifier: Option<String>,
+ pub title: Option<String>,
+ pub summary: Option<String>,
+ pub location: Option<String>,
+ pub event_timing: Option<FfiEventTimingKind>,
+ pub event_start_date: Option<String>,
+ pub event_end_date: Option<String>,
+ pub event_start_unix_s: Option<u64>,
+ pub event_end_unix_s: Option<u64>,
+ pub event_timezone: Option<String>,
+ pub price_amount: Option<String>,
+ pub currency: Option<String>,
+ pub unit: Option<String>,
+ pub quantity: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
+ pub food_status: Option<String>,
+ pub media: Vec<FfiDraftFormMediaRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord {
+ fn from(value: &Phase1DraftFormSnapshot) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: value.command_type.into(),
+ content: value.content.clone(),
+ identifier: value.identifier.clone(),
+ title: value.title.clone(),
+ summary: value.summary.clone(),
+ location: value.location.clone(),
+ event_timing: value.event_timing.map(|value| match value {
+ Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay,
+ Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed,
+ }),
+ event_start_date: value.event_start_date.clone(),
+ event_end_date: value.event_end_date.clone(),
+ event_start_unix_s: value.event_start_unix_s,
+ event_end_unix_s: value.event_end_unix_s,
+ event_timezone: value.event_timezone.clone(),
+ price_amount: value.price_amount.clone(),
+ currency: value.currency.clone(),
+ unit: value.unit.clone(),
+ quantity: value.quantity.clone(),
+ food_published_at_unix_s: value.food_published_at_unix_s,
+ food_status: value.food_status.clone(),
+ media: value.media.iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1OutboxState> for FfiOutboxState {
+ fn from(value: Phase1OutboxState) -> Self {
+ match value {
+ Phase1OutboxState::Draft => Self::Draft,
+ Phase1OutboxState::MediaPreparing => Self::MediaPreparing,
+ Phase1OutboxState::MediaUploading => Self::MediaUploading,
+ Phase1OutboxState::ReadyToSign => Self::ReadyToSign,
+ Phase1OutboxState::Signing => Self::Signing,
+ Phase1OutboxState::Signed => Self::Signed,
+ Phase1OutboxState::Queued => Self::Queued,
+ Phase1OutboxState::Delivering => Self::Delivering,
+ Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered,
+ Phase1OutboxState::Retryable => Self::Retryable,
+ Phase1OutboxState::Terminal => Self::Terminal,
+ Phase1OutboxState::Cancelled => Self::Cancelled,
+ Phase1OutboxState::Complete => Self::Complete,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftStatusRecord {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub revision: u64,
+ pub author_public_key: String,
+ pub kind: FfiDraftKind,
+ pub command_type: FfiAddCommandType,
+ pub form: Option<FfiDraftFormRecord>,
+ pub state: FfiOutboxState,
+ pub card_id: String,
+ pub operation_id: Option<String>,
+ pub created_at_unix_ms: u64,
+ pub updated_at_unix_ms: u64,
+ pub media: Vec<FfiDraftMediaRecord>,
+ pub settlement: Option<FfiOperationSettlementRecord>,
+ pub is_revision: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1DraftStatus> for FfiDraftStatusRecord {
+ fn from(value: Phase1DraftStatus) -> Self {
+ let draft = value.draft();
+ let settlement = value.push().map(|push| push.settlement());
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: hex::encode(draft.draft_id().as_bytes()),
+ revision: draft.revision().get(),
+ author_public_key: hex::encode(draft.author()),
+ kind: value.kind().into(),
+ command_type: value.command_type().into(),
+ form: value.form().map(Into::into),
+ state: value.state().into(),
+ card_id: value.card_id().to_hex(),
+ operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
+ created_at_unix_ms: draft.created_at_unix_ms(),
+ updated_at_unix_ms: draft.updated_at_unix_ms(),
+ media: value.media().iter().map(Into::into).collect(),
+ settlement: settlement.map(Into::into),
+ is_revision: value.revision_policy().is_some(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiOperationSettlementRecord {
+ pub schema_version: u16,
+ pub artifacts: u16,
+ pub signed: u16,
+ pub admitted: u16,
+ pub pending: u16,
+ pub retryable: u16,
+ pub indeterminate: u16,
+ pub failed_terminal: u16,
+ pub cancelled: u16,
+ pub delivery_plans: u16,
+ pub delivery_satisfied: u16,
+ pub delivery_pending: u16,
+ pub delivery_retryable: u16,
+ pub delivery_exhausted: u16,
+ pub delivery_failed_terminal: u16,
+ pub delivery_cancelled: u16,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord {
+ fn from(value: radroots_storage::authored::OperationSettlement) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ artifacts: value.artifacts(),
+ signed: value.signed(),
+ admitted: value.admitted(),
+ pending: value.pending(),
+ retryable: value.retryable(),
+ indeterminate: value.indeterminate(),
+ failed_terminal: value.failed_terminal(),
+ cancelled: value.cancelled(),
+ delivery_plans: value.delivery_plans(),
+ delivery_satisfied: value.delivery_satisfied(),
+ delivery_pending: value.delivery_pending(),
+ delivery_retryable: value.delivery_retryable(),
+ delivery_exhausted: value.delivery_exhausted(),
+ delivery_failed_terminal: value.delivery_failed_terminal(),
+ delivery_cancelled: value.delivery_cancelled(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRelaySatisfaction {
+ AnyAccepted,
+ AllAccepted,
+ AnyDelivered,
+ AllDelivered,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiCancellationPolicy {
+ PreservePublishedRequest,
+ LocalCooperative,
+}
+
+impl FfiCancellationPolicy {
+ pub(crate) const fn core(self) -> Phase1CancellationPolicy {
+ match self {
+ Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest,
+ Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiQueuePolicyRecord {
+ pub schema_version: u16,
+ pub relay_urls: Vec<String>,
+ pub satisfaction: FfiRelaySatisfaction,
+ pub delivery_deadline_unix_ms: u64,
+ pub cancellation: FfiCancellationPolicy,
+}
+
+impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ Phase1QueuePolicy::new(
+ value.relay_urls,
+ match value.satisfaction {
+ FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted,
+ FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted,
+ FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered,
+ FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered,
+ },
+ value.delivery_deadline_unix_ms,
+ match value.cancellation {
+ FfiCancellationPolicy::PreservePublishedRequest => {
+ Phase1CancellationPolicy::PreservePublishedRequest
+ }
+ FfiCancellationPolicy::LocalCooperative => {
+ Phase1CancellationPolicy::LocalCooperative
+ }
+ },
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_queue_policy"))
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiCapabilityRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub compiled: bool,
+ pub configured: bool,
+ pub availability: String,
+ pub maturity: String,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkCapabilityRecord> for FfiCapabilityRecord {
+ fn from(value: SdkCapabilityRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: value.id,
+ compiled: value.compiled,
+ configured: value.configured,
+ availability: value.availability,
+ maturity: value.maturity,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiStorageStatusRecord {
+ pub schema_version: u16,
+ pub backend: String,
+ pub open_mode: String,
+ pub shutdown: String,
+ pub integrity: String,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord {
+ fn from(value: SdkStorageStatusRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ backend: value.backend,
+ open_mode: value.open_mode,
+ shutdown: value.shutdown,
+ integrity: value.integrity,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRelayAccessRecord {
+ ReadOnly,
+ ReadWrite,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayStatusRecord {
+ pub schema_version: u16,
+ pub relay_url: String,
+ pub access: FfiRelayAccessRecord,
+ pub read_state: String,
+ pub write_state: String,
+ pub read_last_attempt_unix_ms: Option<u64>,
+ pub write_last_attempt_unix_ms: Option<u64>,
+ pub read_next_attempt_unix_ms: Option<u64>,
+ pub write_next_attempt_unix_ms: Option<u64>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord {
+ fn from(value: SdkRelayStatusRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_url: value.relay_url,
+ access: match value.access {
+ SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly,
+ SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite,
+ },
+ read_state: value.read_state,
+ write_state: value.write_state,
+ read_last_attempt_unix_ms: value.read_last_attempt_unix_ms,
+ write_last_attempt_unix_ms: value.write_last_attempt_unix_ms,
+ read_next_attempt_unix_ms: value.read_next_attempt_unix_ms,
+ write_next_attempt_unix_ms: value.write_next_attempt_unix_ms,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayStatusReportRecord {
+ pub schema_version: u16,
+ pub profile: String,
+ pub state: String,
+ pub read_availability: String,
+ pub write_availability: String,
+ pub relays: Vec<FfiRelayStatusRecord>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiBlossomHostKind {
+ Native,
+ Simulator,
+ PhysicalDevice,
+}
+
+impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind {
+ fn from(value: FfiBlossomHostKind) -> Self {
+ match value {
+ FfiBlossomHostKind::Native => Self::Native,
+ FfiBlossomHostKind::Simulator => Self::Simulator,
+ FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiBlossomEndpointAuthority {
+ PublicWebPki,
+ LoopbackDevelopment,
+ PrivateNetworkDevelopment,
+}
+
+impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority {
+ fn from(value: FfiBlossomEndpointAuthority) -> Self {
+ match value {
+ FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment,
+ FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => {
+ Self::PrivateNetworkDevelopment
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomConfigurationRecord {
+ pub schema_version: u16,
+ pub host_kind: String,
+ pub endpoint_authority: String,
+ pub primary_origin: String,
+ pub fallback_origins: Vec<String>,
+ pub config_fingerprint: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomEvidenceRecord {
+ pub schema_version: u16,
+ pub origin: String,
+ pub config_fingerprint: String,
+ pub state: String,
+ pub last_successful_state: String,
+ pub transport_security: String,
+ pub observed_at_unix_ms: Option<u64>,
+ pub http_status: Option<u16>,
+ pub error_code: Option<String>,
+ pub server_error_code: Option<String>,
+ pub error_phase: Option<String>,
+ pub retryable: bool,
+ pub possible_orphan: bool,
+ pub attempts: u8,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord {
+ fn from(value: SdkBlossomConfigurationRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ host_kind: value.host_kind,
+ endpoint_authority: value.endpoint_authority,
+ primary_origin: value.primary_origin,
+ fallback_origins: value.fallback_origins,
+ config_fingerprint: value.config_fingerprint,
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord {
+ fn from(value: SdkBlossomEvidenceRecord) -> Self {
+ Self {
+ schema_version: value.schema_version,
+ origin: value.origin,
+ config_fingerprint: value.config_fingerprint,
+ state: value.state,
+ last_successful_state: value.last_successful_state,
+ transport_security: value.transport_security,
+ observed_at_unix_ms: value.observed_at_unix_ms,
+ http_status: value.http_status,
+ error_code: value.error_code,
+ server_error_code: value.server_error_code,
+ error_phase: value.error_phase,
+ retryable: value.retryable,
+ possible_orphan: value.possible_orphan,
+ attempts: value.attempts,
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord {
+ fn from(value: SdkRelayStatusReportRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ profile: value.profile,
+ state: value.state,
+ read_availability: value.read_availability,
+ write_availability: value.write_availability,
+ relays: value.relays.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiShutdownRecord {
+ pub schema_version: u16,
+ pub state: String,
+ pub already_closed: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkShutdownRecord> for FfiShutdownRecord {
+ fn from(value: SdkShutdownRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ state: value.state,
+ already_closed: value.already_closed,
+ }
+ }
+}
+
+pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], RadrootsAppError> {
+ if value.len() != 32 {
+ return Err(RadrootsAppError::invalid_argument(code));
+ }
+ let bytes = hex::decode(value).map_err(|_| RadrootsAppError::invalid_argument(code))?;
+ bytes
+ .try_into()
+ .map_err(|_| RadrootsAppError::invalid_argument(code))
+}
+
+fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> {
+ if schema_version == MOBILE_FFI_SCHEMA_VERSION {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::invalid_argument(
+ "unsupported_schema_version",
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use core::num::NonZeroU64;
+ use std::io::Write;
+ use std::os::fd::AsRawFd;
+
+ use radroots_event::id::TradeId;
+
+ use super::*;
+
+ fn rhi_attestation_fixture() -> serde_json::Value {
+ let fixture: serde_json::Value = serde_json::from_str(include_str!(
+ "../../../test-fixtures/tera_ffi/authored_operations.v1.json"
+ ))
+ .expect("authored corpus");
+ fixture["vectors"]
+ .as_array()
+ .expect("operations")
+ .iter()
+ .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017")
+ .expect("RHI operation")
+ .get("expected")
+ .expect("expected")
+ .clone()
+ }
+
+ #[test]
+ fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() {
+ let expected = rhi_attestation_fixture();
+ let content = expected["content"].as_str().expect("content").to_owned();
+ let report = parse_rhi_evidence_report(content.clone()).expect("report");
+ assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate);
+ assert_eq!(report.trade_generation, "7");
+ assert_eq!(report.observed_at_unix_s, "1800000000");
+
+ let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan");
+ assert_eq!(plan.kind, 3_441);
+ assert_eq!(plan.created_at_unix_s, "1784347200");
+ assert_eq!(
+ plan.expected_event_id,
+ expected["event_id"].as_str().expect("event id")
+ );
+
+ let raw: serde_json::Value =
+ serde_json::from_str(expected["raw_json"].as_str().expect("raw event"))
+ .expect("raw event JSON");
+ let signed = FfiSignedEvidenceEventRecord {
+ id: raw["id"].as_str().expect("id").to_owned(),
+ author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(),
+ created_at_unix_s: raw["created_at"].as_u64().expect("created_at"),
+ kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"),
+ tags: serde_json::from_value(raw["tags"].clone()).expect("tags"),
+ content: raw["content"].as_str().expect("content").to_owned(),
+ signature: raw["sig"].as_str().expect("signature").to_owned(),
+ };
+ let attestation = validate_rhi_evidence_attestation(signed).expect("attestation");
+ assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate);
+ assert_eq!(attestation.trade_generation, "7");
+ }
+
+ #[test]
+ fn evidence_manifest_and_supersession_project_the_complete_vocabulary() {
+ use radroots_sdk::trade::{
+ RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1,
+ RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1,
+ RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1,
+ RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1,
+ RadrootsTradeEvidenceSourceResultV1,
+ };
+
+ assert_eq!(
+ FfiTradeEvidenceCoverage::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing
+ ),
+ FfiTradeEvidenceCoverage::Missing
+ );
+ assert_eq!(
+ FfiTradeEvidenceCoverage::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial
+ ),
+ FfiTradeEvidenceCoverage::Partial
+ );
+ assert_eq!(
+ FfiTradeEvidenceCoverage::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied
+ ),
+ FfiTradeEvidenceCoverage::ScopeSatisfied
+ );
+ assert_eq!(
+ FfiTradeEvidenceCoverage::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported
+ ),
+ FfiTradeEvidenceCoverage::Unsupported
+ );
+ assert_eq!(
+ FfiTradeEvidenceOutcome::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid
+ ),
+ FfiTradeEvidenceOutcome::Valid
+ );
+ assert_eq!(
+ FfiTradeEvidenceOutcome::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid
+ ),
+ FfiTradeEvidenceOutcome::Invalid
+ );
+ assert_eq!(
+ FfiTradeEvidenceOutcome::from(
+ radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate
+ ),
+ FfiTradeEvidenceOutcome::Indeterminate
+ );
+
+ let source = RadrootsTradeEvidenceManifestSourceResultV1::new(
+ RadrootsTradeEvidenceSourceIdV1::parse("typed_source").expect("source id"),
+ RadrootsTradeEvidenceSourceResultV1::new(
+ RadrootsTradeEvidenceSourceRequirementV1::Required,
+ RadrootsTradeEvidenceSourceCompletionV1::Complete,
+ 0,
+ )
+ .expect("source result"),
+ RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]),
+ );
+ let manifest = RadrootsTradeEvidenceManifestV1::new(
+ TradeId::from_bytes([0x11; 16]),
+ NonZeroU64::new(1).expect("nonzero generation"),
+ RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x22; 32]),
+ 1_800_000_000,
+ RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied,
+ [source],
+ [],
+ )
+ .expect("manifest");
+ let projected = parse_trade_evidence_manifest(manifest.canonical_bytes().to_vec())
+ .expect("manifest projection");
+ assert_eq!(projected.coverage, FfiTradeEvidenceCoverage::ScopeSatisfied);
+ assert_eq!(
+ projected.canonical_bytes_hex,
+ hex::encode(manifest.canonical_bytes())
+ );
+
+ let decisions: serde_json::Value = serde_json::from_str(include_str!(
+ "../../../test-fixtures/tera_ffi/evidence_attestation_decision.v1.json"
+ ))
+ .expect("RHI decision corpus");
+ let superseding = decisions["vectors"]
+ .as_array()
+ .expect("RHI decision vectors")
+ .iter()
+ .find(|entry| entry["id"] == "rhi_evidence_attestation_superseding_002")
+ .expect("superseding vector");
+ let report = parse_rhi_evidence_report(
+ superseding["expected"]["canonical_event_content_utf8"]
+ .as_str()
+ .expect("canonical event content")
+ .to_owned(),
+ )
+ .expect("superseding report");
+ assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Valid);
+ assert_eq!(
+ report.supersedes_report_id.as_deref(),
+ Some("7777777777777777777777777777777777777777777777777777777777777777")
+ );
+ assert_eq!(
+ report.supersedes_event_id.as_deref(),
+ Some("8888888888888888888888888888888888888888888888888888888888888888")
+ );
+ }
+
+ #[test]
+ fn transport_policy_enums_map_every_closed_variant() {
+ assert_eq!(
+ FfiCancellationPolicy::PreservePublishedRequest.core(),
+ Phase1CancellationPolicy::PreservePublishedRequest
+ );
+ assert_eq!(
+ FfiCancellationPolicy::LocalCooperative.core(),
+ Phase1CancellationPolicy::LocalCooperative
+ );
+ assert_eq!(
+ radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Native),
+ radroots_sdk::transport::BlossomHostKind::Native
+ );
+ assert_eq!(
+ radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Simulator),
+ radroots_sdk::transport::BlossomHostKind::Simulator
+ );
+ assert_eq!(
+ radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::PhysicalDevice),
+ radroots_sdk::transport::BlossomHostKind::PhysicalDevice
+ );
+ }
+
+ #[test]
+ fn evidence_errors_do_not_render_untrusted_content() {
+ let secret = "mobile-private-evidence";
+ let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report");
+ assert!(!error.to_string().contains(secret));
+ assert!(!format!("{error:?}").contains(secret));
+ }
+
+ fn png(width: u32, height: u32) -> Vec<u8> {
+ let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
+ bytes.extend_from_slice(&width.to_be_bytes());
+ bytes.extend_from_slice(&height.to_be_bytes());
+ bytes
+ }
+
+ fn blossom_slot() -> radroots_sdk::transport::BlossomSlot {
+ let profile = radroots_sdk::transport::BlossomProfile::new(
+ radroots_sdk::transport::BlossomHostKind::Simulator,
+ radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment,
+ "http://127.0.0.1:3000",
+ std::iter::empty::<&str>(),
+ )
+ .unwrap();
+ let slot = radroots_sdk::transport::BlossomSlot::new();
+ slot.configure(radroots_sdk::transport::BlossomConfig::from_profile(
+ profile,
+ ))
+ .unwrap();
+ slot
+ }
+
+ fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreatePhotoUpdate,
+ content: "Fresh carrots from this morning.".to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: vec![FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: "media:carrots-01".to_owned(),
+ file_descriptor,
+ sha256: digest,
+ media_type: "image/png".to_owned(),
+ byte_size: bytes.len() as u64,
+ width: 2,
+ height: 2,
+ alt: "A basket of carrots".to_owned(),
+ prepared_at_unix_s: 1_800_000_000,
+ }],
+ }
+ }
+
+ fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type,
+ content: "Fresh from the farm".to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn exact_five_add_inputs_build_their_typed_core_commands() {
+ let (update, media) = text_input(FfiAddCommandType::CreateUpdate)
+ .command_and_media(1_800_000_000, None)
+ .expect("update");
+ assert!(matches!(update, Phase1AddCommand::CreateUpdate(_)));
+ assert!(media.is_empty());
+
+ let (ask, media) = text_input(FfiAddCommandType::CreateAsk)
+ .command_and_media(1_800_000_000, None)
+ .expect("ask");
+ assert!(matches!(ask, Phase1AddCommand::CreateAsk(_)));
+ assert!(media.is_empty());
+
+ let mut all_day = text_input(FfiAddCommandType::CreateEvent);
+ all_day.identifier = Some("market-day".to_owned());
+ all_day.title = Some("Farmers market".to_owned());
+ all_day.location = Some("Town square".to_owned());
+ all_day.event_timing = Some(FfiEventTimingKind::AllDay);
+ all_day.event_start_date = Some("2026-08-08".to_owned());
+ all_day.event_end_date = Some("2026-08-09".to_owned());
+ let (event, media) = all_day
+ .command_and_media(1_800_000_000, None)
+ .expect("all-day event");
+ assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
+ assert!(media.is_empty());
+
+ let mut timed = text_input(FfiAddCommandType::CreateEvent);
+ timed.identifier = Some("harvest-tour".to_owned());
+ timed.title = Some("Harvest tour".to_owned());
+ timed.event_timing = Some(FfiEventTimingKind::Timed);
+ timed.event_start_unix_s = Some(1_800_000_000);
+ timed.event_end_unix_s = Some(1_800_003_600);
+ timed.event_timezone = Some("America/Vancouver".to_owned());
+ let (event, media) = timed
+ .command_and_media(1_800_000_000, None)
+ .expect("timed event");
+ assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
+ assert!(media.is_empty());
+
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(&bytes).to_hex();
+ let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
+ food.command_type = FfiAddCommandType::CreateFoodAvailability;
+ food.identifier = Some("carrots-2026-08".to_owned());
+ food.title = Some("Carrots".to_owned());
+ food.summary = Some("Fresh bunches".to_owned());
+ food.location = Some("Victoria".to_owned());
+ food.price_amount = Some("4.5".to_owned());
+ food.currency = Some("CAD".to_owned());
+ food.unit = Some("bunch".to_owned());
+ food.quantity = Some("12".to_owned());
+ food.food_status = Some("active".to_owned());
+ let blossom = blossom_slot();
+ let (food, media) = food
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect("food availability");
+ assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_)));
+ assert_eq!(media.len(), 1);
+ }
+
+ #[test]
+ fn add_validation_reports_schema_shape_media_and_required_field_failures() {
+ let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate);
+ wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1;
+ assert_eq!(
+ wrong_schema
+ .command_and_media(1_800_000_000, None)
+ .expect_err("schema")
+ .report()
+ .code,
+ "unsupported_schema_version"
+ );
+ assert_eq!(
+ text_input(FfiAddCommandType::CreateUpdate)
+ .command_and_media(0, None)
+ .expect_err("authored time")
+ .report()
+ .code,
+ "invalid_add_draft"
+ );
+ assert_eq!(
+ text_input(FfiAddCommandType::CreateEvent)
+ .command_and_media(1_800_000_000, None)
+ .expect_err("event identity")
+ .report()
+ .code,
+ "event_identifier_required"
+ );
+ let mut food = text_input(FfiAddCommandType::CreateFoodAvailability);
+ food.unit = Some("crate".to_owned());
+ assert_eq!(
+ food.command_and_media(1_800_000_000, None)
+ .expect_err("food unit")
+ .report()
+ .code,
+ "invalid_food_unit"
+ );
+ }
+
+ #[test]
+ fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() {
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(&bytes).to_hex();
+ let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone());
+ let blossom = blossom_slot();
+
+ let (command, media) = input
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect("verified media input");
+ assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_)));
+ assert_eq!(media.len(), 1);
+ assert_eq!(
+ media[0].url(),
+ format!("http://127.0.0.1:3000/{digest}.png")
+ );
+ assert_eq!(
+ file.as_file().metadata().expect("caller-owned media").len(),
+ bytes.len() as u64
+ );
+ }
+
+ #[test]
+ fn prepared_media_rejects_file_descriptors_outside_the_platform_range() {
+ let bytes = png(2, 2);
+ let blossom = blossom_slot();
+ let input = photo_input(u64::MAX, &bytes, Sha256::digest(&bytes).to_hex());
+
+ assert_eq!(
+ input
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("out-of-range descriptor")
+ .report()
+ .code,
+ "media_handle_unavailable"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn prepared_media_rejects_unavailable_in_range_file_descriptors() {
+ let bytes = png(2, 2);
+ let blossom = blossom_slot();
+ let input = photo_input(i32::MAX as u64, &bytes, Sha256::digest(&bytes).to_hex());
+
+ assert_eq!(
+ input
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("unavailable in-range descriptor")
+ .report()
+ .code,
+ "media_handle_unavailable"
+ );
+ }
+
+ #[test]
+ fn prepared_media_rejects_digest_tamper_and_path_like_references() {
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+
+ let tampered = photo_input(
+ file.as_file().as_raw_fd() as u64,
+ &bytes,
+ Sha256::digest(b"other").to_hex(),
+ );
+ let blossom = blossom_slot();
+ assert_eq!(
+ tampered
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("digest mismatch")
+ .report()
+ .code,
+ "media_verification_failed"
+ );
+
+ let mut path_like = photo_input(
+ file.as_file().as_raw_fd() as u64,
+ &bytes,
+ Sha256::digest(&bytes).to_hex(),
+ );
+ path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned();
+ assert_eq!(
+ path_like
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("path-like reference")
+ .report()
+ .code,
+ "invalid_media_reference"
+ );
+ }
+
+ #[test]
+ fn prepared_media_constructs_a_bounded_verified_upload_request() {
+ let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone());
+ input.media[0].media_type = "image/png".to_owned();
+
+ let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media");
+ let request = prepared
+ .upload_request(1_800_000_000_000)
+ .expect("bounded upload request");
+ assert_eq!(request.sha256().to_hex(), digest);
+ assert_eq!(request.byte_size(), bytes.len() as u64);
+ assert_eq!(request.media_type().as_str(), "image/png");
+ assert_eq!(request.dimensions().width(), 2);
+ assert_eq!(request.dimensions().height(), 2);
+ }
+
+ #[test]
+ fn media_validation_executes_every_bounded_shape_guard() {
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(&bytes).to_hex();
+ let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone();
+
+ let mut invalid_values = Vec::new();
+ let mut value = valid.clone();
+ value.byte_size = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.byte_size = MEDIA_FILE_MAX_BYTES + 1;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.width = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.height = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.prepared_at_unix_s = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.alt = " ".to_owned();
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.alt = "a".repeat(1_025);
+ invalid_values.push(value);
+
+ for value in invalid_values {
+ assert_eq!(
+ PreparedMedia::try_from(value)
+ .err()
+ .expect("invalid bounded media")
+ .report()
+ .code,
+ "invalid_media_reference"
+ );
+ }
+
+ let mut wrong_size = valid.clone();
+ wrong_size.byte_size += 1;
+ assert_eq!(
+ PreparedMedia::try_from(wrong_size)
+ .err()
+ .expect("descriptor size")
+ .report()
+ .code,
+ "media_size_mismatch"
+ );
+ for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] {
+ assert!(!opaque_media_reference_is_valid(reference));
+ }
+ assert!(opaque_media_reference_is_valid("media:item_01-a"));
+ assert!(!opaque_media_reference_is_valid(&format!(
+ "media:{}",
+ "a".repeat(MEDIA_REFERENCE_MAX_BYTES)
+ )));
+ }
+
+ #[test]
+ fn event_and_post_optional_branches_remain_strict_and_complete() {
+ let mut minimal_date = text_input(FfiAddCommandType::CreateEvent);
+ minimal_date.content.clear();
+ minimal_date.identifier = Some("minimal-date".to_owned());
+ minimal_date.title = Some("Minimal date".to_owned());
+ minimal_date.event_timing = Some(FfiEventTimingKind::AllDay);
+ minimal_date.event_start_date = Some("2026-08-08".to_owned());
+ assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok());
+
+ let mut minimal_time = text_input(FfiAddCommandType::CreateEvent);
+ minimal_time.content.clear();
+ minimal_time.identifier = Some("minimal-time".to_owned());
+ minimal_time.title = Some("Minimal time".to_owned());
+ minimal_time.event_timing = Some(FfiEventTimingKind::Timed);
+ minimal_time.event_start_unix_s = Some(1_800_000_000);
+ assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok());
+
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(&bytes).to_hex();
+ let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
+ event.command_type = FfiAddCommandType::CreateEvent;
+ event.identifier = Some("event-image".to_owned());
+ event.title = Some("Event image".to_owned());
+ event.event_timing = Some(FfiEventTimingKind::Timed);
+ event.event_start_unix_s = Some(1_800_000_000);
+ let blossom = blossom_slot();
+ assert!(
+ event
+ .clone()
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .is_ok()
+ );
+
+ let mut second = event.media[0].clone();
+ second.opaque_reference = "media:event-image-two".to_owned();
+ event.media.push(second);
+ assert_eq!(
+ event
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("event media limit")
+ .report()
+ .code,
+ "event_image_limit"
+ );
+ }
+
+ #[test]
+ fn content_references_and_identifier_decoding_cover_all_outcomes() {
+ let bytes = png(2, 2);
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(&bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(&bytes).to_hex();
+ let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest);
+ let blossom = blossom_slot();
+ let prepared = PreparedMedia::try_from(input.media[0].clone())
+ .expect("prepared media")
+ .bind(&blossom)
+ .expect("bound media");
+ let url = prepared.descriptor.url().as_str().to_owned();
+
+ assert_eq!(
+ content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared))
+ .expect_err("blank content")
+ .report()
+ .code,
+ "content_required"
+ );
+ assert_eq!(
+ content_with_media_references(
+ format!("caption\n{url}"),
+ std::slice::from_ref(&prepared)
+ )
+ .expect("one existing reference")
+ .match_indices(&url)
+ .count(),
+ 1
+ );
+ assert!(
+ content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared))
+ .expect("newline append")
+ .ends_with(&url)
+ );
+ assert_eq!(
+ content_with_media_references(
+ format!("{url}\n{url}"),
+ std::slice::from_ref(&prepared),
+ )
+ .expect_err("duplicate reference")
+ .report()
+ .code,
+ "duplicate_media_reference"
+ );
+
+ let mut update = text_input(FfiAddCommandType::CreateUpdate);
+ update.media.push(input.media[0].clone());
+ assert_eq!(
+ update
+ .command_and_media(1_800_000_000, Some(&blossom))
+ .expect_err("update media")
+ .report()
+ .code,
+ "media_not_allowed"
+ );
+ let mut over_limit = text_input(FfiAddCommandType::CreateUpdate);
+ over_limit.media = vec![input.media[0].clone(); 21];
+ assert_eq!(
+ over_limit
+ .command_and_media(1_800_000_000, None)
+ .expect_err("media count")
+ .report()
+ .code,
+ "invalid_add_draft"
+ );
+
+ assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]);
+ assert_eq!(
+ decode_id("01", "bad").expect_err("short id").report().code,
+ "bad"
+ );
+ assert_eq!(
+ decode_id(&"gg".repeat(16), "bad")
+ .expect_err("non-hex id")
+ .report()
+ .code,
+ "bad"
+ );
+ }
+}
diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs
@@ -0,0 +1,481 @@
+use tera_core::runtime::product_surface::{
+ Phase1DraftError, ProfileMetadataError, SettingsError, TodayError,
+};
+use thiserror::Error;
+
+use crate::MOBILE_FFI_SCHEMA_VERSION;
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct RadrootsErrorRecord {
+ pub schema_version: u16,
+ pub code: String,
+ pub category: String,
+ pub retryable: bool,
+ pub recovery_actions: Vec<String>,
+ pub operation_id: Option<String>,
+ pub capability_id: Option<String>,
+ pub safe_message: String,
+}
+
+/// The only error envelope exported across the native language boundary.
+#[derive(Debug, Error, uniffi::Error)]
+pub enum RadrootsAppError {
+ #[error("radroots operation failed: {report:?}")]
+ Failure { report: RadrootsErrorRecord },
+}
+
+impl RadrootsAppError {
+ pub(crate) fn initialization(_message: impl Into<String>) -> Self {
+ Self::failure(
+ "initialization_failed",
+ "initialization",
+ true,
+ &["retry"],
+ "The Radroots runtime could not be initialized.",
+ )
+ }
+
+ pub(crate) fn invalid_argument(code: impl Into<String>) -> Self {
+ Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: code.into(),
+ category: "validation".to_owned(),
+ retryable: false,
+ recovery_actions: vec!["correct_input".to_owned()],
+ operation_id: None,
+ capability_id: None,
+ safe_message: "The request is invalid.".to_owned(),
+ },
+ }
+ }
+
+ pub(crate) fn failure(
+ code: &str,
+ category: &str,
+ retryable: bool,
+ recovery_actions: &[&str],
+ safe_message: &str,
+ ) -> Self {
+ Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: code.to_owned(),
+ category: category.to_owned(),
+ retryable,
+ recovery_actions: recovery_actions
+ .iter()
+ .map(|action| (*action).to_owned())
+ .collect(),
+ operation_id: None,
+ capability_id: None,
+ safe_message: safe_message.to_owned(),
+ },
+ }
+ }
+
+ pub fn report(&self) -> &RadrootsErrorRecord {
+ match self {
+ Self::Failure { report } => report,
+ }
+ }
+
+ pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self {
+ match &mut self {
+ Self::Failure { report } => report.operation_id = Some(operation_id),
+ }
+ self
+ }
+}
+
+impl From<tera_core::RadrootsAppError> for RadrootsAppError {
+ fn from(error: tera_core::RadrootsAppError) -> Self {
+ match error {
+ tera_core::RadrootsAppError::Sdk { report } => Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: report.code,
+ category: report.class,
+ retryable: report.retryable,
+ recovery_actions: report.recovery_actions,
+ operation_id: report.operation_id,
+ capability_id: report.capability_id,
+ safe_message: report.message,
+ },
+ },
+ tera_core::RadrootsAppError::Store { report } => Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: report.code,
+ category: report.class,
+ retryable: report.retryable,
+ recovery_actions: report.recovery_actions,
+ operation_id: None,
+ capability_id: None,
+ safe_message: report.message,
+ },
+ },
+ tera_core::RadrootsAppError::Initialization(_) => Self::initialization("redacted"),
+ tera_core::RadrootsAppError::Runtime(_) => Self::failure(
+ "runtime_failed",
+ "runtime",
+ true,
+ &["retry"],
+ "The runtime operation failed.",
+ ),
+ tera_core::RadrootsAppError::Unsupported(_) => Self::failure(
+ "unsupported",
+ "capability",
+ false,
+ &[],
+ "The requested capability is unsupported.",
+ ),
+ tera_core::RadrootsAppError::Internal(_) => Self::failure(
+ "internal_failure",
+ "internal",
+ false,
+ &["restart"],
+ "An internal Radroots error occurred.",
+ ),
+ }
+ }
+}
+
+impl From<TodayError> for RadrootsAppError {
+ fn from(error: TodayError) -> Self {
+ let (code, retryable, actions) = match error {
+ TodayError::InvalidRequest | TodayError::EventNotVisible => {
+ ("today_invalid_request", false, &["correct_input"][..])
+ }
+ TodayError::ProjectionMissing | TodayError::SnapshotMissing => {
+ ("today_refresh_required", true, &["refresh"][..])
+ }
+ TodayError::CursorPositionMissing | TodayError::Cursor(_) => {
+ ("today_cursor_invalid", true, &["restart_pagination"][..])
+ }
+ TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]),
+ TodayError::InboundMedia(_) => ("today_media_invalid", false, &["retry_media"][..]),
+ TodayError::InboundRetrieval(error) => {
+ if error.retryable() {
+ ("today_media_retrieval_failed", true, &["retry_media"][..])
+ } else {
+ ("today_media_retrieval_failed", false, &["review_media"][..])
+ }
+ }
+ TodayError::CorruptProjection | TodayError::Serialization | TodayError::Storage(_) => {
+ ("today_state_failed", true, &["rebuild", "retry"][..])
+ }
+ };
+ Self::failure(
+ code,
+ "today",
+ retryable,
+ actions,
+ "The Today operation could not be completed.",
+ )
+ }
+}
+
+impl From<Phase1DraftError> for RadrootsAppError {
+ fn from(error: Phase1DraftError) -> Self {
+ let (code, retryable, actions) = match error {
+ Phase1DraftError::IdentityUnavailable => (
+ "identity_unavailable",
+ true,
+ &["unlock_identity", "retry"][..],
+ ),
+ Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]),
+ Phase1DraftError::InvalidMedia => {
+ ("draft_media_invalid", false, &["replace_media"][..])
+ }
+ Phase1DraftError::InvalidQueuePolicy => {
+ ("draft_queue_policy_invalid", false, &["correct_input"][..])
+ }
+ Phase1DraftError::RevisionConflict => {
+ ("draft_revision_conflict", true, &["refresh"][..])
+ }
+ Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]),
+ Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]),
+ Phase1DraftError::MediaNotReady => {
+ ("draft_media_not_ready", true, &["retry_media"][..])
+ }
+ Phase1DraftError::OperationUnavailable => {
+ ("authoring_unavailable", true, &["retry"][..])
+ }
+ Phase1DraftError::Operation | Phase1DraftError::Storage | Phase1DraftError::Overlay => {
+ ("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"][..],
+ ),
+ Phase1DraftError::InvalidRevision => {
+ ("revision_invalid", false, &["review_revision"][..])
+ }
+ };
+ Self::failure(
+ code,
+ "authoring",
+ retryable,
+ actions,
+ "The authored operation could not be completed.",
+ )
+ }
+}
+
+impl From<SettingsError> for RadrootsAppError {
+ fn from(error: SettingsError) -> Self {
+ let retryable = matches!(
+ error,
+ SettingsError::RevisionConflict
+ | SettingsError::RevisionExhausted
+ | SettingsError::Storage
+ );
+ let actions = if matches!(error, SettingsError::RevisionConflict) {
+ &["refresh"] as &[&str]
+ } else if retryable {
+ &["retry"] as &[&str]
+ } else {
+ &["correct_input"] as &[&str]
+ };
+ Self::failure(
+ error.code(),
+ "settings",
+ retryable,
+ actions,
+ "The settings operation could not be completed.",
+ )
+ }
+}
+
+impl From<ProfileMetadataError> for RadrootsAppError {
+ fn from(error: ProfileMetadataError) -> Self {
+ Self::failure(
+ error.code(),
+ "profile",
+ false,
+ &["correct_input"],
+ "The profile metadata is invalid.",
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn internal_core_messages_are_not_copied_to_the_ffi_record() {
+ let error = RadrootsAppError::from(tera_core::RadrootsAppError::internal(
+ "private/path/secret-value",
+ ));
+ assert_eq!(error.report().code, "internal_failure");
+ assert!(!format!("{error:?}").contains("secret-value"));
+ assert!(!error.report().safe_message.contains("secret-value"));
+ }
+
+ #[test]
+ fn every_core_error_class_maps_to_a_versioned_redacted_record() {
+ let sdk = tera_core::RadrootsAppError::Sdk {
+ report: tera_core::SdkErrorRecord {
+ schema_version: 1,
+ code: "relay_unavailable".to_owned(),
+ class: "transport".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["retry".to_owned()],
+ operation_id: Some("operation".to_owned()),
+ capability_id: Some("relay".to_owned()),
+ message: "Safe relay failure".to_owned(),
+ },
+ };
+ let sdk = RadrootsAppError::from(sdk);
+ assert_eq!(sdk.report().code, "relay_unavailable");
+ assert_eq!(sdk.report().operation_id.as_deref(), Some("operation"));
+
+ let store = tera_core::RadrootsAppError::Store {
+ report: tera_core::StoreErrorRecord {
+ schema_version: 1,
+ code: "store_locked".to_owned(),
+ class: "storage".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["unlock".to_owned()],
+ message: "Safe store failure".to_owned(),
+ },
+ };
+ let store = RadrootsAppError::from(store);
+ assert_eq!(store.report().code, "store_locked");
+ assert!(store.report().operation_id.is_none());
+
+ for (core, code, category) in [
+ (
+ tera_core::RadrootsAppError::initialization("secret"),
+ "initialization_failed",
+ "initialization",
+ ),
+ (
+ tera_core::RadrootsAppError::runtime("secret"),
+ "runtime_failed",
+ "runtime",
+ ),
+ (
+ tera_core::RadrootsAppError::unsupported("secret"),
+ "unsupported",
+ "capability",
+ ),
+ (
+ tera_core::RadrootsAppError::internal("secret"),
+ "internal_failure",
+ "internal",
+ ),
+ ] {
+ let ffi = RadrootsAppError::from(core);
+ assert_eq!(ffi.report().code, code);
+ assert_eq!(ffi.report().category, category);
+ assert!(!ffi.report().safe_message.contains("secret"));
+ }
+ }
+
+ #[test]
+ fn today_and_draft_failures_have_stable_recovery_classes() {
+ for error in [
+ TodayError::InvalidRequest,
+ TodayError::EventNotVisible,
+ TodayError::ProjectionMissing,
+ TodayError::SnapshotMissing,
+ TodayError::CursorPositionMissing,
+ TodayError::RuntimeUnavailable,
+ TodayError::CorruptProjection,
+ TodayError::Serialization,
+ ] {
+ let ffi = RadrootsAppError::from(error);
+ assert_eq!(ffi.report().category, "today");
+ assert!(!ffi.report().safe_message.is_empty());
+ }
+
+ for error in [
+ Phase1DraftError::IdentityUnavailable,
+ Phase1DraftError::InvalidDraft,
+ Phase1DraftError::InvalidMedia,
+ Phase1DraftError::InvalidQueuePolicy,
+ Phase1DraftError::RevisionConflict,
+ Phase1DraftError::NotFound,
+ Phase1DraftError::Terminal,
+ Phase1DraftError::MediaNotReady,
+ Phase1DraftError::OperationUnavailable,
+ Phase1DraftError::Operation,
+ Phase1DraftError::Storage,
+ Phase1DraftError::Overlay,
+ Phase1DraftError::Corrupt,
+ Phase1DraftError::ClockUnavailable,
+ Phase1DraftError::DeadlineOverflow,
+ Phase1DraftError::NoWritableRelay,
+ Phase1DraftError::InvalidRevision,
+ ] {
+ let ffi = RadrootsAppError::from(error);
+ assert_eq!(ffi.report().category, "authoring");
+ assert!(!ffi.report().safe_message.is_empty());
+ }
+ }
+
+ #[test]
+ fn cursor_media_settings_and_profile_failures_cover_every_stable_class() {
+ use tera_core::runtime::product_surface::{
+ CursorError, IdentitySettingsError, Phase1InboundMediaError,
+ };
+
+ for cursor in [
+ CursorError::InvalidContext,
+ CursorError::Malformed,
+ CursorError::Integrity,
+ CursorError::Version,
+ CursorError::ContextMismatch,
+ CursorError::SnapshotMismatch,
+ CursorError::Stale,
+ CursorError::InvalidPosition,
+ ] {
+ let error = RadrootsAppError::from(TodayError::Cursor(cursor));
+ assert_eq!(error.report().code, "today_cursor_invalid");
+ }
+ for media in [
+ Phase1InboundMediaError::InvalidReference,
+ Phase1InboundMediaError::InvalidDigest,
+ Phase1InboundMediaError::MissingDigest,
+ Phase1InboundMediaError::InvalidMediaType,
+ Phase1InboundMediaError::InvalidDimensions,
+ Phase1InboundMediaError::InvalidByteSize,
+ Phase1InboundMediaError::InvalidAlt,
+ Phase1InboundMediaError::MetadataMismatch,
+ Phase1InboundMediaError::InvalidOperation,
+ Phase1InboundMediaError::OperationMismatch,
+ Phase1InboundMediaError::InvalidFailure,
+ Phase1InboundMediaError::InvalidConfiguration,
+ Phase1InboundMediaError::ConfigurationMismatch,
+ Phase1InboundMediaError::InvalidVerificationTime,
+ Phase1InboundMediaError::InvalidCachePolicy,
+ Phase1InboundMediaError::InvalidCacheObservation,
+ Phase1InboundMediaError::CacheQuotaExceeded,
+ Phase1InboundMediaError::ArtifactCollision,
+ Phase1InboundMediaError::CorruptReceipt,
+ Phase1InboundMediaError::CorruptState,
+ Phase1InboundMediaError::UnsupportedSchema,
+ Phase1InboundMediaError::CacheUnavailable,
+ Phase1InboundMediaError::CacheIo,
+ Phase1InboundMediaError::CorruptArtifact,
+ ] {
+ let error = RadrootsAppError::from(TodayError::InboundMedia(media));
+ assert_eq!(error.report().code, "today_media_invalid");
+ }
+ for settings in [
+ SettingsError::UnknownRelayAccess,
+ SettingsError::InvalidRelayEndpoint,
+ SettingsError::InvalidRelayEndpointCount,
+ SettingsError::DuplicateRelayEndpoint,
+ SettingsError::InvalidBlossomEndpoint,
+ SettingsError::InvalidBlossomEndpointCount,
+ SettingsError::NetworkEnvironmentMismatch,
+ SettingsError::InvalidMediaCacheBytes,
+ SettingsError::InvalidMediaCacheArtifacts,
+ SettingsError::RevisionConflict,
+ SettingsError::RevisionExhausted,
+ SettingsError::UnsupportedSchema,
+ SettingsError::CorruptDocument,
+ SettingsError::Storage,
+ SettingsError::Identity(IdentitySettingsError::InvalidIdentityId),
+ ] {
+ let expected_retryable = matches!(
+ settings,
+ SettingsError::RevisionConflict
+ | SettingsError::RevisionExhausted
+ | SettingsError::Storage
+ );
+ let error = RadrootsAppError::from(settings);
+ assert_eq!(error.report().retryable, expected_retryable);
+ }
+ for profile in [
+ ProfileMetadataError::InvalidName,
+ ProfileMetadataError::InvalidDisplayName,
+ ProfileMetadataError::InvalidAbout,
+ ProfileMetadataError::InvalidNip05,
+ ] {
+ assert_eq!(RadrootsAppError::from(profile).report().category, "profile");
+ }
+ assert_eq!(
+ RadrootsAppError::initialization("private").report().code,
+ "initialization_failed"
+ );
+ assert_eq!(
+ RadrootsAppError::invalid_argument("bad")
+ .with_operation_id("operation".to_owned())
+ .report()
+ .operation_id
+ .as_deref(),
+ Some("operation")
+ );
+ }
+}
diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs
@@ -0,0 +1,46 @@
+// UniFFI serializes the complete versioned error record across the language
+// boundary; keeping it by value preserves the generated wire contract.
+#![allow(clippy::result_large_err)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+
+uniffi::setup_scaffolding!("tera_core");
+
+mod dto;
+pub mod logging;
+mod operations;
+mod runtime;
+mod signer;
+mod subscription;
+
+pub use dto::*;
+pub use error::{RadrootsAppError, RadrootsErrorRecord};
+pub use operations::*;
+pub use runtime::{ProtectedDataAvailability, RadrootsRuntime};
+pub use signer::{
+ HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult,
+ RadrootsHostSigner, SignerAvailabilityRecord, SignerStatusRecord,
+};
+pub use subscription::{
+ FfiRuntimeChangeKind, FfiRuntimeChangeRecord, FfiSubscriptionHandle, RadrootsRuntimeObserver,
+};
+
+mod error;
+
+#[allow(
+ clippy::if_same_then_else,
+ reason = "coverage probe intentionally exercises both paths with a stable value"
+)]
+pub fn coverage_branch_probe(input: bool) -> &'static str {
+ if input { "ffi" } else { "ffi" }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::coverage_branch_probe;
+
+ #[test]
+ fn coverage_branch_probe_hits_both_paths() {
+ assert_eq!(coverage_branch_probe(true), "ffi");
+ assert_eq!(coverage_branch_probe(false), "ffi");
+ }
+}
diff --git a/core/crates/tera_ffi/src/logging.rs b/core/crates/tera_ffi/src/logging.rs
@@ -0,0 +1,284 @@
+mod writer;
+
+use std::fs;
+use std::path::{Component, Path, PathBuf};
+use std::sync::Mutex;
+
+use tracing_appender::non_blocking::WorkerGuard;
+use tracing_subscriber::prelude::*;
+
+use self::writer::{LogRotation, SizeRotatingWriter};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct LoggingOptions {
+ dir: Option<PathBuf>,
+ file_name: String,
+ stdout: bool,
+ rotation: LogRotation,
+}
+
+impl Default for LoggingOptions {
+ fn default() -> Self {
+ Self {
+ dir: None,
+ file_name: "radroots.log".to_owned(),
+ stdout: true,
+ rotation: LogRotation::default(),
+ }
+ }
+}
+
+struct ActiveLogging {
+ options: LoggingOptions,
+ _file_guard: Option<WorkerGuard>,
+}
+
+static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None);
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn init_logging(
+ dir: Option<String>,
+ file_name: Option<String>,
+ is_stdout: Option<bool>,
+) -> Result<(), crate::RadrootsAppError> {
+ let opts = logging_options(dir, file_name, is_stdout);
+ initialize(opts).map_err(crate::RadrootsAppError::initialization)
+}
+
+fn logging_options(
+ dir: Option<String>,
+ file_name: Option<String>,
+ is_stdout: Option<bool>,
+) -> LoggingOptions {
+ LoggingOptions {
+ dir: dir.map(PathBuf::from),
+ file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()),
+ stdout: is_stdout.unwrap_or(true),
+ ..LoggingOptions::default()
+ }
+}
+
+fn initialize(options: LoggingOptions) -> Result<(), String> {
+ validate_options(&options)?;
+ let mut active = LOGGING
+ .lock()
+ .map_err(|_| "logging initialization state is poisoned".to_owned())?;
+ if let Some(existing) = active.as_ref() {
+ return if existing.options == options {
+ Ok(())
+ } else {
+ Err("logging is already initialized with a different configuration".to_owned())
+ };
+ }
+
+ let (file_writer, file_guard) = build_file_writer(&options)?;
+ let file_layer = file_writer.as_ref().map(|writer| {
+ tracing_subscriber::fmt::layer()
+ .with_writer(writer.clone())
+ .with_ansi(false)
+ .with_target(false)
+ });
+ let stdout_layer = options.stdout.then(|| {
+ tracing_subscriber::fmt::layer()
+ .with_writer(std::io::stdout)
+ .with_target(false)
+ });
+ tracing_subscriber::registry()
+ .with(file_layer)
+ .with(stdout_layer)
+ .try_init()
+ .map_err(|error| error.to_string())?;
+ *active = Some(ActiveLogging {
+ options,
+ _file_guard: file_guard,
+ });
+ Ok(())
+}
+
+fn validate_options(options: &LoggingOptions) -> Result<(), String> {
+ if options.dir.is_none() && !options.stdout {
+ return Err("logging requires at least one configured output".to_owned());
+ }
+ if options.file_name.is_empty()
+ || Path::new(&options.file_name).components().count() != 1
+ || !matches!(
+ Path::new(&options.file_name).components().next(),
+ Some(Component::Normal(_))
+ )
+ {
+ return Err("log file_name must be a safe basename".to_owned());
+ }
+ Ok(())
+}
+
+type FileWriter = tracing_appender::non_blocking::NonBlocking;
+
+fn build_file_writer(
+ options: &LoggingOptions,
+) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> {
+ #[cfg(windows)]
+ if options.dir.is_some() {
+ return Err(
+ "secure file logging is unavailable until Windows ACL and reparse-point enforcement is active"
+ .to_owned(),
+ );
+ }
+ let Some(dir) = options.dir.as_ref() else {
+ return Ok((None, None));
+ };
+ fs::create_dir_all(dir).map_err(|error| error.to_string())?;
+ let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err("log directory is not a safe directory".to_owned());
+ }
+ let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation)
+ .map_err(|error| error.to_string())?;
+ let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
+ .buffered_lines_limit(8192)
+ .lossy(false)
+ .thread_name("radroots-app-log-writer")
+ .finish(writer);
+ Ok((Some(writer), Some(guard)))
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> {
+ initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization)
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> {
+ tracing::info!("{msg}");
+ Ok(())
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> {
+ tracing::error!("{msg}");
+ Ok(())
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export)]
+pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> {
+ tracing::debug!("{msg}");
+ Ok(())
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::{
+ LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info,
+ logging_options, validate_options,
+ };
+ use std::path::PathBuf;
+
+ #[test]
+ fn logging_options_adopt_bounded_library_defaults() {
+ let options = logging_options(
+ Some("logs".to_owned()),
+ Some("mobile.log".to_owned()),
+ Some(false),
+ );
+
+ assert_eq!(options.dir, Some(PathBuf::from("logs")));
+ assert_eq!(options.file_name, "mobile.log");
+ assert!(!options.stdout);
+ assert_eq!(options.rotation, LogRotation::default());
+ }
+
+ #[test]
+ fn logging_options_preserve_public_api_defaults() {
+ let options = logging_options(None, None, None);
+
+ assert_eq!(options.file_name, "radroots.log");
+ assert!(options.stdout);
+ assert_eq!(options, LoggingOptions::default());
+ }
+
+ #[test]
+ fn validation_rejects_missing_outputs_and_unsafe_names() {
+ let mut options = LoggingOptions {
+ stdout: false,
+ ..LoggingOptions::default()
+ };
+ assert!(validate_options(&options).is_err());
+
+ options.stdout = true;
+ for name in ["", "../radroots.log", "nested/radroots.log", "."] {
+ options.file_name = name.to_owned();
+ assert!(validate_options(&options).is_err(), "accepted {name:?}");
+ }
+
+ options.dir = Some(PathBuf::from("logs"));
+ options.file_name = "radroots.log".to_owned();
+ options.stdout = false;
+ validate_options(&options).expect("file output is sufficient");
+ }
+
+ #[test]
+ fn file_writer_is_optional_and_rejects_non_directories() {
+ let options = LoggingOptions::default();
+ let (writer, guard) = build_file_writer(&options).expect("stdout only");
+ assert!(writer.is_none());
+ assert!(guard.is_none());
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let file = directory.path().join("not-a-directory");
+ std::fs::write(&file, b"not a directory").expect("fixture");
+ let options = LoggingOptions {
+ dir: Some(file),
+ ..LoggingOptions::default()
+ };
+ assert!(build_file_writer(&options).is_err());
+
+ #[cfg(unix)]
+ {
+ let target = directory.path().join("real-directory");
+ std::fs::create_dir(&target).expect("real directory");
+ let link = directory.path().join("linked-directory");
+ std::os::unix::fs::symlink(&target, &link).expect("directory symlink");
+ let options = LoggingOptions {
+ dir: Some(link),
+ ..LoggingOptions::default()
+ };
+ assert!(build_file_writer(&options).is_err());
+ }
+ }
+
+ #[test]
+ fn logging_entry_points_accept_secret_safe_messages() {
+ log_info("info".to_owned()).expect("info");
+ log_error("error".to_owned()).expect("error");
+ log_debug("debug".to_owned()).expect("debug");
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_file_logging_fails_before_filesystem_mutation() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let requested = directory.path().join("must-not-exist");
+ let options = LoggingOptions {
+ dir: Some(requested.clone()),
+ stdout: false,
+ ..LoggingOptions::default()
+ };
+ let error = build_file_writer(&options).expect_err("ACL capability must fail closed");
+ assert!(error.contains("ACL"));
+ assert!(error.contains("reparse-point"));
+ assert!(!requested.exists());
+ }
+
+ #[test]
+ fn initialization_is_idempotent_but_rejects_reconfiguration() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let options = LoggingOptions {
+ dir: Some(directory.path().to_owned()),
+ stdout: false,
+ ..LoggingOptions::default()
+ };
+ initialize(options.clone()).expect("first initialization");
+ initialize(options).expect("idempotent initialization");
+ assert!(initialize(LoggingOptions::default()).is_err());
+ }
+}
diff --git a/core/crates/tera_ffi/src/logging/writer.rs b/core/crates/tera_ffi/src/logging/writer.rs
@@ -0,0 +1,436 @@
+use std::ffi::{OsStr, OsString};
+#[cfg(any(test, not(unix)))]
+use std::fs;
+use std::fs::File;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
+const DEFAULT_RETAINED_FILES: usize = 5;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) struct LogRotation {
+ max_file_bytes: u64,
+ retained_files: usize,
+}
+
+impl Default for LogRotation {
+ fn default() -> Self {
+ Self {
+ max_file_bytes: DEFAULT_MAX_FILE_BYTES,
+ retained_files: DEFAULT_RETAINED_FILES,
+ }
+ }
+}
+
+pub(super) struct SizeRotatingWriter {
+ #[cfg(not(unix))]
+ path: PathBuf,
+ #[cfg(unix)]
+ directory: File,
+ file_name: OsString,
+ file: Option<File>,
+ bytes_written: u64,
+ policy: LogRotation,
+}
+
+impl SizeRotatingWriter {
+ pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> {
+ let file_name = path
+ .file_name()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log target has no name"))?
+ .to_owned();
+ #[cfg(unix)]
+ let directory = open_secure_directory(path.parent().ok_or_else(|| {
+ io::Error::new(io::ErrorKind::InvalidInput, "log target has no parent")
+ })?)?;
+ #[cfg(unix)]
+ let file = open_append_at(&directory, &file_name)?;
+ #[cfg(not(unix))]
+ let file = open_append(&path)?;
+ let bytes_written = file.metadata()?.len();
+ let mut writer = Self {
+ #[cfg(not(unix))]
+ path,
+ #[cfg(unix)]
+ directory,
+ file_name,
+ file: Some(file),
+ bytes_written,
+ policy,
+ };
+ if writer.bytes_written >= writer.policy.max_file_bytes {
+ writer.rotate()?;
+ }
+ Ok(writer)
+ }
+
+ fn rotate(&mut self) -> io::Result<()> {
+ if let Some(mut file) = self.file.take() {
+ file.flush()?;
+ file.sync_data()?;
+ }
+ if self.policy.retained_files == 1 {
+ self.remove_if_present(&self.file_name)?;
+ } else {
+ self.remove_if_present(&rotated_name(
+ &self.file_name,
+ self.policy.retained_files - 1,
+ ))?;
+ for index in (2..self.policy.retained_files).rev() {
+ self.rename_if_present(
+ &rotated_name(&self.file_name, index - 1),
+ &rotated_name(&self.file_name, index),
+ )?;
+ }
+ self.rename_if_present(&self.file_name, &rotated_name(&self.file_name, 1))?;
+ }
+ #[cfg(unix)]
+ let file = open_append_at(&self.directory, &self.file_name)?;
+ #[cfg(not(unix))]
+ let file = open_append(&self.path)?;
+ self.file = Some(file);
+ self.bytes_written = 0;
+ Ok(())
+ }
+
+ fn remove_if_present(&self, name: &OsStr) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ use rustix::fs::{AtFlags, unlinkat};
+ match unlinkat(&self.directory, name, AtFlags::empty()) {
+ Ok(()) => Ok(()),
+ Err(error) if error == rustix::io::Errno::NOENT => Ok(()),
+ Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())),
+ }
+ }
+ #[cfg(not(unix))]
+ remove_if_present(&self.path.with_file_name(name))
+ }
+
+ fn rename_if_present(&self, source: &OsStr, target: &OsStr) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ use rustix::fs::renameat;
+ match renameat(&self.directory, source, &self.directory, target) {
+ Ok(()) => Ok(()),
+ Err(error) if error == rustix::io::Errno::NOENT => Ok(()),
+ Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())),
+ }
+ }
+ #[cfg(not(unix))]
+ rename_if_present(
+ &self.path.with_file_name(source),
+ &self.path.with_file_name(target),
+ )
+ }
+
+ fn file_mut(&mut self) -> io::Result<&mut File> {
+ self.file
+ .as_mut()
+ .ok_or_else(|| io::Error::other("log file is unavailable"))
+ }
+}
+
+impl Write for SizeRotatingWriter {
+ fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
+ let incoming = u64::try_from(buffer.len())
+ .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?;
+ if incoming > self.policy.max_file_bytes {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "log event exceeds the configured file limit",
+ ));
+ }
+ if self.bytes_written > 0
+ && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes
+ {
+ self.rotate()?;
+ }
+ let written = self.file_mut()?.write(buffer)?;
+ self.bytes_written = self
+ .bytes_written
+ .saturating_add(u64::try_from(written).unwrap_or(u64::MAX));
+ Ok(written)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.file_mut()?.flush()
+ }
+}
+
+#[cfg(test)]
+fn reject_unsafe_target(path: &Path) -> io::Result<()> {
+ match fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err(
+ io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"),
+ ),
+ Ok(_) => Ok(()),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ }
+}
+
+#[cfg(unix)]
+fn open_secure_directory(path: &Path) -> io::Result<File> {
+ use rustix::fs::{FileType, Mode, OFlags, fstat, open};
+ use rustix::process::geteuid;
+
+ let directory = open(
+ path,
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
+ Mode::empty(),
+ )
+ .map_err(errno_to_io)?;
+ let status = fstat(&directory).map_err(errno_to_io)?;
+ if FileType::from_raw_mode(status.st_mode) != FileType::Directory
+ || status.st_uid != geteuid().as_raw()
+ || status.st_mode & 0o022 != 0
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "log parent must be an owner-controlled non-writable directory",
+ ));
+ }
+ Ok(File::from(directory))
+}
+
+#[cfg(unix)]
+fn open_append_at(directory: &File, name: &OsStr) -> io::Result<File> {
+ use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, openat};
+
+ let descriptor = openat(
+ directory,
+ name,
+ OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC,
+ Mode::RUSR | Mode::WUSR,
+ )
+ .map_err(errno_to_io)?;
+ let status = fstat(&descriptor).map_err(errno_to_io)?;
+ if FileType::from_raw_mode(status.st_mode) != FileType::RegularFile || status.st_nlink != 1 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "log target must be one regular file link",
+ ));
+ }
+ fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(errno_to_io)?;
+ Ok(File::from(descriptor))
+}
+
+#[cfg(unix)]
+fn errno_to_io(error: rustix::io::Errno) -> io::Error {
+ io::Error::from_raw_os_error(error.raw_os_error())
+}
+
+#[cfg(not(unix))]
+fn open_append(_path: &Path) -> io::Result<File> {
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "secure file logging is unavailable without a no-follow ACL implementation",
+ ))
+}
+
+#[cfg(test)]
+fn rotated_path(path: &Path, index: usize) -> PathBuf {
+ let mut value = path.as_os_str().to_owned();
+ value.push(format!(".{index}"));
+ PathBuf::from(value)
+}
+
+fn rotated_name(name: &OsStr, index: usize) -> OsString {
+ let mut value = name.to_owned();
+ value.push(format!(".{index}"));
+ value
+}
+
+#[cfg(any(test, not(unix)))]
+fn remove_if_present(path: &Path) -> io::Result<()> {
+ match fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ }
+}
+
+#[cfg(any(test, not(unix)))]
+fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> {
+ match fs::rename(source, target) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::{
+ LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present,
+ rename_if_present, rotated_path,
+ };
+ use std::ffi::OsStr;
+ use std::io::Write;
+
+ #[test]
+ fn rotation_is_size_bounded_and_retention_is_finite() {
+ let directory = tempfile::tempdir().expect("temporary log directory");
+ let path = directory.path().join("radroots.log");
+ let mut writer = SizeRotatingWriter::new(
+ path.clone(),
+ LogRotation {
+ max_file_bytes: 5,
+ retained_files: 3,
+ },
+ )
+ .expect("writer");
+ for value in [b"1111", b"2222", b"3333", b"4444"] {
+ writer.write_all(value).expect("log entry");
+ }
+ writer.flush().expect("flush");
+ assert_eq!(std::fs::read(&path).expect("current"), b"4444");
+ assert_eq!(
+ std::fs::read(rotated_path(&path, 1)).expect("first retained"),
+ b"3333"
+ );
+ assert_eq!(
+ std::fs::read(rotated_path(&path, 2)).expect("second retained"),
+ b"2222"
+ );
+ assert!(!rotated_path(&path, 3).exists());
+ }
+
+ #[test]
+ fn single_file_rotation_removes_the_previous_file() {
+ let directory = tempfile::tempdir().expect("temporary log directory");
+ let path = directory.path().join("radroots.log");
+ std::fs::write(&path, b"full!").expect("fixture");
+ let mut writer = SizeRotatingWriter::new(
+ path.clone(),
+ LogRotation {
+ max_file_bytes: 5,
+ retained_files: 1,
+ },
+ )
+ .expect("writer");
+ writer.write_all(b"next").expect("write");
+ writer.flush().expect("flush");
+ assert_eq!(std::fs::read(path).expect("current"), b"next");
+ }
+
+ #[test]
+ fn oversized_events_and_unavailable_files_fail_closed() {
+ let directory = tempfile::tempdir().expect("temporary log directory");
+ let path = directory.path().join("radroots.log");
+ let mut writer = SizeRotatingWriter::new(
+ path,
+ LogRotation {
+ max_file_bytes: 3,
+ retained_files: 2,
+ },
+ )
+ .expect("writer");
+ assert_eq!(
+ writer.write(b"four").expect_err("oversized").kind(),
+ std::io::ErrorKind::InvalidData
+ );
+ writer.write_all(b"a").expect("first short write");
+ writer.write_all(b"b").expect("second short write");
+ writer.file = None;
+ assert_eq!(
+ writer.flush().expect_err("missing file").kind(),
+ std::io::ErrorKind::Other
+ );
+ }
+
+ #[test]
+ fn unsafe_targets_and_absent_rotation_files_are_handled() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ assert!(reject_unsafe_target(directory.path()).is_err());
+ let regular = directory.path().join("regular");
+ std::fs::write(®ular, b"regular").expect("regular file");
+ assert!(reject_unsafe_target(®ular).is_ok());
+ let missing = directory.path().join("missing");
+ assert!(reject_unsafe_target(&missing).is_ok());
+ assert!(remove_if_present(&missing).is_ok());
+ assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok());
+ assert!(remove_if_present(directory.path()).is_err());
+
+ let source = directory.path().join("source");
+ std::fs::write(&source, b"source").expect("source");
+ assert!(rename_if_present(&source, directory.path()).is_err());
+
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink");
+ assert!(reject_unsafe_target(&missing).is_err());
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn descriptor_relative_open_rejects_symlinks_and_multiple_links() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let outside = tempfile::NamedTempFile::new().expect("outside file");
+ let symlink = directory.path().join("radroots.log");
+ std::os::unix::fs::symlink(outside.path(), &symlink).expect("symlink");
+ assert!(SizeRotatingWriter::new(symlink.clone(), LogRotation::default()).is_err());
+
+ std::fs::remove_file(&symlink).expect("remove symlink");
+ std::fs::hard_link(outside.path(), &symlink).expect("hard link");
+ assert!(SizeRotatingWriter::new(symlink, LogRotation::default()).is_err());
+
+ let link_holder = tempfile::tempdir().expect("link holder");
+ let parent_link = link_holder.path().join("parent-link");
+ std::os::unix::fs::symlink(directory.path(), &parent_link).expect("parent symlink");
+ assert!(
+ SizeRotatingWriter::new(parent_link.join("other.log"), LogRotation::default()).is_err()
+ );
+
+ use std::os::unix::fs::PermissionsExt;
+ let insecure = tempfile::tempdir().expect("insecure parent");
+ std::fs::set_permissions(insecure.path(), std::fs::Permissions::from_mode(0o777))
+ .expect("insecure permissions");
+ assert!(
+ SizeRotatingWriter::new(insecure.path().join("radroots.log"), LogRotation::default())
+ .is_err()
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn descriptor_relative_rotation_propagates_non_missing_errors() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let mut writer = SizeRotatingWriter::new(
+ directory.path().join("radroots.log"),
+ LogRotation::default(),
+ )
+ .expect("writer");
+ writer.flush().expect("flush");
+
+ std::fs::create_dir(directory.path().join("blocked")).expect("blocked directory");
+ assert!(writer.remove_if_present(OsStr::new("blocked")).is_err());
+ std::fs::write(directory.path().join("source"), b"source").expect("source");
+ assert!(
+ writer
+ .rename_if_present(OsStr::new("source"), OsStr::new("blocked"))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn rotation_without_an_open_file_recreates_the_target() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let path = directory.path().join("radroots.log");
+ let mut writer = SizeRotatingWriter::new(
+ path,
+ LogRotation {
+ max_file_bytes: 3,
+ retained_files: 2,
+ },
+ )
+ .expect("writer");
+ writer.file = None;
+ writer.rotate().expect("rotation");
+ writer.write_all(b"ok").expect("write after rotation");
+ }
+}
diff --git a/core/crates/tera_ffi/src/operations.rs b/core/crates/tera_ffi/src/operations.rs
@@ -0,0 +1,1005 @@
+//! Focused secret-safe operation records for settings, profile, revision, and inbound media.
+
+use tera_core::runtime::product_surface::{
+ AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand,
+ IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy,
+ MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus,
+ Phase1ProfileStatus, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus,
+ Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, RelayEndpointPreference,
+ RelayPreferences, SettingsTransition, phase1_new_operation_id,
+};
+
+use crate::dto::PreparedMedia;
+use crate::{
+ FfiAddDraftInput, FfiDraftStatusRecord, FfiOperationSettlementRecord, FfiOutboxState,
+ FfiPreparedMediaInput, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError,
+};
+
+impl From<crate::FfiAddCommandType> for AddCommandType {
+ fn from(value: crate::FfiAddCommandType) -> Self {
+ match value {
+ crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate,
+ crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
+ crate::FfiAddCommandType::CreateAsk => Self::CreateAsk,
+ crate::FfiAddCommandType::CreateEvent => Self::CreateEvent,
+ crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiIdentityLockState {
+ Locked,
+ Unlocked,
+}
+
+impl From<IdentityLockState> for FfiIdentityLockState {
+ fn from(value: IdentityLockState) -> Self {
+ match value {
+ IdentityLockState::Locked => Self::Locked,
+ IdentityLockState::Unlocked => Self::Unlocked,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiSettingsIdentityRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub public_key: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiIdentityStateRecord {
+ pub schema_version: u16,
+ pub identities: Vec<FfiSettingsIdentityRecord>,
+ pub active_identity_id: Option<String>,
+ pub lock_state: FfiIdentityLockState,
+ pub pending_import_operation_id: Option<String>,
+}
+
+impl From<&IdentityState> for FfiIdentityStateRecord {
+ fn from(value: &IdentityState) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ identities: value
+ .identities()
+ .iter()
+ .map(|identity| FfiSettingsIdentityRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: identity.id().to_owned(),
+ public_key: identity.public_key_hex().to_owned(),
+ })
+ .collect(),
+ active_identity_id: value.active_identity_id().map(str::to_owned),
+ lock_state: value.lock_state().into(),
+ pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiIdentityCommandKind {
+ BeginImport,
+ CompleteImport,
+ CancelImport,
+ Select,
+ Lock,
+ Unlock,
+ Recover,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiIdentityCommandRecord {
+ pub schema_version: u16,
+ pub kind: FfiIdentityCommandKind,
+ pub operation_id: Option<String>,
+ pub identity_id: Option<String>,
+ pub public_key: Option<String>,
+}
+
+impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ let no_operation = value.operation_id.is_none();
+ let no_identity = value.identity_id.is_none() && value.public_key.is_none();
+ match value.kind {
+ FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport {
+ operation_id: required(value.operation_id, "identity_operation_id_required")?,
+ }),
+ FfiIdentityCommandKind::CompleteImport => {
+ let operation_id = required(value.operation_id, "identity_operation_id_required")?;
+ let identity_id = required(value.identity_id, "identity_id_required")?;
+ let public_key = required(value.public_key, "identity_public_key_required")?;
+ Ok(Self::CompleteImport {
+ operation_id,
+ identity: IdentityRecord::new(identity_id, &public_key)
+ .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?,
+ })
+ }
+ FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport {
+ operation_id: required(value.operation_id, "identity_operation_id_required")?,
+ }),
+ FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => {
+ Ok(Self::Select {
+ identity_id: required(value.identity_id, "identity_id_required")?,
+ })
+ }
+ FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock),
+ FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock),
+ FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover),
+ _ => Err(RadrootsAppError::invalid_argument(
+ "invalid_identity_command",
+ )),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiMobileNetworkEnvironment {
+ Public,
+ Simulator,
+ PhysicalDevice,
+}
+
+impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment {
+ fn from(value: FfiMobileNetworkEnvironment) -> Self {
+ match value {
+ FfiMobileNetworkEnvironment::Public => Self::Public,
+ FfiMobileNetworkEnvironment::Simulator => Self::Simulator,
+ FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
+ }
+ }
+}
+
+impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment {
+ fn from(value: MobileNetworkEnvironment) -> Self {
+ match value {
+ MobileNetworkEnvironment::Public => Self::Public,
+ MobileNetworkEnvironment::Simulator => Self::Simulator,
+ MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRelayAccessPreference {
+ ReadOnly,
+ ReadWrite,
+}
+
+impl From<FfiRelayAccessPreference> for RelayAccessPreference {
+ fn from(value: FfiRelayAccessPreference) -> Self {
+ match value {
+ FfiRelayAccessPreference::ReadOnly => Self::ReadOnly,
+ FfiRelayAccessPreference::ReadWrite => Self::ReadWrite,
+ }
+ }
+}
+
+impl From<RelayAccessPreference> for FfiRelayAccessPreference {
+ fn from(value: RelayAccessPreference) -> Self {
+ match value {
+ RelayAccessPreference::ReadOnly => Self::ReadOnly,
+ RelayAccessPreference::ReadWrite => Self::ReadWrite,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayPreferenceRecord {
+ pub schema_version: u16,
+ pub url: String,
+ pub access: FfiRelayAccessPreference,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayPreferencesRecord {
+ pub schema_version: u16,
+ pub environment: FfiMobileNetworkEnvironment,
+ pub endpoints: Vec<FfiRelayPreferenceRecord>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiBlossomAuthorityPreference {
+ PublicWebPki,
+ LoopbackDevelopment,
+ PrivateNetworkDevelopment,
+}
+
+impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference {
+ fn from(value: FfiBlossomAuthorityPreference) -> Self {
+ match value {
+ FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki,
+ FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
+ FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => {
+ Self::PrivateNetworkDevelopment
+ }
+ }
+ }
+}
+
+impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference {
+ fn from(value: BlossomEndpointAuthorityPreference) -> Self {
+ match value {
+ BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki,
+ BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
+ BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => {
+ Self::PrivateNetworkDevelopment
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomPreferencesRecord {
+ pub schema_version: u16,
+ pub environment: FfiMobileNetworkEnvironment,
+ pub authority: FfiBlossomAuthorityPreference,
+ pub primary_origin: String,
+ pub fallback_origins: Vec<String>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMediaNetworkPolicyRecord {
+ pub schema_version: u16,
+ pub allow_cellular_downloads: bool,
+ pub allow_cellular_uploads: bool,
+ pub allow_background_transfers: bool,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiLocalStoragePolicyRecord {
+ pub schema_version: u16,
+ pub media_cache_bytes: u64,
+ pub media_cache_artifacts: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMobileSettingsRecord {
+ pub schema_version: u16,
+ pub revision: u64,
+ pub identity: FfiIdentityStateRecord,
+ pub relays: FfiRelayPreferencesRecord,
+ pub blossom: FfiBlossomPreferencesRecord,
+ pub media_network: FfiMediaNetworkPolicyRecord,
+ pub local_storage: FfiLocalStoragePolicyRecord,
+}
+
+impl From<&MobileSettings> for FfiMobileSettingsRecord {
+ fn from(value: &MobileSettings) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ revision: value.revision(),
+ identity: value.identity().into(),
+ relays: FfiRelayPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: value.relays().environment().into(),
+ endpoints: value
+ .relays()
+ .endpoints()
+ .iter()
+ .map(|endpoint| FfiRelayPreferenceRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: endpoint.url().to_owned(),
+ access: endpoint.access().into(),
+ })
+ .collect(),
+ },
+ blossom: FfiBlossomPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: value.blossom().environment().into(),
+ authority: value.blossom().authority().into(),
+ primary_origin: value.blossom().primary_origin().to_owned(),
+ fallback_origins: value.blossom().fallback_origins().to_vec(),
+ },
+ media_network: FfiMediaNetworkPolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ allow_cellular_downloads: value.media_network().allow_cellular_downloads(),
+ allow_cellular_uploads: value.media_network().allow_cellular_uploads(),
+ allow_background_transfers: value.media_network().allow_background_transfers(),
+ },
+ local_storage: FfiLocalStoragePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ media_cache_bytes: value.local_storage().media_cache_bytes(),
+ media_cache_artifacts: value.local_storage().media_cache_artifacts(),
+ },
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiReplaceSettingsRecord {
+ pub schema_version: u16,
+ pub expected_revision: u64,
+ pub relays: FfiRelayPreferencesRecord,
+ pub blossom: FfiBlossomPreferencesRecord,
+ pub media_network: FfiMediaNetworkPolicyRecord,
+ pub local_storage: FfiLocalStoragePolicyRecord,
+}
+
+impl FfiReplaceSettingsRecord {
+ pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, RadrootsAppError> {
+ require_schema(self.schema_version)?;
+ if current.revision() != self.expected_revision {
+ return Err(RadrootsAppError::invalid_argument(
+ "settings_revision_conflict",
+ ));
+ }
+ require_schema(self.relays.schema_version)?;
+ let relay_environment: MobileNetworkEnvironment = self.relays.environment.into();
+ let relay_endpoints = self
+ .relays
+ .endpoints
+ .into_iter()
+ .map(|endpoint| {
+ require_schema(endpoint.schema_version)?;
+ RelayEndpointPreference::new(
+ relay_environment,
+ endpoint.url,
+ endpoint.access.into(),
+ )
+ .map_err(Into::into)
+ })
+ .collect::<Result<Vec<_>, RadrootsAppError>>()?;
+ let relays = RelayPreferences::new(relay_environment, relay_endpoints)?;
+
+ require_schema(self.blossom.schema_version)?;
+ let blossom = BlossomPreferences::new(
+ self.blossom.environment.into(),
+ self.blossom.authority.into(),
+ self.blossom.primary_origin,
+ self.blossom.fallback_origins,
+ )?;
+ require_schema(self.media_network.schema_version)?;
+ let media_network = MediaNetworkPolicy::new(
+ self.media_network.allow_cellular_downloads,
+ self.media_network.allow_cellular_uploads,
+ self.media_network.allow_background_transfers,
+ );
+ require_schema(self.local_storage.schema_version)?;
+ let local_storage = LocalStoragePolicy::new(
+ self.local_storage.media_cache_bytes,
+ self.local_storage.media_cache_artifacts,
+ )?;
+ Ok(current
+ .with_relays(relays)
+ .with_blossom(blossom)
+ .with_media_network(media_network)
+ .with_local_storage(local_storage))
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiSettingsTransitionRecord {
+ pub schema_version: u16,
+ pub settings: FfiMobileSettingsRecord,
+ pub runtime_restart_required: bool,
+ pub outbox_requeue_required: bool,
+ pub media_cache_invalidation_required: bool,
+}
+
+impl From<SettingsTransition> for FfiSettingsTransitionRecord {
+ fn from(value: SettingsTransition) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ settings: (&value.settings).into(),
+ runtime_restart_required: value.runtime_restart_required,
+ outbox_requeue_required: value.outbox_requeue_required,
+ media_cache_invalidation_required: value.media_cache_invalidation_required,
+ }
+ }
+}
+
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct FfiProfileMetadataInputRecord {
+ pub schema_version: u16,
+ pub name: String,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<FfiPreparedMediaInput>,
+ pub banner: Option<FfiPreparedMediaInput>,
+ pub nip05: Option<String>,
+ pub bot: Option<bool>,
+}
+
+impl FfiProfileMetadataInputRecord {
+ pub(crate) fn command(
+ self,
+ blossom: Option<&radroots_sdk::transport::BlossomSlot>,
+ ) -> Result<ProfileMetadataCommand, RadrootsAppError> {
+ require_schema(self.schema_version)?;
+ let picture = self
+ .picture
+ .map(PreparedMedia::try_from)
+ .transpose()?
+ .map(|media| {
+ let blossom = blossom.ok_or_else(|| {
+ RadrootsAppError::failure(
+ "blossom_unconfigured",
+ "profile",
+ true,
+ &["configure_blossom"],
+ "Profile media configuration is unavailable.",
+ )
+ })?;
+ media.into_authored_image(blossom)
+ })
+ .transpose()?;
+ let banner = self
+ .banner
+ .map(PreparedMedia::try_from)
+ .transpose()?
+ .map(|media| {
+ let blossom = blossom.ok_or_else(|| {
+ RadrootsAppError::failure(
+ "blossom_unconfigured",
+ "profile",
+ true,
+ &["configure_blossom"],
+ "Profile media configuration is unavailable.",
+ )
+ })?;
+ media.into_authored_image(blossom)
+ })
+ .transpose()?;
+ ProfileMetadataCommand::new(
+ self.name,
+ self.display_name,
+ self.about,
+ picture,
+ banner,
+ self.nip05,
+ self.bot,
+ )
+ .map_err(Into::into)
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiProfileStatusRecord {
+ pub schema_version: u16,
+ pub operation_id: String,
+ pub revision: u64,
+ pub author_public_key: String,
+ pub state: FfiOutboxState,
+ pub delivery_id: Option<String>,
+ pub created_at_unix_ms: u64,
+ pub updated_at_unix_ms: u64,
+ pub settlement: Option<FfiOperationSettlementRecord>,
+}
+
+impl From<Phase1ProfileStatus> for FfiProfileStatusRecord {
+ fn from(value: Phase1ProfileStatus) -> Self {
+ let draft = value.draft();
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_id: hex::encode(draft.draft_id().as_bytes()),
+ revision: draft.revision().get(),
+ author_public_key: hex::encode(draft.author()),
+ state: value.state().into(),
+ delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
+ created_at_unix_ms: draft.created_at_unix_ms(),
+ updated_at_unix_ms: draft.updated_at_unix_ms(),
+ settlement: value.push().map(|push| push.settlement().into()),
+ }
+ }
+}
+
+#[derive(Clone, Debug, uniffi::Record)]
+pub struct FfiRevisionInputRecord {
+ pub schema_version: u16,
+ pub card_id: String,
+ pub source_event_id: String,
+ pub source_address: Option<String>,
+ pub author_public_key: String,
+ pub replacement: FfiAddDraftInput,
+}
+
+impl FfiRevisionInputRecord {
+ pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> {
+ require_schema(self.schema_version)?;
+ Phase1RevisionTarget::from_source(
+ self.replacement.command_type.into(),
+ tera_core::runtime::product_surface::CardId::parse(&self.card_id)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?,
+ self.source_event_id.clone(),
+ self.source_address.clone(),
+ self.author_public_key.clone(),
+ )
+ .map_err(Into::into)
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRevisionPolicy {
+ ReplaceThenRetract,
+ AddressableReplacement,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRevisionPhase {
+ ReplacementPending,
+ ReplacementFailed,
+ RetractionPending,
+ Complete,
+ PartialEffect,
+ Cancelled,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRevisionStatusRecord {
+ pub schema_version: u16,
+ pub operation_id: String,
+ pub replacement: FfiDraftStatusRecord,
+ pub retraction: Option<FfiDraftStatusRecord>,
+ pub policy: FfiRevisionPolicy,
+ pub phase: FfiRevisionPhase,
+}
+
+impl From<Phase1RevisionStatus> for FfiRevisionStatusRecord {
+ fn from(value: Phase1RevisionStatus) -> Self {
+ let operation_id = hex::encode(value.replacement().draft().draft_id().as_bytes());
+ let retraction = value.retraction().cloned().map(Into::into);
+ let replacement = value.replacement().clone().into();
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_id,
+ replacement,
+ retraction,
+ policy: match value.policy() {
+ Phase1RevisionPolicy::ReplaceThenRetract => FfiRevisionPolicy::ReplaceThenRetract,
+ Phase1RevisionPolicy::AddressableReplacement => {
+ FfiRevisionPolicy::AddressableReplacement
+ }
+ },
+ phase: match value.phase() {
+ Phase1RevisionPhase::ReplacementPending => FfiRevisionPhase::ReplacementPending,
+ Phase1RevisionPhase::ReplacementFailed => FfiRevisionPhase::ReplacementFailed,
+ Phase1RevisionPhase::RetractionPending => FfiRevisionPhase::RetractionPending,
+ Phase1RevisionPhase::Complete => FfiRevisionPhase::Complete,
+ Phase1RevisionPhase::PartialEffect => FfiRevisionPhase::PartialEffect,
+ Phase1RevisionPhase::Cancelled => FfiRevisionPhase::Cancelled,
+ },
+ }
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct FfiMediaOperation {
+ operation_id: [u8; 16],
+ cancellation: radroots_sdk::transport::BlossomCancellation,
+ claimed: std::sync::atomic::AtomicBool,
+}
+
+#[uniffi::export]
+impl FfiMediaOperation {
+ #[uniffi::constructor]
+ pub fn new() -> Result<Self, RadrootsAppError> {
+ Ok(Self {
+ operation_id: phase1_new_operation_id().map_err(RadrootsAppError::from)?,
+ cancellation: radroots_sdk::transport::BlossomCancellation::default(),
+ claimed: std::sync::atomic::AtomicBool::new(false),
+ })
+ }
+
+ pub fn operation_id(&self) -> String {
+ hex::encode(self.operation_id)
+ }
+
+ pub fn cancel(&self) {
+ self.cancellation.cancel();
+ }
+
+ pub fn is_cancelled(&self) -> bool {
+ self.cancellation.is_cancelled()
+ }
+}
+
+impl FfiMediaOperation {
+ pub(crate) fn claim(&self) -> Result<(), RadrootsAppError> {
+ self.claimed
+ .compare_exchange(
+ false,
+ true,
+ std::sync::atomic::Ordering::AcqRel,
+ std::sync::atomic::Ordering::Acquire,
+ )
+ .map(|_| ())
+ .map_err(|_| RadrootsAppError::invalid_argument("media_operation_already_used"))
+ }
+
+ pub(crate) const fn id(&self) -> [u8; 16] {
+ self.operation_id
+ }
+
+ pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation {
+ self.cancellation.clone()
+ }
+}
+
+#[derive(Clone, Eq, PartialEq, uniffi::Record)]
+pub struct FfiVerifiedMediaArtifactRecord {
+ pub schema_version: u16,
+ pub operation_id: Option<String>,
+ pub artifact_id: String,
+ pub bytes: Vec<u8>,
+ pub byte_size: u64,
+ pub media_type: String,
+ pub width: u32,
+ pub height: u32,
+}
+
+impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("FfiVerifiedMediaArtifactRecord")
+ .field("schema_version", &self.schema_version)
+ .field("operation_id", &self.operation_id)
+ .field("artifact_id", &self.artifact_id)
+ .field("bytes", &"<redacted>")
+ .field("byte_size", &self.byte_size)
+ .field("media_type", &self.media_type)
+ .field("width", &self.width)
+ .field("height", &self.height)
+ .finish()
+ }
+}
+
+impl FfiVerifiedMediaArtifactRecord {
+ pub(crate) fn from_artifact(
+ value: Phase1LocalMediaArtifact,
+ operation_id: Option<String>,
+ ) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_id,
+ artifact_id: value.artifact_id().to_hex(),
+ bytes: value.bytes().to_vec(),
+ byte_size: value.byte_size(),
+ media_type: value.media_type().to_owned(),
+ width: value.width(),
+ height: value.height(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMediaCacheStatusRecord {
+ pub schema_version: u16,
+ pub artifact_count: u32,
+ pub total_bytes: u64,
+ pub configuration_fingerprint: Option<String>,
+}
+
+impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord {
+ fn from(value: Phase1MediaCacheStatus) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ artifact_count: value.artifacts,
+ total_bytes: value.bytes,
+ configuration_fingerprint: value.configuration.map(|value| value.to_hex()),
+ }
+ }
+}
+
+pub(crate) fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> {
+ if schema_version == MOBILE_FFI_SCHEMA_VERSION {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::invalid_argument(
+ "unsupported_schema_version",
+ ))
+ }
+}
+
+fn required(value: Option<String>, code: &'static str) -> Result<String, RadrootsAppError> {
+ value.ok_or_else(|| RadrootsAppError::invalid_argument(code))
+}
+
+pub(crate) fn decode_artifact_id(
+ value: &str,
+) -> Result<tera_core::runtime::product_surface::Phase1MediaArtifactId, RadrootsAppError> {
+ tera_core::runtime::product_surface::Phase1MediaArtifactId::parse(value)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_artifact_id"))
+}
+
+pub(crate) fn decode_configuration(
+ value: &str,
+) -> Result<
+ tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint,
+ RadrootsAppError,
+> {
+ tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse(value)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_configuration"))
+}
+
+pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], RadrootsAppError> {
+ let bytes = hex::decode(value)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))?;
+ bytes
+ .try_into()
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint;
+
+ const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+
+ #[test]
+ fn media_operation_can_be_claimed_exactly_once() {
+ let operation = FfiMediaOperation::new().unwrap();
+ operation.claim().unwrap();
+ let error = operation.claim().unwrap_err();
+ assert_eq!(error.report().code, "media_operation_already_used");
+ assert_eq!(operation.operation_id().len(), 32);
+ }
+
+ #[test]
+ fn verified_media_artifact_debug_never_exposes_renderable_bytes() {
+ let artifact = FfiVerifiedMediaArtifactRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_id: None,
+ artifact_id: "11".repeat(32),
+ bytes: b"private farm image".to_vec(),
+ byte_size: 18,
+ media_type: "image/png".to_owned(),
+ width: 1,
+ height: 1,
+ };
+ let debug = format!("{artifact:?}");
+ assert!(debug.contains("<redacted>"));
+ assert!(!debug.contains("private farm image"));
+ }
+
+ #[test]
+ fn identity_commands_reject_fields_outside_the_selected_variant() {
+ let error = IdentityCommand::try_from(FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Lock,
+ operation_id: Some("unexpected".to_owned()),
+ identity_id: None,
+ public_key: None,
+ })
+ .unwrap_err();
+ assert_eq!(error.report().code, "invalid_identity_command");
+ }
+
+ #[test]
+ fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() {
+ for (ffi, core) in [
+ (
+ crate::FfiAddCommandType::CreateUpdate,
+ AddCommandType::CreateUpdate,
+ ),
+ (
+ crate::FfiAddCommandType::CreatePhotoUpdate,
+ AddCommandType::CreatePhotoUpdate,
+ ),
+ (
+ crate::FfiAddCommandType::CreateAsk,
+ AddCommandType::CreateAsk,
+ ),
+ (
+ crate::FfiAddCommandType::CreateEvent,
+ AddCommandType::CreateEvent,
+ ),
+ (
+ crate::FfiAddCommandType::CreateFoodAvailability,
+ AddCommandType::CreateFoodAvailability,
+ ),
+ ] {
+ assert_eq!(AddCommandType::from(ffi), core);
+ }
+ for environment in [
+ FfiMobileNetworkEnvironment::Public,
+ FfiMobileNetworkEnvironment::Simulator,
+ FfiMobileNetworkEnvironment::PhysicalDevice,
+ ] {
+ assert_eq!(
+ FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)),
+ environment
+ );
+ }
+ for access in [
+ FfiRelayAccessPreference::ReadOnly,
+ FfiRelayAccessPreference::ReadWrite,
+ ] {
+ assert_eq!(
+ FfiRelayAccessPreference::from(RelayAccessPreference::from(access)),
+ access
+ );
+ }
+ for authority in [
+ FfiBlossomAuthorityPreference::PublicWebPki,
+ FfiBlossomAuthorityPreference::LoopbackDevelopment,
+ FfiBlossomAuthorityPreference::PrivateNetworkDevelopment,
+ ] {
+ assert_eq!(
+ FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from(
+ authority
+ )),
+ authority
+ );
+ }
+
+ let commands = [
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::BeginImport,
+ operation_id: Some("operation".to_owned()),
+ identity_id: None,
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::CompleteImport,
+ operation_id: Some("operation".to_owned()),
+ identity_id: Some("primary".to_owned()),
+ public_key: Some(PUBLIC_KEY.to_owned()),
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::CancelImport,
+ operation_id: Some("operation".to_owned()),
+ identity_id: None,
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Select,
+ operation_id: None,
+ identity_id: Some("primary".to_owned()),
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Lock,
+ operation_id: None,
+ identity_id: None,
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Unlock,
+ operation_id: None,
+ identity_id: None,
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Recover,
+ operation_id: None,
+ identity_id: None,
+ public_key: None,
+ },
+ ];
+ for command in commands {
+ assert!(IdentityCommand::try_from(command).is_ok());
+ }
+ for command in [
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::BeginImport,
+ operation_id: Some("operation".to_owned()),
+ identity_id: Some("unexpected".to_owned()),
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::CancelImport,
+ operation_id: Some("operation".to_owned()),
+ identity_id: None,
+ public_key: Some(PUBLIC_KEY.to_owned()),
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Select,
+ operation_id: Some("unexpected".to_owned()),
+ identity_id: Some("primary".to_owned()),
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Select,
+ operation_id: None,
+ identity_id: Some("primary".to_owned()),
+ public_key: Some(PUBLIC_KEY.to_owned()),
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Lock,
+ operation_id: Some("unexpected".to_owned()),
+ identity_id: None,
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Unlock,
+ operation_id: None,
+ identity_id: Some("unexpected".to_owned()),
+ public_key: None,
+ },
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Recover,
+ operation_id: None,
+ identity_id: None,
+ public_key: Some(PUBLIC_KEY.to_owned()),
+ },
+ ] {
+ assert!(IdentityCommand::try_from(command).is_err());
+ }
+ assert!(
+ IdentityCommand::try_from(FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ kind: FfiIdentityCommandKind::Lock,
+ operation_id: None,
+ identity_id: None,
+ public_key: None,
+ })
+ .is_err()
+ );
+
+ let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap();
+ for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] {
+ let state = IdentityState::new(
+ vec![identity.clone()],
+ Some("primary".to_owned()),
+ lock_state,
+ None,
+ )
+ .unwrap();
+ let record = FfiIdentityStateRecord::from(&state);
+ assert_eq!(record.identities.len(), 1);
+ assert_eq!(record.lock_state, lock_state.into());
+ }
+ }
+
+ #[test]
+ fn media_decoders_status_and_private_operation_accessors_are_exhaustive() {
+ assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok());
+ assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err());
+ for invalid in ["", "not-hex", "00"] {
+ assert!(decode_artifact_id(invalid).is_err());
+ assert!(decode_configuration(invalid).is_err());
+ assert!(decode_reference_fingerprint(invalid).is_err());
+ }
+ let digest = "11".repeat(32);
+ assert!(decode_artifact_id(&digest).is_ok());
+ assert!(decode_configuration(&digest).is_ok());
+ assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]);
+
+ let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap();
+ let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
+ artifacts: 2,
+ bytes: 9,
+ configuration: Some(configuration),
+ });
+ assert_eq!(status.artifact_count, 2);
+ assert_eq!(status.total_bytes, 9);
+ assert_eq!(status.configuration_fingerprint, Some("07".repeat(32)));
+ assert!(
+ FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
+ artifacts: 0,
+ bytes: 0,
+ configuration: None,
+ })
+ .configuration_fingerprint
+ .is_none()
+ );
+
+ let operation = FfiMediaOperation::new().unwrap();
+ assert_eq!(operation.id(), operation.operation_id);
+ assert!(!operation.cancellation().is_cancelled());
+ assert!(!operation.is_cancelled());
+ operation.cancel();
+ assert!(operation.is_cancelled());
+ assert!(operation.cancellation().is_cancelled());
+ }
+}
diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs
@@ -0,0 +1,1186 @@
+use std::sync::Arc;
+
+use tera_core::runtime::product_surface::{
+ LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1MediaCachePolicy,
+ Phase1QueueIntent, Phase1ReviseIntent, ReplaceMobileSettings, TodayPageRequest,
+ phase1_new_addressable_identifier, phase1_operation_now_unix_ms,
+};
+
+use crate::dto::PreparedMedia;
+use crate::operations::{
+ FfiIdentityCommandRecord, FfiMediaCacheStatusRecord, FfiMediaOperation,
+ FfiMobileSettingsRecord, FfiProfileMetadataInputRecord, FfiProfileStatusRecord,
+ FfiReplaceSettingsRecord, FfiRevisionInputRecord, FfiRevisionStatusRecord,
+ FfiSettingsTransitionRecord, FfiVerifiedMediaArtifactRecord, decode_artifact_id,
+ decode_configuration, decode_reference_fingerprint,
+};
+use crate::signer::HostSignerAdapter;
+use crate::subscription::SubscriptionHub;
+use crate::{
+ FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord,
+ FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind,
+ 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)]
+pub enum ProtectedDataAvailability {
+ Available,
+ Unavailable,
+}
+
+impl From<ProtectedDataAvailability> for tera_core::runtime::store::ProtectedDataAvailability {
+ fn from(value: ProtectedDataAvailability) -> Self {
+ match value {
+ ProtectedDataAvailability::Available => Self::Available,
+ ProtectedDataAvailability::Unavailable => Self::Unavailable,
+ }
+ }
+}
+
+/// Native boundary object delegating all product behavior to the ordinary Rust core.
+#[derive(uniffi::Object)]
+pub struct RadrootsRuntime {
+ inner: tera_core::RadrootsRuntime,
+ has_host_signer: bool,
+ subscriptions: Arc<SubscriptionHub>,
+}
+
+#[cfg_attr(not(coverage_nightly), uniffi::export(async_runtime = "tokio"))]
+impl RadrootsRuntime {
+ #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
+ pub async fn new(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ ) -> Result<Self, RadrootsAppError> {
+ build_runtime(
+ application_support_directory,
+ public_key_hex,
+ source_generation_hex,
+ source_generation_created_at_unix_ms,
+ protected_data,
+ None,
+ )
+ .await
+ }
+
+ #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
+ pub async fn with_host_signer(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ host_signer: Box<dyn RadrootsHostSigner>,
+ ) -> Result<Self, RadrootsAppError> {
+ build_runtime(
+ application_support_directory,
+ public_key_hex,
+ source_generation_hex,
+ source_generation_created_at_unix_ms,
+ protected_data,
+ Some(host_signer),
+ )
+ .await
+ }
+
+ pub async fn shutdown(&self) -> Result<FfiShutdownRecord, RadrootsAppError> {
+ let result = self
+ .inner
+ .shutdown()
+ .await
+ .map(Into::into)
+ .map_err(Into::into);
+ if result.is_ok() {
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Lifecycle, None);
+ self.subscriptions.close();
+ }
+ result
+ }
+
+ pub fn uptime_millis(&self) -> i64 {
+ self.inner.uptime_millis()
+ }
+
+ pub fn info(&self) -> FfiRuntimeInfoRecord {
+ self.inner.info().into()
+ }
+
+ pub fn info_json(&self) -> String {
+ self.inner.info_json()
+ }
+
+ pub fn set_app_info_platform(
+ &self,
+ platform: Option<String>,
+ bundle_id: Option<String>,
+ version: Option<String>,
+ build_number: Option<String>,
+ build_sha: Option<String>,
+ ) {
+ self.inner
+ .set_app_info_platform(platform, bundle_id, version, build_number, build_sha);
+ }
+
+ pub fn identity_status(&self) -> Result<FfiIdentityStatusRecord, RadrootsAppError> {
+ let public_key = self
+ .inner
+ .authenticated_store_public_key_hex()
+ .ok_or_else(|| {
+ RadrootsAppError::failure(
+ "identity_unavailable",
+ "identity",
+ true,
+ &["unlock_identity"],
+ "The active identity is unavailable.",
+ )
+ })?;
+ Ok(FfiIdentityStatusRecord {
+ schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
+ public_key,
+ host_signer_configured: self.has_host_signer,
+ })
+ }
+
+ pub fn sdk_capabilities(&self) -> Vec<FfiCapabilityRecord> {
+ self.inner
+ .sdk_capabilities()
+ .into_iter()
+ .map(Into::into)
+ .collect()
+ }
+
+ pub async fn sdk_storage_status(&self) -> Result<FfiStorageStatusRecord, RadrootsAppError> {
+ self.inner
+ .sdk_storage_status()
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn sdk_relay_status(&self) -> Result<Option<FfiRelayStatusReportRecord>, RadrootsAppError> {
+ self.inner
+ .sdk_relay_status()
+ .map(|value| value.map(Into::into))
+ .map_err(Into::into)
+ }
+
+ pub fn sdk_blossom_configuration(
+ &self,
+ ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> {
+ self.inner
+ .sdk_blossom_configuration()
+ .map(|value| value.map(Into::into))
+ .map_err(Into::into)
+ }
+
+ pub fn sdk_blossom_evidence(
+ &self,
+ ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> {
+ self.inner
+ .sdk_blossom_evidence()
+ .map(|value| value.map(Into::into))
+ .map_err(Into::into)
+ }
+
+ pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> {
+ let evidence = self
+ .inner
+ .probe_blossom()
+ .await
+ .map(Into::into)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(evidence)
+ }
+
+ pub fn subscribe_changes(
+ &self,
+ observer: Box<dyn RadrootsRuntimeObserver>,
+ ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
+ self.subscriptions.subscribe(observer)
+ }
+
+ pub fn configure_public_relays(
+ &self,
+ writable_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_public_relays(writable_relays)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
+ }
+
+ pub fn configure_simulator_relays(
+ &self,
+ loopback_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_simulator_relays(loopback_relays)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
+ }
+
+ pub fn configure_device_relays(
+ &self,
+ writable_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_device_relays(writable_relays)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
+ }
+
+ pub fn configure_blossom(
+ &self,
+ host_kind: FfiBlossomHostKind,
+ endpoint_authority: FfiBlossomEndpointAuthority,
+ primary_origin: String,
+ fallback_origins: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_blossom(
+ host_kind.into(),
+ endpoint_authority.into(),
+ primary_origin,
+ fallback_origins,
+ )
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(())
+ }
+
+ pub fn phase1_card_add_parity(&self) -> Vec<FfiCardAddParityRecord> {
+ self.inner
+ .phase1_card_add_parity()
+ .into_iter()
+ .map(|value| FfiCardAddParityRecord {
+ schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
+ card_type: value.card_type.into(),
+ command_type: value.add_command_type.into(),
+ })
+ .collect()
+ }
+
+ pub fn phase1_add_schemas(&self) -> Vec<FfiAddSchemaRecord> {
+ add_schemas()
+ }
+
+ pub fn phase1_local_network(
+ &self,
+ context: FfiLocalNetworkRecord,
+ ) -> Result<FfiLocalNetworkRecord, RadrootsAppError> {
+ self.local_network(context).map(Into::into)
+ }
+
+ pub async fn phase1_today_page(
+ &self,
+ context: FfiLocalNetworkRecord,
+ limit: u16,
+ as_of_unix_s: Option<u64>,
+ cursor: Option<String>,
+ ) -> Result<FfiTodayPageRecord, RadrootsAppError> {
+ if as_of_unix_s.is_some() == cursor.is_some() {
+ return Err(RadrootsAppError::invalid_argument(
+ "invalid_today_page_request",
+ ));
+ }
+ let context = self.local_network(context)?;
+ let request = match cursor {
+ Some(cursor) => TodayPageRequest::after(limit, cursor),
+ None => TodayPageRequest::first(
+ limit,
+ as_of_unix_s
+ .ok_or_else(|| RadrootsAppError::invalid_argument("today_as_of_required"))?,
+ ),
+ };
+ self.inner
+ .phase1_today_page(&context, request)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_refresh_today(
+ &self,
+ context: FfiLocalNetworkRecord,
+ now_unix_s: u64,
+ update: FfiTodayProjectionUpdate,
+ ) -> Result<FfiTodayRefreshRecord, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ let receipt = self
+ .inner
+ .phase1_refresh_today(&context, now_unix_s, update.into())
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Today, None);
+ Ok(receipt.into())
+ }
+
+ pub async fn phase1_sync_today(
+ &self,
+ context: FfiLocalNetworkRecord,
+ now_unix_s: u64,
+ update: FfiTodayProjectionUpdate,
+ ) -> Result<FfiTodaySyncRecord, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ let receipt = self
+ .inner
+ .phase1_sync_today(&context, now_unix_s, update.into())
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Today, None);
+ Ok(receipt.into())
+ }
+
+ pub async fn phase1_search(
+ &self,
+ context: FfiLocalNetworkRecord,
+ query: String,
+ limit: u16,
+ as_of_unix_s: u64,
+ ) -> Result<Vec<FfiSearchResultRecord>, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ self.inner
+ .phase1_search(&context, &query, limit, as_of_unix_s)
+ .await
+ .map(|results| results.into_iter().map(Into::into).collect())
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_me(
+ &self,
+ context: FfiLocalNetworkRecord,
+ as_of_unix_s: u64,
+ ) -> Result<FfiMeRecord, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ let public_key = self
+ .inner
+ .authenticated_store_public_key_hex()
+ .ok_or_else(|| {
+ RadrootsAppError::failure(
+ "identity_unavailable",
+ "identity",
+ true,
+ &["unlock_identity"],
+ "The active identity is unavailable.",
+ )
+ })?;
+ self.inner
+ .phase1_me(&context, &public_key, as_of_unix_s)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn phase1_validate_add_draft(
+ &self,
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ ) -> Result<(), RadrootsAppError> {
+ let blossom = self
+ .inner
+ .sdk_blossom_slot()
+ .map_err(RadrootsAppError::from)?;
+ input
+ .command_and_media(authored_at_unix_s, blossom.as_ref())
+ .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,
+ draft_id: String,
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ expected_revision: Option<u64>,
+ persisted_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ 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 decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_save_draft_with_form(
+ decoded_id,
+ command,
+ authored_at_unix_s,
+ media,
+ form,
+ expected_revision,
+ persisted_at_unix_ms,
+ )
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_save_retraction_draft(
+ &self,
+ draft_id: String,
+ input: FfiRetractionDraftInput,
+ authored_at_unix_s: u64,
+ persisted_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION {
+ return Err(RadrootsAppError::invalid_argument(
+ "unsupported_schema_version",
+ ));
+ }
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let command_type = match input.command_type {
+ crate::FfiAddCommandType::CreateUpdate => {
+ tera_core::runtime::product_surface::AddCommandType::CreateUpdate
+ }
+ crate::FfiAddCommandType::CreatePhotoUpdate => {
+ tera_core::runtime::product_surface::AddCommandType::CreatePhotoUpdate
+ }
+ crate::FfiAddCommandType::CreateAsk => {
+ tera_core::runtime::product_surface::AddCommandType::CreateAsk
+ }
+ crate::FfiAddCommandType::CreateEvent => {
+ tera_core::runtime::product_surface::AddCommandType::CreateEvent
+ }
+ crate::FfiAddCommandType::CreateFoodAvailability => {
+ tera_core::runtime::product_surface::AddCommandType::CreateFoodAvailability
+ }
+ };
+ let card_id = tera_core::runtime::product_surface::CardId::parse(&input.target_card_id)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?;
+ let status = self
+ .inner
+ .phase1_save_retraction_draft(
+ decoded_id,
+ command_type,
+ card_id,
+ &input.target_event_id,
+ input.target_kind,
+ input.target_address.as_deref(),
+ &input.reason,
+ authored_at_unix_s,
+ persisted_at_unix_ms,
+ )
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_draft_status(
+ &self,
+ draft_id: String,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ self.inner
+ .phase1_draft_status(decode_id(&draft_id, "invalid_draft_id")?)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_draft_heads(
+ &self,
+ limit: u16,
+ ) -> Result<Vec<FfiDraftStatusRecord>, RadrootsAppError> {
+ self.inner
+ .phase1_draft_heads(limit)
+ .await
+ .map(|values| values.into_iter().map(Into::into).collect())
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_queue_draft(
+ &self,
+ draft_id: String,
+ expected_revision: u64,
+ policy: FfiQueuePolicyRecord,
+ queued_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_queue_draft(
+ decoded_id,
+ expected_revision,
+ policy.try_into()?,
+ queued_at_unix_ms,
+ )
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ 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,
+ recovered_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_recover_draft_queue(decoded_id, recovered_at_unix_ms)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ 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,
+ expected_revision: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_sign_queued_draft(decoded_id, expected_revision)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_advance_draft(
+ &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_advance_draft(decoded_id, expected_revision)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_upload_draft_media(
+ &self,
+ input: FfiBlossomUploadInput,
+ ) -> 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 operation_id = decode_id(&input.operation_id, "invalid_operation_id")?;
+ let artifact_id = decode_id(&input.artifact_id, "invalid_artifact_id")?;
+ let media = PreparedMedia::try_from(input.media)?;
+ let request = media.upload_request(input.verified_at_unix_ms)?;
+ let content = radroots_blossom::authorization::AuthorizationContent::parse(
+ &input.authorization_content,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_authorization"))?;
+ let status = self
+ .inner
+ .phase1_upload_draft_media(
+ draft_id,
+ input.expected_revision,
+ request,
+ content,
+ input.authorization_created_at_unix_s,
+ input.authorization_lifetime_seconds,
+ operation_id,
+ artifact_id,
+ input.signing_deadline_unix_ms,
+ input.signing_cancellation.core(),
+ radroots_sdk::transport::BlossomCancellation::default(),
+ input.updated_at_unix_ms,
+ )
+ .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())
+ }
+
+ /// 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())
+ }
+
+ /// Persists the upload transition before returning an immutable native
+ /// background-transfer job.
+ pub async fn phase1_prepare_add_media_background(
+ &self,
+ input: crate::FfiBlossomUploadIntent,
+ ) -> Result<crate::FfiNativeUploadJobRecord, 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, job) = self
+ .inner
+ .phase1_prepare_native_upload(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(crate::FfiNativeUploadJobRecord {
+ schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
+ operation_id: hex::encode(job.operation_id()),
+ draft: status.into(),
+ remote_url: job.remote_url().to_owned(),
+ authorization_header: job.authorization_header().to_owned(),
+ expected_sha256: job.expected_sha256().to_owned(),
+ media_type: job.media_type().to_owned(),
+ byte_size: job.byte_size(),
+ })
+ }
+
+ /// Accepts only bounded native HTTP evidence; Rust performs descriptor and
+ /// exact-byte retrieval verification before advancing durable state.
+ pub async fn phase1_complete_add_media_background(
+ &self,
+ input: crate::FfiNativeUploadCompletionInput,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION
+ || input.response_body.len() > 16_384
+ {
+ return Err(RadrootsAppError::invalid_argument(
+ "invalid_native_upload_completion",
+ ));
+ }
+ 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_complete_native_upload(
+ intent,
+ input.status_code,
+ input.response_media_type.as_deref(),
+ input.response_content_encoding.as_deref(),
+ input.response_body.as_slice(),
+ )
+ .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,
+ expected_revision: u64,
+ cancelled_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_cancel_draft(decoded_id, expected_revision, cancelled_at_unix_ms)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .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())
+ }
+
+ pub async fn phase1_settings(&self) -> Result<FfiMobileSettingsRecord, RadrootsAppError> {
+ self.inner
+ .phase1_settings()
+ .await
+ .map(|settings| (&settings).into())
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_apply_settings_to_runtime(
+ &self,
+ ) -> Result<FfiMobileSettingsRecord, RadrootsAppError> {
+ let settings = self.inner.phase1_settings().await?;
+ self.inner
+ .configure_relay_preferences(settings.relays())
+ .map_err(RadrootsAppError::from)?;
+ self.inner
+ .configure_blossom_preferences(settings.blossom())
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Settings, None);
+ Ok((&settings).into())
+ }
+
+ pub async fn phase1_replace_settings(
+ &self,
+ input: FfiReplaceSettingsRecord,
+ ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> {
+ let current = self.inner.phase1_settings().await?;
+ let expected_revision = input.expected_revision;
+ let next = input.apply(current)?;
+ let transition = self
+ .inner
+ .phase1_replace_settings(ReplaceMobileSettings::new(expected_revision, next)?)
+ .await?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Settings, None);
+ Ok(transition.into())
+ }
+
+ pub async fn phase1_apply_identity_command(
+ &self,
+ expected_revision: u64,
+ command: FfiIdentityCommandRecord,
+ ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> {
+ let transition = self
+ .inner
+ .phase1_apply_identity_command(expected_revision, command.try_into()?)
+ .await?;
+ let identity_id = transition
+ .settings
+ .identity()
+ .active_identity_id()
+ .map(str::to_owned);
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Identity, identity_id);
+ Ok(transition.into())
+ }
+
+ pub async fn phase1_save_profile_metadata(
+ &self,
+ input: FfiProfileMetadataInputRecord,
+ ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
+ let blossom = self
+ .inner
+ .sdk_blossom_slot()
+ .map_err(RadrootsAppError::from)?;
+ let status = self
+ .inner
+ .phase1_save_profile_metadata(input.command(blossom.as_ref())?)
+ .await?;
+ let operation_id = hex::encode(status.draft().draft_id().as_bytes());
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_profile_status(
+ &self,
+ operation_id: String,
+ ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
+ self.inner
+ .phase1_profile_status(decode_id(&operation_id, "invalid_operation_id")?)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_advance_profile(
+ &self,
+ operation_id: String,
+ ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
+ let status = self
+ .inner
+ .phase1_advance_profile(decode_id(&operation_id, "invalid_operation_id")?)
+ .await?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_cancel_profile(
+ &self,
+ operation_id: String,
+ expected_revision: u64,
+ ) -> Result<FfiProfileStatusRecord, RadrootsAppError> {
+ let status = self
+ .inner
+ .phase1_cancel_profile(
+ decode_id(&operation_id, "invalid_operation_id")?,
+ expected_revision,
+ )
+ .await?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Profile, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_save_revision_intent(
+ &self,
+ mut input: FfiRevisionInputRecord,
+ ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
+ let target = input.target()?;
+ if input.replacement.identifier.is_none()
+ && matches!(
+ input.replacement.command_type,
+ crate::FfiAddCommandType::CreateEvent
+ | crate::FfiAddCommandType::CreateFoodAvailability
+ )
+ {
+ input.replacement.identifier = Some(phase1_new_addressable_identifier());
+ }
+ let authored_at_unix_s = phase1_operation_now_unix_ms()? / 1_000;
+ let blossom = self
+ .inner
+ .sdk_blossom_slot()
+ .map_err(RadrootsAppError::from)?;
+ let (command, media, form) = input
+ .replacement
+ .command_media_and_form(authored_at_unix_s, blossom.as_ref())?;
+ let status = self
+ .inner
+ .phase1_save_revision_intent(Phase1ReviseIntent::new(target, command, media, form)?)
+ .await?;
+ let operation_id = hex::encode(status.replacement().draft().draft_id().as_bytes());
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_revision_status(
+ &self,
+ operation_id: String,
+ ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
+ self.inner
+ .phase1_revision_status(decode_id(&operation_id, "invalid_operation_id")?)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_advance_revision(
+ &self,
+ operation_id: String,
+ ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
+ let status = self
+ .inner
+ .phase1_advance_revision(decode_id(&operation_id, "invalid_operation_id")?)
+ .await?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_cancel_revision(
+ &self,
+ operation_id: String,
+ ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> {
+ let status = self
+ .inner
+ .phase1_cancel_revision(decode_id(&operation_id, "invalid_operation_id")?)
+ .await?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_retrieve_media(
+ &self,
+ context: FfiLocalNetworkRecord,
+ reference_fingerprint: String,
+ operation: Arc<FfiMediaOperation>,
+ ) -> Result<FfiVerifiedMediaArtifactRecord, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ operation.claim()?;
+ let operation_id = operation.operation_id();
+ let settings = self.inner.phase1_settings().await?;
+ let policy = Phase1MediaCachePolicy::new(
+ settings.local_storage().media_cache_bytes(),
+ settings.local_storage().media_cache_artifacts(),
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_cache_policy"))?;
+ let artifact = self
+ .inner
+ .phase1_retrieve_media(
+ &context,
+ decode_reference_fingerprint(&reference_fingerprint)?,
+ operation.id(),
+ policy,
+ operation.cancellation(),
+ )
+ .await
+ .map_err(|error| {
+ RadrootsAppError::from(error).with_operation_id(operation_id.clone())
+ })?;
+ self.subscriptions.notify(
+ FfiRuntimeChangeKind::Media,
+ Some(artifact.artifact_id().to_hex()),
+ );
+ Ok(FfiVerifiedMediaArtifactRecord::from_artifact(
+ artifact,
+ Some(operation_id),
+ ))
+ }
+
+ pub async fn phase1_verified_media_artifact(
+ &self,
+ context: FfiLocalNetworkRecord,
+ artifact_id: String,
+ ) -> Result<Option<FfiVerifiedMediaArtifactRecord>, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ self.inner
+ .phase1_verified_media_artifact(
+ &context,
+ decode_artifact_id(&artifact_id)?,
+ phase1_operation_now_unix_ms()?,
+ )
+ .await
+ .map(|value| {
+ value.map(|artifact| FfiVerifiedMediaArtifactRecord::from_artifact(artifact, None))
+ })
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_media_cache_status(
+ &self,
+ context: FfiLocalNetworkRecord,
+ ) -> Result<FfiMediaCacheStatusRecord, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ self.inner
+ .phase1_media_cache_status(&context)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_invalidate_media_artifact(
+ &self,
+ context: FfiLocalNetworkRecord,
+ artifact_id: String,
+ ) -> Result<bool, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ let changed = self
+ .inner
+ .phase1_invalidate_media_artifact(&context, decode_artifact_id(&artifact_id)?)
+ .await?;
+ if changed {
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Media, Some(artifact_id));
+ }
+ Ok(changed)
+ }
+
+ pub async fn phase1_invalidate_media_configuration(
+ &self,
+ context: FfiLocalNetworkRecord,
+ configuration_fingerprint: String,
+ ) -> Result<Vec<String>, RadrootsAppError> {
+ let context = self.local_network(context)?;
+ let removed = self
+ .inner
+ .phase1_invalidate_media_configuration(
+ &context,
+ decode_configuration(&configuration_fingerprint)?,
+ )
+ .await?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(removed.into_iter().map(|value| value.to_hex()).collect())
+ }
+}
+
+impl RadrootsRuntime {
+ fn local_network(
+ &self,
+ context: FfiLocalNetworkRecord,
+ ) -> Result<tera_core::runtime::product_surface::LocalNetwork, RadrootsAppError> {
+ let profile = self.inner.sdk_relay_status()?.ok_or_else(|| {
+ RadrootsAppError::failure(
+ "relay_profile_unavailable",
+ "relay",
+ true,
+ &["configure_relay"],
+ "The relay profile is unavailable.",
+ )
+ })?;
+ let relay_policy = match profile.profile.as_str() {
+ "public" => LocalNetworkRelayPolicy::Public,
+ "simulator_local" => LocalNetworkRelayPolicy::Simulator,
+ "device_development" => LocalNetworkRelayPolicy::Device,
+ _ => {
+ return Err(RadrootsAppError::failure(
+ "relay_profile_unsupported",
+ "relay",
+ false,
+ &["configure_relay"],
+ "The relay profile is unsupported.",
+ ));
+ }
+ };
+ context.try_into_with_relay_policy(relay_policy)
+ }
+}
+
+async fn build_runtime(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ host_signer: Option<Box<dyn RadrootsHostSigner>>,
+) -> Result<RadrootsRuntime, RadrootsAppError> {
+ let store = tera_core::runtime::store::MobileUserStoreConfig::from_encoded(
+ application_support_directory,
+ public_key_hex.as_str(),
+ source_generation_hex.as_str(),
+ source_generation_created_at_unix_ms,
+ protected_data.into(),
+ )?;
+ let has_host_signer = host_signer.is_some();
+ let mut builder = tera_core::runtime::builder::RuntimeBuilder::new(store);
+ if let Some(host_signer) = host_signer {
+ builder = builder.signer(Arc::new(HostSignerAdapter::new(host_signer)));
+ }
+ builder
+ .build()
+ .await
+ .map(|inner| RadrootsRuntime {
+ inner,
+ has_host_signer,
+ subscriptions: SubscriptionHub::new(),
+ })
+ .map_err(Into::into)
+}
diff --git a/core/crates/tera_ffi/src/signer.rs b/core/crates/tera_ffi/src/signer.rs
@@ -0,0 +1,404 @@
+//! Opaque, secret-free host signing bridge.
+
+use std::sync::Arc;
+
+use radroots_event::{SignedEvent, wire::v1::Nip01EventWire};
+use radroots_signing::{
+ Error, SignReceipt, SignRequest, Signer, SignerStatus,
+ capability::{CancellationSupport, SignerCapability, SignerKind},
+ error::Kind,
+ recovery::ReplayCapability,
+ signer::BoxFuture,
+ status::SignerAvailability,
+};
+
+use crate::MOBILE_FFI_SCHEMA_VERSION;
+
+const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SignerAvailabilityRecord {
+ Ready,
+ Busy,
+ Locked,
+ Unavailable,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct SignerStatusRecord {
+ pub schema_version: u16,
+ pub availability: SignerAvailabilityRecord,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum HostSigningPurpose {
+ NostrEvent,
+ BlossomUpload,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct HostSigningRequest {
+ pub schema_version: u16,
+ pub operation_kind: String,
+ pub operation_id: String,
+ pub artifact_id: String,
+ pub signer_request_id: String,
+ pub public_key: String,
+ pub purpose: HostSigningPurpose,
+ pub deadline_unix_ms: u64,
+ pub event_id_digest: Vec<u8>,
+ pub expected_event_id: String,
+ pub created_at_unix_s: u64,
+ pub kind: u32,
+ pub tags: Vec<Vec<String>>,
+ pub content: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum HostSigningOutcome {
+ Signed,
+ Locked,
+ Cancelled,
+ Rejected,
+ TimedOut,
+ Unavailable,
+ Invalidated,
+ Failed,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct HostSigningResult {
+ pub schema_version: u16,
+ pub outcome: HostSigningOutcome,
+ pub operation_id: String,
+ pub signer_request_id: String,
+ pub public_key: String,
+ pub purpose: HostSigningPurpose,
+ pub signature_hex: Option<String>,
+ pub completed_at_unix_ms: u64,
+}
+
+#[uniffi::export(callback_interface)]
+#[async_trait::async_trait]
+pub trait RadrootsHostSigner: Send + Sync {
+ async fn signer_status(&self) -> SignerStatusRecord;
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult;
+}
+
+pub(crate) struct HostSignerAdapter {
+ host: Arc<dyn RadrootsHostSigner>,
+}
+
+impl HostSignerAdapter {
+ pub(crate) fn new(host: Box<dyn RadrootsHostSigner>) -> Self {
+ Self {
+ host: Arc::from(host),
+ }
+ }
+}
+
+impl Signer for HostSignerAdapter {
+ fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
+ Box::pin(async move {
+ let status = self.host.signer_status().await;
+ if status.schema_version != MOBILE_FFI_SCHEMA_VERSION {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ let availability = match status.availability {
+ SignerAvailabilityRecord::Ready => SignerAvailability::Ready,
+ SignerAvailabilityRecord::Busy => SignerAvailability::Busy,
+ SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication,
+ SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable,
+ };
+ Ok(SignerStatus::new(
+ availability,
+ vec![SignerCapability::new(
+ SignerKind::HostMediated,
+ ReplayCapability::ExactReplayByRequestId,
+ CancellationSupport::BeforeAndAfterPublication,
+ false,
+ true,
+ )],
+ None,
+ ))
+ })
+ }
+
+ fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
+ Box::pin(async move {
+ request.ensure_active(now_unix_ms())?;
+ let ffi_request = HostSigningRequest::from_request(&request)?;
+ let result = self.host.sign(ffi_request.clone()).await;
+ request.ensure_active(now_unix_ms())?;
+ request.ensure_active(result.completed_at_unix_ms)?;
+ validate_result_binding(&ffi_request, &result)?;
+ match result.outcome {
+ HostSigningOutcome::Signed => signed_receipt(&request, result),
+ HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => {
+ Err(Error::new(Kind::SignerUnavailable))
+ }
+ HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)),
+ HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)),
+ HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)),
+ HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)),
+ HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)),
+ }
+ })
+ }
+}
+
+impl HostSigningRequest {
+ fn from_request(request: &SignRequest) -> Result<Self, Error> {
+ let purpose = match request.purpose() {
+ radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent,
+ radroots_signing::SigningPurpose::BlossomUploadAuthorization => {
+ HostSigningPurpose::BlossomUpload
+ }
+ _ => return Err(Error::new(Kind::SignerOutputInvalid)),
+ };
+ Ok(Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_kind: request.operation_kind().as_str().to_owned(),
+ operation_id: uuid_string(request.intent_id().operation_id().as_bytes()),
+ artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()),
+ signer_request_id: request.signer_request_id().to_hex(),
+ public_key: request.expected_author().to_hex(),
+ purpose,
+ deadline_unix_ms: request.policy().deadline_unix_ms(),
+ event_id_digest: request.expected_event_id().as_bytes().to_vec(),
+ expected_event_id: request.expected_event_id().to_hex(),
+ created_at_unix_s: request.created_at(),
+ kind: request.kind(),
+ tags: request.tags().to_vec(),
+ content: request.content().to_owned(),
+ })
+ }
+}
+
+fn validate_result_binding(
+ request: &HostSigningRequest,
+ result: &HostSigningResult,
+) -> Result<(), Error> {
+ if result.schema_version != MOBILE_FFI_SCHEMA_VERSION
+ || result.operation_id != request.operation_id
+ || result.signer_request_id != request.signer_request_id
+ || result.public_key != request.public_key
+ || result.purpose != request.purpose
+ || result.completed_at_unix_ms == 0
+ || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some()
+ {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ Ok(())
+}
+
+fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> {
+ let signature = result
+ .signature_hex
+ .filter(|value| {
+ value.len() == 128
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ })
+ .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?;
+ let wire = Nip01EventWire {
+ id: request.expected_event_id().to_hex(),
+ pubkey: request.expected_author().to_hex(),
+ created_at: request.created_at(),
+ kind: request.kind(),
+ tags: request.tags().to_vec(),
+ content: request.content().to_owned(),
+ sig: signature,
+ extra: Default::default(),
+ };
+ let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?;
+ if raw_json.len() > SIGNED_EVENT_MAX_BYTES {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ let signed = SignedEvent::from_wire_verified_id(wire, raw_json)
+ .map_err(|_| Error::new(Kind::SignerOutputInvalid))?;
+ SignReceipt::from_signed_event(request, signed, result.completed_at_unix_ms)
+}
+
+fn uuid_string(bytes: &[u8; 16]) -> String {
+ let hex = hex::encode(bytes);
+ format!(
+ "{}-{}-{}-{}-{}",
+ &hex[0..8],
+ &hex[8..12],
+ &hex[12..16],
+ &hex[16..20],
+ &hex[20..32]
+ )
+}
+
+fn now_unix_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |duration| {
+ duration.as_millis().try_into().unwrap_or(u64::MAX)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct StatusHost {
+ schema_version: u16,
+ availability: SignerAvailabilityRecord,
+ }
+
+ #[async_trait::async_trait]
+ impl RadrootsHostSigner for StatusHost {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: self.schema_version,
+ availability: self.availability,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Failed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: None,
+ completed_at_unix_ms: 1,
+ }
+ }
+ }
+
+ #[test]
+ fn opaque_operation_identity_has_canonical_uuid_shape() {
+ assert_eq!(
+ uuid_string(&[0xabu8; 16]),
+ "abababab-abab-abab-abab-abababababab"
+ );
+ }
+
+ #[test]
+ fn result_binding_rejects_signature_on_failure() {
+ let request = HostSigningRequest {
+ schema_version: 1,
+ operation_kind: "sync.push".to_owned(),
+ operation_id: "11111111-1111-1111-1111-111111111111".to_owned(),
+ artifact_id: "22".repeat(16),
+ signer_request_id: "33".repeat(32),
+ public_key: "44".repeat(32),
+ purpose: HostSigningPurpose::NostrEvent,
+ deadline_unix_ms: 10,
+ event_id_digest: vec![5; 32],
+ expected_event_id: "55".repeat(32),
+ created_at_unix_s: 1,
+ kind: 1,
+ tags: Vec::new(),
+ content: "test".to_owned(),
+ };
+ let result = HostSigningResult {
+ schema_version: 1,
+ outcome: HostSigningOutcome::Failed,
+ operation_id: request.operation_id.clone(),
+ signer_request_id: request.signer_request_id.clone(),
+ public_key: request.public_key.clone(),
+ purpose: request.purpose,
+ signature_hex: Some("66".repeat(64)),
+ completed_at_unix_ms: 2,
+ };
+ assert_eq!(
+ validate_result_binding(&request, &result)
+ .expect_err("failure cannot carry signature")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+
+ let valid = HostSigningResult {
+ signature_hex: None,
+ ..result.clone()
+ };
+ assert!(validate_result_binding(&request, &valid).is_ok());
+ let invalid_results = [
+ HostSigningResult {
+ schema_version: 2,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ operation_id: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ signer_request_id: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ public_key: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ purpose: HostSigningPurpose::BlossomUpload,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ completed_at_unix_ms: 0,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ outcome: HostSigningOutcome::Signed,
+ signature_hex: None,
+ ..valid
+ },
+ ];
+ for invalid in invalid_results {
+ assert_eq!(
+ validate_result_binding(&request, &invalid)
+ .expect_err("binding mismatch")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn host_status_maps_every_availability_and_rejects_schema_drift() {
+ for (ffi, expected) in [
+ (SignerAvailabilityRecord::Ready, SignerAvailability::Ready),
+ (SignerAvailabilityRecord::Busy, SignerAvailability::Busy),
+ (
+ SignerAvailabilityRecord::Locked,
+ SignerAvailability::AwaitingAuthentication,
+ ),
+ (
+ SignerAvailabilityRecord::Unavailable,
+ SignerAvailability::Unavailable,
+ ),
+ ] {
+ let adapter = HostSignerAdapter::new(Box::new(StatusHost {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: ffi,
+ }));
+ assert_eq!(
+ Signer::status(&adapter)
+ .await
+ .expect("mapped host status")
+ .availability(),
+ expected
+ );
+ }
+
+ let adapter = HostSignerAdapter::new(Box::new(StatusHost {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ availability: SignerAvailabilityRecord::Ready,
+ }));
+ assert_eq!(
+ Signer::status(&adapter)
+ .await
+ .expect_err("schema drift")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+ }
+}
diff --git a/core/crates/tera_ffi/src/subscription.rs b/core/crates/tera_ffi/src/subscription.rs
@@ -0,0 +1,321 @@
+//! Bounded, independent host subscriptions for focused runtime invalidation signals.
+
+use std::collections::BTreeMap;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::mpsc::{SyncSender, TrySendError, sync_channel};
+use std::sync::{Arc, Mutex, Weak};
+
+use crate::{MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError};
+
+const MAX_SUBSCRIPTIONS: usize = 32;
+const CHANGE_BUFFER_CAPACITY: usize = 16;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRuntimeChangeKind {
+ Initial,
+ Identity,
+ Settings,
+ Profile,
+ Today,
+ Drafts,
+ Relay,
+ Media,
+ Lifecycle,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRuntimeChangeRecord {
+ pub schema_version: u16,
+ pub generation: u64,
+ pub kind: FfiRuntimeChangeKind,
+ pub entity_id: Option<String>,
+}
+
+#[uniffi::export(callback_interface)]
+pub trait RadrootsRuntimeObserver: Send + Sync {
+ fn on_change(&self, change: FfiRuntimeChangeRecord);
+}
+
+pub(crate) struct SubscriptionHub {
+ next_id: AtomicU64,
+ generation: AtomicU64,
+ closed: AtomicBool,
+ subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>,
+}
+
+impl SubscriptionHub {
+ pub(crate) fn new() -> Arc<Self> {
+ Arc::new(Self {
+ next_id: AtomicU64::new(1),
+ generation: AtomicU64::new(1),
+ closed: AtomicBool::new(false),
+ subscriptions: Mutex::new(BTreeMap::new()),
+ })
+ }
+
+ pub(crate) fn subscribe(
+ self: &Arc<Self>,
+ observer: Box<dyn RadrootsRuntimeObserver>,
+ ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
+ if self.closed.load(Ordering::Acquire) {
+ return Err(subscription_error("runtime_closed", false));
+ }
+ let id = self.next_id.fetch_add(1, Ordering::AcqRel);
+ let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY);
+ let observer: Arc<dyn RadrootsRuntimeObserver> = Arc::from(observer);
+ let hub = Arc::downgrade(self);
+ std::thread::Builder::new()
+ .name(format!("radroots-ffi-observer-{id}"))
+ .spawn(move || {
+ while let Ok(change) = receiver.recv() {
+ if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() {
+ break;
+ }
+ }
+ if let Some(hub) = hub.upgrade() {
+ hub.remove(id);
+ }
+ })
+ .map_err(|_| subscription_error("subscription_worker_unavailable", true))?;
+
+ {
+ let mut subscriptions = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS {
+ return Err(subscription_error("subscription_limit_reached", true));
+ }
+ subscriptions.insert(id, sender.clone());
+ }
+
+ let initial = FfiRuntimeChangeRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ generation: self.generation.load(Ordering::Acquire),
+ kind: FfiRuntimeChangeKind::Initial,
+ entity_id: None,
+ };
+ let _ = sender.try_send(initial);
+ Ok(Arc::new(FfiSubscriptionHandle {
+ hub: Arc::downgrade(self),
+ id: Mutex::new(Some(id)),
+ }))
+ }
+
+ pub(crate) fn notify(&self, kind: FfiRuntimeChangeKind, entity_id: Option<String>) {
+ if self.closed.load(Ordering::Acquire) {
+ return;
+ }
+ let change = FfiRuntimeChangeRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1,
+ kind,
+ entity_id,
+ };
+ let senders = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .iter()
+ .map(|(id, sender)| (*id, sender.clone()))
+ .collect::<Vec<_>>();
+ let mut disconnected = Vec::new();
+ for (id, sender) in senders {
+ match sender.try_send(change.clone()) {
+ Ok(()) | Err(TrySendError::Full(_)) => {}
+ Err(TrySendError::Disconnected(_)) => disconnected.push(id),
+ }
+ }
+ if !disconnected.is_empty() {
+ let mut subscriptions = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ for id in disconnected {
+ subscriptions.remove(&id);
+ }
+ }
+ }
+
+ pub(crate) fn close(&self) {
+ if !self.closed.swap(true, Ordering::AcqRel) {
+ self.generation.fetch_add(1, Ordering::AcqRel);
+ self.subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clear();
+ }
+ }
+
+ fn remove(&self, id: u64) {
+ self.subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&id);
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct FfiSubscriptionHandle {
+ hub: Weak<SubscriptionHub>,
+ id: Mutex<Option<u64>>,
+}
+
+#[uniffi::export]
+impl FfiSubscriptionHandle {
+ pub fn unsubscribe(&self) {
+ let id = self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
+ hub.remove(id);
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ let id = *self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let (Some(hub), Some(id)) = (self.hub.upgrade(), id) else {
+ return false;
+ };
+ !hub.closed.load(Ordering::Acquire)
+ && hub
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .contains_key(&id)
+ }
+}
+
+impl Drop for FfiSubscriptionHandle {
+ fn drop(&mut self) {
+ let id = self
+ .id
+ .get_mut()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
+ hub.remove(id);
+ }
+ }
+}
+
+fn subscription_error(code: &str, retryable: bool) -> RadrootsAppError {
+ RadrootsAppError::failure(
+ code,
+ "subscription",
+ retryable,
+ if retryable { &["retry"] } else { &[] },
+ "The runtime change subscription is unavailable.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, Condvar};
+ use std::time::{Duration, Instant};
+
+ use super::*;
+
+ struct NoopObserver;
+
+ impl RadrootsRuntimeObserver for NoopObserver {
+ fn on_change(&self, _change: FfiRuntimeChangeRecord) {}
+ }
+
+ struct PanicObserver;
+
+ impl RadrootsRuntimeObserver for PanicObserver {
+ fn on_change(&self, _change: FfiRuntimeChangeRecord) {
+ panic!("observer panic is isolated");
+ }
+ }
+
+ struct BlockingObserver(Arc<(Mutex<bool>, Condvar)>);
+
+ impl RadrootsRuntimeObserver for BlockingObserver {
+ fn on_change(&self, change: FfiRuntimeChangeRecord) {
+ if change.kind == FfiRuntimeChangeKind::Initial {
+ let (released, wake) = &*self.0;
+ let guard = released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _guard = wake
+ .wait_while(guard, |released| !*released)
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ }
+ }
+ }
+
+ #[test]
+ fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() {
+ let closed = SubscriptionHub::new();
+ closed.close();
+ closed.close();
+ closed.notify(FfiRuntimeChangeKind::Today, None);
+ let error = closed
+ .subscribe(Box::new(NoopObserver))
+ .err()
+ .expect("closed hub");
+ assert_eq!(error.report().code, "runtime_closed");
+ assert!(!error.report().retryable);
+
+ let hub = SubscriptionHub::new();
+ let handles = (0..MAX_SUBSCRIPTIONS)
+ .map(|_| hub.subscribe(Box::new(NoopObserver)).expect("subscription"))
+ .collect::<Vec<_>>();
+ let error = hub
+ .subscribe(Box::new(NoopObserver))
+ .err()
+ .expect("bounded subscription limit");
+ assert_eq!(error.report().code, "subscription_limit_reached");
+ assert!(error.report().retryable);
+ drop(handles);
+
+ let detached_hub = SubscriptionHub::new();
+ let detached = detached_hub
+ .subscribe(Box::new(NoopObserver))
+ .expect("detached subscription");
+ drop(detached_hub);
+ assert!(!detached.is_active());
+ detached.unsubscribe();
+ detached.unsubscribe();
+ }
+
+ #[test]
+ fn callback_panics_and_full_buffers_never_escape_or_block_publishers() {
+ let hub = SubscriptionHub::new();
+ let panicking = hub
+ .subscribe(Box::new(PanicObserver))
+ .expect("panicking subscription");
+ let deadline = Instant::now() + Duration::from_secs(1);
+ while panicking.is_active() && Instant::now() < deadline {
+ std::thread::yield_now();
+ }
+ assert!(!panicking.is_active());
+
+ let release = Arc::new((Mutex::new(false), Condvar::new()));
+ let blocked = hub
+ .subscribe(Box::new(BlockingObserver(Arc::clone(&release))))
+ .expect("blocked subscription");
+ for generation in 0..=CHANGE_BUFFER_CAPACITY {
+ hub.notify(
+ FfiRuntimeChangeKind::Drafts,
+ Some(format!("draft-{generation}")),
+ );
+ }
+ assert!(blocked.is_active());
+ let (released, wake) = &*release;
+ *released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
+ wake.notify_all();
+ hub.close();
+ assert!(!blocked.is_active());
+ }
+}
diff --git a/core/crates/tera_ffi/tests/compatibility.rs b/core/crates/tera_ffi/tests/compatibility.rs
@@ -0,0 +1,201 @@
+//! Frozen compatibility across application FFI history transfer. All stores are
+//! isolated fixtures; signer keys are generated in memory and never serialized.
+
+use secp256k1::{Keypair, Message, Secp256k1};
+use serde_json::Value;
+use std::path::Path;
+use tera_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiDraftStatusRecord,
+ FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest,
+ HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner,
+ RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord,
+};
+
+fn fixture() -> Value {
+ serde_json::from_str(include_str!(
+ "../../../../test-fixtures/tera-compatibility.v1.json"
+ ))
+ .expect("checked synthetic fixture")
+}
+
+fn field<'a>(fixture: &'a Value, key: &str) -> &'a str {
+ fixture[key].as_str().expect("fixture string")
+}
+
+fn input(fixture: &Value) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: field(fixture, "content").to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ }
+}
+
+async fn runtime(root: &Path, public_key: &str, signer: Option<Keypair>) -> RadrootsRuntime {
+ std::fs::create_dir_all(root.join("radroots/users").join(public_key))
+ .expect("isolated application owner directory");
+ let fixture = fixture();
+ let generation = field(&fixture["queued_update"], "source_generation").to_owned();
+ if let Some(keypair) = signer {
+ RadrootsRuntime::with_host_signer(
+ root.to_string_lossy().into_owned(),
+ public_key.to_owned(),
+ generation,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(EphemeralSigner(keypair)),
+ )
+ .await
+ .expect("runtime with ephemeral fixture signer")
+ } else {
+ RadrootsRuntime::new(
+ root.to_string_lossy().into_owned(),
+ public_key.to_owned(),
+ generation,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("runtime using current persistent reader")
+ }
+}
+
+async fn queue(runtime: &RadrootsRuntime, fixture: &Value) -> FfiDraftStatusRecord {
+ let id = field(fixture, "draft_id");
+ let persisted = fixture["persisted_at_unix_ms"].as_u64().unwrap();
+ let saved = runtime
+ .phase1_save_draft(
+ id.to_owned(),
+ input(fixture),
+ fixture["authored_at_unix_s"].as_u64().unwrap(),
+ None,
+ persisted,
+ )
+ .await
+ .expect("save synthetic draft through real FFI");
+ let policy = FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: fixture["relay_urls"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|value| value.as_str().unwrap().to_owned())
+ .collect(),
+ satisfaction: FfiRelaySatisfaction::AllAccepted,
+ delivery_deadline_unix_ms: fixture["delivery_deadline_unix_ms"].as_u64().unwrap(),
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ };
+ runtime
+ .phase1_queue_draft(id.to_owned(), saved.revision, policy, persisted + 1)
+ .await
+ .expect("queue locally without starting relay delivery")
+}
+
+#[tokio::test]
+async fn queued_update_reopens_with_frozen_operation_and_card_identity() {
+ let fixture = fixture()["queued_update"].clone();
+ let root = tempfile::tempdir().unwrap();
+ let public_key = field(&fixture, "public_key");
+ let first = runtime(root.path(), public_key, None).await;
+ let queued = queue(&first, &fixture).await;
+ assert_eq!(
+ queued.operation_id.as_deref(),
+ Some(field(&fixture, "expected_operation_id"))
+ );
+ assert_eq!(queued.card_id, field(&fixture, "expected_card_id"));
+ first.shutdown().await.unwrap();
+ drop(first);
+ let reopened = runtime(root.path(), public_key, None).await;
+ let restored = reopened
+ .phase1_draft_status(field(&fixture, "draft_id").to_owned())
+ .await
+ .expect("existing persisted draft reader");
+ assert_eq!(restored, queued);
+ let recovered = reopened
+ .phase1_recover_draft_queue(field(&fixture, "draft_id").to_owned(), 1_900_000_000_002)
+ .await
+ .expect("existing queued operation reader");
+ assert_eq!(recovered, queued);
+ reopened.shutdown().await.unwrap();
+}
+
+#[tokio::test]
+async fn signed_operation_reopens_without_replacing_its_author_or_identity() {
+ let fixture = fixture()["queued_update"].clone();
+ let root = tempfile::tempdir().unwrap();
+ let keypair = Keypair::new(&Secp256k1::new(), &mut secp256k1::rand::thread_rng());
+ let public_key = keypair.x_only_public_key().0.to_string();
+ let first = runtime(root.path(), &public_key, Some(keypair)).await;
+ let queued = queue(&first, &fixture).await;
+ let signed = first
+ .phase1_sign_queued_draft(queued.draft_id.clone(), queued.revision)
+ .await
+ .expect("sign and durably admit the fixed operation");
+ assert_eq!(signed.operation_id, queued.operation_id);
+ assert_eq!(signed.card_id, queued.card_id);
+ assert_eq!(signed.author_public_key, public_key);
+ assert_eq!(signed.settlement.unwrap().signed, 1);
+ first.shutdown().await.unwrap();
+ drop(first);
+ // Reopening has no signer. Reading must preserve the already signed facts.
+ let reopened = runtime(root.path(), &public_key, None).await;
+ let restored = reopened
+ .phase1_draft_status(signed.draft_id.clone())
+ .await
+ .unwrap();
+ assert_eq!(restored, signed);
+ reopened.shutdown().await.unwrap();
+}
+
+struct EphemeralSigner(Keypair);
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for EphemeralSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ assert_eq!(request.public_key, self.0.x_only_public_key().0.to_string());
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .try_into()
+ .expect("exact event digest");
+ let signature =
+ Secp256k1::new().sign_schnorr_no_aux_rand(&Message::from_digest(digest), &self.0);
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Signed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: Some(signature.to_string()),
+ completed_at_unix_ms: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_millis()
+ .try_into()
+ .unwrap(),
+ }
+ }
+}
diff --git a/core/crates/tera_ffi/tests/local_mvp_real_io.rs b/core/crates/tera_ffi/tests/local_mvp_real_io.rs
@@ -0,0 +1,964 @@
+use std::io::Write;
+use std::os::fd::AsRawFd;
+use std::sync::Arc;
+use std::time::Duration;
+
+use nostr::{EventBuilder, Keys, Kind, Metadata, Tag, Timestamp};
+use nostr_relay_builder::MockRelay;
+use nostr_sdk::Client;
+use radroots_blossom::Sha256;
+use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
+use tera_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind,
+ FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord,
+ FfiMediaOperation, FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord,
+ FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate,
+ FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult,
+ MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime,
+ SignerAvailabilityRecord, SignerStatusRecord,
+};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+
+#[allow(dead_code)]
+mod support;
+
+const AUTHORED_AT: u64 = 1_786_000_000;
+const AS_OF: u64 = 1_786_200_000;
+const FIXTURE_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
+const REPLY_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002";
+
+struct FixtureHostSigner;
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for FixtureHostSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ let secret = SecretKey::from_slice(&hex::decode(FIXTURE_SECRET).expect("fixture secret"))
+ .expect("valid fixture secret");
+ let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .clone()
+ .try_into()
+ .expect("32-byte event digest");
+ assert_eq!(hex::encode(digest), request.expected_event_id);
+ assert_eq!(
+ keypair.x_only_public_key().0.to_string(),
+ request.public_key
+ );
+ let signature = Secp256k1::new()
+ .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair)
+ .to_string();
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Signed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: Some(signature),
+ completed_at_unix_ms: unix_time_ms(),
+ }
+ }
+}
+
+struct BlossomServer {
+ origin: String,
+ task: tokio::task::JoinHandle<()>,
+}
+
+impl BlossomServer {
+ async fn spawn(bytes: Vec<u8>, corrupt_retrieval: bool) -> Self {
+ Self::spawn_with_retrievals(bytes, corrupt_retrieval, 1).await
+ }
+
+ async fn spawn_with_retrievals(
+ bytes: Vec<u8>,
+ corrupt_retrieval: bool,
+ retrievals: usize,
+ ) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind Blossom server");
+ let origin = format!("http://{}", listener.local_addr().expect("Blossom address"));
+ let hash = Sha256::digest(bytes.as_slice()).to_string();
+ let blob_url = format!("{origin}/{hash}.png");
+ let descriptor = serde_json::to_vec(&serde_json::json!({
+ "url": blob_url,
+ "sha256": hash,
+ "size": bytes.len(),
+ "type": "image/png",
+ "uploaded": AUTHORED_AT,
+ }))
+ .expect("descriptor JSON");
+ let task = tokio::spawn(async move {
+ let (mut upload, _) = listener.accept().await.expect("accept Blossom upload");
+ let request = read_http_request(&mut upload).await;
+ let request_text = String::from_utf8_lossy(&request);
+ assert!(request_text.starts_with("PUT /upload HTTP/1.1\r\n"));
+ assert!(
+ request_text
+ .to_ascii_lowercase()
+ .contains("authorization: nostr ")
+ );
+ assert!(
+ request_text
+ .to_ascii_lowercase()
+ .contains(format!("x-sha-256: {hash}").as_str())
+ );
+ write_http_response(&mut upload, "application/json", &descriptor).await;
+
+ for _ in 0..retrievals {
+ let (mut retrieval, _) = listener.accept().await.expect("accept Blossom retrieval");
+ let request = read_http_request(&mut retrieval).await;
+ assert!(
+ String::from_utf8_lossy(&request)
+ .starts_with(format!("GET /{hash}.png HTTP/1.1\r\n").as_str())
+ );
+ let mut response_bytes = bytes.clone();
+ if corrupt_retrieval {
+ response_bytes[0] ^= 1;
+ }
+ write_http_response(&mut retrieval, "image/png", &response_bytes).await;
+ }
+ });
+ Self { origin, task }
+ }
+
+ async fn finish(self) {
+ self.task.await.expect("Blossom server task");
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() {
+ let relay = MockRelay::run().await.expect("local NIP-01 relay");
+ let relay_url = relay.url().await.to_string();
+ let image_bytes = png(2, 3);
+ let blossom = BlossomServer::spawn_with_retrievals(image_bytes.clone(), false, 2).await;
+ let mut image_file = tempfile::tempfile().expect("media file");
+ image_file.write_all(&image_bytes).expect("write media");
+ let media = prepared_media(&blossom.origin, &image_bytes, &image_file, "Harvest photo");
+
+ let publisher_root = tempfile::tempdir().expect("publisher root");
+ support::prepare(publisher_root.path());
+ let publisher = runtime_with_signer(publisher_root.path()).await;
+ configure_simulator(&publisher, &relay_url, &blossom.origin);
+ let context = local_network(&relay_url);
+
+ let update_id = draft_id(1);
+ let update = publisher
+ .phase1_save_draft(
+ update_id.clone(),
+ add_input(
+ FfiAddCommandType::CreateUpdate,
+ "Equal-time harvest update",
+ None,
+ ),
+ AUTHORED_AT,
+ None,
+ 1_800_000_000_000,
+ )
+ .await
+ .expect("save offline update");
+ let queued_update = queue(
+ &publisher,
+ &update_id,
+ update.revision,
+ &relay_url,
+ false,
+ 1_800_000_000_500,
+ )
+ .await;
+ assert_eq!(queued_update.state, FfiOutboxState::Queued);
+ publisher
+ .shutdown()
+ .await
+ .expect("shutdown before delivery");
+
+ let publisher = runtime_with_signer(publisher_root.path()).await;
+ configure_simulator(&publisher, &relay_url, &blossom.origin);
+ let recovered = publisher
+ .phase1_recover_draft_queue(update_id.clone(), 1_800_000_001_000)
+ .await
+ .expect("recover queued update after restart");
+ assert_eq!(recovered.state, FfiOutboxState::Queued);
+ advance_complete(&publisher, &update_id, recovered.revision).await;
+
+ let flows = [
+ (
+ 2,
+ add_input(
+ FfiAddCommandType::CreatePhotoUpdate,
+ "Equal-time photo harvest",
+ Some(media.clone()),
+ ),
+ ),
+ (
+ 3,
+ add_input(
+ FfiAddCommandType::CreateAsk,
+ "Equal-time ask: who has basil?",
+ None,
+ ),
+ ),
+ (4, event_input("Equal-time Saturday market")),
+ (5, food_input("Equal-time carrots", "today-carrots")),
+ ];
+ for (index, input) in flows {
+ let id = draft_id(index);
+ let saved = publisher
+ .phase1_save_draft(
+ id.clone(),
+ input,
+ AUTHORED_AT,
+ None,
+ 1_800_000_000_000 + u64::from(index),
+ )
+ .await
+ .expect("save authored flow");
+ let ready = if index == 2 {
+ let uploaded = publisher
+ .phase1_upload_draft_media(FfiBlossomUploadInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: id.clone(),
+ expected_revision: saved.revision,
+ media: media.clone(),
+ authorization_content: "Upload the exact local harvest image".to_owned(),
+ authorization_created_at_unix_s: AUTHORED_AT,
+ authorization_lifetime_seconds: 300,
+ operation_id: "21".repeat(16),
+ artifact_id: "22".repeat(16),
+ signing_deadline_unix_ms: u64::MAX,
+ signing_cancellation: FfiCancellationPolicy::LocalCooperative,
+ verified_at_unix_ms: 1_800_000_000_100,
+ updated_at_unix_ms: 1_800_000_000_200,
+ })
+ .await
+ .expect("upload and re-fetch exact media");
+ assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified);
+ assert_eq!(uploaded.state, FfiOutboxState::MediaPreparing);
+ let evidence = publisher
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.state, "retrieval_verified");
+ assert_eq!(evidence.last_successful_state, "retrieval_verified");
+ assert!(evidence.error_code.is_none());
+ uploaded
+ } else {
+ saved
+ };
+ let queued = queue(
+ &publisher,
+ &id,
+ ready.revision,
+ &relay_url,
+ false,
+ 1_800_000_000_500 + u64::from(index),
+ )
+ .await;
+ advance_complete(&publisher, &id, queued.revision).await;
+ }
+ let reader_root = tempfile::tempdir().expect("fresh reader root");
+ support::prepare(reader_root.path());
+ let reader = RadrootsRuntime::new(
+ reader_root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("fresh reader runtime");
+ reader
+ .configure_simulator_relays(vec![relay_url.clone()])
+ .expect("reader relay profile");
+ let first_sync = reader
+ .phase1_sync_today(context.clone(), AS_OF, FfiTodayProjectionUpdate::Rebuild)
+ .await
+ .expect("fresh relay re-read");
+ assert_eq!(first_sync.relay_state, FfiTodayRelaySyncState::Complete);
+ assert_eq!(first_sync.events_admitted, 5);
+
+ let cards = collect_pages(&reader, &context, 2, AS_OF).await;
+ assert_eq!(cards.len(), 5, "all equal-time cards survive frozen paging");
+ let mut card_types = cards.iter().map(|card| card.card_type).collect::<Vec<_>>();
+ card_types.sort_by_key(|card_type| match card_type {
+ FfiTodayCardType::Update => 0,
+ FfiTodayCardType::PhotoUpdate => 1,
+ FfiTodayCardType::Ask => 2,
+ FfiTodayCardType::Event => 3,
+ FfiTodayCardType::FoodAvailability => 4,
+ });
+ assert_eq!(
+ card_types,
+ vec![
+ FfiTodayCardType::Update,
+ FfiTodayCardType::PhotoUpdate,
+ FfiTodayCardType::Ask,
+ FfiTodayCardType::Event,
+ FfiTodayCardType::FoodAvailability,
+ ]
+ );
+ let photo = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::PhotoUpdate)
+ .expect("photo card");
+ assert_eq!(photo.media.len(), 1);
+ assert_eq!(
+ photo.media[0].sha256.as_deref(),
+ Some(media.sha256.as_str())
+ );
+ reader
+ .configure_blossom(
+ FfiBlossomHostKind::Simulator,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment,
+ blossom.origin.clone(),
+ vec![],
+ )
+ .expect("reader Blossom profile");
+ let artifact = reader
+ .phase1_retrieve_media(
+ context.clone(),
+ photo.media[0].reference_fingerprint.clone(),
+ Arc::new(FfiMediaOperation::new().expect("media operation")),
+ )
+ .await
+ .expect("retrieve projected media");
+ assert_eq!(artifact.bytes, image_bytes);
+ assert_eq!(
+ artifact.byte_size,
+ u64::try_from(image_bytes.len()).unwrap()
+ );
+ assert!(
+ reader
+ .phase1_verified_media_artifact(context.clone(), artifact.artifact_id.clone())
+ .await
+ .expect("verify cached media")
+ .is_some()
+ );
+ let media_cache = reader
+ .phase1_media_cache_status(context.clone())
+ .await
+ .expect("media cache status");
+ assert_eq!(media_cache.artifact_count, 1);
+ assert_eq!(media_cache.total_bytes, artifact.byte_size);
+ assert!(media_cache.configuration_fingerprint.is_some());
+ assert!(
+ reader
+ .phase1_invalidate_media_artifact(context.clone(), artifact.artifact_id.clone())
+ .await
+ .expect("invalidate cached media")
+ );
+ assert!(
+ reader
+ .phase1_verified_media_artifact(context.clone(), artifact.artifact_id)
+ .await
+ .expect("verify invalidated media")
+ .is_none()
+ );
+ blossom.finish().await;
+ let update = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::Update)
+ .expect("update card");
+ let food = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::FoodAvailability)
+ .expect("food card");
+ assert_eq!(food.food_summary.as_deref(), Some("Freshly harvested"));
+ assert_eq!(food.food_published_at_unix_s, Some(AUTHORED_AT));
+ assert_eq!(food.food_status.as_deref(), Some("active"));
+ let update_event_id = update.source_event_id.clone();
+ let update_card_id = update.card_id.clone();
+ let food_event_id = food.source_event_id.clone();
+ let food_card_id = food.card_id.clone();
+
+ publish_supporting_events(&relay_url, &update_event_id, &food_event_id).await;
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync profile and thread events");
+ let enriched = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("enriched Today");
+ assert_eq!(
+ enriched
+ .items
+ .iter()
+ .find(|card| card.card_id == update_card_id)
+ .expect("reply root")
+ .thread
+ .len(),
+ 1
+ );
+ assert_eq!(
+ enriched
+ .items
+ .iter()
+ .find(|card| card.card_id == food_card_id)
+ .expect("comment root")
+ .thread
+ .len(),
+ 1
+ );
+
+ let offline_port = unused_loopback_port().await;
+ let replacement_id = draft_id(6);
+ let mut replacement_input = food_input("Corrected carrots from Moss Farm", "today-carrots");
+ replacement_input.food_published_at_unix_s = Some(AUTHORED_AT);
+ let replacement = publisher
+ .phase1_save_draft(
+ replacement_id.clone(),
+ replacement_input,
+ AUTHORED_AT + 10,
+ None,
+ 1_800_000_010_000,
+ )
+ .await
+ .expect("save replacement");
+ let replacement_queued = publisher
+ .phase1_queue_draft(
+ replacement_id.clone(),
+ replacement.revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec![relay_url.clone(), format!("ws://127.0.0.1:{offline_port}")],
+ satisfaction: FfiRelaySatisfaction::AllAccepted,
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ },
+ 1_800_000_010_100,
+ )
+ .await
+ .expect("queue replacement");
+ let partial = publisher
+ .phase1_advance_draft(replacement_id, replacement_queued.revision)
+ .await
+ .expect("attempt partial replacement delivery");
+ assert_eq!(
+ partial.state,
+ FfiOutboxState::PartiallyDelivered,
+ "partial delivery status: {partial:?}"
+ );
+ let settlement = partial.settlement.expect("partial settlement");
+ assert_eq!(settlement.delivery_satisfied, 0);
+ assert_eq!(settlement.delivery_exhausted, 1);
+
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync replacement");
+ let replaced = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("replacement projection");
+ let current_food = replaced
+ .items
+ .iter()
+ .find(|card| card.card_id == food_card_id)
+ .expect("current food head");
+ assert_eq!(current_food.content, "Corrected carrots from Moss Farm");
+ assert_ne!(current_food.source_event_id, food_event_id);
+
+ let retraction_id = draft_id(7);
+ let retraction = publisher
+ .phase1_save_retraction_draft(
+ retraction_id.clone(),
+ FfiRetractionDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ target_card_id: update_card_id.clone(),
+ target_event_id: update_event_id,
+ target_kind: 1,
+ target_address: None,
+ reason: "Superseded local update".to_owned(),
+ },
+ AUTHORED_AT + 20,
+ 1_800_000_020_000,
+ )
+ .await
+ .expect("save deletion request");
+ let retraction_queued = queue(
+ &publisher,
+ &retraction_id,
+ retraction.revision,
+ &relay_url,
+ false,
+ 1_800_000_020_100,
+ )
+ .await;
+ advance_complete(&publisher, &retraction_id, retraction_queued.revision).await;
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync deletion");
+ let after_deletion = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("projection after deletion");
+ assert_eq!(after_deletion.items.len(), 4);
+ assert!(
+ after_deletion
+ .items
+ .iter()
+ .all(|card| card.card_id != update_card_id)
+ );
+
+ let search = reader
+ .phase1_search(context.clone(), "moss farm".to_owned(), 20, AS_OF)
+ .await
+ .expect("search current projection");
+ assert!(search.iter().any(|result| result.profile.is_some()));
+ assert!(search.iter().any(|result| {
+ result
+ .card
+ .as_ref()
+ .is_some_and(|card| card.content == "Corrected carrots from Moss Farm")
+ }));
+ let me = reader
+ .phase1_me(context.clone(), AS_OF)
+ .await
+ .expect("Me projection");
+ assert_eq!(
+ me.profile
+ .as_ref()
+ .and_then(|profile| profile.display_name.as_deref()),
+ Some("Moss Farm")
+ );
+ assert_eq!(me.cards.len(), 4);
+
+ prove_corrupted_media_fails(&publisher, &image_bytes, &image_file).await;
+
+ reader.shutdown().await.expect("reader shutdown");
+ publisher.shutdown().await.expect("publisher shutdown");
+ relay.shutdown();
+}
+
+async fn runtime_with_signer(root: &std::path::Path) -> RadrootsRuntime {
+ RadrootsRuntime::with_host_signer(
+ root.to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(FixtureHostSigner),
+ )
+ .await
+ .expect("runtime with fixture host signer")
+}
+
+fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origin: &str) {
+ runtime
+ .configure_simulator_relays(vec![relay_url.to_owned()])
+ .expect("simulator relay profile");
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::Simulator,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment,
+ blossom_origin.to_owned(),
+ vec![],
+ )
+ .expect("simulator Blossom profile");
+}
+
+fn local_network(relay_url: &str) -> FfiLocalNetworkRecord {
+ FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "local-mvp".to_owned(),
+ label: "Local MVP".to_owned(),
+ relay_urls: vec![relay_url.to_owned()],
+ locality: None,
+ followed_authors: Vec::new(),
+ generation: 1,
+ }
+}
+
+fn add_input(
+ command_type: FfiAddCommandType,
+ content: &str,
+ media: Option<FfiPreparedMediaInput>,
+) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type,
+ content: content.to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: media.into_iter().collect(),
+ }
+}
+
+fn event_input(content: &str) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ identifier: Some("saturday-market".to_owned()),
+ title: Some("Saturday Market".to_owned()),
+ location: Some("Victoria".to_owned()),
+ event_timing: Some(FfiEventTimingKind::AllDay),
+ event_start_date: Some("2026-08-09".to_owned()),
+ ..add_input(FfiAddCommandType::CreateEvent, content, None)
+ }
+}
+
+fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ identifier: Some(identifier.to_owned()),
+ title: Some("Carrots".to_owned()),
+ summary: Some("Freshly harvested".to_owned()),
+ location: Some("Victoria".to_owned()),
+ price_amount: Some("4.5".to_owned()),
+ currency: Some("CAD".to_owned()),
+ unit: Some("kg".to_owned()),
+ quantity: Some("12".to_owned()),
+ food_status: Some("active".to_owned()),
+ ..add_input(FfiAddCommandType::CreateFoodAvailability, content, None)
+ }
+}
+
+fn prepared_media(
+ _origin: &str,
+ bytes: &[u8],
+ file: &std::fs::File,
+ alt: &str,
+) -> FfiPreparedMediaInput {
+ let hash = Sha256::digest(bytes).to_string();
+ FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: format!("media:{hash}"),
+ file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"),
+ sha256: hash,
+ media_type: "image/png".to_owned(),
+ byte_size: u64::try_from(bytes.len()).expect("media size"),
+ width: 2,
+ height: 3,
+ alt: alt.to_owned(),
+ prepared_at_unix_s: AUTHORED_AT,
+ }
+}
+
+async fn queue(
+ runtime: &RadrootsRuntime,
+ draft_id: &str,
+ revision: u64,
+ relay_url: &str,
+ all: bool,
+ queued_at_unix_ms: u64,
+) -> tera_ffi::FfiDraftStatusRecord {
+ runtime
+ .phase1_queue_draft(
+ draft_id.to_owned(),
+ revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec![relay_url.to_owned()],
+ satisfaction: if all {
+ FfiRelaySatisfaction::AllAccepted
+ } else {
+ FfiRelaySatisfaction::AnyAccepted
+ },
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ },
+ queued_at_unix_ms,
+ )
+ .await
+ .expect("queue draft")
+}
+
+async fn advance_complete(runtime: &RadrootsRuntime, draft_id: &str, revision: u64) {
+ let status = match runtime
+ .phase1_advance_draft(draft_id.to_owned(), revision)
+ .await
+ {
+ Ok(status) => status,
+ Err(error) => {
+ let durable = runtime
+ .phase1_draft_status(draft_id.to_owned())
+ .await
+ .expect("durable failure status");
+ panic!("advance failed: {error:?}; durable status: {durable:?}");
+ }
+ };
+ assert_eq!(status.state, FfiOutboxState::Complete);
+ let settlement = status.settlement.expect("complete settlement");
+ assert_eq!(settlement.signed, 1);
+ assert_eq!(settlement.admitted, 1);
+ assert_eq!(settlement.delivery_satisfied, 1);
+}
+
+async fn collect_pages(
+ runtime: &RadrootsRuntime,
+ context: &FfiLocalNetworkRecord,
+ limit: u16,
+ as_of: u64,
+) -> Vec<tera_ffi::FfiTodayCardRecord> {
+ let first = runtime
+ .phase1_today_page(context.clone(), limit, Some(as_of), None)
+ .await
+ .expect("first Today page");
+ let mut items = first.items;
+ let mut cursor = first.next_cursor;
+ while let Some(next) = cursor {
+ let page = runtime
+ .phase1_today_page(context.clone(), limit, None, Some(next))
+ .await
+ .expect("continued Today page");
+ items.extend(page.items);
+ cursor = page.next_cursor;
+ }
+ let mut unique = items.iter().map(|card| &card.card_id).collect::<Vec<_>>();
+ unique.sort_unstable();
+ unique.dedup();
+ assert_eq!(unique.len(), items.len(), "frozen pages have no duplicates");
+ items
+}
+
+async fn publish_supporting_events(relay_url: &str, update_id: &str, food_id: &str) {
+ let profile_keys = Keys::parse(FIXTURE_SECRET).expect("profile keys");
+ let profile_client = Client::new(profile_keys);
+ profile_client
+ .add_relay(relay_url)
+ .await
+ .expect("profile relay");
+ profile_client.connect().await;
+ profile_client
+ .wait_for_connection(Duration::from_secs(2))
+ .await;
+ profile_client
+ .send_event_builder(
+ EventBuilder::metadata(
+ &Metadata::new()
+ .name("moss")
+ .display_name("Moss Farm")
+ .about("Local harvests"),
+ )
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 1)),
+ )
+ .await
+ .expect("publish profile");
+ profile_client.shutdown().await;
+
+ let reply_keys = Keys::parse(REPLY_SECRET).expect("reply keys");
+ let reply_author = reply_keys.public_key().to_string();
+ let reply_client = Client::new(reply_keys);
+ reply_client
+ .add_relay(relay_url)
+ .await
+ .expect("reply relay");
+ reply_client.connect().await;
+ reply_client
+ .wait_for_connection(Duration::from_secs(2))
+ .await;
+ reply_client
+ .send_event_builder(
+ EventBuilder::text_note("The farm stand is open")
+ .tags([
+ Tag::parse(["e", update_id, relay_url, "root"]).expect("reply root tag"),
+ Tag::parse(["p", support::PUBLIC_KEY]).expect("reply author tag"),
+ ])
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 2)),
+ )
+ .await
+ .expect("publish reply");
+ reply_client
+ .send_event_builder(
+ EventBuilder::new(Kind::Custom(1_111), "Are these available Saturday?")
+ .tags([
+ Tag::parse(["E", food_id, relay_url, support::PUBLIC_KEY])
+ .expect("comment root event tag"),
+ Tag::parse(["K", "30402"]).expect("comment root kind tag"),
+ Tag::parse(["P", support::PUBLIC_KEY, relay_url])
+ .expect("comment root author tag"),
+ Tag::parse(["e", food_id, relay_url, support::PUBLIC_KEY])
+ .expect("comment parent event tag"),
+ Tag::parse(["k", "30402"]).expect("comment parent kind tag"),
+ Tag::parse(["p", support::PUBLIC_KEY, relay_url])
+ .expect("comment parent author tag"),
+ Tag::parse(["p", reply_author.as_str()]).expect("self author context tag"),
+ ])
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 3)),
+ )
+ .await
+ .expect("publish comment");
+ reply_client.shutdown().await;
+}
+
+async fn prove_corrupted_media_fails(
+ runtime: &RadrootsRuntime,
+ bytes: &[u8],
+ image_file: &std::fs::File,
+) {
+ let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await;
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::Simulator,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment,
+ corrupt.origin.clone(),
+ vec![],
+ )
+ .expect("corrupt test Blossom profile");
+ let media = prepared_media(
+ &corrupt.origin,
+ bytes,
+ image_file,
+ "Corrupt retrieval proof",
+ );
+ let id = draft_id(8);
+ let saved = runtime
+ .phase1_save_draft(
+ id.clone(),
+ add_input(
+ FfiAddCommandType::CreatePhotoUpdate,
+ "This image must fail closed",
+ Some(media.clone()),
+ ),
+ AUTHORED_AT + 30,
+ None,
+ 1_800_000_030_000,
+ )
+ .await
+ .expect("save corrupt-media draft");
+ let error = runtime
+ .phase1_upload_draft_media(FfiBlossomUploadInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: id.clone(),
+ expected_revision: saved.revision,
+ media,
+ authorization_content: "Verify corrupt retrieval rejection".to_owned(),
+ authorization_created_at_unix_s: AUTHORED_AT,
+ authorization_lifetime_seconds: 300,
+ operation_id: "31".repeat(16),
+ artifact_id: "32".repeat(16),
+ signing_deadline_unix_ms: u64::MAX,
+ signing_cancellation: FfiCancellationPolicy::LocalCooperative,
+ verified_at_unix_ms: 1_800_000_030_100,
+ updated_at_unix_ms: 1_800_000_030_200,
+ })
+ .await
+ .expect_err("corrupt media retrieval must fail");
+ assert_eq!(error.report().code, "authoring_failed");
+ let failed = runtime
+ .phase1_draft_status(id)
+ .await
+ .expect("durable corrupt-media status");
+ assert_eq!(failed.media[0].stage, FfiMediaStage::Failed);
+ assert_eq!(failed.state, FfiOutboxState::MediaPreparing);
+ assert!(failed.media[0].possible_orphan);
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.state, "terminal_failure");
+ assert_eq!(evidence.last_successful_state, "upload_verified");
+ assert_eq!(
+ evidence.error_code.as_deref(),
+ Some("blossom_response_hash_mismatch")
+ );
+ assert_eq!(evidence.error_phase.as_deref(), Some("verification"));
+ assert!(evidence.possible_orphan);
+ corrupt.finish().await;
+}
+
+async fn unused_loopback_port() -> u16 {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("reserve unused port");
+ listener.local_addr().expect("unused address").port()
+}
+
+async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
+ let mut request = Vec::new();
+ let header_end = loop {
+ let mut chunk = [0_u8; 1_024];
+ let read = stream.read(&mut chunk).await.expect("read HTTP request");
+ assert_ne!(read, 0, "HTTP request ended before headers");
+ request.extend_from_slice(&chunk[..read]);
+ if let Some(index) = request.windows(4).position(|value| value == b"\r\n\r\n") {
+ break index + 4;
+ }
+ assert!(request.len() < 64 * 1_024, "HTTP headers are bounded");
+ };
+ let content_length = String::from_utf8_lossy(&request[..header_end])
+ .lines()
+ .find_map(|line| {
+ line.to_ascii_lowercase()
+ .strip_prefix("content-length: ")
+ .and_then(|value| value.trim().parse::<usize>().ok())
+ })
+ .unwrap_or_default();
+ while request.len() - header_end < content_length {
+ let mut chunk = [0_u8; 1_024];
+ let read = stream.read(&mut chunk).await.expect("read HTTP body");
+ assert_ne!(read, 0, "HTTP request ended before body");
+ request.extend_from_slice(&chunk[..read]);
+ }
+ request
+}
+
+async fn write_http_response(stream: &mut tokio::net::TcpStream, content_type: &str, body: &[u8]) {
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ stream
+ .write_all(head.as_bytes())
+ .await
+ .expect("write response head");
+ stream.write_all(body).await.expect("write response body");
+ stream.shutdown().await.expect("close HTTP response");
+}
+
+fn draft_id(index: u8) -> String {
+ format!("{index:02x}").repeat(16)
+}
+
+fn png(width: u32, height: u32) -> Vec<u8> {
+ let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
+ bytes.extend_from_slice(&width.to_be_bytes());
+ bytes.extend_from_slice(&height.to_be_bytes());
+ bytes
+}
+
+fn unix_time_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_millis()
+ .try_into()
+ .expect("current time fits u64")
+}
diff --git a/core/crates/tera_ffi/tests/logging_error.rs b/core/crates/tera_ffi/tests/logging_error.rs
@@ -0,0 +1,13 @@
+use tera_ffi::RadrootsAppError;
+use tera_ffi::logging;
+
+#[test]
+fn init_logging_stdout_maps_global_subscriber_error() {
+ let _ = tracing_subscriber::fmt().try_init();
+ let err = logging::init_logging_stdout();
+ assert!(matches!(
+ err,
+ Err(RadrootsAppError::Failure { report })
+ if report.code == "initialization_failed"
+ ));
+}
diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs
@@ -0,0 +1,850 @@
+use std::sync::Arc;
+
+use tera_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiBlossomAuthorityPreference,
+ FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomPreferencesRecord,
+ FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiEventTimingKind,
+ FfiIdentityCommandKind, FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord,
+ FfiLocalStoragePolicyRecord, FfiMediaNetworkPolicyRecord, FfiMediaOperation,
+ FfiMobileNetworkEnvironment, FfiNativeUploadCompletionInput, FfiOutboxState,
+ FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord,
+ FfiRelayAccessPreference, FfiRelayAccessRecord, FfiRelayPreferenceRecord,
+ FfiRelayPreferencesRecord, FfiRelaySatisfaction, FfiReplaceSettingsRecord,
+ FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, FfiTodayCardType,
+ FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError,
+};
+
+mod support;
+
+#[tokio::test]
+async fn native_boundary_delegates_the_complete_core_surface() {
+ let (_root, runtime) = support::runtime().await;
+ assert!(runtime.uptime_millis() >= 0);
+ assert!(runtime.info_json().contains("sdk"));
+ runtime.set_app_info_platform(
+ Some("ios".to_owned()),
+ Some("org.radroots.app".to_owned()),
+ Some("0.1.0-alpha".to_owned()),
+ Some("1".to_owned()),
+ Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
+ );
+ assert_eq!(
+ runtime.info().app.platform.expect("platform").platform,
+ Some("ios".to_owned())
+ );
+ assert!(!runtime.sdk_capabilities().is_empty());
+ assert_eq!(
+ runtime.sdk_storage_status().await.expect("storage").backend,
+ "sqlite"
+ );
+
+ let public = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("default public profile");
+ assert_eq!(public.profile, "public");
+ assert_eq!(public.state, "configured");
+ assert_eq!(public.read_availability, "unavailable");
+ assert_eq!(public.write_availability, "unavailable");
+ assert_eq!(public.relays.len(), 1);
+ assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite);
+ assert_eq!(public.relays[0].read_state, "unobserved");
+ assert_eq!(public.relays[0].write_state, "unobserved");
+
+ runtime
+ .configure_public_relays(vec!["wss://write.example".to_owned()])
+ .expect("public relays");
+ let public = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("public profile");
+ assert_eq!(public.relays.len(), 1);
+ assert_eq!(public.relays[0].relay_url, "wss://write.example");
+ assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite);
+ assert!(
+ runtime
+ .configure_public_relays(vec!["ws://127.0.0.1:7447".to_owned()])
+ .is_err()
+ );
+
+ let settings = runtime.phase1_settings().await.expect("settings");
+ runtime
+ .phase1_replace_settings(FfiReplaceSettingsRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ expected_revision: settings.revision,
+ relays: FfiRelayPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: FfiMobileNetworkEnvironment::Public,
+ endpoints: vec![
+ FfiRelayPreferenceRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: "wss://read.example".to_owned(),
+ access: FfiRelayAccessPreference::ReadOnly,
+ },
+ FfiRelayPreferenceRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: "wss://write.example".to_owned(),
+ access: FfiRelayAccessPreference::ReadWrite,
+ },
+ ],
+ },
+ blossom: FfiBlossomPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: FfiMobileNetworkEnvironment::Public,
+ authority: FfiBlossomAuthorityPreference::PublicWebPki,
+ primary_origin: "https://media.example".to_owned(),
+ fallback_origins: vec![],
+ },
+ media_network: FfiMediaNetworkPolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ allow_cellular_downloads: true,
+ allow_cellular_uploads: true,
+ allow_background_transfers: true,
+ },
+ local_storage: FfiLocalStoragePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ media_cache_bytes: 256 * 1024 * 1024,
+ media_cache_artifacts: 1024,
+ },
+ })
+ .await
+ .expect("replace settings");
+ runtime
+ .phase1_apply_settings_to_runtime()
+ .await
+ .expect("apply settings");
+ let applied = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("applied settings profile");
+ assert_eq!(applied.relays.len(), 2);
+ assert_eq!(applied.relays[0].access, FfiRelayAccessRecord::ReadOnly);
+ assert_eq!(applied.relays[1].access, FfiRelayAccessRecord::ReadWrite);
+
+ runtime
+ .configure_simulator_relays(vec!["ws://127.0.0.1:7447".to_owned()])
+ .expect("simulator relays");
+ let simulator = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("simulator profile");
+ assert_eq!(simulator.profile, "simulator_local");
+ assert_eq!(simulator.relays.len(), 1);
+ assert_eq!(simulator.relays[0].access, FfiRelayAccessRecord::ReadWrite);
+ assert!(
+ runtime
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "simulator".to_owned(),
+ label: "Simulator".to_owned(),
+ relay_urls: vec!["ws://127.0.0.1:7447".to_owned()],
+ locality: None,
+ followed_authors: vec![],
+ generation: 1,
+ })
+ .is_ok()
+ );
+ assert!(
+ runtime
+ .configure_simulator_relays(vec!["wss://relay.example".to_owned()])
+ .is_err()
+ );
+
+ runtime
+ .configure_device_relays(vec!["wss://10.0.0.5:7447".to_owned()])
+ .expect("device relays");
+ let device = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("device profile");
+ assert_eq!(device.profile, "device_development");
+ assert_eq!(device.relays.len(), 1);
+ assert_eq!(device.relays[0].relay_url, "wss://10.0.0.5:7447");
+ assert_eq!(device.relays[0].access, FfiRelayAccessRecord::ReadWrite);
+ assert!(
+ runtime
+ .configure_device_relays(vec!["wss://127.0.0.1:7447".to_owned()])
+ .is_err()
+ );
+
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::PhysicalDevice,
+ FfiBlossomEndpointAuthority::PublicWebPki,
+ "https://media.example".to_owned(),
+ vec!["https://fallback.example".to_owned()],
+ )
+ .expect("public Blossom");
+ let blossom = runtime
+ .sdk_blossom_configuration()
+ .expect("Blossom configuration")
+ .expect("configured Blossom");
+ assert_eq!(blossom.host_kind, "physical_device");
+ assert_eq!(blossom.endpoint_authority, "public_webpki");
+ assert_eq!(blossom.primary_origin, "https://media.example");
+ assert_eq!(blossom.fallback_origins, ["https://fallback.example"]);
+ assert_eq!(blossom.config_fingerprint.len(), 64);
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.schema_version, 2);
+ assert_eq!(evidence.origin, "https://media.example");
+ assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint);
+ assert_eq!(evidence.state, "configured_unobserved");
+ assert_eq!(evidence.transport_security, "public_webpki");
+ assert!(evidence.observed_at_unix_ms.is_none());
+ assert!(evidence.error_code.is_none());
+ assert!(evidence.server_error_code.is_none());
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::Simulator,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment,
+ "http://127.0.0.1:3100".to_owned(),
+ vec![],
+ )
+ .expect("simulator Blossom");
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::Simulator,
+ FfiBlossomEndpointAuthority::LoopbackDevelopment,
+ "http://127.0.0.1:1".to_owned(),
+ vec![],
+ )
+ .expect("unavailable simulator Blossom");
+ assert!(runtime.probe_blossom().await.is_err());
+ runtime
+ .configure_blossom(
+ FfiBlossomHostKind::PhysicalDevice,
+ FfiBlossomEndpointAuthority::PrivateNetworkDevelopment,
+ "https://10.0.0.5:3100".to_owned(),
+ vec![],
+ )
+ .expect("device Blossom");
+
+ assert_eq!(
+ runtime
+ .phase1_card_add_parity()
+ .into_iter()
+ .map(|item| item.card_type)
+ .collect::<Vec<_>>(),
+ vec![
+ FfiTodayCardType::Update,
+ FfiTodayCardType::PhotoUpdate,
+ FfiTodayCardType::Ask,
+ FfiTodayCardType::Event,
+ FfiTodayCardType::FoodAvailability,
+ ]
+ );
+ assert_eq!(
+ runtime
+ .phase1_add_schemas()
+ .into_iter()
+ .map(|schema| schema.command_type)
+ .collect::<Vec<_>>(),
+ vec![
+ FfiAddCommandType::CreateUpdate,
+ FfiAddCommandType::CreatePhotoUpdate,
+ FfiAddCommandType::CreateAsk,
+ FfiAddCommandType::CreateEvent,
+ FfiAddCommandType::CreateFoodAvailability,
+ ]
+ );
+ let parity = runtime.phase1_card_add_parity();
+ let schemas = runtime.phase1_add_schemas();
+ assert_eq!(parity.len(), 5);
+ for (index, item) in parity.iter().enumerate() {
+ assert_eq!(item.command_type, schemas[index].command_type);
+ }
+ assert_eq!(
+ schemas[1]
+ .fields
+ .iter()
+ .find(|field| field.id == "media")
+ .and_then(|field| field.max_items),
+ Some(20)
+ );
+ assert_eq!(
+ schemas[3]
+ .fields
+ .iter()
+ .find(|field| field.id == "media")
+ .and_then(|field| field.max_items),
+ Some(1)
+ );
+ let local_network = runtime
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: vec!["ws://192.168.1.7:7447".to_owned()],
+ locality: Some("u10h".to_owned()),
+ followed_authors: vec!["a".repeat(64)],
+ generation: 1,
+ })
+ .expect("valid local network");
+ assert_eq!(local_network.id, "nearby");
+ assert!(
+ runtime
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "public-host".to_owned(),
+ label: "Public host".to_owned(),
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ locality: None,
+ followed_authors: vec![],
+ generation: 1,
+ })
+ .is_err()
+ );
+ let refresh = runtime
+ .phase1_refresh_today(
+ local_network.clone(),
+ 1_800_000_001,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("empty Today refresh");
+ assert_eq!(refresh.update, FfiTodayProjectionUpdate::Incremental);
+ assert_eq!(refresh.source_events, 0);
+ assert_eq!(refresh.visible_cards, 0);
+ assert!(refresh.content_generation > 0);
+ let today = runtime
+ .phase1_today_page(local_network.clone(), 20, Some(1_800_000_001), None)
+ .await
+ .expect("empty Today page");
+ assert!(today.items.is_empty());
+ assert!(today.next_cursor.is_none());
+ assert!(
+ runtime
+ .phase1_today_page(local_network.clone(), 20, None, None)
+ .await
+ .is_err()
+ );
+ assert!(
+ runtime
+ .phase1_today_page(
+ local_network.clone(),
+ 20,
+ Some(1_800_000_001),
+ Some("opaque".to_owned()),
+ )
+ .await
+ .is_err()
+ );
+ assert!(
+ runtime
+ .phase1_search(
+ local_network.clone(),
+ "carrots".to_owned(),
+ 20,
+ 1_800_000_001,
+ )
+ .await
+ .expect("empty search")
+ .is_empty()
+ );
+ let me = runtime
+ .phase1_me(local_network.clone(), 1_800_000_001)
+ .await
+ .expect("Me snapshot");
+ assert_eq!(me.public_key, support::PUBLIC_KEY);
+
+ let add = FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: "Farm stand opens at noon".to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ };
+ runtime
+ .phase1_validate_add_draft(add.clone(), 1_800_000_001)
+ .expect("valid draft");
+ let saved = runtime
+ .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!(
+ saved.form.as_ref().map(|form| form.content.as_str()),
+ Some("Farm stand opens at noon")
+ );
+ assert_eq!(
+ runtime
+ .phase1_draft_status(draft_id.clone())
+ .await
+ .expect("draft status")
+ .revision,
+ saved.revision
+ );
+ assert_eq!(
+ runtime
+ .phase1_draft_heads(10)
+ .await
+ .expect("draft heads")
+ .len(),
+ 1
+ );
+ let queued = runtime
+ .phase1_queue_add_intent(draft_id.clone(), saved.revision)
+ .await
+ .expect("queued draft");
+ assert_eq!(queued.state, FfiOutboxState::Queued);
+ let recovered = runtime
+ .phase1_recover_add_intent(draft_id.clone())
+ .await
+ .expect("recovered queue");
+ assert_eq!(recovered.revision, queued.revision);
+ let cancelled = runtime
+ .phase1_cancel_add_intent(draft_id.clone(), recovered.revision)
+ .await
+ .expect("cancelled draft");
+ assert_eq!(cancelled.state, FfiOutboxState::Cancelled);
+ assert!(
+ runtime
+ .phase1_save_add_intent(
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateEvent,
+ content: "Market opens Saturday".to_owned(),
+ identifier: None,
+ title: Some("Saturday market".to_owned()),
+ summary: Some("Weekly market".to_owned()),
+ location: Some("Town square".to_owned()),
+ event_timing: Some(FfiEventTimingKind::AllDay),
+ event_start_date: Some("2027-01-02".to_owned()),
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ },
+ None,
+ Some(1),
+ )
+ .await
+ .is_err()
+ );
+ assert!(
+ runtime
+ .phase1_advance_draft(draft_id.clone(), cancelled.revision)
+ .await
+ .is_err()
+ );
+
+ let retraction_id = "0a".repeat(16);
+ let retraction_input = FfiRetractionDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ target_card_id: "c".repeat(64),
+ target_event_id: "a".repeat(64),
+ target_kind: 1,
+ target_address: None,
+ reason: "Replaced with a corrected copy".to_owned(),
+ };
+ let mut unsupported_retraction = retraction_input.clone();
+ unsupported_retraction.schema_version += 1;
+ assert_eq!(
+ runtime
+ .phase1_save_retraction_draft(
+ retraction_id.clone(),
+ unsupported_retraction,
+ 1_800_000_005,
+ 1_800_000_005_000,
+ )
+ .await
+ .expect_err("unsupported retraction schema")
+ .report()
+ .code,
+ "unsupported_schema_version"
+ );
+ let retraction = runtime
+ .phase1_save_retraction_draft(
+ retraction_id.clone(),
+ retraction_input,
+ 1_800_000_005,
+ 1_800_000_005_000,
+ )
+ .await
+ .expect("saved retraction");
+ assert_eq!(retraction.kind, FfiDraftKind::Retraction);
+ assert_eq!(retraction.card_id, "c".repeat(64));
+ assert!(retraction.form.is_none());
+ let queued_retraction = runtime
+ .phase1_queue_draft(
+ retraction_id.clone(),
+ retraction.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_006_000,
+ )
+ .await
+ .expect("queued retraction");
+ let cancelled_retraction = runtime
+ .phase1_cancel_draft(retraction_id, queued_retraction.revision, 1_800_000_007_000)
+ .await
+ .expect("cancelled retraction");
+ assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled);
+
+ let upload = FfiBlossomUploadIntent {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ draft_id,
+ expected_revision: cancelled.revision,
+ media: FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: "media:unused".to_owned(),
+ file_descriptor: 0,
+ sha256: "00".repeat(32),
+ media_type: "image/png".to_owned(),
+ byte_size: 1,
+ width: 1,
+ height: 1,
+ alt: "unused".to_owned(),
+ prepared_at_unix_s: 1_800_000_000,
+ },
+ };
+ let upload_error = runtime
+ .phase1_upload_add_media_intent(upload.clone())
+ .await
+ .expect_err("unsupported upload schema");
+ assert_eq!(upload_error.report().code, "unsupported_schema_version");
+ assert_eq!(
+ runtime
+ .phase1_prepare_add_media_background(upload.clone())
+ .await
+ .expect_err("unsupported background upload schema")
+ .report()
+ .code,
+ "unsupported_schema_version"
+ );
+ assert_eq!(
+ runtime
+ .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ draft_id: upload.draft_id.clone(),
+ expected_revision: upload.expected_revision,
+ media: upload.media.clone(),
+ status_code: 200,
+ response_media_type: Some("application/json".to_owned()),
+ response_content_encoding: None,
+ response_body: Vec::new(),
+ })
+ .await
+ .expect_err("unsupported completion schema")
+ .report()
+ .code,
+ "invalid_native_upload_completion"
+ );
+ assert_eq!(
+ runtime
+ .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: upload.draft_id.clone(),
+ expected_revision: upload.expected_revision,
+ media: upload.media.clone(),
+ status_code: 200,
+ response_media_type: Some("application/json".to_owned()),
+ response_content_encoding: None,
+ response_body: vec![0; 16_385],
+ })
+ .await
+ .expect_err("oversized completion body")
+ .report()
+ .code,
+ "invalid_native_upload_completion"
+ );
+ let mut invalid_id_upload = upload;
+ invalid_id_upload.schema_version = MOBILE_FFI_SCHEMA_VERSION;
+ invalid_id_upload.draft_id = "not-a-draft-id".to_owned();
+ assert_eq!(
+ runtime
+ .phase1_upload_add_media_intent(invalid_id_upload.clone())
+ .await
+ .expect_err("invalid draft id")
+ .report()
+ .code,
+ "invalid_draft_id"
+ );
+ assert_eq!(
+ runtime
+ .phase1_prepare_add_media_background(invalid_id_upload.clone())
+ .await
+ .expect_err("invalid background draft id")
+ .report()
+ .code,
+ "invalid_draft_id"
+ );
+ assert_eq!(
+ runtime
+ .phase1_complete_add_media_background(FfiNativeUploadCompletionInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: invalid_id_upload.draft_id,
+ expected_revision: invalid_id_upload.expected_revision,
+ media: invalid_id_upload.media,
+ status_code: 200,
+ response_media_type: Some("application/json".to_owned()),
+ response_content_encoding: None,
+ response_body: Vec::new(),
+ })
+ .await
+ .expect_err("invalid completion draft id")
+ .report()
+ .code,
+ "invalid_draft_id"
+ );
+ assert!(
+ runtime
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: Vec::new(),
+ locality: None,
+ followed_authors: Vec::new(),
+ generation: 1,
+ })
+ .is_err()
+ );
+
+ let initial_settings = runtime.phase1_settings().await.expect("settings");
+ let begun = runtime
+ .phase1_apply_identity_command(
+ initial_settings.revision,
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::BeginImport,
+ operation_id: Some("import-ffi-1".to_owned()),
+ identity_id: None,
+ public_key: None,
+ },
+ )
+ .await
+ .expect("begin identity import");
+ let completed = runtime
+ .phase1_apply_identity_command(
+ begun.settings.revision,
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::CompleteImport,
+ operation_id: Some("import-ffi-1".to_owned()),
+ identity_id: Some("primary".to_owned()),
+ public_key: Some(support::PUBLIC_KEY.to_owned()),
+ },
+ )
+ .await
+ .expect("complete identity import");
+ let unlocked = runtime
+ .phase1_apply_identity_command(
+ completed.settings.revision,
+ FfiIdentityCommandRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ kind: FfiIdentityCommandKind::Unlock,
+ operation_id: None,
+ identity_id: None,
+ public_key: None,
+ },
+ )
+ .await
+ .expect("record process-local unlock");
+ assert_eq!(
+ unlocked.settings.identity.lock_state,
+ FfiIdentityLockState::Unlocked
+ );
+ assert_eq!(unlocked.settings.revision, completed.settings.revision);
+
+ let source_event_id = "ab".repeat(32);
+ let source = tera_core::runtime::product_surface::CardSourceIdentity::Event(
+ radroots_event::EventId::parse(&source_event_id).expect("source event id"),
+ );
+ let card_id = tera_core::runtime::product_surface::CardId::derive(
+ tera_core::runtime::product_surface::TodayCardType::Update,
+ &source,
+ )
+ .to_hex();
+ let revision = runtime
+ .phase1_save_revision_intent(FfiRevisionInputRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ card_id,
+ source_event_id,
+ source_address: None,
+ author_public_key: support::PUBLIC_KEY.to_owned(),
+ replacement: FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: "Corrected farm stand hours".to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ },
+ })
+ .await
+ .expect("save lossless revision intent");
+ assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending);
+ assert_eq!(revision.operation_id, revision.replacement.draft_id);
+ assert!(revision.replacement.is_revision);
+ assert_eq!(
+ runtime
+ .phase1_revision_status(revision.operation_id.clone())
+ .await
+ .expect("revision status")
+ .phase,
+ FfiRevisionPhase::ReplacementPending
+ );
+ assert!(
+ runtime
+ .phase1_advance_revision(revision.operation_id.clone())
+ .await
+ .is_err()
+ );
+ let cancelled_revision = runtime
+ .phase1_cancel_revision(revision.operation_id.clone())
+ .await
+ .expect("cancel revision intent");
+ assert_eq!(cancelled_revision.operation_id, revision.operation_id);
+ assert_eq!(cancelled_revision.phase, FfiRevisionPhase::Cancelled);
+
+ let profile = runtime
+ .phase1_save_profile_metadata(FfiProfileMetadataInputRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ name: "grower".to_owned(),
+ display_name: Some("Local Grower".to_owned()),
+ about: Some("Seasonal produce".to_owned()),
+ picture: None,
+ banner: None,
+ nip05: Some("grower@farm.example".to_owned()),
+ bot: Some(false),
+ })
+ .await
+ .expect("save profile intent");
+ assert_eq!(profile.state, FfiOutboxState::Draft);
+ assert_eq!(profile.operation_id.len(), 32);
+ assert_eq!(
+ runtime
+ .phase1_profile_status(profile.operation_id.clone())
+ .await
+ .expect("profile status")
+ .revision,
+ profile.revision
+ );
+ let cancelled_profile = runtime
+ .phase1_cancel_profile(profile.operation_id.clone(), profile.revision)
+ .await
+ .expect("cancel profile intent");
+ assert_eq!(cancelled_profile.operation_id, profile.operation_id);
+ assert_eq!(cancelled_profile.state, FfiOutboxState::Cancelled);
+ assert_eq!(
+ runtime
+ .phase1_advance_profile(profile.operation_id.clone())
+ .await
+ .expect("advance cancelled profile")
+ .state,
+ FfiOutboxState::Cancelled
+ );
+
+ let cache = runtime
+ .phase1_media_cache_status(local_network.clone())
+ .await
+ .expect("empty media cache status");
+ assert_eq!(cache.artifact_count, 0);
+ assert_eq!(cache.total_bytes, 0);
+ assert!(cache.configuration_fingerprint.is_none());
+ assert!(
+ runtime
+ .phase1_verified_media_artifact(local_network.clone(), "11".repeat(32))
+ .await
+ .expect("empty verified media lookup")
+ .is_none()
+ );
+ assert!(
+ !runtime
+ .phase1_invalidate_media_artifact(local_network.clone(), "11".repeat(32))
+ .await
+ .expect("missing media invalidation")
+ );
+ assert!(
+ runtime
+ .phase1_invalidate_media_configuration(local_network.clone(), "22".repeat(32))
+ .await
+ .expect("empty configuration invalidation")
+ .is_empty()
+ );
+ let media_operation = Arc::new(FfiMediaOperation::new().expect("media operation"));
+ let media_error = runtime
+ .phase1_retrieve_media(
+ local_network.clone(),
+ "invalid-reference".to_owned(),
+ Arc::clone(&media_operation),
+ )
+ .await
+ .expect_err("invalid media reference");
+ assert_eq!(
+ media_error.report().code,
+ "invalid_media_reference_fingerprint"
+ );
+ assert_eq!(
+ runtime
+ .phase1_retrieve_media(local_network, "00".repeat(32), Arc::clone(&media_operation),)
+ .await
+ .expect_err("single-use media operation")
+ .report()
+ .code,
+ "media_operation_already_used"
+ );
+
+ runtime.shutdown().await.expect("shutdown");
+ assert!(matches!(
+ runtime.sdk_storage_status().await,
+ Err(RadrootsAppError::Failure { .. })
+ ));
+ assert!(matches!(
+ runtime.sdk_relay_status(),
+ Err(RadrootsAppError::Failure { .. })
+ ));
+ assert!(matches!(
+ runtime.configure_public_relays(Vec::new()),
+ Err(RadrootsAppError::Failure { .. })
+ ));
+}
diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs
@@ -0,0 +1,44 @@
+use std::{sync::Arc, time::Duration};
+
+use tera_ffi::RadrootsAppError;
+
+mod support;
+
+#[tokio::test]
+async fn host_release_ordering_retains_close_and_finishes_within_deadline() {
+ let (_root, runtime) = support::runtime().await;
+ let host = Arc::new(runtime);
+ let closing_owner = Arc::clone(&host);
+ let close = tokio::spawn(async move { closing_owner.shutdown().await });
+ drop(host);
+
+ let result = tokio::time::timeout(Duration::from_secs(1), close)
+ .await
+ .expect("mobile shutdown exceeded its host deadline")
+ .expect("shutdown task panicked")
+ .expect("shutdown failed");
+ assert_eq!(result.state, "closed");
+ assert!(!result.already_closed);
+}
+
+#[tokio::test]
+async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() {
+ let (_root, runtime) = support::runtime().await;
+ let runtime = Arc::new(runtime);
+ let first = Arc::clone(&runtime);
+ let second = Arc::clone(&runtime);
+ let (first, second) = tokio::join!(first.shutdown(), second.shutdown());
+
+ for outcome in [&first, &second] {
+ assert!(
+ outcome.is_ok()
+ || matches!(
+ outcome,
+ Err(RadrootsAppError::Failure { report })
+ if report.code == "client_close_in_progress"
+ )
+ );
+ }
+ let repeated = runtime.shutdown().await.expect("repeated close");
+ assert!(repeated.already_closed);
+}
diff --git a/core/crates/tera_ffi/tests/subscription_contract.rs b/core/crates/tera_ffi/tests/subscription_contract.rs
@@ -0,0 +1,67 @@
+use std::sync::mpsc::{Receiver, Sender, channel};
+use std::time::Duration;
+
+use tera_ffi::{FfiRuntimeChangeKind, FfiRuntimeChangeRecord, RadrootsRuntimeObserver};
+
+mod support;
+
+struct Observer(Sender<FfiRuntimeChangeRecord>);
+
+impl RadrootsRuntimeObserver for Observer {
+ fn on_change(&self, change: FfiRuntimeChangeRecord) {
+ let _ = self.0.send(change);
+ }
+}
+
+fn observer() -> (
+ Box<dyn RadrootsRuntimeObserver>,
+ Receiver<FfiRuntimeChangeRecord>,
+) {
+ let (sender, receiver) = channel();
+ (Box::new(Observer(sender)), receiver)
+}
+
+fn receive(receiver: &Receiver<FfiRuntimeChangeRecord>) -> FfiRuntimeChangeRecord {
+ receiver
+ .recv_timeout(Duration::from_secs(1))
+ .expect("bounded observer delivery")
+}
+
+#[tokio::test]
+async fn subscriptions_are_independent_bounded_handles_and_stop_individually() {
+ let (_root, runtime) = support::runtime().await;
+ let (first_observer, first_receiver) = observer();
+ let (second_observer, second_receiver) = observer();
+ let first = runtime
+ .subscribe_changes(first_observer)
+ .expect("first subscription");
+ let second = runtime
+ .subscribe_changes(second_observer)
+ .expect("second subscription");
+
+ assert_eq!(receive(&first_receiver).kind, FfiRuntimeChangeKind::Initial);
+ assert_eq!(
+ receive(&second_receiver).kind,
+ FfiRuntimeChangeKind::Initial
+ );
+ first.unsubscribe();
+ assert!(!first.is_active());
+ assert!(second.is_active());
+
+ runtime
+ .configure_public_relays(vec!["wss://write.example".to_owned()])
+ .expect("relay configuration");
+ assert_eq!(receive(&second_receiver).kind, FfiRuntimeChangeKind::Relay);
+ assert!(
+ first_receiver
+ .recv_timeout(Duration::from_millis(50))
+ .is_err()
+ );
+
+ runtime.shutdown().await.expect("shutdown");
+ assert_eq!(
+ receive(&second_receiver).kind,
+ FfiRuntimeChangeKind::Lifecycle
+ );
+ assert!(!second.is_active());
+}
diff --git a/core/crates/tera_ffi/tests/support/mod.rs b/core/crates/tera_ffi/tests/support/mod.rs
@@ -0,0 +1,24 @@
+use tera_ffi::{ProtectedDataAvailability, RadrootsRuntime};
+
+pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404";
+
+pub fn prepare(root: &std::path::Path) {
+ std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY))
+ .expect("owner directory");
+}
+
+pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) {
+ let root = tempfile::tempdir().expect("tempdir");
+ prepare(root.path());
+ let runtime = RadrootsRuntime::new(
+ root.path().to_string_lossy().into_owned(),
+ PUBLIC_KEY.to_owned(),
+ GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("runtime");
+ (root, runtime)
+}
diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs
@@ -0,0 +1,258 @@
+use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
+use std::sync::{Arc, Mutex};
+use tera_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation,
+ FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiTradeEvidenceCoverage, FfiTradeEvidenceOutcome,
+ HostSigningOutcome, HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION,
+ ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, RadrootsRuntime,
+ SignerAvailabilityRecord, SignerStatusRecord,
+};
+
+mod support;
+
+struct TestHostSigner(Arc<Mutex<HostSigningOutcome>>);
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for TestHostSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ let outcome = *self
+ .0
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let signature_hex = (outcome == HostSigningOutcome::Signed).then(|| {
+ let mut secret_bytes = [0; 32];
+ secret_bytes[31] = 1;
+ let secret = SecretKey::from_slice(&secret_bytes).expect("fixture secret key");
+ let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .clone()
+ .try_into()
+ .expect("32-byte event ID digest");
+ assert_eq!(hex::encode(digest), request.expected_event_id);
+ assert_eq!(
+ keypair.x_only_public_key().0.to_string(),
+ request.public_key
+ );
+ let message = Message::from_digest(digest);
+ let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&message, &keypair);
+ Secp256k1::new()
+ .verify_schnorr(&signature, &message, &keypair.x_only_public_key().0)
+ .expect("fixture host signature verifies");
+ signature.to_string()
+ });
+ let completed_at_unix_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_millis()
+ .try_into()
+ .expect("current time fits u64");
+ assert!(completed_at_unix_ms < request.deadline_unix_ms);
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex,
+ completed_at_unix_ms,
+ }
+ }
+}
+
+#[test]
+fn swift_module_names_bind_the_tera_producer() {
+ let config = include_str!("../uniffi.toml");
+ assert_eq!(
+ config,
+ "[bindings.swift]\nmodule_name = \"TeraKitBindings\"\nffi_module_name = \"TeraFFI\"\n"
+ );
+}
+
+#[test]
+fn final_evidence_vocabularies_are_exact_at_the_mobile_boundary() {
+ assert_eq!(
+ [
+ FfiTradeEvidenceCoverage::Missing,
+ FfiTradeEvidenceCoverage::Partial,
+ FfiTradeEvidenceCoverage::ScopeSatisfied,
+ FfiTradeEvidenceCoverage::Unsupported,
+ ]
+ .len(),
+ 4
+ );
+ assert_eq!(
+ [
+ FfiTradeEvidenceOutcome::Valid,
+ FfiTradeEvidenceOutcome::Invalid,
+ FfiTradeEvidenceOutcome::Indeterminate,
+ ]
+ .len(),
+ 3
+ );
+}
+
+#[test]
+fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() {
+ let operation = FfiMediaOperation::new().expect("media operation");
+ let operation_id = operation.operation_id();
+ assert_eq!(operation_id.len(), 32);
+ assert!(!operation.is_cancelled());
+ operation.cancel();
+ assert!(operation.is_cancelled());
+ assert_eq!(operation.operation_id(), operation_id);
+}
+
+#[tokio::test]
+async fn protected_data_failure_is_typed_and_opens_no_store() {
+ let root = tempfile::tempdir().expect("tempdir");
+ support::prepare(root.path());
+ let result = RadrootsRuntime::new(
+ root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Unavailable,
+ )
+ .await;
+ let Err(RadrootsAppError::Failure { report }) = result else {
+ panic!("protected data failure must remain typed across UniFFI");
+ };
+ assert_eq!(report.code, "protected_data_unavailable");
+ assert!(report.retryable);
+ assert!(
+ !root
+ .path()
+ .join("radroots/users")
+ .join(support::PUBLIC_KEY)
+ .join("runtime.sqlite")
+ .exists()
+ );
+}
+
+#[tokio::test]
+async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() {
+ let (_root, runtime) = support::runtime().await;
+ let storage = runtime.sdk_storage_status().await.expect("storage status");
+ assert_eq!(storage.backend, "sqlite");
+
+ runtime.shutdown().await.expect("shutdown");
+ let error = runtime
+ .sdk_storage_status()
+ .await
+ .expect_err("closed client must reject operations");
+ let RadrootsAppError::Failure { report } = error;
+ assert_eq!(report.schema_version, 1);
+ assert_eq!(report.code, "client_closed");
+ assert_eq!(report.category, "runtime");
+ assert!(!report.retryable);
+ assert_eq!(report.safe_message, "SDK client is closed");
+}
+
+#[tokio::test]
+async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() {
+ let root = tempfile::tempdir().expect("tempdir");
+ support::prepare(root.path());
+ let outcome = Arc::new(Mutex::new(HostSigningOutcome::Rejected));
+ let runtime = RadrootsRuntime::with_host_signer(
+ root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(TestHostSigner(Arc::clone(&outcome))),
+ )
+ .await
+ .expect("runtime with host signer");
+
+ let identity = runtime.identity_status().expect("identity status");
+ assert_eq!(identity.public_key, support::PUBLIC_KEY);
+ assert!(identity.host_signer_configured);
+
+ for (index, host_outcome) in [
+ HostSigningOutcome::Signed,
+ HostSigningOutcome::Locked,
+ HostSigningOutcome::Cancelled,
+ HostSigningOutcome::Rejected,
+ HostSigningOutcome::TimedOut,
+ HostSigningOutcome::Unavailable,
+ HostSigningOutcome::Invalidated,
+ HostSigningOutcome::Failed,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ *outcome
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = host_outcome;
+ let draft_id = format!("{:02x}", index + 17).repeat(16);
+ let saved = runtime
+ .phase1_save_draft(
+ draft_id.clone(),
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: format!("Signer boundary test {index}"),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ },
+ 1_800_000_000 + index as u64,
+ None,
+ 1_800_000_000_000 + index as u64,
+ )
+ .await
+ .expect("saved draft");
+ let queued = runtime
+ .phase1_queue_draft(
+ draft_id.clone(),
+ saved.revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ satisfaction: FfiRelaySatisfaction::AnyAccepted,
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::PreservePublishedRequest,
+ },
+ 1_800_000_001_000 + index as u64,
+ )
+ .await
+ .expect("queued draft");
+ let signing_result = runtime
+ .phase1_sign_queued_draft(draft_id, queued.revision)
+ .await;
+ if host_outcome == HostSigningOutcome::Signed {
+ assert_eq!(
+ signing_result.expect("valid host signature").state,
+ tera_ffi::FfiOutboxState::Signed
+ );
+ } else {
+ let signing_error = signing_result.expect_err("host failure remains typed");
+ assert_eq!(signing_error.report().category, "authoring");
+ assert!(!signing_error.report().safe_message.contains("signature"));
+ }
+ }
+ runtime.shutdown().await.expect("shutdown");
+}
diff --git a/core/crates/tera_ffi/uniffi.toml b/core/crates/tera_ffi/uniffi.toml
@@ -0,0 +1,3 @@
+[bindings.swift]
+module_name = "TeraKitBindings"
+ffi_module_name = "TeraFFI"
diff --git a/core/provenance/tera_ffi/LICENSE-APACHE b/core/provenance/tera_ffi/LICENSE-APACHE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright 2025 Tyson Lupul
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/core/provenance/tera_ffi/LICENSE-GPL-3.0-only b/core/provenance/tera_ffi/LICENSE-GPL-3.0-only
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/core/provenance/tera_ffi/LICENSE-GPL-3.0-or-later b/core/provenance/tera_ffi/LICENSE-GPL-3.0-or-later
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/core/provenance/tera_ffi/LICENSE-MIT b/core/provenance/tera_ffi/LICENSE-MIT
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2025 Tyson Lupul
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/core/test-fixtures/tera_ffi/authored_operations.v1.json b/core/test-fixtures/tera_ffi/authored_operations.v1.json
@@ -0,0 +1,765 @@
+{
+ "suite": "authored_operations_wire",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "typed_profile_optional_escaping_001",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.profile.metadata.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 0,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [],
+ "content": "{\"name\":\"Alice \\\"Sprout\\\"\",\"display_name\":\"Alice's Orchard\",\"about\":\"Tree fruit\\nDirect from the farm\",\"bot\":false}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,0,[],\"{\\\"name\\\":\\\"Alice \\\\\\\"Sprout\\\\\\\"\\\",\\\"display_name\\\":\\\"Alice's Orchard\\\",\\\"about\\\":\\\"Tree fruit\\\\nDirect from the farm\\\",\\\"bot\\\":false}\"]",
+ "event_id": "cd7c7d681ca7c11536da8008ca404cca394d68c9217d943939b385784d7327e8",
+ "signature": "c07b2e5fade95ad0cb9c0ffe58812afb359d0fed77af57d532eb68c15f40d0e07a0231daf681de5309da4baafa679f3c07fc93123fe772c9a76f4268c3ee9cb9",
+ "raw_json": "{\"id\":\"cd7c7d681ca7c11536da8008ca404cca394d68c9217d943939b385784d7327e8\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"Alice \\\\\\\"Sprout\\\\\\\"\\\",\\\"display_name\\\":\\\"Alice's Orchard\\\",\\\"about\\\":\\\"Tree fruit\\\\nDirect from the farm\\\",\\\"bot\\\":false}\",\"sig\":\"c07b2e5fade95ad0cb9c0ffe58812afb359d0fed77af57d532eb68c15f40d0e07a0231daf681de5309da4baafa679f3c07fc93123fe772c9a76f4268c3ee9cb9\"}"
+ }
+ },
+ {
+ "id": "typed_update_escaping_002",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.update.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 1,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [],
+ "content": "Farm update: \"ready\"\\\n🍓",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[],\"Farm update: \\\"ready\\\"\\\\\\n🍓\"]",
+ "event_id": "11bcbaeab205194e26ae4d950190c03d18edb1b65f428048e589e4bbdcfa9d50",
+ "signature": "a6a323774f1ce4aafa6c479e758eb350a7e5c4bdc55c7690beba2ccb1631839a1246a83e62f4b29e74f9afda62802794981570e10c7d4ad41c392dab4066b88c",
+ "raw_json": "{\"id\":\"11bcbaeab205194e26ae4d950190c03d18edb1b65f428048e589e4bbdcfa9d50\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[],\"content\":\"Farm update: \\\"ready\\\"\\\\\\n🍓\",\"sig\":\"a6a323774f1ce4aafa6c479e758eb350a7e5c4bdc55c7690beba2ccb1631839a1246a83e62f4b29e74f9afda62802794981570e10c7d4ad41c392dab4066b88c\"}"
+ }
+ },
+ {
+ "id": "typed_photo_update_imeta_003",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.photo_update.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 1,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "imeta",
+ "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp",
+ "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276",
+ "m image/webp",
+ "dim 1200x900",
+ "size 12",
+ "alt Harvest"
+ ]
+ ],
+ "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\"]",
+ "event_id": "de986339a659b8586390a7e64a59f67dc7f2702c3882fa8677e17ea0cbf99847",
+ "signature": "d38dfb534c2df4e9b62165cf7e557e060bf024f9b1e44794438924511620750533dd76c307f06ceeae5c852fcc9293e9d61e83bded9c432f50a2886ba59b7817",
+ "raw_json": "{\"id\":\"de986339a659b8586390a7e64a59f67dc7f2702c3882fa8677e17ea0cbf99847\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"content\":\"Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"sig\":\"d38dfb534c2df4e9b62165cf7e557e060bf024f9b1e44794438924511620750533dd76c307f06ceeae5c852fcc9293e9d61e83bded9c432f50a2886ba59b7817\"}"
+ }
+ },
+ {
+ "id": "typed_ask_marker_order_004",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.ask.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 1,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "t",
+ "radroots-ask"
+ ],
+ [
+ "imeta",
+ "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp",
+ "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276",
+ "m image/webp",
+ "dim 1200x900",
+ "size 12",
+ "alt Harvest"
+ ]
+ ],
+ "content": "Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\"]",
+ "event_id": "21b3d75acd9c78b6caeacd2c14dc8371ca1348ec688b1bf2b606199c73e7155b",
+ "signature": "e90f3919f0045527bc131fb9f5c59708ce62074f78c6a3e7912e1da8647b1765ab513881c0894c9814c7a2ec574d1fd0d09565d06aa559d3efe6a3785c76bf38",
+ "raw_json": "{\"id\":\"21b3d75acd9c78b6caeacd2c14dc8371ca1348ec688b1bf2b606199c73e7155b\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"content\":\"Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"sig\":\"e90f3919f0045527bc131fb9f5c59708ce62074f78c6a3e7912e1da8647b1765ab513881c0894c9814c7a2ec574d1fd0d09565d06aa559d3efe6a3785c76bf38\"}"
+ }
+ },
+ {
+ "id": "typed_nip10_direct_reply_005",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.reply.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 1,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "e",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "wss://relay.example.com",
+ "root"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "Direct reply",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"Direct reply\"]",
+ "event_id": "89ce87ff86cd157d115d23ea63ba9f9323525af4a1035edc58407a2733efb413",
+ "signature": "3d012eae90eed5b0b00a0ce91c3779ad72bc91233b33fe520c588479619060ab6271f06933ac50d92a0e7dfffa91c41c7ad1291b4310166fb4a58a469bc4dfec",
+ "raw_json": "{\"id\":\"89ce87ff86cd157d115d23ea63ba9f9323525af4a1035edc58407a2733efb413\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"3d012eae90eed5b0b00a0ce91c3779ad72bc91233b33fe520c588479619060ab6271f06933ac50d92a0e7dfffa91c41c7ad1291b4310166fb4a58a469bc4dfec\"}"
+ }
+ },
+ {
+ "id": "typed_nip09_sorted_targets_006",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.deletion_request.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 5,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "e",
+ "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+ ],
+ [
+ "a",
+ "0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:"
+ ],
+ [
+ "a",
+ "30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots"
+ ],
+ [
+ "k",
+ "0"
+ ],
+ [
+ "k",
+ "1"
+ ],
+ [
+ "k",
+ "30402"
+ ]
+ ],
+ "content": "superseded",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,5,[[\"e\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"],[\"a\",\"0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots\"],[\"k\",\"0\"],[\"k\",\"1\"],[\"k\",\"30402\"]],\"superseded\"]",
+ "event_id": "f8f3e07f23928f32be533b6a3b013eb15dd0ed2ecca851774461a4e4081acfa3",
+ "signature": "cf54061a7224e13cc54880ad0e64d95e971eb48b9f08f96de7fac68b5c2056e9f737253cdb15e61a566bb503142c1992abdb025ccadf08634efc7e623514aa0e",
+ "raw_json": "{\"id\":\"f8f3e07f23928f32be533b6a3b013eb15dd0ed2ecca851774461a4e4081acfa3\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":5,\"tags\":[[\"e\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"],[\"a\",\"0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots\"],[\"k\",\"0\"],[\"k\",\"1\"],[\"k\",\"30402\"]],\"content\":\"superseded\",\"sig\":\"cf54061a7224e13cc54880ad0e64d95e971eb48b9f08f96de7fac68b5c2056e9f737253cdb15e61a566bb503142c1992abdb025ccadf08634efc7e623514aa0e\"}"
+ }
+ },
+ {
+ "id": "typed_nip22_top_level_event_007",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.social.comment.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 1111,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "E",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "wss://relay.example.com",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ],
+ [
+ "K",
+ "30402"
+ ],
+ [
+ "P",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
+ "wss://relay.example.com"
+ ],
+ [
+ "e",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "wss://relay.example.com",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ],
+ [
+ "k",
+ "30402"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af",
+ "wss://relay.example.com"
+ ]
+ ],
+ "content": "Are these carrots available Saturday?",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1111,[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"]],\"Are these carrots available Saturday?\"]",
+ "event_id": "a3d324a8eef83c53168ed0635d416a6152575f6882a1e67984a3d62144aa1789",
+ "signature": "c6e72dbbb54c7d1230257f5cb40a859461a6ad538ae689cc9794c5dfa28f5d8a031bfcd04a156769fbffd35832f5ee05fd1e01bea20776f8d5a227ff7fbffcba",
+ "raw_json": "{\"id\":\"a3d324a8eef83c53168ed0635d416a6152575f6882a1e67984a3d62144aa1789\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"c6e72dbbb54c7d1230257f5cb40a859461a6ad538ae689cc9794c5dfa28f5d8a031bfcd04a156769fbffd35832f5ee05fd1e01bea20776f8d5a227ff7fbffcba\"}"
+ }
+ },
+ {
+ "id": "typed_food_availability_optional_quantity_008",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.food.availability.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 30402,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "d",
+ "nantes-carrots"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "summary",
+ "Fresh bunches"
+ ],
+ [
+ "published_at",
+ "1784347100"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "price",
+ "3",
+ "CAD"
+ ],
+ [
+ "radroots:price_unit",
+ "lb"
+ ],
+ [
+ "radroots:quantity",
+ "24",
+ "lb"
+ ],
+ [
+ "status",
+ "active"
+ ]
+ ],
+ "content": "Carrots available this week.",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,30402,[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347100\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"]],\"Carrots available this week.\"]",
+ "event_id": "46e6584b2a23a96bad5d899d4df2c7803dfa363a9ecf95e389ecb935b512f454",
+ "signature": "e159b747ba94215066bb0a6e3845d828204b528551722a76e7d4a867f47f2fd4ff87abf6bbc9c90eab1f4e265d43fcadf2a5afb494bb90f73183f14ff637e98b",
+ "raw_json": "{\"id\":\"46e6584b2a23a96bad5d899d4df2c7803dfa363a9ecf95e389ecb935b512f454\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347100\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"]],\"content\":\"Carrots available this week.\",\"sig\":\"e159b747ba94215066bb0a6e3845d828204b528551722a76e7d4a867f47f2fd4ff87abf6bbc9c90eab1f4e265d43fcadf2a5afb494bb90f73183f14ff637e98b\"}"
+ }
+ },
+ {
+ "id": "typed_calendar_date_event_011",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.calendar.date_event.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 31922,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "d",
+ "market-day"
+ ],
+ [
+ "title",
+ "Saturday Market"
+ ],
+ [
+ "start",
+ "2026-08-08"
+ ],
+ [
+ "end",
+ "2026-08-09"
+ ],
+ [
+ "location",
+ "Central Saanich, BC"
+ ],
+ [
+ "summary",
+ "Local food and seeds"
+ ]
+ ],
+ "content": "Farmers market and seed swap.",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31922,[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"Farmers market and seed swap.\"]",
+ "event_id": "b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6",
+ "signature": "0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469",
+ "raw_json": "{\"id\":\"b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"content\":\"Farmers market and seed swap.\",\"sig\":\"0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469\"}"
+ }
+ },
+ {
+ "id": "typed_calendar_time_event_012",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.calendar.time_event.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 31923,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "d",
+ "farm-tour"
+ ],
+ [
+ "title",
+ "Farm Tour"
+ ],
+ [
+ "start",
+ "1784380800"
+ ],
+ [
+ "end",
+ "1784387900"
+ ],
+ [
+ "D",
+ "20652"
+ ],
+ [
+ "start_tzid",
+ "America/Vancouver"
+ ],
+ [
+ "location",
+ "Saanich Peninsula"
+ ]
+ ],
+ "content": "Walk the fields and meet the growers.",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31923,[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"Walk the fields and meet the growers.\"]",
+ "event_id": "22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433",
+ "signature": "ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5",
+ "raw_json": "{\"id\":\"22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"content\":\"Walk the fields and meet the growers.\",\"sig\":\"ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5\"}"
+ }
+ },
+ {
+ "id": "generic_operational_listing_009",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.operational_listing.published.v1",
+ "authoring": "generic"
+ },
+ "expected": {
+ "kind": 30402,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "d",
+ "AAAAAAAAAAAAAAAAAAAAAg"
+ ],
+ [
+ "p",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ],
+ [
+ "a",
+ "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA"
+ ],
+ [
+ "key",
+ "carrot-nantes"
+ ],
+ [
+ "title",
+ "Nantes Carrots"
+ ],
+ [
+ "category",
+ "produce"
+ ],
+ [
+ "summary",
+ "Fresh bunches harvested in Saanich"
+ ],
+ [
+ "published_at",
+ "1700000000"
+ ],
+ [
+ "radroots:primary_bin",
+ "bunch"
+ ],
+ [
+ "radroots:bin",
+ "bunch",
+ "1",
+ "each"
+ ],
+ [
+ "radroots:price",
+ "bunch",
+ "4",
+ "CAD",
+ "1",
+ "each"
+ ],
+ [
+ "price",
+ "4",
+ "CAD"
+ ],
+ [
+ "inventory",
+ "24"
+ ],
+ [
+ "status",
+ "active"
+ ],
+ [
+ "delivery",
+ "pickup"
+ ],
+ [
+ "location",
+ "Saanich Peninsula",
+ "Victoria",
+ "BC",
+ "CA"
+ ],
+ [
+ "g",
+ "c28hr"
+ ]
+ ],
+ "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,30402,[[\"d\",\"AAAAAAAAAAAAAAAAAAAAAg\"],[\"p\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA\"],[\"key\",\"carrot-nantes\"],[\"title\",\"Nantes Carrots\"],[\"category\",\"produce\"],[\"summary\",\"Fresh bunches harvested in Saanich\"],[\"published_at\",\"1700000000\"],[\"radroots:primary_bin\",\"bunch\"],[\"radroots:bin\",\"bunch\",\"1\",\"each\"],[\"radroots:price\",\"bunch\",\"4\",\"CAD\",\"1\",\"each\"],[\"price\",\"4\",\"CAD\"],[\"inventory\",\"24\"],[\"status\",\"active\"],[\"delivery\",\"pickup\"],[\"location\",\"Saanich Peninsula\",\"Victoria\",\"BC\",\"CA\"],[\"g\",\"c28hr\"]],\"# Nantes Carrots\\n\\nFresh bunches harvested in Saanich\"]",
+ "event_id": "da14c35c4afe472a2ddef6d7298cc736782eaf09b55511c3b5774ad79468ada8",
+ "signature": "07edaeab0b05807d4987346c95fd2441b46949be03f455bfbb3678c07b50fa95fb563b509b33f5d547e7cb74f09475c04e281c66362a8db206e57ee757d60dbe",
+ "raw_json": "{\"id\":\"da14c35c4afe472a2ddef6d7298cc736782eaf09b55511c3b5774ad79468ada8\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"AAAAAAAAAAAAAAAAAAAAAg\"],[\"p\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA\"],[\"key\",\"carrot-nantes\"],[\"title\",\"Nantes Carrots\"],[\"category\",\"produce\"],[\"summary\",\"Fresh bunches harvested in Saanich\"],[\"published_at\",\"1700000000\"],[\"radroots:primary_bin\",\"bunch\"],[\"radroots:bin\",\"bunch\",\"1\",\"each\"],[\"radroots:price\",\"bunch\",\"4\",\"CAD\",\"1\",\"each\"],[\"price\",\"4\",\"CAD\"],[\"inventory\",\"24\"],[\"status\",\"active\"],[\"delivery\",\"pickup\"],[\"location\",\"Saanich Peninsula\",\"Victoria\",\"BC\",\"CA\"],[\"g\",\"c28hr\"]],\"content\":\"# Nantes Carrots\\n\\nFresh bunches harvested in Saanich\",\"sig\":\"07edaeab0b05807d4987346c95fd2441b46949be03f455bfbb3678c07b50fa95fb563b509b33f5d547e7cb74f09475c04e281c66362a8db206e57ee757d60dbe\"}"
+ }
+ },
+ {
+ "id": "typed_trade_proposal_010",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.trade.proposal.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3470,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.trade.proposal.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "mutation"
+ ],
+ [
+ "p",
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate\":{\"base_candidate_id\":null,\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"cancellation\":{\"buyer_pre_agreement\":true,\"post_agreement_cutoff_unix_s\":null,\"profile_id\":\"buyer-pre-agreement\"},\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"economics\":{\"adjustment_total_mantissa\":\"0\",\"adjustments\":[],\"currency_code\":\"USD\",\"currency_exponent\":2,\"discount_total_mantissa\":\"0\",\"profile_id\":\"mvp-fixed\",\"rounding_profile\":\"half-even\",\"subtotal_mantissa\":\"1000\",\"total_mantissa\":\"1000\"},\"farm_id\":\"farm-1\",\"fulfillment\":{\"ends_at_unix_s\":1800003600,\"fold\":0,\"location_class\":\"farmstand\",\"method\":\"pickup\",\"profile_id\":\"market-pickup\",\"requires_private_terms\":false,\"starts_at_unix_s\":1800000000,\"timezone\":\"America/New_York\",\"utc_offset_seconds\":-18000},\"line_tombstones\":[],\"lines\":[{\"bin_id\":\"bin-1\",\"currency_code\":\"USD\",\"line_id\":\"line-1\",\"line_subtotal_mantissa\":\"1000\",\"listing_addr\":\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\",\"listing_event_id\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"listing_snapshot_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"option_id\":null,\"product_id\":\"carrots\",\"quantity_mantissa\":\"2\",\"quantity_scale\":0,\"replaces_line_id\":null,\"unit_code\":\"count\",\"unit_price_mantissa\":\"500\",\"unit_profile\":\"mvp-count\"}],\"private_terms\":null,\"proposal_expires_at_unix_s\":1799999000,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"supersession_intent\":null},\"mutation_type\":\"proposal\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.proposal.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent_mutation_ids\":[],\"root_mutation_id\":null,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3470,[[\"contract\",\"radroots.trade.proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"mutation\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"parent_mutation_ids\\\":[],\\\"root_mutation_id\\\":null,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a",
+ "signature": "b39fc5d285826bda6462dc9fb8c20c6519b54d4c8ac9673f3c53c6424e2e1703762b4126282e579dcb11c6738477738a61396595fb6c13331e038c3b06b18517",
+ "raw_json": "{\"id\":\"58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3470,\"tags\":[[\"contract\",\"radroots.trade.proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"mutation\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"parent_mutation_ids\\\":[],\\\"root_mutation_id\\\":null,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"b39fc5d285826bda6462dc9fb8c20c6519b54d4c8ac9673f3c53c6424e2e1703762b4126282e579dcb11c6738477738a61396595fb6c13331e038c3b06b18517\"}"
+ }
+ },
+ {
+ "id": "typed_trade_decision_013",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.trade.decision.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3471,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.trade.decision.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027",
+ "mutation"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "root"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "parent"
+ ],
+ [
+ "p",
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"decision\":{\"decision\":\"declined\",\"reason\":\"inventory unavailable\"},\"mutation_type\":\"decision\",\"proposal_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.decision.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3471,[[\"contract\",\"radroots.trade.decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "7dbe947cbeb1cde706bb240657fee585aa4214baa61d728b7bec7ac5ee18350e",
+ "signature": "222a04c15f66c6028c5b6619116ae92958a5e853293697f2fdbe0450300a50f9bfd2f1f56f1ce3eb2bd037d938bf22f6d99ecdb924de8c2fd5f0da846c1d2817",
+ "raw_json": "{\"id\":\"7dbe947cbeb1cde706bb240657fee585aa4214baa61d728b7bec7ac5ee18350e\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3471,\"tags\":[[\"contract\",\"radroots.trade.decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"222a04c15f66c6028c5b6619116ae92958a5e853293697f2fdbe0450300a50f9bfd2f1f56f1ce3eb2bd037d938bf22f6d99ecdb924de8c2fd5f0da846c1d2817\"}"
+ }
+ },
+ {
+ "id": "typed_trade_revision_proposal_014",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.trade.revision_proposal.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3472,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.trade.revision_proposal.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847",
+ "mutation"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "root"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "parent"
+ ],
+ [
+ "p",
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate\":{\"base_candidate_id\":null,\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"cancellation\":{\"buyer_pre_agreement\":true,\"post_agreement_cutoff_unix_s\":null,\"profile_id\":\"buyer-pre-agreement\"},\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"economics\":{\"adjustment_total_mantissa\":\"0\",\"adjustments\":[],\"currency_code\":\"USD\",\"currency_exponent\":2,\"discount_total_mantissa\":\"0\",\"profile_id\":\"mvp-fixed\",\"rounding_profile\":\"half-even\",\"subtotal_mantissa\":\"1000\",\"total_mantissa\":\"1000\"},\"farm_id\":\"farm-1\",\"fulfillment\":{\"ends_at_unix_s\":1800003600,\"fold\":0,\"location_class\":\"farmstand\",\"method\":\"pickup\",\"profile_id\":\"market-pickup\",\"requires_private_terms\":false,\"starts_at_unix_s\":1800000000,\"timezone\":\"America/New_York\",\"utc_offset_seconds\":-18000},\"line_tombstones\":[],\"lines\":[{\"bin_id\":\"bin-1\",\"currency_code\":\"USD\",\"line_id\":\"line-1\",\"line_subtotal_mantissa\":\"1000\",\"listing_addr\":\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\",\"listing_event_id\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"listing_snapshot_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"option_id\":null,\"product_id\":\"carrots\",\"quantity_mantissa\":\"2\",\"quantity_scale\":0,\"replaces_line_id\":null,\"unit_code\":\"count\",\"unit_price_mantissa\":\"500\",\"unit_profile\":\"mvp-count\"}],\"private_terms\":null,\"proposal_expires_at_unix_s\":1799999000,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"supersession_intent\":null},\"mutation_type\":\"revision_proposal\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.revision_proposal.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3472,[[\"contract\",\"radroots.trade.revision_proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"revision_proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "3be0e2a2f3f9eef21127eed0dab79ce31932e05927d6558999c5a50022d2eefe",
+ "signature": "777eb26ba6b01ee154a006907cb99ad43dace95208138a30dc48d36419c93f8e1e256d3707eb28a0dd42ed50ed3954b803656f83b0259afae02bb86cc7ae3742",
+ "raw_json": "{\"id\":\"3be0e2a2f3f9eef21127eed0dab79ce31932e05927d6558999c5a50022d2eefe\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3472,\"tags\":[[\"contract\",\"radroots.trade.revision_proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"revision_proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"777eb26ba6b01ee154a006907cb99ad43dace95208138a30dc48d36419c93f8e1e256d3707eb28a0dd42ed50ed3954b803656f83b0259afae02bb86cc7ae3742\"}"
+ }
+ },
+ {
+ "id": "typed_trade_revision_decision_015",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.trade.revision_decision.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3473,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.trade.revision_decision.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732",
+ "mutation"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "root"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "parent"
+ ],
+ [
+ "p",
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"decision\":{\"decision\":\"declined\",\"reason\":\"inventory unavailable\"},\"mutation_type\":\"revision_decision\",\"proposal_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.revision_decision.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3473,[[\"contract\",\"radroots.trade.revision_decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"revision_decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "064a6208b3a064e472fd8f1c4d56c9ca49513693d1343feb0ceeb7a8814f93b6",
+ "signature": "60a9241287ba780bcccc8b3b5c85c102bfab40fa6898990f420d634f3eba01c4b9a76a6ad9efed64d111f1e39d5b5d923810c6cd2793f71d2bd19cd7fb21cdf3",
+ "raw_json": "{\"id\":\"064a6208b3a064e472fd8f1c4d56c9ca49513693d1343feb0ceeb7a8814f93b6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3473,\"tags\":[[\"contract\",\"radroots.trade.revision_decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"revision_decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"60a9241287ba780bcccc8b3b5c85c102bfab40fa6898990f420d634f3eba01c4b9a76a6ad9efed64d111f1e39d5b5d923810c6cd2793f71d2bd19cd7fb21cdf3\"}"
+ }
+ },
+ {
+ "id": "typed_trade_cancellation_016",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.trade.cancellation.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3474,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.trade.cancellation.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d",
+ "mutation"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "root"
+ ],
+ [
+ "x",
+ "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799",
+ "parent"
+ ],
+ [
+ "p",
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+ ],
+ [
+ "p",
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"
+ ]
+ ],
+ "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"mutation_type\":\"cancellation\",\"reason\":\"cancelled\",\"target_candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"target_claim_mutation_id\":null},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.cancellation.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3474,[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a",
+ "signature": "141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36",
+ "raw_json": "{\"id\":\"2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3474,\"tags\":[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36\"}"
+ }
+ },
+ {
+ "id": "typed_rhi_evidence_attestation_017",
+ "kind": "authored_operations.wire",
+ "input": {
+ "contract_id": "radroots.rhi.evidence_attestation.v1",
+ "authoring": "typed"
+ },
+ "expected": {
+ "kind": 3441,
+ "created_at": 1784347200,
+ "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df",
+ "tags": [
+ [
+ "contract",
+ "radroots.rhi.evidence_attestation.v1"
+ ],
+ [
+ "d",
+ "11111111111111111111111111111111"
+ ],
+ [
+ "x",
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ "claim"
+ ],
+ [
+ "x",
+ "254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30",
+ "statement"
+ ],
+ [
+ "t",
+ "radroots:rhi-outcome:indeterminate"
+ ]
+ ],
+ "content": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}",
+ "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3441,[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]",
+ "event_id": "a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce",
+ "signature": "8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063",
+ "raw_json": "{\"id\":\"a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3441,\"tags\":[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"content\":\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063\"}"
+ }
+ }
+ ]
+}
diff --git a/core/test-fixtures/tera_ffi/evidence_attestation_decision.v1.json b/core/test-fixtures/tera_ffi/evidence_attestation_decision.v1.json
@@ -0,0 +1,153 @@
+{
+ "suite": "rhi_evidence_attestation_decision",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "rhi_evidence_attestation_current_001",
+ "kind": "rhi.evidence_attestation.valid",
+ "input": {
+ "statement_payload": {
+ "attestation_method": "signed_evidence_snapshot",
+ "claim_mutation_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "contract_id": "radroots.rhi.evidence_attestation.v1",
+ "contract_version": 1,
+ "evidence_manifest_digest": "4444444444444444444444444444444444444444444444444444444444444444",
+ "evidence_policy_digest": "5555555555555555555555555555555555555555555555555555555555555555",
+ "issuer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "observed_at_unix_s": 1800000000,
+ "outcome": "indeterminate",
+ "projection_digest": "6666666666666666666666666666666666666666666666666666666666666666",
+ "reason_codes": ["required_source_incomplete"],
+ "reducer_contract_id": "radroots.trade.reducer.v1",
+ "reducer_contract_version": 1,
+ "supersedes_event_id": null,
+ "supersedes_report_id": null,
+ "trade_generation": 7,
+ "trade_id": "11111111111111111111111111111111"
+ }
+ },
+ "expected": {
+ "kind": 3441,
+ "event_class": "regular_immutable",
+ "canonical_statement_payload_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}",
+ "statement_digest": "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44",
+ "report_id": "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44",
+ "canonical_event_content_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"statement_digest\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}",
+ "tags": [
+ ["contract", "radroots.rhi.evidence_attestation.v1"],
+ ["d", "11111111111111111111111111111111"],
+ ["x", "2222222222222222222222222222222222222222222222222222222222222222", "claim"],
+ ["x", "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44", "statement"],
+ ["t", "radroots:rhi-outcome:indeterminate"]
+ ]
+ }
+ },
+ {
+ "id": "rhi_evidence_attestation_superseding_002",
+ "kind": "rhi.evidence_attestation.valid",
+ "input": {
+ "statement_payload": {
+ "attestation_method": "signed_evidence_snapshot",
+ "claim_mutation_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "contract_id": "radroots.rhi.evidence_attestation.v1",
+ "contract_version": 1,
+ "evidence_manifest_digest": "4444444444444444444444444444444444444444444444444444444444444444",
+ "evidence_policy_digest": "5555555555555555555555555555555555555555555555555555555555555555",
+ "issuer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "observed_at_unix_s": 1800000100,
+ "outcome": "valid",
+ "projection_digest": "6666666666666666666666666666666666666666666666666666666666666666",
+ "reason_codes": ["scope_satisfied"],
+ "reducer_contract_id": "radroots.trade.reducer.v1",
+ "reducer_contract_version": 1,
+ "supersedes_event_id": "8888888888888888888888888888888888888888888888888888888888888888",
+ "supersedes_report_id": "7777777777777777777777777777777777777777777777777777777777777777",
+ "trade_generation": 8,
+ "trade_id": "11111111111111111111111111111111"
+ }
+ },
+ "expected": {
+ "canonical_statement_payload_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}",
+ "statement_digest": "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807",
+ "report_id": "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807",
+ "canonical_event_content_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"statement_digest\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}",
+ "tags": [
+ ["contract", "radroots.rhi.evidence_attestation.v1"],
+ ["d", "11111111111111111111111111111111"],
+ ["x", "2222222222222222222222222222222222222222222222222222222222222222", "claim"],
+ ["x", "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807", "statement"],
+ ["t", "radroots:rhi-outcome:valid"],
+ ["x", "7777777777777777777777777777777777777777777777777777777777777777", "supersedes_report"],
+ ["e", "8888888888888888888888888888888888888888888888888888888888888888"]
+ ],
+ "mutates_prior_report": false
+ }
+ },
+ {
+ "id": "rhi_evidence_attestation_wrong_kind_003",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "kind": 3440 },
+ "expected": { "layer": "wire", "error_code": "invalid_attestation_kind" }
+ },
+ {
+ "id": "rhi_evidence_attestation_wrong_author_004",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "event_author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
+ "expected": { "layer": "wire", "error_code": "issuer_author_mismatch" }
+ },
+ {
+ "id": "rhi_evidence_attestation_noncanonical_content_005",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "content_transform": "append_ascii_space" },
+ "expected": { "layer": "wire", "error_code": "noncanonical_report_content" }
+ },
+ {
+ "id": "rhi_evidence_attestation_digest_mismatch_006",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "statement_digest": "0000000000000000000000000000000000000000000000000000000000000000" },
+ "expected": { "layer": "wire", "error_code": "statement_digest_mismatch" }
+ },
+ {
+ "id": "rhi_evidence_attestation_unknown_outcome_007",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "outcome": "complete" },
+ "expected": { "layer": "wire", "error_code": "invalid_outcome" }
+ },
+ {
+ "id": "rhi_evidence_attestation_missing_claim_tag_008",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "remove_tag": ["x", "claim"] },
+ "expected": { "layer": "wire", "error_code": "missing_claim_tag" }
+ },
+ {
+ "id": "rhi_evidence_attestation_duplicate_trade_tag_009",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "append_tag": ["d", "99999999999999999999999999999999"] },
+ "expected": { "layer": "wire", "error_code": "duplicate_trade_tag" }
+ },
+ {
+ "id": "rhi_evidence_attestation_duplicate_statement_tag_010",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "append_tag": ["x", "9999999999999999999999999999999999999999999999999999999999999999", "statement"] },
+ "expected": { "layer": "wire", "error_code": "duplicate_statement_tag" }
+ },
+ {
+ "id": "rhi_evidence_attestation_incomplete_supersession_011",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_current_001", "supersedes_report_id": "7777777777777777777777777777777777777777777777777777777777777777", "supersedes_event_id": null },
+ "expected": { "layer": "wire", "error_code": "incomplete_supersession_reference" }
+ },
+ {
+ "id": "rhi_evidence_attestation_stale_supersession_012",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "base": "rhi_evidence_attestation_superseding_002", "current_trade_generation": 8, "candidate_trade_generation": 7 },
+ "expected": { "layer": "admission", "error_code": "stale_trade_generation" }
+ },
+ {
+ "id": "rhi_evidence_attestation_caller_structural_tag_013",
+ "kind": "rhi.evidence_attestation.invalid",
+ "input": { "builder_extra_tags": [["d", "11111111111111111111111111111111"]] },
+ "expected": { "layer": "builder", "error_code": "caller_structural_tag_forbidden" }
+ }
+ ]
+}
diff --git a/crates/source_lock/Cargo.toml b/crates/source_lock/Cargo.toml
@@ -12,12 +12,5 @@ publish = false
[dependencies]
radroots_mobile_ffi = { workspace = true }
-[dev-dependencies]
-async-trait = "=0.1.91"
-secp256k1 = { version = "=0.29.1", features = ["rand-std"] }
-serde_json = "=1.0.151"
-tempfile = "=3.27.0"
-tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread"] }
-
[lints]
workspace = true
diff --git a/crates/source_lock/tests/compatibility.rs b/crates/source_lock/tests/compatibility.rs
@@ -1,201 +0,0 @@
-//! Pre-transfer compatibility at the consumed FFI boundary. All stores are
-//! isolated fixtures; signer keys are generated in memory and never serialized.
-
-use radroots_mobile_ffi::{
- FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiDraftStatusRecord,
- FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest,
- HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner,
- RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord,
-};
-use secp256k1::{Keypair, Message, Secp256k1};
-use serde_json::Value;
-use std::path::Path;
-
-fn fixture() -> Value {
- serde_json::from_str(include_str!(
- "../../../test-fixtures/tera-compatibility.v1.json"
- ))
- .expect("checked synthetic fixture")
-}
-
-fn field<'a>(fixture: &'a Value, key: &str) -> &'a str {
- fixture[key].as_str().expect("fixture string")
-}
-
-fn input(fixture: &Value) -> FfiAddDraftInput {
- FfiAddDraftInput {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- command_type: FfiAddCommandType::CreateUpdate,
- content: field(fixture, "content").to_owned(),
- identifier: None,
- title: None,
- summary: None,
- location: None,
- event_timing: None,
- event_start_date: None,
- event_end_date: None,
- event_start_unix_s: None,
- event_end_unix_s: None,
- event_timezone: None,
- price_amount: None,
- currency: None,
- unit: None,
- quantity: None,
- food_published_at_unix_s: None,
- food_status: None,
- media: Vec::new(),
- }
-}
-
-async fn runtime(root: &Path, public_key: &str, signer: Option<Keypair>) -> RadrootsRuntime {
- std::fs::create_dir_all(root.join("radroots/users").join(public_key))
- .expect("isolated application owner directory");
- let fixture = fixture();
- let generation = field(&fixture["queued_update"], "source_generation").to_owned();
- if let Some(keypair) = signer {
- RadrootsRuntime::with_host_signer(
- root.to_string_lossy().into_owned(),
- public_key.to_owned(),
- generation,
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- Box::new(EphemeralSigner(keypair)),
- )
- .await
- .expect("runtime with ephemeral fixture signer")
- } else {
- RadrootsRuntime::new(
- root.to_string_lossy().into_owned(),
- public_key.to_owned(),
- generation,
- 1_800_000_000_000,
- ProtectedDataAvailability::Available,
- )
- .await
- .expect("runtime using current persistent reader")
- }
-}
-
-async fn queue(runtime: &RadrootsRuntime, fixture: &Value) -> FfiDraftStatusRecord {
- let id = field(fixture, "draft_id");
- let persisted = fixture["persisted_at_unix_ms"].as_u64().unwrap();
- let saved = runtime
- .phase1_save_draft(
- id.to_owned(),
- input(fixture),
- fixture["authored_at_unix_s"].as_u64().unwrap(),
- None,
- persisted,
- )
- .await
- .expect("save synthetic draft through real FFI");
- let policy = FfiQueuePolicyRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- relay_urls: fixture["relay_urls"]
- .as_array()
- .unwrap()
- .iter()
- .map(|value| value.as_str().unwrap().to_owned())
- .collect(),
- satisfaction: FfiRelaySatisfaction::AllAccepted,
- delivery_deadline_unix_ms: fixture["delivery_deadline_unix_ms"].as_u64().unwrap(),
- cancellation: FfiCancellationPolicy::LocalCooperative,
- };
- runtime
- .phase1_queue_draft(id.to_owned(), saved.revision, policy, persisted + 1)
- .await
- .expect("queue locally without starting relay delivery")
-}
-
-#[tokio::test]
-async fn queued_update_reopens_with_frozen_operation_and_card_identity() {
- let fixture = fixture()["queued_update"].clone();
- let root = tempfile::tempdir().unwrap();
- let public_key = field(&fixture, "public_key");
- let first = runtime(root.path(), public_key, None).await;
- let queued = queue(&first, &fixture).await;
- assert_eq!(
- queued.operation_id.as_deref(),
- Some(field(&fixture, "expected_operation_id"))
- );
- assert_eq!(queued.card_id, field(&fixture, "expected_card_id"));
- first.shutdown().await.unwrap();
- drop(first);
- let reopened = runtime(root.path(), public_key, None).await;
- let restored = reopened
- .phase1_draft_status(field(&fixture, "draft_id").to_owned())
- .await
- .expect("existing persisted draft reader");
- assert_eq!(restored, queued);
- let recovered = reopened
- .phase1_recover_draft_queue(field(&fixture, "draft_id").to_owned(), 1_900_000_000_002)
- .await
- .expect("existing queued operation reader");
- assert_eq!(recovered, queued);
- reopened.shutdown().await.unwrap();
-}
-
-#[tokio::test]
-async fn signed_operation_reopens_without_replacing_its_author_or_identity() {
- let fixture = fixture()["queued_update"].clone();
- let root = tempfile::tempdir().unwrap();
- let keypair = Keypair::new(&Secp256k1::new(), &mut secp256k1::rand::thread_rng());
- let public_key = keypair.x_only_public_key().0.to_string();
- let first = runtime(root.path(), &public_key, Some(keypair)).await;
- let queued = queue(&first, &fixture).await;
- let signed = first
- .phase1_sign_queued_draft(queued.draft_id.clone(), queued.revision)
- .await
- .expect("sign and durably admit the fixed operation");
- assert_eq!(signed.operation_id, queued.operation_id);
- assert_eq!(signed.card_id, queued.card_id);
- assert_eq!(signed.author_public_key, public_key);
- assert_eq!(signed.settlement.unwrap().signed, 1);
- first.shutdown().await.unwrap();
- drop(first);
- // Reopening has no signer. Reading must preserve the already signed facts.
- let reopened = runtime(root.path(), &public_key, None).await;
- let restored = reopened
- .phase1_draft_status(signed.draft_id.clone())
- .await
- .unwrap();
- assert_eq!(restored, signed);
- reopened.shutdown().await.unwrap();
-}
-
-struct EphemeralSigner(Keypair);
-
-#[async_trait::async_trait]
-impl RadrootsHostSigner for EphemeralSigner {
- async fn signer_status(&self) -> SignerStatusRecord {
- SignerStatusRecord {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- availability: SignerAvailabilityRecord::Ready,
- }
- }
-
- async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
- assert_eq!(request.public_key, self.0.x_only_public_key().0.to_string());
- let digest: [u8; 32] = request
- .event_id_digest
- .try_into()
- .expect("exact event digest");
- let signature =
- Secp256k1::new().sign_schnorr_no_aux_rand(&Message::from_digest(digest), &self.0);
- HostSigningResult {
- schema_version: MOBILE_FFI_SCHEMA_VERSION,
- outcome: HostSigningOutcome::Signed,
- operation_id: request.operation_id,
- signer_request_id: request.signer_request_id,
- public_key: request.public_key,
- purpose: request.purpose,
- signature_hex: Some(signature.to_string()),
- completed_at_unix_ms: std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_millis()
- .try_into()
- .unwrap(),
- }
- }
-}