commit ead4c3f7b0a2069a49f9eaaa5fbbddbd7aefb686
parent 7f679fa8e0d4c43ed6828f718cd1cd01a2821e68
Author: triesap <tyson@radroots.org>
Date: Tue, 8 Sep 2026 18:42:56 +0000
tera: own the shared application core locally
- preserve the verified core history and original license provenance
- wire tera_core into the sole workspace at the exact shared source pin
- adapt package identity and test imports without runtime behavior changes
- verify default and social core profiles plus frozen ffi compatibility
Diffstat:
33 files changed, 18402 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1365,6 +1365,7 @@ dependencies = [
"unicode-normalization",
"url",
"url-fork",
+ "zeroize",
]
[[package]]
@@ -1790,6 +1791,7 @@ dependencies = [
"radroots_event",
"radroots_event_codec",
"radroots_identity",
+ "radroots_signing",
"serde",
"serde_json",
"thiserror 2.0.19",
@@ -2581,6 +2583,35 @@ dependencies = [
]
[[package]]
+name = "tera_core"
+version = "0.1.0-alpha"
+dependencies = [
+ "chrono",
+ "hex",
+ "nostr",
+ "radroots_blossom",
+ "radroots_event",
+ "radroots_event_codec",
+ "radroots_identity",
+ "radroots_nostr",
+ "radroots_protocol",
+ "radroots_sdk",
+ "radroots_signing",
+ "radroots_storage",
+ "radroots_sync",
+ "radroots_transport",
+ "radroots_transport_nostr",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "thiserror 1.0.69",
+ "tokio",
+ "url",
+ "uuid",
+]
+
+[[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"]
+members = ["crates/source_lock", "core/crates/tera_core"]
resolver = "3"
[workspace.package]
@@ -9,6 +9,7 @@ rust-version = "1.97.1"
license = "GPL-3.0-or-later"
repository = "https://github.com/radrootslabs/tera"
homepage = "https://radroots.org"
+readme = "README.md"
authors = ["Tyson Lupul <tyson@radroots.org>"]
[workspace.lints.rust]
@@ -24,3 +25,26 @@ unimplemented = "deny"
[workspace.dependencies]
radroots_mobile_ffi = { git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", version = "=0.1.0-alpha" }
+chrono = { version = "0.4" }
+hex = { version = "0.4" }
+nostr = { version = "0.44.7", default-features = false }
+radroots_blossom = { package = "radroots_blossom", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_event = { package = "radroots_event", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_event_codec = { package = "radroots_event_codec", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_identity = { package = "radroots_identity", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_nostr = { package = "radroots_nostr", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_protocol = { package = "radroots_protocol", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_sdk = { version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_signing = { package = "radroots_signing", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_storage = { package = "radroots_storage", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_sync = { package = "radroots_sync", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_transport = { package = "radroots_transport", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+radroots_transport_nostr = { package = "radroots_transport_nostr", version = "=0.1.0-alpha", default-features = false, git = "https://github.com/radrootslabs/lib", rev = "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" }
+serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
+serde_json = { version = "1", default-features = false, features = ["alloc"] }
+sha2 = { version = "0.10", default-features = false }
+tempfile = { version = "3" }
+thiserror = { version = "1" }
+tokio = { version = "1" }
+url = { version = "2" }
+uuid = { version = "1.22.0", features = ["v4", "v7"] }
diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml
@@ -0,0 +1,78 @@
+[package]
+name = "tera_core"
+version = "0.1.0-alpha"
+edition.workspace = true
+authors = ["Radroots Authors"]
+rust-version.workspace = true
+license = "GPL-3.0-or-later"
+description = "Application core runtime for Radroots apps"
+repository.workspace = true
+homepage.workspace = true
+readme.workspace = true
+publish = false
+include = ["src/**", "tests/**", "build.rs", "Cargo.toml"]
+
+[lib]
+crate-type = ["rlib"]
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
+
+[features]
+default = []
+mobile-social = [
+ "radroots_sdk/blossom",
+ "radroots_sdk/nostr",
+ "radroots_sdk/sync",
+ "dep:tokio",
+]
+
+[dependencies]
+radroots_blossom = { workspace = true, default-features = false, features = [
+ "serde",
+ "std",
+] }
+radroots_sdk = { workspace = true, features = ["sqlite"] }
+radroots_event = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_event_codec = { workspace = true, default-features = false, features = [
+ "json",
+ "std",
+] }
+radroots_identity = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_protocol = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_signing = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_storage = { workspace = true, default-features = false }
+radroots_sync = { workspace = true, default-features = false }
+radroots_transport = { workspace = true, default-features = false, features = [
+ "std",
+] }
+radroots_transport_nostr = { workspace = true }
+chrono = { workspace = true }
+hex = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+sha2 = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true, optional = true, features = [
+ "fs",
+ "io-util",
+ "rt",
+ "sync",
+] }
+uuid = { workspace = true, features = ["v4"] }
+url = { workspace = true }
+
+[dev-dependencies]
+nostr = { workspace = true, features = ["std"] }
+radroots_nostr = { workspace = true, features = ["blossom", "signing"] }
+radroots_sdk = { workspace = true, features = ["memory", "sqlite"] }
+tempfile = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/core/crates/tera_core/build.rs b/core/crates/tera_core/build.rs
@@ -0,0 +1,59 @@
+use std::{env, process::Command};
+
+#[path = "src/provenance.rs"]
+mod provenance;
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-env-changed=RUSTC");
+ println!("cargo:rerun-if-env-changed=PROFILE");
+ println!("cargo:rerun-if-env-changed=RADROOTS_LIB_REVISION");
+ println!("cargo:rerun-if-env-changed=RADROOTS_CONSUMER_REVISION");
+ println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
+
+ let rustc = env::var("RUSTC").expect("missing required env var RUSTC");
+ if let Ok(output) = Command::new(rustc).arg("--version").output()
+ && output.status.success()
+ && let Ok(version) = String::from_utf8(output.stdout)
+ {
+ println!("cargo:rustc-env=RUSTC_VERSION={}", version.trim());
+ }
+
+ let lib_revision = optional_full_revision("RADROOTS_LIB_REVISION");
+ let consumer_revision = optional_full_revision("RADROOTS_CONSUMER_REVISION");
+ assert!(
+ consumer_revision.is_none() || lib_revision.is_some(),
+ "RADROOTS_CONSUMER_REVISION requires RADROOTS_LIB_REVISION"
+ );
+ if let Some(revision) = lib_revision {
+ println!("cargo:rustc-env=RADROOTS_LIB_REVISION={revision}");
+ }
+ if let Some(revision) = consumer_revision {
+ println!("cargo:rustc-env=RADROOTS_CONSUMER_REVISION={revision}");
+ }
+
+ let profile = env::var("PROFILE").expect("missing required env var PROFILE");
+ println!("cargo:rustc-env=PROFILE={profile}");
+
+ if let Some(epoch) = optional_source_date_epoch() {
+ println!("cargo:rustc-env=BUILD_TIME_UNIX={epoch}");
+ }
+}
+
+fn optional_full_revision(name: &str) -> Option<String> {
+ let value = env::var(name).ok()?;
+ assert!(
+ provenance::is_full_revision(&value),
+ "{name} must contain exactly 40 lowercase hexadecimal characters"
+ );
+ Some(value)
+}
+
+fn optional_source_date_epoch() -> Option<u64> {
+ let value = env::var("SOURCE_DATE_EPOCH").ok()?;
+ Some(
+ value
+ .parse()
+ .expect("SOURCE_DATE_EPOCH must be an unsigned Unix timestamp"),
+ )
+}
diff --git a/core/crates/tera_core/src/error.rs b/core/crates/tera_core/src/error.rs
@@ -0,0 +1,187 @@
+use thiserror::Error;
+
+/// Versioned, secret-safe SDK failure exposed to mobile hosts.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkErrorRecord {
+ pub schema_version: u16,
+ pub code: String,
+ pub class: String,
+ pub retryable: bool,
+ pub recovery_actions: Vec<String>,
+ pub operation_id: Option<String>,
+ pub capability_id: Option<String>,
+ pub message: String,
+}
+
+/// Versioned, path-redacted mobile store failure exposed to native hosts.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct StoreErrorRecord {
+ pub schema_version: u16,
+ pub code: String,
+ pub class: String,
+ pub retryable: bool,
+ pub recovery_actions: Vec<String>,
+ pub message: String,
+}
+
+#[derive(Debug, Error)]
+pub enum RadrootsAppError {
+ #[error("initialization: {0}")]
+ Initialization(String),
+ #[error("sdk: {report:?}")]
+ Sdk { report: SdkErrorRecord },
+ #[error("store: {report:?}")]
+ Store { report: StoreErrorRecord },
+ #[error("runtime: {0}")]
+ Runtime(String),
+ #[error("unsupported: {0}")]
+ Unsupported(String),
+ #[error("internal: {0}")]
+ Internal(String),
+}
+
+impl RadrootsAppError {
+ /// Returns the stable store report when this is a mobile storage failure.
+ pub const fn store_report(&self) -> Option<&StoreErrorRecord> {
+ match self {
+ Self::Store { report } => Some(report),
+ _ => None,
+ }
+ }
+
+ pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self {
+ let report = error.to_report();
+ Self::Sdk {
+ report: SdkErrorRecord {
+ schema_version: report.schema_version(),
+ code: report.code().as_str().to_owned(),
+ class: report.class().as_str().to_owned(),
+ retryable: report.retryable(),
+ recovery_actions: report
+ .recovery_actions()
+ .iter()
+ .map(|action| action.as_str().to_owned())
+ .collect(),
+ operation_id: report.operation_id().map(|id| id.as_str().to_owned()),
+ capability_id: report.capability_id().map(|id| id.as_str().to_owned()),
+ message: report.message().as_str().to_owned(),
+ },
+ }
+ }
+
+ pub fn initialization(message: impl Into<String>) -> Self {
+ Self::Initialization(message.into())
+ }
+
+ pub fn runtime(message: impl Into<String>) -> Self {
+ Self::Runtime(message.into())
+ }
+
+ pub fn unsupported(message: impl Into<String>) -> Self {
+ Self::Unsupported(message.into())
+ }
+
+ pub fn internal(message: impl Into<String>) -> Self {
+ Self::Internal(message.into())
+ }
+
+ pub(crate) fn store_invalid_configuration() -> Self {
+ Self::Store {
+ report: StoreErrorRecord {
+ schema_version: 1,
+ code: "invalid_store_configuration".to_owned(),
+ class: "validation".to_owned(),
+ retryable: false,
+ recovery_actions: vec!["configure_user_store".to_owned()],
+ message: "mobile user store configuration is invalid".to_owned(),
+ },
+ }
+ }
+
+ pub(crate) fn protected_data_unavailable() -> Self {
+ Self::Store {
+ report: StoreErrorRecord {
+ schema_version: 1,
+ code: "protected_data_unavailable".to_owned(),
+ class: "storage".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["retry_after_protected_data_available".to_owned()],
+ message: "Apple protected data is unavailable".to_owned(),
+ },
+ }
+ }
+
+ pub(crate) fn store_path_unavailable() -> Self {
+ Self::Store {
+ report: StoreErrorRecord {
+ schema_version: 1,
+ code: "store_path_unavailable".to_owned(),
+ class: "storage".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["prepare_application_support_directory".to_owned()],
+ message: "mobile user store directory is unavailable".to_owned(),
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord};
+
+ #[test]
+ fn sdk_error_records_are_versioned_stable_and_secret_safe() {
+ let error = radroots_sdk::ClientBuilder::new()
+ .build()
+ .expect_err("storage is required");
+ let RadrootsAppError::Sdk { report } = RadrootsAppError::from_sdk(error) else {
+ panic!("expected SDK report");
+ };
+ assert_eq!(
+ report,
+ SdkErrorRecord {
+ schema_version: 1,
+ code: "missing_storage".to_owned(),
+ class: "capability".to_owned(),
+ retryable: false,
+ recovery_actions: vec!["configure_storage".to_owned()],
+ operation_id: None,
+ capability_id: Some("storage.canonical".to_owned()),
+ message: "SDK storage capability is not configured".to_owned(),
+ }
+ );
+ assert!(!format!("{report:?}").contains("source"));
+ }
+
+ #[test]
+ fn public_error_constructors_preserve_typed_variants() {
+ assert!(matches!(
+ RadrootsAppError::initialization("init"),
+ RadrootsAppError::Initialization(message) if message == "init"
+ ));
+ assert!(matches!(
+ RadrootsAppError::runtime("runtime"),
+ RadrootsAppError::Runtime(message) if message == "runtime"
+ ));
+ assert!(matches!(
+ RadrootsAppError::unsupported("unsupported"),
+ RadrootsAppError::Unsupported(message) if message == "unsupported"
+ ));
+ assert!(matches!(
+ RadrootsAppError::internal("internal"),
+ RadrootsAppError::Internal(message) if message == "internal"
+ ));
+ assert_eq!(
+ RadrootsAppError::protected_data_unavailable().store_report(),
+ Some(&StoreErrorRecord {
+ schema_version: 1,
+ code: "protected_data_unavailable".to_owned(),
+ class: "storage".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["retry_after_protected_data_available".to_owned()],
+ message: "Apple protected data is unavailable".to_owned(),
+ })
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/lib.rs b/core/crates/tera_core/src/lib.rs
@@ -0,0 +1,12 @@
+// Mobile errors retain the complete stable SDK report. Preserving that typed
+// value is more important than optimizing the Rust enum's in-process size.
+#![allow(clippy::result_large_err)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+
+pub mod error;
+#[cfg(test)]
+mod provenance;
+pub mod runtime;
+
+pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord};
+pub use runtime::RadrootsRuntime;
diff --git a/core/crates/tera_core/src/provenance.rs b/core/crates/tera_core/src/provenance.rs
@@ -0,0 +1,26 @@
+pub(crate) fn is_full_revision(value: &str) -> bool {
+ value.len() == 40
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::is_full_revision;
+
+ #[test]
+ fn only_full_lowercase_git_revisions_are_accepted() {
+ assert!(is_full_revision("0123456789abcdef0123456789abcdef01234567"));
+ assert!(!is_full_revision("0123456"));
+ assert!(!is_full_revision(
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ ));
+ assert!(!is_full_revision(
+ "0123456789ABCDEF0123456789abcdef01234567"
+ ));
+ assert!(!is_full_revision(
+ "g123456789abcdef0123456789abcdef01234567"
+ ));
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/app_info.rs b/core/crates/tera_core/src/runtime/app_info.rs
@@ -0,0 +1,26 @@
+#[derive(Debug, Clone, Default, serde::Serialize)]
+pub struct AppInfoPlatform {
+ pub platform: Option<String>,
+ pub bundle_id: Option<String>,
+ pub version: Option<String>,
+ pub build_number: Option<String>,
+ pub build_sha: Option<String>,
+}
+
+impl AppInfoPlatform {
+ pub fn new(
+ platform: Option<String>,
+ bundle_id: Option<String>,
+ version: Option<String>,
+ build_number: Option<String>,
+ build_sha: Option<String>,
+ ) -> Self {
+ Self {
+ platform,
+ bundle_id,
+ version,
+ build_number,
+ build_sha,
+ }
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs
@@ -0,0 +1,250 @@
+use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability};
+use crate::{RadrootsAppError, RadrootsRuntime};
+
+/// Host-owned construction boundary for the shared SDK-backed runtime.
+pub struct RuntimeBuilder {
+ store: MobileUserStoreConfig,
+ #[cfg(feature = "mobile-social")]
+ signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>,
+ #[cfg(feature = "mobile-social")]
+ relay_profile: radroots_sdk::transport::RelayProfile,
+ #[cfg(feature = "mobile-social")]
+ blossom_config: Option<radroots_sdk::transport::BlossomConfig>,
+}
+
+impl RuntimeBuilder {
+ #[must_use]
+ pub fn new(store: MobileUserStoreConfig) -> Self {
+ Self {
+ store,
+ #[cfg(feature = "mobile-social")]
+ signer: None,
+ #[cfg(feature = "mobile-social")]
+ relay_profile: radroots_sdk::transport::RelayProfile::explicit(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ [radroots_sdk::transport::RelayEndpoint::new(
+ "wss://radroots.org",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadWrite,
+ )
+ .expect("bundled public relay endpoint is valid")],
+ )
+ .expect("bundled public relay profile is valid"),
+ #[cfg(feature = "mobile-social")]
+ blossom_config: None,
+ }
+ }
+
+ /// Installs one opaque host signer without transferring secret material.
+ #[cfg(feature = "mobile-social")]
+ #[must_use]
+ pub fn signer(mut self, signer: std::sync::Arc<dyn radroots_signing::Signer>) -> Self {
+ self.signer = Some(signer);
+ self
+ }
+
+ /// Replaces the bundled read-only public profile with one validated host
+ /// environment profile. Construction remains inert.
+ #[cfg(feature = "mobile-social")]
+ #[must_use]
+ pub fn relay_profile(mut self, relay_profile: radroots_sdk::transport::RelayProfile) -> Self {
+ self.relay_profile = relay_profile;
+ self
+ }
+
+ /// Installs one validated inert Blossom environment profile.
+ #[cfg(feature = "mobile-social")]
+ #[must_use]
+ pub fn blossom_config(
+ mut self,
+ blossom_config: radroots_sdk::transport::BlossomConfig,
+ ) -> Self {
+ self.blossom_config = Some(blossom_config);
+ self
+ }
+
+ /// Opens the exact authenticated user's durable SQLite store.
+ pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> {
+ if self.store.protected_data() == ProtectedDataAvailability::Unavailable {
+ return Err(RadrootsAppError::protected_data_unavailable());
+ }
+ self.store.validate_host_filesystem()?;
+ let options = self.store.sqlite_options()?;
+ #[cfg(feature = "mobile-social")]
+ let inbound_media_directory = self.store.owner_directory().join("inbound_media.v1");
+ let builder = radroots_sdk::ClientBuilder::sqlite(options)
+ .await
+ .map_err(RadrootsAppError::from_sdk)?;
+ RadrootsRuntime::from_client_builder(
+ builder,
+ Some(self.store.public_key()),
+ #[cfg(feature = "mobile-social")]
+ Some(inbound_media_directory),
+ #[cfg(feature = "mobile-social")]
+ self.signer,
+ #[cfg(feature = "mobile-social")]
+ Some(self.relay_profile),
+ #[cfg(feature = "mobile-social")]
+ self.blossom_config,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::RuntimeBuilder;
+ #[cfg(feature = "mobile-social")]
+ use crate::runtime::sdk::SdkRelayAccessRecord;
+ use crate::runtime::store::{MobileUserStoreConfig, ProtectedDataAvailability};
+
+ const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+ const GENERATION: &str = "0202020202020202020202020202020202020202020202020202020202020202";
+
+ fn store(
+ root: &std::path::Path,
+ protected_data: ProtectedDataAvailability,
+ ) -> MobileUserStoreConfig {
+ let store = MobileUserStoreConfig::from_encoded(
+ root,
+ PUBLIC_KEY,
+ GENERATION,
+ 1_800_000_000_000,
+ protected_data,
+ )
+ .expect("store config");
+ std::fs::create_dir_all(store.owner_directory()).expect("owner directory");
+ store
+ }
+
+ #[tokio::test]
+ async fn builder_constructs_a_durable_sdk_backed_runtime() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available))
+ .build()
+ .await
+ .expect("runtime");
+ assert!(!runtime.info().sdk_closed);
+ assert_eq!(
+ runtime.sdk_storage_status().await.expect("status").backend,
+ "sqlite"
+ );
+ #[cfg(feature = "mobile-social")]
+ {
+ let report = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("configured profile");
+ assert_eq!(report.profile, "public");
+ assert_eq!(report.state, "configured");
+ assert_eq!(report.relays.len(), 1);
+ assert_eq!(report.relays[0].relay_url, "wss://radroots.org");
+ assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite);
+ assert_eq!(report.relays[0].read_state, "unobserved");
+ assert_eq!(report.relays[0].write_state, "unobserved");
+ }
+ runtime.shutdown().await.expect("shutdown");
+ }
+
+ #[cfg(feature = "mobile-social")]
+ #[tokio::test]
+ async fn runtime_reconfiguration_preserves_profile_network_boundaries() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available))
+ .build()
+ .await
+ .expect("runtime");
+ assert!(
+ runtime
+ .configure_simulator_relays(vec!["ws://127.0.0.1:8080".to_owned()])
+ .is_ok()
+ );
+ let report = runtime
+ .sdk_relay_status()
+ .expect("simulator status")
+ .expect("configured profile");
+ assert_eq!(report.profile, "simulator_local");
+ assert_eq!(report.relays.len(), 1);
+ assert_eq!(report.relays[0].access, SdkRelayAccessRecord::ReadWrite);
+ assert!(
+ runtime
+ .configure_public_relays(vec!["ws://127.0.0.1:8080".to_owned()])
+ .is_err()
+ );
+ assert_eq!(
+ runtime
+ .sdk_relay_status()
+ .expect("unchanged status")
+ .expect("configured profile"),
+ report
+ );
+ assert!(
+ runtime
+ .configure_blossom(
+ radroots_sdk::transport::BlossomHostKind::Simulator,
+ radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment,
+ "http://127.0.0.1:3000".to_owned(),
+ vec![],
+ )
+ .is_ok()
+ );
+ assert_eq!(
+ runtime
+ .sdk_blossom_configuration()
+ .expect("Blossom profile")
+ .expect("configured")
+ .host_kind,
+ "simulator"
+ );
+ let evidence = runtime
+ .sdk_blossom_evidence()
+ .expect("Blossom evidence")
+ .expect("configured evidence");
+ assert_eq!(evidence.schema_version, 2);
+ assert_eq!(evidence.state, "configured_unobserved");
+ assert_eq!(evidence.last_successful_state, "configured_unobserved");
+ assert_eq!(evidence.transport_security, "development_cleartext");
+ assert!(evidence.observed_at_unix_ms.is_none());
+ assert!(evidence.error_code.is_none());
+ assert!(evidence.server_error_code.is_none());
+ assert!(
+ runtime
+ .configure_blossom(
+ radroots_sdk::transport::BlossomHostKind::PhysicalDevice,
+ radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki,
+ "http://127.0.0.1:3000".to_owned(),
+ vec![],
+ )
+ .is_err()
+ );
+ assert_eq!(
+ runtime
+ .sdk_blossom_configuration()
+ .expect("unchanged profile")
+ .expect("configured")
+ .host_kind,
+ "simulator"
+ );
+ runtime.shutdown().await.expect("shutdown");
+ }
+
+ #[tokio::test]
+ async fn protected_data_unavailability_is_retryable_and_reopen_recovers() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let unavailable =
+ RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Unavailable))
+ .build()
+ .await;
+ let Err(unavailable) = unavailable else {
+ panic!("protected data unavailability must fail");
+ };
+ let report = unavailable.store_report().expect("store report");
+ assert_eq!(report.code, "protected_data_unavailable");
+ assert!(report.retryable);
+
+ let runtime = RuntimeBuilder::new(store(root.path(), ProtectedDataAvailability::Available))
+ .build()
+ .await
+ .expect("recovered runtime");
+ runtime.shutdown().await.expect("shutdown");
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/info.rs b/core/crates/tera_core/src/runtime/info.rs
@@ -0,0 +1,79 @@
+use super::RadrootsRuntime;
+use chrono::Utc;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Serialize, Default)]
+pub struct RuntimeBuildInfo {
+ 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>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct AppInfo {
+ pub build: RuntimeBuildInfo,
+ pub started_unix_ms: i64,
+ pub uptime_millis: i64,
+ pub shutting_down: bool,
+ pub platform: Option<super::app_info::AppInfoPlatform>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct RuntimeInfo {
+ pub app: AppInfo,
+ pub sdk: RuntimeBuildInfo,
+ pub sdk_closed: bool,
+}
+
+pub fn gather_runtime_info(runtime: &RadrootsRuntime) -> RuntimeInfo {
+ let now_ms = Utc::now().timestamp_millis();
+ RuntimeInfo {
+ app: AppInfo {
+ build: app_build_info(),
+ started_unix_ms: runtime.started_unix_ms,
+ uptime_millis: now_ms - runtime.started_unix_ms,
+ shutting_down: runtime
+ .shutting_down
+ .load(std::sync::atomic::Ordering::SeqCst),
+ platform: runtime
+ .platform_app
+ .read()
+ .ok()
+ .and_then(|value| (*value).clone()),
+ },
+ sdk: RuntimeBuildInfo {
+ crate_name: "radroots_sdk".to_owned(),
+ crate_version: "0.1.0-alpha".to_owned(),
+ ..RuntimeBuildInfo::default()
+ },
+ sdk_closed: runtime.client.is_closed(),
+ }
+}
+
+pub fn app_build_info() -> RuntimeBuildInfo {
+ RuntimeBuildInfo {
+ crate_name: env!("CARGO_PKG_NAME").to_owned(),
+ crate_version: env!("CARGO_PKG_VERSION").to_owned(),
+ rustc: option_env!("RUSTC_VERSION").map(str::to_owned),
+ profile: option_env!("PROFILE").map(str::to_owned),
+ lib_revision: option_env!("RADROOTS_LIB_REVISION").map(str::to_owned),
+ consumer_revision: option_env!("RADROOTS_CONSUMER_REVISION").map(str::to_owned),
+ build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|value| value.parse().ok()),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn build_info_uses_sdk_identity_without_lower_runtime_metadata() {
+ let runtime = super::RadrootsRuntime::test_memory().expect("runtime");
+ let info = runtime.info();
+ assert_eq!(info.sdk.crate_name, "radroots_sdk");
+ assert_eq!(info.sdk.crate_version, "0.1.0-alpha");
+ assert!(!info.sdk_closed);
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs
@@ -0,0 +1,223 @@
+pub mod app_info;
+pub mod builder;
+pub mod info;
+pub mod product_surface;
+pub mod sdk;
+pub mod store;
+
+use chrono::Utc;
+use radroots_identity::PublicKey;
+use radroots_sdk::{Client, ClientBuilder};
+#[cfg(feature = "mobile-social")]
+use std::path::PathBuf;
+use std::sync::{
+ RwLock,
+ atomic::{AtomicBool, Ordering},
+};
+
+use self::{
+ app_info::AppInfoPlatform,
+ info::{RuntimeInfo, gather_runtime_info},
+};
+use crate::RadrootsAppError;
+
+pub struct RadrootsRuntime {
+ pub(crate) client: Client,
+ pub(crate) started_unix_ms: i64,
+ pub(crate) shutting_down: AtomicBool,
+ pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>,
+ pub(crate) store_public_key: Option<PublicKey>,
+ #[cfg(feature = "mobile-social")]
+ pub(crate) settings_lock: tokio::sync::Mutex<()>,
+ #[cfg(feature = "mobile-social")]
+ pub(crate) identity_session: tokio::sync::RwLock<Option<(u64, product_surface::IdentityState)>>,
+ #[cfg(feature = "mobile-social")]
+ pub(crate) inbound_media_directory: Option<PathBuf>,
+ #[cfg(feature = "mobile-social")]
+ pub(crate) inbound_media_lock: tokio::sync::Mutex<()>,
+}
+
+impl RadrootsRuntime {
+ pub(crate) fn from_client_builder(
+ builder: ClientBuilder,
+ store_public_key: Option<PublicKey>,
+ #[cfg(feature = "mobile-social")] inbound_media_directory: Option<PathBuf>,
+ #[cfg(feature = "mobile-social")] signer: Option<
+ std::sync::Arc<dyn radroots_signing::Signer>,
+ >,
+ #[cfg(feature = "mobile-social")] relay_profile: Option<
+ radroots_sdk::transport::RelayProfile,
+ >,
+ #[cfg(feature = "mobile-social")] blossom_config: Option<
+ radroots_sdk::transport::BlossomConfig,
+ >,
+ ) -> Result<Self, RadrootsAppError> {
+ #[cfg(feature = "mobile-social")]
+ let builder = {
+ let nostr_slot = radroots_sdk::transport::NostrSlot::new();
+ if let Some(profile) = relay_profile {
+ nostr_slot
+ .configure(profile)
+ .map_err(RadrootsAppError::from_sdk)?;
+ }
+ let builder = builder
+ .nostr(nostr_slot)
+ .blossom({
+ let slot = radroots_sdk::transport::BlossomSlot::new();
+ if let Some(config) = blossom_config {
+ slot.configure(config)
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?;
+ }
+ slot
+ })
+ .host_sync(radroots_sdk::sync::HostPolicy::standard());
+ match signer {
+ Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)),
+ None => builder,
+ }
+ };
+ let client = builder.build().map_err(RadrootsAppError::from_sdk)?;
+
+ Ok(Self {
+ client,
+ started_unix_ms: Utc::now().timestamp_millis(),
+ shutting_down: AtomicBool::new(false),
+ platform_app: RwLock::new(None),
+ store_public_key,
+ #[cfg(feature = "mobile-social")]
+ settings_lock: tokio::sync::Mutex::new(()),
+ #[cfg(feature = "mobile-social")]
+ identity_session: tokio::sync::RwLock::new(None),
+ #[cfg(feature = "mobile-social")]
+ inbound_media_directory,
+ #[cfg(feature = "mobile-social")]
+ inbound_media_lock: tokio::sync::Mutex::new(()),
+ })
+ }
+
+ #[cfg(test)]
+ pub(crate) fn test_memory() -> Result<Self, RadrootsAppError> {
+ Self::from_client_builder(
+ ClientBuilder::memory_default(),
+ None,
+ #[cfg(feature = "mobile-social")]
+ None,
+ #[cfg(feature = "mobile-social")]
+ None,
+ #[cfg(feature = "mobile-social")]
+ None,
+ #[cfg(feature = "mobile-social")]
+ None,
+ )
+ }
+
+ /// Closes SDK resources asynchronously across every runtime reference.
+ ///
+ /// Dropping the returned future before its first poll has no effect. If a
+ /// host cancels after close begins, it must call `shutdown` again; the SDK
+ /// remains unavailable and resumes the explicit close attempt. Completed
+ /// calls are idempotent and no blocking destructor is installed.
+ pub async fn shutdown(&self) -> Result<sdk::SdkShutdownRecord, RadrootsAppError> {
+ let already_closed = self.client.is_closed();
+ self.shutting_down.store(true, Ordering::Release);
+ self.client
+ .close()
+ .await
+ .map_err(RadrootsAppError::from_sdk)?;
+ Ok(sdk::SdkShutdownRecord {
+ state: "closed".to_owned(),
+ already_closed,
+ })
+ }
+
+ pub fn uptime_millis(&self) -> i64 {
+ Utc::now().timestamp_millis() - self.started_unix_ms
+ }
+
+ /// Returns the canonical public identity that scopes durable storage.
+ /// Explicit unit-test memory runtimes are the only runtimes without one.
+ pub fn authenticated_store_public_key_hex(&self) -> Option<String> {
+ self.store_public_key.map(|key| key.to_hex())
+ }
+
+ pub fn info(&self) -> RuntimeInfo {
+ gather_runtime_info(self)
+ }
+
+ pub fn info_json(&self) -> String {
+ serde_json::to_string_pretty(&self.info())
+ .unwrap_or_else(|error| format!(r#"{{"error":"serialize RuntimeInfo: {error}"}}"#))
+ }
+
+ 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>,
+ ) {
+ let platform_info =
+ AppInfoPlatform::new(platform, bundle_id, version, build_number, build_sha);
+ if let Ok(mut guard) = self.platform_app.write() {
+ *guard = Some(platform_info);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::RadrootsRuntime;
+ use radroots_sdk::capability::{Availability, CapabilityId};
+ use std::panic::{AssertUnwindSafe, catch_unwind};
+
+ fn poison_platform_lock(runtime: &RadrootsRuntime) {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ let _guard = runtime.platform_app.write().expect("lock platform");
+ panic!("poison platform lock");
+ }));
+ }
+
+ #[test]
+ fn runtime_owns_one_sdk_client() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let storage = runtime
+ .client
+ .capabilities()
+ .get(CapabilityId::CANONICAL_STORAGE)
+ .expect("storage capability");
+ assert_eq!(storage.availability(), Availability::Available);
+ assert!(!runtime.client.is_closed());
+ }
+
+ #[test]
+ fn set_platform_info_handles_poisoned_lock() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ runtime.set_app_info_platform(
+ Some("ios".to_owned()),
+ Some("org.radroots.app".to_owned()),
+ Some("1.0.0".to_owned()),
+ Some("100".to_owned()),
+ Some("abc123".to_owned()),
+ );
+ assert_eq!(
+ runtime
+ .info()
+ .app
+ .platform
+ .as_ref()
+ .and_then(|value| value.platform.clone()),
+ Some("ios".to_owned())
+ );
+ poison_platform_lock(&runtime);
+ runtime.set_app_info_platform(None, None, None, None, None);
+ }
+
+ #[test]
+ fn runtime_metadata_helpers_are_host_safe() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ assert!(runtime.uptime_millis() >= 0);
+ let json = runtime.info_json();
+ assert!(json.contains("sdk"));
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs
@@ -0,0 +1,154 @@
+//! Focused Phase 1 social-product domain for native Radroots clients.
+//!
+//! This module owns presentation-neutral product semantics. Protocol parsing
+//! and admission remain in lower event crates; persistence and live query
+//! composition remain in the runtime slices that consume these types.
+
+mod authoring;
+mod context;
+mod cursor;
+mod identity;
+mod media;
+mod model;
+#[cfg(feature = "mobile-social")]
+mod outbox;
+mod projection;
+mod ranking;
+#[cfg(feature = "mobile-social")]
+mod settings;
+mod today;
+
+pub use authoring::{
+ CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate,
+ Phase1AddCommand, Phase1ReplacementPolicy, phase1_retraction_plan,
+};
+pub use context::{
+ ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError,
+ LocalNetworkRelayPolicy, LocalityEvidence,
+};
+pub use cursor::{CursorError, CursorScope, TodayCursor, TodayCursorPosition};
+pub use identity::{CARD_ID_SCHEMA_VERSION, CardId, CardIdError, CardSourceIdentity};
+#[cfg(feature = "mobile-social")]
+pub use media::Phase1LocalMediaArtifact;
+pub use media::{
+ MediaReference, Phase1InboundMediaError, Phase1InboundMediaFailure, Phase1InboundMediaPending,
+ Phase1InboundMediaState, Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCachePolicy,
+ Phase1MediaCacheStatus, Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference,
+ Phase1VerifiedMediaReceipt,
+};
+pub use model::{
+ AddCommandType, CANONICAL_ADD_COMMAND_TYPES, CANONICAL_CARD_ADD_PARITY,
+ CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard,
+ LocalAuthorOverlay, MeSnapshot, ProfileSummary, SearchResult, SearchResultType,
+ SupportingProfile, ThreadEntry, ThreadReference, TodayCard, TodayCardType, TodayPage,
+};
+#[cfg(feature = "mobile-social")]
+pub use outbox::{
+ Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming,
+ Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus,
+ Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage,
+ Phase1NativeUploadJob, Phase1OutboxState, Phase1ProfileStatus, Phase1QueueIntent,
+ Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1ReviseIntent, Phase1RevisionPhase,
+ Phase1RevisionPolicy, Phase1RevisionStatus, Phase1RevisionTarget, Phase1UploadIntent,
+ Phase1UploadPlan, phase1_new_addressable_identifier, phase1_new_operation_id,
+ phase1_operation_now_unix_ms,
+};
+pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event};
+pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput};
+#[cfg(feature = "mobile-social")]
+pub use settings::{
+ BlossomEndpointAuthorityPreference, BlossomPreferences, DEFAULT_PUBLIC_BLOSSOM_ORIGIN,
+ DEFAULT_PUBLIC_RELAY, DEFAULT_SIMULATOR_BLOSSOM_ORIGIN, DEFAULT_SIMULATOR_RELAY,
+ IdentityCommand, IdentityLockState, IdentityRecord, IdentitySettingsError, IdentityState,
+ LocalStoragePolicy, MOBILE_SETTINGS_SCHEMA_VERSION, MediaNetworkPolicy,
+ MobileNetworkEnvironment, MobileSettings, ProfileMetadataCommand, ProfileMetadataError,
+ RelayAccessPreference, RelayEndpointPreference, RelayPreferences, ReplaceMobileSettings,
+ SettingsError, SettingsTransition,
+};
+pub use today::{
+ TodayError, TodayIngestReceipt, TodayPageRequest, TodayProjectionUpdate, TodayRefreshReceipt,
+};
+#[cfg(feature = "mobile-social")]
+pub use today::{TodayRelaySyncState, TodaySyncReceipt};
+
+use super::RadrootsRuntime;
+
+impl RadrootsRuntime {
+ /// Returns the exact five Phase 1 Today card types in contract order.
+ pub fn phase1_card_types(&self) -> Vec<TodayCardType> {
+ CANONICAL_TODAY_CARD_TYPES.to_vec()
+ }
+
+ /// Returns the exact five Phase 1 Add commands in card-parity order.
+ pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> {
+ CANONICAL_ADD_COMMAND_TYPES.to_vec()
+ }
+
+ /// Returns the closed one-to-one Today/Add mapping.
+ pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> {
+ CANONICAL_CARD_ADD_PARITY.to_vec()
+ }
+
+ /// Constructs a validated local query/composer context.
+ pub fn phase1_local_network(
+ &self,
+ id: String,
+ label: String,
+ relay_urls: Vec<String>,
+ locality: Option<String>,
+ followed_authors: Vec<String>,
+ generation: u64,
+ ) -> Result<LocalNetwork, crate::RadrootsAppError> {
+ LocalNetwork::new(
+ id,
+ label,
+ relay_urls,
+ locality,
+ followed_authors,
+ generation,
+ )
+ .map_err(|error| crate::RadrootsAppError::runtime(error.to_string()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn runtime_exposes_only_the_locked_card_and_add_catalogs() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ assert_eq!(runtime.phase1_card_types(), CANONICAL_TODAY_CARD_TYPES);
+ assert_eq!(
+ runtime.phase1_add_command_types(),
+ CANONICAL_ADD_COMMAND_TYPES
+ );
+ assert_eq!(runtime.phase1_card_add_parity(), CANONICAL_CARD_ADD_PARITY);
+ assert_eq!(
+ runtime
+ .phase1_local_network(
+ "nearby".into(),
+ "Near me".into(),
+ vec!["wss://relay.example".into()],
+ Some("u10h".into()),
+ vec!["a".repeat(64)],
+ 1,
+ )
+ .expect("network")
+ .id,
+ "nearby"
+ );
+ assert!(
+ runtime
+ .phase1_local_network(
+ "nearby".into(),
+ "Near me".into(),
+ Vec::new(),
+ None,
+ Vec::new(),
+ 1,
+ )
+ .is_err()
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/authoring.rs b/core/crates/tera_core/src/runtime/product_surface/authoring.rs
@@ -0,0 +1,310 @@
+use radroots_event::{
+ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent},
+ food::availability::FoodAvailabilityDetails,
+ post::{
+ AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate,
+ deletion::AuthoredNip09DeletionRequest,
+ },
+};
+use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError};
+
+use super::AddCommandType;
+
+/// A strict `CreateUpdate` command.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CreateUpdate(AuthoredUpdate);
+
+impl CreateUpdate {
+ pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> {
+ AuthoredUpdate::new(content).map(Self)
+ }
+
+ pub const fn authored(&self) -> &AuthoredUpdate {
+ &self.0
+ }
+}
+
+/// A strict `CreatePhotoUpdate` command.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CreatePhotoUpdate(AuthoredPhotoUpdate);
+
+impl CreatePhotoUpdate {
+ pub fn new(
+ content: impl Into<String>,
+ images: Vec<AuthoredPostImage>,
+ ) -> Result<Self, AuthoredPostError> {
+ AuthoredPhotoUpdate::new(content, images).map(Self)
+ }
+
+ pub const fn authored(&self) -> &AuthoredPhotoUpdate {
+ &self.0
+ }
+}
+
+/// A strict `CreateAsk` command.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CreateAsk(AuthoredAsk);
+
+impl CreateAsk {
+ pub fn new(
+ question: impl Into<String>,
+ images: Vec<AuthoredPostImage>,
+ ) -> Result<Self, AuthoredPostError> {
+ AuthoredAsk::new(question, images).map(Self)
+ }
+
+ pub const fn authored(&self) -> &AuthoredAsk {
+ &self.0
+ }
+}
+
+/// A strict `CreateEvent` command with an explicit all-day or timed profile.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CreateEvent(Box<CreateEventProfile>);
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum CreateEventProfile {
+ Date(AuthoredCalendarDateEvent),
+ Time(AuthoredCalendarTimeEvent),
+}
+
+impl CreateEvent {
+ pub fn date(event: AuthoredCalendarDateEvent) -> Self {
+ Self(Box::new(CreateEventProfile::Date(event)))
+ }
+
+ pub fn time(event: AuthoredCalendarTimeEvent) -> Self {
+ Self(Box::new(CreateEventProfile::Time(event)))
+ }
+}
+
+/// A strict `CreateFoodAvailability` command.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CreateFoodAvailability(FoodAvailabilityDetails);
+
+impl CreateFoodAvailability {
+ pub const fn new(details: FoodAvailabilityDetails) -> Self {
+ Self(details)
+ }
+
+ pub const fn authored(&self) -> &FoodAvailabilityDetails {
+ &self.0
+ }
+}
+
+/// The only five Phase 1 Add commands accepted by focused mobile authoring.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Phase1AddCommand {
+ CreateUpdate(CreateUpdate),
+ CreatePhotoUpdate(CreatePhotoUpdate),
+ CreateAsk(CreateAsk),
+ CreateEvent(CreateEvent),
+ CreateFoodAvailability(CreateFoodAvailability),
+}
+
+/// Standard revision behavior for a Phase 1 authored profile.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Phase1ReplacementPolicy {
+ /// Regular kind-1 events have no edit convention. The corrected event must
+ /// settle before an independent retraction is allowed to begin.
+ CreateThenRetract,
+ /// Addressable events replace the current head by reusing their stable `d`.
+ AddressableReplacement,
+}
+
+impl Phase1AddCommand {
+ pub const fn command_type(&self) -> AddCommandType {
+ match self {
+ Self::CreateUpdate(_) => AddCommandType::CreateUpdate,
+ Self::CreatePhotoUpdate(_) => AddCommandType::CreatePhotoUpdate,
+ Self::CreateAsk(_) => AddCommandType::CreateAsk,
+ Self::CreateEvent(_) => AddCommandType::CreateEvent,
+ Self::CreateFoodAvailability(_) => AddCommandType::CreateFoodAvailability,
+ }
+ }
+
+ pub const fn replacement_policy(&self) -> Phase1ReplacementPolicy {
+ match self {
+ Self::CreateUpdate(_) | Self::CreatePhotoUpdate(_) | Self::CreateAsk(_) => {
+ Phase1ReplacementPolicy::CreateThenRetract
+ }
+ Self::CreateEvent(_) | Self::CreateFoodAvailability(_) => {
+ Phase1ReplacementPolicy::AddressableReplacement
+ }
+ }
+ }
+
+ /// Binds the validated command to one exact timestamp and expected author.
+ pub fn authored_plan(
+ &self,
+ created_at: u64,
+ expected_author: impl AsRef<str>,
+ ) -> Result<AuthoredEventPlan, AuthoredPlanError> {
+ let expected_author = expected_author.as_ref();
+ match self {
+ Self::CreateUpdate(command) => {
+ AuthoredEventPlan::from_update(command.authored(), created_at, expected_author)
+ }
+ Self::CreatePhotoUpdate(command) => AuthoredEventPlan::from_photo_update(
+ command.authored(),
+ created_at,
+ expected_author,
+ ),
+ Self::CreateAsk(command) => {
+ AuthoredEventPlan::from_ask(command.authored(), created_at, expected_author)
+ }
+ Self::CreateEvent(command) => match command.0.as_ref() {
+ CreateEventProfile::Date(event) => {
+ AuthoredEventPlan::from_calendar_date_event(event, created_at, expected_author)
+ }
+ CreateEventProfile::Time(event) => {
+ AuthoredEventPlan::from_calendar_time_event(event, created_at, expected_author)
+ }
+ },
+ Self::CreateFoodAvailability(command) => AuthoredEventPlan::from_food_availability(
+ command.authored(),
+ created_at,
+ expected_author,
+ ),
+ }
+ }
+}
+
+/// Builds the independent strict NIP-09 plan used for retraction or withdrawal.
+///
+/// This function intentionally does not combine retraction and replacement
+/// into one purportedly atomic operation.
+pub fn phase1_retraction_plan(
+ request: &AuthoredNip09DeletionRequest,
+ created_at: u64,
+ expected_author: impl AsRef<str>,
+) -> Result<AuthoredEventPlan, AuthoredPlanError> {
+ AuthoredEventPlan::from_nip09_deletion_request(request, created_at, expected_author)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256};
+ use radroots_event::{
+ calendar::CalendarDate,
+ envelope::kind::{
+ KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST,
+ },
+ food::availability::{
+ FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency,
+ FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit,
+ },
+ media::AuthoredImage,
+ post::PostImageDimensions,
+ post::deletion::{AuthoredNip09DeletionRequest, Nip09DeletionEventTarget},
+ };
+
+ const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ #[test]
+ fn focused_commands_bind_only_locked_wire_profiles() {
+ let date = AuthoredCalendarDateEvent::new(
+ "market-day",
+ "Saturday Market",
+ CalendarDate::parse("2026-08-08").unwrap(),
+ )
+ .unwrap();
+ let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap();
+ let image = post_image();
+ let commands = [
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()),
+ Phase1AddCommand::CreatePhotoUpdate(
+ CreatePhotoUpdate::new(format!("Harvest photo {}", image.url()), vec![image])
+ .unwrap(),
+ ),
+ Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()),
+ Phase1AddCommand::CreateEvent(CreateEvent::date(date)),
+ Phase1AddCommand::CreateEvent(CreateEvent::time(time)),
+ Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())),
+ ];
+ let expected = [
+ (AddCommandType::CreateUpdate, KIND_POST),
+ (AddCommandType::CreatePhotoUpdate, KIND_POST),
+ (AddCommandType::CreateAsk, KIND_POST),
+ (AddCommandType::CreateEvent, KIND_CALENDAR_DATE_EVENT),
+ (AddCommandType::CreateEvent, KIND_CALENDAR_TIME_EVENT),
+ (
+ AddCommandType::CreateFoodAvailability,
+ KIND_CLASSIFIED_LISTING,
+ ),
+ ];
+ for (command, (command_type, kind)) in commands.iter().zip(expected) {
+ let plan = command.authored_plan(1_784_347_200, AUTHOR).unwrap();
+ assert_eq!(command.command_type(), command_type);
+ assert_eq!(plan.body().kind(), kind);
+ assert_ne!(plan.body().kind(), 20);
+ }
+ }
+
+ #[test]
+ fn revision_and_retraction_semantics_are_explicit() {
+ let update = Phase1AddCommand::CreateUpdate(CreateUpdate::new("new post").unwrap());
+ assert_eq!(
+ update.replacement_policy(),
+ Phase1ReplacementPolicy::CreateThenRetract
+ );
+ let event = Phase1AddCommand::CreateEvent(CreateEvent::time(
+ AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(),
+ ));
+ assert_eq!(
+ event.replacement_policy(),
+ Phase1ReplacementPolicy::AddressableReplacement
+ );
+
+ let request = AuthoredNip09DeletionRequest::new(
+ "retracted",
+ vec![Nip09DeletionEventTarget::parse("b".repeat(64), KIND_POST).unwrap()],
+ Vec::new(),
+ )
+ .unwrap();
+ let plan = phase1_retraction_plan(&request, 1_784_347_201, AUTHOR).unwrap();
+ assert_eq!(plan.body().kind(), 5);
+ }
+
+ fn food() -> FoodAvailabilityDetails {
+ FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new("Carrots available this week.").unwrap(),
+ identifier: FoodIdentifier::parse("nantes-carrots").unwrap(),
+ title: FoodText::new("Nantes Carrots").unwrap(),
+ summary: FoodText::new("Fresh bunches").unwrap(),
+ published_at: FoodPublishedAt::new(1_784_347_100).unwrap(),
+ location: FoodText::new("Central Saanich, BC").unwrap(),
+ price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound)
+ .unwrap(),
+ quantity: None,
+ status: FoodAvailabilityStatus::Active,
+ images: Vec::new(),
+ })
+ .unwrap()
+ }
+
+ fn post_image() -> AuthoredPostImage {
+ let bytes = b"harvest-photo";
+ let hash = Sha256::digest(bytes);
+ let media_type = MediaType::parse("image/webp").unwrap();
+ let descriptor = BlobDescriptor::new(
+ BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_100,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap();
+ AuthoredPostImage::new(
+ AuthoredImage::try_from(descriptor).unwrap(),
+ PostImageDimensions::new(1200, 900).unwrap(),
+ "Harvest",
+ )
+ .unwrap()
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/context.rs b/core/crates/tera_core/src/runtime/product_surface/context.rs
@@ -0,0 +1,466 @@
+use std::collections::BTreeSet;
+
+use radroots_transport_nostr::{RelayUrl, RelayUrlPolicy};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+const CONTEXT_TEXT_MAX_BYTES: usize = 256;
+const RELAY_URL_MAX_BYTES: usize = 2_048;
+
+/// A validated local query/composer context. It has no Nostr event identity.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LocalNetwork {
+ pub id: String,
+ pub label: String,
+ pub relay_urls: Vec<String>,
+ pub locality: Option<String>,
+ pub followed_authors: Vec<String>,
+ pub generation: u64,
+}
+
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
+pub enum LocalNetworkError {
+ #[error("local network {field} is invalid")]
+ InvalidText { field: &'static str },
+ #[error("local network requires at least one relay")]
+ MissingRelay,
+ #[error("local network relay URL is invalid")]
+ InvalidRelay,
+ #[error("local network relay URLs must be unique")]
+ DuplicateRelay,
+ #[error("local network followed author is invalid")]
+ InvalidAuthor,
+ #[error("local network followed authors must be unique")]
+ DuplicateAuthor,
+}
+
+/// Host environment whose destination policy governs LocalNetwork relays.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum LocalNetworkRelayPolicy {
+ Public,
+ Simulator,
+ Device,
+}
+
+impl LocalNetwork {
+ pub fn new(
+ id: String,
+ label: String,
+ relay_urls: Vec<String>,
+ locality: Option<String>,
+ followed_authors: Vec<String>,
+ generation: u64,
+ ) -> Result<Self, LocalNetworkError> {
+ Self::new_for_relay_policy(
+ id,
+ label,
+ relay_urls,
+ locality,
+ followed_authors,
+ generation,
+ LocalNetworkRelayPolicy::Public,
+ )
+ }
+
+ /// Constructs a context under the exact host relay destination policy.
+ #[allow(clippy::too_many_arguments)]
+ pub fn new_for_relay_policy(
+ id: String,
+ label: String,
+ relay_urls: Vec<String>,
+ locality: Option<String>,
+ followed_authors: Vec<String>,
+ generation: u64,
+ relay_policy: LocalNetworkRelayPolicy,
+ ) -> Result<Self, LocalNetworkError> {
+ validate_text(&id, "id")?;
+ validate_text(&label, "label")?;
+ if let Some(locality) = locality.as_deref() {
+ validate_text(locality, "locality")?;
+ }
+ if relay_urls.is_empty() {
+ return Err(LocalNetworkError::MissingRelay);
+ }
+ let mut relays = BTreeSet::new();
+ let mut canonical_relay_urls = Vec::with_capacity(relay_urls.len());
+ for relay in relay_urls {
+ if relay.is_empty() || relay.len() > RELAY_URL_MAX_BYTES {
+ return Err(LocalNetworkError::InvalidRelay);
+ }
+ let relay = RelayUrl::parse(
+ relay,
+ match relay_policy {
+ LocalNetworkRelayPolicy::Public => RelayUrlPolicy::Public,
+ LocalNetworkRelayPolicy::Simulator => RelayUrlPolicy::Local,
+ LocalNetworkRelayPolicy::Device => RelayUrlPolicy::PrivateNetwork,
+ },
+ )
+ .map_err(|_| LocalNetworkError::InvalidRelay)?;
+ if !relays.insert(relay.clone()) {
+ return Err(LocalNetworkError::DuplicateRelay);
+ }
+ canonical_relay_urls.push(relay.to_string());
+ }
+ let mut authors = BTreeSet::new();
+ for author in &followed_authors {
+ if author.len() != 64
+ || !author
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(LocalNetworkError::InvalidAuthor);
+ }
+ if !authors.insert(author) {
+ return Err(LocalNetworkError::DuplicateAuthor);
+ }
+ }
+ Ok(Self {
+ id,
+ label,
+ relay_urls: canonical_relay_urls,
+ locality,
+ followed_authors,
+ generation,
+ })
+ }
+
+ /// Applies the locked locality policy to the selected local context.
+ pub const fn admit(&self, evidence: LocalityEvidence) -> LocalNetworkAdmission {
+ match evidence {
+ LocalityEvidence::Match => LocalNetworkAdmission::Included(ContextAdmission {
+ rank: ContextRank::LocalityMatch,
+ reason: "locality_match",
+ }),
+ LocalityEvidence::Missing => LocalNetworkAdmission::Included(ContextAdmission {
+ rank: ContextRank::MissingLocalityFallback,
+ reason: "locality_missing_fallback",
+ }),
+ LocalityEvidence::Nonmatch => LocalNetworkAdmission::Excluded {
+ reason: "locality_nonmatch",
+ },
+ }
+ }
+}
+
+fn validate_text(value: &str, field: &'static str) -> Result<(), LocalNetworkError> {
+ if value.is_empty()
+ || value.trim() != value
+ || value.len() > CONTEXT_TEXT_MAX_BYTES
+ || value.chars().any(char::is_control)
+ {
+ return Err(LocalNetworkError::InvalidText { field });
+ }
+ Ok(())
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum LocalityEvidence {
+ Match,
+ Missing,
+ Nonmatch,
+}
+
+/// The only admitted context-rank values.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum ContextRank {
+ MissingLocalityFallback = 1,
+ LocalityMatch = 2,
+}
+
+impl ContextRank {
+ pub const fn value(self) -> u8 {
+ self as u8
+ }
+
+ pub const fn from_value(value: u8) -> Option<Self> {
+ match value {
+ 1 => Some(Self::MissingLocalityFallback),
+ 2 => Some(Self::LocalityMatch),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ContextAdmission {
+ pub rank: ContextRank,
+ pub reason: &'static str,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum LocalNetworkAdmission {
+ Included(ContextAdmission),
+ Excluded { reason: &'static str },
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn network() -> LocalNetwork {
+ LocalNetwork::new(
+ "local-network".into(),
+ "Near me".into(),
+ vec!["wss://relay.example".into()],
+ Some("u10h".into()),
+ vec!["a".repeat(64)],
+ 7,
+ )
+ .expect("network")
+ }
+
+ #[test]
+ fn locality_policy_has_exact_rank_and_exclusion_outcomes() {
+ assert_eq!(
+ network().admit(LocalityEvidence::Match),
+ LocalNetworkAdmission::Included(ContextAdmission {
+ rank: ContextRank::LocalityMatch,
+ reason: "locality_match",
+ })
+ );
+ assert_eq!(
+ network().admit(LocalityEvidence::Missing),
+ LocalNetworkAdmission::Included(ContextAdmission {
+ rank: ContextRank::MissingLocalityFallback,
+ reason: "locality_missing_fallback",
+ })
+ );
+ assert!(matches!(
+ network().admit(LocalityEvidence::Nonmatch),
+ LocalNetworkAdmission::Excluded { .. }
+ ));
+ }
+
+ #[test]
+ fn local_network_fields_are_bounded_and_unique() {
+ assert_eq!(network().generation, 7);
+ for invalid in [
+ LocalNetwork::new(
+ "".into(),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new("id".into(), "label".into(), vec![], None, vec![], 0),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec![format!("wss://{}", "r".repeat(RELAY_URL_MAX_BYTES))],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://relay example".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://relay\u{7f}".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["https://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://user@relay.example".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://relay.example#fragment".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://r".into(), "wss://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec!["A".repeat(64)],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec!["a".repeat(63)],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec!["a".repeat(64), "a".repeat(64)],
+ 0,
+ ),
+ LocalNetwork::new(
+ " id ".into(),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "i".repeat(CONTEXT_TEXT_MAX_BYTES + 1),
+ "label".into(),
+ vec!["wss://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ LocalNetwork::new(
+ "id".into(),
+ "la\u{7f}bel".into(),
+ vec!["wss://r".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ ] {
+ assert!(invalid.is_err());
+ }
+ let canonical = LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["WSS://RELAY.EXAMPLE:443/".into()],
+ None,
+ vec![],
+ 0,
+ )
+ .expect("canonical relay");
+ assert_eq!(canonical.relay_urls, vec!["wss://relay.example"]);
+ assert!(matches!(
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec![
+ "wss://relay.example".into(),
+ "WSS://RELAY.EXAMPLE:443/".into(),
+ ],
+ None,
+ vec![],
+ 0,
+ ),
+ Err(LocalNetworkError::DuplicateRelay)
+ ));
+ assert!(matches!(
+ LocalNetwork::new(
+ "id".into(),
+ "label".into(),
+ vec!["wss://127.0.0.1:7447".into()],
+ None,
+ vec![],
+ 0,
+ ),
+ Err(LocalNetworkError::InvalidRelay)
+ ));
+ assert!(
+ LocalNetwork::new_for_relay_policy(
+ "id".into(),
+ "label".into(),
+ vec!["ws://127.0.0.1:7447".into()],
+ None,
+ vec![],
+ 0,
+ LocalNetworkRelayPolicy::Simulator,
+ )
+ .is_ok()
+ );
+ assert!(
+ LocalNetwork::new_for_relay_policy(
+ "id".into(),
+ "label".into(),
+ vec!["wss://192.168.1.7:7447".into()],
+ None,
+ vec![],
+ 0,
+ LocalNetworkRelayPolicy::Device,
+ )
+ .is_ok()
+ );
+ assert!(
+ LocalNetwork::new_for_relay_policy(
+ "id".into(),
+ "label".into(),
+ vec!["ws://192.168.1.7:7447".into()],
+ None,
+ vec![],
+ 0,
+ LocalNetworkRelayPolicy::Device,
+ )
+ .is_ok()
+ );
+ for denied in [
+ "wss://relay.example",
+ "ws://127.0.0.1:7447",
+ "ws://169.254.1.7:7447",
+ "ws://8.8.8.8:7447",
+ ] {
+ assert!(matches!(
+ LocalNetwork::new_for_relay_policy(
+ "id".into(),
+ "label".into(),
+ vec![denied.into()],
+ None,
+ vec![],
+ 0,
+ LocalNetworkRelayPolicy::Device,
+ ),
+ Err(LocalNetworkError::InvalidRelay)
+ ));
+ }
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/cursor.rs b/core/crates/tera_core/src/runtime/product_surface/cursor.rs
@@ -0,0 +1,457 @@
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use super::{CardId, ContextRank, TODAY_RANK_SCHEMA_VERSION, TodayRank};
+use crate::runtime::product_surface::ranking::TODAY_RANK_ALGORITHM_VERSION;
+
+const CURSOR_PREFIX: &str = "rrtc1:";
+const CURSOR_DOMAIN: &[u8] = b"radroots.today-cursor.v1\0";
+const CURSOR_SCHEMA_VERSION: u16 = 1;
+const MAX_CONTEXT_ID_BYTES: usize = 256;
+const FIXED_PAYLOAD_BYTES: usize = 2 + 2 + 2 + 2 + 8 + 8 + 32 + 8 + 1 + 1 + 8 + 32;
+const DIGEST_BYTES: usize = 32;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct CursorScope {
+ pub context_id: String,
+ pub context_generation: u64,
+ pub as_of: u64,
+ pub store_generation: [u8; 32],
+ pub projection_generation: u64,
+}
+
+impl CursorScope {
+ pub fn new(
+ context_id: String,
+ context_generation: u64,
+ as_of: u64,
+ store_generation: [u8; 32],
+ projection_generation: u64,
+ ) -> Result<Self, CursorError> {
+ validate_context_id(&context_id)?;
+ Ok(Self {
+ context_id,
+ context_generation,
+ as_of,
+ store_generation,
+ projection_generation,
+ })
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TodayCursorPosition {
+ pub rank: TodayRank,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TodayCursor(String);
+
+#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
+pub enum CursorError {
+ #[error("today cursor context id is invalid")]
+ InvalidContext,
+ #[error("today cursor encoding is malformed")]
+ Malformed,
+ #[error("today cursor integrity check failed")]
+ Integrity,
+ #[error("today cursor version is unsupported")]
+ Version,
+ #[error("today cursor belongs to another context")]
+ ContextMismatch,
+ #[error("today cursor belongs to another frozen snapshot")]
+ SnapshotMismatch,
+ #[error("today cursor belongs to a retired store or projection generation")]
+ Stale,
+ #[error("today cursor position is invalid")]
+ InvalidPosition,
+}
+
+impl TodayCursor {
+ pub fn encode(scope: &CursorScope, position: TodayCursorPosition) -> Result<Self, CursorError> {
+ if position.rank.schema_version != TODAY_RANK_SCHEMA_VERSION
+ || position.rank.algorithm_version != TODAY_RANK_ALGORITHM_VERSION
+ {
+ return Err(CursorError::Version);
+ }
+ if position.rank.time_relevance_rank > 4 {
+ return Err(CursorError::InvalidPosition);
+ }
+ let context_bytes = scope.context_id.as_bytes();
+ let mut payload = Vec::with_capacity(FIXED_PAYLOAD_BYTES + context_bytes.len());
+ payload.extend_from_slice(&CURSOR_SCHEMA_VERSION.to_be_bytes());
+ payload.extend_from_slice(&TODAY_RANK_SCHEMA_VERSION.to_be_bytes());
+ payload.extend_from_slice(&TODAY_RANK_ALGORITHM_VERSION.to_be_bytes());
+ payload.extend_from_slice(
+ &u16::try_from(context_bytes.len())
+ .expect("validated context length fits u16")
+ .to_be_bytes(),
+ );
+ payload.extend_from_slice(context_bytes);
+ payload.extend_from_slice(&scope.context_generation.to_be_bytes());
+ payload.extend_from_slice(&scope.as_of.to_be_bytes());
+ payload.extend_from_slice(&scope.store_generation);
+ payload.extend_from_slice(&scope.projection_generation.to_be_bytes());
+ payload.push(position.rank.context_rank.value());
+ payload.push(position.rank.time_relevance_rank);
+ payload.extend_from_slice(&position.rank.effective_at.to_be_bytes());
+ payload.extend_from_slice(position.rank.card_id.as_bytes());
+ let digest = cursor_digest(&payload);
+ payload.extend_from_slice(&digest);
+ Ok(Self(format!("{CURSOR_PREFIX}{}", hex::encode(payload))))
+ }
+
+ pub fn decode(value: &str, expected: &CursorScope) -> Result<TodayCursorPosition, CursorError> {
+ let (scope, position) = decode_unbound(value)?;
+ if scope.context_id != expected.context_id
+ || scope.context_generation != expected.context_generation
+ {
+ return Err(CursorError::ContextMismatch);
+ }
+ if scope.as_of != expected.as_of {
+ return Err(CursorError::SnapshotMismatch);
+ }
+ if scope.store_generation != expected.store_generation
+ || scope.projection_generation != expected.projection_generation
+ {
+ return Err(CursorError::Stale);
+ }
+ Ok(position)
+ }
+
+ /// Recovers the integrity-checked frozen scope carried by an opaque cursor.
+ pub fn scope(value: &str) -> Result<CursorScope, CursorError> {
+ decode_unbound(value).map(|(scope, _)| scope)
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+fn decode_unbound(value: &str) -> Result<(CursorScope, TodayCursorPosition), CursorError> {
+ let encoded = value
+ .strip_prefix(CURSOR_PREFIX)
+ .ok_or(CursorError::Malformed)?;
+ if encoded.len() % 2 != 0
+ || !encoded
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(CursorError::Malformed);
+ }
+ let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?;
+ if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES {
+ return Err(CursorError::Malformed);
+ }
+ let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES);
+ if cursor_digest(payload).as_slice() != observed_digest {
+ return Err(CursorError::Integrity);
+ }
+ decode_payload(payload)
+}
+
+fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), CursorError> {
+ let mut decoder = Decoder::new(payload);
+ let cursor_version = decoder.u16()?;
+ let rank_schema_version = decoder.u16()?;
+ let rank_algorithm_version = decoder.u16()?;
+ if cursor_version != CURSOR_SCHEMA_VERSION
+ || rank_schema_version != TODAY_RANK_SCHEMA_VERSION
+ || rank_algorithm_version != TODAY_RANK_ALGORITHM_VERSION
+ {
+ return Err(CursorError::Version);
+ }
+ let context_len = usize::from(decoder.u16()?);
+ let context_id =
+ core::str::from_utf8(decoder.bytes(context_len)?).map_err(|_| CursorError::Malformed)?;
+ validate_context_id(context_id)?;
+ let context_generation = decoder.u64()?;
+ let as_of = decoder.u64()?;
+ let store_generation = decoder.array_32()?;
+ let projection_generation = decoder.u64()?;
+ let context_rank = ContextRank::from_value(decoder.u8()?).ok_or(CursorError::Malformed)?;
+ let time_relevance_rank = decoder.u8()?;
+ if time_relevance_rank > 4 {
+ return Err(CursorError::Malformed);
+ }
+ let effective_at = decoder.u64()?;
+ let card_id =
+ CardId::parse(&hex::encode(decoder.array_32()?)).map_err(|_| CursorError::Malformed)?;
+ if !decoder.is_finished() {
+ return Err(CursorError::Malformed);
+ }
+ Ok((
+ CursorScope {
+ context_id: context_id.to_owned(),
+ context_generation,
+ as_of,
+ store_generation,
+ projection_generation,
+ },
+ TodayCursorPosition {
+ rank: TodayRank {
+ schema_version: rank_schema_version,
+ algorithm_version: rank_algorithm_version,
+ context_rank,
+ time_relevance_rank,
+ effective_at,
+ card_id,
+ },
+ },
+ ))
+}
+
+fn validate_context_id(value: &str) -> Result<(), CursorError> {
+ if value.is_empty()
+ || value.len() > MAX_CONTEXT_ID_BYTES
+ || value.trim() != value
+ || value.chars().any(char::is_control)
+ {
+ return Err(CursorError::InvalidContext);
+ }
+ Ok(())
+}
+
+fn cursor_digest(payload: &[u8]) -> [u8; 32] {
+ let mut digest = Sha256::new();
+ digest.update(CURSOR_DOMAIN);
+ digest.update(payload);
+ digest.finalize().into()
+}
+
+struct Decoder<'a> {
+ remaining: &'a [u8],
+}
+
+impl<'a> Decoder<'a> {
+ const fn new(value: &'a [u8]) -> Self {
+ Self { remaining: value }
+ }
+
+ fn bytes(&mut self, length: usize) -> Result<&'a [u8], CursorError> {
+ if self.remaining.len() < length {
+ return Err(CursorError::Malformed);
+ }
+ let (value, remaining) = self.remaining.split_at(length);
+ self.remaining = remaining;
+ Ok(value)
+ }
+
+ fn u8(&mut self) -> Result<u8, CursorError> {
+ Ok(self.bytes(1)?[0])
+ }
+
+ fn u16(&mut self) -> Result<u16, CursorError> {
+ Ok(u16::from_be_bytes(
+ self.bytes(2)?.try_into().expect("exact length"),
+ ))
+ }
+
+ fn u64(&mut self) -> Result<u64, CursorError> {
+ Ok(u64::from_be_bytes(
+ self.bytes(8)?.try_into().expect("exact length"),
+ ))
+ }
+
+ fn array_32(&mut self) -> Result<[u8; 32], CursorError> {
+ Ok(self.bytes(32)?.try_into().expect("exact length"))
+ }
+
+ const fn is_finished(&self) -> bool {
+ self.remaining.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn scope() -> CursorScope {
+ CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope")
+ }
+
+ fn position() -> TodayCursorPosition {
+ TodayCursorPosition {
+ rank: TodayRank {
+ schema_version: TODAY_RANK_SCHEMA_VERSION,
+ algorithm_version: TODAY_RANK_ALGORITHM_VERSION,
+ context_rank: ContextRank::LocalityMatch,
+ time_relevance_rank: 3,
+ effective_at: 1_999_999_000,
+ card_id: CardId::parse(&"a".repeat(64)).expect("card"),
+ },
+ }
+ }
+
+ fn payload(cursor: &TodayCursor) -> Vec<u8> {
+ let bytes =
+ hex::decode(cursor.as_str().strip_prefix(CURSOR_PREFIX).expect("prefix")).expect("hex");
+ bytes[..bytes.len() - DIGEST_BYTES].to_vec()
+ }
+
+ fn signed_payload(mut payload: Vec<u8>) -> String {
+ payload.extend_from_slice(&cursor_digest(&payload));
+ format!("{CURSOR_PREFIX}{}", hex::encode(payload))
+ }
+
+ #[test]
+ fn cursor_vector_round_trips_and_is_fixed() {
+ let cursor = TodayCursor::encode(&scope(), position()).expect("cursor");
+ assert_eq!(
+ cursor.as_str(),
+ "rrtc1:00010001000100066e6561726279000000000000000400000000773594000707070707070707070707070707070707070707070707070707070707070707000000000000000902030000000077359018aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaedf305be41633dfc2f7d621e067c3d33a71c3548c6a1fcf68a6707a1d8664b11"
+ );
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"),
+ position()
+ );
+ assert_eq!(TodayCursor::scope(cursor.as_str()).expect("scope"), scope());
+ }
+
+ #[test]
+ fn cursor_rejects_tamper_context_snapshot_and_stale_generations() {
+ let cursor = TodayCursor::encode(&scope(), position()).expect("cursor");
+ let mut tampered = cursor.as_str().as_bytes().to_vec();
+ *tampered.last_mut().expect("byte") = b'0';
+ assert_eq!(
+ TodayCursor::decode(core::str::from_utf8(&tampered).expect("utf8"), &scope()),
+ Err(CursorError::Integrity)
+ );
+ let other_context =
+ CursorScope::new("other".into(), 4, 2_000_000_000, [7; 32], 9).expect("scope");
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &other_context),
+ Err(CursorError::ContextMismatch)
+ );
+ let other_context_generation =
+ CursorScope::new("nearby".into(), 5, 2_000_000_000, [7; 32], 9).expect("scope");
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &other_context_generation),
+ Err(CursorError::ContextMismatch)
+ );
+ let other_snapshot =
+ CursorScope::new("nearby".into(), 4, 2_000_000_001, [7; 32], 9).expect("scope");
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &other_snapshot),
+ Err(CursorError::SnapshotMismatch)
+ );
+ let stale = CursorScope::new("nearby".into(), 4, 2_000_000_000, [8; 32], 9).expect("scope");
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &stale),
+ Err(CursorError::Stale)
+ );
+ let stale_projection =
+ CursorScope::new("nearby".into(), 4, 2_000_000_000, [7; 32], 10).expect("scope");
+ assert_eq!(
+ TodayCursor::decode(cursor.as_str(), &stale_projection),
+ Err(CursorError::Stale)
+ );
+ }
+
+ #[test]
+ fn malformed_and_versioned_cursor_inputs_fail_closed() {
+ assert_eq!(
+ TodayCursor::decode("nope", &scope()),
+ Err(CursorError::Malformed)
+ );
+ for malformed in ["rrtc1:0", "rrtc1:GG", "rrtc1:00"] {
+ assert_eq!(
+ TodayCursor::decode(malformed, &scope()),
+ Err(CursorError::Malformed)
+ );
+ }
+ assert_eq!(
+ TodayCursor::decode(
+ &TodayCursor::encode(&scope(), position())
+ .expect("cursor")
+ .as_str()
+ .to_uppercase(),
+ &scope()
+ ),
+ Err(CursorError::Malformed)
+ );
+ assert!(CursorScope::new("".into(), 0, 0, [0; 32], 0).is_err());
+ assert!(CursorScope::new("x".repeat(257), 0, 0, [0; 32], 0).is_err());
+ assert!(CursorScope::new(" nearby ".into(), 0, 0, [0; 32], 0).is_err());
+ assert!(CursorScope::new("near\u{7f}by".into(), 0, 0, [0; 32], 0).is_err());
+ let cursor = TodayCursor::encode(&scope(), position()).expect("cursor");
+ for version_offset in [1, 3, 5] {
+ let mut unsupported = payload(&cursor);
+ unsupported[version_offset] = 2;
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(unsupported), &scope()),
+ Err(CursorError::Version)
+ );
+ }
+ let mut invalid_utf8 = payload(&cursor);
+ invalid_utf8[8] = 0xff;
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(invalid_utf8), &scope()),
+ Err(CursorError::Malformed)
+ );
+ let mut invalid_context = payload(&cursor);
+ invalid_context[8] = b' ';
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(invalid_context), &scope()),
+ Err(CursorError::InvalidContext)
+ );
+ let mut trailing = payload(&cursor);
+ trailing.push(0);
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(trailing), &scope()),
+ Err(CursorError::Malformed)
+ );
+ let mut invalid_context_rank = payload(&cursor);
+ invalid_context_rank[70] = 3;
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(invalid_context_rank), &scope()),
+ Err(CursorError::Malformed)
+ );
+ let mut invalid_time_rank = payload(&cursor);
+ invalid_time_rank[71] = 5;
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(invalid_time_rank), &scope()),
+ Err(CursorError::Malformed)
+ );
+ let mut truncated_field = vec![0; FIXED_PAYLOAD_BYTES];
+ truncated_field[1] = 1;
+ truncated_field[3] = 1;
+ truncated_field[5] = 1;
+ truncated_field[6] = 1;
+ assert_eq!(
+ TodayCursor::decode(&signed_payload(truncated_field), &scope()),
+ Err(CursorError::Malformed)
+ );
+ let invalid_version = TodayCursorPosition {
+ rank: TodayRank {
+ schema_version: 2,
+ ..position().rank
+ },
+ };
+ assert_eq!(
+ TodayCursor::encode(&scope(), invalid_version),
+ Err(CursorError::Version)
+ );
+ let invalid_algorithm = TodayCursorPosition {
+ rank: TodayRank {
+ algorithm_version: 2,
+ ..position().rank
+ },
+ };
+ assert_eq!(
+ TodayCursor::encode(&scope(), invalid_algorithm),
+ Err(CursorError::Version)
+ );
+ let invalid_rank = TodayCursorPosition {
+ rank: TodayRank {
+ time_relevance_rank: 5,
+ ..position().rank
+ },
+ };
+ assert_eq!(
+ TodayCursor::encode(&scope(), invalid_rank),
+ Err(CursorError::InvalidPosition)
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/identity.rs b/core/crates/tera_core/src/runtime/product_surface/identity.rs
@@ -0,0 +1,185 @@
+use core::fmt;
+
+use radroots_event::EventId;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use super::TodayCardType;
+
+pub const CARD_ID_SCHEMA_VERSION: u16 = 1;
+const CARD_ID_DOMAIN: &[u8] = b"radroots.today-card.v1\0";
+
+/// Canonical source identity used to derive a stable card identifier.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum CardSourceIdentity {
+ Event(EventId),
+ Address {
+ kind: u32,
+ author_pubkey: String,
+ identifier: String,
+ },
+}
+
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
+pub enum CardIdError {
+ #[error("card address kind must be parameterized replaceable")]
+ InvalidAddressKind,
+ #[error("card address author must be canonical lowercase hexadecimal")]
+ InvalidAuthor,
+ #[error("card address identifier is invalid")]
+ InvalidIdentifier,
+ #[error("card identifier must be 64 lowercase hexadecimal characters")]
+ InvalidCardId,
+}
+
+impl CardSourceIdentity {
+ pub fn address(
+ kind: u32,
+ author_pubkey: impl Into<String>,
+ identifier: impl Into<String>,
+ ) -> Result<Self, CardIdError> {
+ if !(30_000..40_000).contains(&kind) {
+ return Err(CardIdError::InvalidAddressKind);
+ }
+ let author_pubkey = author_pubkey.into();
+ if author_pubkey.len() != 64
+ || !author_pubkey
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(CardIdError::InvalidAuthor);
+ }
+ let identifier = identifier.into();
+ if identifier.is_empty()
+ || identifier.len() > 512
+ || identifier.chars().any(char::is_control)
+ {
+ return Err(CardIdError::InvalidIdentifier);
+ }
+ Ok(Self::Address {
+ kind,
+ author_pubkey,
+ identifier,
+ })
+ }
+
+ pub fn canonical_string(&self) -> String {
+ match self {
+ Self::Event(event_id) => format!("event:{}", event_id.to_hex()),
+ Self::Address {
+ kind,
+ author_pubkey,
+ identifier,
+ } => format!("address:{kind}:{author_pubkey}:{identifier}"),
+ }
+ }
+}
+
+/// Lowercase SHA-256 stable identity for one top-level Today card.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct CardId([u8; 32]);
+
+impl CardId {
+ pub fn derive(card_type: TodayCardType, source: &CardSourceIdentity) -> Self {
+ let mut digest = Sha256::new();
+ digest.update(CARD_ID_DOMAIN);
+ digest.update(card_type.label().as_bytes());
+ digest.update(b"\0");
+ digest.update(source.canonical_string().as_bytes());
+ Self(digest.finalize().into())
+ }
+
+ pub fn parse(value: &str) -> Result<Self, CardIdError> {
+ if value.len() != 64
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ {
+ return Err(CardIdError::InvalidCardId);
+ }
+ let mut bytes = [0; 32];
+ hex::decode_to_slice(value, &mut bytes).map_err(|_| CardIdError::InvalidCardId)?;
+ Ok(Self(bytes))
+ }
+
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+
+ pub fn to_hex(self) -> String {
+ hex::encode(self.0)
+ }
+}
+
+impl fmt::Display for CardId {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&hex::encode(self.0))
+ }
+}
+
+impl Serialize for CardId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.to_hex())
+ }
+}
+
+impl<'de> Deserialize<'de> for CardId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn stable_card_id_vectors_cover_regular_and_addressable_sources() {
+ let event = EventId::parse("a".repeat(64)).expect("event");
+ assert_eq!(
+ CardId::derive(TodayCardType::Update, &CardSourceIdentity::Event(event)).to_hex(),
+ "36bf89dc7a6759143986b1f339870ec792f7bee865c9738b0adb50ac9c5197be"
+ );
+ let address = CardSourceIdentity::address(31_923, "b".repeat(64), "farmers-market-2026")
+ .expect("address");
+ assert_eq!(
+ CardId::derive(TodayCardType::Event, &address).to_hex(),
+ "75f6127161783c583368b6c76ed779ae5e02cd7bc9f71ef55ff39e32a59274fc"
+ );
+ let replacement = CardId::derive(TodayCardType::Event, &address);
+ assert_eq!(replacement, CardId::derive(TodayCardType::Event, &address));
+ }
+
+ #[test]
+ fn card_identity_rejects_noncanonical_addresses_and_ids() {
+ assert!(CardSourceIdentity::address(1, "a".repeat(64), "id").is_err());
+ assert!(CardSourceIdentity::address(40_000, "a".repeat(64), "id").is_err());
+ assert!(CardSourceIdentity::address(30_402, "a".repeat(63), "id").is_err());
+ assert!(CardSourceIdentity::address(30_402, "A".repeat(64), "id").is_err());
+ assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "").is_err());
+ assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "i".repeat(513)).is_err());
+ assert!(CardSourceIdentity::address(30_402, "a".repeat(64), "bad\nid").is_err());
+ let opaque = CardSourceIdentity::address(31_923, "a".repeat(64), " market day ")
+ .expect("opaque d value");
+ assert!(opaque.canonical_string().ends_with(": market day "));
+ assert!(CardId::parse(&"a".repeat(63)).is_err());
+ assert!(CardId::parse(&"A".repeat(64)).is_err());
+
+ let card = CardId::parse(&"c".repeat(64)).expect("card");
+ assert_eq!(card.to_string(), "c".repeat(64));
+ let encoded = serde_json::to_string(&card).expect("serialize");
+ assert_eq!(
+ serde_json::from_str::<CardId>(&encoded).expect("deserialize"),
+ card
+ );
+ assert!(serde_json::from_str::<CardId>(&format!("\"{}\"", "G".repeat(64))).is_err());
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/media.rs b/core/crates/tera_core/src/runtime/product_surface/media.rs
@@ -0,0 +1,2172 @@
+//! Typed inbound-media trust, receipt, and bounded cache metadata.
+//!
+//! Structural Nostr references are intentionally distinct from locally
+//! verified artifacts. A caller cannot represent renderable media with a URL
+//! and a boolean: the `Verified` state always contains a receipt derived from
+//! an actual byte commitment and bound to the active retrieval configuration.
+
+use std::collections::BTreeMap;
+#[cfg(feature = "mobile-social")]
+use std::path::{Path, PathBuf};
+
+use radroots_blossom::{BlobUrl, MediaType, Sha256, descriptor::ByteCommitment};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256 as Sha256Hasher};
+use thiserror::Error;
+#[cfg(feature = "mobile-social")]
+use tokio::io::AsyncWriteExt;
+
+const MEDIA_REFERENCE_SCHEMA_VERSION: u16 = 1;
+const MEDIA_RECEIPT_SCHEMA_VERSION: u16 = 1;
+const MEDIA_CACHE_SCHEMA_VERSION: u16 = 1;
+const MEDIA_URL_MAX_BYTES: usize = 8_192;
+const MEDIA_ALT_MAX_BYTES: usize = 2_048;
+const MEDIA_FAILURE_CODE_MAX_BYTES: usize = 96;
+const MEDIA_DIMENSION_MAX_EDGE: u32 = 16_384;
+const MEDIA_DIMENSION_MAX_PIXELS: u64 = 100_000_000;
+const MEDIA_REFERENCE_FINGERPRINT_DOMAIN: &[u8] = b"radroots.inbound-media-reference.v1\0";
+#[cfg(feature = "mobile-social")]
+const MEDIA_CACHE_EXTENSIONS: &[&str] = &["gif", "jpg", "png", "webp"];
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(transparent)]
+pub struct Phase1MediaConfigurationFingerprint([u8; 32]);
+
+impl Phase1MediaConfigurationFingerprint {
+ pub fn new(value: [u8; 32]) -> Result<Self, Phase1InboundMediaError> {
+ (value != [0; 32])
+ .then_some(Self(value))
+ .ok_or(Phase1InboundMediaError::InvalidConfiguration)
+ }
+
+ pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> {
+ let decoded =
+ hex::decode(value).map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?;
+ let bytes: [u8; 32] = decoded
+ .try_into()
+ .map_err(|_| Phase1InboundMediaError::InvalidConfiguration)?;
+ Self::new(bytes)
+ }
+
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+
+ pub fn to_hex(self) -> String {
+ hex::encode(self.0)
+ }
+
+ fn validate(self) -> Result<(), Phase1InboundMediaError> {
+ Self::new(self.0).map(|_| ())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(transparent)]
+pub struct Phase1MediaArtifactId([u8; 32]);
+
+impl Phase1MediaArtifactId {
+ pub fn parse(value: &str) -> Result<Self, Phase1InboundMediaError> {
+ let hash = Sha256::from_hex(value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?;
+ Ok(Self(*hash.as_bytes()))
+ }
+
+ pub const fn from_sha256(value: Sha256) -> Self {
+ Self(*value.as_bytes())
+ }
+
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+
+ pub fn to_hex(self) -> String {
+ hex::encode(self.0)
+ }
+}
+
+/// Signed-event media facts. These facts do not imply that any bytes were
+/// fetched, trusted, stored, or rendered.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1StructuralMediaReference {
+ schema_version: u16,
+ source_url: String,
+ expected_sha256: Option<String>,
+ expected_media_type: Option<String>,
+ expected_width: Option<u32>,
+ expected_height: Option<u32>,
+ expected_byte_size: Option<u64>,
+ alt: Option<String>,
+ fingerprint: [u8; 32],
+}
+
+impl Phase1StructuralMediaReference {
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ source_url: impl Into<String>,
+ expected_sha256: Option<String>,
+ expected_media_type: Option<String>,
+ expected_width: Option<u32>,
+ expected_height: Option<u32>,
+ expected_byte_size: Option<u64>,
+ alt: Option<String>,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ let source_url = source_url.into();
+ validate_url_text(&source_url)?;
+ let parsed =
+ url::Url::parse(&source_url).map_err(|_| Phase1InboundMediaError::InvalidReference)?;
+ if !matches!(parsed.scheme(), "http" | "https")
+ || parsed.host_str().is_none()
+ || !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.as_str() != source_url
+ {
+ return Err(Phase1InboundMediaError::InvalidReference);
+ }
+ let path_digest = BlobUrl::parse(&source_url)
+ .ok()
+ .map(|value| value.hash_path().hash().to_hex());
+ let expected_sha256 = match expected_sha256 {
+ Some(value) => {
+ let digest =
+ Sha256::from_hex(&value).map_err(|_| Phase1InboundMediaError::InvalidDigest)?;
+ if digest.to_hex() != value
+ || path_digest
+ .as_deref()
+ .is_some_and(|path| digest.to_hex() != path)
+ {
+ return Err(Phase1InboundMediaError::MetadataMismatch);
+ }
+ Some(value)
+ }
+ None => path_digest,
+ };
+ let expected_media_type = expected_media_type
+ .map(|value| {
+ MediaType::parse(&value)
+ .map(|parsed| parsed.to_string())
+ .map_err(|_| Phase1InboundMediaError::InvalidMediaType)
+ })
+ .transpose()?;
+ validate_dimensions(expected_width, expected_height)?;
+ if expected_byte_size == Some(0) {
+ return Err(Phase1InboundMediaError::InvalidByteSize);
+ }
+ if alt.as_deref().is_some_and(|value| {
+ value.len() > MEDIA_ALT_MAX_BYTES || value.chars().any(char::is_control)
+ }) {
+ return Err(Phase1InboundMediaError::InvalidAlt);
+ }
+ let mut value = Self {
+ schema_version: MEDIA_REFERENCE_SCHEMA_VERSION,
+ source_url: parsed.to_string(),
+ expected_sha256,
+ expected_media_type,
+ expected_width,
+ expected_height,
+ expected_byte_size,
+ alt,
+ fingerprint: [0; 32],
+ };
+ value.fingerprint = value.derive_fingerprint();
+ Ok(value)
+ }
+
+ fn validate(&self) -> Result<(), Phase1InboundMediaError> {
+ if self.schema_version != MEDIA_REFERENCE_SCHEMA_VERSION {
+ return Err(Phase1InboundMediaError::UnsupportedSchema);
+ }
+ let canonical = Self::new(
+ self.source_url.clone(),
+ self.expected_sha256.clone(),
+ self.expected_media_type.clone(),
+ self.expected_width,
+ self.expected_height,
+ self.expected_byte_size,
+ self.alt.clone(),
+ )?;
+ (canonical == *self)
+ .then_some(())
+ .ok_or(Phase1InboundMediaError::CorruptState)
+ }
+
+ fn derive_fingerprint(&self) -> [u8; 32] {
+ let mut digest = Sha256Hasher::new();
+ digest.update(MEDIA_REFERENCE_FINGERPRINT_DOMAIN);
+ digest.update(self.source_url.as_bytes());
+ update_optional(&mut digest, self.expected_sha256.as_deref());
+ update_optional(&mut digest, self.expected_media_type.as_deref());
+ update_optional_u64(&mut digest, self.expected_width.map(u64::from));
+ update_optional_u64(&mut digest, self.expected_height.map(u64::from));
+ update_optional_u64(&mut digest, self.expected_byte_size);
+ update_optional(&mut digest, self.alt.as_deref());
+ digest.finalize().into()
+ }
+
+ pub fn source_url(&self) -> &str {
+ self.source_url.as_str()
+ }
+
+ pub fn expected_sha256(&self) -> Option<&str> {
+ self.expected_sha256.as_deref()
+ }
+
+ pub fn expected_media_type(&self) -> Option<&str> {
+ self.expected_media_type.as_deref()
+ }
+
+ pub const fn expected_width(&self) -> Option<u32> {
+ self.expected_width
+ }
+
+ pub const fn expected_height(&self) -> Option<u32> {
+ self.expected_height
+ }
+
+ pub const fn expected_byte_size(&self) -> Option<u64> {
+ self.expected_byte_size
+ }
+
+ pub fn alt(&self) -> Option<&str> {
+ self.alt.as_deref()
+ }
+
+ pub const fn fingerprint(&self) -> &[u8; 32] {
+ &self.fingerprint
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1InboundMediaPending {
+ operation_id: [u8; 16],
+ configuration: Phase1MediaConfigurationFingerprint,
+ started_at_unix_ms: u64,
+}
+
+impl Phase1InboundMediaPending {
+ pub fn new(
+ operation_id: [u8; 16],
+ configuration: Phase1MediaConfigurationFingerprint,
+ started_at_unix_ms: u64,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ configuration.validate()?;
+ if operation_id == [0; 16] || started_at_unix_ms == 0 {
+ return Err(Phase1InboundMediaError::InvalidOperation);
+ }
+ Ok(Self {
+ operation_id,
+ configuration,
+ started_at_unix_ms,
+ })
+ }
+
+ pub const fn operation_id(&self) -> &[u8; 16] {
+ &self.operation_id
+ }
+
+ pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint {
+ self.configuration
+ }
+
+ pub const fn started_at_unix_ms(&self) -> u64 {
+ self.started_at_unix_ms
+ }
+
+ fn validate(&self) -> Result<(), Phase1InboundMediaError> {
+ Self::new(
+ self.operation_id,
+ self.configuration,
+ self.started_at_unix_ms,
+ )
+ .map(|_| ())
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1InboundMediaFailure {
+ operation_id: [u8; 16],
+ safe_code: String,
+ retryable: bool,
+ failed_at_unix_ms: u64,
+}
+
+impl Phase1InboundMediaFailure {
+ pub fn new(
+ operation_id: [u8; 16],
+ safe_code: impl Into<String>,
+ retryable: bool,
+ failed_at_unix_ms: u64,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ let safe_code = safe_code.into();
+ if operation_id == [0; 16]
+ || failed_at_unix_ms == 0
+ || safe_code.is_empty()
+ || safe_code.len() > MEDIA_FAILURE_CODE_MAX_BYTES
+ || !safe_code
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
+ {
+ return Err(Phase1InboundMediaError::InvalidFailure);
+ }
+ Ok(Self {
+ operation_id,
+ safe_code,
+ retryable,
+ failed_at_unix_ms,
+ })
+ }
+
+ pub fn safe_code(&self) -> &str {
+ self.safe_code.as_str()
+ }
+
+ pub const fn retryable(&self) -> bool {
+ self.retryable
+ }
+
+ fn validate(&self) -> Result<(), Phase1InboundMediaError> {
+ Self::new(
+ self.operation_id,
+ self.safe_code.clone(),
+ self.retryable,
+ self.failed_at_unix_ms,
+ )
+ .map(|_| ())
+ }
+}
+
+/// Exact-byte verification evidence. Construction requires a byte commitment,
+/// binds every signed expected field, and derives the artifact identity from
+/// the observed digest rather than caller input.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1VerifiedMediaReceipt {
+ schema_version: u16,
+ reference_fingerprint: [u8; 32],
+ source_url: String,
+ canonical_final_url: String,
+ expected_sha256: String,
+ observed_sha256: String,
+ byte_size: u64,
+ media_type: String,
+ extension: String,
+ width: u32,
+ height: u32,
+ artifact_id: Phase1MediaArtifactId,
+ configuration: Phase1MediaConfigurationFingerprint,
+ verified_at_unix_ms: u64,
+}
+
+impl Phase1VerifiedMediaReceipt {
+ pub fn from_commitment(
+ reference: &Phase1StructuralMediaReference,
+ canonical_final_url: BlobUrl,
+ commitment: &ByteCommitment,
+ width: u32,
+ height: u32,
+ configuration: Phase1MediaConfigurationFingerprint,
+ verified_at_unix_ms: u64,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ reference.validate()?;
+ configuration.validate()?;
+ if verified_at_unix_ms == 0 {
+ return Err(Phase1InboundMediaError::InvalidVerificationTime);
+ }
+ BlobUrl::parse(reference.source_url())
+ .and_then(BlobUrl::approve)
+ .map_err(|_| Phase1InboundMediaError::InvalidReference)?;
+ canonical_final_url
+ .clone()
+ .approve()
+ .map_err(|_| Phase1InboundMediaError::InvalidReference)?;
+ validate_dimensions(Some(width), Some(height))?;
+ let observed_sha256 = commitment.sha256().to_hex();
+ let expected_sha256 = reference
+ .expected_sha256()
+ .ok_or(Phase1InboundMediaError::MissingDigest)?;
+ if observed_sha256 != expected_sha256
+ || reference
+ .expected_byte_size()
+ .is_some_and(|value| value != commitment.size())
+ || reference
+ .expected_media_type()
+ .is_some_and(|value| value != commitment.media_type().to_string())
+ || reference
+ .expected_width()
+ .is_some_and(|value| value != width)
+ || reference
+ .expected_height()
+ .is_some_and(|value| value != height)
+ {
+ return Err(Phase1InboundMediaError::MetadataMismatch);
+ }
+ let extension = canonical_final_url
+ .hash_path()
+ .extension()
+ .ok_or(Phase1InboundMediaError::InvalidReference)?
+ .as_str()
+ .to_owned();
+ if canonical_extension(commitment.media_type()) != Some(extension.as_str()) {
+ return Err(Phase1InboundMediaError::MetadataMismatch);
+ }
+ if canonical_final_url.hash_path().hash() != commitment.sha256() {
+ return Err(Phase1InboundMediaError::MetadataMismatch);
+ }
+ let receipt = Self {
+ schema_version: MEDIA_RECEIPT_SCHEMA_VERSION,
+ reference_fingerprint: *reference.fingerprint(),
+ source_url: reference.source_url().to_owned(),
+ canonical_final_url: canonical_final_url.to_string(),
+ expected_sha256: expected_sha256.to_owned(),
+ observed_sha256,
+ byte_size: commitment.size(),
+ media_type: commitment.media_type().to_string(),
+ extension,
+ width,
+ height,
+ artifact_id: Phase1MediaArtifactId::from_sha256(commitment.sha256()),
+ configuration,
+ verified_at_unix_ms,
+ };
+ receipt.validate(reference)?;
+ Ok(receipt)
+ }
+
+ fn validate(
+ &self,
+ reference: &Phase1StructuralMediaReference,
+ ) -> Result<(), Phase1InboundMediaError> {
+ self.validate_intrinsic()?;
+ if self.reference_fingerprint != *reference.fingerprint()
+ || self.source_url != reference.source_url()
+ || reference.expected_sha256() != Some(self.expected_sha256.as_str())
+ {
+ return Err(Phase1InboundMediaError::CorruptReceipt);
+ }
+ BlobUrl::parse(reference.source_url())
+ .and_then(BlobUrl::approve)
+ .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?;
+ if reference
+ .expected_byte_size()
+ .is_some_and(|value| value != self.byte_size)
+ || reference
+ .expected_media_type()
+ .is_some_and(|value| value != self.media_type)
+ || reference
+ .expected_width()
+ .is_some_and(|value| value != self.width)
+ || reference
+ .expected_height()
+ .is_some_and(|value| value != self.height)
+ {
+ return Err(Phase1InboundMediaError::CorruptReceipt);
+ }
+ Ok(())
+ }
+
+ fn validate_intrinsic(&self) -> Result<(), Phase1InboundMediaError> {
+ if self.schema_version != MEDIA_RECEIPT_SCHEMA_VERSION
+ || self.expected_sha256 != self.observed_sha256
+ || self.expected_sha256 != self.artifact_id.to_hex()
+ || self.byte_size == 0
+ || self.verified_at_unix_ms == 0
+ {
+ return Err(Phase1InboundMediaError::CorruptReceipt);
+ }
+ self.configuration
+ .validate()
+ .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?;
+ let final_url = BlobUrl::parse(&self.canonical_final_url)
+ .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?;
+ final_url
+ .clone()
+ .approve()
+ .map_err(|_| Phase1InboundMediaError::CorruptReceipt)?;
+ if final_url.to_string() != self.canonical_final_url
+ || final_url.hash_path().hash().to_hex() != self.observed_sha256
+ || final_url
+ .hash_path()
+ .extension()
+ .is_none_or(|value| value.as_str() != self.extension)
+ || !canonical_media_type(&self.media_type)
+ || MediaType::parse(&self.media_type)
+ .ok()
+ .and_then(|value| canonical_extension(&value))
+ != Some(self.extension.as_str())
+ || validate_dimensions(Some(self.width), Some(self.height)).is_err()
+ {
+ return Err(Phase1InboundMediaError::CorruptReceipt);
+ }
+ Ok(())
+ }
+
+ pub const fn artifact_id(&self) -> Phase1MediaArtifactId {
+ self.artifact_id
+ }
+
+ pub const fn configuration(&self) -> Phase1MediaConfigurationFingerprint {
+ self.configuration
+ }
+
+ pub fn canonical_final_url(&self) -> &str {
+ self.canonical_final_url.as_str()
+ }
+
+ pub fn observed_sha256(&self) -> &str {
+ self.observed_sha256.as_str()
+ }
+
+ pub const fn byte_size(&self) -> u64 {
+ self.byte_size
+ }
+
+ pub fn media_type(&self) -> &str {
+ self.media_type.as_str()
+ }
+
+ pub fn extension(&self) -> &str {
+ self.extension.as_str()
+ }
+
+ pub const fn width(&self) -> u32 {
+ self.width
+ }
+
+ pub const fn height(&self) -> u32 {
+ self.height
+ }
+
+ pub const fn verified_at_unix_ms(&self) -> u64 {
+ self.verified_at_unix_ms
+ }
+}
+
+/// One immutable exact-byte artifact in the authenticated user's local cache.
+#[cfg(feature = "mobile-social")]
+#[derive(Clone, Eq, PartialEq)]
+pub struct Phase1LocalMediaArtifact {
+ artifact_id: Phase1MediaArtifactId,
+ local_path: PathBuf,
+ bytes: Vec<u8>,
+ byte_size: u64,
+ media_type: String,
+ width: u32,
+ height: u32,
+}
+
+#[cfg(feature = "mobile-social")]
+impl std::fmt::Debug for Phase1LocalMediaArtifact {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("Phase1LocalMediaArtifact")
+ .field("artifact_id", &self.artifact_id)
+ .field("local_path", &"<redacted>")
+ .field("byte_size", &self.byte_size)
+ .field("media_type", &self.media_type)
+ .field("width", &self.width)
+ .field("height", &self.height)
+ .finish()
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+impl Phase1LocalMediaArtifact {
+ pub const fn artifact_id(&self) -> Phase1MediaArtifactId {
+ self.artifact_id
+ }
+
+ pub fn local_path(&self) -> &Path {
+ self.local_path.as_path()
+ }
+
+ pub fn bytes(&self) -> &[u8] {
+ self.bytes.as_slice()
+ }
+
+ pub const fn byte_size(&self) -> u64 {
+ self.byte_size
+ }
+
+ pub fn media_type(&self) -> &str {
+ self.media_type.as_str()
+ }
+
+ pub const fn width(&self) -> u32 {
+ self.width
+ }
+
+ pub const fn height(&self) -> u32 {
+ self.height
+ }
+}
+
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase", tag = "state", content = "evidence")]
+pub enum Phase1InboundMediaState {
+ #[default]
+ Unavailable,
+ Pending(Phase1InboundMediaPending),
+ Failed(Phase1InboundMediaFailure),
+ Verified(Box<Phase1VerifiedMediaReceipt>),
+}
+
+/// Public media model: signed structure plus local retrieval evidence.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct MediaReference {
+ structural: Phase1StructuralMediaReference,
+ retrieval: Phase1InboundMediaState,
+}
+
+impl MediaReference {
+ pub fn new(
+ structural: Phase1StructuralMediaReference,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ structural.validate()?;
+ Ok(Self {
+ structural,
+ retrieval: Phase1InboundMediaState::Unavailable,
+ })
+ }
+
+ pub(crate) fn legacy_unavailable(
+ source_url: String,
+ expected_sha256: Option<String>,
+ expected_media_type: Option<String>,
+ expected_width: Option<u32>,
+ expected_height: Option<u32>,
+ expected_byte_size: Option<u64>,
+ alt: Option<String>,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ Self::new(Phase1StructuralMediaReference::new(
+ source_url,
+ expected_sha256,
+ expected_media_type,
+ expected_width,
+ expected_height,
+ expected_byte_size,
+ alt,
+ )?)
+ }
+
+ pub fn structural(&self) -> &Phase1StructuralMediaReference {
+ &self.structural
+ }
+
+ pub const fn retrieval(&self) -> &Phase1InboundMediaState {
+ &self.retrieval
+ }
+
+ pub(crate) fn validate(&self) -> Result<(), Phase1InboundMediaError> {
+ self.structural.validate()?;
+ match &self.retrieval {
+ Phase1InboundMediaState::Unavailable => Ok(()),
+ Phase1InboundMediaState::Pending(value) => value.validate(),
+ Phase1InboundMediaState::Failed(value) => value.validate(),
+ Phase1InboundMediaState::Verified(value) => value.validate(&self.structural),
+ }
+ }
+
+ pub(crate) fn restore(
+ &mut self,
+ retrieval: Phase1InboundMediaState,
+ cache: &Phase1MediaCacheIndex,
+ ) -> Result<(), Phase1InboundMediaError> {
+ let mut candidate = self.clone();
+ candidate.retrieval = retrieval;
+ candidate.validate()?;
+ if let Phase1InboundMediaState::Verified(receipt) = &candidate.retrieval
+ && !cache.contains(receipt)
+ {
+ candidate.retrieval = Phase1InboundMediaState::Unavailable;
+ }
+ *self = candidate;
+ Ok(())
+ }
+
+ pub fn begin(
+ &mut self,
+ pending: Phase1InboundMediaPending,
+ ) -> Result<(), Phase1InboundMediaError> {
+ self.structural.validate()?;
+ pending.validate()?;
+ self.retrieval = Phase1InboundMediaState::Pending(pending);
+ Ok(())
+ }
+
+ pub fn fail(
+ &mut self,
+ failure: Phase1InboundMediaFailure,
+ ) -> Result<(), Phase1InboundMediaError> {
+ failure.validate()?;
+ match &self.retrieval {
+ Phase1InboundMediaState::Pending(pending)
+ if pending.operation_id == failure.operation_id =>
+ {
+ self.retrieval = Phase1InboundMediaState::Failed(failure);
+ Ok(())
+ }
+ _ => Err(Phase1InboundMediaError::OperationMismatch),
+ }
+ }
+
+ pub fn verify(
+ &mut self,
+ operation_id: [u8; 16],
+ receipt: Phase1VerifiedMediaReceipt,
+ ) -> Result<(), Phase1InboundMediaError> {
+ let Phase1InboundMediaState::Pending(pending) = &self.retrieval else {
+ return Err(Phase1InboundMediaError::OperationMismatch);
+ };
+ if pending.operation_id != operation_id || pending.configuration != receipt.configuration {
+ return Err(Phase1InboundMediaError::OperationMismatch);
+ }
+ receipt.validate(&self.structural)?;
+ self.retrieval = Phase1InboundMediaState::Verified(Box::new(receipt));
+ Ok(())
+ }
+
+ pub fn invalidate(&mut self) -> Option<Phase1MediaArtifactId> {
+ let artifact = match &self.retrieval {
+ Phase1InboundMediaState::Verified(receipt) => Some(receipt.artifact_id),
+ _ => None,
+ };
+ self.retrieval = Phase1InboundMediaState::Unavailable;
+ artifact
+ }
+
+ pub fn is_renderable_with(
+ &self,
+ cache: &Phase1MediaCacheIndex,
+ configuration: Phase1MediaConfigurationFingerprint,
+ ) -> bool {
+ match &self.retrieval {
+ Phase1InboundMediaState::Verified(receipt)
+ if receipt.configuration == configuration =>
+ {
+ cache.contains(receipt)
+ }
+ _ => false,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1MediaCachePolicy {
+ max_bytes: u64,
+ max_artifacts: u32,
+}
+
+impl Phase1MediaCachePolicy {
+ pub fn new(max_bytes: u64, max_artifacts: u32) -> Result<Self, Phase1InboundMediaError> {
+ if max_bytes == 0 || max_artifacts == 0 {
+ return Err(Phase1InboundMediaError::InvalidCachePolicy);
+ }
+ Ok(Self {
+ max_bytes,
+ max_artifacts,
+ })
+ }
+
+ pub const fn max_bytes(&self) -> u64 {
+ self.max_bytes
+ }
+
+ pub const fn max_artifacts(&self) -> u32 {
+ self.max_artifacts
+ }
+}
+
+impl Default for Phase1MediaCachePolicy {
+ fn default() -> Self {
+ Self {
+ max_bytes: 256 * 1024 * 1024,
+ max_artifacts: 2_000,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct Phase1MediaCacheEntry {
+ artifact_id: Phase1MediaArtifactId,
+ byte_size: u64,
+ media_type: String,
+ extension: String,
+ width: u32,
+ height: u32,
+ cached_at_unix_ms: u64,
+ last_accessed_at_unix_ms: u64,
+}
+
+impl Phase1MediaCacheEntry {
+ fn from_receipt(
+ receipt: &Phase1VerifiedMediaReceipt,
+ cached_at_unix_ms: u64,
+ ) -> Result<Self, Phase1InboundMediaError> {
+ if cached_at_unix_ms < receipt.verified_at_unix_ms {
+ return Err(Phase1InboundMediaError::InvalidCacheObservation);
+ }
+ Ok(Self {
+ artifact_id: receipt.artifact_id,
+ byte_size: receipt.byte_size,
+ media_type: receipt.media_type.clone(),
+ extension: receipt.extension.clone(),
+ width: receipt.width,
+ height: receipt.height,
+ cached_at_unix_ms,
+ last_accessed_at_unix_ms: cached_at_unix_ms,
+ })
+ }
+
+ fn matches(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool {
+ self.artifact_id == receipt.artifact_id
+ && self.byte_size == receipt.byte_size
+ && self.media_type == receipt.media_type
+ && self.extension == receipt.extension
+ && self.width == receipt.width
+ && self.height == receipt.height
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct Phase1MediaCacheIndex {
+ schema_version: u16,
+ configuration: Option<Phase1MediaConfigurationFingerprint>,
+ entries: BTreeMap<String, Phase1MediaCacheEntry>,
+}
+
+impl Default for Phase1MediaCacheIndex {
+ fn default() -> Self {
+ Self {
+ schema_version: MEDIA_CACHE_SCHEMA_VERSION,
+ configuration: None,
+ entries: BTreeMap::new(),
+ }
+ }
+}
+
+impl Phase1MediaCacheIndex {
+ pub fn admit(
+ &mut self,
+ receipt: &Phase1VerifiedMediaReceipt,
+ policy: Phase1MediaCachePolicy,
+ cached_at_unix_ms: u64,
+ ) -> Result<Vec<Phase1MediaArtifactId>, Phase1InboundMediaError> {
+ self.validate()?;
+ receipt.validate_intrinsic()?;
+ if receipt.byte_size > policy.max_bytes {
+ return Err(Phase1InboundMediaError::CacheQuotaExceeded);
+ }
+ if self
+ .configuration
+ .is_some_and(|value| value != receipt.configuration)
+ {
+ return Err(Phase1InboundMediaError::ConfigurationMismatch);
+ }
+ self.configuration = Some(receipt.configuration);
+ let key = receipt.artifact_id.to_hex();
+ let entry = Phase1MediaCacheEntry::from_receipt(receipt, cached_at_unix_ms)?;
+ if self
+ .entries
+ .get(&key)
+ .is_some_and(|existing| !existing.matches(receipt))
+ {
+ return Err(Phase1InboundMediaError::ArtifactCollision);
+ }
+ self.entries.insert(key.clone(), entry);
+ let mut evicted = Vec::new();
+ while self.entries.len() > policy.max_artifacts as usize
+ || self.total_bytes()? > policy.max_bytes
+ {
+ let oldest = self
+ .entries
+ .iter()
+ .filter(|(candidate, _)| candidate.as_str() != key)
+ .min_by_key(|(key, entry)| (entry.last_accessed_at_unix_ms, key.as_str()))
+ .map(|(key, _)| key.clone())
+ .ok_or(Phase1InboundMediaError::CorruptState)?;
+ let removed = self
+ .entries
+ .remove(&oldest)
+ .ok_or(Phase1InboundMediaError::CorruptState)?;
+ evicted.push(removed.artifact_id);
+ }
+ Ok(evicted)
+ }
+
+ pub fn contains(&self, receipt: &Phase1VerifiedMediaReceipt) -> bool {
+ self.schema_version == MEDIA_CACHE_SCHEMA_VERSION
+ && self.configuration == Some(receipt.configuration)
+ && self
+ .entries
+ .get(&receipt.artifact_id.to_hex())
+ .is_some_and(|entry| entry.matches(receipt))
+ }
+
+ pub fn touch(
+ &mut self,
+ artifact_id: Phase1MediaArtifactId,
+ observed_at_unix_ms: u64,
+ ) -> Result<bool, Phase1InboundMediaError> {
+ if observed_at_unix_ms == 0 {
+ return Err(Phase1InboundMediaError::InvalidCacheObservation);
+ }
+ let Some(entry) = self.entries.get_mut(&artifact_id.to_hex()) else {
+ return Ok(false);
+ };
+ entry.last_accessed_at_unix_ms = entry.last_accessed_at_unix_ms.max(observed_at_unix_ms);
+ Ok(true)
+ }
+
+ pub fn invalidate_artifact(&mut self, artifact_id: Phase1MediaArtifactId) -> bool {
+ self.entries.remove(&artifact_id.to_hex()).is_some()
+ }
+
+ pub fn invalidate_configuration(
+ &mut self,
+ configuration: Phase1MediaConfigurationFingerprint,
+ ) -> Vec<Phase1MediaArtifactId> {
+ if self
+ .configuration
+ .is_none_or(|current| current == configuration)
+ {
+ self.configuration = Some(configuration);
+ return Vec::new();
+ }
+ let removed = self
+ .entries
+ .values()
+ .map(|entry| entry.artifact_id)
+ .collect();
+ self.entries.clear();
+ self.configuration = Some(configuration);
+ removed
+ }
+
+ pub fn artifact_count(&self) -> u32 {
+ self.entries.len().try_into().unwrap_or(u32::MAX)
+ }
+
+ pub fn total_bytes(&self) -> Result<u64, Phase1InboundMediaError> {
+ self.entries.values().try_fold(0_u64, |total, entry| {
+ total
+ .checked_add(entry.byte_size)
+ .ok_or(Phase1InboundMediaError::CorruptState)
+ })
+ }
+
+ fn validate(&self) -> Result<(), Phase1InboundMediaError> {
+ if self.schema_version != MEDIA_CACHE_SCHEMA_VERSION
+ || (self.configuration.is_none() && !self.entries.is_empty())
+ || self.entries.iter().any(|(key, entry)| {
+ key != &entry.artifact_id.to_hex()
+ || key != &hex::encode(entry.artifact_id.as_bytes())
+ || entry.byte_size == 0
+ || entry.cached_at_unix_ms == 0
+ || entry.last_accessed_at_unix_ms < entry.cached_at_unix_ms
+ || !canonical_media_type(&entry.media_type)
+ || entry.extension.is_empty()
+ || validate_dimensions(Some(entry.width), Some(entry.height)).is_err()
+ })
+ {
+ return Err(Phase1InboundMediaError::CorruptState);
+ }
+ if self
+ .configuration
+ .is_some_and(|value| value.validate().is_err())
+ {
+ return Err(Phase1InboundMediaError::CorruptState);
+ }
+ self.total_bytes().map(|_| ())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Phase1MediaCacheStatus {
+ pub artifacts: u32,
+ pub bytes: u64,
+ pub configuration: Option<Phase1MediaConfigurationFingerprint>,
+}
+
+impl Phase1MediaCacheIndex {
+ pub fn status(&self) -> Result<Phase1MediaCacheStatus, Phase1InboundMediaError> {
+ self.validate()?;
+ Ok(Phase1MediaCacheStatus {
+ artifacts: self.artifact_count(),
+ bytes: self.total_bytes()?,
+ configuration: self.configuration,
+ })
+ }
+}
+
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
+pub enum Phase1InboundMediaError {
+ #[error("inbound media reference is invalid")]
+ InvalidReference,
+ #[error("inbound media digest is invalid")]
+ InvalidDigest,
+ #[error("inbound media reference requires a digest")]
+ MissingDigest,
+ #[error("inbound media type is invalid")]
+ InvalidMediaType,
+ #[error("inbound media dimensions are invalid")]
+ InvalidDimensions,
+ #[error("inbound media byte size is invalid")]
+ InvalidByteSize,
+ #[error("inbound media alternative text is invalid")]
+ InvalidAlt,
+ #[error("inbound media metadata does not match verified bytes")]
+ MetadataMismatch,
+ #[error("inbound media operation is invalid")]
+ InvalidOperation,
+ #[error("inbound media operation identity does not match")]
+ OperationMismatch,
+ #[error("inbound media failure evidence is invalid")]
+ InvalidFailure,
+ #[error("inbound media configuration is invalid")]
+ InvalidConfiguration,
+ #[error("inbound media configuration changed")]
+ ConfigurationMismatch,
+ #[error("inbound media verification time is invalid")]
+ InvalidVerificationTime,
+ #[error("inbound media cache policy is invalid")]
+ InvalidCachePolicy,
+ #[error("inbound media cache observation is invalid")]
+ InvalidCacheObservation,
+ #[error("inbound media artifact exceeds cache quota")]
+ CacheQuotaExceeded,
+ #[error("inbound media artifact identity collides with different metadata")]
+ ArtifactCollision,
+ #[error("inbound media receipt is corrupt")]
+ CorruptReceipt,
+ #[error("inbound media state is corrupt")]
+ CorruptState,
+ #[error("inbound media schema version is unsupported")]
+ UnsupportedSchema,
+ #[error("inbound media cache directory is unavailable")]
+ CacheUnavailable,
+ #[error("inbound media cache filesystem operation failed")]
+ CacheIo,
+ #[error("inbound media cache artifact is corrupt")]
+ CorruptArtifact,
+}
+
+#[cfg(feature = "mobile-social")]
+pub(crate) async fn write_verified_artifact(
+ directory: &Path,
+ receipt: &Phase1VerifiedMediaReceipt,
+ bytes: &[u8],
+) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> {
+ receipt.validate_intrinsic()?;
+ if bytes.len() as u64 != receipt.byte_size
+ || Sha256::digest(bytes).to_hex() != receipt.observed_sha256
+ {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ ensure_cache_directory(directory).await?;
+ let final_path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?;
+ match tokio::fs::symlink_metadata(&final_path).await {
+ Ok(_) => {
+ let verified_bytes = verify_artifact_file(&final_path, receipt).await?;
+ return Ok(local_artifact(final_path, receipt, verified_bytes));
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(_) => return Err(Phase1InboundMediaError::CacheIo),
+ }
+
+ let temporary_path = directory.join(format!(
+ ".{}.{}.tmp",
+ receipt.artifact_id.to_hex(),
+ uuid::Uuid::new_v4().simple()
+ ));
+ let mut temporary = tokio::fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&temporary_path)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ let write_result = async {
+ temporary
+ .write_all(bytes)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ temporary
+ .flush()
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ temporary
+ .sync_all()
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ drop(temporary);
+ match tokio::fs::hard_link(&temporary_path, &final_path).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+ verify_artifact_file(&final_path, receipt).await?;
+ }
+ Err(_) => return Err(Phase1InboundMediaError::CacheIo),
+ }
+ tokio::fs::remove_file(&temporary_path)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ sync_cache_directory(directory).await?;
+ verify_artifact_file(&final_path, receipt).await
+ }
+ .await;
+ if write_result.is_err() {
+ let _ = tokio::fs::remove_file(&temporary_path).await;
+ }
+ let verified_bytes = write_result?;
+ Ok(local_artifact(final_path, receipt, verified_bytes))
+}
+
+#[cfg(feature = "mobile-social")]
+pub(crate) async fn remove_artifact_files(
+ directory: &Path,
+ artifact_id: Phase1MediaArtifactId,
+) -> Result<(), Phase1InboundMediaError> {
+ ensure_cache_directory(directory).await?;
+ for extension in MEDIA_CACHE_EXTENSIONS {
+ let path = artifact_path(directory, artifact_id, extension)?;
+ match tokio::fs::symlink_metadata(&path).await {
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ Ok(_) => tokio::fs::remove_file(path)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(_) => return Err(Phase1InboundMediaError::CacheIo),
+ }
+ }
+ sync_cache_directory(directory).await
+}
+
+#[cfg(feature = "mobile-social")]
+pub(crate) async fn verified_artifact(
+ directory: &Path,
+ receipt: &Phase1VerifiedMediaReceipt,
+) -> Result<Phase1LocalMediaArtifact, Phase1InboundMediaError> {
+ receipt.validate_intrinsic()?;
+ ensure_cache_directory(directory).await?;
+ let path = artifact_path(directory, receipt.artifact_id, receipt.extension.as_str())?;
+ let verified_bytes = verify_artifact_file(&path, receipt).await?;
+ Ok(local_artifact(path, receipt, verified_bytes))
+}
+
+#[cfg(feature = "mobile-social")]
+async fn ensure_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> {
+ match tokio::fs::create_dir(directory).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(_) => return Err(Phase1InboundMediaError::CacheIo),
+ }
+ let metadata = tokio::fs::symlink_metadata(directory)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ Ok(())
+}
+
+#[cfg(feature = "mobile-social")]
+fn artifact_path(
+ directory: &Path,
+ artifact_id: Phase1MediaArtifactId,
+ extension: &str,
+) -> Result<PathBuf, Phase1InboundMediaError> {
+ if !MEDIA_CACHE_EXTENSIONS.contains(&extension) {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ Ok(directory.join(format!("{}.{}", artifact_id.to_hex(), extension)))
+}
+
+#[cfg(feature = "mobile-social")]
+async fn verify_artifact_file(
+ path: &Path,
+ receipt: &Phase1VerifiedMediaReceipt,
+) -> Result<Vec<u8>, Phase1InboundMediaError> {
+ let metadata = tokio::fs::symlink_metadata(path)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CorruptArtifact)?;
+ if metadata.file_type().is_symlink()
+ || !metadata.is_file()
+ || metadata.len() != receipt.byte_size
+ {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ let bytes = tokio::fs::read(path)
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?;
+ if Sha256::digest(bytes.as_slice()).to_hex() != receipt.observed_sha256 {
+ return Err(Phase1InboundMediaError::CorruptArtifact);
+ }
+ Ok(bytes)
+}
+
+#[cfg(feature = "mobile-social")]
+async fn sync_cache_directory(directory: &Path) -> Result<(), Phase1InboundMediaError> {
+ let directory = directory.to_path_buf();
+ tokio::task::spawn_blocking(move || std::fs::File::open(directory)?.sync_all())
+ .await
+ .map_err(|_| Phase1InboundMediaError::CacheIo)?
+ .map_err(|_| Phase1InboundMediaError::CacheIo)
+}
+
+#[cfg(feature = "mobile-social")]
+fn local_artifact(
+ local_path: PathBuf,
+ receipt: &Phase1VerifiedMediaReceipt,
+ bytes: Vec<u8>,
+) -> Phase1LocalMediaArtifact {
+ Phase1LocalMediaArtifact {
+ artifact_id: receipt.artifact_id,
+ local_path,
+ bytes,
+ byte_size: receipt.byte_size,
+ media_type: receipt.media_type.clone(),
+ width: receipt.width,
+ height: receipt.height,
+ }
+}
+
+fn validate_url_text(value: &str) -> Result<(), Phase1InboundMediaError> {
+ if value.is_empty()
+ || value.len() > MEDIA_URL_MAX_BYTES
+ || value.chars().any(|character| character.is_control())
+ {
+ return Err(Phase1InboundMediaError::InvalidReference);
+ }
+ Ok(())
+}
+
+fn canonical_media_type(value: &str) -> bool {
+ MediaType::parse(value).is_ok_and(|parsed| parsed.to_string() == value)
+}
+
+fn canonical_extension(media_type: &MediaType) -> Option<&'static str> {
+ match media_type.as_str() {
+ "image/gif" => Some("gif"),
+ "image/jpeg" => Some("jpg"),
+ "image/png" => Some("png"),
+ "image/webp" => Some("webp"),
+ _ => None,
+ }
+}
+
+fn validate_dimensions(
+ width: Option<u32>,
+ height: Option<u32>,
+) -> Result<(), Phase1InboundMediaError> {
+ match (width, height) {
+ (None, None) => Ok(()),
+ (Some(width), Some(height))
+ if width != 0
+ && height != 0
+ && width <= MEDIA_DIMENSION_MAX_EDGE
+ && height <= MEDIA_DIMENSION_MAX_EDGE
+ && u64::from(width) * u64::from(height) <= MEDIA_DIMENSION_MAX_PIXELS =>
+ {
+ Ok(())
+ }
+ _ => Err(Phase1InboundMediaError::InvalidDimensions),
+ }
+}
+
+fn update_optional(digest: &mut Sha256Hasher, value: Option<&str>) {
+ match value {
+ Some(value) => {
+ digest.update([1]);
+ digest.update((value.len() as u64).to_be_bytes());
+ digest.update(value.as_bytes());
+ }
+ None => digest.update([0]),
+ }
+}
+
+fn update_optional_u64(digest: &mut Sha256Hasher, value: Option<u64>) {
+ match value {
+ Some(value) => {
+ digest.update([1]);
+ digest.update(value.to_be_bytes());
+ }
+ None => digest.update([0]),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ type ReceiptMutation = Box<dyn Fn(&mut Phase1VerifiedMediaReceipt)>;
+ type CacheMutation = Box<dyn Fn(&mut Phase1MediaCacheIndex)>;
+
+ const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
+
+ fn reference(alt: Option<&str>) -> Phase1StructuralMediaReference {
+ Phase1StructuralMediaReference::new(
+ format!("https://media.example/{HASH}.jpg"),
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ alt.map(str::to_owned),
+ )
+ .expect("reference")
+ }
+
+ fn configuration(value: u8) -> Phase1MediaConfigurationFingerprint {
+ Phase1MediaConfigurationFingerprint::new([value; 32]).expect("configuration")
+ }
+
+ fn receipt(
+ reference: &Phase1StructuralMediaReference,
+ configuration: Phase1MediaConfigurationFingerprint,
+ ) -> Phase1VerifiedMediaReceipt {
+ let commitment =
+ ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap());
+ Phase1VerifiedMediaReceipt::from_commitment(
+ reference,
+ BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(),
+ &commitment,
+ 2,
+ 3,
+ configuration,
+ 10,
+ )
+ .expect("receipt")
+ }
+
+ #[test]
+ fn structural_reference_is_canonical_and_metadata_sensitive() {
+ let first = reference(Some("Harvest"));
+ let second = reference(Some("Harvest detail"));
+ assert_ne!(first.fingerprint(), second.fingerprint());
+ assert_eq!(first.expected_sha256(), Some(HASH));
+ assert!(
+ Phase1StructuralMediaReference::new(
+ format!("https://media.example/{}.jpg", "a".repeat(64)),
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ None,
+ )
+ .is_err()
+ );
+ let interoperable = Phase1StructuralMediaReference::new(
+ "https://cdn.example/harvest.jpg",
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ None,
+ )
+ .expect("non-Blossom NIP-92 reference remains structural");
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &interoperable,
+ BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(),
+ &ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap()),
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn structural_reference_rejects_each_noncanonical_field_independently() {
+ for source_url in [
+ "",
+ "ftp://media.example/file.jpg",
+ "https:///file.jpg",
+ "https://user@media.example/file.jpg",
+ "https://user:password@media.example/file.jpg",
+ "https://media.example/file.jpg\n",
+ ] {
+ assert!(
+ Phase1StructuralMediaReference::new(
+ source_url,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ .is_err(),
+ "source URL should fail: {source_url:?}"
+ );
+ }
+ assert!(
+ Phase1StructuralMediaReference::new(
+ format!("https://media.example/{}", "x".repeat(MEDIA_URL_MAX_BYTES)),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ .is_err()
+ );
+ for digest in ["not-hex".to_owned(), HASH.to_ascii_uppercase()] {
+ assert!(
+ Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ Some(digest),
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ .is_err()
+ );
+ }
+ assert!(
+ Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ Some(HASH.to_owned()),
+ Some("not a media type".to_owned()),
+ None,
+ None,
+ None,
+ None,
+ )
+ .is_err()
+ );
+ for (width, height) in [
+ (Some(1), None),
+ (None, Some(1)),
+ (Some(0), Some(1)),
+ (Some(1), Some(0)),
+ (Some(MEDIA_DIMENSION_MAX_EDGE + 1), Some(1)),
+ (Some(1), Some(MEDIA_DIMENSION_MAX_EDGE + 1)),
+ (Some(10_001), Some(10_000)),
+ ] {
+ assert!(
+ Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ width,
+ height,
+ Some(5),
+ None,
+ )
+ .is_err()
+ );
+ }
+ for alt in [
+ "x".repeat(MEDIA_ALT_MAX_BYTES + 1),
+ "line\nbreak".to_owned(),
+ ] {
+ assert!(
+ Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ None,
+ None,
+ Some(5),
+ Some(alt),
+ )
+ .is_err()
+ );
+ }
+ assert!(
+ Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ Some(HASH.to_owned()),
+ Some("image/jpeg".to_owned()),
+ None,
+ None,
+ Some(0),
+ None,
+ )
+ .is_err()
+ );
+
+ let mut unsupported = reference(None);
+ unsupported.schema_version = 2;
+ assert_eq!(
+ unsupported.validate(),
+ Err(Phase1InboundMediaError::UnsupportedSchema)
+ );
+ let mut corrupt = reference(None);
+ corrupt.fingerprint = [9; 32];
+ assert_eq!(
+ corrupt.validate(),
+ Err(Phase1InboundMediaError::CorruptState)
+ );
+ }
+
+ #[test]
+ fn operation_and_failure_evidence_reject_each_invalid_field() {
+ assert!(Phase1MediaConfigurationFingerprint::new([0; 32]).is_err());
+ assert!(Phase1MediaConfigurationFingerprint::parse("not-hex").is_err());
+ assert!(Phase1MediaConfigurationFingerprint::parse("00").is_err());
+ assert!(Phase1MediaArtifactId::parse("not-hex").is_err());
+
+ assert!(Phase1InboundMediaPending::new([0; 16], configuration(1), 1).is_err());
+ assert!(Phase1InboundMediaPending::new([1; 16], configuration(1), 0).is_err());
+ assert!(
+ Phase1InboundMediaPending::new(
+ [1; 16],
+ Phase1MediaConfigurationFingerprint([0; 32]),
+ 1,
+ )
+ .is_err()
+ );
+
+ for (operation_id, code, failed_at) in [
+ ([0; 16], "failed".to_owned(), 1),
+ ([1; 16], "failed".to_owned(), 0),
+ ([1; 16], String::new(), 1),
+ ([1; 16], "x".repeat(MEDIA_FAILURE_CODE_MAX_BYTES + 1), 1),
+ ([1; 16], "Not_Safe".to_owned(), 1),
+ ] {
+ assert!(Phase1InboundMediaFailure::new(operation_id, code, true, failed_at).is_err());
+ }
+ let evidence = Phase1InboundMediaFailure::new([2; 16], "retry_2", true, 9).unwrap();
+ assert_eq!(evidence.safe_code(), "retry_2");
+ assert!(evidence.retryable());
+ }
+
+ #[test]
+ fn media_helper_vocabularies_cover_every_closed_outcome() {
+ assert!(validate_url_text("https://example.test/media").is_ok());
+ assert_eq!(
+ validate_url_text(""),
+ Err(Phase1InboundMediaError::InvalidReference)
+ );
+ assert_eq!(
+ validate_url_text(&"x".repeat(MEDIA_URL_MAX_BYTES + 1)),
+ Err(Phase1InboundMediaError::InvalidReference)
+ );
+ assert_eq!(
+ validate_url_text("bad\nurl"),
+ Err(Phase1InboundMediaError::InvalidReference)
+ );
+ assert!(canonical_media_type("image/jpeg"));
+ assert!(!canonical_media_type("IMAGE/JPEG"));
+ assert_eq!(
+ canonical_extension(&MediaType::parse("image/gif").unwrap()),
+ Some("gif")
+ );
+ assert_eq!(
+ canonical_extension(&MediaType::parse("image/jpeg").unwrap()),
+ Some("jpg")
+ );
+ assert_eq!(
+ canonical_extension(&MediaType::parse("image/png").unwrap()),
+ Some("png")
+ );
+ assert_eq!(
+ canonical_extension(&MediaType::parse("image/webp").unwrap()),
+ Some("webp")
+ );
+ assert_eq!(
+ canonical_extension(&MediaType::parse("image/svg+xml").unwrap()),
+ None
+ );
+ assert!(validate_dimensions(None, None).is_ok());
+ assert!(validate_dimensions(Some(10_000), Some(10_000)).is_ok());
+ }
+
+ #[test]
+ fn verified_state_requires_matching_operation_bytes_and_configuration() {
+ let structural = reference(None);
+ let mut media = MediaReference::new(structural.clone()).unwrap();
+ let pending = Phase1InboundMediaPending::new([7; 16], configuration(3), 9).unwrap();
+ media.begin(pending).unwrap();
+ assert_eq!(
+ media.verify([8; 16], receipt(&structural, configuration(3))),
+ Err(Phase1InboundMediaError::OperationMismatch)
+ );
+ media
+ .verify([7; 16], receipt(&structural, configuration(3)))
+ .unwrap();
+ assert!(matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ media
+ .restore(
+ Phase1InboundMediaState::Unavailable,
+ &Phase1MediaCacheIndex::default(),
+ )
+ .unwrap();
+ assert!(matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ }
+
+ #[test]
+ fn media_state_transitions_and_renderability_cover_every_state() {
+ let structural = reference(None);
+ let config = configuration(3);
+ let verified = receipt(&structural, config);
+ let pending = Phase1InboundMediaPending::new([7; 16], config, 9).unwrap();
+ let failure = Phase1InboundMediaFailure::new([7; 16], "network", true, 10).unwrap();
+ let wrong_failure = Phase1InboundMediaFailure::new([8; 16], "network", true, 10).unwrap();
+
+ let mut media = MediaReference::new(structural.clone()).unwrap();
+ assert_eq!(media.invalidate(), None);
+ assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config));
+ assert_eq!(
+ media.fail(failure.clone()),
+ Err(Phase1InboundMediaError::OperationMismatch)
+ );
+ assert_eq!(
+ media.verify([7; 16], verified.clone()),
+ Err(Phase1InboundMediaError::OperationMismatch)
+ );
+
+ media.begin(pending.clone()).unwrap();
+ assert_eq!(
+ media.fail(wrong_failure),
+ Err(Phase1InboundMediaError::OperationMismatch)
+ );
+ assert_eq!(
+ media.verify([7; 16], receipt(&structural, configuration(4))),
+ Err(Phase1InboundMediaError::OperationMismatch)
+ );
+ media.fail(failure).unwrap();
+ assert!(matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Failed(_)
+ ));
+ assert!(media.validate().is_ok());
+
+ media.begin(pending).unwrap();
+ media.verify([7; 16], verified.clone()).unwrap();
+ assert!(!media.is_renderable_with(&Phase1MediaCacheIndex::default(), config));
+ let mut cache = Phase1MediaCacheIndex::default();
+ cache
+ .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10)
+ .unwrap();
+ assert!(media.is_renderable_with(&cache, config));
+ assert!(!media.is_renderable_with(&cache, configuration(4)));
+ assert_eq!(media.invalidate(), Some(verified.artifact_id()));
+
+ media
+ .restore(
+ Phase1InboundMediaState::Verified(Box::new(verified.clone())),
+ &Phase1MediaCacheIndex::default(),
+ )
+ .unwrap();
+ assert!(matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ media
+ .restore(
+ Phase1InboundMediaState::Verified(Box::new(verified)),
+ &cache,
+ )
+ .unwrap();
+ assert!(matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ }
+
+ #[test]
+ fn receipt_rejects_hash_size_type_and_dimension_mismatch() {
+ let expected = reference(None);
+ let wrong_bytes =
+ ByteCommitment::from_bytes(b"other", MediaType::parse("image/jpeg").unwrap());
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &expected,
+ BlobUrl::parse(&format!("https://cdn.example/{}.jpg", wrong_bytes.sha256()))
+ .unwrap(),
+ &wrong_bytes,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ )
+ .is_err()
+ );
+ let commitment =
+ ByteCommitment::from_bytes(b"hello", MediaType::parse("image/png").unwrap());
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &expected,
+ BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap(),
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn receipt_validation_rejects_each_bound_field_independently() {
+ let expected = reference(None);
+ let commitment =
+ ByteCommitment::from_bytes(b"hello", MediaType::parse("image/jpeg").unwrap());
+ let final_url = BlobUrl::parse(&format!("https://cdn.example/{HASH}.jpg")).unwrap();
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &expected,
+ final_url.clone(),
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 0,
+ )
+ .is_err()
+ );
+
+ let mutations: [fn(&mut Phase1StructuralMediaReference); 4] = [
+ |value: &mut Phase1StructuralMediaReference| value.expected_byte_size = Some(6),
+ |value: &mut Phase1StructuralMediaReference| {
+ value.expected_media_type = Some("image/png".to_owned())
+ },
+ |value: &mut Phase1StructuralMediaReference| value.expected_width = Some(3),
+ |value: &mut Phase1StructuralMediaReference| value.expected_height = Some(4),
+ ];
+ for mutate in mutations {
+ let mut changed = expected.clone();
+ mutate(&mut changed);
+ changed.fingerprint = changed.derive_fingerprint();
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &changed,
+ final_url.clone(),
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ )
+ .is_err()
+ );
+ }
+
+ let mut without_digest = Phase1StructuralMediaReference::new(
+ "https://media.example/file.jpg",
+ None,
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ None,
+ )
+ .unwrap();
+ without_digest.expected_sha256 = None;
+ without_digest.fingerprint = without_digest.derive_fingerprint();
+ assert!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &without_digest,
+ final_url,
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ )
+ .is_err()
+ );
+
+ let valid = receipt(&expected, configuration(1));
+ let mut corruptions: Vec<ReceiptMutation> = vec![
+ Box::new(|value| value.schema_version = 2),
+ Box::new(|value| value.expected_sha256 = "a".repeat(64)),
+ Box::new(|value| value.artifact_id = Phase1MediaArtifactId([8; 32])),
+ Box::new(|value| value.byte_size = 0),
+ Box::new(|value| value.verified_at_unix_ms = 0),
+ Box::new(|value| value.configuration = Phase1MediaConfigurationFingerprint([0; 32])),
+ Box::new(|value| value.canonical_final_url = "not a url".to_owned()),
+ Box::new(|value| value.canonical_final_url.push_str("?changed=1")),
+ Box::new(|value| value.extension = "png".to_owned()),
+ Box::new(|value| value.media_type = "not a media type".to_owned()),
+ Box::new(|value| value.media_type = "image/svg+xml".to_owned()),
+ Box::new(|value| value.width = 0),
+ ];
+ for mutate in corruptions.drain(..) {
+ let mut changed = valid.clone();
+ mutate(&mut changed);
+ assert_eq!(
+ changed.validate_intrinsic(),
+ Err(Phase1InboundMediaError::CorruptReceipt)
+ );
+ }
+ assert_eq!(valid.artifact_id().to_hex(), HASH);
+ assert_eq!(valid.configuration(), configuration(1));
+ assert_eq!(
+ valid.canonical_final_url(),
+ format!("https://cdn.example/{HASH}.jpg")
+ );
+ assert_eq!(valid.observed_sha256(), HASH);
+ assert_eq!(valid.byte_size(), 5);
+ assert_eq!(valid.media_type(), "image/jpeg");
+ assert_eq!(valid.extension(), "jpg");
+ assert_eq!(
+ (valid.width(), valid.height(), valid.verified_at_unix_ms()),
+ (2, 3, 10)
+ );
+
+ let wrong_extension = BlobUrl::parse(&format!("https://cdn.example/{HASH}.png")).unwrap();
+ assert_eq!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &expected,
+ wrong_extension,
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ ),
+ Err(Phase1InboundMediaError::MetadataMismatch)
+ );
+ let wrong_path_hash = "a".repeat(64);
+ assert_eq!(
+ Phase1VerifiedMediaReceipt::from_commitment(
+ &expected,
+ BlobUrl::parse(&format!("https://cdn.example/{wrong_path_hash}.jpg")).unwrap(),
+ &commitment,
+ 2,
+ 3,
+ configuration(1),
+ 1,
+ ),
+ Err(Phase1InboundMediaError::MetadataMismatch)
+ );
+
+ let reference_mutations: [fn(&mut Phase1StructuralMediaReference); 7] = [
+ |value| value.fingerprint = [8; 32],
+ |value| value.source_url = "https://other.example/file.jpg".to_owned(),
+ |value| value.expected_sha256 = Some("a".repeat(64)),
+ |value| value.expected_byte_size = Some(6),
+ |value| value.expected_media_type = Some("image/png".to_owned()),
+ |value| value.expected_width = Some(3),
+ |value| value.expected_height = Some(4),
+ ];
+ for mutate in reference_mutations {
+ let mut changed = expected.clone();
+ mutate(&mut changed);
+ assert_eq!(
+ valid.validate(&changed),
+ Err(Phase1InboundMediaError::CorruptReceipt)
+ );
+ }
+ }
+
+ #[test]
+ fn cache_validation_rejects_each_invalid_field_independently() {
+ assert!(Phase1MediaCachePolicy::new(0, 1).is_err());
+ assert!(Phase1MediaCachePolicy::new(1, 0).is_err());
+ let policy = Phase1MediaCachePolicy::default();
+ assert_eq!(policy.max_bytes(), 256 * 1024 * 1024);
+ assert_eq!(policy.max_artifacts(), 2_000);
+
+ let structural = reference(None);
+ let verified = receipt(&structural, configuration(4));
+ assert!(Phase1MediaCacheEntry::from_receipt(&verified, 9).is_err());
+ let mut cache = Phase1MediaCacheIndex::default();
+ assert!(!cache.touch(verified.artifact_id(), 1).unwrap());
+ assert!(cache.touch(verified.artifact_id(), 0).is_err());
+ assert!(!cache.invalidate_artifact(verified.artifact_id()));
+ assert!(cache.invalidate_configuration(configuration(4)).is_empty());
+ cache
+ .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10)
+ .unwrap();
+ assert!(cache.touch(verified.artifact_id(), 11).unwrap());
+ assert!(cache.invalidate_artifact(verified.artifact_id()));
+
+ let mut baseline = Phase1MediaCacheIndex::default();
+ baseline
+ .admit(&verified, Phase1MediaCachePolicy::new(5, 1).unwrap(), 10)
+ .unwrap();
+ let key = verified.artifact_id().to_hex();
+ let mutations: Vec<CacheMutation> = vec![
+ Box::new(|value| value.schema_version = 2),
+ Box::new(|value| value.configuration = None),
+ Box::new({
+ let key = key.clone();
+ move |value| value.entries.get_mut(&key).unwrap().byte_size = 0
+ }),
+ Box::new({
+ let key = key.clone();
+ move |value| value.entries.get_mut(&key).unwrap().cached_at_unix_ms = 0
+ }),
+ Box::new({
+ let key = key.clone();
+ move |value| {
+ value
+ .entries
+ .get_mut(&key)
+ .unwrap()
+ .last_accessed_at_unix_ms = 9
+ }
+ }),
+ Box::new({
+ let key = key.clone();
+ move |value| value.entries.get_mut(&key).unwrap().media_type = "bad".to_owned()
+ }),
+ Box::new({
+ let key = key.clone();
+ move |value| value.entries.get_mut(&key).unwrap().extension.clear()
+ }),
+ Box::new({
+ let key = key.clone();
+ move |value| value.entries.get_mut(&key).unwrap().width = 0
+ }),
+ Box::new(|value| {
+ let entry = value.entries.pop_first().unwrap().1;
+ value.entries.insert("wrong-key".to_owned(), entry);
+ }),
+ Box::new(|value| {
+ value.configuration = Some(Phase1MediaConfigurationFingerprint([0; 32]));
+ }),
+ ];
+ for mutate in mutations {
+ let mut changed = baseline.clone();
+ mutate(&mut changed);
+ assert_eq!(changed.status(), Err(Phase1InboundMediaError::CorruptState));
+ }
+ assert_eq!(
+ baseline.admit(&verified, Phase1MediaCachePolicy::new(4, 1).unwrap(), 10),
+ Err(Phase1InboundMediaError::CacheQuotaExceeded)
+ );
+
+ let mut wrong_schema = baseline.clone();
+ wrong_schema.schema_version = 2;
+ assert!(!wrong_schema.contains(&verified));
+ let mut wrong_configuration = baseline.clone();
+ wrong_configuration.configuration = Some(configuration(5));
+ assert!(!wrong_configuration.contains(&verified));
+ let mut missing = baseline.clone();
+ missing.entries.clear();
+ assert!(!missing.contains(&verified));
+ let mut mismatched = baseline.clone();
+ mismatched.entries.get_mut(&key).unwrap().height = 4;
+ assert!(!mismatched.contains(&verified));
+
+ let receipt_mutations: [fn(&mut Phase1VerifiedMediaReceipt); 6] = [
+ |value| value.artifact_id = Phase1MediaArtifactId([8; 32]),
+ |value| value.byte_size = 6,
+ |value| value.media_type = "image/png".to_owned(),
+ |value| value.extension = "png".to_owned(),
+ |value| value.width = 3,
+ |value| value.height = 4,
+ ];
+ let entry = baseline.entries.get(&key).unwrap();
+ for mutate in receipt_mutations {
+ let mut changed = verified.clone();
+ mutate(&mut changed);
+ assert!(!entry.matches(&changed));
+ }
+
+ let mut collision = baseline.clone();
+ collision.entries.get_mut(&key).unwrap().byte_size = 4;
+ assert_eq!(
+ collision.admit(&verified, Phase1MediaCachePolicy::new(10, 2).unwrap(), 10),
+ Err(Phase1InboundMediaError::ArtifactCollision)
+ );
+
+ let second_hash = Sha256::digest(b"world").to_hex();
+ let second_reference = Phase1StructuralMediaReference::new(
+ format!("https://media.example/{second_hash}.jpg"),
+ Some(second_hash.clone()),
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ None,
+ )
+ .unwrap();
+ let second = Phase1VerifiedMediaReceipt::from_commitment(
+ &second_reference,
+ BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(),
+ &ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap()),
+ 2,
+ 3,
+ configuration(4),
+ 11,
+ )
+ .unwrap();
+ let mut byte_limited = baseline.clone();
+ assert_eq!(
+ byte_limited
+ .admit(&second, Phase1MediaCachePolicy::new(8, 2).unwrap(), 11)
+ .unwrap(),
+ vec![verified.artifact_id()]
+ );
+
+ let mut overflow = Phase1MediaCacheIndex {
+ configuration: Some(configuration(4)),
+ ..Phase1MediaCacheIndex::default()
+ };
+ let mut first_entry = Phase1MediaCacheEntry::from_receipt(&verified, 10).unwrap();
+ first_entry.byte_size = u64::MAX;
+ overflow.entries.insert(key, first_entry);
+ let second_key = second.artifact_id().to_hex();
+ overflow.entries.insert(
+ second_key,
+ Phase1MediaCacheEntry::from_receipt(&second, 11).unwrap(),
+ );
+ assert_eq!(
+ overflow.total_bytes(),
+ Err(Phase1InboundMediaError::CorruptState)
+ );
+ }
+
+ #[test]
+ fn cache_is_content_addressed_bounded_lru_and_configuration_scoped() {
+ let config = configuration(4);
+ let first_reference = reference(None);
+ let first = receipt(&first_reference, config);
+ let second_hash = Sha256::digest(b"world").to_hex();
+ let second_reference = Phase1StructuralMediaReference::new(
+ format!("https://media.example/{second_hash}.jpg"),
+ Some(second_hash.clone()),
+ Some("image/jpeg".to_owned()),
+ Some(2),
+ Some(3),
+ Some(5),
+ None,
+ )
+ .unwrap();
+ let second_commitment =
+ ByteCommitment::from_bytes(b"world", MediaType::parse("image/jpeg").unwrap());
+ let second = Phase1VerifiedMediaReceipt::from_commitment(
+ &second_reference,
+ BlobUrl::parse(&format!("https://cdn.example/{second_hash}.jpg")).unwrap(),
+ &second_commitment,
+ 2,
+ 3,
+ config,
+ 11,
+ )
+ .unwrap();
+ let mut cache = Phase1MediaCacheIndex::default();
+ let policy = Phase1MediaCachePolicy::new(5, 1).unwrap();
+ assert!(cache.admit(&first, policy, 10).unwrap().is_empty());
+ let evicted = cache.admit(&second, policy, 11).unwrap();
+ assert_eq!(evicted, vec![first.artifact_id()]);
+ assert!(!cache.contains(&first));
+ assert!(cache.contains(&second));
+ assert_eq!(cache.status().unwrap().artifacts, 1);
+ assert_eq!(
+ cache.admit(&second, policy, 12),
+ Ok(Vec::new()),
+ "idempotent cache admission remains bounded"
+ );
+ assert_eq!(
+ cache.admit(&receipt(&first_reference, configuration(5)), policy, 13,),
+ Err(Phase1InboundMediaError::ConfigurationMismatch)
+ );
+ assert_eq!(
+ cache.invalidate_configuration(configuration(5)),
+ vec![second.artifact_id()]
+ );
+ assert_eq!(cache.status().unwrap().artifacts, 0);
+ }
+
+ #[test]
+ fn persisted_receipt_and_cache_tamper_fail_closed() {
+ let structural = reference(None);
+ let config = configuration(7);
+ let mut media = MediaReference::new(structural.clone()).unwrap();
+ media
+ .begin(Phase1InboundMediaPending::new([8; 16], config, 1).unwrap())
+ .unwrap();
+ let verified = receipt(&structural, config);
+ media.verify([8; 16], verified.clone()).unwrap();
+ let mut media_value = serde_json::to_value(&media).unwrap();
+ media_value["retrieval"]["evidence"]["observedSha256"] = serde_json::json!("a".repeat(64));
+ let corrupt: MediaReference = serde_json::from_value(media_value).unwrap();
+ assert_eq!(
+ corrupt.validate(),
+ Err(Phase1InboundMediaError::CorruptReceipt)
+ );
+
+ let mut cache = Phase1MediaCacheIndex::default();
+ cache
+ .admit(&verified, Phase1MediaCachePolicy::new(10, 1).unwrap(), 12)
+ .unwrap();
+ let mut cache_value = serde_json::to_value(cache).unwrap();
+ let entry = cache_value["entries"]
+ .as_object_mut()
+ .unwrap()
+ .values_mut()
+ .next()
+ .unwrap();
+ entry["byteSize"] = serde_json::json!(0);
+ let corrupt: Phase1MediaCacheIndex = serde_json::from_value(cache_value).unwrap();
+ assert_eq!(corrupt.status(), Err(Phase1InboundMediaError::CorruptState));
+ }
+
+ #[cfg(feature = "mobile-social")]
+ #[tokio::test]
+ async fn atomic_artifact_writes_converge_and_corruption_fails_closed() {
+ let bytes = b"GIF89a\x02\0\x03\0";
+ let hash = Sha256::digest(bytes).to_hex();
+ let structural = Phase1StructuralMediaReference::new(
+ format!("https://media.example/{hash}.gif"),
+ Some(hash.clone()),
+ Some("image/gif".to_owned()),
+ Some(2),
+ Some(3),
+ Some(bytes.len() as u64),
+ None,
+ )
+ .unwrap();
+ let receipt = Phase1VerifiedMediaReceipt::from_commitment(
+ &structural,
+ BlobUrl::parse(&format!("https://media.example/{hash}.gif")).unwrap(),
+ &ByteCommitment::from_bytes(bytes, MediaType::parse("image/gif").unwrap()),
+ 2,
+ 3,
+ configuration(4),
+ 1,
+ )
+ .unwrap();
+ let root = tempfile::tempdir().unwrap();
+ let directory = root.path().join("cache");
+ let (left, right) = tokio::join!(
+ write_verified_artifact(&directory, &receipt, bytes),
+ write_verified_artifact(&directory, &receipt, bytes),
+ );
+ let left = left.unwrap();
+ let right = right.unwrap();
+ assert_eq!(left, right);
+ assert_eq!(left.bytes(), bytes);
+ assert_eq!(tokio::fs::read(left.local_path()).await.unwrap(), bytes);
+ assert_eq!(
+ std::fs::read_dir(&directory).unwrap().count(),
+ 1,
+ "no temporary file survives a converged write"
+ );
+ tokio::fs::write(left.local_path(), b"GIF89a\x03\0\x03\0")
+ .await
+ .unwrap();
+ assert_eq!(
+ verified_artifact(&directory, &receipt).await,
+ Err(Phase1InboundMediaError::CorruptArtifact)
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/model.rs b/core/crates/tera_core/src/runtime/product_surface/model.rs
@@ -0,0 +1,251 @@
+use serde::{Deserialize, Serialize};
+
+use super::{CardId, ContextRank, MediaReference, TodayRank};
+
+/// The closed Phase 1 top-level Today taxonomy.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum TodayCardType {
+ Update,
+ PhotoUpdate,
+ Ask,
+ Event,
+ FoodAvailability,
+}
+
+impl TodayCardType {
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Update => "Update",
+ Self::PhotoUpdate => "PhotoUpdate",
+ Self::Ask => "Ask",
+ Self::Event => "Event",
+ Self::FoodAvailability => "FoodAvailability",
+ }
+ }
+}
+
+/// The closed Phase 1 Add command taxonomy.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum AddCommandType {
+ CreateUpdate,
+ CreatePhotoUpdate,
+ CreateAsk,
+ CreateEvent,
+ CreateFoodAvailability,
+}
+
+/// One exact top-level card to Add-command mapping.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CardAddParity {
+ pub card_type: TodayCardType,
+ pub add_command_type: AddCommandType,
+}
+
+pub const CANONICAL_TODAY_CARD_TYPES: [TodayCardType; 5] = [
+ TodayCardType::Update,
+ TodayCardType::PhotoUpdate,
+ TodayCardType::Ask,
+ TodayCardType::Event,
+ TodayCardType::FoodAvailability,
+];
+
+pub const CANONICAL_ADD_COMMAND_TYPES: [AddCommandType; 5] = [
+ AddCommandType::CreateUpdate,
+ AddCommandType::CreatePhotoUpdate,
+ AddCommandType::CreateAsk,
+ AddCommandType::CreateEvent,
+ AddCommandType::CreateFoodAvailability,
+];
+
+pub const CANONICAL_CARD_ADD_PARITY: [CardAddParity; 5] = [
+ CardAddParity {
+ card_type: TodayCardType::Update,
+ add_command_type: AddCommandType::CreateUpdate,
+ },
+ CardAddParity {
+ card_type: TodayCardType::PhotoUpdate,
+ add_command_type: AddCommandType::CreatePhotoUpdate,
+ },
+ CardAddParity {
+ card_type: TodayCardType::Ask,
+ add_command_type: AddCommandType::CreateAsk,
+ },
+ CardAddParity {
+ card_type: TodayCardType::Event,
+ add_command_type: AddCommandType::CreateEvent,
+ },
+ CardAddParity {
+ card_type: TodayCardType::FoodAvailability,
+ add_command_type: AddCommandType::CreateFoodAvailability,
+ },
+];
+
+/// Supporting standard profiles that enrich the product without creating cards.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum SupportingProfile {
+ Profile,
+ Reply,
+ Comment,
+ Deletion,
+}
+
+/// Tolerant profile attribution attached to cards and Me results.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProfileSummary {
+ pub author_pubkey: String,
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<MediaReference>,
+ pub banner: Option<MediaReference>,
+ pub nip05: Option<String>,
+ pub website: Option<String>,
+ pub lightning_address: Option<String>,
+}
+
+/// Thread enrichment identity; replies and comments never become top-level cards.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThreadReference {
+ pub profile: SupportingProfile,
+ pub root: String,
+ pub parent_event_id: String,
+}
+
+/// One admitted reply or comment attached to its canonical root.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThreadEntry {
+ pub event_id: String,
+ pub author_pubkey: String,
+ pub content: String,
+ pub authored_at: u64,
+ pub reference: ThreadReference,
+ pub author_profile: Option<ProfileSummary>,
+}
+
+/// Durable local-only authored state overlaid without changing event truth.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LocalAuthorOverlay {
+ pub operation_id: String,
+ pub state: String,
+}
+
+/// Current rendering state derived from standard event semantics.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum CardLifecycleState {
+ Active,
+ Sold,
+ Past,
+}
+
+/// Verified, visible, context-admitted source facts for one top-level card.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ClassifiedCard {
+ pub schema_version: u16,
+ pub card_id: CardId,
+ pub card_type: TodayCardType,
+ pub source_event_id: String,
+ pub source_address: Option<String>,
+ pub author_pubkey: String,
+ pub contract_id: String,
+ pub title: Option<String>,
+ pub content: String,
+ pub authored_at: u64,
+ pub effective_at: u64,
+ pub event_start: Option<u64>,
+ pub event_end: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub location: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_amount: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_currency: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub price_unit: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub quantity: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_summary: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_published_at: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_status: Option<String>,
+ pub context_rank: ContextRank,
+ pub inclusion_reason: String,
+ pub media: Vec<MediaReference>,
+ pub lifecycle: CardLifecycleState,
+ pub rank: Option<TodayRank>,
+}
+
+/// One fully enriched Today card returned to a host.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodayCard {
+ pub card: ClassifiedCard,
+ pub author_profile: Option<ProfileSummary>,
+ pub thread: Vec<ThreadEntry>,
+ pub local_overlay: Option<LocalAuthorOverlay>,
+}
+
+/// One frozen, cursor-addressable Today page.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodayPage {
+ pub as_of: u64,
+ pub items: Vec<TodayCard>,
+ pub next_cursor: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum SearchResultType {
+ Card,
+ Profile,
+}
+
+/// One local search result governed by the same current projection as Today.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SearchResult {
+ pub result_type: SearchResultType,
+ pub stable_id: String,
+ pub card: Option<TodayCard>,
+ pub profile: Option<ProfileSummary>,
+}
+
+/// Active identity attribution and its current visible Phase 1 cards.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MeSnapshot {
+ pub public_key: String,
+ pub profile: Option<ProfileSummary>,
+ pub cards: Vec<TodayCard>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn card_and_add_taxonomies_are_exact_and_serialized_stably() {
+ assert_eq!(CANONICAL_TODAY_CARD_TYPES.len(), 5);
+ assert_eq!(CANONICAL_ADD_COMMAND_TYPES.len(), 5);
+ for (index, parity) in CANONICAL_CARD_ADD_PARITY.iter().enumerate() {
+ assert_eq!(parity.card_type, CANONICAL_TODAY_CARD_TYPES[index]);
+ assert_eq!(parity.add_command_type, CANONICAL_ADD_COMMAND_TYPES[index]);
+ }
+ assert_eq!(
+ serde_json::to_string(&CANONICAL_TODAY_CARD_TYPES).expect("cards"),
+ r#"["Update","PhotoUpdate","Ask","Event","FoodAvailability"]"#
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs
@@ -0,0 +1,4402 @@
+use std::{
+ collections::BTreeSet,
+ sync::Arc,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use radroots_blossom::{
+ BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim,
+};
+use radroots_event::{
+ contract::AuthorRole,
+ post::deletion::{
+ AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget,
+ },
+};
+use radroots_event_codec::authoring::{AuthoredEventPlan, PlanWireV1};
+use radroots_identity::PublicKey;
+use radroots_signing::{
+ Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId,
+ actor::ActorSource,
+ request::{CancellationPolicy, SignPolicy},
+};
+use radroots_storage::{
+ authored::{AdmissionState, SigningState},
+ authored_delivery::{AuthoredDeliveryState, DeliveryAttemptOutcome},
+ authored_draft::{
+ AuthoredDraft, AuthoredDraftId, AuthoredDraftRevision, AuthoredDraftStage,
+ AuthoredDraftStore,
+ },
+ journal::{IdempotencyKey, OperationInstanceId},
+};
+use radroots_sync::{PushRequest, PushStatus, policy::SyncId};
+use radroots_transport::{
+ Target, TargetSet,
+ outcome::DeliveryOutcomeKind,
+ policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
+};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use super::{
+ AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand,
+ ProfileMetadataCommand, TodayCardType, TodayError, phase1_retraction_plan,
+};
+use crate::runtime::RadrootsRuntime;
+
+const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1";
+const PROFILE_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-profile.v1";
+const DRAFT_SCHEMA_VERSION: u16 = 1;
+const DRAFT_MEDIA_MAX: usize = 20;
+const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096;
+const DRAFT_FAILURE_CODE_MAX_BYTES: usize = 96;
+const DRAFT_OPERATION_DOMAIN: &[u8] = b"radroots.mobile.phase1-draft-operation.v1\0";
+const ADD_DELIVERY_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000;
+const BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS: u64 = 5;
+const BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS: u64 = 5 * 60;
+const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000;
+const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Radroots image";
+const REVISION_RETRACTION_REASON: &str = "Replaced by a corrected Radroots event";
+
+/// Product intent represented by one durable draft/outbox item.
+#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1DraftKind {
+ #[default]
+ Add,
+ Retraction,
+}
+
+/// Event timing profile retained for a reopenable Add form.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1DraftEventTiming {
+ AllDay,
+ Timed,
+}
+
+/// Secret-free, restart-safe media fields retained with an Add form.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1DraftMediaSnapshot {
+ 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,
+}
+
+/// Immutable, validated presentation input used to reopen a durable draft.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1DraftFormSnapshot {
+ pub command_type: AddCommandType,
+ pub content: String,
+ pub identifier: Option<String>,
+ pub title: Option<String>,
+ pub summary: Option<String>,
+ pub location: Option<String>,
+ pub event_timing: Option<Phase1DraftEventTiming>,
+ 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>,
+ #[serde(default)]
+ pub food_published_at_unix_s: Option<u64>,
+ pub food_status: Option<String>,
+ pub media: Vec<Phase1DraftMediaSnapshot>,
+}
+
+impl Phase1DraftFormSnapshot {
+ fn validate(
+ &self,
+ command_type: AddCommandType,
+ media: &[Phase1MediaPrerequisite],
+ ) -> Result<(), Phase1DraftError> {
+ let bounded = |value: &str, maximum: usize| {
+ value.len() <= maximum && !value.chars().any(char::is_control)
+ };
+ if self.command_type != command_type
+ || self.content.len() > 65_535
+ || self.media.len() != media.len()
+ || [
+ self.identifier.as_deref(),
+ self.title.as_deref(),
+ self.summary.as_deref(),
+ self.location.as_deref(),
+ self.event_start_date.as_deref(),
+ self.event_end_date.as_deref(),
+ self.event_timezone.as_deref(),
+ self.price_amount.as_deref(),
+ self.currency.as_deref(),
+ self.unit.as_deref(),
+ self.quantity.as_deref(),
+ self.food_status.as_deref(),
+ ]
+ .into_iter()
+ .flatten()
+ .any(|value| !bounded(value, 1_024))
+ {
+ return Err(Phase1DraftError::InvalidDraft);
+ }
+ for (snapshot, prerequisite) in self.media.iter().zip(media) {
+ if snapshot.opaque_reference.is_empty()
+ || snapshot.opaque_reference != prerequisite.local_reference()
+ || snapshot.url != prerequisite.url
+ || snapshot.sha256 != prerequisite.sha256()
+ || snapshot.media_type != prerequisite.media_type()
+ || snapshot.byte_size != prerequisite.byte_size()
+ || snapshot.width == 0
+ || snapshot.height == 0
+ || snapshot.alt.trim().is_empty()
+ || !bounded(&snapshot.alt, 1_024)
+ || snapshot.prepared_at_unix_s == 0
+ {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Durable state of one media prerequisite referenced by an Add command.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1MediaStage {
+ Pending,
+ Preparing,
+ Uploading,
+ Verified,
+ Failed,
+ Orphaned,
+}
+
+/// Exact local and remote identity of one Add media prerequisite.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1MediaPrerequisite {
+ local_reference: String,
+ url: String,
+ sha256: String,
+ media_type: String,
+ byte_size: u64,
+ stage: Phase1MediaStage,
+ failure_code: Option<String>,
+ upload_attempts: u8,
+ verified_at_unix_ms: Option<u64>,
+ orphan: Option<Phase1MediaOrphanRecord>,
+}
+
+/// Durable, secret-safe evidence that a remote blob may be unreferenced.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1MediaOrphanRecord {
+ reason_code: String,
+ recorded_at_unix_ms: u64,
+}
+
+impl Phase1MediaOrphanRecord {
+ pub fn reason_code(&self) -> &str {
+ self.reason_code.as_str()
+ }
+
+ pub const fn recorded_at_unix_ms(&self) -> u64 {
+ self.recorded_at_unix_ms
+ }
+}
+
+impl Phase1MediaPrerequisite {
+ pub fn new(
+ local_reference: impl Into<String>,
+ descriptor: &ByteVerifiedDescriptor,
+ ) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ local_reference: local_reference.into(),
+ url: descriptor.url().as_blob_url().as_str().to_owned(),
+ sha256: descriptor.sha256().to_hex(),
+ media_type: descriptor.media_type().as_str().to_owned(),
+ byte_size: descriptor.size(),
+ stage: Phase1MediaStage::Pending,
+ failure_code: None,
+ upload_attempts: 0,
+ verified_at_unix_ms: None,
+ orphan: None,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ fn validate(&self) -> Result<(), Phase1DraftError> {
+ let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let hash = blob.hash_path().hash().to_string();
+ if self.local_reference.is_empty()
+ || self.local_reference.len() > DRAFT_LOCAL_REFERENCE_MAX_BYTES
+ || self.local_reference != self.local_reference.trim()
+ || self.local_reference.chars().any(char::is_control)
+ || self.sha256 != hash
+ || MediaType::parse(self.media_type.as_str()).is_err()
+ || self.byte_size == 0
+ || self.failure_code.as_ref().is_some_and(|code| {
+ code.is_empty()
+ || code.len() > DRAFT_FAILURE_CODE_MAX_BYTES
+ || code != code.trim()
+ || code.chars().any(char::is_control)
+ })
+ || self.orphan.as_ref().is_some_and(|record| {
+ record.reason_code.is_empty()
+ || record.reason_code.len() > DRAFT_FAILURE_CODE_MAX_BYTES
+ || record.reason_code != record.reason_code.trim()
+ || record.reason_code.chars().any(char::is_control)
+ || record.recorded_at_unix_ms == 0
+ })
+ || match self.stage {
+ Phase1MediaStage::Pending | Phase1MediaStage::Preparing => {
+ self.failure_code.is_some()
+ || self.upload_attempts != 0
+ || self.verified_at_unix_ms.is_some()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Uploading => {
+ self.failure_code.is_some()
+ || self.verified_at_unix_ms.is_some()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Verified => {
+ self.failure_code.is_some()
+ || self.upload_attempts == 0
+ || self.verified_at_unix_ms.is_none()
+ || self.orphan.is_some()
+ }
+ Phase1MediaStage::Failed => {
+ self.failure_code.is_none() || self.verified_at_unix_ms.is_some()
+ }
+ Phase1MediaStage::Orphaned => self.failure_code.is_some() || self.orphan.is_none(),
+ }
+ {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ Ok(())
+ }
+
+ pub fn url(&self) -> &str {
+ self.url.as_str()
+ }
+ pub fn local_reference(&self) -> &str {
+ self.local_reference.as_str()
+ }
+ pub fn sha256(&self) -> &str {
+ self.sha256.as_str()
+ }
+ pub fn media_type(&self) -> &str {
+ self.media_type.as_str()
+ }
+ pub const fn byte_size(&self) -> u64 {
+ self.byte_size
+ }
+ pub const fn stage(&self) -> Phase1MediaStage {
+ self.stage
+ }
+
+ pub const fn upload_attempts(&self) -> u8 {
+ self.upload_attempts
+ }
+
+ pub const fn verified_at_unix_ms(&self) -> Option<u64> {
+ self.verified_at_unix_ms
+ }
+
+ pub const fn orphan(&self) -> Option<&Phase1MediaOrphanRecord> {
+ self.orphan.as_ref()
+ }
+
+ fn matches_receipt(&self, receipt: &radroots_sdk::transport::BlossomUploadReceipt) -> bool {
+ let descriptor = receipt.descriptor();
+ self.url == descriptor.url().as_blob_url().as_str()
+ && self.sha256 == descriptor.sha256().to_hex()
+ && self.media_type == descriptor.media_type().as_str()
+ && self.byte_size == descriptor.size()
+ && receipt.attempts() > 0
+ && receipt.verified_at_unix_ms() > 0
+ }
+
+ fn is_remote_verified(&self) -> bool {
+ self.stage == Phase1MediaStage::Verified
+ && self.upload_attempts > 0
+ && self.verified_at_unix_ms.is_some()
+ }
+}
+
+/// Closed delivery-satisfaction profiles available to Phase 1 Add.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1RelaySatisfaction {
+ AnyAccepted,
+ AllAccepted,
+ AnyDelivered,
+ AllDelivered,
+}
+
+/// Stable product-level cancellation policy persisted with queue intent.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1CancellationPolicy {
+ PreservePublishedRequest,
+ LocalCooperative,
+}
+
+impl Phase1CancellationPolicy {
+ const fn signing(self) -> CancellationPolicy {
+ match self {
+ Self::PreservePublishedRequest => CancellationPolicy::PreservePublishedRequest,
+ Self::LocalCooperative => CancellationPolicy::LocalCooperative,
+ }
+ }
+}
+
+/// Exact relay and deadline intent frozen before an operation is prepared.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1QueuePolicy {
+ relay_urls: Vec<String>,
+ satisfaction: Phase1RelaySatisfaction,
+ delivery_deadline_unix_ms: u64,
+ cancellation: Phase1CancellationPolicy,
+}
+
+impl Phase1QueuePolicy {
+ pub fn new(
+ relay_urls: Vec<String>,
+ satisfaction: Phase1RelaySatisfaction,
+ delivery_deadline_unix_ms: u64,
+ cancellation: Phase1CancellationPolicy,
+ ) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ relay_urls,
+ satisfaction,
+ delivery_deadline_unix_ms,
+ cancellation,
+ };
+ value.materialize()?;
+ Ok(value)
+ }
+
+ fn materialize(
+ &self,
+ ) -> Result<(TargetSet, SatisfactionPolicy, CancellationPolicy), Phase1DraftError> {
+ if self.delivery_deadline_unix_ms == 0 || self.relay_urls.is_empty() {
+ return Err(Phase1DraftError::InvalidQueuePolicy);
+ }
+ let mut canonical = BTreeSet::new();
+ let targets = self
+ .relay_urls
+ .iter()
+ .map(|url| {
+ let target =
+ Target::nostr_relay(url).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?;
+ if target.uri().as_str() != url || !canonical.insert(url.as_str()) {
+ return Err(Phase1DraftError::InvalidQueuePolicy);
+ }
+ Ok(target)
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ let targets = TargetSet::new(targets).map_err(|_| Phase1DraftError::InvalidQueuePolicy)?;
+ let (class, target_policy) = match self.satisfaction {
+ Phase1RelaySatisfaction::AnyAccepted => {
+ (SatisfactionClass::Accepted, TargetPolicy::any())
+ }
+ Phase1RelaySatisfaction::AllAccepted => {
+ (SatisfactionClass::Accepted, TargetPolicy::all())
+ }
+ Phase1RelaySatisfaction::AnyDelivered => {
+ (SatisfactionClass::Delivered, TargetPolicy::any())
+ }
+ Phase1RelaySatisfaction::AllDelivered => {
+ (SatisfactionClass::Delivered, TargetPolicy::all())
+ }
+ };
+ let cancellation = match self.cancellation {
+ Phase1CancellationPolicy::PreservePublishedRequest => {
+ CancellationPolicy::PreservePublishedRequest
+ }
+ Phase1CancellationPolicy::LocalCooperative => CancellationPolicy::LocalCooperative,
+ };
+ Ok((
+ targets,
+ SatisfactionPolicy::new(class, target_policy),
+ cancellation,
+ ))
+ }
+}
+
+/// Existing durable draft selected for an optimistic replacement.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Phase1ExistingDraft {
+ draft_id: [u8; 16],
+ expected_revision: u64,
+}
+
+impl Phase1ExistingDraft {
+ pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> {
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ Ok(Self {
+ draft_id,
+ expected_revision,
+ })
+ }
+}
+
+/// One typed Add save intent. Rust supplies the creation identifier and all
+/// policy timestamps; a caller can only name an existing revision to replace.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Phase1AddIntent {
+ command: Phase1AddCommand,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Phase1DraftFormSnapshot,
+ existing: Option<Phase1ExistingDraft>,
+}
+
+impl Phase1AddIntent {
+ pub fn new(
+ command: Phase1AddCommand,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Phase1DraftFormSnapshot,
+ existing: Option<Phase1ExistingDraft>,
+ ) -> Result<Self, Phase1DraftError> {
+ if media.len() > DRAFT_MEDIA_MAX {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ form.validate(command.command_type(), &media)?;
+ Ok(Self {
+ command,
+ media,
+ form,
+ existing,
+ })
+ }
+}
+
+/// Minimal queue intent. Relay selection, settlement, deadline, and
+/// cancellation are derived from the active typed Rust transport profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct Phase1QueueIntent {
+ draft_id: [u8; 16],
+ expected_revision: u64,
+}
+
+impl Phase1QueueIntent {
+ pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> {
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ Ok(Self {
+ draft_id,
+ expected_revision,
+ })
+ }
+}
+
+/// Bounded exact-byte input for one Rust-planned Blossom upload attempt.
+#[derive(Clone)]
+pub struct Phase1UploadIntent {
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ bytes: Arc<[u8]>,
+ media_type: MediaType,
+ dimensions: radroots_sdk::transport::BlossomImageDimensions,
+}
+
+impl Phase1UploadIntent {
+ pub fn new(
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ bytes: Arc<[u8]>,
+ media_type: MediaType,
+ width: u32,
+ height: u32,
+ ) -> Result<Self, Phase1DraftError> {
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ if bytes.is_empty() {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ let dimensions = radroots_sdk::transport::BlossomImageDimensions::new(width, height)
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ Ok(Self {
+ draft_id,
+ expected_revision,
+ bytes,
+ media_type,
+ dimensions,
+ })
+ }
+}
+
+/// Immutable Rust-derived policy for one upload attempt.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Phase1UploadPlan {
+ pub authorization_content: String,
+ pub authorization_created_at_unix_s: u64,
+ pub authorization_lifetime_seconds: u64,
+ pub operation_id: [u8; 16],
+ pub artifact_id: [u8; 16],
+ pub signing_deadline_unix_ms: u64,
+ pub cancellation: Phase1CancellationPolicy,
+ pub updated_at_unix_ms: u64,
+}
+
+/// Immutable Rust-authorized upload job handed to a native background
+/// transfer implementation after the durable draft enters `media_uploading`.
+#[derive(Clone)]
+pub struct Phase1NativeUploadJob {
+ operation_id: [u8; 16],
+ remote_url: String,
+ authorization_header: String,
+ expected_sha256: String,
+ media_type: String,
+ byte_size: u64,
+}
+
+impl Phase1NativeUploadJob {
+ pub const fn operation_id(&self) -> [u8; 16] {
+ self.operation_id
+ }
+ pub fn remote_url(&self) -> &str {
+ self.remote_url.as_str()
+ }
+ pub fn authorization_header(&self) -> &str {
+ self.authorization_header.as_str()
+ }
+ pub fn expected_sha256(&self) -> &str {
+ self.expected_sha256.as_str()
+ }
+ pub fn media_type(&self) -> &str {
+ self.media_type.as_str()
+ }
+ pub const fn byte_size(&self) -> u64 {
+ self.byte_size
+ }
+}
+
+/// Existing published card selected for one lossless revision operation.
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Phase1RevisionTarget {
+ command_type: AddCommandType,
+ card_id: CardId,
+ source_event_id: String,
+ source_kind: u32,
+ source_address: Option<String>,
+ author_public_key: String,
+}
+
+impl Phase1RevisionTarget {
+ pub fn new(
+ command_type: AddCommandType,
+ card_id: CardId,
+ source_event_id: impl Into<String>,
+ source_kind: u32,
+ source_address: Option<String>,
+ author_public_key: impl Into<String>,
+ ) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ command_type,
+ card_id,
+ source_event_id: source_event_id.into(),
+ source_kind,
+ source_address,
+ author_public_key: author_public_key.into(),
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ /// Builds a revision target from its canonical source identity while
+ /// keeping Nostr kind parsing inside the Rust protocol boundary.
+ pub fn from_source(
+ command_type: AddCommandType,
+ card_id: CardId,
+ source_event_id: impl Into<String>,
+ source_address: Option<String>,
+ author_public_key: impl Into<String>,
+ ) -> Result<Self, Phase1DraftError> {
+ let source_kind = match source_address.as_deref() {
+ Some(address) => parse_address(address)?.0,
+ None => 1,
+ };
+ Self::new(
+ command_type,
+ card_id,
+ source_event_id,
+ source_kind,
+ source_address,
+ author_public_key,
+ )
+ }
+
+ fn validate(&self) -> Result<(), Phase1DraftError> {
+ let author = PublicKey::from_hex(&self.author_public_key)
+ .map_err(|_| Phase1DraftError::InvalidRevision)?;
+ if author.to_hex() != self.author_public_key {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ let event_id = radroots_event::EventId::parse(&self.source_event_id)
+ .map_err(|_| Phase1DraftError::InvalidRevision)?;
+ if event_id.to_hex() != self.source_event_id {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ Nip09DeletionEventTarget::parse(&self.source_event_id, self.source_kind)
+ .map_err(|_| Phase1DraftError::InvalidRevision)?;
+ let addressable = matches!(self.source_kind, 30_402 | 31_922 | 31_923);
+ let source = if addressable {
+ let address = self
+ .source_address
+ .as_deref()
+ .ok_or(Phase1DraftError::InvalidRevision)?;
+ Nip09DeletionAddressTarget::parse(address)
+ .map_err(|_| Phase1DraftError::InvalidRevision)?;
+ let (kind, author, _) = parse_address(address)?;
+ if kind != self.source_kind || author != self.author_public_key {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ let (_, _, identifier) = parse_address(address)?;
+ if format!("{kind}:{author}:{identifier}") != address {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ CardSourceIdentity::address(kind, author, identifier)
+ .map_err(|_| Phase1DraftError::InvalidRevision)?
+ } else if self.source_kind != 1 || self.source_address.is_some() {
+ return Err(Phase1DraftError::InvalidRevision);
+ } else {
+ CardSourceIdentity::Event(event_id)
+ };
+ let command_matches = match self.command_type {
+ AddCommandType::CreateUpdate
+ | AddCommandType::CreatePhotoUpdate
+ | AddCommandType::CreateAsk => self.source_kind == 1,
+ AddCommandType::CreateEvent => matches!(self.source_kind, 31_922 | 31_923),
+ AddCommandType::CreateFoodAvailability => self.source_kind == 30_402,
+ };
+ let card_type = match self.command_type {
+ AddCommandType::CreateUpdate => TodayCardType::Update,
+ AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate,
+ AddCommandType::CreateAsk => TodayCardType::Ask,
+ AddCommandType::CreateEvent => TodayCardType::Event,
+ AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability,
+ };
+ (command_matches && CardId::derive(card_type, &source) == self.card_id)
+ .then_some(())
+ .ok_or(Phase1DraftError::InvalidRevision)
+ }
+
+ pub const fn command_type(&self) -> AddCommandType {
+ self.command_type
+ }
+
+ pub const fn card_id(&self) -> CardId {
+ self.card_id
+ }
+
+ pub fn source_event_id(&self) -> &str {
+ self.source_event_id.as_str()
+ }
+
+ pub const fn source_kind(&self) -> u32 {
+ self.source_kind
+ }
+
+ pub fn source_address(&self) -> Option<&str> {
+ self.source_address.as_deref()
+ }
+}
+
+/// Protocol-correct ordering selected from the source event kind.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Phase1RevisionPolicy {
+ ReplaceThenRetract,
+ AddressableReplacement,
+}
+
+/// One complete replacement intent. The form is the canonical lossless reopen
+/// snapshot; the command is its validated authored representation.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Phase1ReviseIntent {
+ target: Phase1RevisionTarget,
+ command: Phase1AddCommand,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Phase1DraftFormSnapshot,
+}
+
+impl Phase1ReviseIntent {
+ pub fn new(
+ target: Phase1RevisionTarget,
+ command: Phase1AddCommand,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Phase1DraftFormSnapshot,
+ ) -> Result<Self, Phase1DraftError> {
+ target.validate()?;
+ if media.len() > DRAFT_MEDIA_MAX {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ form.validate(command.command_type(), &media)?;
+ let same_family = match (target.command_type(), command.command_type()) {
+ (
+ AddCommandType::CreateUpdate
+ | AddCommandType::CreatePhotoUpdate
+ | AddCommandType::CreateAsk,
+ AddCommandType::CreateUpdate
+ | AddCommandType::CreatePhotoUpdate
+ | AddCommandType::CreateAsk,
+ ) => true,
+ (left, right) => left == right,
+ };
+ if !same_family {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ Ok(Self {
+ target,
+ command,
+ media,
+ form,
+ })
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Phase1RevisionRecord {
+ target: Phase1RevisionTarget,
+ policy: Phase1RevisionPolicy,
+ retraction_draft_id: Option<[u8; 16]>,
+}
+
+/// Honest aggregate state for a durable revision and its ordered child work.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Phase1RevisionPhase {
+ ReplacementPending,
+ ReplacementFailed,
+ RetractionPending,
+ Complete,
+ PartialEffect,
+ Cancelled,
+}
+
+#[derive(Clone, Debug)]
+pub struct Phase1RevisionStatus {
+ replacement: Phase1DraftStatus,
+ retraction: Option<Phase1DraftStatus>,
+ target: Phase1RevisionTarget,
+ policy: Phase1RevisionPolicy,
+ phase: Phase1RevisionPhase,
+}
+
+/// Durable kind-0 profile publication state. The profile fields remain inside
+/// the canonical authored plan and are never duplicated in outbox metadata.
+#[derive(Clone, Debug)]
+pub struct Phase1ProfileStatus {
+ draft: AuthoredDraft,
+ state: Phase1OutboxState,
+ push: Option<PushStatus>,
+}
+
+impl Phase1ProfileStatus {
+ pub const fn draft(&self) -> &AuthoredDraft {
+ &self.draft
+ }
+
+ pub const fn state(&self) -> Phase1OutboxState {
+ self.state
+ }
+
+ pub const fn push(&self) -> Option<&PushStatus> {
+ self.push.as_ref()
+ }
+}
+
+impl Phase1RevisionStatus {
+ pub const fn replacement(&self) -> &Phase1DraftStatus {
+ &self.replacement
+ }
+
+ pub const fn retraction(&self) -> Option<&Phase1DraftStatus> {
+ self.retraction.as_ref()
+ }
+
+ pub const fn target(&self) -> &Phase1RevisionTarget {
+ &self.target
+ }
+
+ pub const fn policy(&self) -> Phase1RevisionPolicy {
+ self.policy
+ }
+
+ pub const fn phase(&self) -> Phase1RevisionPhase {
+ self.phase
+ }
+}
+
+impl Phase1UploadPlan {
+ fn derive(
+ now_unix_ms: u64,
+ operation_id: [u8; 16],
+ artifact_id: [u8; 16],
+ ) -> Result<Self, Phase1DraftError> {
+ let now_unix_s = now_unix_ms / 1_000;
+ if now_unix_s == 0 {
+ return Err(Phase1DraftError::ClockUnavailable);
+ }
+ Ok(Self {
+ authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(),
+ authorization_created_at_unix_s: now_unix_s
+ .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS),
+ authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS,
+ operation_id,
+ artifact_id,
+ signing_deadline_unix_ms: now_unix_ms
+ .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS)
+ .ok_or(Phase1DraftError::DeadlineOverflow)?,
+ cancellation: Phase1CancellationPolicy::LocalCooperative,
+ updated_at_unix_ms: now_unix_ms,
+ })
+ }
+}
+
+/// Honest aggregate state for a local draft and its durable authored operation.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum Phase1OutboxState {
+ Draft,
+ MediaPreparing,
+ MediaUploading,
+ ReadyToSign,
+ Signing,
+ Signed,
+ Queued,
+ Delivering,
+ PartiallyDelivered,
+ Retryable,
+ Terminal,
+ Cancelled,
+ Complete,
+}
+
+impl Phase1OutboxState {
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Draft => "draft",
+ Self::MediaPreparing => "media_preparing",
+ Self::MediaUploading => "media_uploading",
+ Self::ReadyToSign => "ready_to_sign",
+ Self::Signing => "signing",
+ Self::Signed => "signed",
+ Self::Queued => "queued",
+ Self::Delivering => "delivering",
+ Self::PartiallyDelivered => "partially_delivered",
+ Self::Retryable => "retryable",
+ Self::Terminal => "terminal",
+ Self::Cancelled => "cancelled",
+ Self::Complete => "complete",
+ }
+ }
+}
+
+/// Current reconstructable product view of one Add draft.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Phase1DraftStatus {
+ draft: AuthoredDraft,
+ kind: Phase1DraftKind,
+ command_type: AddCommandType,
+ form: Option<Phase1DraftFormSnapshot>,
+ media: Vec<Phase1MediaPrerequisite>,
+ state: Phase1OutboxState,
+ card_id: CardId,
+ push: Option<PushStatus>,
+ revision_policy: Option<Phase1RevisionPolicy>,
+}
+
+impl Phase1DraftStatus {
+ pub const fn draft(&self) -> &AuthoredDraft {
+ &self.draft
+ }
+ pub const fn command_type(&self) -> AddCommandType {
+ self.command_type
+ }
+ pub const fn kind(&self) -> Phase1DraftKind {
+ self.kind
+ }
+ pub const fn form(&self) -> Option<&Phase1DraftFormSnapshot> {
+ self.form.as_ref()
+ }
+ pub fn media(&self) -> &[Phase1MediaPrerequisite] {
+ self.media.as_slice()
+ }
+ pub const fn state(&self) -> Phase1OutboxState {
+ self.state
+ }
+ pub const fn card_id(&self) -> CardId {
+ self.card_id
+ }
+ pub const fn push(&self) -> Option<&PushStatus> {
+ self.push.as_ref()
+ }
+ pub const fn revision_policy(&self) -> Option<Phase1RevisionPolicy> {
+ self.revision_policy
+ }
+}
+
+#[derive(Clone, Debug, Error, Eq, PartialEq)]
+pub enum Phase1DraftError {
+ #[error("authenticated draft identity is unavailable")]
+ IdentityUnavailable,
+ #[error("phase 1 draft input is invalid")]
+ InvalidDraft,
+ #[error("phase 1 media prerequisite is invalid")]
+ InvalidMedia,
+ #[error("phase 1 queue policy is invalid")]
+ InvalidQueuePolicy,
+ #[error("phase 1 draft revision conflicts with durable state")]
+ RevisionConflict,
+ #[error("phase 1 draft is not found")]
+ NotFound,
+ #[error("phase 1 draft is terminal")]
+ Terminal,
+ #[error("phase 1 draft media is not ready")]
+ MediaNotReady,
+ #[error("phase 1 authored operation is unavailable")]
+ OperationUnavailable,
+ #[error("phase 1 draft persistence failed")]
+ Storage,
+ #[error("phase 1 draft payload is corrupt")]
+ Corrupt,
+ #[error("phase 1 authored operation failed")]
+ Operation,
+ #[error("phase 1 Today overlay failed")]
+ Overlay,
+ #[error("phase 1 operation clock is unavailable")]
+ ClockUnavailable,
+ #[error("phase 1 operation deadline overflowed")]
+ DeadlineOverflow,
+ #[error("no configured relay authorizes publication")]
+ NoWritableRelay,
+ #[error("phase 1 revision input or ordering is invalid")]
+ InvalidRevision,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Phase1DraftPayload {
+ schema_version: u16,
+ #[serde(default)]
+ kind: Phase1DraftKind,
+ command_type: AddCommandType,
+ #[serde(default)]
+ form: Option<Phase1DraftFormSnapshot>,
+ #[serde(default)]
+ target_card_id: Option<CardId>,
+ plan_wire_json: Vec<u8>,
+ media: Vec<Phase1MediaPrerequisite>,
+ queue: Option<Phase1QueuePolicy>,
+ #[serde(default)]
+ revision: Option<Phase1RevisionRecord>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields)]
+struct Phase1ProfilePayload {
+ schema_version: u16,
+ plan_wire_json: Vec<u8>,
+ queue: Option<Phase1QueuePolicy>,
+}
+
+impl Phase1ProfilePayload {
+ fn new(plan_wire_json: Vec<u8>) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ schema_version: DRAFT_SCHEMA_VERSION,
+ plan_wire_json,
+ queue: None,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ fn validate(&self) -> Result<(), Phase1DraftError> {
+ if self.schema_version != DRAFT_SCHEMA_VERSION {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let plan = PlanWireV1::from_json(self.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ if plan.plan().body().kind() != 0 {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ if let Some(queue) = &self.queue {
+ queue.materialize()?;
+ }
+ Ok(())
+ }
+
+ fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> {
+ if draft.payload_schema() != PROFILE_PAYLOAD_SCHEMA {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let value = serde_json::from_slice::<Self>(draft.payload())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ value.validate()?;
+ let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ if plan.plan().author().as_bytes() != draft.author() {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ Ok(value)
+ }
+
+ fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> {
+ self.validate()?;
+ serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft)
+ }
+}
+
+impl Phase1DraftPayload {
+ fn new(
+ command: &Phase1AddCommand,
+ plan_wire_json: Vec<u8>,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Option<Phase1DraftFormSnapshot>,
+ ) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ schema_version: DRAFT_SCHEMA_VERSION,
+ kind: Phase1DraftKind::Add,
+ command_type: command.command_type(),
+ form,
+ target_card_id: None,
+ plan_wire_json,
+ media,
+ queue: None,
+ revision: None,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ fn retraction(
+ command_type: AddCommandType,
+ target_card_id: CardId,
+ plan_wire_json: Vec<u8>,
+ ) -> Result<Self, Phase1DraftError> {
+ let value = Self {
+ schema_version: DRAFT_SCHEMA_VERSION,
+ kind: Phase1DraftKind::Retraction,
+ command_type,
+ form: None,
+ target_card_id: Some(target_card_id),
+ plan_wire_json,
+ media: Vec::new(),
+ queue: None,
+ revision: None,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ fn validate(&self) -> Result<(), Phase1DraftError> {
+ if self.schema_version != DRAFT_SCHEMA_VERSION || self.media.len() > DRAFT_MEDIA_MAX {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ match self.kind {
+ Phase1DraftKind::Add => {
+ if self.target_card_id.is_some() {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ if let Some(form) = &self.form {
+ form.validate(self.command_type, &self.media)?;
+ }
+ }
+ Phase1DraftKind::Retraction => {
+ if self.form.is_some()
+ || self.target_card_id.is_none()
+ || !self.media.is_empty()
+ || self.revision.is_some()
+ {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ }
+ }
+ let integrity = PlanWireV1::from_json(self.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ let plan = integrity.plan();
+ if let Some(revision) = &self.revision {
+ revision.target.validate()?;
+ if self.kind != Phase1DraftKind::Add {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let expected_policy = if revision.target.source_kind == 1 {
+ Phase1RevisionPolicy::ReplaceThenRetract
+ } else {
+ Phase1RevisionPolicy::AddressableReplacement
+ };
+ if revision.policy != expected_policy
+ || (expected_policy == Phase1RevisionPolicy::ReplaceThenRetract)
+ != revision.retraction_draft_id.is_some()
+ {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ if let Some(child_id) = revision.retraction_draft_id {
+ AuthoredDraftId::new(child_id).map_err(|_| Phase1DraftError::Corrupt)?;
+ }
+ if expected_policy == Phase1RevisionPolicy::AddressableReplacement
+ && (plan.body().kind() != revision.target.source_kind
+ || card_id(self.command_type, plan)? != revision.target.card_id)
+ {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ if expected_policy == Phase1RevisionPolicy::ReplaceThenRetract
+ && plan.body().kind() != 1
+ {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ }
+ let expected_media = media_urls(plan.body().tags())?;
+ let actual_media = self
+ .media
+ .iter()
+ .map(|media| {
+ media.validate()?;
+ Ok(media.url.as_str())
+ })
+ .collect::<Result<BTreeSet<_>, Phase1DraftError>>()?;
+ if expected_media != actual_media || actual_media.len() != self.media.len() {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ if let Some(queue) = &self.queue {
+ queue.materialize()?;
+ }
+ Ok(())
+ }
+
+ fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> {
+ if draft.payload_schema() != DRAFT_PAYLOAD_SCHEMA {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let value = serde_json::from_slice::<Self>(draft.payload())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ value.validate()?;
+ let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ if plan.plan().author().as_bytes() != draft.author() {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ Ok(value)
+ }
+
+ fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> {
+ self.validate()?;
+ serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft)
+ }
+}
+
+impl RadrootsRuntime {
+ /// Persists a strict kind-0 profile intent before any signing or network
+ /// side effect. Identity and timestamps remain Rust-owned.
+ pub async fn phase1_save_profile_metadata(
+ &self,
+ command: ProfileMetadataCommand,
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let author = self.draft_author()?;
+ let draft_id = AuthoredDraftId::new(phase1_random_id()?)
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let plan = AuthoredEventPlan::from_profile(
+ command.authored(),
+ now_unix_ms / 1_000,
+ hex::encode(author),
+ )
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let wire = PlanWireV1::from_plan(&plan)
+ .to_json()
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let payload = Phase1ProfilePayload::new(wire)?;
+ let draft = AuthoredDraft::initial(
+ draft_id,
+ author,
+ PROFILE_PAYLOAD_SCHEMA,
+ payload.encode()?,
+ AuthoredDraftStage::Draft,
+ None,
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let receipt = self
+ .storage()?
+ .append_authored_draft(draft, None)
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.profile_status_from(receipt.draft().clone()).await
+ }
+
+ pub async fn phase1_profile_status(
+ &self,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let head = self
+ .storage()?
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ self.profile_status_from(head).await
+ }
+
+ /// Recovers and advances one profile operation through the same durable
+ /// sign/admit/deliver engine used by Add without exposing queue policy.
+ pub async fn phase1_advance_profile(
+ &self,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let mut status = self.phase1_profile_status(draft_id).await?;
+ if status.draft.stage() == AuthoredDraftStage::Draft {
+ status = self
+ .phase1_queue_profile(draft_id, status.draft.revision().get())
+ .await?;
+ } else if status.draft.stage() == AuthoredDraftStage::ReadyToSign {
+ status = self
+ .finish_profile_queue(status.draft.clone(), phase1_operation_now_unix_ms()?)
+ .await?;
+ }
+ if status.draft.stage() != AuthoredDraftStage::Queued
+ || matches!(
+ status.state,
+ Phase1OutboxState::Complete
+ | Phase1OutboxState::Terminal
+ | Phase1OutboxState::Cancelled
+ )
+ {
+ return Ok(status);
+ }
+ let request = profile_push_request(&status.draft)?;
+ let operation_id = request.operation_id();
+ let sync = self.sync()?;
+ let mut push = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+ if matches!(
+ push.artifact().signing_state(),
+ SigningState::Planned | SigningState::Retryable
+ ) {
+ sync.sign_prepared(request)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ push = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+ }
+ if push.artifact().signing_state() == SigningState::Signed
+ && matches!(
+ push.artifact().admission_state(),
+ AdmissionState::Pending | AdmissionState::Retryable
+ )
+ {
+ sync.admit_signed(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ push = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+ }
+ if push.artifact().admission_state().is_admitted()
+ && matches!(
+ push.delivery_plan().state(),
+ AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable
+ )
+ {
+ sync.deliver_push(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ }
+ self.phase1_profile_status(draft_id).await
+ }
+
+ pub async fn phase1_cancel_profile(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ Phase1ProfilePayload::decode(&head)?;
+ if head.stage() == AuthoredDraftStage::Cancelled {
+ return self.profile_status_from(head).await;
+ }
+ if head.revision() != expected {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let push = self.profile_push_status_for(&head).await?;
+ if let Some(status) = push {
+ if status.settlement().is_successful() {
+ return Err(Phase1DraftError::Terminal);
+ }
+ self.sync()?
+ .cancel_push(sync_id_for(&head)?)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ }
+ let next = head
+ .successor(
+ head.payload().to_vec(),
+ AuthoredDraftStage::Cancelled,
+ head.operation_id(),
+ phase1_operation_now_unix_ms()?,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.profile_status_from(receipt.draft().clone()).await
+ }
+
+ /// Persists one complete Add intent with Rust-owned identity and time.
+ pub async fn phase1_save_add_intent(
+ &self,
+ intent: Phase1AddIntent,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let authored_at_unix_s = now_unix_ms / 1_000;
+ let (draft_id, expected_revision) = match intent.existing {
+ Some(existing) => (existing.draft_id, Some(existing.expected_revision)),
+ None => (phase1_random_id()?, None),
+ };
+ self.phase1_save_draft_with_form(
+ draft_id,
+ intent.command,
+ authored_at_unix_s,
+ intent.media,
+ intent.form,
+ expected_revision,
+ now_unix_ms,
+ )
+ .await
+ }
+
+ /// Freezes the canonical Rust-owned queue policy for the active relay
+ /// profile before preparing the durable outbox operation.
+ pub async fn phase1_queue_add_intent(
+ &self,
+ intent: Phase1QueueIntent,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let policy = self.active_queue_policy(now_unix_ms)?;
+ self.phase1_queue_draft(
+ intent.draft_id,
+ intent.expected_revision,
+ policy,
+ now_unix_ms,
+ )
+ .await
+ }
+
+ fn active_queue_policy(&self, now_unix_ms: u64) -> Result<Phase1QueuePolicy, Phase1DraftError> {
+ let report = self
+ .client
+ .nostr_status()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ let relay_urls = report
+ .relays()
+ .iter()
+ .filter(|relay| relay.endpoint().access().can_write())
+ .map(|relay| relay.endpoint().url().as_str().to_owned())
+ .collect::<Vec<_>>();
+ if relay_urls.is_empty() {
+ return Err(Phase1DraftError::NoWritableRelay);
+ }
+ let deadline = now_unix_ms
+ .checked_add(ADD_DELIVERY_TIMEOUT_MS)
+ .ok_or(Phase1DraftError::DeadlineOverflow)?;
+ Phase1QueuePolicy::new(
+ relay_urls,
+ Phase1RelaySatisfaction::AllAccepted,
+ deadline,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ }
+
+ /// Resumes a durable queue checkpoint with a Rust-owned recovery time.
+ pub async fn phase1_recover_add_intent(
+ &self,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?)
+ .await
+ }
+
+ /// Plans authorization, signing, and state timestamps in Rust, then runs
+ /// one complete exact-byte upload attempt.
+ pub async fn phase1_upload_add_media_intent(
+ &self,
+ intent: Phase1UploadIntent,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?;
+ let request = radroots_sdk::transport::BlossomUploadRequest::new(
+ intent.bytes,
+ intent.media_type,
+ intent.dimensions,
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let content = radroots_blossom::authorization::AuthorizationContent::parse(
+ &plan.authorization_content,
+ )
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ self.phase1_upload_draft_media(
+ intent.draft_id,
+ intent.expected_revision,
+ request,
+ content,
+ plan.authorization_created_at_unix_s,
+ plan.authorization_lifetime_seconds,
+ plan.operation_id,
+ plan.artifact_id,
+ plan.signing_deadline_unix_ms,
+ plan.cancellation,
+ radroots_sdk::transport::BlossomCancellation::default(),
+ plan.updated_at_unix_ms,
+ )
+ .await
+ }
+
+ /// Persists the upload transition and returns one immutable native job.
+ /// The authorization header is deliberately absent from durable draft
+ /// state and must never be persisted by the host.
+ pub async fn phase1_prepare_native_upload(
+ &self,
+ intent: Phase1UploadIntent,
+ ) -> Result<(Phase1DraftStatus, Phase1NativeUploadJob), Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?;
+ let request = radroots_sdk::transport::BlossomUploadRequest::new(
+ intent.bytes,
+ intent.media_type,
+ intent.dimensions,
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ let transaction = blossom
+ .prepare_upload(request)
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let remote_url = transaction.expected_url().as_str().to_owned();
+ let content = radroots_blossom::authorization::AuthorizationContent::parse(
+ &plan.authorization_content,
+ )
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let claim = blossom
+ .authored_upload_claim(
+ &transaction,
+ content,
+ plan.authorization_created_at_unix_s,
+ plan.authorization_lifetime_seconds,
+ )
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let authorization = self
+ .phase1_authorize_blossom_upload(
+ plan.operation_id,
+ plan.artifact_id,
+ claim,
+ plan.signing_deadline_unix_ms,
+ plan.cancellation,
+ )
+ .await?;
+ let uploading = self
+ .phase1_update_draft_media(
+ intent.draft_id,
+ intent.expected_revision,
+ remote_url.as_str(),
+ Phase1MediaStage::Uploading,
+ None,
+ now_unix_ms,
+ )
+ .await?;
+ Ok((
+ uploading,
+ Phase1NativeUploadJob {
+ operation_id: plan.operation_id,
+ remote_url,
+ authorization_header: authorization.into_string(),
+ expected_sha256: transaction.request().sha256().to_string(),
+ media_type: transaction.request().media_type().as_str().to_owned(),
+ byte_size: transaction.request().byte_size(),
+ },
+ ))
+ }
+
+ /// Verifies a native BUD-02 response and canonical BUD-01 retrieval before
+ /// advancing the durable media prerequisite.
+ pub async fn phase1_complete_native_upload(
+ &self,
+ intent: Phase1UploadIntent,
+ status_code: u16,
+ response_media_type: Option<&str>,
+ response_content_encoding: Option<&str>,
+ response_body: &[u8],
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let request = radroots_sdk::transport::BlossomUploadRequest::new(
+ intent.bytes,
+ intent.media_type,
+ intent.dimensions,
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ let transaction = blossom
+ .prepare_upload(request)
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let url = transaction.expected_url().as_str().to_owned();
+ match blossom
+ .complete_native_upload(
+ transaction,
+ status_code,
+ response_media_type,
+ response_content_encoding,
+ response_body,
+ radroots_sdk::transport::BlossomCancellation::default(),
+ )
+ .await
+ {
+ Ok(receipt) => {
+ self.phase1_complete_draft_media(
+ intent.draft_id,
+ intent.expected_revision,
+ url.as_str(),
+ receipt,
+ now_unix_ms,
+ )
+ .await
+ }
+ Err(error) => {
+ self.phase1_fail_draft_media(
+ intent.draft_id,
+ intent.expected_revision,
+ url.as_str(),
+ &error,
+ now_unix_ms,
+ )
+ .await?;
+ Err(Phase1DraftError::Operation)
+ }
+ }
+ }
+
+ /// Cancels local work with a Rust-owned transition timestamp.
+ pub async fn phase1_cancel_add_intent(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?)
+ .await
+ }
+
+ /// Persists the replacement half of one revision before any retraction can
+ /// exist. Standard kind-1 revisions receive a deterministic child draft ID
+ /// that remains inert until replacement settlement succeeds.
+ pub async fn phase1_save_revision_intent(
+ &self,
+ intent: Phase1ReviseIntent,
+ ) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let authored_at_unix_s = now_unix_ms / 1_000;
+ let author = self.draft_author()?;
+ if intent.target.author_public_key != hex::encode(author) {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ let draft_id = AuthoredDraftId::new(phase1_random_id()?)
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let plan = intent
+ .command
+ .authored_plan(authored_at_unix_s, hex::encode(author))
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let wire = PlanWireV1::from_plan(&plan)
+ .to_json()
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let policy = if intent.target.source_kind == 1 {
+ Phase1RevisionPolicy::ReplaceThenRetract
+ } else {
+ Phase1RevisionPolicy::AddressableReplacement
+ };
+ let retraction_draft_id = match policy {
+ Phase1RevisionPolicy::ReplaceThenRetract => {
+ let child_id = phase1_random_id()?;
+ if child_id == *draft_id.as_bytes() {
+ return Err(Phase1DraftError::OperationUnavailable);
+ }
+ Some(child_id)
+ }
+ Phase1RevisionPolicy::AddressableReplacement => None,
+ };
+ let mut payload =
+ Phase1DraftPayload::new(&intent.command, wire, intent.media, Some(intent.form))?;
+ payload.revision = Some(Phase1RevisionRecord {
+ target: intent.target,
+ policy,
+ retraction_draft_id,
+ });
+ let bytes = payload.encode()?;
+ let draft = AuthoredDraft::initial(
+ draft_id,
+ author,
+ DRAFT_PAYLOAD_SCHEMA,
+ bytes,
+ draft_stage_for_media(&payload.media),
+ None,
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ self.storage()?
+ .append_authored_draft(draft, None)
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.phase1_revision_status(*draft_id.as_bytes()).await
+ }
+
+ /// Reconstructs the complete ordered revision from durable replacement and
+ /// optional retraction child state after any process boundary.
+ pub async fn phase1_revision_status(
+ &self,
+ replacement_draft_id: [u8; 16],
+ ) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let replacement = self.phase1_draft_status(replacement_draft_id).await?;
+ let payload = Phase1DraftPayload::decode(replacement.draft())?;
+ let revision = payload.revision.ok_or(Phase1DraftError::InvalidRevision)?;
+ let retraction = match revision.retraction_draft_id {
+ Some(id) => match self.phase1_draft_status(id).await {
+ Ok(status) => {
+ validate_revision_retraction(&status, &revision.target)?;
+ Some(status)
+ }
+ Err(Phase1DraftError::NotFound) => None,
+ Err(error) => return Err(error),
+ },
+ None => None,
+ };
+ let phase = revision_phase(&replacement, retraction.as_ref(), revision.policy);
+ Ok(Phase1RevisionStatus {
+ replacement,
+ retraction,
+ target: revision.target,
+ policy: revision.policy,
+ phase,
+ })
+ }
+
+ /// Advances the replacement first and creates the NIP-09 child only after
+ /// all configured replacement delivery targets accepted it.
+ pub async fn phase1_advance_revision(
+ &self,
+ replacement_draft_id: [u8; 16],
+ ) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let mut status = self.phase1_revision_status(replacement_draft_id).await?;
+ if matches!(
+ status.replacement.state(),
+ Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign
+ ) {
+ self.phase1_queue_add_intent(Phase1QueueIntent::new(
+ replacement_draft_id,
+ status.replacement.draft().revision().get(),
+ )?)
+ .await?;
+ status = self.phase1_revision_status(replacement_draft_id).await?;
+ }
+ if matches!(
+ status.replacement.state(),
+ Phase1OutboxState::Queued
+ | Phase1OutboxState::Retryable
+ | Phase1OutboxState::PartiallyDelivered
+ ) {
+ self.phase1_advance_draft(
+ replacement_draft_id,
+ status.replacement.draft().revision().get(),
+ )
+ .await?;
+ status = self.phase1_revision_status(replacement_draft_id).await?;
+ }
+ if status.replacement.state() != Phase1OutboxState::Complete
+ || status.policy != Phase1RevisionPolicy::ReplaceThenRetract
+ {
+ return Ok(status);
+ }
+
+ let child_id =
+ revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?;
+ if status.retraction.is_none() {
+ self.phase1_create_revision_retraction(&status.target, child_id)
+ .await?;
+ status = self.phase1_revision_status(replacement_draft_id).await?;
+ }
+ let child = status
+ .retraction
+ .as_ref()
+ .ok_or(Phase1DraftError::Corrupt)?;
+ if matches!(
+ child.state(),
+ Phase1OutboxState::Draft | Phase1OutboxState::ReadyToSign
+ ) {
+ self.phase1_queue_add_intent(Phase1QueueIntent::new(
+ child_id,
+ child.draft().revision().get(),
+ )?)
+ .await?;
+ status = self.phase1_revision_status(replacement_draft_id).await?;
+ }
+ let child = status
+ .retraction
+ .as_ref()
+ .ok_or(Phase1DraftError::Corrupt)?;
+ if matches!(
+ child.state(),
+ Phase1OutboxState::Queued
+ | Phase1OutboxState::Retryable
+ | Phase1OutboxState::PartiallyDelivered
+ ) {
+ self.phase1_advance_draft(child_id, child.draft().revision().get())
+ .await?;
+ }
+ self.phase1_revision_status(replacement_draft_id).await
+ }
+
+ /// Cancels only still-pending work. If a kind-1 replacement is already
+ /// visible, a cancelled child records the deliberate partial effect and
+ /// prevents a later recovery from retracting the original unexpectedly.
+ pub async fn phase1_cancel_revision(
+ &self,
+ replacement_draft_id: [u8; 16],
+ ) -> Result<Phase1RevisionStatus, Phase1DraftError> {
+ let mut status = self.phase1_revision_status(replacement_draft_id).await?;
+ if !matches!(
+ status.replacement.state(),
+ Phase1OutboxState::Complete
+ | Phase1OutboxState::Terminal
+ | Phase1OutboxState::Cancelled
+ ) {
+ self.phase1_cancel_add_intent(
+ replacement_draft_id,
+ status.replacement.draft().revision().get(),
+ )
+ .await?;
+ return self.phase1_revision_status(replacement_draft_id).await;
+ }
+ if status.replacement.state() == Phase1OutboxState::Complete
+ && status.policy == Phase1RevisionPolicy::ReplaceThenRetract
+ {
+ let child_id =
+ revision_child_id(status.replacement.draft())?.ok_or(Phase1DraftError::Corrupt)?;
+ if status.retraction.is_none() {
+ self.phase1_create_revision_retraction(&status.target, child_id)
+ .await?;
+ status = self.phase1_revision_status(replacement_draft_id).await?;
+ }
+ let child = status
+ .retraction
+ .as_ref()
+ .ok_or(Phase1DraftError::Corrupt)?;
+ if !matches!(
+ child.state(),
+ Phase1OutboxState::Complete
+ | Phase1OutboxState::Terminal
+ | Phase1OutboxState::Cancelled
+ ) {
+ self.phase1_cancel_add_intent(child_id, child.draft().revision().get())
+ .await?;
+ }
+ }
+ self.phase1_revision_status(replacement_draft_id).await
+ }
+
+ async fn phase1_create_revision_retraction(
+ &self,
+ target: &Phase1RevisionTarget,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ match self.phase1_draft_status(draft_id).await {
+ Ok(existing) => return Ok(existing),
+ Err(Phase1DraftError::NotFound) => {}
+ Err(error) => return Err(error),
+ }
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ self.phase1_save_retraction_draft(
+ draft_id,
+ target.command_type,
+ target.card_id,
+ &target.source_event_id,
+ target.source_kind,
+ target.source_address.as_deref(),
+ REVISION_RETRACTION_REASON,
+ now_unix_ms / 1_000,
+ now_unix_ms,
+ )
+ .await
+ }
+
+ /// Creates or replaces the editable content of one immutable-revision draft.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_save_draft(
+ &self,
+ draft_id: [u8; 16],
+ command: Phase1AddCommand,
+ authored_at_unix_s: u64,
+ media: Vec<Phase1MediaPrerequisite>,
+ expected_revision: Option<u64>,
+ persisted_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ self.phase1_save_draft_inner(
+ draft_id,
+ command,
+ authored_at_unix_s,
+ media,
+ None,
+ expected_revision,
+ persisted_at_unix_ms,
+ )
+ .await
+ }
+
+ /// Creates or replaces a draft while retaining its validated, reopenable form.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_save_draft_with_form(
+ &self,
+ draft_id: [u8; 16],
+ command: Phase1AddCommand,
+ authored_at_unix_s: u64,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Phase1DraftFormSnapshot,
+ expected_revision: Option<u64>,
+ persisted_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ self.phase1_save_draft_inner(
+ draft_id,
+ command,
+ authored_at_unix_s,
+ media,
+ Some(form),
+ expected_revision,
+ persisted_at_unix_ms,
+ )
+ .await
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn phase1_save_draft_inner(
+ &self,
+ draft_id: [u8; 16],
+ command: Phase1AddCommand,
+ authored_at_unix_s: u64,
+ media: Vec<Phase1MediaPrerequisite>,
+ form: Option<Phase1DraftFormSnapshot>,
+ expected_revision: Option<u64>,
+ persisted_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let author = self.draft_author()?;
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let plan = command
+ .authored_plan(authored_at_unix_s, hex::encode(author))
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let wire = PlanWireV1::from_plan(&plan)
+ .to_json()
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let payload = Phase1DraftPayload::new(&command, wire, media, form)?;
+ let bytes = payload.encode()?;
+ let storage = self.storage()?;
+ let expected = expected_revision
+ .map(AuthoredDraftRevision::new)
+ .transpose()
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let draft = if let Some(expected) = expected {
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ head.successor(
+ bytes,
+ draft_stage_for_media(&payload.media),
+ None,
+ persisted_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?
+ } else {
+ AuthoredDraft::initial(
+ draft_id,
+ author,
+ DRAFT_PAYLOAD_SCHEMA,
+ bytes,
+ draft_stage_for_media(&payload.media),
+ None,
+ persisted_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidDraft)?
+ };
+ let receipt = storage
+ .append_authored_draft(draft, expected)
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Persists an independent strict NIP-09 retraction as a normal durable outbox item.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_save_retraction_draft(
+ &self,
+ draft_id: [u8; 16],
+ command_type: AddCommandType,
+ target_card_id: CardId,
+ target_event_id: &str,
+ target_kind: u32,
+ target_address: Option<&str>,
+ reason: &str,
+ authored_at_unix_s: u64,
+ persisted_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let target_shape_valid = match command_type {
+ AddCommandType::CreateUpdate
+ | AddCommandType::CreatePhotoUpdate
+ | AddCommandType::CreateAsk => target_kind == 1 && target_address.is_none(),
+ AddCommandType::CreateEvent => {
+ matches!(target_kind, 31_922 | 31_923) && target_address.is_some()
+ }
+ AddCommandType::CreateFoodAvailability => {
+ target_kind == 30_402 && target_address.is_some()
+ }
+ };
+ if !target_shape_valid || authored_at_unix_s == 0 || persisted_at_unix_ms == 0 {
+ return Err(Phase1DraftError::InvalidDraft);
+ }
+ let author = self.draft_author()?;
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let event_target = Nip09DeletionEventTarget::parse(target_event_id, target_kind)
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let address_targets = target_address
+ .map(Nip09DeletionAddressTarget::parse)
+ .transpose()
+ .map_err(|_| Phase1DraftError::InvalidDraft)?
+ .into_iter()
+ .collect();
+ let request =
+ AuthoredNip09DeletionRequest::new(reason, vec![event_target], address_targets)
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let plan = phase1_retraction_plan(&request, authored_at_unix_s, hex::encode(author))
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let wire = PlanWireV1::from_plan(&plan)
+ .to_json()
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let payload = Phase1DraftPayload::retraction(command_type, target_card_id, wire)?;
+ let bytes = payload.encode()?;
+ let draft = AuthoredDraft::initial(
+ draft_id,
+ author,
+ DRAFT_PAYLOAD_SCHEMA,
+ bytes,
+ AuthoredDraftStage::Draft,
+ None,
+ persisted_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let receipt = self
+ .storage()?
+ .append_authored_draft(draft, None)
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Advances one media prerequisite without mutating any prior revision.
+ pub async fn phase1_update_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ stage: Phase1MediaStage,
+ failure_code: Option<String>,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let media = payload
+ .media
+ .iter_mut()
+ .find(|media| media.url == url)
+ .ok_or(Phase1DraftError::InvalidMedia)?;
+ if !valid_media_transition(media.stage, stage)
+ || matches!(
+ stage,
+ Phase1MediaStage::Verified | Phase1MediaStage::Orphaned
+ )
+ {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ media.stage = stage;
+ media.failure_code = failure_code;
+ if stage != Phase1MediaStage::Failed {
+ media.failure_code = None;
+ }
+ media.validate()?;
+ let next_stage = draft_stage_for_media(&payload.media);
+ let next = head
+ .successor(payload.encode()?, next_stage, None, updated_at_unix_ms)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Records the only proof that can advance media to remote-byte-verified.
+ pub async fn phase1_complete_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ receipt: radroots_sdk::transport::BlossomUploadReceipt,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let media = payload
+ .media
+ .iter_mut()
+ .find(|media| media.url == url)
+ .ok_or(Phase1DraftError::InvalidMedia)?;
+ if media.stage != Phase1MediaStage::Uploading || !media.matches_receipt(&receipt) {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ media.stage = Phase1MediaStage::Verified;
+ media.failure_code = None;
+ media.upload_attempts = receipt.attempts();
+ media.verified_at_unix_ms = Some(receipt.verified_at_unix_ms());
+ media.orphan = None;
+ media.validate()?;
+ let next_stage = draft_stage_for_media(&payload.media);
+ let next = head
+ .successor(payload.encode()?, next_stage, None, updated_at_unix_ms)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Persists a redacted recoverable Blossom failure and possible-orphan evidence.
+ pub async fn phase1_fail_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ url: &str,
+ error: &radroots_sdk::transport::BlossomError,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ if updated_at_unix_ms == 0 {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected
+ || head.stage().is_terminal()
+ || matches!(
+ head.stage(),
+ AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued
+ )
+ {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let media = payload
+ .media
+ .iter_mut()
+ .find(|media| media.url == url)
+ .ok_or(Phase1DraftError::InvalidMedia)?;
+ if !matches!(
+ media.stage,
+ Phase1MediaStage::Pending
+ | Phase1MediaStage::Preparing
+ | Phase1MediaStage::Uploading
+ | Phase1MediaStage::Failed
+ ) {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ media.stage = Phase1MediaStage::Failed;
+ media.failure_code = Some(error.code().to_owned());
+ media.upload_attempts = error.attempts();
+ media.verified_at_unix_ms = None;
+ media.orphan = error.possible_orphan().then(|| Phase1MediaOrphanRecord {
+ reason_code: error.code().to_owned(),
+ recorded_at_unix_ms: updated_at_unix_ms,
+ });
+ media.validate()?;
+ let next_stage = draft_stage_for_media(&payload.media);
+ let next = head
+ .successor(payload.encode()?, next_stage, None, updated_at_unix_ms)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Freezes queue intent and atomically prepares the canonical outbox before
+ /// returning `queued`. Network connectivity is neither read nor required.
+ pub async fn phase1_queue_draft(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ policy: Phase1QueuePolicy,
+ queued_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let ready = match head.stage() {
+ AuthoredDraftStage::ReadyToSign => {
+ if payload.queue.as_ref() != Some(&policy) {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ head
+ }
+ AuthoredDraftStage::Queued => {
+ if payload.queue.as_ref() != Some(&policy) {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ return self.draft_status_from(head).await;
+ }
+ AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal),
+ AuthoredDraftStage::Draft
+ | AuthoredDraftStage::MediaPreparing
+ | AuthoredDraftStage::MediaUploading => {
+ if payload
+ .media
+ .iter()
+ .any(|media| !media.is_remote_verified())
+ {
+ return Err(Phase1DraftError::MediaNotReady);
+ }
+ policy.materialize()?;
+ payload.queue = Some(policy);
+ let bytes = payload.encode()?;
+ let operation_id = operation_id(draft_id, bytes.as_slice())?;
+ let operation_id = OperationInstanceId::new(*operation_id.as_bytes())
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let ready = head
+ .successor(
+ bytes,
+ AuthoredDraftStage::ReadyToSign,
+ Some(operation_id),
+ queued_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ storage
+ .append_authored_draft(ready.clone(), Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ ready
+ }
+ };
+ self.finish_queue(ready, queued_at_unix_ms).await
+ }
+
+ /// Resumes a queue transition interrupted after its durable ready-to-sign
+ /// checkpoint, including the crash window after outbox preparation.
+ pub async fn phase1_recover_draft_queue(
+ &self,
+ draft_id: [u8; 16],
+ recovered_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let head = self
+ .storage()?
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ match head.stage() {
+ AuthoredDraftStage::ReadyToSign => self.finish_queue(head, recovered_at_unix_ms).await,
+ AuthoredDraftStage::Queued | AuthoredDraftStage::Cancelled => {
+ self.draft_status_from(head).await
+ }
+ AuthoredDraftStage::Draft
+ | AuthoredDraftStage::MediaPreparing
+ | AuthoredDraftStage::MediaUploading => Err(Phase1DraftError::InvalidDraft),
+ }
+ }
+
+ /// Invokes the configured opaque host signer for one durably queued draft.
+ ///
+ /// The canonical sync engine verifies author, event ID, exact fields,
+ /// signature, deadline, cancellation, and operation binding before the
+ /// signed artifact can be persisted. Delivery remains a separate phase.
+ pub async fn phase1_sign_queued_draft(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let head = self
+ .storage()?
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ self.sync()?
+ .sign_prepared(push_request(&head)?)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ self.draft_status_from(head).await
+ }
+
+ /// Advances one durably queued draft through signing, local admission, and
+ /// at most one bounded relay-delivery attempt.
+ pub async fn phase1_advance_draft(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let head = self
+ .storage()?
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let request = push_request(&head)?;
+ let operation_id = request.operation_id();
+ let sync = self.sync()?;
+ let mut status = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+
+ if matches!(
+ status.artifact().signing_state(),
+ SigningState::Planned | SigningState::Retryable
+ ) {
+ sync.sign_prepared(request)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ status = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+ }
+ if status.artifact().signing_state() == SigningState::Signed
+ && matches!(
+ status.artifact().admission_state(),
+ AdmissionState::Pending | AdmissionState::Retryable
+ )
+ {
+ sync.admit_signed(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ status = sync
+ .push_status(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?
+ .ok_or(Phase1DraftError::Corrupt)?;
+ }
+ if status.artifact().admission_state().is_admitted()
+ && matches!(
+ status.delivery_plan().state(),
+ AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable
+ )
+ {
+ sync.deliver_push(operation_id)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ }
+ self.draft_status_from(head).await
+ }
+
+ /// Signs one short-lived BUD-11 upload credential for HTTP use only.
+ ///
+ /// The returned value is not persisted and its distinct plan type cannot
+ /// enter the relay push pipeline.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_authorize_blossom_upload(
+ &self,
+ operation_id: [u8; 16],
+ artifact_id: [u8; 16],
+ claim: AuthoredUploadClaim,
+ deadline_unix_ms: u64,
+ cancellation: Phase1CancellationPolicy,
+ ) -> Result<radroots_sdk::signing::AuthorizationHeader, Phase1DraftError> {
+ let public_key = self
+ .store_public_key
+ .ok_or(Phase1DraftError::IdentityUnavailable)?;
+ let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL)
+ .map_err(|_| Phase1DraftError::IdentityUnavailable)?;
+ let plan = radroots_sdk::signing::BlossomAuthorizationPlan::for_upload(&claim, public_key)
+ .map_err(|_| Phase1DraftError::InvalidMedia)?;
+ let operation_id =
+ SigningOperationId::new(operation_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let artifact_id =
+ AuthoredArtifactId::new(artifact_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let policy = SignPolicy::new(deadline_unix_ms, cancellation.signing())
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let request = radroots_sdk::signing::blossom_upload_request(
+ radroots_protocol::runtime::v1::OperationId::SyncPush,
+ SigningIntentId::new(operation_id, artifact_id),
+ actor,
+ plan,
+ policy,
+ )
+ .map_err(|_| Phase1DraftError::Operation)?;
+ self.client
+ .signing()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?
+ .authorize_blossom_upload(request)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)
+ }
+
+ /// Runs the complete durable BUD-11/BUD-02/BUD-01 media transaction.
+ ///
+ /// Final image bytes are bound before authorization. The draft becomes
+ /// verified only after the upload descriptor and a full retrieval agree.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_upload_draft_media(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ request: radroots_sdk::transport::BlossomUploadRequest,
+ authorization_content: radroots_blossom::authorization::AuthorizationContent,
+ authorization_created_at_unix_s: u64,
+ authorization_lifetime_seconds: u64,
+ operation_id: [u8; 16],
+ artifact_id: [u8; 16],
+ signing_deadline_unix_ms: u64,
+ signing_cancellation: Phase1CancellationPolicy,
+ transfer_cancellation: radroots_sdk::transport::BlossomCancellation,
+ updated_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ let transaction = blossom
+ .prepare_upload(request)
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let url = transaction.expected_url().as_str().to_owned();
+ let uploading = self
+ .phase1_update_draft_media(
+ draft_id,
+ expected_revision,
+ url.as_str(),
+ Phase1MediaStage::Uploading,
+ None,
+ updated_at_unix_ms,
+ )
+ .await?;
+ let revision = uploading.draft().revision().get();
+ let claim = match blossom.authored_upload_claim(
+ &transaction,
+ authorization_content,
+ authorization_created_at_unix_s,
+ authorization_lifetime_seconds,
+ ) {
+ Ok(claim) => claim,
+ Err(_) => {
+ self.phase1_update_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ Phase1MediaStage::Failed,
+ Some("blossom_authorization_failed".to_owned()),
+ updated_at_unix_ms,
+ )
+ .await?;
+ return Err(Phase1DraftError::Operation);
+ }
+ };
+ let authorization = match self
+ .phase1_authorize_blossom_upload(
+ operation_id,
+ artifact_id,
+ claim,
+ signing_deadline_unix_ms,
+ signing_cancellation,
+ )
+ .await
+ {
+ Ok(authorization) => authorization,
+ Err(error) => {
+ self.phase1_update_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ Phase1MediaStage::Failed,
+ Some("blossom_authorization_failed".to_owned()),
+ updated_at_unix_ms,
+ )
+ .await?;
+ return Err(error);
+ }
+ };
+ match blossom
+ .upload(transaction, authorization, transfer_cancellation)
+ .await
+ {
+ Ok(receipt) => {
+ self.phase1_complete_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ receipt,
+ updated_at_unix_ms,
+ )
+ .await
+ }
+ Err(error) => {
+ self.phase1_fail_draft_media(
+ draft_id,
+ revision,
+ url.as_str(),
+ &error,
+ updated_at_unix_ms,
+ )
+ .await?;
+ Err(Phase1DraftError::Operation)
+ }
+ }
+ }
+
+ /// Returns durable draft state composed with canonical authored-operation state.
+ pub async fn phase1_draft_status(
+ &self,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let head = self
+ .storage()?
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ self.draft_status_from(head).await
+ }
+
+ /// Lists the newest immutable revision of each draft for the active author.
+ pub async fn phase1_draft_heads(
+ &self,
+ limit: u16,
+ ) -> Result<Vec<Phase1DraftStatus>, Phase1DraftError> {
+ let drafts = self
+ .storage()?
+ .authored_draft_heads(self.draft_author()?, limit)
+ .await
+ .map_err(map_draft_storage_error)?;
+ let mut statuses = Vec::with_capacity(drafts.len());
+ for draft in drafts {
+ statuses.push(self.draft_status_from(draft).await?);
+ }
+ Ok(statuses)
+ }
+
+ /// Cancels still-pending authored work and records uploaded-but-unreferenced
+ /// media as possible orphans without deleting any evidence.
+ pub async fn phase1_cancel_draft(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ cancelled_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.stage() == AuthoredDraftStage::Cancelled {
+ return self.draft_status_from(head).await;
+ }
+ if head.revision() != expected {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1DraftPayload::decode(&head)?;
+ let push = self.push_status_for(&head).await?;
+ if let Some(status) = &push {
+ self.sync()?
+ .cancel_push(sync_id_for(&head)?)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ if status.artifact().signed().is_none() {
+ mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms);
+ }
+ } else {
+ mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms);
+ }
+ let next = head
+ .successor(
+ payload.encode()?,
+ AuthoredDraftStage::Cancelled,
+ head.operation_id(),
+ cancelled_at_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = storage
+ .append_authored_draft(next, Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ /// Applies the current durable operation state to an already-projected
+ /// active-author card. The overlay remains local and never changes event truth.
+ pub async fn phase1_apply_draft_overlay(
+ &self,
+ context: &LocalNetwork,
+ draft_id: [u8; 16],
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let status = self.phase1_draft_status(draft_id).await?;
+ let operation_id = status
+ .draft
+ .operation_id()
+ .map(|id| hex::encode(id.as_bytes()))
+ .ok_or(Phase1DraftError::OperationUnavailable)?;
+ self.phase1_set_local_author_overlay(
+ context,
+ status.card_id,
+ Some(LocalAuthorOverlay {
+ operation_id,
+ state: status.state.label().to_owned(),
+ }),
+ )
+ .await
+ .map_err(map_overlay_error)?;
+ Ok(status)
+ }
+
+ async fn phase1_queue_profile(
+ &self,
+ draft_id: [u8; 16],
+ expected_revision: u64,
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let now_unix_ms = phase1_operation_now_unix_ms()?;
+ let draft_id =
+ AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let expected = AuthoredDraftRevision::new(expected_revision)
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let storage = self.storage()?;
+ let head = storage
+ .authored_draft_head(draft_id)
+ .await
+ .map_err(|_| Phase1DraftError::Storage)?
+ .ok_or(Phase1DraftError::NotFound)?;
+ if head.revision() != expected {
+ return Err(Phase1DraftError::RevisionConflict);
+ }
+ let mut payload = Phase1ProfilePayload::decode(&head)?;
+ let ready = match head.stage() {
+ AuthoredDraftStage::Draft => {
+ payload.queue = Some(self.active_queue_policy(now_unix_ms)?);
+ let bytes = payload.encode()?;
+ let operation_id = operation_id(draft_id, bytes.as_slice())?;
+ let operation_id = OperationInstanceId::new(*operation_id.as_bytes())
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ let ready = head
+ .successor(
+ bytes,
+ AuthoredDraftStage::ReadyToSign,
+ Some(operation_id),
+ now_unix_ms,
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ storage
+ .append_authored_draft(ready.clone(), Some(expected))
+ .await
+ .map_err(map_draft_storage_error)?;
+ ready
+ }
+ AuthoredDraftStage::ReadyToSign => head,
+ AuthoredDraftStage::Queued => return self.profile_status_from(head).await,
+ AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal),
+ AuthoredDraftStage::MediaPreparing | AuthoredDraftStage::MediaUploading => {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ };
+ self.finish_profile_queue(ready, now_unix_ms).await
+ }
+
+ async fn finish_profile_queue(
+ &self,
+ ready: AuthoredDraft,
+ queued_at_unix_ms: u64,
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ let request = profile_push_request(&ready)?;
+ self.sync()?
+ .prepare_push(request)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let queued = ready
+ .successor(
+ ready.payload().to_vec(),
+ AuthoredDraftStage::Queued,
+ ready.operation_id(),
+ queued_at_unix_ms.max(ready.updated_at_unix_ms()),
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = self
+ .storage()?
+ .append_authored_draft(queued, Some(ready.revision()))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.profile_status_from(receipt.draft().clone()).await
+ }
+
+ async fn profile_status_from(
+ &self,
+ draft: AuthoredDraft,
+ ) -> Result<Phase1ProfileStatus, Phase1DraftError> {
+ if draft.author() != &self.draft_author()? {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ Phase1ProfilePayload::decode(&draft)?;
+ let push = self.profile_push_status_for(&draft).await?;
+ if draft.stage() == AuthoredDraftStage::Queued && push.is_none() {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let state = aggregate_state(&draft, push.as_ref());
+ Ok(Phase1ProfileStatus { draft, state, push })
+ }
+
+ async fn profile_push_status_for(
+ &self,
+ draft: &AuthoredDraft,
+ ) -> Result<Option<PushStatus>, Phase1DraftError> {
+ let Some(_) = draft.operation_id() else {
+ return Ok(None);
+ };
+ self.sync()?
+ .push_status(sync_id_for(draft)?)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)
+ }
+
+ async fn finish_queue(
+ &self,
+ ready: AuthoredDraft,
+ queued_at_unix_ms: u64,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ let request = push_request(&ready)?;
+ self.sync()?
+ .prepare_push(request)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)?;
+ let queued = ready
+ .successor(
+ ready.payload().to_vec(),
+ AuthoredDraftStage::Queued,
+ ready.operation_id(),
+ queued_at_unix_ms.max(ready.updated_at_unix_ms()),
+ )
+ .map_err(|_| Phase1DraftError::RevisionConflict)?;
+ let receipt = self
+ .storage()?
+ .append_authored_draft(queued, Some(ready.revision()))
+ .await
+ .map_err(map_draft_storage_error)?;
+ self.draft_status_from(receipt.draft().clone()).await
+ }
+
+ async fn draft_status_from(
+ &self,
+ draft: AuthoredDraft,
+ ) -> Result<Phase1DraftStatus, Phase1DraftError> {
+ if draft.author() != &self.draft_author()? {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let payload = Phase1DraftPayload::decode(&draft)?;
+ let integrity = PlanWireV1::from_json(payload.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ let card_id = payload
+ .target_card_id
+ .map(Ok)
+ .unwrap_or_else(|| card_id(payload.command_type, integrity.plan()))?;
+ let revision_policy = payload.revision.as_ref().map(|value| value.policy);
+ let push = self.push_status_for(&draft).await?;
+ if draft.stage() == AuthoredDraftStage::Queued && push.is_none() {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let state = aggregate_state(&draft, push.as_ref());
+ Ok(Phase1DraftStatus {
+ draft,
+ kind: payload.kind,
+ command_type: payload.command_type,
+ form: payload.form,
+ media: payload.media,
+ state,
+ card_id,
+ push,
+ revision_policy,
+ })
+ }
+
+ async fn push_status_for(
+ &self,
+ draft: &AuthoredDraft,
+ ) -> Result<Option<PushStatus>, Phase1DraftError> {
+ let Some(_) = draft.operation_id() else {
+ return Ok(None);
+ };
+ self.sync()?
+ .push_status(sync_id_for(draft)?)
+ .await
+ .map_err(|_| Phase1DraftError::Operation)
+ }
+
+ fn storage(&self) -> Result<&dyn AuthoredDraftStore, Phase1DraftError> {
+ self.client
+ .storage()
+ .map(|storage| storage as &dyn AuthoredDraftStore)
+ .map_err(|_| Phase1DraftError::Storage)
+ }
+
+ fn sync(&self) -> Result<radroots_sdk::sync::Operations<'_>, Phase1DraftError> {
+ self.client
+ .sync()
+ .map_err(|_| Phase1DraftError::OperationUnavailable)?
+ .ok_or(Phase1DraftError::OperationUnavailable)
+ }
+
+ fn draft_author(&self) -> Result<[u8; 32], Phase1DraftError> {
+ self.store_public_key
+ .map(|key| *key.as_bytes())
+ .ok_or(Phase1DraftError::IdentityUnavailable)
+ }
+}
+
+fn push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> {
+ let payload = Phase1DraftPayload::decode(draft)?;
+ let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?;
+ let (targets, satisfaction, cancellation) = policy.materialize()?;
+ let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?
+ .into_plan();
+ let public_key = PublicKey::from_bytes(*draft.author())
+ .map_err(|_| Phase1DraftError::IdentityUnavailable)?;
+ let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL)
+ .map_err(|_| Phase1DraftError::IdentityUnavailable)?;
+ let sync_id = sync_id_for(draft)?;
+ let idempotency = IdempotencyKey::parse(format!(
+ "phase1-draft-{}",
+ hex::encode(draft.draft_id().as_bytes())
+ ))
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ PushRequest::new(
+ sync_id,
+ idempotency,
+ actor,
+ plan,
+ targets,
+ satisfaction,
+ policy.delivery_deadline_unix_ms,
+ cancellation,
+ )
+ .map_err(|_| Phase1DraftError::InvalidQueuePolicy)
+}
+
+fn profile_push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> {
+ let payload = Phase1ProfilePayload::decode(draft)?;
+ let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?;
+ let (targets, satisfaction, cancellation) = policy.materialize()?;
+ let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?
+ .into_plan();
+ let public_key = PublicKey::from_bytes(*draft.author())
+ .map_err(|_| Phase1DraftError::IdentityUnavailable)?;
+ let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL)
+ .map_err(|_| Phase1DraftError::IdentityUnavailable)?;
+ let sync_id = sync_id_for(draft)?;
+ let idempotency = IdempotencyKey::parse(format!(
+ "phase1-profile-{}",
+ hex::encode(draft.draft_id().as_bytes())
+ ))
+ .map_err(|_| Phase1DraftError::InvalidDraft)?;
+ PushRequest::new(
+ sync_id,
+ idempotency,
+ actor,
+ plan,
+ targets,
+ satisfaction,
+ policy.delivery_deadline_unix_ms,
+ cancellation,
+ )
+ .map_err(|_| Phase1DraftError::InvalidQueuePolicy)
+}
+
+fn operation_id(
+ draft_id: AuthoredDraftId,
+ ready_payload: &[u8],
+) -> Result<SyncId, Phase1DraftError> {
+ let mut hasher = Sha256::new();
+ hasher.update(DRAFT_OPERATION_DOMAIN);
+ hasher.update(draft_id.as_bytes());
+ hasher.update(
+ u64::try_from(ready_payload.len())
+ .map_err(|_| Phase1DraftError::InvalidDraft)?
+ .to_be_bytes(),
+ );
+ hasher.update(ready_payload);
+ let digest: [u8; 32] = hasher.finalize().into();
+ let mut value = [0_u8; 16];
+ value.copy_from_slice(&digest[..16]);
+ if value.iter().all(|byte| *byte == 0) {
+ value[15] = 1;
+ }
+ SyncId::new(value).map_err(|_| Phase1DraftError::InvalidDraft)
+}
+
+fn sync_id_for(draft: &AuthoredDraft) -> Result<SyncId, Phase1DraftError> {
+ draft
+ .operation_id()
+ .ok_or(Phase1DraftError::OperationUnavailable)
+ .and_then(|id| SyncId::new(*id.as_bytes()).map_err(|_| Phase1DraftError::Corrupt))
+}
+
+fn media_urls(tags: &[Vec<String>]) -> Result<BTreeSet<&str>, Phase1DraftError> {
+ let mut urls = BTreeSet::new();
+ for tag in tags {
+ let candidate = match tag.first().map(String::as_str) {
+ Some("imeta") => tag
+ .iter()
+ .skip(1)
+ .find_map(|value| value.strip_prefix("url ")),
+ Some("image") => tag.get(1).map(String::as_str),
+ _ => None,
+ };
+ if let Some(candidate) = candidate
+ && BlobUrl::parse(candidate).is_ok()
+ && !urls.insert(candidate)
+ {
+ return Err(Phase1DraftError::InvalidMedia);
+ }
+ }
+ Ok(urls)
+}
+
+fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStage {
+ if media.is_empty() {
+ AuthoredDraftStage::Draft
+ } else if media
+ .iter()
+ .any(|media| media.stage == Phase1MediaStage::Uploading)
+ {
+ AuthoredDraftStage::MediaUploading
+ } else {
+ AuthoredDraftStage::MediaPreparing
+ }
+}
+
+fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite], recorded_at_unix_ms: u64) {
+ for media in media {
+ if media.stage == Phase1MediaStage::Verified || media.orphan.is_some() {
+ media.stage = Phase1MediaStage::Orphaned;
+ media.failure_code = None;
+ media.orphan = Some(Phase1MediaOrphanRecord {
+ reason_code: "draft_cancelled_after_upload".to_owned(),
+ recorded_at_unix_ms,
+ });
+ }
+ }
+}
+
+const fn valid_media_transition(previous: Phase1MediaStage, next: Phase1MediaStage) -> bool {
+ match previous {
+ Phase1MediaStage::Pending => matches!(
+ next,
+ Phase1MediaStage::Pending
+ | Phase1MediaStage::Preparing
+ | Phase1MediaStage::Uploading
+ | Phase1MediaStage::Failed
+ ),
+ Phase1MediaStage::Preparing => matches!(
+ next,
+ Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed
+ ),
+ Phase1MediaStage::Uploading => matches!(
+ next,
+ Phase1MediaStage::Uploading | Phase1MediaStage::Verified | Phase1MediaStage::Failed
+ ),
+ Phase1MediaStage::Failed => matches!(
+ next,
+ Phase1MediaStage::Preparing | Phase1MediaStage::Uploading | Phase1MediaStage::Failed
+ ),
+ Phase1MediaStage::Verified => matches!(next, Phase1MediaStage::Verified),
+ Phase1MediaStage::Orphaned => matches!(next, Phase1MediaStage::Orphaned),
+ }
+}
+
+fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1OutboxState {
+ if draft.stage() == AuthoredDraftStage::Cancelled {
+ return Phase1OutboxState::Cancelled;
+ }
+ let Some(push) = push else {
+ return match draft.stage() {
+ AuthoredDraftStage::Draft => Phase1OutboxState::Draft,
+ AuthoredDraftStage::MediaPreparing => Phase1OutboxState::MediaPreparing,
+ AuthoredDraftStage::MediaUploading => Phase1OutboxState::MediaUploading,
+ AuthoredDraftStage::ReadyToSign => Phase1OutboxState::ReadyToSign,
+ AuthoredDraftStage::Queued => Phase1OutboxState::Queued,
+ AuthoredDraftStage::Cancelled => Phase1OutboxState::Cancelled,
+ };
+ };
+ if push.settlement().is_successful() {
+ return Phase1OutboxState::Complete;
+ }
+ if push.settlement().has_failures() {
+ return if has_delivery_success(push) {
+ Phase1OutboxState::PartiallyDelivered
+ } else if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0
+ {
+ Phase1OutboxState::Retryable
+ } else if push.settlement().cancelled() != 0 || push.settlement().delivery_cancelled() != 0
+ {
+ Phase1OutboxState::Cancelled
+ } else {
+ Phase1OutboxState::Terminal
+ };
+ }
+ match push.artifact().signing_state() {
+ SigningState::Planned if push.artifact().signing_claim().is_some() => {
+ Phase1OutboxState::Signing
+ }
+ SigningState::Planned => Phase1OutboxState::Queued,
+ SigningState::Retryable => Phase1OutboxState::Retryable,
+ SigningState::Indeterminate | SigningState::FailedTerminal => Phase1OutboxState::Terminal,
+ SigningState::Cancelled => Phase1OutboxState::Cancelled,
+ SigningState::Signed => match push.artifact().admission_state() {
+ AdmissionState::Pending => Phase1OutboxState::Signed,
+ AdmissionState::Retryable => Phase1OutboxState::Retryable,
+ AdmissionState::Rejected => Phase1OutboxState::Terminal,
+ AdmissionState::Cancelled => Phase1OutboxState::Cancelled,
+ AdmissionState::Inserted | AdmissionState::Duplicate => match push
+ .delivery_plan()
+ .state()
+ {
+ AuthoredDeliveryState::Pending
+ if push.delivery_plan().claim_evidence().is_some() =>
+ {
+ Phase1OutboxState::Delivering
+ }
+ AuthoredDeliveryState::Pending if push.delivery_plan().attempts().is_empty() => {
+ Phase1OutboxState::Queued
+ }
+ AuthoredDeliveryState::Pending => Phase1OutboxState::PartiallyDelivered,
+ AuthoredDeliveryState::Retryable if has_delivery_success(push) => {
+ Phase1OutboxState::PartiallyDelivered
+ }
+ AuthoredDeliveryState::Retryable => Phase1OutboxState::Retryable,
+ AuthoredDeliveryState::Satisfied => Phase1OutboxState::Complete,
+ AuthoredDeliveryState::Exhausted | AuthoredDeliveryState::FailedTerminal => {
+ Phase1OutboxState::Terminal
+ }
+ AuthoredDeliveryState::Cancelled => Phase1OutboxState::Cancelled,
+ },
+ },
+ }
+}
+
+fn has_delivery_success(push: &PushStatus) -> bool {
+ push.delivery_plan().attempts().iter().any(|attempt| {
+ let evidence = match attempt.outcome() {
+ DeliveryAttemptOutcome::Receipt(receipt) => receipt.target_receipts(),
+ DeliveryAttemptOutcome::SinkFailure(failure) => failure.partial_evidence(),
+ };
+ evidence.iter().any(|receipt| {
+ matches!(
+ receipt.outcome().kind(),
+ DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered
+ )
+ })
+ })
+}
+
+fn revision_phase(
+ replacement: &Phase1DraftStatus,
+ retraction: Option<&Phase1DraftStatus>,
+ policy: Phase1RevisionPolicy,
+) -> Phase1RevisionPhase {
+ match replacement.state() {
+ Phase1OutboxState::Cancelled => return Phase1RevisionPhase::Cancelled,
+ Phase1OutboxState::Terminal => return Phase1RevisionPhase::ReplacementFailed,
+ Phase1OutboxState::Complete => {}
+ _ => return Phase1RevisionPhase::ReplacementPending,
+ }
+ if policy == Phase1RevisionPolicy::AddressableReplacement {
+ return Phase1RevisionPhase::Complete;
+ }
+ match retraction.map(Phase1DraftStatus::state) {
+ Some(Phase1OutboxState::Complete) => Phase1RevisionPhase::Complete,
+ Some(Phase1OutboxState::Cancelled | Phase1OutboxState::Terminal) => {
+ Phase1RevisionPhase::PartialEffect
+ }
+ Some(_) | None => Phase1RevisionPhase::RetractionPending,
+ }
+}
+
+fn revision_child_id(draft: &AuthoredDraft) -> Result<Option<[u8; 16]>, Phase1DraftError> {
+ Ok(Phase1DraftPayload::decode(draft)?
+ .revision
+ .ok_or(Phase1DraftError::InvalidRevision)?
+ .retraction_draft_id)
+}
+
+fn validate_revision_retraction(
+ status: &Phase1DraftStatus,
+ target: &Phase1RevisionTarget,
+) -> Result<(), Phase1DraftError> {
+ if status.kind() != Phase1DraftKind::Retraction
+ || status.command_type() != target.command_type
+ || status.card_id() != target.card_id
+ {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ let payload = Phase1DraftPayload::decode(status.draft())?;
+ let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice())
+ .map_err(|_| Phase1DraftError::Corrupt)?;
+ if plan.plan().body().kind() != 5
+ || !plan.plan().body().tags().iter().any(|tag| {
+ tag.first().map(String::as_str) == Some("e")
+ && tag.get(1).map(String::as_str) == Some(target.source_event_id())
+ })
+ || target.source_address().is_some_and(|address| {
+ !plan.plan().body().tags().iter().any(|tag| {
+ tag.first().map(String::as_str) == Some("a")
+ && tag.get(1).map(String::as_str) == Some(address)
+ })
+ })
+ {
+ return Err(Phase1DraftError::Corrupt);
+ }
+ Ok(())
+}
+
+fn parse_address(address: &str) -> Result<(u32, &str, &str), Phase1DraftError> {
+ let mut parts = address.splitn(3, ':');
+ let kind = parts
+ .next()
+ .and_then(|value| value.parse::<u32>().ok())
+ .ok_or(Phase1DraftError::InvalidRevision)?;
+ let author = parts.next().ok_or(Phase1DraftError::InvalidRevision)?;
+ let identifier = parts.next().ok_or(Phase1DraftError::InvalidRevision)?;
+ if author.len() != 64 || identifier.is_empty() {
+ return Err(Phase1DraftError::InvalidRevision);
+ }
+ Ok((kind, author, identifier))
+}
+
+fn card_id(
+ command_type: AddCommandType,
+ plan: &radroots_event_codec::authoring::AuthoredEventPlan,
+) -> Result<CardId, Phase1DraftError> {
+ let card_type = match command_type {
+ AddCommandType::CreateUpdate => TodayCardType::Update,
+ AddCommandType::CreatePhotoUpdate => TodayCardType::PhotoUpdate,
+ AddCommandType::CreateAsk => TodayCardType::Ask,
+ AddCommandType::CreateEvent => TodayCardType::Event,
+ AddCommandType::CreateFoodAvailability => TodayCardType::FoodAvailability,
+ };
+ let source = if (30_000..40_000).contains(&plan.body().kind()) {
+ let identifier = plan
+ .body()
+ .tags()
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("d") && tag.len() == 2)
+ .and_then(|tag| tag.get(1))
+ .ok_or(Phase1DraftError::Corrupt)?;
+ CardSourceIdentity::address(
+ plan.body().kind(),
+ plan.author().to_hex(),
+ identifier.clone(),
+ )
+ .map_err(|_| Phase1DraftError::Corrupt)?
+ } else {
+ CardSourceIdentity::Event(*plan.expected_event_id())
+ };
+ Ok(CardId::derive(card_type, &source))
+}
+
+fn map_draft_storage_error(error: radroots_storage::Error) -> Phase1DraftError {
+ match error {
+ radroots_storage::Error::DraftRevisionConflict => Phase1DraftError::RevisionConflict,
+ radroots_storage::Error::DraftNotFound => Phase1DraftError::NotFound,
+ radroots_storage::Error::CorruptAuthoredDraft => Phase1DraftError::Corrupt,
+ _ => Phase1DraftError::Storage,
+ }
+}
+
+fn map_overlay_error(_: TodayError) -> Phase1DraftError {
+ Phase1DraftError::Overlay
+}
+
+/// Captures the canonical wall-clock input for Rust-owned Phase 1 policy.
+pub fn phase1_operation_now_unix_ms() -> Result<u64, Phase1DraftError> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .and_then(|duration| u64::try_from(duration.as_millis()).ok())
+ .filter(|value| *value >= 1_000)
+ .ok_or(Phase1DraftError::ClockUnavailable)
+}
+
+/// Generates a canonical public identifier for an addressable Add form.
+pub fn phase1_new_addressable_identifier() -> String {
+ uuid::Uuid::new_v4().simple().to_string()
+}
+
+/// Generates one opaque operation identity for host-visible cancellation and
+/// receipt correlation without delegating identity policy to the host.
+pub fn phase1_new_operation_id() -> Result<[u8; 16], Phase1DraftError> {
+ phase1_random_id()
+}
+
+fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> {
+ let value = *uuid::Uuid::new_v4().as_bytes();
+ if value.iter().all(|byte| *byte == 0) {
+ return Err(Phase1DraftError::OperationUnavailable);
+ }
+ Ok(value)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::runtime::product_surface::{
+ CANONICAL_ADD_COMMAND_TYPES, CreateAsk, CreateEvent, CreateFoodAvailability,
+ CreatePhotoUpdate, CreateUpdate,
+ };
+ use crate::runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ };
+ use radroots_blossom::{BlobDescriptor, Sha256 as BlossomSha256};
+ use radroots_event::{
+ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate},
+ food::availability::{
+ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus,
+ FoodContent, FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText,
+ FoodUnit,
+ },
+ media::AuthoredImage,
+ post::{AuthoredPostImage, PostImageDimensions},
+ };
+ use radroots_sdk::ClientBuilder;
+
+ const AUTHOR: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+ const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
+
+ fn runtime() -> RadrootsRuntime {
+ RadrootsRuntime::from_client_builder(
+ ClientBuilder::memory_default(),
+ Some(PublicKey::from_hex(AUTHOR).unwrap()),
+ None,
+ None,
+ None,
+ None,
+ )
+ .unwrap()
+ }
+
+ fn profiled_runtime(profile: radroots_sdk::transport::RelayProfile) -> RadrootsRuntime {
+ RadrootsRuntime::from_client_builder(
+ ClientBuilder::memory_default(),
+ Some(PublicKey::from_hex(AUTHOR).unwrap()),
+ None,
+ None,
+ Some(profile),
+ None,
+ )
+ .unwrap()
+ }
+
+ fn signing_runtime() -> RadrootsRuntime {
+ let signer = radroots_nostr::signing::LocalSigner::new(
+ radroots_nostr::key::SecretKey::parse(SECRET).unwrap(),
+ )
+ .unwrap();
+ RadrootsRuntime::from_client_builder(
+ ClientBuilder::memory_default(),
+ Some(PublicKey::from_hex(AUTHOR).unwrap()),
+ None,
+ Some(std::sync::Arc::new(signer)),
+ None,
+ None,
+ )
+ .unwrap()
+ }
+
+ fn policy() -> Phase1QueuePolicy {
+ Phase1QueuePolicy::new(
+ vec![
+ "wss://relay-one.example".to_owned(),
+ "wss://relay-two.example".to_owned(),
+ ],
+ Phase1RelaySatisfaction::AllAccepted,
+ 2_000_000_000_000,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .unwrap()
+ }
+
+ fn update_form() -> Phase1DraftFormSnapshot {
+ Phase1DraftFormSnapshot {
+ command_type: AddCommandType::CreateUpdate,
+ content: "Harvest update".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 upload_policy_derivation_is_exact_and_bounded() {
+ let plan = Phase1UploadPlan::derive(1_800_000_000_000, [7; 16], [8; 16]).unwrap();
+ assert_eq!(plan.authorization_content, BLOSSOM_AUTHORIZATION_CONTENT);
+ assert_eq!(plan.authorization_created_at_unix_s, 1_799_999_995);
+ assert_eq!(plan.authorization_lifetime_seconds, 300);
+ assert_eq!(plan.operation_id, [7; 16]);
+ assert_eq!(plan.artifact_id, [8; 16]);
+ assert_eq!(plan.signing_deadline_unix_ms, 1_800_000_060_000);
+ assert_eq!(
+ plan.cancellation,
+ Phase1CancellationPolicy::LocalCooperative
+ );
+ assert_eq!(plan.updated_at_unix_ms, 1_800_000_000_000);
+ assert_eq!(
+ Phase1UploadPlan::derive(u64::MAX, [7; 16], [8; 16]).unwrap_err(),
+ Phase1DraftError::DeadlineOverflow
+ );
+ }
+
+ #[tokio::test]
+ async fn add_intent_owns_draft_identity_time_and_writable_relay_policy() {
+ let profile = radroots_sdk::transport::RelayProfile::explicit(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ [
+ radroots_sdk::transport::RelayEndpoint::new(
+ "wss://read.example",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadOnly,
+ )
+ .unwrap(),
+ radroots_sdk::transport::RelayEndpoint::new(
+ "wss://write.example",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadWrite,
+ )
+ .unwrap(),
+ ],
+ )
+ .unwrap();
+ let runtime = profiled_runtime(profile);
+ let saved = runtime
+ .phase1_save_add_intent(
+ Phase1AddIntent::new(
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()),
+ Vec::new(),
+ update_form(),
+ None,
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_ne!(saved.draft().draft_id().as_bytes(), &[0; 16]);
+ assert!(saved.draft().created_at_unix_ms() >= 1_700_000_000_000);
+
+ let queued = runtime
+ .phase1_queue_add_intent(
+ Phase1QueueIntent::new(
+ *saved.draft().draft_id().as_bytes(),
+ saved.draft().revision().get(),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let payload = Phase1DraftPayload::decode(queued.draft()).unwrap();
+ let queue = payload.queue.unwrap();
+ assert_eq!(queue.relay_urls, vec!["wss://write.example"]);
+ assert_eq!(queue.satisfaction, Phase1RelaySatisfaction::AllAccepted);
+ assert_eq!(
+ queue.cancellation,
+ Phase1CancellationPolicy::LocalCooperative
+ );
+ assert!(
+ queue.delivery_deadline_unix_ms
+ >= queued.draft().updated_at_unix_ms() + ADD_DELIVERY_TIMEOUT_MS
+ );
+ }
+
+ #[tokio::test]
+ async fn profile_metadata_uses_the_durable_outbox_with_stable_operation_identity() {
+ let profile = radroots_sdk::transport::RelayProfile::explicit(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ [radroots_sdk::transport::RelayEndpoint::new(
+ "wss://write.example",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadWrite,
+ )
+ .unwrap()],
+ )
+ .unwrap();
+ let runtime = profiled_runtime(profile);
+ let saved = runtime
+ .phase1_save_profile_metadata(
+ ProfileMetadataCommand::new(
+ "grower".to_owned(),
+ Some("Local Grower".to_owned()),
+ Some("Seasonal produce".to_owned()),
+ None,
+ None,
+ Some("grower@farm.example".to_owned()),
+ Some(false),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let operation_id = *saved.draft().draft_id().as_bytes();
+ assert_eq!(saved.state(), Phase1OutboxState::Draft);
+ assert_eq!(
+ PlanWireV1::from_json(
+ Phase1ProfilePayload::decode(saved.draft())
+ .unwrap()
+ .plan_wire_json
+ .as_slice(),
+ )
+ .unwrap()
+ .plan()
+ .body()
+ .kind(),
+ 0
+ );
+
+ let queued = runtime
+ .phase1_queue_profile(operation_id, saved.draft().revision().get())
+ .await
+ .unwrap();
+ assert_eq!(queued.state(), Phase1OutboxState::Queued);
+ assert_eq!(*queued.draft().draft_id().as_bytes(), operation_id);
+ let cancelled = runtime
+ .phase1_cancel_profile(operation_id, queued.draft().revision().get())
+ .await
+ .unwrap();
+ assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled);
+ assert_eq!(*cancelled.draft().draft_id().as_bytes(), operation_id);
+ }
+
+ #[tokio::test]
+ async fn add_queue_intent_fails_closed_without_a_writable_relay() {
+ let profile = radroots_sdk::transport::RelayProfile::explicit(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ [radroots_sdk::transport::RelayEndpoint::new(
+ "wss://read.example",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadOnly,
+ )
+ .unwrap()],
+ )
+ .unwrap();
+ let runtime = profiled_runtime(profile);
+ let saved = runtime
+ .phase1_save_add_intent(
+ Phase1AddIntent::new(
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()),
+ Vec::new(),
+ update_form(),
+ None,
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ runtime
+ .phase1_queue_add_intent(
+ Phase1QueueIntent::new(
+ *saved.draft().draft_id().as_bytes(),
+ saved.draft().revision().get(),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap_err(),
+ Phase1DraftError::NoWritableRelay
+ );
+ }
+
+ #[tokio::test]
+ async fn addressable_revision_preserves_every_form_field_and_colon_identifier() {
+ let identifier = "market:summer:2026";
+ let source = CardSourceIdentity::address(31_923, AUTHOR, identifier).unwrap();
+ let target_card = CardId::derive(TodayCardType::Event, &source);
+ let target = Phase1RevisionTarget::from_source(
+ AddCommandType::CreateEvent,
+ target_card,
+ "b".repeat(64),
+ Some(format!("31923:{AUTHOR}:{identifier}")),
+ AUTHOR,
+ )
+ .unwrap();
+ let event = AuthoredCalendarTimeEvent::new(identifier, "Evening market", 1_900_003_600)
+ .unwrap()
+ .with_end(1_900_007_200)
+ .unwrap()
+ .with_start_tzid("America/Vancouver")
+ .unwrap()
+ .with_end_tzid("America/Vancouver")
+ .unwrap()
+ .with_locations(vec!["Town square".to_owned()])
+ .unwrap()
+ .with_description("Bring reusable bags")
+ .unwrap();
+ let form = Phase1DraftFormSnapshot {
+ command_type: AddCommandType::CreateEvent,
+ content: "Bring reusable bags".to_owned(),
+ identifier: Some(identifier.to_owned()),
+ title: Some("Evening market".to_owned()),
+ summary: Some("Local farms and neighbours".to_owned()),
+ location: Some("Town square".to_owned()),
+ event_timing: Some(Phase1DraftEventTiming::Timed),
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: Some(1_900_003_600),
+ event_end_unix_s: Some(1_900_007_200),
+ event_timezone: Some("America/Vancouver".to_owned()),
+ price_amount: Some("5".to_owned()),
+ currency: Some("CAD".to_owned()),
+ unit: Some("entry".to_owned()),
+ quantity: Some("100".to_owned()),
+ food_published_at_unix_s: Some(1_900_000_000),
+ food_status: Some("active".to_owned()),
+ media: Vec::new(),
+ };
+ let runtime = runtime();
+ let saved = runtime
+ .phase1_save_revision_intent(
+ Phase1ReviseIntent::new(
+ target.clone(),
+ Phase1AddCommand::CreateEvent(CreateEvent::time(event)),
+ Vec::new(),
+ form.clone(),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(saved.policy(), Phase1RevisionPolicy::AddressableReplacement);
+ assert_eq!(saved.phase(), Phase1RevisionPhase::ReplacementPending);
+ assert_eq!(saved.target(), &target);
+ assert_eq!(saved.replacement().card_id(), target_card);
+ assert_eq!(saved.replacement().form(), Some(&form));
+ assert!(saved.retraction().is_none());
+ assert_eq!(
+ parse_address(saved.target().source_address().unwrap())
+ .unwrap()
+ .2,
+ identifier
+ );
+ }
+
+ #[tokio::test]
+ async fn addressable_revision_rejects_identity_change_and_preserves_date_boundary() {
+ let identifier = "winter:market";
+ let target_card = CardId::derive(
+ TodayCardType::Event,
+ &CardSourceIdentity::address(31_922, AUTHOR, identifier).unwrap(),
+ );
+ let target = Phase1RevisionTarget::new(
+ AddCommandType::CreateEvent,
+ target_card,
+ "d".repeat(64),
+ 31_922,
+ Some(format!("31922:{AUTHOR}:{identifier}")),
+ AUTHOR,
+ )
+ .unwrap();
+ let form = Phase1DraftFormSnapshot {
+ command_type: AddCommandType::CreateEvent,
+ content: "New Year farm market".to_owned(),
+ identifier: Some(identifier.to_owned()),
+ title: Some("Winter market".to_owned()),
+ summary: None,
+ location: Some("Barn".to_owned()),
+ event_timing: Some(Phase1DraftEventTiming::AllDay),
+ event_start_date: Some("2026-12-31".to_owned()),
+ event_end_date: Some("2027-01-01".to_owned()),
+ 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(),
+ };
+ let event = AuthoredCalendarDateEvent::new(
+ identifier,
+ "Winter market",
+ CalendarDate::parse("2026-12-31").unwrap(),
+ )
+ .unwrap()
+ .with_end(CalendarDate::parse("2027-01-01").unwrap())
+ .unwrap()
+ .with_description("New Year farm market")
+ .unwrap()
+ .with_locations(vec!["Barn".to_owned()])
+ .unwrap();
+ let runtime = runtime();
+ let saved = runtime
+ .phase1_save_revision_intent(
+ Phase1ReviseIntent::new(
+ target.clone(),
+ Phase1AddCommand::CreateEvent(CreateEvent::date(event)),
+ Vec::new(),
+ form.clone(),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(saved.replacement().form(), Some(&form));
+
+ let changed_identity = AuthoredCalendarDateEvent::new(
+ "different-market",
+ "Winter market",
+ CalendarDate::parse("2026-12-31").unwrap(),
+ )
+ .unwrap();
+ assert_eq!(
+ runtime
+ .phase1_save_revision_intent(
+ Phase1ReviseIntent::new(
+ target,
+ Phase1AddCommand::CreateEvent(CreateEvent::date(changed_identity)),
+ Vec::new(),
+ Phase1DraftFormSnapshot {
+ identifier: Some("different-market".to_owned()),
+ ..form
+ },
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap_err(),
+ Phase1DraftError::InvalidRevision
+ );
+ }
+
+ #[tokio::test]
+ async fn kind_one_revision_never_creates_retraction_before_replacement_acceptance() {
+ let profile = radroots_sdk::transport::RelayProfile::explicit(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ [radroots_sdk::transport::RelayEndpoint::new(
+ "wss://offline.example",
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ radroots_sdk::transport::RelayAccess::ReadWrite,
+ )
+ .unwrap()],
+ )
+ .unwrap();
+ let signer = radroots_nostr::signing::LocalSigner::new(
+ radroots_nostr::key::SecretKey::parse(SECRET).unwrap(),
+ )
+ .unwrap();
+ let runtime = RadrootsRuntime::from_client_builder(
+ ClientBuilder::memory_default(),
+ Some(PublicKey::from_hex(AUTHOR).unwrap()),
+ None,
+ Some(std::sync::Arc::new(signer)),
+ Some(profile),
+ None,
+ )
+ .unwrap();
+ let source_event_id = "b".repeat(64);
+ let source =
+ CardSourceIdentity::Event(radroots_event::EventId::parse(&source_event_id).unwrap());
+ let target = Phase1RevisionTarget::new(
+ AddCommandType::CreatePhotoUpdate,
+ CardId::derive(TodayCardType::PhotoUpdate, &source),
+ source_event_id,
+ 1,
+ None,
+ AUTHOR,
+ )
+ .unwrap();
+ let (command, media) = photo_command();
+ let replacement_content = format!("Harvest photo {}", media.url());
+ let media_form = Phase1DraftMediaSnapshot {
+ opaque_reference: media.local_reference().to_owned(),
+ url: media.url().to_owned(),
+ sha256: media.sha256().to_owned(),
+ media_type: media.media_type().to_owned(),
+ byte_size: media.byte_size(),
+ width: 1200,
+ height: 900,
+ alt: "Harvest".to_owned(),
+ prepared_at_unix_s: 1_784_347_100,
+ };
+ let saved = runtime
+ .phase1_save_revision_intent(
+ Phase1ReviseIntent::new(
+ target,
+ command,
+ vec![media],
+ Phase1DraftFormSnapshot {
+ command_type: AddCommandType::CreatePhotoUpdate,
+ content: replacement_content,
+ media: vec![media_form],
+ ..update_form()
+ },
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let replacement_id = *saved.replacement().draft().draft_id().as_bytes();
+ assert_eq!(saved.policy(), Phase1RevisionPolicy::ReplaceThenRetract);
+ assert_eq!(saved.replacement().media().len(), 1);
+ assert_eq!(saved.replacement().form().unwrap().media.len(), 1);
+ assert!(saved.retraction().is_none());
+
+ let queued = runtime
+ .phase1_queue_add_intent(
+ Phase1QueueIntent::new(
+ replacement_id,
+ saved.replacement().draft().revision().get(),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ runtime
+ .phase1_sign_queued_draft(replacement_id, queued.draft().revision().get())
+ .await
+ .unwrap();
+ let recovered = runtime
+ .phase1_revision_status(replacement_id)
+ .await
+ .unwrap();
+ assert!(recovered.retraction().is_none());
+ assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending);
+
+ let cancelled = runtime
+ .phase1_cancel_revision(replacement_id)
+ .await
+ .unwrap();
+ assert_eq!(cancelled.phase(), Phase1RevisionPhase::Cancelled);
+ assert!(cancelled.retraction().is_none());
+ }
+
+ #[tokio::test]
+ async fn revision_coordinator_reopens_from_sqlite_without_losing_ordering() {
+ let root = tempfile::tempdir().unwrap();
+ let store = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ AUTHOR,
+ "0404040404040404040404040404040404040404040404040404040404040404",
+ 1_900_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .unwrap();
+ std::fs::create_dir_all(store.owner_directory()).unwrap();
+ let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap();
+ let source_event_id = "c".repeat(64);
+ let target = Phase1RevisionTarget::new(
+ AddCommandType::CreateAsk,
+ CardId::derive(
+ TodayCardType::Ask,
+ &CardSourceIdentity::Event(
+ radroots_event::EventId::parse(&source_event_id).unwrap(),
+ ),
+ ),
+ source_event_id,
+ 1,
+ None,
+ AUTHOR,
+ )
+ .unwrap();
+ let saved = runtime
+ .phase1_save_revision_intent(
+ Phase1ReviseIntent::new(
+ target.clone(),
+ Phase1AddCommand::CreateAsk(
+ CreateAsk::new("Who has seed potatoes now?", Vec::new()).unwrap(),
+ ),
+ Vec::new(),
+ Phase1DraftFormSnapshot {
+ command_type: AddCommandType::CreateAsk,
+ content: "Who has seed potatoes now?".to_owned(),
+ ..update_form()
+ },
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let id = *saved.replacement().draft().draft_id().as_bytes();
+ let queued = runtime
+ .phase1_queue_draft(
+ id,
+ saved.replacement().draft().revision().get(),
+ policy(),
+ saved.replacement().draft().updated_at_unix_ms() + 1,
+ )
+ .await
+ .unwrap();
+ runtime.shutdown().await.unwrap();
+ drop(runtime);
+
+ let reopened = RuntimeBuilder::new(store).build().await.unwrap();
+ let recovered = reopened.phase1_revision_status(id).await.unwrap();
+ assert_eq!(recovered.target(), &target);
+ assert_eq!(recovered.policy(), Phase1RevisionPolicy::ReplaceThenRetract);
+ assert_eq!(recovered.phase(), Phase1RevisionPhase::ReplacementPending);
+ assert_eq!(
+ recovered.replacement().draft().revision(),
+ queued.draft().revision()
+ );
+ assert_eq!(recovered.replacement().state(), Phase1OutboxState::Queued);
+ assert!(recovered.retraction().is_none());
+ assert_eq!(
+ recovered.replacement().form().unwrap().content,
+ "Who has seed potatoes now?"
+ );
+ reopened.shutdown().await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn form_snapshots_reopen_exactly_and_freeze_after_queue() {
+ let runtime = runtime();
+ let id = [6; 16];
+ let saved = runtime
+ .phase1_save_draft_with_form(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()),
+ 1_900_000_000,
+ Vec::new(),
+ update_form(),
+ None,
+ 10,
+ )
+ .await
+ .unwrap();
+ assert_eq!(saved.kind(), Phase1DraftKind::Add);
+ assert_eq!(saved.form(), Some(&update_form()));
+ assert_eq!(
+ runtime.phase1_draft_status(id).await.unwrap().form(),
+ Some(&update_form())
+ );
+
+ let queued = runtime
+ .phase1_queue_draft(id, 1, policy(), 11)
+ .await
+ .unwrap();
+ assert_eq!(queued.form(), Some(&update_form()));
+ assert_eq!(
+ runtime
+ .phase1_save_draft_with_form(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Changed").unwrap()),
+ 1_900_000_001,
+ Vec::new(),
+ update_form(),
+ Some(queued.draft().revision().get()),
+ 12,
+ )
+ .await
+ .unwrap_err(),
+ Phase1DraftError::RevisionConflict
+ );
+ }
+
+ #[tokio::test]
+ async fn retraction_is_independent_and_add_advance_attempts_delivery() {
+ let runtime = signing_runtime();
+ let target = CardId::parse(&"a".repeat(64)).unwrap();
+ let retraction = runtime
+ .phase1_save_retraction_draft(
+ [5; 16],
+ AddCommandType::CreateUpdate,
+ target,
+ &"b".repeat(64),
+ 1,
+ None,
+ "Replaced by a corrected copy",
+ 1_900_000_000,
+ 20,
+ )
+ .await
+ .unwrap();
+ assert_eq!(retraction.kind(), Phase1DraftKind::Retraction);
+ assert_eq!(retraction.card_id(), target);
+ assert!(retraction.form().is_none());
+ let queued = runtime
+ .phase1_queue_draft([5; 16], retraction.draft().revision().get(), policy(), 21)
+ .await
+ .unwrap();
+ let signed_retraction = runtime
+ .phase1_sign_queued_draft([5; 16], queued.draft().revision().get())
+ .await;
+ let signed_retraction = signed_retraction.unwrap();
+ assert_eq!(signed_retraction.kind(), Phase1DraftKind::Retraction);
+ let push = signed_retraction
+ .push()
+ .expect("durable retraction operation");
+ assert_eq!(
+ push.artifact()
+ .signed()
+ .expect("signed retraction")
+ .event()
+ .kind(),
+ 5
+ );
+
+ let saved = runtime
+ .phase1_save_draft(
+ [4; 16],
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Deliver me").unwrap()),
+ 1_900_000_001,
+ Vec::new(),
+ None,
+ 30,
+ )
+ .await
+ .unwrap();
+ let queued = runtime
+ .phase1_queue_draft([4; 16], saved.draft().revision().get(), policy(), 31)
+ .await
+ .unwrap();
+ let _ = runtime
+ .phase1_advance_draft([4; 16], queued.draft().revision().get())
+ .await;
+ let advanced = runtime.phase1_draft_status([4; 16]).await.unwrap();
+ let push = advanced.push().expect("durable add operation");
+ assert!(push.artifact().admission_state().is_admitted());
+ assert!(!push.delivery_plan().attempts().is_empty());
+ assert!(matches!(
+ advanced.state(),
+ Phase1OutboxState::Retryable
+ | Phase1OutboxState::PartiallyDelivered
+ | Phase1OutboxState::Complete
+ | Phase1OutboxState::Terminal
+ ));
+ }
+
+ #[tokio::test]
+ async fn media_free_draft_queues_offline_and_recovers_exactly() {
+ let runtime = runtime();
+ let id = [7; 16];
+ let draft = runtime
+ .phase1_save_draft(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest").unwrap()),
+ 1_900_000_000,
+ Vec::new(),
+ None,
+ 10,
+ )
+ .await
+ .unwrap();
+ assert_eq!(draft.state(), Phase1OutboxState::Draft);
+ let queued = runtime
+ .phase1_queue_draft(id, 1, policy(), 11)
+ .await
+ .unwrap();
+ assert_eq!(queued.state(), Phase1OutboxState::Queued);
+ assert_eq!(queued.draft().revision().get(), 3);
+ assert!(queued.push().is_some());
+ let recovered = runtime.phase1_recover_draft_queue(id, 12).await.unwrap();
+ assert_eq!(recovered.draft(), queued.draft());
+ assert_eq!(recovered.push(), queued.push());
+ }
+
+ #[tokio::test]
+ async fn queue_recovery_closes_both_preparation_crash_windows() {
+ let runtime = runtime();
+ for (id_byte, prepare_before_recovery) in [(10, false), (11, true)] {
+ let id = [id_byte; 16];
+ let saved = runtime
+ .phase1_save_draft(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Recover").unwrap()),
+ 1_900_000_010,
+ Vec::new(),
+ None,
+ 40,
+ )
+ .await
+ .unwrap();
+ let mut payload = Phase1DraftPayload::decode(saved.draft()).unwrap();
+ payload.queue = Some(policy());
+ let bytes = payload.encode().unwrap();
+ let draft_id = saved.draft().draft_id();
+ let operation = operation_id(draft_id, bytes.as_slice()).unwrap();
+ let operation = OperationInstanceId::new(*operation.as_bytes()).unwrap();
+ let ready = saved
+ .draft()
+ .successor(bytes, AuthoredDraftStage::ReadyToSign, Some(operation), 41)
+ .unwrap();
+ runtime
+ .storage()
+ .unwrap()
+ .append_authored_draft(ready.clone(), Some(saved.draft().revision()))
+ .await
+ .unwrap();
+ if prepare_before_recovery {
+ runtime
+ .sync()
+ .unwrap()
+ .prepare_push(push_request(&ready).unwrap())
+ .await
+ .unwrap();
+ }
+ let recovered = runtime.phase1_recover_draft_queue(id, 42).await.unwrap();
+ assert_eq!(recovered.draft().stage(), AuthoredDraftStage::Queued);
+ assert_eq!(recovered.draft().revision().get(), 3);
+ assert!(recovered.push().is_some());
+ }
+ }
+
+ #[tokio::test]
+ async fn all_five_add_flows_queue_and_sign_without_network_access() {
+ let runtime = signing_runtime();
+ let (photo, media) = photo_command();
+ let commands = [
+ (
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()),
+ Vec::new(),
+ ),
+ (photo, vec![media]),
+ (
+ Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()),
+ Vec::new(),
+ ),
+ (
+ Phase1AddCommand::CreateEvent(CreateEvent::date(
+ AuthoredCalendarDateEvent::new(
+ "market-day",
+ "Saturday Market",
+ CalendarDate::parse("2026-08-08").unwrap(),
+ )
+ .unwrap(),
+ )),
+ Vec::new(),
+ ),
+ (
+ Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())),
+ Vec::new(),
+ ),
+ ];
+ for (index, (command, media)) in commands.into_iter().enumerate() {
+ let mut id = [30; 16];
+ id[15] = u8::try_from(index + 1).unwrap();
+ let saved = runtime
+ .phase1_save_draft(
+ id,
+ command,
+ 1_784_347_200,
+ media,
+ None,
+ 100 + u64::try_from(index).unwrap() * 10,
+ )
+ .await
+ .unwrap();
+ let queued = runtime
+ .phase1_queue_draft(
+ id,
+ saved.draft().revision().get(),
+ policy(),
+ 101 + u64::try_from(index).unwrap() * 10,
+ )
+ .await
+ .unwrap();
+ assert_eq!(queued.state(), Phase1OutboxState::Queued);
+ assert_eq!(queued.command_type(), CANONICAL_ADD_COMMAND_TYPES[index]);
+ let signed = runtime
+ .phase1_sign_queued_draft(id, queued.draft().revision().get())
+ .await
+ .unwrap();
+ assert_eq!(signed.state(), Phase1OutboxState::Signed);
+ assert_eq!(
+ signed
+ .push()
+ .and_then(|push| push.artifact().signed())
+ .expect("signed artifact")
+ .event()
+ .kind(),
+ match index {
+ 0..=2 => 1,
+ 3 => 31_922,
+ 4 => 30_402,
+ _ => unreachable!(),
+ }
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn blossom_authorization_uses_the_same_opaque_signer_but_never_the_outbox() {
+ use radroots_blossom::authorization::{
+ AuthorizationContent, AuthorizationTarget, AuthorizationValidation, ServerDomain,
+ };
+
+ let runtime = signing_runtime();
+ let hash = BlossomSha256::digest(b"exact upload bytes");
+ let server = ServerDomain::parse("media.example").unwrap();
+ let claim = AuthoredUploadClaim::new(
+ AuthorizationContent::parse("Upload exact Radroots image").unwrap(),
+ server.clone(),
+ hash,
+ 1_900_000_000,
+ 60,
+ )
+ .unwrap();
+ let header = runtime
+ .phase1_authorize_blossom_upload(
+ [71; 16],
+ [72; 16],
+ claim,
+ u64::MAX,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .await
+ .unwrap();
+ let verified = radroots_nostr::blossom::decode_verify_authorization_header(
+ header.as_str(),
+ &AuthorizationValidation::bud11(
+ AuthorizationTarget::Upload(hash),
+ server,
+ 1_900_000_001,
+ ),
+ )
+ .unwrap();
+ assert_eq!(verified.claim().hashes(), &[hash]);
+ assert!(runtime.phase1_draft_heads(10).await.unwrap().is_empty());
+ }
+
+ #[tokio::test]
+ async fn cancellation_is_terminal_and_preserves_operation_evidence() {
+ let runtime = runtime();
+ let id = [8; 16];
+ runtime
+ .phase1_save_draft(
+ id,
+ Phase1AddCommand::CreateUpdate(CreateUpdate::new("Cancelled").unwrap()),
+ 1_900_000_001,
+ Vec::new(),
+ None,
+ 20,
+ )
+ .await
+ .unwrap();
+ let queued = runtime
+ .phase1_queue_draft(id, 1, policy(), 21)
+ .await
+ .unwrap();
+ let cancelled = runtime
+ .phase1_cancel_draft(id, queued.draft().revision().get(), 22)
+ .await
+ .unwrap();
+ assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled);
+ let push = cancelled.push().expect("retained push evidence");
+ assert_eq!(push.artifact().signing_state(), SigningState::Cancelled);
+ assert_eq!(
+ push.delivery_plan().state(),
+ AuthoredDeliveryState::Cancelled
+ );
+ assert!(push.settlement().is_settled());
+ }
+
+ #[tokio::test]
+ async fn media_phase_revisions_gate_queue_and_reject_forged_verification() {
+ let runtime = runtime();
+ let id = [9; 16];
+ let (command, mut media) = photo_command();
+ media.stage = Phase1MediaStage::Pending;
+ media.upload_attempts = 0;
+ media.verified_at_unix_ms = None;
+ media.validate().unwrap();
+ let saved = runtime
+ .phase1_save_draft(id, command, 1_784_347_200, vec![media], None, 30)
+ .await
+ .unwrap();
+ assert_eq!(saved.state(), Phase1OutboxState::MediaPreparing);
+ assert_eq!(
+ runtime
+ .phase1_queue_draft(id, 1, policy(), 31)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::MediaNotReady
+ );
+ let preparing = runtime
+ .phase1_update_draft_media(
+ id,
+ 1,
+ saved.media()[0].url(),
+ Phase1MediaStage::Preparing,
+ None,
+ 31,
+ )
+ .await
+ .unwrap();
+ let uploading = runtime
+ .phase1_update_draft_media(
+ id,
+ 2,
+ preparing.media()[0].url(),
+ Phase1MediaStage::Uploading,
+ None,
+ 32,
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ runtime
+ .phase1_update_draft_media(
+ id,
+ 3,
+ uploading.media()[0].url(),
+ Phase1MediaStage::Verified,
+ None,
+ 33,
+ )
+ .await
+ .unwrap_err(),
+ Phase1DraftError::InvalidMedia
+ );
+ assert_eq!(
+ runtime
+ .phase1_queue_draft(id, 3, policy(), 34)
+ .await
+ .unwrap_err(),
+ Phase1DraftError::MediaNotReady
+ );
+ assert_eq!(runtime.phase1_draft_heads(10).await.unwrap().len(), 1);
+ }
+
+ #[test]
+ fn queue_policy_rejects_duplicates_and_noncanonical_relays() {
+ assert!(
+ Phase1QueuePolicy::new(
+ vec!["wss://relay.example".into(), "wss://relay.example".into()],
+ Phase1RelaySatisfaction::AnyAccepted,
+ 1,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .is_err()
+ );
+ assert!(
+ Phase1QueuePolicy::new(
+ vec!["WSS://relay.example".into()],
+ Phase1RelaySatisfaction::AnyAccepted,
+ 1,
+ Phase1CancellationPolicy::LocalCooperative,
+ )
+ .is_err()
+ );
+ }
+
+ fn photo_command() -> (Phase1AddCommand, Phase1MediaPrerequisite) {
+ let bytes = b"harvest-photo";
+ let hash = BlossomSha256::digest(bytes);
+ let url = format!("https://media.example/{hash}.webp");
+ let media_type = MediaType::parse("image/webp").unwrap();
+ let descriptor = BlobDescriptor::new(
+ BlobUrl::parse(url.as_str()).unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_100,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap();
+ let mut prerequisite =
+ Phase1MediaPrerequisite::new("protected://draft/photo-1", &descriptor).unwrap();
+ let image = AuthoredPostImage::new(
+ AuthoredImage::try_from(descriptor).unwrap(),
+ PostImageDimensions::new(1200, 900).unwrap(),
+ "Harvest",
+ )
+ .unwrap();
+ let command = Phase1AddCommand::CreatePhotoUpdate(
+ CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).unwrap(),
+ );
+ prerequisite.stage = Phase1MediaStage::Verified;
+ prerequisite.upload_attempts = 2;
+ prerequisite.verified_at_unix_ms = Some(1_784_347_100_000);
+ prerequisite.validate().unwrap();
+ (command, prerequisite)
+ }
+
+ fn food() -> FoodAvailabilityDetails {
+ FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new("Carrots available this week.").unwrap(),
+ identifier: FoodIdentifier::parse("nantes-carrots").unwrap(),
+ title: FoodText::new("Nantes Carrots").unwrap(),
+ summary: FoodText::new("Fresh bunches").unwrap(),
+ published_at: FoodPublishedAt::new(1_784_347_100).unwrap(),
+ location: FoodText::new("Central Saanich, BC").unwrap(),
+ price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound)
+ .unwrap(),
+ quantity: None,
+ status: FoodAvailabilityStatus::Active,
+ images: Vec::new(),
+ })
+ .unwrap()
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/projection.rs b/core/crates/tera_core/src/runtime/product_surface/projection.rs
@@ -0,0 +1,646 @@
+use radroots_event::food::availability::FoodAvailabilityStatus;
+use radroots_event_codec::{
+ admission::RadrootsAdmittedEvent, decode::post::RadrootsPostClassification,
+};
+use serde::{Deserialize, Serialize};
+
+use super::{
+ CardId, CardLifecycleState, CardSourceIdentity, ClassifiedCard, ContextAdmission,
+ LocalNetworkAdmission, MediaReference, Phase1StructuralMediaReference, SupportingProfile,
+ TodayCardType,
+};
+
+const CLASSIFIED_CARD_SCHEMA_VERSION: u16 = 1;
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ProductEventClassification {
+ Card(Box<ClassifiedCard>),
+ Supporting(SupportingProfile),
+ Excluded(ProductEventExclusion),
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum ProductEventExclusion {
+ LocalityNonmatch,
+ UnsupportedProfile,
+ InvalidSourceIdentity,
+}
+
+/// Classifies an already signature/id-verified and standard-profile-admitted event.
+///
+/// The caller must supply the result of the selected LocalNetwork admission.
+/// Replacement and deletion are applied by storage before the event reaches
+/// this boundary. No content prose or remote media retrieval influences type.
+pub fn classify_admitted_event(
+ admitted: &RadrootsAdmittedEvent,
+ context: LocalNetworkAdmission,
+) -> ProductEventClassification {
+ let context = match context {
+ LocalNetworkAdmission::Included(context) => context,
+ LocalNetworkAdmission::Excluded { .. } => {
+ return ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch);
+ }
+ };
+
+ match admitted {
+ RadrootsAdmittedEvent::Profile(_) => supporting(SupportingProfile::Profile),
+ RadrootsAdmittedEvent::Reply(_) => supporting(SupportingProfile::Reply),
+ RadrootsAdmittedEvent::Comment(_) => supporting(SupportingProfile::Comment),
+ RadrootsAdmittedEvent::DeletionRequest(_) => supporting(SupportingProfile::Deletion),
+ RadrootsAdmittedEvent::RootPost(event) => {
+ let card_type = match event.projection().classification() {
+ RadrootsPostClassification::Update => TodayCardType::Update,
+ RadrootsPostClassification::PhotoUpdate => TodayCardType::PhotoUpdate,
+ RadrootsPostClassification::Ask => TodayCardType::Ask,
+ RadrootsPostClassification::ThreadExcluded => {
+ return ProductEventClassification::Excluded(
+ ProductEventExclusion::UnsupportedProfile,
+ );
+ }
+ _ => {
+ return ProductEventClassification::Excluded(
+ ProductEventExclusion::UnsupportedProfile,
+ );
+ }
+ };
+ card(
+ admitted,
+ card_type,
+ context,
+ post_media(event.projection()),
+ CardLifecycleState::Active,
+ )
+ }
+ RadrootsAdmittedEvent::FoodAvailability(event) => {
+ let lifecycle = match event.projection().status() {
+ FoodAvailabilityStatus::Active => CardLifecycleState::Active,
+ FoodAvailabilityStatus::Sold => CardLifecycleState::Sold,
+ };
+ card(
+ admitted,
+ TodayCardType::FoodAvailability,
+ context,
+ food_media(event.projection()),
+ lifecycle,
+ )
+ }
+ RadrootsAdmittedEvent::ContractValidated(event) => match event.contract_id() {
+ "radroots.calendar.date_event.v1" | "radroots.calendar.time_event.v1" => card(
+ admitted,
+ TodayCardType::Event,
+ context,
+ calendar_media(event.event().tags_as_vec()),
+ CardLifecycleState::Active,
+ ),
+ _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile),
+ },
+ _ => ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile),
+ }
+}
+
+const fn supporting(profile: SupportingProfile) -> ProductEventClassification {
+ ProductEventClassification::Supporting(profile)
+}
+
+fn card(
+ admitted: &RadrootsAdmittedEvent,
+ card_type: TodayCardType,
+ context: ContextAdmission,
+ media: Vec<MediaReference>,
+ lifecycle: CardLifecycleState,
+) -> ProductEventClassification {
+ let event = admitted.event();
+ let source = match card_type {
+ TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => {
+ CardSourceIdentity::Event(*event.id())
+ }
+ TodayCardType::Event | TodayCardType::FoodAvailability => {
+ let identifier = event
+ .tags_as_vec()
+ .into_iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("d"))
+ .and_then(|tag| tag.get(1).cloned());
+ let Some(identifier) = identifier else {
+ return ProductEventClassification::Excluded(
+ ProductEventExclusion::InvalidSourceIdentity,
+ );
+ };
+ let Ok(source) =
+ CardSourceIdentity::address(event.kind_u32(), event.author().to_hex(), identifier)
+ else {
+ return ProductEventClassification::Excluded(
+ ProductEventExclusion::InvalidSourceIdentity,
+ );
+ };
+ source
+ }
+ };
+ let source_address = match &source {
+ CardSourceIdentity::Event(_) => None,
+ CardSourceIdentity::Address {
+ kind,
+ author_pubkey,
+ identifier,
+ } => Some(format!("{kind}:{author_pubkey}:{identifier}")),
+ };
+ let tags = event.tags_as_vec();
+ let title = tag_value(&tags, &["title", "name"]);
+ let location = matches!(
+ card_type,
+ TodayCardType::Event | TodayCardType::FoodAvailability
+ )
+ .then(|| tag_value(&tags, &["location"]))
+ .flatten();
+ let price = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_values(&tags, "price"))
+ .flatten();
+ let price_amount = price.as_ref().and_then(|values| values.first().cloned());
+ let price_currency = price.as_ref().and_then(|values| values.get(1).cloned());
+ let price_unit = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["radroots:price_unit"]))
+ .flatten();
+ let quantity = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_values(&tags, "radroots:quantity"))
+ .flatten()
+ .and_then(|values| values.first().cloned());
+ let food_summary = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["summary"]))
+ .flatten();
+ let food_published_at = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_time(&tags, "published_at"))
+ .flatten();
+ let food_status = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["status"]))
+ .flatten();
+ let (effective_at, event_start, event_end) = match card_type {
+ TodayCardType::Event => {
+ let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64());
+ (start, Some(start), tag_time(&tags, "end"))
+ }
+ TodayCardType::FoodAvailability => (
+ tag_time(&tags, "published_at").unwrap_or_else(|| event.created_at_u64()),
+ None,
+ None,
+ ),
+ TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => {
+ (event.created_at_u64(), None, None)
+ }
+ };
+ ProductEventClassification::Card(Box::new(ClassifiedCard {
+ schema_version: CLASSIFIED_CARD_SCHEMA_VERSION,
+ card_id: CardId::derive(card_type, &source),
+ card_type,
+ source_event_id: event.id_hex(),
+ source_address,
+ author_pubkey: event.author().to_hex(),
+ contract_id: admitted.contract_id().to_owned(),
+ title,
+ content: event.content().to_owned(),
+ authored_at: event.created_at_u64(),
+ effective_at,
+ event_start,
+ event_end,
+ location,
+ price_amount,
+ price_currency,
+ price_unit,
+ quantity,
+ food_summary,
+ food_published_at,
+ food_status,
+ context_rank: context.rank,
+ inclusion_reason: context.reason.to_owned(),
+ media,
+ lifecycle,
+ rank: None,
+ }))
+}
+
+fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> {
+ tags.iter().find_map(|tag| {
+ names
+ .contains(&tag.first()?.as_str())
+ .then(|| tag.get(1).cloned())
+ .flatten()
+ })
+}
+
+fn tag_values(tags: &[Vec<String>], name: &str) -> Option<Vec<String>> {
+ tags.iter().find_map(|tag| {
+ (tag.first().map(String::as_str) == Some(name) && tag.len() > 1).then(|| tag[1..].to_vec())
+ })
+}
+
+fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> {
+ let value = tag_value(tags, &[name])?;
+ value.parse().ok().or_else(|| {
+ chrono::NaiveDate::parse_from_str(&value, "%Y-%m-%d")
+ .ok()?
+ .and_hms_opt(0, 0, 0)?
+ .and_utc()
+ .timestamp()
+ .try_into()
+ .ok()
+ })
+}
+
+fn post_media(
+ projection: &radroots_event_codec::decode::post::RadrootsInboundPostProjection,
+) -> Vec<MediaReference> {
+ projection
+ .imeta()
+ .iter()
+ .filter_map(|media| {
+ let dimensions = media.dimensions();
+ media_reference(
+ media.url()?,
+ media.sha256().map(str::to_owned),
+ media.media_type().map(str::to_owned),
+ dimensions.map(|value| value.width()),
+ dimensions.map(|value| value.height()),
+ media.size(),
+ media.alt().map(str::to_owned),
+ )
+ })
+ .collect()
+}
+
+fn food_media(
+ projection: &radroots_event_codec::decode::food_availability::RadrootsInboundFoodAvailabilityProjection,
+) -> Vec<MediaReference> {
+ projection
+ .images()
+ .iter()
+ .filter_map(|media| {
+ let dimensions = media.dimensions();
+ media_reference(
+ media.url()?,
+ blossom_digest(media.url()?),
+ None,
+ dimensions.map(|value| value.width()),
+ dimensions.map(|value| value.height()),
+ None,
+ None,
+ )
+ })
+ .collect()
+}
+
+fn calendar_media(tags: Vec<Vec<String>>) -> Vec<MediaReference> {
+ tags.into_iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("image"))
+ .and_then(|tag| tag.get(1).cloned())
+ .and_then(|url| media_reference(&url, blossom_digest(&url), None, None, None, None, None))
+ .into_iter()
+ .collect()
+}
+
+#[allow(clippy::too_many_arguments)]
+fn media_reference(
+ url: &str,
+ sha256: Option<String>,
+ media_type: Option<String>,
+ width: Option<u32>,
+ height: Option<u32>,
+ byte_size: Option<u64>,
+ alt: Option<String>,
+) -> Option<MediaReference> {
+ Phase1StructuralMediaReference::new(url, sha256, media_type, width, height, byte_size, alt)
+ .and_then(MediaReference::new)
+ .ok()
+}
+
+fn blossom_digest(url: &str) -> Option<String> {
+ let path = url.split_once("://")?.1.split_once('/')?.1;
+ let candidate = path.split(['.', '/', '?', '#']).next()?;
+ (candidate.len() == 64
+ && candidate
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
+ .then(|| candidate.to_owned())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nostr::secp256k1::Message;
+ use nostr::{Keys, SECP256K1};
+ use radroots_event::{
+ Event, envelope::EventEnvelopeParts, wire::compute_canonical_nip01_event_id,
+ };
+ use radroots_event_codec::{admission::admit_verified_event, verify::verify_nip01_event};
+
+ const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+
+ fn admitted(kind: u32, tags: Vec<Vec<&str>>, content: &str) -> RadrootsAdmittedEvent {
+ admitted_owned(
+ kind,
+ tags.into_iter()
+ .map(|tag| tag.into_iter().map(str::to_owned).collect())
+ .collect(),
+ content,
+ )
+ }
+
+ fn admitted_owned(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsAdmittedEvent {
+ let keys = Keys::parse(SECRET).expect("key");
+ let author = keys.public_key().to_string();
+ let created_at = 2_000_000_000;
+ let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content)
+ .expect("id");
+ let message = Message::from_digest(*id.as_bytes());
+ let signature = SECP256K1.sign_schnorr_no_aux_rand(
+ &message,
+ &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()),
+ );
+ let event = Event::new(EventEnvelopeParts {
+ id: id.to_hex(),
+ author,
+ created_at,
+ kind,
+ tags,
+ content: content.into(),
+ sig: signature.to_string(),
+ })
+ .expect("event");
+ let verified = verify_nip01_event(event).expect("verified");
+ admit_verified_event(verified).expect("admitted")
+ }
+
+ const fn context() -> LocalNetworkAdmission {
+ LocalNetworkAdmission::Included(ContextAdmission {
+ rank: super::super::ContextRank::MissingLocalityFallback,
+ reason: "locality_missing_fallback",
+ })
+ }
+
+ fn card_type(event: RadrootsAdmittedEvent) -> TodayCardType {
+ match classify_admitted_event(&event, context()) {
+ ProductEventClassification::Card(card) => card.card_type,
+ other => panic!("expected card, got {other:?}"),
+ }
+ }
+
+ fn card(event: RadrootsAdmittedEvent) -> ClassifiedCard {
+ match classify_admitted_event(&event, context()) {
+ ProductEventClassification::Card(card) => *card,
+ other => panic!("expected card, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn exact_five_card_classifier_precedence_is_protocol_structural() {
+ assert_eq!(
+ card_type(admitted(1, vec![], "ordinary note")),
+ TodayCardType::Update
+ );
+ let digest_tag = format!("x {}", "a".repeat(64));
+ assert_eq!(
+ card_type(admitted(
+ 1,
+ vec![vec![
+ "imeta",
+ "url https://media.example/a.jpg",
+ &digest_tag,
+ "m image/jpeg",
+ "dim 10x20",
+ "size 123",
+ "alt field photo"
+ ]],
+ "photo https://media.example/a.jpg",
+ )),
+ TodayCardType::PhotoUpdate
+ );
+ assert_eq!(
+ card_type(admitted(
+ 1,
+ vec![vec!["t", " RADROOTS-ASK "], vec!["imeta", "broken"]],
+ "Anyone have carrots",
+ )),
+ TodayCardType::Ask
+ );
+ assert_eq!(
+ card_type(admitted(
+ 31_923,
+ vec![
+ vec!["d", "market-2026"],
+ vec!["title", "Saturday market"],
+ vec!["start", "2000000100"],
+ vec!["end", "2000000200"],
+ vec!["D", "23148"],
+ ],
+ "Farm market",
+ )),
+ TodayCardType::Event
+ );
+ assert_eq!(
+ card_type(admitted(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh bunches"],
+ vec!["published_at", "1999999999"],
+ vec!["location", "Saanich"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "active"],
+ ],
+ "Carrots available",
+ )),
+ TodayCardType::FoodAvailability
+ );
+ }
+
+ #[test]
+ fn event_and_food_cards_preserve_required_rendering_fields() {
+ let event = card(admitted(
+ 31_923,
+ vec![
+ vec!["d", "market-2026"],
+ vec!["title", "Saturday market"],
+ vec!["start", "2000000100"],
+ vec!["D", "23148"],
+ vec!["location", "Town square"],
+ ],
+ "Farm market",
+ ));
+ assert_eq!(event.location.as_deref(), Some("Town square"));
+ assert_eq!(event.price_amount, None);
+
+ let food = card(admitted(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh bunches"],
+ vec!["published_at", "1999999999"],
+ vec!["location", "Saanich"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["radroots:quantity", "12", "lb"],
+ vec!["status", "active"],
+ ],
+ "Carrots available",
+ ));
+ assert_eq!(food.location.as_deref(), Some("Saanich"));
+ assert_eq!(food.price_amount.as_deref(), Some("3"));
+ assert_eq!(food.price_currency.as_deref(), Some("CAD"));
+ assert_eq!(food.price_unit.as_deref(), Some("lb"));
+ assert_eq!(food.quantity.as_deref(), Some("12"));
+ assert_eq!(food.food_summary.as_deref(), Some("Fresh bunches"));
+ assert_eq!(food.food_published_at, Some(1_999_999_999));
+ assert_eq!(food.food_status.as_deref(), Some("active"));
+ }
+
+ #[test]
+ fn ordinary_standard_kind_one_needs_no_product_marker() {
+ let event = admitted(1, vec![vec!["t", "gardening"]], "Seedlings are ready");
+ let ProductEventClassification::Card(card) = classify_admitted_event(&event, context())
+ else {
+ panic!("standard kind-1 must remain admitted");
+ };
+ assert_eq!(card.card_type, TodayCardType::Update);
+ assert_eq!(
+ card.context_rank,
+ super::super::ContextRank::MissingLocalityFallback
+ );
+ }
+
+ #[test]
+ fn malformed_address_and_media_helpers_fail_closed() {
+ assert_eq!(tag_value(&[Vec::new()], &["title"]), None);
+ assert_eq!(tag_value(&[vec!["title".to_owned()]], &["title"]), None);
+ assert_eq!(tag_values(&[Vec::new()], "price"), None);
+ assert_eq!(tag_values(&[vec!["price".to_owned()]], "price"), None);
+ assert_eq!(
+ tag_values(
+ &[vec!["price".to_owned(), "3".to_owned(), "CAD".to_owned()]],
+ "price"
+ ),
+ Some(vec!["3".to_owned(), "CAD".to_owned()])
+ );
+ assert_eq!(
+ tag_time(&[vec!["start".to_owned(), "bad".to_owned()]], "start"),
+ None
+ );
+ assert!(
+ tag_time(
+ &[vec!["start".to_owned(), "2026-08-13".to_owned()]],
+ "start"
+ )
+ .is_some()
+ );
+
+ assert_eq!(blossom_digest("not-a-url"), None);
+ assert_eq!(blossom_digest("https://example.test"), None);
+ assert_eq!(blossom_digest("https://example.test/short"), None);
+ assert_eq!(
+ blossom_digest(&format!("https://example.test/{}", "G".repeat(64))),
+ None
+ );
+ assert_eq!(
+ blossom_digest(&format!("https://example.test/{}/file.jpg", "a".repeat(64))),
+ Some("a".repeat(64))
+ );
+ assert_eq!(
+ media_reference("not-a-url", None, None, None, None, None, None,),
+ None
+ );
+ }
+
+ #[test]
+ fn supporting_profiles_never_become_cards_and_nonmatches_are_excluded() {
+ let profile = admitted(0, vec![], r#"{"name":"Farm"}"#);
+ let reply = admitted(1, vec![vec!["e", &"a".repeat(64), "", "root"]], "Reply");
+ let author = Keys::parse(SECRET).expect("key").public_key().to_string();
+ let root_id = "a".repeat(64);
+ let comment = admitted_owned(
+ 1_111,
+ vec![
+ vec!["E".into(), root_id.clone(), String::new(), author.clone()],
+ vec!["K".into(), "30402".into()],
+ vec!["P".into(), author.clone()],
+ vec!["e".into(), root_id.clone(), String::new(), author.clone()],
+ vec!["k".into(), "30402".into()],
+ vec!["p".into(), author],
+ ],
+ "Comment",
+ );
+ let deletion = admitted(5, vec![vec!["e", &root_id]], "Superseded");
+ for (event, expected) in [
+ (profile, SupportingProfile::Profile),
+ (reply, SupportingProfile::Reply),
+ (comment, SupportingProfile::Comment),
+ (deletion, SupportingProfile::Deletion),
+ ] {
+ assert_eq!(
+ classify_admitted_event(&event, context()),
+ ProductEventClassification::Supporting(expected)
+ );
+ }
+
+ let nip98 = admitted(
+ 27_235,
+ vec![
+ vec!["u", "https://media.example/upload"],
+ vec!["method", "GET"],
+ ],
+ "{}",
+ );
+ assert_eq!(
+ classify_admitted_event(&nip98, context()),
+ ProductEventClassification::Excluded(ProductEventExclusion::UnsupportedProfile)
+ );
+
+ let update = admitted(1, vec![], "ordinary note");
+ assert_eq!(
+ classify_admitted_event(
+ &update,
+ LocalNetworkAdmission::Excluded {
+ reason: "locality_nonmatch"
+ }
+ ),
+ ProductEventClassification::Excluded(ProductEventExclusion::LocalityNonmatch)
+ );
+ }
+
+ #[test]
+ fn addressable_replacements_keep_stable_card_identity() {
+ let first = admitted(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh"],
+ vec!["published_at", "1999999999"],
+ vec!["location", "Saanich"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "active"],
+ ],
+ "First",
+ );
+ let second = admitted(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh"],
+ vec!["published_at", "1999999999"],
+ vec!["location", "Saanich"],
+ vec!["price", "4", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "active"],
+ ],
+ "Second",
+ );
+ let ids = [first, second].map(|event| match classify_admitted_event(&event, context()) {
+ ProductEventClassification::Card(card) => card.card_id,
+ other => panic!("expected card, got {other:?}"),
+ });
+ assert_eq!(ids[0], ids[1]);
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/ranking.rs b/core/crates/tera_core/src/runtime/product_surface/ranking.rs
@@ -0,0 +1,259 @@
+use core::cmp::Ordering;
+
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use super::{CardId, ContextRank, TodayCardType};
+
+pub const TODAY_RANK_SCHEMA_VERSION: u16 = 1;
+pub const TODAY_RANK_ALGORITHM_VERSION: u16 = 1;
+const RANK_DIGEST_DOMAIN: &[u8] = b"radroots.today-rank.v1\0";
+const UPCOMING_EVENT_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60;
+
+/// Exact time inputs used by the deliberately small Phase 1 ranking function.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TimeRelevance {
+ Published,
+ Event { start: u64, end: Option<u64> },
+ FoodAvailability { active: bool },
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TodayRankInput {
+ pub card_type: TodayCardType,
+ pub context_rank: ContextRank,
+ pub as_of: u64,
+ pub effective_at: u64,
+ pub time: TimeRelevance,
+ pub card_id: CardId,
+}
+
+#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
+pub enum RankError {
+ #[error("card type and time-relevance input do not match")]
+ MismatchedTimeProfile,
+ #[error("event end must be later than its start")]
+ InvalidEventRange,
+}
+
+/// Lexicographic Today order key.
+///
+/// Its [`Ord`] implementation sorts directly into feed order: higher context,
+/// time relevance, and effective time first, then lower card ID.
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodayRank {
+ pub schema_version: u16,
+ pub algorithm_version: u16,
+ pub context_rank: ContextRank,
+ pub time_relevance_rank: u8,
+ pub effective_at: u64,
+ pub card_id: CardId,
+}
+
+impl TodayRank {
+ pub fn derive(input: TodayRankInput) -> Result<Self, RankError> {
+ let time_relevance_rank = time_relevance_rank(input)?;
+ Ok(Self {
+ schema_version: TODAY_RANK_SCHEMA_VERSION,
+ algorithm_version: TODAY_RANK_ALGORITHM_VERSION,
+ context_rank: input.context_rank,
+ time_relevance_rank,
+ effective_at: input.effective_at,
+ card_id: input.card_id,
+ })
+ }
+
+ pub fn digest(self) -> [u8; 32] {
+ let mut digest = Sha256::new();
+ digest.update(RANK_DIGEST_DOMAIN);
+ digest.update(self.schema_version.to_be_bytes());
+ digest.update(self.algorithm_version.to_be_bytes());
+ digest.update([self.context_rank.value()]);
+ digest.update([self.time_relevance_rank]);
+ digest.update(self.effective_at.to_be_bytes());
+ digest.update(self.card_id.as_bytes());
+ digest.finalize().into()
+ }
+
+ pub fn digest_hex(self) -> String {
+ hex::encode(self.digest())
+ }
+}
+
+impl Ord for TodayRank {
+ fn cmp(&self, other: &Self) -> Ordering {
+ other
+ .context_rank
+ .cmp(&self.context_rank)
+ .then_with(|| other.time_relevance_rank.cmp(&self.time_relevance_rank))
+ .then_with(|| other.effective_at.cmp(&self.effective_at))
+ .then_with(|| self.card_id.cmp(&other.card_id))
+ }
+}
+
+impl PartialOrd for TodayRank {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+fn time_relevance_rank(input: TodayRankInput) -> Result<u8, RankError> {
+ match (input.card_type, input.time) {
+ (
+ TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask,
+ TimeRelevance::Published,
+ ) => Ok(1),
+ (TodayCardType::FoodAvailability, TimeRelevance::FoodAvailability { active }) => {
+ Ok(if active { 3 } else { 0 })
+ }
+ (TodayCardType::Event, TimeRelevance::Event { start, end }) => {
+ if end.is_some_and(|end| end <= start) {
+ return Err(RankError::InvalidEventRange);
+ }
+ if end.is_some_and(|end| input.as_of >= end) {
+ return Ok(0);
+ }
+ if input.as_of >= start {
+ return Ok(4);
+ }
+ if start.saturating_sub(input.as_of) <= UPCOMING_EVENT_WINDOW_SECONDS {
+ Ok(3)
+ } else {
+ Ok(2)
+ }
+ }
+ _ => Err(RankError::MismatchedTimeProfile),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn id(value: char) -> CardId {
+ CardId::parse(&value.to_string().repeat(64)).expect("card id")
+ }
+
+ fn input(card_type: TodayCardType, time: TimeRelevance) -> TodayRankInput {
+ TodayRankInput {
+ card_type,
+ context_rank: ContextRank::LocalityMatch,
+ as_of: 2_000_000_000,
+ effective_at: 1_999_999_900,
+ time,
+ card_id: id('a'),
+ }
+ }
+
+ #[test]
+ fn time_relevance_boundaries_are_exact() {
+ assert_eq!(
+ TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published))
+ .expect("update")
+ .time_relevance_rank,
+ 1
+ );
+ assert_eq!(
+ TodayRank::derive(input(
+ TodayCardType::FoodAvailability,
+ TimeRelevance::FoodAvailability { active: true }
+ ))
+ .expect("food")
+ .time_relevance_rank,
+ 3
+ );
+ assert_eq!(
+ TodayRank::derive(input(
+ TodayCardType::FoodAvailability,
+ TimeRelevance::FoodAvailability { active: false }
+ ))
+ .expect("sold food")
+ .time_relevance_rank,
+ 0
+ );
+ for (start, end, expected) in [
+ (1_999_999_900, Some(2_000_000_100), 4),
+ (1_999_999_900, None, 4),
+ (2_000_604_800, Some(2_000_604_900), 3),
+ (2_000_604_801, None, 2),
+ (1_999_999_000, Some(2_000_000_000), 0),
+ ] {
+ assert_eq!(
+ TodayRank::derive(input(
+ TodayCardType::Event,
+ TimeRelevance::Event { start, end }
+ ))
+ .expect("event")
+ .time_relevance_rank,
+ expected
+ );
+ }
+ }
+
+ #[test]
+ fn tuple_sorts_in_locked_feed_order_and_has_a_fixed_digest() {
+ let exact = TodayRank::derive(input(TodayCardType::Update, TimeRelevance::Published))
+ .expect("rank");
+ assert_eq!(
+ exact.digest_hex(),
+ "c7792876c8177f6f5420cc0f9aa84fb3c478f0bc6555c94ea5a7288502d6e4db"
+ );
+ let fallback = TodayRank {
+ context_rank: ContextRank::MissingLocalityFallback,
+ time_relevance_rank: 4,
+ effective_at: exact.effective_at + 100,
+ card_id: id('e'),
+ ..exact
+ };
+ let lower_time = TodayRank {
+ time_relevance_rank: 0,
+ effective_at: exact.effective_at + 200,
+ card_id: id('d'),
+ ..exact
+ };
+ let older = TodayRank {
+ effective_at: exact.effective_at - 1,
+ card_id: id('c'),
+ ..exact
+ };
+ let tie_high_id = TodayRank {
+ effective_at: exact.effective_at + 1,
+ card_id: id('b'),
+ ..exact
+ };
+ let tie_low_id = TodayRank {
+ effective_at: exact.effective_at + 1,
+ card_id: id('a'),
+ ..exact
+ };
+ let mut values = vec![fallback, lower_time, older, tie_high_id, tie_low_id];
+ values.sort();
+ assert_eq!(
+ values,
+ vec![tie_low_id, tie_high_id, older, lower_time, fallback]
+ );
+ }
+
+ #[test]
+ fn mismatched_and_invalid_time_inputs_fail_closed() {
+ assert_eq!(
+ TodayRank::derive(input(
+ TodayCardType::Update,
+ TimeRelevance::FoodAvailability { active: true }
+ )),
+ Err(RankError::MismatchedTimeProfile)
+ );
+ assert_eq!(
+ TodayRank::derive(input(
+ TodayCardType::Event,
+ TimeRelevance::Event {
+ start: 10,
+ end: Some(10),
+ }
+ )),
+ Err(RankError::InvalidEventRange)
+ );
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/settings.rs b/core/crates/tera_core/src/runtime/product_surface/settings.rs
@@ -0,0 +1,1669 @@
+//! Versioned, secret-safe mobile identity and product configuration policy.
+
+use std::collections::BTreeSet;
+
+use radroots_event::{
+ media::AuthoredImage,
+ profile::{AuthoredProfile, Nip05Identifier},
+};
+use radroots_identity::PublicKey;
+use radroots_storage::projection::{
+ ProjectionDocument, ProjectionGeneration, ProjectionId, ProjectionStore,
+};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use super::super::RadrootsRuntime;
+
+pub const DEFAULT_PUBLIC_RELAY: &str = "wss://radroots.org";
+
+pub const MOBILE_SETTINGS_SCHEMA_VERSION: u16 = 1;
+pub const DEFAULT_PUBLIC_BLOSSOM_ORIGIN: &str = "https://blossom.radroots.org";
+pub const DEFAULT_SIMULATOR_RELAY: &str = "ws://127.0.0.1:21000";
+pub const DEFAULT_SIMULATOR_BLOSSOM_ORIGIN: &str = "http://127.0.0.1:21100";
+
+const SETTINGS_PROJECTION_ID: &str = "radroots.mobile.settings.v1";
+const SETTINGS_DOCUMENT_KEY: &str = "settings.current";
+const SETTINGS_GENERATION_DOMAIN: &[u8] = b"radroots.mobile.settings.generation.v1";
+const IDENTITY_ID_MAX_BYTES: usize = 128;
+const OPERATION_ID_MAX_BYTES: usize = 128;
+const PROFILE_NAME_MAX_BYTES: usize = 256;
+const PROFILE_DISPLAY_NAME_MAX_BYTES: usize = 512;
+const PROFILE_ABOUT_MAX_BYTES: usize = 8 * 1024;
+const RELAY_ENDPOINT_MAX: usize = 32;
+const BLOSSOM_FALLBACK_MAX: usize = 15;
+const MEDIA_CACHE_MIN_BYTES: u64 = 16 * 1024 * 1024;
+const MEDIA_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
+const MEDIA_CACHE_MAX_ARTIFACTS: u32 = 10_000;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IdentityLockState {
+ Locked,
+ Unlocked,
+}
+
+/// Secret-safe reference to one Apple-custodied identity.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct IdentityRecord {
+ id: String,
+ public_key_hex: String,
+}
+
+impl IdentityRecord {
+ pub fn new(id: impl Into<String>, public_key_hex: &str) -> Result<Self, IdentitySettingsError> {
+ let id = id.into();
+ validate_identifier(&id, IDENTITY_ID_MAX_BYTES)
+ .then_some(())
+ .ok_or(IdentitySettingsError::InvalidIdentityId)?;
+ let public_key = PublicKey::from_hex(public_key_hex)
+ .map_err(|_| IdentitySettingsError::InvalidPublicKey)?;
+ Ok(Self {
+ id,
+ public_key_hex: public_key.to_hex(),
+ })
+ }
+
+ pub fn id(&self) -> &str {
+ self.id.as_str()
+ }
+
+ pub fn public_key_hex(&self) -> &str {
+ self.public_key_hex.as_str()
+ }
+}
+
+/// Durable identity selection. It contains public metadata only; key material
+/// and user-presence state remain in the native custody provider.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct IdentityState {
+ identities: Vec<IdentityRecord>,
+ active_identity_id: Option<String>,
+ lock_state: IdentityLockState,
+ pending_import_operation_id: Option<String>,
+}
+
+impl Default for IdentityState {
+ fn default() -> Self {
+ Self {
+ identities: Vec::new(),
+ active_identity_id: None,
+ lock_state: IdentityLockState::Locked,
+ pending_import_operation_id: None,
+ }
+ }
+}
+
+impl IdentityState {
+ pub fn new(
+ identities: Vec<IdentityRecord>,
+ active_identity_id: Option<String>,
+ lock_state: IdentityLockState,
+ pending_import_operation_id: Option<String>,
+ ) -> Result<Self, IdentitySettingsError> {
+ let mut ids = BTreeSet::new();
+ let mut public_keys = BTreeSet::new();
+ for identity in &identities {
+ if !ids.insert(identity.id()) {
+ return Err(IdentitySettingsError::DuplicateIdentityId);
+ }
+ if !public_keys.insert(identity.public_key_hex()) {
+ return Err(IdentitySettingsError::DuplicatePublicKey);
+ }
+ }
+ if let Some(active) = active_identity_id.as_deref()
+ && !ids.contains(active)
+ {
+ return Err(IdentitySettingsError::UnknownIdentity);
+ }
+ if let Some(operation_id) = pending_import_operation_id.as_deref()
+ && !validate_identifier(operation_id, OPERATION_ID_MAX_BYTES)
+ {
+ return Err(IdentitySettingsError::InvalidOperationId);
+ }
+ if active_identity_id.is_none() && lock_state == IdentityLockState::Unlocked {
+ return Err(IdentitySettingsError::NoActiveIdentity);
+ }
+ Ok(Self {
+ identities,
+ active_identity_id,
+ lock_state,
+ pending_import_operation_id,
+ })
+ }
+
+ pub fn identities(&self) -> &[IdentityRecord] {
+ self.identities.as_slice()
+ }
+
+ pub fn active_identity_id(&self) -> Option<&str> {
+ self.active_identity_id.as_deref()
+ }
+
+ pub const fn lock_state(&self) -> IdentityLockState {
+ self.lock_state
+ }
+
+ pub fn pending_import_operation_id(&self) -> Option<&str> {
+ self.pending_import_operation_id.as_deref()
+ }
+
+ /// Applies a secret-free identity state transition after the native host
+ /// has completed any required Keychain or user-presence operation.
+ pub fn apply(&self, command: IdentityCommand) -> Result<Self, IdentitySettingsError> {
+ let mut next = self.clone();
+ match command {
+ IdentityCommand::BeginImport { operation_id } => {
+ if next.pending_import_operation_id.is_some() {
+ return Err(IdentitySettingsError::ImportAlreadyPending);
+ }
+ if !validate_identifier(&operation_id, OPERATION_ID_MAX_BYTES) {
+ return Err(IdentitySettingsError::InvalidOperationId);
+ }
+ next.pending_import_operation_id = Some(operation_id);
+ }
+ IdentityCommand::CompleteImport {
+ operation_id,
+ identity,
+ } => {
+ if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) {
+ return Err(IdentitySettingsError::ImportOperationMismatch);
+ }
+ if next
+ .identities
+ .iter()
+ .any(|value| value.id() == identity.id())
+ {
+ return Err(IdentitySettingsError::DuplicateIdentityId);
+ }
+ if next
+ .identities
+ .iter()
+ .any(|value| value.public_key_hex() == identity.public_key_hex())
+ {
+ return Err(IdentitySettingsError::DuplicatePublicKey);
+ }
+ next.active_identity_id = Some(identity.id().to_owned());
+ next.identities.push(identity);
+ next.lock_state = IdentityLockState::Locked;
+ next.pending_import_operation_id = None;
+ }
+ IdentityCommand::CancelImport { operation_id } => {
+ if next.pending_import_operation_id.as_deref() != Some(operation_id.as_str()) {
+ return Err(IdentitySettingsError::ImportOperationMismatch);
+ }
+ next.pending_import_operation_id = None;
+ }
+ IdentityCommand::Select { identity_id } => {
+ if !next
+ .identities
+ .iter()
+ .any(|identity| identity.id() == identity_id)
+ {
+ return Err(IdentitySettingsError::UnknownIdentity);
+ }
+ next.active_identity_id = Some(identity_id);
+ next.lock_state = IdentityLockState::Locked;
+ }
+ IdentityCommand::Lock => {
+ if next.active_identity_id.is_none() {
+ return Err(IdentitySettingsError::NoActiveIdentity);
+ }
+ next.lock_state = IdentityLockState::Locked;
+ }
+ IdentityCommand::Unlock => {
+ if next.active_identity_id.is_none() {
+ return Err(IdentitySettingsError::NoActiveIdentity);
+ }
+ next.lock_state = IdentityLockState::Unlocked;
+ }
+ IdentityCommand::Recover => {
+ if next.active_identity_id.is_none() {
+ return Err(IdentitySettingsError::NoActiveIdentity);
+ }
+ next.lock_state = IdentityLockState::Locked;
+ next.pending_import_operation_id = None;
+ }
+ }
+ Ok(next)
+ }
+}
+
+/// Secret-free intent/result commands. `CompleteImport` carries only the
+/// public identity returned by the Apple custody provider.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum IdentityCommand {
+ BeginImport {
+ operation_id: String,
+ },
+ CompleteImport {
+ operation_id: String,
+ identity: IdentityRecord,
+ },
+ CancelImport {
+ operation_id: String,
+ },
+ Select {
+ identity_id: String,
+ },
+ Lock,
+ Unlock,
+ Recover,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
+pub enum IdentitySettingsError {
+ #[error("identity id is invalid")]
+ InvalidIdentityId,
+ #[error("identity public key is invalid")]
+ InvalidPublicKey,
+ #[error("identity operation id is invalid")]
+ InvalidOperationId,
+ #[error("identity id is duplicated")]
+ DuplicateIdentityId,
+ #[error("identity public key is duplicated")]
+ DuplicatePublicKey,
+ #[error("identity is unknown")]
+ UnknownIdentity,
+ #[error("no active identity exists")]
+ NoActiveIdentity,
+ #[error("an identity import is already pending")]
+ ImportAlreadyPending,
+ #[error("identity import operation does not match")]
+ ImportOperationMismatch,
+}
+
+impl IdentitySettingsError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::InvalidIdentityId => "invalid_identity_id",
+ Self::InvalidPublicKey => "invalid_public_key",
+ Self::InvalidOperationId => "invalid_identity_operation_id",
+ Self::DuplicateIdentityId => "duplicate_identity_id",
+ Self::DuplicatePublicKey => "duplicate_identity_public_key",
+ Self::UnknownIdentity => "unknown_identity",
+ Self::NoActiveIdentity => "no_active_identity",
+ Self::ImportAlreadyPending => "identity_import_already_pending",
+ Self::ImportOperationMismatch => "identity_import_operation_mismatch",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RelayAccessPreference {
+ ReadOnly,
+ ReadWrite,
+}
+
+impl RelayAccessPreference {
+ pub const fn can_write(self) -> bool {
+ matches!(self, Self::ReadWrite)
+ }
+
+ fn parse(value: &str) -> Result<Self, SettingsError> {
+ match value {
+ "read_only" => Ok(Self::ReadOnly),
+ "read_write" => Ok(Self::ReadWrite),
+ _ => Err(SettingsError::UnknownRelayAccess),
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::ReadOnly => "read_only",
+ Self::ReadWrite => "read_write",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MobileNetworkEnvironment {
+ Public,
+ Simulator,
+ PhysicalDevice,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RelayEndpointPreference {
+ url: String,
+ access: RelayAccessPreference,
+}
+
+impl RelayEndpointPreference {
+ pub fn new(
+ environment: MobileNetworkEnvironment,
+ url: impl AsRef<str>,
+ access: RelayAccessPreference,
+ ) -> Result<Self, SettingsError> {
+ let policy = match environment {
+ MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public,
+ MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local,
+ MobileNetworkEnvironment::PhysicalDevice => {
+ radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork
+ }
+ };
+ let url = radroots_sdk::transport::RelayUrl::parse(url, policy)
+ .map_err(|_| SettingsError::InvalidRelayEndpoint)?;
+ Ok(Self {
+ url: url.to_string(),
+ access,
+ })
+ }
+
+ pub fn url(&self) -> &str {
+ self.url.as_str()
+ }
+
+ pub const fn access(&self) -> RelayAccessPreference {
+ self.access
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RelayPreferences {
+ environment: MobileNetworkEnvironment,
+ endpoints: Vec<RelayEndpointPreference>,
+}
+
+impl RelayPreferences {
+ pub fn new(
+ environment: MobileNetworkEnvironment,
+ endpoints: Vec<RelayEndpointPreference>,
+ ) -> Result<Self, SettingsError> {
+ if endpoints.is_empty() || endpoints.len() > RELAY_ENDPOINT_MAX {
+ return Err(SettingsError::InvalidRelayEndpointCount);
+ }
+ let mut seen = BTreeSet::new();
+ for endpoint in &endpoints {
+ let validated =
+ RelayEndpointPreference::new(environment, endpoint.url(), endpoint.access())?;
+ if validated != *endpoint || !seen.insert(endpoint.url()) {
+ return Err(SettingsError::DuplicateRelayEndpoint);
+ }
+ }
+ Ok(Self {
+ environment,
+ endpoints,
+ })
+ }
+
+ pub fn production_default() -> Self {
+ Self::new(
+ MobileNetworkEnvironment::Public,
+ vec![
+ RelayEndpointPreference::new(
+ MobileNetworkEnvironment::Public,
+ DEFAULT_PUBLIC_RELAY,
+ RelayAccessPreference::ReadWrite,
+ )
+ .expect("bundled public relay is valid"),
+ ],
+ )
+ .expect("bundled public relay profile is valid")
+ }
+
+ pub fn simulator_default() -> Self {
+ Self::new(
+ MobileNetworkEnvironment::Simulator,
+ vec![
+ RelayEndpointPreference::new(
+ MobileNetworkEnvironment::Simulator,
+ DEFAULT_SIMULATOR_RELAY,
+ RelayAccessPreference::ReadWrite,
+ )
+ .expect("bundled simulator relay is valid"),
+ ],
+ )
+ .expect("bundled simulator relay profile is valid")
+ }
+
+ pub const fn environment(&self) -> MobileNetworkEnvironment {
+ self.environment
+ }
+
+ pub fn endpoints(&self) -> &[RelayEndpointPreference] {
+ self.endpoints.as_slice()
+ }
+
+ pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::RelayProfile, SettingsError> {
+ let kind = match self.environment {
+ MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayProfileKind::Public,
+ MobileNetworkEnvironment::Simulator => {
+ radroots_sdk::transport::RelayProfileKind::Simulator
+ }
+ MobileNetworkEnvironment::PhysicalDevice => {
+ radroots_sdk::transport::RelayProfileKind::Device
+ }
+ };
+ let policy = match self.environment {
+ MobileNetworkEnvironment::Public => radroots_sdk::transport::RelayUrlPolicy::Public,
+ MobileNetworkEnvironment::Simulator => radroots_sdk::transport::RelayUrlPolicy::Local,
+ MobileNetworkEnvironment::PhysicalDevice => {
+ radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork
+ }
+ };
+ let endpoints = self
+ .endpoints
+ .iter()
+ .map(|endpoint| {
+ let access = match endpoint.access {
+ RelayAccessPreference::ReadOnly => {
+ radroots_sdk::transport::RelayAccess::ReadOnly
+ }
+ RelayAccessPreference::ReadWrite => {
+ radroots_sdk::transport::RelayAccess::ReadWrite
+ }
+ };
+ radroots_sdk::transport::RelayEndpoint::new(endpoint.url.as_str(), policy, access)
+ })
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| SettingsError::InvalidRelayEndpoint)?;
+ radroots_sdk::transport::RelayProfile::explicit(kind, endpoints)
+ .map_err(|_| SettingsError::InvalidRelayEndpoint)
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum BlossomEndpointAuthorityPreference {
+ PublicWebPki,
+ LoopbackDevelopment,
+ PrivateNetworkDevelopment,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BlossomPreferences {
+ environment: MobileNetworkEnvironment,
+ authority: BlossomEndpointAuthorityPreference,
+ primary_origin: String,
+ fallback_origins: Vec<String>,
+}
+
+impl BlossomPreferences {
+ pub fn new(
+ environment: MobileNetworkEnvironment,
+ authority: BlossomEndpointAuthorityPreference,
+ primary_origin: impl Into<String>,
+ fallback_origins: Vec<String>,
+ ) -> Result<Self, SettingsError> {
+ if fallback_origins.len() > BLOSSOM_FALLBACK_MAX {
+ return Err(SettingsError::InvalidBlossomEndpointCount);
+ }
+ let candidate = Self {
+ environment,
+ authority,
+ primary_origin: primary_origin.into(),
+ fallback_origins,
+ };
+ let profile = candidate.sdk_profile()?;
+ Ok(Self {
+ primary_origin: profile.primary().origin().to_owned(),
+ fallback_origins: profile
+ .fallbacks()
+ .iter()
+ .map(|endpoint| endpoint.origin().to_owned())
+ .collect(),
+ ..candidate
+ })
+ }
+
+ pub fn production_default() -> Self {
+ Self::new(
+ MobileNetworkEnvironment::Public,
+ BlossomEndpointAuthorityPreference::PublicWebPki,
+ DEFAULT_PUBLIC_BLOSSOM_ORIGIN,
+ Vec::new(),
+ )
+ .expect("bundled public Blossom origin is valid")
+ }
+
+ pub fn simulator_default() -> Self {
+ Self::new(
+ MobileNetworkEnvironment::Simulator,
+ BlossomEndpointAuthorityPreference::LoopbackDevelopment,
+ DEFAULT_SIMULATOR_BLOSSOM_ORIGIN,
+ Vec::new(),
+ )
+ .expect("bundled simulator Blossom origin is valid")
+ }
+
+ pub const fn environment(&self) -> MobileNetworkEnvironment {
+ self.environment
+ }
+
+ pub const fn authority(&self) -> BlossomEndpointAuthorityPreference {
+ self.authority
+ }
+
+ pub fn primary_origin(&self) -> &str {
+ self.primary_origin.as_str()
+ }
+
+ pub fn fallback_origins(&self) -> &[String] {
+ self.fallback_origins.as_slice()
+ }
+
+ pub fn sdk_profile(&self) -> Result<radroots_sdk::transport::BlossomProfile, SettingsError> {
+ let host_kind = match self.environment {
+ MobileNetworkEnvironment::Public => radroots_sdk::transport::BlossomHostKind::Native,
+ MobileNetworkEnvironment::Simulator => {
+ radroots_sdk::transport::BlossomHostKind::Simulator
+ }
+ MobileNetworkEnvironment::PhysicalDevice => {
+ radroots_sdk::transport::BlossomHostKind::PhysicalDevice
+ }
+ };
+ let authority = match self.authority {
+ BlossomEndpointAuthorityPreference::PublicWebPki => {
+ radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki
+ }
+ BlossomEndpointAuthorityPreference::LoopbackDevelopment => {
+ radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment
+ }
+ BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => {
+ radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment
+ }
+ };
+ radroots_sdk::transport::BlossomProfile::new(
+ host_kind,
+ authority,
+ self.primary_origin.as_str(),
+ self.fallback_origins.iter().map(String::as_str),
+ )
+ .map_err(|_| SettingsError::InvalidBlossomEndpoint)
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct MediaNetworkPolicy {
+ allow_cellular_downloads: bool,
+ allow_cellular_uploads: bool,
+ allow_background_transfers: bool,
+}
+
+impl MediaNetworkPolicy {
+ pub const fn new(
+ allow_cellular_downloads: bool,
+ allow_cellular_uploads: bool,
+ allow_background_transfers: bool,
+ ) -> Self {
+ Self {
+ allow_cellular_downloads,
+ allow_cellular_uploads,
+ allow_background_transfers,
+ }
+ }
+
+ pub const fn allow_cellular_downloads(&self) -> bool {
+ self.allow_cellular_downloads
+ }
+
+ pub const fn allow_cellular_uploads(&self) -> bool {
+ self.allow_cellular_uploads
+ }
+
+ pub const fn allow_background_transfers(&self) -> bool {
+ self.allow_background_transfers
+ }
+}
+
+impl Default for MediaNetworkPolicy {
+ fn default() -> Self {
+ Self::new(true, true, true)
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LocalStoragePolicy {
+ media_cache_bytes: u64,
+ media_cache_artifacts: u32,
+}
+
+impl LocalStoragePolicy {
+ pub fn new(media_cache_bytes: u64, media_cache_artifacts: u32) -> Result<Self, SettingsError> {
+ if !(MEDIA_CACHE_MIN_BYTES..=MEDIA_CACHE_MAX_BYTES).contains(&media_cache_bytes) {
+ return Err(SettingsError::InvalidMediaCacheBytes);
+ }
+ if media_cache_artifacts == 0 || media_cache_artifacts > MEDIA_CACHE_MAX_ARTIFACTS {
+ return Err(SettingsError::InvalidMediaCacheArtifacts);
+ }
+ Ok(Self {
+ media_cache_bytes,
+ media_cache_artifacts,
+ })
+ }
+
+ pub const fn media_cache_bytes(&self) -> u64 {
+ self.media_cache_bytes
+ }
+
+ pub const fn media_cache_artifacts(&self) -> u32 {
+ self.media_cache_artifacts
+ }
+}
+
+impl Default for LocalStoragePolicy {
+ fn default() -> Self {
+ Self::new(256 * 1024 * 1024, 2_000).expect("default storage policy is valid")
+ }
+}
+
+/// Complete replacement command for the adopted kind-0 metadata surface.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ProfileMetadataCommand(AuthoredProfile);
+
+impl ProfileMetadataCommand {
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ name: String,
+ display_name: Option<String>,
+ about: Option<String>,
+ picture: Option<AuthoredImage>,
+ banner: Option<AuthoredImage>,
+ nip05: Option<String>,
+ bot: Option<bool>,
+ ) -> Result<Self, ProfileMetadataError> {
+ validate_profile_text(&name, PROFILE_NAME_MAX_BYTES, false)
+ .then_some(())
+ .ok_or(ProfileMetadataError::InvalidName)?;
+ if display_name.as_deref().is_some_and(|value| {
+ !validate_profile_text(value, PROFILE_DISPLAY_NAME_MAX_BYTES, true)
+ }) {
+ return Err(ProfileMetadataError::InvalidDisplayName);
+ }
+ if about
+ .as_deref()
+ .is_some_and(|value| !validate_profile_text(value, PROFILE_ABOUT_MAX_BYTES, true))
+ {
+ return Err(ProfileMetadataError::InvalidAbout);
+ }
+ let nip05 = nip05
+ .as_deref()
+ .map(Nip05Identifier::parse)
+ .transpose()
+ .map_err(|_| ProfileMetadataError::InvalidNip05)?;
+ let mut profile =
+ AuthoredProfile::new(name).map_err(|_| ProfileMetadataError::InvalidName)?;
+ if let Some(value) = display_name {
+ profile = profile.with_display_name(value);
+ }
+ if let Some(value) = about {
+ profile = profile.with_about(value);
+ }
+ if let Some(value) = picture {
+ profile = profile.with_picture(value);
+ }
+ if let Some(value) = banner {
+ profile = profile.with_banner(value);
+ }
+ if let Some(value) = nip05 {
+ profile = profile.with_nip05(value);
+ }
+ if let Some(value) = bot {
+ profile = profile.with_bot(value);
+ }
+ Ok(Self(profile))
+ }
+
+ pub const fn authored(&self) -> &AuthoredProfile {
+ &self.0
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
+pub enum ProfileMetadataError {
+ #[error("profile name is invalid")]
+ InvalidName,
+ #[error("profile display name is invalid")]
+ InvalidDisplayName,
+ #[error("profile about text is invalid")]
+ InvalidAbout,
+ #[error("profile NIP-05 identifier is invalid")]
+ InvalidNip05,
+}
+
+impl ProfileMetadataError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::InvalidName => "invalid_profile_name",
+ Self::InvalidDisplayName => "invalid_profile_display_name",
+ Self::InvalidAbout => "invalid_profile_about",
+ Self::InvalidNip05 => "invalid_profile_nip05",
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MobileSettings {
+ revision: u64,
+ identity: IdentityState,
+ relays: RelayPreferences,
+ blossom: BlossomPreferences,
+ media_network: MediaNetworkPolicy,
+ local_storage: LocalStoragePolicy,
+}
+
+impl Default for MobileSettings {
+ fn default() -> Self {
+ Self {
+ revision: 1,
+ identity: IdentityState::default(),
+ relays: RelayPreferences::production_default(),
+ blossom: BlossomPreferences::production_default(),
+ media_network: MediaNetworkPolicy::default(),
+ local_storage: LocalStoragePolicy::default(),
+ }
+ }
+}
+
+impl MobileSettings {
+ pub const fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ pub const fn identity(&self) -> &IdentityState {
+ &self.identity
+ }
+
+ pub const fn relays(&self) -> &RelayPreferences {
+ &self.relays
+ }
+
+ pub const fn blossom(&self) -> &BlossomPreferences {
+ &self.blossom
+ }
+
+ pub const fn media_network(&self) -> &MediaNetworkPolicy {
+ &self.media_network
+ }
+
+ pub const fn local_storage(&self) -> &LocalStoragePolicy {
+ &self.local_storage
+ }
+
+ #[must_use]
+ pub fn with_identity(mut self, identity: IdentityState) -> Self {
+ self.identity = identity;
+ self
+ }
+
+ #[must_use]
+ pub fn with_relays(mut self, relays: RelayPreferences) -> Self {
+ self.relays = relays;
+ self
+ }
+
+ #[must_use]
+ pub fn with_blossom(mut self, blossom: BlossomPreferences) -> Self {
+ self.blossom = blossom;
+ self
+ }
+
+ #[must_use]
+ pub fn with_media_network(mut self, media_network: MediaNetworkPolicy) -> Self {
+ self.media_network = media_network;
+ self
+ }
+
+ #[must_use]
+ pub fn with_local_storage(mut self, local_storage: LocalStoragePolicy) -> Self {
+ self.local_storage = local_storage;
+ self
+ }
+
+ fn validate(&self) -> Result<(), SettingsError> {
+ if self.relays.environment() != self.blossom.environment() {
+ return Err(SettingsError::NetworkEnvironmentMismatch);
+ }
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReplaceMobileSettings {
+ expected_revision: u64,
+ settings: MobileSettings,
+}
+
+impl ReplaceMobileSettings {
+ pub fn new(expected_revision: u64, settings: MobileSettings) -> Result<Self, SettingsError> {
+ if expected_revision == 0 || settings.revision != expected_revision {
+ return Err(SettingsError::RevisionConflict);
+ }
+ settings.validate()?;
+ Ok(Self {
+ expected_revision,
+ settings,
+ })
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SettingsTransition {
+ pub settings: MobileSettings,
+ pub runtime_restart_required: bool,
+ pub outbox_requeue_required: bool,
+ pub media_cache_invalidation_required: bool,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
+pub enum SettingsError {
+ #[error("relay access value is unknown")]
+ UnknownRelayAccess,
+ #[error("relay endpoint is invalid")]
+ InvalidRelayEndpoint,
+ #[error("relay endpoint count is invalid")]
+ InvalidRelayEndpointCount,
+ #[error("relay endpoint is duplicated")]
+ DuplicateRelayEndpoint,
+ #[error("Blossom endpoint is invalid")]
+ InvalidBlossomEndpoint,
+ #[error("Blossom endpoint count is invalid")]
+ InvalidBlossomEndpointCount,
+ #[error("relay and Blossom network environments do not match")]
+ NetworkEnvironmentMismatch,
+ #[error("media cache byte quota is invalid")]
+ InvalidMediaCacheBytes,
+ #[error("media cache artifact quota is invalid")]
+ InvalidMediaCacheArtifacts,
+ #[error("settings revision conflicts with durable state")]
+ RevisionConflict,
+ #[error("settings revision is exhausted")]
+ RevisionExhausted,
+ #[error("settings schema version is unsupported")]
+ UnsupportedSchema,
+ #[error("settings document is corrupt")]
+ CorruptDocument,
+ #[error("settings storage is unavailable")]
+ Storage,
+ #[error("identity settings are invalid: {0}")]
+ Identity(#[from] IdentitySettingsError),
+}
+
+impl SettingsError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::UnknownRelayAccess => "unknown_relay_access",
+ Self::InvalidRelayEndpoint => "invalid_relay_endpoint",
+ Self::InvalidRelayEndpointCount => "invalid_relay_endpoint_count",
+ Self::DuplicateRelayEndpoint => "duplicate_relay_endpoint",
+ Self::InvalidBlossomEndpoint => "invalid_blossom_endpoint",
+ Self::InvalidBlossomEndpointCount => "invalid_blossom_endpoint_count",
+ Self::NetworkEnvironmentMismatch => "network_environment_mismatch",
+ Self::InvalidMediaCacheBytes => "invalid_media_cache_bytes",
+ Self::InvalidMediaCacheArtifacts => "invalid_media_cache_artifacts",
+ Self::RevisionConflict => "settings_revision_conflict",
+ Self::RevisionExhausted => "settings_revision_exhausted",
+ Self::UnsupportedSchema => "unsupported_settings_schema",
+ Self::CorruptDocument => "corrupt_settings_document",
+ Self::Storage => "settings_storage_unavailable",
+ Self::Identity(error) => error.code(),
+ }
+ }
+}
+
+impl RadrootsRuntime {
+ pub async fn phase1_settings(&self) -> Result<MobileSettings, SettingsError> {
+ let storage = self.client.storage().map_err(|_| SettingsError::Storage)?;
+ let mut settings = load_settings(storage).await?;
+ let session = self.identity_session.read().await;
+ if let Some((revision, identity)) = session.as_ref()
+ && *revision == settings.revision
+ {
+ settings.identity = identity.clone();
+ }
+ Ok(settings)
+ }
+
+ pub async fn phase1_replace_settings(
+ &self,
+ command: ReplaceMobileSettings,
+ ) -> Result<SettingsTransition, SettingsError> {
+ let _guard = self.settings_lock.lock().await;
+ let storage = self.client.storage().map_err(|_| SettingsError::Storage)?;
+ let transition = replace_settings(storage, command).await?;
+ *self.identity_session.write().await = None;
+ Ok(transition)
+ }
+
+ /// Applies one secret-free identity transition atomically against the
+ /// current settings revision. Unlock evidence is process-local: it is
+ /// observable for the current runtime but never written to durable state.
+ pub async fn phase1_apply_identity_command(
+ &self,
+ expected_revision: u64,
+ command: IdentityCommand,
+ ) -> Result<SettingsTransition, SettingsError> {
+ let _guard = self.settings_lock.lock().await;
+ let storage = self.client.storage().map_err(|_| SettingsError::Storage)?;
+ let mut prior = load_settings(storage).await?;
+ if prior.revision != expected_revision {
+ return Err(SettingsError::RevisionConflict);
+ }
+ if let Some((revision, identity)) = self.identity_session.read().await.as_ref()
+ && *revision == prior.revision
+ {
+ prior.identity = identity.clone();
+ }
+ let next_identity = prior.identity.apply(command.clone())?;
+ if matches!(command, IdentityCommand::Unlock) {
+ *self.identity_session.write().await = Some((prior.revision, next_identity.clone()));
+ return Ok(SettingsTransition {
+ settings: prior.with_identity(next_identity),
+ runtime_restart_required: false,
+ outbox_requeue_required: false,
+ media_cache_invalidation_required: false,
+ });
+ }
+ let command =
+ ReplaceMobileSettings::new(prior.revision, prior.with_identity(next_identity))?;
+ let transition = replace_settings(storage, command).await?;
+ *self.identity_session.write().await = None;
+ Ok(transition)
+ }
+}
+
+async fn load_settings(
+ storage: &dyn radroots_storage::Storage,
+) -> Result<MobileSettings, SettingsError> {
+ let document = ProjectionStore::projection_document(
+ storage,
+ settings_projection_id()?,
+ settings_generation()?,
+ SETTINGS_DOCUMENT_KEY.to_owned(),
+ )
+ .await
+ .map_err(|_| SettingsError::Storage)?;
+ document
+ .map(|document| decode_settings(document.value()))
+ .transpose()
+ .map(Option::unwrap_or_default)
+}
+
+async fn replace_settings(
+ storage: &dyn radroots_storage::Storage,
+ command: ReplaceMobileSettings,
+) -> Result<SettingsTransition, SettingsError> {
+ let prior = load_settings(storage).await?;
+ if prior.revision != command.expected_revision {
+ return Err(SettingsError::RevisionConflict);
+ }
+ let mut next = command.settings;
+ next.revision = prior
+ .revision
+ .checked_add(1)
+ .ok_or(SettingsError::RevisionExhausted)?;
+ // Unlock is a native, process-local custody result. A durable settings
+ // write must never make a later process assume user presence succeeded.
+ next.identity.lock_state = IdentityLockState::Locked;
+ let transition = settings_transition(&prior, next);
+ ProjectionStore::put_projection_document(
+ storage,
+ settings_projection_id()?,
+ settings_generation()?,
+ ProjectionDocument::new(
+ SETTINGS_DOCUMENT_KEY.to_owned(),
+ encode_settings(&transition.settings)?,
+ )
+ .map_err(|_| SettingsError::CorruptDocument)?,
+ )
+ .await
+ .map_err(|_| SettingsError::Storage)?;
+ Ok(transition)
+}
+
+fn settings_transition(prior: &MobileSettings, settings: MobileSettings) -> SettingsTransition {
+ let identity_changed = prior.identity != settings.identity;
+ let relay_changed = prior.relays != settings.relays;
+ let blossom_changed = prior.blossom != settings.blossom;
+ let media_changed = prior.media_network != settings.media_network;
+ let storage_changed = prior.local_storage != settings.local_storage;
+ SettingsTransition {
+ runtime_restart_required: identity_changed || relay_changed || blossom_changed,
+ outbox_requeue_required: identity_changed || relay_changed || blossom_changed,
+ media_cache_invalidation_required: identity_changed
+ || blossom_changed
+ || media_changed
+ || storage_changed,
+ settings,
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredSettingsV1 {
+ schema_version: u16,
+ revision: u64,
+ identity: StoredIdentity,
+ relays: StoredRelays,
+ blossom: StoredBlossom,
+ media_network: MediaNetworkPolicy,
+ local_storage: StoredLocalStorage,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredSettingsV0 {
+ schema_version: u16,
+ revision: u64,
+ identity: StoredIdentity,
+ relays: StoredRelays,
+ blossom: StoredBlossom,
+ allow_cellular_downloads: bool,
+ allow_cellular_uploads: bool,
+ media_cache_bytes: u64,
+ media_cache_artifacts: u32,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredIdentity {
+ identities: Vec<StoredIdentityRecord>,
+ active_identity_id: Option<String>,
+ lock_state: IdentityLockState,
+ pending_import_operation_id: Option<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredIdentityRecord {
+ id: String,
+ public_key_hex: String,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredRelays {
+ environment: MobileNetworkEnvironment,
+ endpoints: Vec<StoredRelayEndpoint>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredRelayEndpoint {
+ url: String,
+ access: String,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredBlossom {
+ environment: MobileNetworkEnvironment,
+ authority: BlossomEndpointAuthorityPreference,
+ primary_origin: String,
+ fallback_origins: Vec<String>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StoredLocalStorage {
+ media_cache_bytes: u64,
+ media_cache_artifacts: u32,
+}
+
+#[derive(Deserialize)]
+struct VersionProbe {
+ schema_version: u16,
+}
+
+fn encode_settings(settings: &MobileSettings) -> Result<Vec<u8>, SettingsError> {
+ serde_json::to_vec(&StoredSettingsV1::from(settings))
+ .map_err(|_| SettingsError::CorruptDocument)
+}
+
+fn decode_settings(value: &[u8]) -> Result<MobileSettings, SettingsError> {
+ let version = serde_json::from_slice::<VersionProbe>(value)
+ .map_err(|_| SettingsError::CorruptDocument)?
+ .schema_version;
+ match version {
+ 0 => serde_json::from_slice::<StoredSettingsV0>(value)
+ .map_err(|_| SettingsError::CorruptDocument)?
+ .try_into(),
+ MOBILE_SETTINGS_SCHEMA_VERSION => serde_json::from_slice::<StoredSettingsV1>(value)
+ .map_err(|_| SettingsError::CorruptDocument)?
+ .try_into(),
+ _ => Err(SettingsError::UnsupportedSchema),
+ }
+}
+
+impl From<&MobileSettings> for StoredSettingsV1 {
+ fn from(value: &MobileSettings) -> Self {
+ Self {
+ schema_version: MOBILE_SETTINGS_SCHEMA_VERSION,
+ revision: value.revision,
+ identity: StoredIdentity::from(&value.identity),
+ relays: StoredRelays::from(&value.relays),
+ blossom: StoredBlossom::from(&value.blossom),
+ media_network: value.media_network.clone(),
+ local_storage: StoredLocalStorage::from(&value.local_storage),
+ }
+ }
+}
+
+impl TryFrom<StoredSettingsV1> for MobileSettings {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredSettingsV1) -> Result<Self, Self::Error> {
+ if value.schema_version != MOBILE_SETTINGS_SCHEMA_VERSION || value.revision == 0 {
+ return Err(SettingsError::CorruptDocument);
+ }
+ let settings = Self {
+ revision: value.revision,
+ identity: value.identity.try_into()?,
+ relays: value.relays.try_into()?,
+ blossom: value.blossom.try_into()?,
+ media_network: value.media_network,
+ local_storage: value.local_storage.try_into()?,
+ };
+ settings.validate()?;
+ Ok(settings)
+ }
+}
+
+impl TryFrom<StoredSettingsV0> for MobileSettings {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredSettingsV0) -> Result<Self, Self::Error> {
+ if value.schema_version != 0 || value.revision == 0 {
+ return Err(SettingsError::CorruptDocument);
+ }
+ let settings = Self {
+ revision: value.revision,
+ identity: value.identity.try_into()?,
+ relays: value.relays.try_into()?,
+ blossom: value.blossom.try_into()?,
+ media_network: MediaNetworkPolicy::new(
+ value.allow_cellular_downloads,
+ value.allow_cellular_uploads,
+ false,
+ ),
+ local_storage: LocalStoragePolicy::new(
+ value.media_cache_bytes,
+ value.media_cache_artifacts,
+ )?,
+ };
+ settings.validate()?;
+ Ok(settings)
+ }
+}
+
+impl From<&IdentityState> for StoredIdentity {
+ fn from(value: &IdentityState) -> Self {
+ Self {
+ identities: value
+ .identities
+ .iter()
+ .map(|identity| StoredIdentityRecord {
+ id: identity.id.clone(),
+ public_key_hex: identity.public_key_hex.clone(),
+ })
+ .collect(),
+ active_identity_id: value.active_identity_id.clone(),
+ lock_state: IdentityLockState::Locked,
+ pending_import_operation_id: value.pending_import_operation_id.clone(),
+ }
+ }
+}
+
+impl TryFrom<StoredIdentity> for IdentityState {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredIdentity) -> Result<Self, Self::Error> {
+ let identities = value
+ .identities
+ .into_iter()
+ .map(|identity| IdentityRecord::new(identity.id, &identity.public_key_hex))
+ .collect::<Result<Vec<_>, _>>()?;
+ IdentityState::new(
+ identities,
+ value.active_identity_id,
+ IdentityLockState::Locked,
+ value.pending_import_operation_id,
+ )
+ .map_err(SettingsError::from)
+ }
+}
+
+impl From<&RelayPreferences> for StoredRelays {
+ fn from(value: &RelayPreferences) -> Self {
+ Self {
+ environment: value.environment,
+ endpoints: value
+ .endpoints
+ .iter()
+ .map(|endpoint| StoredRelayEndpoint {
+ url: endpoint.url.clone(),
+ access: endpoint.access.as_str().to_owned(),
+ })
+ .collect(),
+ }
+ }
+}
+
+impl TryFrom<StoredRelays> for RelayPreferences {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredRelays) -> Result<Self, Self::Error> {
+ let endpoints = value
+ .endpoints
+ .into_iter()
+ .map(|endpoint| {
+ RelayEndpointPreference::new(
+ value.environment,
+ endpoint.url,
+ RelayAccessPreference::parse(&endpoint.access)?,
+ )
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ Self::new(value.environment, endpoints)
+ }
+}
+
+impl From<&BlossomPreferences> for StoredBlossom {
+ fn from(value: &BlossomPreferences) -> Self {
+ Self {
+ environment: value.environment,
+ authority: value.authority,
+ primary_origin: value.primary_origin.clone(),
+ fallback_origins: value.fallback_origins.clone(),
+ }
+ }
+}
+
+impl TryFrom<StoredBlossom> for BlossomPreferences {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredBlossom) -> Result<Self, Self::Error> {
+ Self::new(
+ value.environment,
+ value.authority,
+ value.primary_origin,
+ value.fallback_origins,
+ )
+ }
+}
+
+impl From<&LocalStoragePolicy> for StoredLocalStorage {
+ fn from(value: &LocalStoragePolicy) -> Self {
+ Self {
+ media_cache_bytes: value.media_cache_bytes,
+ media_cache_artifacts: value.media_cache_artifacts,
+ }
+ }
+}
+
+impl TryFrom<StoredLocalStorage> for LocalStoragePolicy {
+ type Error = SettingsError;
+
+ fn try_from(value: StoredLocalStorage) -> Result<Self, Self::Error> {
+ Self::new(value.media_cache_bytes, value.media_cache_artifacts)
+ }
+}
+
+fn settings_projection_id() -> Result<ProjectionId, SettingsError> {
+ ProjectionId::parse(SETTINGS_PROJECTION_ID).map_err(|_| SettingsError::CorruptDocument)
+}
+
+fn settings_generation() -> Result<ProjectionGeneration, SettingsError> {
+ ProjectionGeneration::new(Sha256::digest(SETTINGS_GENERATION_DOMAIN).into())
+ .map_err(|_| SettingsError::CorruptDocument)
+}
+
+fn validate_identifier(value: &str, max_bytes: usize) -> bool {
+ !value.is_empty()
+ && value.len() <= max_bytes
+ && value == value.trim()
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
+}
+
+fn validate_profile_text(value: &str, max_bytes: usize, allow_empty: bool) -> bool {
+ value.len() <= max_bytes
+ && (allow_empty || !value.trim().is_empty())
+ && !value.chars().any(char::is_control)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ };
+
+ const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+
+ #[test]
+ fn identity_transitions_never_accept_or_serialize_private_key_material() {
+ let state = IdentityState::default()
+ .apply(IdentityCommand::BeginImport {
+ operation_id: "import:1".to_owned(),
+ })
+ .unwrap()
+ .apply(IdentityCommand::CompleteImport {
+ operation_id: "import:1".to_owned(),
+ identity: IdentityRecord::new("primary", PUBLIC_KEY).unwrap(),
+ })
+ .unwrap();
+ assert_eq!(state.active_identity_id(), Some("primary"));
+ assert_eq!(state.lock_state(), IdentityLockState::Locked);
+ let settings = MobileSettings::default().with_identity(state);
+ let encoded = String::from_utf8(encode_settings(&settings).unwrap()).unwrap();
+ assert!(encoded.contains(PUBLIC_KEY));
+ assert!(!encoded.contains("private"));
+ assert!(!encoded.contains("secret"));
+ }
+
+ #[test]
+ fn durable_identity_state_always_reopens_locked() {
+ let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap();
+ let state = IdentityState::new(
+ vec![identity],
+ Some("primary".to_owned()),
+ IdentityLockState::Unlocked,
+ None,
+ )
+ .unwrap();
+ let settings = MobileSettings::default().with_identity(state);
+ let encoded = encode_settings(&settings).unwrap();
+ assert!(!String::from_utf8_lossy(&encoded).contains("unlocked"));
+ assert_eq!(
+ decode_settings(&encoded).unwrap().identity().lock_state(),
+ IdentityLockState::Locked
+ );
+ }
+
+ #[test]
+ fn unknown_relay_access_fails_closed_during_decode() {
+ let mut stored = StoredSettingsV1::from(&MobileSettings::default());
+ stored.relays.endpoints[0].access = "write".to_owned();
+ let encoded = serde_json::to_vec(&stored).unwrap();
+ assert_eq!(
+ decode_settings(&encoded).unwrap_err(),
+ SettingsError::UnknownRelayAccess
+ );
+ }
+
+ #[test]
+ fn validated_relay_preferences_preserve_read_only_access() {
+ let preferences = RelayPreferences::new(
+ MobileNetworkEnvironment::Public,
+ vec![
+ RelayEndpointPreference::new(
+ MobileNetworkEnvironment::Public,
+ "wss://read.example",
+ RelayAccessPreference::ReadOnly,
+ )
+ .unwrap(),
+ ],
+ )
+ .unwrap();
+ let profile = preferences.sdk_profile().unwrap();
+ assert_eq!(profile.endpoints().len(), 1);
+ assert!(!profile.endpoints()[0].access().can_write());
+ }
+
+ #[test]
+ fn production_and_simulator_defaults_use_canonical_origins() {
+ let production = MobileSettings::default();
+ assert_eq!(
+ production.relays().endpoints()[0].url(),
+ DEFAULT_PUBLIC_RELAY
+ );
+ assert_eq!(
+ production.blossom().primary_origin(),
+ DEFAULT_PUBLIC_BLOSSOM_ORIGIN
+ );
+ assert_eq!(
+ RelayPreferences::simulator_default().endpoints()[0].url(),
+ DEFAULT_SIMULATOR_RELAY
+ );
+ assert_eq!(
+ BlossomPreferences::simulator_default().primary_origin(),
+ DEFAULT_SIMULATOR_BLOSSOM_ORIGIN
+ );
+ }
+
+ #[test]
+ fn version_zero_migrates_background_transfers_to_disabled() {
+ let current = StoredSettingsV1::from(&MobileSettings::default());
+ let legacy = serde_json::json!({
+ "schema_version": 0,
+ "revision": current.revision,
+ "identity": current.identity,
+ "relays": current.relays,
+ "blossom": current.blossom,
+ "allow_cellular_downloads": true,
+ "allow_cellular_uploads": false,
+ "media_cache_bytes": current.local_storage.media_cache_bytes,
+ "media_cache_artifacts": current.local_storage.media_cache_artifacts,
+ });
+ let migrated = decode_settings(&serde_json::to_vec(&legacy).unwrap()).unwrap();
+ assert!(!migrated.media_network().allow_background_transfers());
+ assert!(!migrated.media_network().allow_cellular_uploads());
+ }
+
+ #[test]
+ fn future_or_unknown_fields_fail_safely() {
+ let mut future =
+ serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap();
+ future["schema_version"] = serde_json::json!(2);
+ assert_eq!(
+ decode_settings(&serde_json::to_vec(&future).unwrap()).unwrap_err(),
+ SettingsError::UnsupportedSchema
+ );
+
+ let mut unknown =
+ serde_json::to_value(StoredSettingsV1::from(&MobileSettings::default())).unwrap();
+ unknown["write_all_relays"] = serde_json::json!(true);
+ assert_eq!(
+ decode_settings(&serde_json::to_vec(&unknown).unwrap()).unwrap_err(),
+ SettingsError::CorruptDocument
+ );
+ }
+
+ #[test]
+ fn profile_metadata_command_validates_the_adopted_kind_zero_fields() {
+ let command = ProfileMetadataCommand::new(
+ "grower".to_owned(),
+ Some("Local Grower".to_owned()),
+ Some("Seasonal produce".to_owned()),
+ None,
+ None,
+ Some("grower@farm.example".to_owned()),
+ Some(false),
+ )
+ .unwrap();
+ assert_eq!(command.authored().name(), "grower");
+ assert_eq!(
+ command.authored().nip05().map(Nip05Identifier::as_str),
+ Some("grower@farm.example")
+ );
+ assert_eq!(
+ ProfileMetadataCommand::new(
+ "grower".to_owned(),
+ None,
+ None,
+ None,
+ None,
+ Some("GROWER@farm.example".to_owned()),
+ None,
+ )
+ .unwrap_err()
+ .code(),
+ "invalid_profile_nip05"
+ );
+ }
+
+ #[tokio::test]
+ async fn settings_are_revision_checked_persisted_and_report_exact_effects() {
+ let runtime = RadrootsRuntime::test_memory().unwrap();
+ let settings = runtime.phase1_settings().await.unwrap();
+ let next = settings
+ .clone()
+ .with_media_network(MediaNetworkPolicy::new(false, true, false));
+ let transition = runtime
+ .phase1_replace_settings(ReplaceMobileSettings::new(settings.revision(), next).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(transition.settings.revision(), 2);
+ assert!(!transition.runtime_restart_required);
+ assert!(!transition.outbox_requeue_required);
+ assert!(transition.media_cache_invalidation_required);
+ assert_eq!(
+ runtime.phase1_settings().await.unwrap(),
+ transition.settings
+ );
+
+ let conflict = runtime
+ .phase1_replace_settings(
+ ReplaceMobileSettings::new(settings.revision(), settings).unwrap(),
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(conflict, SettingsError::RevisionConflict);
+ }
+
+ #[test]
+ fn settings_reject_mixed_network_environments() {
+ let settings =
+ MobileSettings::default().with_blossom(BlossomPreferences::simulator_default());
+ assert_eq!(
+ ReplaceMobileSettings::new(settings.revision(), settings).unwrap_err(),
+ SettingsError::NetworkEnvironmentMismatch
+ );
+ }
+
+ #[tokio::test]
+ async fn atomic_identity_commands_persist_public_state_but_keep_unlock_process_local() {
+ let runtime = RadrootsRuntime::test_memory().unwrap();
+ let begun = runtime
+ .phase1_apply_identity_command(
+ 1,
+ IdentityCommand::BeginImport {
+ operation_id: "import-1".to_owned(),
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(begun.settings.revision(), 2);
+ assert_eq!(
+ begun.settings.identity().pending_import_operation_id(),
+ Some("import-1")
+ );
+
+ let completed = runtime
+ .phase1_apply_identity_command(
+ 2,
+ IdentityCommand::CompleteImport {
+ operation_id: "import-1".to_owned(),
+ identity: IdentityRecord::new(
+ "primary",
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ )
+ .unwrap(),
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(completed.settings.revision(), 3);
+ assert_eq!(
+ completed.settings.identity().active_identity_id(),
+ Some("primary")
+ );
+
+ let unlocked = runtime
+ .phase1_apply_identity_command(3, IdentityCommand::Unlock)
+ .await
+ .unwrap();
+ assert_eq!(unlocked.settings.revision(), 3);
+ assert_eq!(
+ unlocked.settings.identity().lock_state(),
+ IdentityLockState::Unlocked
+ );
+ assert_eq!(
+ runtime
+ .phase1_settings()
+ .await
+ .unwrap()
+ .identity()
+ .lock_state(),
+ IdentityLockState::Unlocked
+ );
+
+ let locked = runtime
+ .phase1_apply_identity_command(3, IdentityCommand::Lock)
+ .await
+ .unwrap();
+ assert_eq!(locked.settings.revision(), 4);
+ assert_eq!(
+ locked.settings.identity().lock_state(),
+ IdentityLockState::Locked
+ );
+ }
+
+ #[tokio::test]
+ async fn concurrent_replacements_cannot_both_commit_the_same_revision() {
+ let runtime = RadrootsRuntime::test_memory().unwrap();
+ let settings = runtime.phase1_settings().await.unwrap();
+ let first = ReplaceMobileSettings::new(
+ settings.revision(),
+ settings
+ .clone()
+ .with_media_network(MediaNetworkPolicy::new(false, true, true)),
+ )
+ .unwrap();
+ let second = ReplaceMobileSettings::new(
+ settings.revision(),
+ settings.with_media_network(MediaNetworkPolicy::new(true, false, true)),
+ )
+ .unwrap();
+
+ let (first, second) = tokio::join!(
+ runtime.phase1_replace_settings(first),
+ runtime.phase1_replace_settings(second)
+ );
+ assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1);
+ let failure = first.err().or_else(|| second.err()).unwrap();
+ assert_eq!(failure, SettingsError::RevisionConflict);
+ }
+
+ #[tokio::test]
+ async fn sqlite_settings_survive_a_runtime_restart() {
+ let root = tempfile::tempdir().unwrap();
+ let store = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ PUBLIC_KEY,
+ "0303030303030303030303030303030303030303030303030303030303030303",
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .unwrap();
+ std::fs::create_dir_all(store.owner_directory()).unwrap();
+ let runtime = RuntimeBuilder::new(store.clone()).build().await.unwrap();
+ let settings = runtime.phase1_settings().await.unwrap();
+ let transition = runtime
+ .phase1_replace_settings(
+ ReplaceMobileSettings::new(
+ settings.revision(),
+ settings.with_media_network(MediaNetworkPolicy::new(false, false, false)),
+ )
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ runtime.shutdown().await.unwrap();
+ drop(runtime);
+
+ let reopened = RuntimeBuilder::new(store).build().await.unwrap();
+ assert_eq!(
+ reopened.phase1_settings().await.unwrap(),
+ transition.settings
+ );
+ reopened.shutdown().await.unwrap();
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs
@@ -0,0 +1,3776 @@
+use std::collections::BTreeMap;
+
+use radroots_event_codec::{
+ admission::{RadrootsAdmittedEvent, admit_verified_event},
+ verify::verify_nip01_event,
+};
+use radroots_storage::{
+ EventStore, ProjectionStore,
+ event::{
+ AdmissionReceipt, EventAdmission, EventPosition, EventQuery, EventQueryBounds,
+ EventSequence,
+ },
+ projection::{
+ ProjectionCheckpoint, ProjectionDocument, ProjectionGeneration, ProjectionId,
+ ProjectionSnapshot,
+ },
+};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+#[cfg(feature = "mobile-social")]
+use radroots_blossom::{BlobUrl, MediaType};
+#[cfg(feature = "mobile-social")]
+use radroots_event::admission::ContractValidatedEvent;
+#[cfg(feature = "mobile-social")]
+use radroots_sdk::transport::{
+ BlossomCancellation, BlossomError, BlossomImageDimensions, BlossomInboundRequest,
+};
+#[cfg(feature = "mobile-social")]
+use radroots_sync::{
+ PullRequest,
+ ingest::{AdmissionDecision, AdmissionPolicy},
+ pull::PullTermination,
+};
+#[cfg(feature = "mobile-social")]
+use radroots_transport::{
+ Target, outcome::FetchTargetState, source::FetchSelector, target::TargetSet,
+};
+
+#[cfg(feature = "mobile-social")]
+use super::Phase1LocalMediaArtifact;
+use super::{
+ CardId, CardLifecycleState, ClassifiedCard, CursorError, CursorScope, LocalAuthorOverlay,
+ LocalNetwork, LocalityEvidence, MeSnapshot, MediaReference, Phase1InboundMediaError,
+ Phase1InboundMediaFailure, Phase1InboundMediaPending, Phase1InboundMediaState,
+ Phase1MediaArtifactId, Phase1MediaCacheIndex, Phase1MediaCacheStatus,
+ Phase1MediaConfigurationFingerprint, Phase1StructuralMediaReference,
+ ProductEventClassification, ProfileSummary, SearchResult, SearchResultType, SupportingProfile,
+ ThreadEntry, ThreadReference, TimeRelevance, TodayCard, TodayCardType, TodayCursor,
+ TodayCursorPosition, TodayPage, TodayRank, TodayRankInput, classify_admitted_event,
+};
+#[cfg(any(feature = "mobile-social", test))]
+use super::{Phase1MediaCachePolicy, Phase1VerifiedMediaReceipt};
+use crate::runtime::RadrootsRuntime;
+
+const TODAY_PROJECTION_ID: &str = "radroots.today.v1";
+const TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION: u16 = 1;
+const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 1;
+const TODAY_PAGE_LIMIT_MAX: u16 = 100;
+const TODAY_SEARCH_LIMIT_MAX: u16 = 100;
+#[cfg(feature = "mobile-social")]
+const TODAY_SYNC_PAGE_LIMIT: u16 = 500;
+#[cfg(feature = "mobile-social")]
+const TODAY_SYNC_MAX_PAGES: u16 = 8;
+#[cfg(feature = "mobile-social")]
+const TODAY_SYNC_KINDS: [u32; 7] = [0, 1, 5, 1111, 30_402, 31_922, 31_923];
+const PROJECTION_GENERATION_DOMAIN: &[u8] = b"radroots.today-projection.v1\0";
+const PROJECTION_CONTENT_DOMAIN: &[u8] = b"radroots.today-content-generation.v1\0";
+const PROJECTION_DOCUMENT_KEY_DOMAIN: &[u8] = b"radroots.today-document-key.v1\0";
+const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v1\0";
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum TodayProjectionUpdate {
+ Incremental,
+ Rebuild,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodayRefreshReceipt {
+ pub update: TodayProjectionUpdate,
+ pub source_events: u64,
+ pub visible_cards: u64,
+ pub profiles: u64,
+ pub thread_entries: u64,
+ pub content_generation: u64,
+ pub changed: bool,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodayIngestReceipt {
+ pub event_id: String,
+ pub disposition: String,
+ pub source_sequence: u64,
+ pub projection: TodayRefreshReceipt,
+}
+
+#[cfg(feature = "mobile-social")]
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "PascalCase")]
+pub enum TodayRelaySyncState {
+ Complete,
+ Partial,
+ Offline,
+}
+
+#[cfg(feature = "mobile-social")]
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TodaySyncReceipt {
+ pub relay_state: TodayRelaySyncState,
+ pub pages_fetched: u16,
+ pub events_observed: u64,
+ pub events_admitted: u64,
+ pub events_rejected: u64,
+ pub projection: TodayRefreshReceipt,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TodayPageRequest {
+ pub limit: u16,
+ pub as_of: Option<u64>,
+ pub cursor: Option<String>,
+}
+
+impl TodayPageRequest {
+ pub const fn first(limit: u16, as_of: u64) -> Self {
+ Self {
+ limit,
+ as_of: Some(as_of),
+ cursor: None,
+ }
+ }
+
+ pub fn after(limit: u16, cursor: String) -> Self {
+ Self {
+ limit,
+ as_of: None,
+ cursor: Some(cursor),
+ }
+ }
+}
+
+#[derive(Debug, Error)]
+pub enum TodayError {
+ #[error("today runtime is unavailable")]
+ RuntimeUnavailable,
+ #[error("today request is invalid")]
+ InvalidRequest,
+ #[error("today projection has not been refreshed")]
+ ProjectionMissing,
+ #[error("today frozen snapshot is unavailable")]
+ SnapshotMissing,
+ #[error("today cursor position is absent from its frozen snapshot")]
+ CursorPositionMissing,
+ #[error("today event was not admitted as visible")]
+ EventNotVisible,
+ #[error("today projection state is corrupt")]
+ CorruptProjection,
+ #[error(transparent)]
+ Cursor(#[from] CursorError),
+ #[error(transparent)]
+ Storage(#[from] radroots_storage::Error),
+ #[error("today projection serialization failed")]
+ Serialization,
+ #[error(transparent)]
+ InboundMedia(#[from] Phase1InboundMediaError),
+ #[cfg(feature = "mobile-social")]
+ #[error(transparent)]
+ InboundRetrieval(#[from] BlossomError),
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ProjectedCard {
+ card: ClassifiedCard,
+ locality: Vec<LocalityTag>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct LocalityTag {
+ kind: String,
+ value: String,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct TodayProjectionState {
+ schema_version: u16,
+ context_id: String,
+ context_generation: u64,
+ store_generation: [u8; 32],
+ source_events: u64,
+ content_generation: u64,
+ cards: Vec<ProjectedCard>,
+ profiles: BTreeMap<String, ProfileSummary>,
+ thread: Vec<ThreadEntry>,
+ overlays: BTreeMap<String, LocalAuthorOverlay>,
+ #[serde(default)]
+ media_cache: Phase1MediaCacheIndex,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct FrozenTodaySnapshot {
+ schema_version: u16,
+ context_id: String,
+ context_generation: u64,
+ as_of: u64,
+ store_generation: [u8; 32],
+ projection_generation: u64,
+ items: Vec<TodayCard>,
+}
+
+impl RadrootsRuntime {
+ /// Pulls bounded Today-relevant relay pages, canonically admits valid
+ /// observations, and materializes the selected LocalNetwork projection.
+ #[cfg(feature = "mobile-social")]
+ pub async fn phase1_sync_today(
+ &self,
+ context: &LocalNetwork,
+ now_unix_seconds: u64,
+ update: TodayProjectionUpdate,
+ ) -> Result<TodaySyncReceipt, TodayError> {
+ if now_unix_seconds == 0 {
+ return Err(TodayError::InvalidRequest);
+ }
+ let targets = context
+ .relay_urls
+ .iter()
+ .map(Target::nostr_relay)
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| TodayError::InvalidRequest)?;
+ let targets = TargetSet::new(targets).map_err(|_| TodayError::InvalidRequest)?;
+ let selector = FetchSelector::all()
+ .with_kinds(TODAY_SYNC_KINDS.to_vec())
+ .map_err(|_| TodayError::InvalidRequest)?;
+ let request = PullRequest::new(targets, TODAY_SYNC_PAGE_LIMIT, TODAY_SYNC_MAX_PAGES)
+ .map_err(|_| TodayError::RuntimeUnavailable)?
+ .with_selector(selector);
+ let sync = self
+ .client
+ .sync()
+ .map_err(|_| TodayError::RuntimeUnavailable)?
+ .ok_or(TodayError::RuntimeUnavailable)?;
+ let pull = sync
+ .pull(request, &TodayAdmissionPolicy)
+ .await
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let projection = self
+ .phase1_refresh_today(context, now_unix_seconds, update)
+ .await?;
+ let events_admitted = pull
+ .ingest_outcomes()
+ .iter()
+ .filter(|outcome| outcome.is_ok())
+ .count() as u64;
+ let events_observed = u64::try_from(pull.events_observed()).unwrap_or(u64::MAX);
+ let events_rejected = events_observed.saturating_sub(events_admitted);
+ let target_complete = !pull.target_outcomes().is_empty()
+ && pull
+ .target_outcomes()
+ .iter()
+ .all(|outcome| outcome.state() == FetchTargetState::Complete);
+ let relay_state = match pull.termination() {
+ PullTermination::Complete if target_complete => TodayRelaySyncState::Complete,
+ PullTermination::SourceFailed if pull.pages_fetched() == 0 => {
+ TodayRelaySyncState::Offline
+ }
+ _ => TodayRelaySyncState::Partial,
+ };
+ Ok(TodaySyncReceipt {
+ relay_state,
+ pages_fetched: pull.pages_fetched(),
+ events_observed,
+ events_admitted,
+ events_rejected,
+ projection,
+ })
+ }
+
+ /// Durably admits one already verified and visibility-authorized relay observation,
+ /// then advances the selected LocalNetwork projection.
+ pub async fn phase1_ingest_visible(
+ &self,
+ admission: EventAdmission,
+ context: &LocalNetwork,
+ now_unix_seconds: u64,
+ ) -> Result<TodayIngestReceipt, TodayError> {
+ if admission.visible_event().is_none() {
+ return Err(TodayError::EventNotVisible);
+ }
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let receipt = EventStore::admit(storage, admission).await?;
+ let projection = self
+ .phase1_refresh_today(
+ context,
+ now_unix_seconds,
+ TodayProjectionUpdate::Incremental,
+ )
+ .await?;
+ Ok(ingest_receipt(receipt, projection))
+ }
+
+ /// Materializes current visible event truth for one LocalNetwork.
+ pub async fn phase1_refresh_today(
+ &self,
+ context: &LocalNetwork,
+ now_unix_seconds: u64,
+ update: TodayProjectionUpdate,
+ ) -> Result<TodayRefreshReceipt, TodayError> {
+ if now_unix_seconds == 0 {
+ return Err(TodayError::InvalidRequest);
+ }
+ let requested_updated_at_unix_ms = now_unix_seconds
+ .checked_mul(1_000)
+ .ok_or(TodayError::InvalidRequest)?;
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ if update == TodayProjectionUpdate::Rebuild {
+ EventStore::rebuild_visibility(storage).await?;
+ }
+ let event_status = EventStore::status(storage).await?;
+ let generation = projection_generation()?;
+ let projection_id = projection_id()?;
+ let key = projection_document_key(context);
+ let prior = load_state(storage, context, generation).await?;
+
+ if update == TodayProjectionUpdate::Incremental
+ && prior
+ .as_ref()
+ .is_some_and(|state| state.source_events == event_status.raw_events())
+ {
+ let state = prior.expect("checked present");
+ return Ok(refresh_receipt(update, &state, false));
+ }
+
+ let visible = query_all_visible(storage).await?;
+ let local_media = prior.as_ref().map(local_media_evidence).unwrap_or_default();
+ let overlays = prior
+ .as_ref()
+ .map_or_else(BTreeMap::new, |state| state.overlays.clone());
+ let media_cache =
+ prior.map_or_else(Phase1MediaCacheIndex::default, |state| state.media_cache);
+ let mut state = project_state(
+ context,
+ event_status.generation().as_bytes(),
+ event_status.raw_events(),
+ visible,
+ overlays,
+ )?;
+ state.media_cache = media_cache;
+ apply_local_media_evidence(&mut state, &local_media);
+ state.content_generation = content_generation(&state)?;
+ let encoded = encode(&state)?;
+ let changed = ProjectionStore::projection_document(
+ storage,
+ projection_id.clone(),
+ generation,
+ key.clone(),
+ )
+ .await?
+ .is_none_or(|document| document.value() != encoded);
+ ProjectionStore::put_projection_document(
+ storage,
+ projection_id.clone(),
+ generation,
+ ProjectionDocument::new(key, encoded)?,
+ )
+ .await?;
+
+ let source_position = if event_status.raw_events() == 0 {
+ None
+ } else {
+ Some(EventPosition::new(
+ event_status.generation(),
+ EventSequence::new(event_status.raw_events())?,
+ ))
+ };
+ let prior_updated_at = ProjectionStore::status(storage, projection_id.clone())
+ .await?
+ .and_then(|status| {
+ status
+ .checkpoint()
+ .map(ProjectionCheckpoint::updated_at_unix_ms)
+ })
+ .unwrap_or(0);
+ let updated_at_unix_ms = requested_updated_at_unix_ms.max(prior_updated_at);
+ ProjectionStore::checkpoint(
+ storage,
+ ProjectionCheckpoint::new(
+ projection_id,
+ generation,
+ source_position,
+ event_status.raw_events(),
+ updated_at_unix_ms,
+ )?,
+ )
+ .await?;
+ Ok(refresh_receipt(update, &state, changed))
+ }
+
+ /// Returns one page from a durable frozen Today snapshot.
+ pub async fn phase1_today_page(
+ &self,
+ context: &LocalNetwork,
+ request: TodayPageRequest,
+ ) -> Result<TodayPage, TodayError> {
+ if request.limit == 0 || request.limit > TODAY_PAGE_LIMIT_MAX {
+ return Err(TodayError::InvalidRequest);
+ }
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let event_status = EventStore::status(storage).await?;
+ let algorithm_generation = projection_generation()?;
+ let projection_id = projection_id()?;
+
+ let (scope, snapshot, after) = if let Some(cursor) = request.cursor.as_deref() {
+ let scope = TodayCursor::scope(cursor)?;
+ if scope.context_id != context.id || scope.context_generation != context.generation {
+ return Err(CursorError::ContextMismatch.into());
+ }
+ if request.as_of.is_some_and(|as_of| as_of != scope.as_of) {
+ return Err(CursorError::SnapshotMismatch.into());
+ }
+ if scope.store_generation != *event_status.generation().as_bytes() {
+ return Err(CursorError::Stale.into());
+ }
+ let position = TodayCursor::decode(cursor, &scope)?;
+ let mut snapshot = load_snapshot(storage, projection_id, algorithm_generation, &scope)
+ .await?
+ .ok_or(TodayError::SnapshotMissing)?;
+ let current = load_state(storage, context, algorithm_generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ sanitize_snapshot_media(&mut snapshot, ¤t.media_cache);
+ (scope, snapshot, Some(position.rank))
+ } else {
+ let as_of = request
+ .as_of
+ .filter(|value| *value != 0)
+ .ok_or(TodayError::InvalidRequest)?;
+ let state = load_state(storage, context, algorithm_generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ if state.store_generation != *event_status.generation().as_bytes() {
+ return Err(CursorError::Stale.into());
+ }
+ let scope = CursorScope::new(
+ context.id.clone(),
+ context.generation,
+ as_of,
+ state.store_generation,
+ state.content_generation,
+ )?;
+ let snapshot = frozen_snapshot(&state, context, as_of)?;
+ persist_snapshot(storage, algorithm_generation, &scope, &snapshot).await?;
+ (scope, snapshot, None)
+ };
+
+ page_from_snapshot(snapshot, scope, after, request.limit)
+ }
+
+ /// Searches the current local projection using Today visibility and context rules.
+ pub async fn phase1_search(
+ &self,
+ context: &LocalNetwork,
+ query: &str,
+ limit: u16,
+ as_of: u64,
+ ) -> Result<Vec<SearchResult>, TodayError> {
+ if limit == 0 || limit > TODAY_SEARCH_LIMIT_MAX || as_of == 0 {
+ return Err(TodayError::InvalidRequest);
+ }
+ let needle = query.trim().to_lowercase();
+ if needle.is_empty() || needle.len() > 256 || query.chars().any(char::is_control) {
+ return Err(TodayError::InvalidRequest);
+ }
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let state = load_state(storage, context, projection_generation()?)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let cards = ranked_cards(&state, context, as_of)?;
+ let mut results = Vec::new();
+ for card in cards {
+ let searchable = format!(
+ "{} {} {} {}",
+ card.card.title.as_deref().unwrap_or(""),
+ card.card.content,
+ card.card.author_pubkey,
+ card.author_profile
+ .as_ref()
+ .and_then(|profile| profile.display_name.as_deref().or(profile.name.as_deref()))
+ .unwrap_or("")
+ )
+ .to_lowercase();
+ if searchable.contains(&needle) {
+ results.push(SearchResult {
+ result_type: SearchResultType::Card,
+ stable_id: card.card.card_id.to_hex(),
+ card: Some(card),
+ profile: None,
+ });
+ if results.len() == usize::from(limit) {
+ return Ok(results);
+ }
+ }
+ }
+ for profile in state.profiles.values() {
+ let searchable = format!(
+ "{} {} {} {} {}",
+ profile.name.as_deref().unwrap_or(""),
+ profile.display_name.as_deref().unwrap_or(""),
+ profile.about.as_deref().unwrap_or(""),
+ profile.website.as_deref().unwrap_or(""),
+ profile.lightning_address.as_deref().unwrap_or("")
+ )
+ .to_lowercase();
+ if searchable.contains(&needle) {
+ results.push(SearchResult {
+ result_type: SearchResultType::Profile,
+ stable_id: profile.author_pubkey.clone(),
+ card: None,
+ profile: Some(profile.clone()),
+ });
+ if results.len() == usize::from(limit) {
+ break;
+ }
+ }
+ }
+ Ok(results)
+ }
+
+ /// Returns current active-identity attribution and visible Phase 1 content.
+ pub async fn phase1_me(
+ &self,
+ context: &LocalNetwork,
+ public_key: &str,
+ as_of: u64,
+ ) -> Result<MeSnapshot, TodayError> {
+ if !valid_public_key(public_key) || as_of == 0 {
+ return Err(TodayError::InvalidRequest);
+ }
+ if self
+ .authenticated_store_public_key_hex()
+ .is_some_and(|store_key| store_key != public_key)
+ {
+ return Err(TodayError::InvalidRequest);
+ }
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let state = load_state(storage, context, projection_generation()?)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let cards = ranked_cards(&state, context, as_of)?
+ .into_iter()
+ .filter(|card| card.card.author_pubkey == public_key)
+ .collect();
+ Ok(MeSnapshot {
+ public_key: public_key.to_owned(),
+ profile: state.profiles.get(public_key).cloned(),
+ cards,
+ })
+ }
+
+ /// Starts one typed retrieval for every occurrence of the exact structural
+ /// reference. URL equality alone is deliberately insufficient.
+ pub async fn phase1_begin_media_retrieval(
+ &self,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+ pending: Phase1InboundMediaPending,
+ ) -> Result<bool, TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let mut trial = state.clone();
+ let prior_configuration = trial.media_cache.status()?.configuration;
+ if prior_configuration.is_some_and(|value| value != pending.configuration()) {
+ return Err(Phase1InboundMediaError::ConfigurationMismatch.into());
+ }
+ trial
+ .media_cache
+ .invalidate_configuration(pending.configuration());
+ let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| {
+ media.begin(pending.clone())
+ })?;
+ if changed {
+ state = trial;
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ Ok(changed)
+ }
+
+ /// Records a bounded, safe retrieval failure for the active operation.
+ pub async fn phase1_fail_media_retrieval(
+ &self,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+ failure: Phase1InboundMediaFailure,
+ ) -> Result<bool, TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let changed = mutate_matching_media(&mut state, reference_fingerprint, |media| {
+ media.fail(failure.clone())
+ })?;
+ if changed {
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ Ok(changed)
+ }
+
+ /// Atomically binds exact-byte evidence to the matching reference and
+ /// admits its content-addressed cache entry under the active LRU quota.
+ #[cfg(any(feature = "mobile-social", test))]
+ pub(crate) async fn phase1_commit_media_receipt(
+ &self,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+ operation_id: [u8; 16],
+ receipt: Phase1VerifiedMediaReceipt,
+ policy: Phase1MediaCachePolicy,
+ cached_at_unix_ms: u64,
+ ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let mut trial = state.clone();
+ let changed = mutate_matching_media(&mut trial, reference_fingerprint, |media| {
+ media.verify(operation_id, receipt.clone())
+ })?;
+ if !changed {
+ return Err(TodayError::InvalidRequest);
+ }
+ let evicted = trial
+ .media_cache
+ .admit(&receipt, policy, cached_at_unix_ms)?;
+ for artifact_id in &evicted {
+ invalidate_artifact_references(&mut trial, *artifact_id);
+ }
+ state = trial;
+ persist_media_state(storage, context, generation, &mut state).await?;
+ Ok(evicted)
+ }
+
+ /// Records one successful local artifact access for deterministic LRU.
+ pub async fn phase1_touch_media_artifact(
+ &self,
+ context: &LocalNetwork,
+ artifact_id: Phase1MediaArtifactId,
+ observed_at_unix_ms: u64,
+ ) -> Result<bool, TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let changed = state.media_cache.touch(artifact_id, observed_at_unix_ms)?;
+ if changed {
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ Ok(changed)
+ }
+
+ /// Invalidates a missing, corrupt, or explicitly evicted local artifact.
+ pub async fn phase1_invalidate_media_artifact(
+ &self,
+ context: &LocalNetwork,
+ artifact_id: Phase1MediaArtifactId,
+ ) -> Result<bool, TodayError> {
+ #[cfg(feature = "mobile-social")]
+ let _guard = self.inbound_media_lock.lock().await;
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let cache_changed = state.media_cache.invalidate_artifact(artifact_id);
+ let references_changed = invalidate_artifact_references(&mut state, artifact_id);
+ if cache_changed || references_changed {
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ #[cfg(feature = "mobile-social")]
+ if let Some(directory) = self.inbound_media_directory.as_deref() {
+ super::media::remove_artifact_files(directory, artifact_id).await?;
+ }
+ Ok(cache_changed || references_changed)
+ }
+
+ /// Clears all trust derived under an obsolete endpoint/network/cache
+ /// configuration and records the new configuration generation.
+ pub async fn phase1_invalidate_media_configuration(
+ &self,
+ context: &LocalNetwork,
+ configuration: Phase1MediaConfigurationFingerprint,
+ ) -> Result<Vec<Phase1MediaArtifactId>, TodayError> {
+ #[cfg(feature = "mobile-social")]
+ let _guard = self.inbound_media_lock.lock().await;
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let prior_configuration = state.media_cache.status()?.configuration;
+ let removed = state.media_cache.invalidate_configuration(configuration);
+ if prior_configuration != Some(configuration) {
+ for_each_media_mut(&mut state, |media| {
+ media.invalidate();
+ });
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ #[cfg(feature = "mobile-social")]
+ if let Some(directory) = self.inbound_media_directory.as_deref() {
+ for artifact_id in &removed {
+ super::media::remove_artifact_files(directory, *artifact_id).await?;
+ }
+ }
+ Ok(removed)
+ }
+
+ pub async fn phase1_media_cache_status(
+ &self,
+ context: &LocalNetwork,
+ ) -> Result<Phase1MediaCacheStatus, TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let state = load_state(storage, context, projection_generation()?)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ state.media_cache.status().map_err(TodayError::from)
+ }
+
+ /// Resolves one renderable artifact only after rechecking the exact local
+ /// file. Missing or corrupt bytes atomically revoke all matching receipts.
+ #[cfg(feature = "mobile-social")]
+ pub async fn phase1_verified_media_artifact(
+ &self,
+ context: &LocalNetwork,
+ artifact_id: Phase1MediaArtifactId,
+ observed_at_unix_ms: u64,
+ ) -> Result<Option<Phase1LocalMediaArtifact>, TodayError> {
+ let _guard = self.inbound_media_lock.lock().await;
+ let directory = self
+ .inbound_media_directory
+ .as_deref()
+ .ok_or(Phase1InboundMediaError::CacheUnavailable)?;
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ let Some(receipt) = verified_receipt(&state, artifact_id) else {
+ return Ok(None);
+ };
+ match super::media::verified_artifact(directory, &receipt).await {
+ Ok(artifact) => {
+ if state.media_cache.touch(artifact_id, observed_at_unix_ms)? {
+ persist_media_state(storage, context, generation, &mut state).await?;
+ }
+ Ok(Some(artifact))
+ }
+ Err(error) => {
+ state.media_cache.invalidate_artifact(artifact_id);
+ invalidate_artifact_references(&mut state, artifact_id);
+ persist_media_state(storage, context, generation, &mut state).await?;
+ let _ = super::media::remove_artifact_files(directory, artifact_id).await;
+ Err(error.into())
+ }
+ }
+ }
+
+ /// Completes one bounded BUD-01 retrieval, exact-byte verification, and
+ /// atomic content-addressed cache commit under the configured Blossom slot.
+ #[cfg(feature = "mobile-social")]
+ pub async fn phase1_retrieve_media(
+ &self,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+ operation_id: [u8; 16],
+ policy: Phase1MediaCachePolicy,
+ cancellation: BlossomCancellation,
+ ) -> Result<Phase1LocalMediaArtifact, TodayError> {
+ let _guard = self.inbound_media_lock.lock().await;
+ let directory = self
+ .inbound_media_directory
+ .as_deref()
+ .ok_or(Phase1InboundMediaError::CacheUnavailable)?;
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(|_| TodayError::RuntimeUnavailable)?
+ .cloned()
+ .ok_or(TodayError::RuntimeUnavailable)?;
+ let sdk_configuration = blossom
+ .config_fingerprint()
+ .ok_or(TodayError::RuntimeUnavailable)?;
+ let configuration =
+ Phase1MediaConfigurationFingerprint::new(*sdk_configuration.as_bytes())?;
+ let structural = load_structural_reference(self, context, reference_fingerprint).await?;
+ let started_at_unix_ms = inbound_now_unix_ms()?;
+ self.phase1_begin_media_retrieval(
+ context,
+ reference_fingerprint,
+ Phase1InboundMediaPending::new(operation_id, configuration, started_at_unix_ms)?,
+ )
+ .await?;
+ let request = match inbound_request(&structural) {
+ Ok(request) => request,
+ Err(error) => {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ "invalid_reference",
+ false,
+ )
+ .await;
+ return Err(error);
+ }
+ };
+ let sdk_receipt = match blossom.retrieve(request, cancellation).await {
+ Ok(receipt) => receipt,
+ Err(error) => {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ error.code().trim_start_matches("blossom_"),
+ error.retryable(),
+ )
+ .await;
+ return Err(error.into());
+ }
+ };
+ if sdk_receipt.config_fingerprint() != sdk_configuration {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ "configuration_changed",
+ false,
+ )
+ .await;
+ return Err(Phase1InboundMediaError::ConfigurationMismatch.into());
+ }
+ let dimensions = sdk_receipt.dimensions();
+ let receipt = match Phase1VerifiedMediaReceipt::from_commitment(
+ &structural,
+ sdk_receipt.final_url().clone(),
+ sdk_receipt.commitment(),
+ dimensions.width(),
+ dimensions.height(),
+ configuration,
+ sdk_receipt.verified_at_unix_ms(),
+ ) {
+ Ok(receipt) => receipt,
+ Err(error) => {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ "verification_failed",
+ false,
+ )
+ .await;
+ return Err(error.into());
+ }
+ };
+ let artifact =
+ match super::media::write_verified_artifact(directory, &receipt, sdk_receipt.bytes())
+ .await
+ {
+ Ok(artifact) => artifact,
+ Err(error) => {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ "cache_write_failed",
+ true,
+ )
+ .await;
+ return Err(error.into());
+ }
+ };
+ let evicted = match self
+ .phase1_commit_media_receipt(
+ context,
+ reference_fingerprint,
+ operation_id,
+ receipt,
+ policy,
+ sdk_receipt.verified_at_unix_ms(),
+ )
+ .await
+ {
+ Ok(evicted) => evicted,
+ Err(error) => {
+ record_inbound_failure(
+ self,
+ context,
+ reference_fingerprint,
+ operation_id,
+ "cache_commit_failed",
+ true,
+ )
+ .await;
+ return Err(error);
+ }
+ };
+ for artifact_id in evicted {
+ super::media::remove_artifact_files(directory, artifact_id).await?;
+ }
+ Ok(artifact)
+ }
+
+ /// Persists active-author delivery state as a local-only Today overlay.
+ pub async fn phase1_set_local_author_overlay(
+ &self,
+ context: &LocalNetwork,
+ card_id: CardId,
+ overlay: Option<LocalAuthorOverlay>,
+ ) -> Result<(), TodayError> {
+ let storage = self
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let generation = projection_generation()?;
+ let mut state = load_state(storage, context, generation)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ if overlay.as_ref().is_some_and(|overlay| {
+ overlay.operation_id.is_empty()
+ || overlay.operation_id.len() > 256
+ || overlay.state.is_empty()
+ || overlay.state.len() > 96
+ || overlay.state.chars().any(char::is_control)
+ }) {
+ return Err(TodayError::InvalidRequest);
+ }
+ let key = card_id.to_hex();
+ let card = state
+ .cards
+ .iter()
+ .find(|projected| projected.card.card_id == card_id)
+ .ok_or(TodayError::InvalidRequest)?;
+ if self
+ .authenticated_store_public_key_hex()
+ .is_some_and(|store_key| store_key != card.card.author_pubkey)
+ {
+ return Err(TodayError::InvalidRequest);
+ }
+ if let Some(overlay) = overlay {
+ state.overlays.insert(key, overlay);
+ } else {
+ state.overlays.remove(&key);
+ }
+ state.content_generation = content_generation(&state)?;
+ store_state(storage, context, generation, &state).await
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+async fn load_structural_reference(
+ runtime: &RadrootsRuntime,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+) -> Result<Phase1StructuralMediaReference, TodayError> {
+ let storage = runtime
+ .client
+ .storage()
+ .map_err(|_| TodayError::RuntimeUnavailable)?;
+ let state = load_state(storage, context, projection_generation()?)
+ .await?
+ .ok_or(TodayError::ProjectionMissing)?;
+ state
+ .cards
+ .iter()
+ .flat_map(|projected| projected.card.media.iter())
+ .chain(
+ state
+ .profiles
+ .values()
+ .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()),
+ )
+ .find(|media| media.structural().fingerprint() == &reference_fingerprint)
+ .map(|media| media.structural().clone())
+ .ok_or(TodayError::InvalidRequest)
+}
+
+#[cfg(feature = "mobile-social")]
+fn inbound_request(
+ structural: &Phase1StructuralMediaReference,
+) -> Result<BlossomInboundRequest, TodayError> {
+ let url = BlobUrl::parse(structural.source_url())
+ .map_err(|_| Phase1InboundMediaError::InvalidReference)?;
+ let media_type = structural
+ .expected_media_type()
+ .map(MediaType::parse)
+ .transpose()
+ .map_err(|_| Phase1InboundMediaError::InvalidMediaType)?;
+ let dimensions = match (structural.expected_width(), structural.expected_height()) {
+ (Some(width), Some(height)) => Some(BlossomImageDimensions::new(width, height)?),
+ (None, None) => None,
+ _ => return Err(Phase1InboundMediaError::InvalidDimensions.into()),
+ };
+ BlossomInboundRequest::new(url, media_type, structural.expected_byte_size(), dimensions)
+ .map_err(TodayError::from)
+}
+
+#[cfg(feature = "mobile-social")]
+fn inbound_now_unix_ms() -> Result<u64, TodayError> {
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .and_then(|duration| u64::try_from(duration.as_millis()).ok())
+ .filter(|value| *value != 0)
+ .ok_or(TodayError::RuntimeUnavailable)
+}
+
+#[cfg(feature = "mobile-social")]
+async fn record_inbound_failure(
+ runtime: &RadrootsRuntime,
+ context: &LocalNetwork,
+ reference_fingerprint: [u8; 32],
+ operation_id: [u8; 16],
+ safe_code: &str,
+ retryable: bool,
+) {
+ let Ok(failed_at_unix_ms) = inbound_now_unix_ms() else {
+ return;
+ };
+ let Ok(failure) =
+ Phase1InboundMediaFailure::new(operation_id, safe_code, retryable, failed_at_unix_ms)
+ else {
+ return;
+ };
+ let _ = runtime
+ .phase1_fail_media_retrieval(context, reference_fingerprint, failure)
+ .await;
+}
+
+#[cfg(feature = "mobile-social")]
+struct TodayAdmissionPolicy;
+
+#[cfg(feature = "mobile-social")]
+impl AdmissionPolicy for TodayAdmissionPolicy {
+ fn policy_id(&self) -> &'static str {
+ "radroots.mobile.today.v1"
+ }
+
+ fn select_contract(
+ &self,
+ event: &radroots_event::admission::SignatureVerifiedEvent,
+ ) -> Option<&'static str> {
+ verify_nip01_event(event.event().clone())
+ .ok()
+ .and_then(|event| admit_verified_event(event).ok())
+ .map(|event| event.contract().id)
+ }
+
+ fn decide(&self, event: &ContractValidatedEvent) -> AdmissionDecision {
+ let admitted = verify_nip01_event(event.event().clone())
+ .ok()
+ .and_then(|event| admit_verified_event(event).ok());
+ if admitted.is_some() {
+ AdmissionDecision::Visible
+ } else {
+ AdmissionDecision::Reject
+ }
+ }
+}
+
+fn ingest_receipt(
+ receipt: AdmissionReceipt,
+ projection: TodayRefreshReceipt,
+) -> TodayIngestReceipt {
+ TodayIngestReceipt {
+ event_id: receipt.event_id().to_hex(),
+ disposition: format!("{:?}", receipt.disposition()).to_lowercase(),
+ source_sequence: receipt.position().sequence().get(),
+ projection,
+ }
+}
+
+fn refresh_receipt(
+ update: TodayProjectionUpdate,
+ state: &TodayProjectionState,
+ changed: bool,
+) -> TodayRefreshReceipt {
+ TodayRefreshReceipt {
+ update,
+ source_events: state.source_events,
+ visible_cards: state.cards.len().try_into().unwrap_or(u64::MAX),
+ profiles: state.profiles.len().try_into().unwrap_or(u64::MAX),
+ thread_entries: state.thread.len().try_into().unwrap_or(u64::MAX),
+ content_generation: state.content_generation,
+ changed,
+ }
+}
+
+fn local_media_evidence(
+ state: &TodayProjectionState,
+) -> BTreeMap<[u8; 32], Phase1InboundMediaState> {
+ let mut evidence = state
+ .cards
+ .iter()
+ .flat_map(|projected| projected.card.media.iter())
+ .filter(|media| !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable))
+ .map(|media| (*media.structural().fingerprint(), media.retrieval().clone()))
+ .collect::<BTreeMap<_, _>>();
+ for profile in state.profiles.values() {
+ for media in [&profile.picture, &profile.banner].into_iter().flatten() {
+ if !matches!(media.retrieval(), Phase1InboundMediaState::Unavailable) {
+ evidence.insert(*media.structural().fingerprint(), media.retrieval().clone());
+ }
+ }
+ }
+ evidence
+}
+
+fn apply_local_media_evidence(
+ state: &mut TodayProjectionState,
+ evidence: &BTreeMap<[u8; 32], Phase1InboundMediaState>,
+) {
+ let cache = state.media_cache.clone();
+ for_each_media_mut(state, |media| {
+ if let Some(retrieval) = evidence.get(media.structural().fingerprint())
+ && media.restore(retrieval.clone(), &cache).is_err()
+ {
+ media.invalidate();
+ }
+ });
+ refresh_thread_profiles(state);
+}
+
+fn for_each_media_mut(
+ state: &mut TodayProjectionState,
+ mut action: impl FnMut(&mut MediaReference),
+) {
+ for projected in &mut state.cards {
+ for media in &mut projected.card.media {
+ action(media);
+ }
+ }
+ for profile in state.profiles.values_mut() {
+ for media in [&mut profile.picture, &mut profile.banner]
+ .into_iter()
+ .flatten()
+ {
+ action(media);
+ }
+ }
+}
+
+fn mutate_matching_media(
+ state: &mut TodayProjectionState,
+ reference_fingerprint: [u8; 32],
+ mut action: impl FnMut(&mut MediaReference) -> Result<(), Phase1InboundMediaError>,
+) -> Result<bool, TodayError> {
+ let mut found = false;
+ let mut failure = None;
+ for_each_media_mut(state, |media| {
+ if failure.is_none() && media.structural().fingerprint() == &reference_fingerprint {
+ found = true;
+ if let Err(error) = action(media) {
+ failure = Some(error);
+ }
+ }
+ });
+ if let Some(error) = failure {
+ return Err(error.into());
+ }
+ if found {
+ refresh_thread_profiles(state);
+ }
+ Ok(found)
+}
+
+fn invalidate_artifact_references(
+ state: &mut TodayProjectionState,
+ artifact_id: Phase1MediaArtifactId,
+) -> bool {
+ let mut changed = false;
+ for_each_media_mut(state, |media| {
+ if matches!(
+ media.retrieval(),
+ Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id
+ ) {
+ media.invalidate();
+ changed = true;
+ }
+ });
+ if changed {
+ refresh_thread_profiles(state);
+ }
+ changed
+}
+
+#[cfg(feature = "mobile-social")]
+fn verified_receipt(
+ state: &TodayProjectionState,
+ artifact_id: Phase1MediaArtifactId,
+) -> Option<Phase1VerifiedMediaReceipt> {
+ state
+ .cards
+ .iter()
+ .flat_map(|projected| projected.card.media.iter())
+ .chain(
+ state
+ .profiles
+ .values()
+ .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()),
+ )
+ .find_map(|media| match media.retrieval() {
+ Phase1InboundMediaState::Verified(receipt) if receipt.artifact_id() == artifact_id => {
+ Some((**receipt).clone())
+ }
+ _ => None,
+ })
+}
+
+async fn persist_media_state(
+ storage: &dyn radroots_storage::Storage,
+ context: &LocalNetwork,
+ generation: ProjectionGeneration,
+ state: &mut TodayProjectionState,
+) -> Result<(), TodayError> {
+ refresh_thread_profiles(state);
+ validate_media_state(state)?;
+ state.content_generation = content_generation(state)?;
+ store_state(storage, context, generation, state).await
+}
+
+fn refresh_thread_profiles(state: &mut TodayProjectionState) {
+ for entry in &mut state.thread {
+ entry.author_profile = state.profiles.get(&entry.author_pubkey).cloned();
+ }
+}
+
+async fn query_all_visible(
+ storage: &dyn radroots_storage::Storage,
+) -> Result<Vec<radroots_storage::event::StoredVisibleEvent>, TodayError> {
+ let mut items = Vec::new();
+ let mut after = None;
+ loop {
+ let mut bounds = EventQueryBounds::first(radroots_storage::event::EVENT_QUERY_LIMIT_MAX)?;
+ if let Some(cursor) = after {
+ bounds = bounds.after(cursor);
+ }
+ let page = EventStore::query_visible(storage, EventQuery::all(bounds)).await?;
+ items.extend_from_slice(page.items());
+ let Some(next) = page.next_cursor() else {
+ break;
+ };
+ after = Some(next);
+ }
+ Ok(items)
+}
+
+fn project_state(
+ context: &LocalNetwork,
+ store_generation: &[u8; 32],
+ source_events: u64,
+ visible: Vec<radroots_storage::event::StoredVisibleEvent>,
+ overlays: BTreeMap<String, LocalAuthorOverlay>,
+) -> Result<TodayProjectionState, TodayError> {
+ let mut cards = Vec::new();
+ let mut profiles = BTreeMap::new();
+ let mut thread = Vec::new();
+ for stored in visible {
+ let verified = verify_nip01_event(stored.event().envelope().clone())
+ .map_err(|_| TodayError::CorruptProjection)?;
+ let admitted = admit_verified_event(verified).map_err(|_| TodayError::CorruptProjection)?;
+ match &admitted {
+ RadrootsAdmittedEvent::Profile(profile) => {
+ profiles.insert(
+ profile.event().author().to_hex(),
+ profile_summary(&admitted)?,
+ );
+ }
+ RadrootsAdmittedEvent::Reply(_) => {
+ thread.push(reply_entry(&admitted)?);
+ }
+ RadrootsAdmittedEvent::Comment(_) => {
+ if let Some(entry) = comment_entry(&admitted) {
+ thread.push(entry);
+ }
+ }
+ _ => {
+ let locality = locality_tags(admitted.event().tags_as_vec());
+ let evidence = locality_evidence(context.locality.as_deref(), &locality);
+ if let ProductEventClassification::Card(card) =
+ classify_admitted_event(&admitted, context.admit(evidence))
+ {
+ cards.push(ProjectedCard {
+ card: *card,
+ locality,
+ });
+ }
+ }
+ }
+ }
+ cards.sort_by_key(|projected| projected.card.card_id);
+ thread.sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ for entry in &mut thread {
+ entry.author_profile = profiles.get(&entry.author_pubkey).cloned();
+ }
+ Ok(TodayProjectionState {
+ schema_version: TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION,
+ context_id: context.id.clone(),
+ context_generation: context.generation,
+ store_generation: *store_generation,
+ source_events,
+ content_generation: 0,
+ cards,
+ profiles,
+ thread,
+ overlays,
+ media_cache: Phase1MediaCacheIndex::default(),
+ })
+}
+
+fn profile_summary(admitted: &RadrootsAdmittedEvent) -> Result<ProfileSummary, TodayError> {
+ let RadrootsAdmittedEvent::Profile(profile) = admitted else {
+ return Err(TodayError::CorruptProjection);
+ };
+ let metadata = profile.metadata();
+ Ok(ProfileSummary {
+ author_pubkey: profile.event().author().to_hex(),
+ name: metadata.name().map(str::to_owned),
+ display_name: metadata.display_name().map(str::to_owned),
+ about: metadata.about().map(str::to_owned),
+ picture: metadata
+ .picture()
+ .map(|value| unverified_media(value.as_str()))
+ .transpose()?,
+ banner: metadata
+ .banner()
+ .map(|value| unverified_media(value.as_str()))
+ .transpose()?,
+ nip05: metadata.nip05().map(|value| value.as_str().to_owned()),
+ website: typed_profile_extra(metadata.raw_fields(), "website"),
+ lightning_address: typed_profile_extra(metadata.raw_fields(), "lud16"),
+ })
+}
+
+fn typed_profile_extra(fields: &BTreeMap<String, serde_json::Value>, key: &str) -> Option<String> {
+ fields
+ .get(key)
+ .and_then(serde_json::Value::as_str)
+ .filter(|value| {
+ !value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control)
+ })
+ .map(str::to_owned)
+}
+
+fn unverified_media(url: &str) -> Result<MediaReference, TodayError> {
+ MediaReference::new(Phase1StructuralMediaReference::new(
+ url,
+ blossom_digest(url),
+ None,
+ None,
+ None,
+ None,
+ None,
+ )?)
+ .map_err(TodayError::from)
+}
+
+fn reply_entry(admitted: &RadrootsAdmittedEvent) -> Result<ThreadEntry, TodayError> {
+ let RadrootsAdmittedEvent::Reply(reply) = admitted else {
+ return Err(TodayError::CorruptProjection);
+ };
+ Ok(ThreadEntry {
+ event_id: reply.event().id_hex(),
+ author_pubkey: reply.event().author().to_hex(),
+ content: reply.event().content().to_owned(),
+ authored_at: reply.event().created_at_u64(),
+ reference: ThreadReference {
+ profile: SupportingProfile::Reply,
+ root: reply.projection().root().event_id().to_hex(),
+ parent_event_id: reply.projection().parent().event_id().to_hex(),
+ },
+ author_profile: None,
+ })
+}
+
+fn comment_entry(admitted: &RadrootsAdmittedEvent) -> Option<ThreadEntry> {
+ let RadrootsAdmittedEvent::Comment(comment) = admitted else {
+ return None;
+ };
+ let tags = comment.event().tags_as_vec();
+ let root = tag_value(&tags, &["E", "A"])?;
+ let parent = tag_value(&tags, &["e", "a"]).unwrap_or_else(|| root.clone());
+ Some(ThreadEntry {
+ event_id: comment.event().id_hex(),
+ author_pubkey: comment.event().author().to_hex(),
+ content: comment.event().content().to_owned(),
+ authored_at: comment.event().created_at_u64(),
+ reference: ThreadReference {
+ profile: SupportingProfile::Comment,
+ root,
+ parent_event_id: parent,
+ },
+ author_profile: None,
+ })
+}
+
+fn locality_tags(tags: Vec<Vec<String>>) -> Vec<LocalityTag> {
+ let mut locality = tags
+ .into_iter()
+ .filter_map(|tag| {
+ let kind = tag.first()?.as_str();
+ if !matches!(kind, "g" | "location") {
+ return None;
+ }
+ let value = tag.get(1)?.trim().to_lowercase();
+ (!value.is_empty()).then(|| LocalityTag {
+ kind: kind.to_owned(),
+ value,
+ })
+ })
+ .collect::<Vec<_>>();
+ locality.sort();
+ locality.dedup();
+ locality
+}
+
+fn locality_evidence(selected: Option<&str>, locality: &[LocalityTag]) -> LocalityEvidence {
+ let Some(selected) = selected else {
+ return LocalityEvidence::Missing;
+ };
+ if locality.is_empty() {
+ return LocalityEvidence::Missing;
+ }
+ let selected = selected.trim().to_lowercase();
+ if locality.iter().any(|tag| {
+ tag.value == selected
+ || (tag.kind == "g"
+ && (tag.value.starts_with(&selected) || selected.starts_with(&tag.value)))
+ }) {
+ LocalityEvidence::Match
+ } else {
+ LocalityEvidence::Nonmatch
+ }
+}
+
+fn ranked_cards(
+ state: &TodayProjectionState,
+ context: &LocalNetwork,
+ as_of: u64,
+) -> Result<Vec<TodayCard>, TodayError> {
+ let mut cards = Vec::new();
+ for projected in &state.cards {
+ let evidence = locality_evidence(context.locality.as_deref(), &projected.locality);
+ let admission = match context.admit(evidence) {
+ super::LocalNetworkAdmission::Included(admission) => admission,
+ super::LocalNetworkAdmission::Excluded { .. } => continue,
+ };
+ let mut card = projected.card.clone();
+ card.context_rank = admission.rank;
+ card.inclusion_reason = admission.reason.to_owned();
+ let time = match card.card_type {
+ TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => {
+ TimeRelevance::Published
+ }
+ TodayCardType::FoodAvailability => TimeRelevance::FoodAvailability {
+ active: card.lifecycle == CardLifecycleState::Active,
+ },
+ TodayCardType::Event => TimeRelevance::Event {
+ start: card.event_start.unwrap_or(card.effective_at),
+ end: card.event_end,
+ },
+ };
+ let rank = TodayRank::derive(TodayRankInput {
+ card_type: card.card_type,
+ context_rank: card.context_rank,
+ as_of,
+ effective_at: card.effective_at,
+ time,
+ card_id: card.card_id,
+ })
+ .map_err(|_| TodayError::CorruptProjection)?;
+ if card.card_type == TodayCardType::Event && rank.time_relevance_rank == 0 {
+ card.lifecycle = CardLifecycleState::Past;
+ }
+ card.rank = Some(rank);
+ let roots = [
+ card.source_event_id.as_str(),
+ card.source_address.as_deref().unwrap_or(""),
+ ];
+ let card_thread = state
+ .thread
+ .iter()
+ .filter(|entry| roots.contains(&entry.reference.root.as_str()))
+ .cloned()
+ .collect();
+ cards.push(TodayCard {
+ author_profile: state.profiles.get(&card.author_pubkey).cloned(),
+ local_overlay: state.overlays.get(&card.card_id.to_hex()).cloned(),
+ card,
+ thread: card_thread,
+ });
+ }
+ cards.sort_by_key(|card| card.card.rank.expect("assigned rank"));
+ Ok(cards)
+}
+
+fn frozen_snapshot(
+ state: &TodayProjectionState,
+ context: &LocalNetwork,
+ as_of: u64,
+) -> Result<FrozenTodaySnapshot, TodayError> {
+ Ok(FrozenTodaySnapshot {
+ schema_version: TODAY_SNAPSHOT_SCHEMA_VERSION,
+ context_id: context.id.clone(),
+ context_generation: context.generation,
+ as_of,
+ store_generation: state.store_generation,
+ projection_generation: state.content_generation,
+ items: ranked_cards(state, context, as_of)?,
+ })
+}
+
+fn page_from_snapshot(
+ snapshot: FrozenTodaySnapshot,
+ scope: CursorScope,
+ after: Option<TodayRank>,
+ limit: u16,
+) -> Result<TodayPage, TodayError> {
+ validate_snapshot(&snapshot, &scope)?;
+ let start = if let Some(after) = after {
+ snapshot
+ .items
+ .iter()
+ .position(|card| card.card.rank == Some(after))
+ .map(|index| index + 1)
+ .ok_or(TodayError::CursorPositionMissing)?
+ } else {
+ 0
+ };
+ let end = start
+ .saturating_add(usize::from(limit))
+ .min(snapshot.items.len());
+ let items = snapshot.items[start..end].to_vec();
+ let next_cursor = if end < snapshot.items.len() {
+ items
+ .last()
+ .and_then(|card| card.card.rank)
+ .map(|rank| TodayCursor::encode(&scope, TodayCursorPosition { rank }))
+ .transpose()?
+ .map(|cursor| cursor.as_str().to_owned())
+ } else {
+ None
+ };
+ Ok(TodayPage {
+ as_of: snapshot.as_of,
+ items,
+ next_cursor,
+ })
+}
+
+fn validate_snapshot(
+ snapshot: &FrozenTodaySnapshot,
+ scope: &CursorScope,
+) -> Result<(), TodayError> {
+ if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION
+ || snapshot.context_id != scope.context_id
+ || snapshot.context_generation != scope.context_generation
+ || snapshot.as_of != scope.as_of
+ || snapshot.store_generation != scope.store_generation
+ || snapshot.projection_generation != scope.projection_generation
+ {
+ return Err(TodayError::CorruptProjection);
+ }
+ Ok(())
+}
+
+async fn load_state(
+ storage: &dyn radroots_storage::Storage,
+ context: &LocalNetwork,
+ generation: ProjectionGeneration,
+) -> Result<Option<TodayProjectionState>, TodayError> {
+ let document = ProjectionStore::projection_document(
+ storage,
+ projection_id()?,
+ generation,
+ projection_document_key(context),
+ )
+ .await?;
+ let Some(document) = document else {
+ return Ok(None);
+ };
+ let (state, migrated) = decode_state_document(document.value())?;
+ if migrated {
+ store_state(storage, context, generation, &state).await?;
+ }
+ Ok(Some(state))
+}
+
+async fn store_state(
+ storage: &dyn radroots_storage::Storage,
+ context: &LocalNetwork,
+ generation: ProjectionGeneration,
+ state: &TodayProjectionState,
+) -> Result<(), TodayError> {
+ ProjectionStore::put_projection_document(
+ storage,
+ projection_id()?,
+ generation,
+ ProjectionDocument::new(projection_document_key(context), encode(state)?)?,
+ )
+ .await?;
+ Ok(())
+}
+
+async fn persist_snapshot(
+ storage: &dyn radroots_storage::Storage,
+ generation: ProjectionGeneration,
+ scope: &CursorScope,
+ snapshot: &FrozenTodaySnapshot,
+) -> Result<(), TodayError> {
+ ProjectionStore::put_projection_snapshot(
+ storage,
+ ProjectionSnapshot::new(
+ projection_id()?,
+ snapshot_id(scope),
+ generation,
+ scope
+ .as_of
+ .checked_mul(1_000)
+ .ok_or(TodayError::InvalidRequest)?,
+ encode(snapshot)?,
+ )?,
+ )
+ .await?;
+ Ok(())
+}
+
+async fn load_snapshot(
+ storage: &dyn radroots_storage::Storage,
+ projection_id: ProjectionId,
+ generation: ProjectionGeneration,
+ scope: &CursorScope,
+) -> Result<Option<FrozenTodaySnapshot>, TodayError> {
+ ProjectionStore::projection_snapshot(storage, projection_id, snapshot_id(scope))
+ .await?
+ .map(|snapshot| {
+ if snapshot.generation() != generation {
+ return Err(TodayError::CorruptProjection);
+ }
+ decode_snapshot(snapshot.value())
+ })
+ .transpose()
+}
+
+#[cfg(test)]
+fn decode_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> {
+ decode_state_document(value).map(|(state, _)| state)
+}
+
+fn decode_state_document(value: &[u8]) -> Result<(TodayProjectionState, bool), TodayError> {
+ if let Ok(state) = serde_json::from_slice::<TodayProjectionState>(value)
+ && state.schema_version == TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION
+ && state.content_generation != 0
+ && content_generation(&state)? == state.content_generation
+ && validate_media_state(&state).is_ok()
+ {
+ return Ok((state, false));
+ }
+ let state = migrate_legacy_state(value)?;
+ if state.schema_version != TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION
+ || state.content_generation == 0
+ || content_generation(&state)? != state.content_generation
+ || validate_media_state(&state).is_err()
+ {
+ return Err(TodayError::CorruptProjection);
+ }
+ Ok((state, true))
+}
+
+fn validate_media_state(state: &TodayProjectionState) -> Result<(), Phase1InboundMediaError> {
+ state.media_cache.status()?;
+ for projected in &state.cards {
+ for media in &projected.card.media {
+ media.validate()?;
+ if let Phase1InboundMediaState::Verified(receipt) = media.retrieval()
+ && !state.media_cache.contains(receipt)
+ {
+ return Err(Phase1InboundMediaError::CorruptState);
+ }
+ }
+ }
+ for profile in state.profiles.values() {
+ for media in [&profile.picture, &profile.banner].into_iter().flatten() {
+ media.validate()?;
+ if let Phase1InboundMediaState::Verified(receipt) = media.retrieval()
+ && !state.media_cache.contains(receipt)
+ {
+ return Err(Phase1InboundMediaError::CorruptState);
+ }
+ }
+ }
+ if state
+ .thread
+ .iter()
+ .any(|entry| entry.author_profile.as_ref() != state.profiles.get(&entry.author_pubkey))
+ {
+ return Err(Phase1InboundMediaError::CorruptState);
+ }
+ Ok(())
+}
+
+fn sanitize_snapshot_media(snapshot: &mut FrozenTodaySnapshot, cache: &Phase1MediaCacheIndex) {
+ for item in &mut snapshot.items {
+ for media in &mut item.card.media {
+ if let Phase1InboundMediaState::Verified(receipt) = media.retrieval()
+ && !cache.contains(receipt)
+ {
+ media.invalidate();
+ }
+ }
+ if let Some(profile) = &mut item.author_profile {
+ for media in [&mut profile.picture, &mut profile.banner]
+ .into_iter()
+ .flatten()
+ {
+ if let Phase1InboundMediaState::Verified(receipt) = media.retrieval()
+ && !cache.contains(receipt)
+ {
+ media.invalidate();
+ }
+ }
+ }
+ for entry in &mut item.thread {
+ if let Some(profile) = &mut entry.author_profile {
+ for media in [&mut profile.picture, &mut profile.banner]
+ .into_iter()
+ .flatten()
+ {
+ if let Phase1InboundMediaState::Verified(receipt) = media.retrieval()
+ && !cache.contains(receipt)
+ {
+ media.invalidate();
+ }
+ }
+ }
+ }
+ }
+}
+
+fn decode_snapshot(value: &[u8]) -> Result<FrozenTodaySnapshot, TodayError> {
+ let snapshot: FrozenTodaySnapshot = match serde_json::from_slice(value) {
+ Ok(snapshot) => snapshot,
+ Err(_) => {
+ let mut legacy: serde_json::Value = decode(value)?;
+ migrate_legacy_media_values(&mut legacy)?;
+ serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)?
+ }
+ };
+ if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION {
+ return Err(TodayError::CorruptProjection);
+ }
+ Ok(snapshot)
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct LegacyMediaReference {
+ url: String,
+ sha256: Option<String>,
+ media_type: Option<String>,
+ width: Option<u32>,
+ height: Option<u32>,
+ byte_size: Option<u64>,
+ alt: Option<String>,
+ verification: LegacyMediaVerificationState,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "PascalCase")]
+enum LegacyMediaVerificationState {
+ Pending,
+ Verified,
+ Failed,
+ Unavailable,
+}
+
+fn migrate_legacy_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> {
+ verify_legacy_content_generation(value)?;
+ let mut legacy: serde_json::Value = decode(value)?;
+ migrate_legacy_media_values(&mut legacy)?;
+ let object = legacy
+ .as_object_mut()
+ .ok_or(TodayError::CorruptProjection)?;
+ if object.contains_key("mediaCache") {
+ return Err(TodayError::CorruptProjection);
+ }
+ object.insert(
+ "mediaCache".to_owned(),
+ serde_json::to_value(Phase1MediaCacheIndex::default())
+ .map_err(|_| TodayError::Serialization)?,
+ );
+ object.insert("contentGeneration".to_owned(), serde_json::json!(0));
+ let mut state: TodayProjectionState =
+ serde_json::from_value(legacy).map_err(|_| TodayError::CorruptProjection)?;
+ state.content_generation = content_generation(&state)?;
+ Ok(state)
+}
+
+fn verify_legacy_content_generation(value: &[u8]) -> Result<(), TodayError> {
+ let parsed: serde_json::Value = decode(value)?;
+ let expected = parsed
+ .get("contentGeneration")
+ .and_then(serde_json::Value::as_u64)
+ .filter(|value| *value != 0)
+ .ok_or(TodayError::CorruptProjection)?;
+ if parsed
+ .get("schemaVersion")
+ .and_then(serde_json::Value::as_u64)
+ != Some(u64::from(TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION))
+ {
+ return Err(TodayError::CorruptProjection);
+ }
+ let marker = b"\"contentGeneration\":";
+ let starts = value
+ .windows(marker.len())
+ .enumerate()
+ .filter_map(|(index, bytes)| (bytes == marker).then_some(index))
+ .collect::<Vec<_>>();
+ let [start] = starts.as_slice() else {
+ return Err(TodayError::CorruptProjection);
+ };
+ let number_start = start + marker.len();
+ let number_end = value[number_start..]
+ .iter()
+ .position(|byte| !byte.is_ascii_digit())
+ .map(|offset| number_start + offset)
+ .ok_or(TodayError::CorruptProjection)?;
+ if number_end == number_start {
+ return Err(TodayError::CorruptProjection);
+ }
+ let mut canonical = Vec::with_capacity(value.len());
+ canonical.extend_from_slice(&value[..number_start]);
+ canonical.push(b'0');
+ canonical.extend_from_slice(&value[number_end..]);
+ let digest = Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat());
+ let observed = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1);
+ (observed == expected)
+ .then_some(())
+ .ok_or(TodayError::CorruptProjection)
+}
+
+fn migrate_legacy_media_values(value: &mut serde_json::Value) -> Result<(), TodayError> {
+ match value {
+ serde_json::Value::Array(values) => {
+ for value in values {
+ migrate_legacy_media_values(value)?;
+ }
+ }
+ serde_json::Value::Object(object)
+ if object.contains_key("verification") && object.contains_key("url") =>
+ {
+ let legacy: LegacyMediaReference =
+ serde_json::from_value(value.clone()).map_err(|_| TodayError::CorruptProjection)?;
+ let _legacy_verification = legacy.verification;
+ let migrated = MediaReference::legacy_unavailable(
+ legacy.url,
+ legacy.sha256,
+ legacy.media_type,
+ legacy.width,
+ legacy.height,
+ legacy.byte_size,
+ legacy.alt,
+ )?;
+ *value = serde_json::to_value(migrated).map_err(|_| TodayError::Serialization)?;
+ }
+ serde_json::Value::Object(object) => {
+ for value in object.values_mut() {
+ migrate_legacy_media_values(value)?;
+ }
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
+fn content_generation(state: &TodayProjectionState) -> Result<u64, TodayError> {
+ let mut canonical = state.clone();
+ canonical.content_generation = 0;
+ let digest =
+ Sha256::digest([PROJECTION_CONTENT_DOMAIN, encode(&canonical)?.as_slice()].concat());
+ let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix"));
+ Ok(generation.max(1))
+}
+
+fn projection_generation() -> Result<ProjectionGeneration, TodayError> {
+ ProjectionGeneration::new(Sha256::digest(PROJECTION_GENERATION_DOMAIN).into())
+ .map_err(TodayError::from)
+}
+
+fn projection_id() -> Result<ProjectionId, TodayError> {
+ ProjectionId::parse(TODAY_PROJECTION_ID).map_err(TodayError::from)
+}
+
+fn projection_document_key(context: &LocalNetwork) -> String {
+ let mut digest = Sha256::new();
+ digest.update(PROJECTION_DOCUMENT_KEY_DOMAIN);
+ digest.update(context.id.as_bytes());
+ digest.update(context.generation.to_be_bytes());
+ format!("context.{}", hex::encode(digest.finalize()))
+}
+
+fn snapshot_id(scope: &CursorScope) -> [u8; 32] {
+ let mut digest = Sha256::new();
+ digest.update(SNAPSHOT_ID_DOMAIN);
+ digest.update(scope.context_id.as_bytes());
+ digest.update(scope.context_generation.to_be_bytes());
+ digest.update(scope.as_of.to_be_bytes());
+ digest.update(scope.store_generation);
+ digest.update(scope.projection_generation.to_be_bytes());
+ digest.finalize().into()
+}
+
+fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> {
+ tags.iter().find_map(|tag| {
+ names
+ .contains(&tag.first()?.as_str())
+ .then(|| tag.get(1).cloned())
+ .flatten()
+ })
+}
+
+fn blossom_digest(url: &str) -> Option<String> {
+ let path = url.split_once("://")?.1.split_once('/')?.1;
+ let candidate = path.split(['.', '/', '?', '#']).next()?;
+ (candidate.len() == 64
+ && candidate
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
+ .then(|| candidate.to_owned())
+}
+
+fn valid_public_key(value: &str) -> bool {
+ value.len() == 64
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn encode(value: &impl Serialize) -> Result<Vec<u8>, TodayError> {
+ serde_json::to_vec(value).map_err(|_| TodayError::Serialization)
+}
+
+fn decode<T: for<'de> Deserialize<'de>>(value: &[u8]) -> Result<T, TodayError> {
+ serde_json::from_slice(value).map_err(|_| TodayError::CorruptProjection)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[cfg(feature = "mobile-social")]
+ use std::sync::{Arc, Mutex, RwLock, atomic::AtomicBool};
+ #[cfg(feature = "mobile-social")]
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ #[cfg(feature = "mobile-social")]
+ use tokio::net::TcpListener;
+
+ use nostr::secp256k1::Message;
+ use nostr::{Keys, SECP256K1};
+ use radroots_blossom::{
+ BlobUrl, MediaType, Sha256 as BlossomSha256, descriptor::ByteCommitment,
+ };
+ use radroots_event::{
+ SignedEvent,
+ admission::{AdmissionPolicy, RawEvent, VisibilityPolicy},
+ wire::{Nip01EventWire, compute_canonical_nip01_event_id},
+ };
+ use radroots_event_codec::verify::Nip01SignatureVerifier;
+ #[cfg(feature = "mobile-social")]
+ use radroots_transport::{
+ Error as TransportError, EventSource, FetchPage, FetchRequest, SourceStatus,
+ outcome::{FetchTargetOutcome, FetchTargetState},
+ source::NextPage,
+ };
+ use radroots_transport::{
+ Target, TransportId,
+ source::{EventProvenance, ObservedEvent},
+ };
+
+ const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+
+ struct Allow;
+
+ #[cfg(feature = "mobile-social")]
+ struct TodaySource {
+ event: SignedEvent,
+ requested_kinds: Mutex<Vec<Vec<u32>>>,
+ }
+
+ #[cfg(feature = "mobile-social")]
+ impl EventSource for TodaySource {
+ fn status(
+ &self,
+ ) -> radroots_transport::BoxFuture<'_, Result<SourceStatus, TransportError>> {
+ Box::pin(async { unreachable!("Today sync does not inspect source status") })
+ }
+
+ fn fetch(
+ &self,
+ request: FetchRequest,
+ ) -> radroots_transport::BoxFuture<'_, Result<FetchPage, TransportError>> {
+ Box::pin(async move {
+ self.requested_kinds
+ .lock()
+ .expect("requested kinds")
+ .push(request.selector().kinds().to_vec());
+ let target = request.target_set().targets()[0].clone();
+ let provenance = EventProvenance::new(
+ TransportId::NOSTR,
+ target.fingerprint().clone(),
+ 2_000_000_100_000,
+ )?;
+ FetchPage::for_request(
+ &request,
+ vec![ObservedEvent::new(self.event.clone(), provenance)],
+ vec![FetchTargetOutcome::new(
+ target.fingerprint().clone(),
+ FetchTargetState::Complete,
+ )],
+ NextPage::Complete,
+ )
+ })
+ }
+ }
+
+ impl AdmissionPolicy for Allow {
+ type Error = core::convert::Infallible;
+
+ fn policy_id(&self) -> &'static str {
+ "test.today.admission.v1"
+ }
+
+ fn admit(
+ &self,
+ _event: &radroots_event::admission::ContractValidatedEvent,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+ }
+
+ impl VisibilityPolicy for Allow {
+ type Error = core::convert::Infallible;
+
+ fn policy_id(&self) -> &'static str {
+ "test.today.visibility.v1"
+ }
+
+ fn make_visible(
+ &self,
+ _event: &radroots_event::admission::AdmittedEvent,
+ ) -> Result<(), Self::Error> {
+ Ok(())
+ }
+ }
+
+ fn context(locality: Option<&str>, generation: u64) -> LocalNetwork {
+ LocalNetwork::new(
+ "victoria".into(),
+ "Victoria".into(),
+ vec!["wss://relay.example".into()],
+ locality.map(str::to_owned),
+ Vec::new(),
+ generation,
+ )
+ .expect("context")
+ }
+
+ fn keys() -> Keys {
+ Keys::parse(SECRET).expect("keys")
+ }
+
+ fn signed(kind: u32, tags: Vec<Vec<&str>>, content: &str, created_at: u64) -> SignedEvent {
+ signed_owned(
+ kind,
+ tags.into_iter()
+ .map(|tag| tag.into_iter().map(str::to_owned).collect())
+ .collect(),
+ content,
+ created_at,
+ )
+ }
+
+ fn signed_owned(
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: &str,
+ created_at: u64,
+ ) -> SignedEvent {
+ let keys = keys();
+ let author = keys.public_key().to_string();
+ let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content)
+ .expect("id");
+ let message = Message::from_digest(*id.as_bytes());
+ let signature = SECP256K1.sign_schnorr_no_aux_rand(
+ &message,
+ &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()),
+ );
+ let wire = Nip01EventWire {
+ id: id.to_hex(),
+ pubkey: author,
+ created_at,
+ kind,
+ tags,
+ content: content.to_owned(),
+ sig: signature.to_string(),
+ extra: Default::default(),
+ };
+ let raw = serde_json::json!({
+ "id": &wire.id,
+ "pubkey": &wire.pubkey,
+ "created_at": wire.created_at,
+ "kind": wire.kind,
+ "tags": &wire.tags,
+ "content": &wire.content,
+ "sig": &wire.sig,
+ })
+ .to_string();
+ SignedEvent::from_wire_verified_id(wire, raw).expect("signed event")
+ }
+
+ fn visible_admission(event: SignedEvent, observed_at: u64) -> EventAdmission {
+ let selected = admit_verified_event(
+ verify_nip01_event(event.envelope().clone()).expect("codec verification"),
+ )
+ .expect("codec admission")
+ .contract_id();
+ let verified = RawEvent::new(event.envelope().clone())
+ .verify_id()
+ .expect("id")
+ .verify_signature(&Nip01SignatureVerifier)
+ .expect("signature");
+ let visible = verified
+ .validate_contract_for_admission(selected)
+ .expect("selected contract")
+ .admit_with(&Allow)
+ .expect("admission")
+ .make_visible_with(&Allow)
+ .expect("visibility");
+ let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target");
+ let provenance = EventProvenance::new(
+ TransportId::NOSTR,
+ target.fingerprint().clone(),
+ observed_at,
+ )
+ .expect("provenance");
+ EventAdmission::visible(ObservedEvent::new(event, provenance), visible)
+ .expect("visible admission")
+ }
+
+ fn raw_admission(event: SignedEvent, observed_at: u64) -> EventAdmission {
+ let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target");
+ let provenance = EventProvenance::new(
+ TransportId::NOSTR,
+ target.fingerprint().clone(),
+ observed_at,
+ )
+ .expect("provenance");
+ EventAdmission::raw(ObservedEvent::new(event, provenance))
+ }
+
+ fn admitted(event: &SignedEvent) -> RadrootsAdmittedEvent {
+ admit_verified_event(
+ verify_nip01_event(event.envelope().clone()).expect("codec verification"),
+ )
+ .expect("codec admission")
+ }
+
+ async fn ingest(
+ runtime: &RadrootsRuntime,
+ context: &LocalNetwork,
+ event: SignedEvent,
+ at: u64,
+ ) -> TodayIngestReceipt {
+ runtime
+ .phase1_ingest_visible(visible_admission(event, at * 1_000), context, at)
+ .await
+ .expect("ingest")
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ async fn verify_inbound_media(
+ runtime: &RadrootsRuntime,
+ context: &LocalNetwork,
+ source_url: &str,
+ bytes: &[u8],
+ media_type: &str,
+ width: u32,
+ height: u32,
+ operation_id: [u8; 16],
+ ) {
+ let storage = runtime.client.storage().expect("storage");
+ let state = load_state(storage, context, projection_generation().unwrap())
+ .await
+ .unwrap()
+ .expect("projection");
+ let reference = state
+ .cards
+ .iter()
+ .flat_map(|value| value.card.media.iter())
+ .chain(
+ state
+ .profiles
+ .values()
+ .flat_map(|profile| [&profile.picture, &profile.banner].into_iter().flatten()),
+ )
+ .find(|media| media.structural().source_url() == source_url)
+ .expect("structural media")
+ .structural()
+ .clone();
+ let configuration = Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap();
+ runtime
+ .phase1_begin_media_retrieval(
+ context,
+ *reference.fingerprint(),
+ Phase1InboundMediaPending::new(operation_id, configuration, 10).unwrap(),
+ )
+ .await
+ .expect("begin retrieval");
+ let commitment = ByteCommitment::from_bytes(bytes, MediaType::parse(media_type).unwrap());
+ let receipt = Phase1VerifiedMediaReceipt::from_commitment(
+ &reference,
+ BlobUrl::parse(source_url).unwrap(),
+ &commitment,
+ width,
+ height,
+ configuration,
+ 11,
+ )
+ .expect("byte receipt");
+ runtime
+ .phase1_commit_media_receipt(
+ context,
+ *reference.fingerprint(),
+ operation_id,
+ receipt,
+ Phase1MediaCachePolicy::new(1_024, 8).unwrap(),
+ 12,
+ )
+ .await
+ .expect("commit receipt");
+ }
+
+ fn downgrade_media_to_legacy(value: &mut serde_json::Value) {
+ match value {
+ serde_json::Value::Array(values) => {
+ for value in values {
+ downgrade_media_to_legacy(value);
+ }
+ }
+ serde_json::Value::Object(object)
+ if object.contains_key("structural") && object.contains_key("retrieval") =>
+ {
+ let structural = object
+ .get("structural")
+ .and_then(serde_json::Value::as_object)
+ .expect("structural object");
+ *value = serde_json::json!({
+ "url": structural.get("sourceUrl").cloned().unwrap(),
+ "sha256": structural.get("expectedSha256").cloned().unwrap(),
+ "mediaType": structural.get("expectedMediaType").cloned().unwrap(),
+ "width": structural.get("expectedWidth").cloned().unwrap(),
+ "height": structural.get("expectedHeight").cloned().unwrap(),
+ "byteSize": structural.get("expectedByteSize").cloned().unwrap(),
+ "alt": structural.get("alt").cloned().unwrap(),
+ "verification": "Verified",
+ });
+ }
+ serde_json::Value::Object(object) => {
+ for value in object.values_mut() {
+ downgrade_media_to_legacy(value);
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn legacy_state_bytes(state: &TodayProjectionState) -> Vec<u8> {
+ let mut value = serde_json::to_value(state).expect("state value");
+ let object = value.as_object_mut().expect("state object");
+ object.remove("mediaCache").expect("new cache field");
+ object.insert("contentGeneration".to_owned(), serde_json::json!(0));
+ downgrade_media_to_legacy(&mut value);
+ let canonical = serde_json::to_vec(&value).expect("legacy canonical");
+ let digest =
+ sha2::Sha256::digest([PROJECTION_CONTENT_DOMAIN, canonical.as_slice()].concat());
+ let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")).max(1);
+ value["contentGeneration"] = serde_json::json!(generation);
+ serde_json::to_vec(&value).expect("legacy state")
+ }
+
+ #[cfg(feature = "mobile-social")]
+ 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
+ }
+
+ #[cfg(feature = "mobile-social")]
+ async fn serve_one_blob(listener: TcpListener, bytes: Vec<u8>) {
+ let (mut stream, _) = listener.accept().await.expect("accept");
+ let mut request = Vec::new();
+ while !request.windows(4).any(|value| value == b"\r\n\r\n") {
+ let mut chunk = [0_u8; 1024];
+ let read = stream.read(&mut chunk).await.expect("request read");
+ assert_ne!(read, 0);
+ request.extend_from_slice(&chunk[..read]);
+ assert!(request.len() <= 64 * 1024);
+ }
+ let headers = String::from_utf8_lossy(&request);
+ assert!(headers.starts_with("GET /"));
+ assert!(
+ headers
+ .to_ascii_lowercase()
+ .contains("accept-encoding: identity")
+ );
+ assert!(!headers.to_ascii_lowercase().contains("authorization:"));
+ let response = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ bytes.len()
+ );
+ stream.write_all(response.as_bytes()).await.expect("head");
+ stream.write_all(&bytes).await.expect("body");
+ stream.shutdown().await.expect("close");
+ }
+
+ #[cfg(feature = "mobile-social")]
+ #[tokio::test]
+ async fn relay_sync_fetches_the_exact_today_selector_and_projects_real_events() {
+ let source = Arc::new(TodaySource {
+ event: signed(1, Vec::new(), "Fresh from the field", 2_000_000_000),
+ requested_kinds: Mutex::new(Vec::new()),
+ });
+ let client = radroots_sdk::ClientBuilder::memory_default()
+ .source(source.clone())
+ .host_sync(radroots_sdk::sync::HostPolicy::standard())
+ .build()
+ .expect("client");
+ let runtime = RadrootsRuntime {
+ client,
+ started_unix_ms: 1,
+ shutting_down: AtomicBool::new(false),
+ platform_app: RwLock::new(None),
+ store_public_key: None,
+ settings_lock: tokio::sync::Mutex::new(()),
+ identity_session: tokio::sync::RwLock::new(None),
+ inbound_media_directory: None,
+ inbound_media_lock: tokio::sync::Mutex::new(()),
+ };
+ let context = context(None, 1);
+
+ let receipt = runtime
+ .phase1_sync_today(&context, 2_000_000_200, TodayProjectionUpdate::Incremental)
+ .await
+ .expect("Today sync");
+ assert_eq!(receipt.relay_state, TodayRelaySyncState::Complete);
+ assert_eq!(receipt.pages_fetched, 1);
+ assert_eq!(receipt.events_observed, 1);
+ assert_eq!(receipt.events_admitted, 1);
+ assert_eq!(receipt.events_rejected, 0);
+ assert_eq!(receipt.projection.visible_cards, 1);
+ assert_eq!(
+ source
+ .requested_kinds
+ .lock()
+ .expect("requested kinds")
+ .as_slice(),
+ &[TODAY_SYNC_KINDS.to_vec()]
+ );
+ let page = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(20, 2_000_000_200))
+ .await
+ .expect("Today page");
+ assert_eq!(page.items.len(), 1);
+ assert_eq!(page.items[0].card.card_type, TodayCardType::Update);
+ assert_eq!(page.items[0].card.content, "Fresh from the field");
+ }
+
+ #[tokio::test]
+ async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let context = context(None, 1);
+ for content in ["alpha", "bravo", "charlie"] {
+ ingest(
+ &runtime,
+ &context,
+ signed(1, Vec::new(), content, 2_000_000_000),
+ 2_000_000_100,
+ )
+ .await;
+ }
+ let first = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200))
+ .await
+ .expect("first page");
+ assert_eq!(first.items.len(), 1);
+ let frozen_cursor = first.next_cursor.clone().expect("cursor");
+
+ ingest(
+ &runtime,
+ &context,
+ signed(1, Vec::new(), "delta", 2_000_000_001),
+ 2_000_000_101,
+ )
+ .await;
+
+ let mut ids = first
+ .items
+ .iter()
+ .map(|card| card.card.card_id.to_hex())
+ .collect::<Vec<_>>();
+ let mut cursor = Some(frozen_cursor);
+ while let Some(value) = cursor {
+ let page = runtime
+ .phase1_today_page(&context, TodayPageRequest::after(1, value))
+ .await
+ .expect("continued frozen page");
+ ids.extend(page.items.iter().map(|card| card.card.card_id.to_hex()));
+ cursor = page.next_cursor;
+ }
+ ids.sort();
+ ids.dedup();
+ assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates");
+
+ let current = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_201))
+ .await
+ .expect("current page");
+ assert_eq!(current.items.len(), 4);
+ }
+
+ #[tokio::test]
+ async fn profile_thread_media_search_me_context_and_rebuild_share_one_projection() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let context = context(Some("victoria"), 7);
+ let author = keys().public_key().to_string();
+ let profile_bytes = b"profile picture";
+ let profile_digest = BlossomSha256::digest(profile_bytes).to_hex();
+ let profile_url = format!("https://blob.example/{profile_digest}.jpg");
+ let profile = signed(
+ 0,
+ Vec::new(),
+ &format!(
+ r#"{{"name":"moss","display_name":"Moss Farm","about":"Local roots","picture":"{profile_url}","website":"https://moss.example","lud16":"moss@example.com"}}"#
+ ),
+ 2_000_000_000,
+ );
+ ingest(&runtime, &context, profile, 2_000_000_100).await;
+
+ let photo_bytes = b"photo";
+ let digest = BlossomSha256::digest(photo_bytes).to_hex();
+ let photo_url = format!("https://blob.example/{digest}.jpg");
+ let photo_content = format!("Fresh field photo {photo_url}");
+ let photo = signed_owned(
+ 1,
+ vec![
+ vec!["location".into(), "Victoria".into()],
+ vec![
+ "imeta".into(),
+ format!("url {photo_url}"),
+ format!("x {digest}"),
+ "m image/jpeg".into(),
+ "dim 120x80".into(),
+ format!("size {}", photo_bytes.len()),
+ "alt Fresh field photo".into(),
+ ],
+ ],
+ &photo_content,
+ 2_000_000_001,
+ );
+ let root_id = photo.id().to_hex();
+ ingest(&runtime, &context, photo, 2_000_000_101).await;
+
+ let nonmatch = signed(
+ 1,
+ vec![vec!["location", "Elsewhere"]],
+ "far away",
+ 2_000_000_002,
+ );
+ ingest(&runtime, &context, nonmatch, 2_000_000_102).await;
+
+ let reply = signed_owned(
+ 1,
+ vec![
+ vec![
+ "e".into(),
+ root_id.clone(),
+ "wss://relay.example".into(),
+ "root".into(),
+ ],
+ vec!["p".into(), author.clone()],
+ ],
+ "Looks good",
+ 2_000_000_003,
+ );
+ ingest(&runtime, &context, reply, 2_000_000_103).await;
+
+ let food = signed(
+ 30_402,
+ vec![
+ vec!["d", "today-carrots"],
+ vec!["title", "Today carrots"],
+ vec!["summary", "Fresh"],
+ vec!["published_at", "2000000004"],
+ vec!["location", "Victoria"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "active"],
+ ],
+ "Fresh carrots",
+ 2_000_000_004,
+ );
+ let food_id = food.id().to_hex();
+ ingest(&runtime, &context, food, 2_000_000_104).await;
+
+ let comment = signed_owned(
+ 1_111,
+ vec![
+ vec![
+ "E".into(),
+ food_id.clone(),
+ "wss://relay.example".into(),
+ author.clone(),
+ ],
+ vec!["K".into(), "30402".into()],
+ vec!["P".into(), author.clone(), "wss://relay.example".into()],
+ vec![
+ "e".into(),
+ food_id,
+ "wss://relay.example".into(),
+ author.clone(),
+ ],
+ vec!["k".into(), "30402".into()],
+ vec!["p".into(), author.clone(), "wss://relay.example".into()],
+ ],
+ "A NIP-22 comment",
+ 2_000_000_005,
+ );
+ ingest(&runtime, &context, comment, 2_000_000_105).await;
+
+ verify_inbound_media(
+ &runtime,
+ &context,
+ &photo_url,
+ photo_bytes,
+ "image/jpeg",
+ 120,
+ 80,
+ [1; 16],
+ )
+ .await;
+ verify_inbound_media(
+ &runtime,
+ &context,
+ &profile_url,
+ profile_bytes,
+ "image/jpeg",
+ 24,
+ 24,
+ [2; 16],
+ )
+ .await;
+ let live = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_200))
+ .await
+ .expect("page");
+ assert_eq!(live.items.len(), 2, "known locality nonmatch is excluded");
+ let card = live
+ .items
+ .iter()
+ .find(|card| card.card.card_type == TodayCardType::PhotoUpdate)
+ .unwrap_or_else(|| panic!("photo card missing from {:#?}", live.items));
+ assert_eq!(card.card.card_type, TodayCardType::PhotoUpdate);
+ assert!(matches!(
+ card.card.media[0].retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ assert_eq!(card.thread.len(), 1);
+ assert_eq!(
+ live.items
+ .iter()
+ .find(|card| card.card.card_type == TodayCardType::FoodAvailability)
+ .expect("food card")
+ .thread
+ .len(),
+ 1
+ );
+ let profile = card.author_profile.as_ref().expect("profile enrichment");
+ assert_eq!(profile.website.as_deref(), Some("https://moss.example"));
+ assert_eq!(
+ profile.lightning_address.as_deref(),
+ Some("moss@example.com")
+ );
+ assert!(matches!(
+ profile
+ .picture
+ .as_ref()
+ .expect("profile picture")
+ .retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+
+ runtime
+ .phase1_set_local_author_overlay(
+ &context,
+ card.card.card_id,
+ Some(LocalAuthorOverlay {
+ operation_id: "publish-photo-1".into(),
+ state: "delivered".into(),
+ }),
+ )
+ .await
+ .expect("active author overlay");
+ assert!(matches!(
+ runtime
+ .phase1_set_local_author_overlay(
+ &context,
+ CardId::parse(&"f".repeat(64)).expect("unknown card id"),
+ None,
+ )
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+
+ let search = runtime
+ .phase1_search(&context, "moss farm", 10, 2_000_000_200)
+ .await
+ .expect("search");
+ assert!(search.iter().any(|result| result.profile.is_some()));
+ let card_search = runtime
+ .phase1_search(&context, "fresh field photo", 10, 2_000_000_200)
+ .await
+ .expect("card search");
+ assert!(card_search.iter().any(|result| result.card.is_some()));
+ let profile_limited = runtime
+ .phase1_search(&context, "moss@example.com", 1, 2_000_000_200)
+ .await
+ .expect("profile-limited search");
+ assert_eq!(profile_limited.len(), 1);
+ assert!(profile_limited[0].profile.is_some());
+ let me = runtime
+ .phase1_me(&context, &author, 2_000_000_200)
+ .await
+ .expect("me");
+ assert_eq!(me.cards.len(), 2);
+ assert_eq!(
+ me.profile.expect("me profile").name.as_deref(),
+ Some("moss")
+ );
+
+ let rebuilt = runtime
+ .phase1_refresh_today(&context, 2_000_000_201, TodayProjectionUpdate::Rebuild)
+ .await
+ .expect("rebuild");
+ assert!(!rebuilt.changed, "rebuild is byte-equivalent to live state");
+ let after = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_202))
+ .await
+ .expect("rebuilt page");
+ let rebuilt_photo = after
+ .items
+ .iter()
+ .find(|card| card.card.card_type == TodayCardType::PhotoUpdate)
+ .expect("rebuilt photo");
+ assert!(matches!(
+ rebuilt_photo.card.media[0].retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ assert!(matches!(
+ rebuilt_photo
+ .author_profile
+ .as_ref()
+ .and_then(|profile| profile.picture.as_ref())
+ .expect("rebuilt profile picture")
+ .retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ assert_eq!(
+ rebuilt_photo
+ .local_overlay
+ .as_ref()
+ .map(|overlay| overlay.state.as_str()),
+ Some("delivered")
+ );
+ assert_eq!(rebuilt_photo.thread.len(), 1);
+
+ let photo_reference = rebuilt_photo.card.media[0].structural().clone();
+ let photo_receipt = match rebuilt_photo.card.media[0].retrieval() {
+ Phase1InboundMediaState::Verified(receipt) => (**receipt).clone(),
+ state => panic!("unexpected photo state: {state:?}"),
+ };
+ let profile_artifact = match rebuilt_photo
+ .author_profile
+ .as_ref()
+ .and_then(|profile| profile.picture.as_ref())
+ .expect("profile picture before eviction")
+ .retrieval()
+ {
+ Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(),
+ state => panic!("unexpected profile state: {state:?}"),
+ };
+ assert!(matches!(
+ runtime
+ .phase1_fail_media_retrieval(
+ &context,
+ *photo_reference.fingerprint(),
+ Phase1InboundMediaFailure::new([3; 16], "network", true, 29).unwrap(),
+ )
+ .await,
+ Err(TodayError::InboundMedia(
+ Phase1InboundMediaError::OperationMismatch
+ ))
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_begin_media_retrieval(
+ &context,
+ *photo_reference.fingerprint(),
+ Phase1InboundMediaPending::new(
+ [3; 16],
+ Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(),
+ 29,
+ )
+ .unwrap(),
+ )
+ .await,
+ Err(TodayError::InboundMedia(
+ Phase1InboundMediaError::ConfigurationMismatch
+ ))
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_commit_media_receipt(
+ &context,
+ [0; 32],
+ [3; 16],
+ photo_receipt.clone(),
+ Phase1MediaCachePolicy::new(1_024, 8).unwrap(),
+ 29,
+ )
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ runtime
+ .phase1_begin_media_retrieval(
+ &context,
+ *photo_reference.fingerprint(),
+ Phase1InboundMediaPending::new(
+ [3; 16],
+ Phase1MediaConfigurationFingerprint::new([9; 32]).unwrap(),
+ 30,
+ )
+ .unwrap(),
+ )
+ .await
+ .expect("repeat retrieval");
+ let evicted = runtime
+ .phase1_commit_media_receipt(
+ &context,
+ *photo_reference.fingerprint(),
+ [3; 16],
+ photo_receipt,
+ Phase1MediaCachePolicy::new(1_024, 1).unwrap(),
+ 31,
+ )
+ .await
+ .expect("quota commit");
+ assert_eq!(evicted, vec![profile_artifact]);
+ let quota_page = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203))
+ .await
+ .expect("quota page");
+ let quota_photo = quota_page
+ .items
+ .iter()
+ .find(|card| card.card.card_type == TodayCardType::PhotoUpdate)
+ .expect("quota photo");
+ assert!(matches!(
+ quota_photo.card.media[0].retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ assert!(matches!(
+ quota_photo
+ .author_profile
+ .as_ref()
+ .and_then(|profile| profile.picture.as_ref())
+ .expect("evicted profile picture")
+ .retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+
+ let removed = runtime
+ .phase1_invalidate_media_configuration(
+ &context,
+ Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(),
+ )
+ .await
+ .expect("configuration invalidation");
+ assert_eq!(removed.len(), 1);
+ assert!(
+ runtime
+ .phase1_invalidate_media_configuration(
+ &context,
+ Phase1MediaConfigurationFingerprint::new([8; 32]).unwrap(),
+ )
+ .await
+ .expect("idempotent configuration invalidation")
+ .is_empty()
+ );
+ assert!(
+ !runtime
+ .phase1_invalidate_media_artifact(
+ &context,
+ Phase1MediaArtifactId::parse(&"f".repeat(64)).unwrap(),
+ )
+ .await
+ .expect("missing artifact invalidation")
+ );
+ let invalidated = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_203))
+ .await
+ .expect("page after configuration change");
+ let invalidated_photo = invalidated
+ .items
+ .iter()
+ .find(|card| card.card.card_type == TodayCardType::PhotoUpdate)
+ .expect("invalidated photo");
+ assert!(matches!(
+ invalidated_photo.card.media[0].retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ assert!(matches!(
+ invalidated_photo
+ .author_profile
+ .as_ref()
+ .and_then(|profile| profile.picture.as_ref())
+ .expect("invalidated profile picture")
+ .retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ }
+
+ #[tokio::test]
+ async fn legacy_enum_only_media_migrates_to_unavailable_and_repersists() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let context = context(None, 1);
+ let bytes = b"legacy profile";
+ let hash = BlossomSha256::digest(bytes).to_hex();
+ let url = format!("https://blob.example/{hash}.jpg");
+ ingest(
+ &runtime,
+ &context,
+ signed(
+ 0,
+ Vec::new(),
+ &format!(r#"{{"name":"legacy","picture":"{url}"}}"#),
+ 2_000_000_000,
+ ),
+ 2_000_000_100,
+ )
+ .await;
+ let storage = runtime.client.storage().expect("storage");
+ let generation = projection_generation().unwrap();
+ let state = load_state(storage, &context, generation)
+ .await
+ .unwrap()
+ .expect("state");
+ let legacy = legacy_state_bytes(&state);
+ ProjectionStore::put_projection_document(
+ storage,
+ projection_id().unwrap(),
+ generation,
+ ProjectionDocument::new(projection_document_key(&context), legacy.clone()).unwrap(),
+ )
+ .await
+ .unwrap();
+
+ let migrated = load_state(storage, &context, generation)
+ .await
+ .unwrap()
+ .expect("migrated state");
+ let picture = migrated
+ .profiles
+ .values()
+ .next()
+ .and_then(|profile| profile.picture.as_ref())
+ .expect("picture");
+ assert!(matches!(
+ picture.retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ assert_eq!(migrated.media_cache.status().unwrap().artifacts, 0);
+ let stored = ProjectionStore::projection_document(
+ storage,
+ projection_id().unwrap(),
+ generation,
+ projection_document_key(&context),
+ )
+ .await
+ .unwrap()
+ .expect("repersisted state");
+ let text = std::str::from_utf8(stored.value()).unwrap();
+ assert!(!text.contains("\"verification\""));
+ assert!(text.contains("\"mediaCache\""));
+
+ let mut tampered: serde_json::Value = serde_json::from_slice(&legacy).unwrap();
+ tampered["profiles"]
+ .as_object_mut()
+ .unwrap()
+ .values_mut()
+ .next()
+ .unwrap()["name"] = serde_json::json!("tampered");
+ assert!(matches!(
+ decode_state(&serde_json::to_vec(&tampered).unwrap()),
+ Err(TodayError::CorruptProjection)
+ ));
+ }
+
+ #[tokio::test]
+ async fn replacement_and_deletion_change_only_current_today_truth() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let context = context(None, 1);
+ let active = signed(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Fresh"],
+ vec!["published_at", "2000000000"],
+ vec!["location", "Victoria"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "active"],
+ ],
+ "available",
+ 2_000_000_000,
+ );
+ ingest(&runtime, &context, active, 2_000_000_100).await;
+ let sold = signed(
+ 30_402,
+ vec![
+ vec!["d", "carrots"],
+ vec!["title", "Carrots"],
+ vec!["summary", "Gone"],
+ vec!["published_at", "2000000001"],
+ vec!["location", "Victoria"],
+ vec!["price", "3", "CAD"],
+ vec!["radroots:price_unit", "lb"],
+ vec!["status", "sold"],
+ ],
+ "sold out",
+ 2_000_000_001,
+ );
+ let sold_id = sold.id().to_hex();
+ ingest(&runtime, &context, sold, 2_000_000_101).await;
+ let current = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_200))
+ .await
+ .expect("current");
+ assert_eq!(current.items.len(), 1);
+ assert_eq!(current.items[0].card.source_event_id, sold_id);
+ assert_eq!(current.items[0].card.lifecycle, CardLifecycleState::Sold);
+
+ let deletion = signed_owned(5, vec![vec!["e".into(), sold_id]], "", 2_000_000_002);
+ ingest(&runtime, &context, deletion, 2_000_000_102).await;
+ let deleted = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_201))
+ .await
+ .expect("deleted");
+ assert!(deleted.items.is_empty());
+ }
+
+ #[tokio::test]
+ async fn sqlite_reopen_preserves_materialized_state_and_frozen_cursor_pages() {
+ use crate::runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ };
+
+ let root = tempfile::tempdir().expect("root");
+ let store = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ &keys().public_key().to_string(),
+ "3131313131313131313131313131313131313131313131313131313131313131",
+ 2_000_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("store");
+ std::fs::create_dir_all(store.owner_directory()).expect("owner directory");
+ let context = context(None, 1);
+ let runtime = RuntimeBuilder::new(store.clone())
+ .build()
+ .await
+ .expect("runtime");
+ ingest(
+ &runtime,
+ &context,
+ signed(1, Vec::new(), "persisted alpha", 2_000_000_000),
+ 2_000_000_100,
+ )
+ .await;
+ ingest(
+ &runtime,
+ &context,
+ signed(1, Vec::new(), "persisted bravo", 2_000_000_000),
+ 2_000_000_101,
+ )
+ .await;
+ let first = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200))
+ .await
+ .expect("first page");
+ let cursor = first.next_cursor.expect("frozen cursor");
+ runtime.shutdown().await.expect("shutdown");
+
+ let reopened = RuntimeBuilder::new(store).build().await.expect("reopen");
+ let second = reopened
+ .phase1_today_page(&context, TodayPageRequest::after(1, cursor))
+ .await
+ .expect("continued page after reopen");
+ assert_eq!(second.items.len(), 1);
+ assert_ne!(first.items[0].card.card_id, second.items[0].card.card_id);
+ assert!(second.next_cursor.is_none());
+ let search = reopened
+ .phase1_search(&context, "persisted", 10, 2_000_000_200)
+ .await
+ .expect("search after reopen");
+ assert_eq!(search.len(), 2);
+ reopened.shutdown().await.expect("shutdown reopened");
+ }
+
+ #[tokio::test]
+ async fn sqlite_reopen_preserves_receipts_and_missing_artifacts_fail_closed() {
+ use crate::runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ };
+
+ let root = tempfile::tempdir().expect("root");
+ let public_key = keys().public_key().to_string();
+ let store = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ &public_key,
+ "4141414141414141414141414141414141414141414141414141414141414141",
+ 2_000_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("store");
+ std::fs::create_dir_all(store.owner_directory()).expect("owner directory");
+ let context = context(None, 1);
+ let runtime = RuntimeBuilder::new(store.clone())
+ .build()
+ .await
+ .expect("runtime");
+ let bytes = b"sqlite profile";
+ let hash = BlossomSha256::digest(bytes).to_hex();
+ let url = format!("https://blob.example/{hash}.jpg");
+ ingest(
+ &runtime,
+ &context,
+ signed(
+ 0,
+ Vec::new(),
+ &format!(r#"{{"name":"sqlite","picture":"{url}"}}"#),
+ 2_000_000_000,
+ ),
+ 2_000_000_100,
+ )
+ .await;
+ verify_inbound_media(
+ &runtime,
+ &context,
+ &url,
+ bytes,
+ "image/jpeg",
+ 20,
+ 20,
+ [6; 16],
+ )
+ .await;
+ assert_eq!(
+ runtime
+ .phase1_media_cache_status(&context)
+ .await
+ .unwrap()
+ .artifacts,
+ 1
+ );
+ runtime.shutdown().await.expect("shutdown");
+
+ let reopened = RuntimeBuilder::new(store).build().await.expect("reopen");
+ let profile = reopened
+ .phase1_me(&context, &public_key, 2_000_000_200)
+ .await
+ .unwrap()
+ .profile
+ .expect("profile");
+ let picture = profile.picture.expect("picture");
+ let artifact_id = match picture.retrieval() {
+ Phase1InboundMediaState::Verified(receipt) => receipt.artifact_id(),
+ state => panic!("unexpected state: {state:?}"),
+ };
+ assert!(
+ reopened
+ .phase1_invalidate_media_artifact(&context, artifact_id)
+ .await
+ .unwrap()
+ );
+ assert_eq!(
+ reopened
+ .phase1_media_cache_status(&context)
+ .await
+ .unwrap()
+ .artifacts,
+ 0
+ );
+ let profile = reopened
+ .phase1_me(&context, &public_key, 2_000_000_201)
+ .await
+ .unwrap()
+ .profile
+ .expect("profile after invalidation");
+ assert!(matches!(
+ profile.picture.unwrap().retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ reopened.shutdown().await.expect("shutdown reopened");
+ }
+
+ #[cfg(feature = "mobile-social")]
+ #[tokio::test]
+ async fn hardened_retrieval_atomically_writes_and_invalidates_the_local_artifact() {
+ use crate::runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ };
+ use radroots_sdk::transport::{
+ BlossomCancellation, BlossomConfig, BlossomEndpointAuthority, BlossomHostKind,
+ BlossomProfile,
+ };
+
+ let bytes = png(2, 3);
+ let hash = BlossomSha256::digest(bytes.as_slice()).to_hex();
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+ let origin = format!("http://{}", listener.local_addr().expect("address"));
+ let url = format!("{origin}/{hash}.png");
+ let server = tokio::spawn(serve_one_blob(listener, bytes.clone()));
+ let root = tempfile::tempdir().expect("root");
+ let public_key = keys().public_key().to_string();
+ let store = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ &public_key,
+ "6161616161616161616161616161616161616161616161616161616161616161",
+ 2_000_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("store");
+ std::fs::create_dir_all(store.owner_directory()).expect("owner directory");
+ let owner_directory = store.owner_directory().to_path_buf();
+ let blossom = BlossomConfig::from_profile(
+ BlossomProfile::new(
+ BlossomHostKind::Simulator,
+ BlossomEndpointAuthority::LoopbackDevelopment,
+ origin,
+ std::iter::empty::<String>(),
+ )
+ .expect("profile"),
+ )
+ .with_network_policy(
+ std::time::Duration::from_millis(100),
+ std::time::Duration::from_millis(100),
+ 1,
+ std::time::Duration::from_millis(1),
+ )
+ .expect("network policy");
+ let runtime = RuntimeBuilder::new(store)
+ .blossom_config(blossom)
+ .build()
+ .await
+ .expect("runtime");
+ let context = context(None, 1);
+ ingest(
+ &runtime,
+ &context,
+ signed(
+ 0,
+ Vec::new(),
+ &format!(r#"{{"name":"retrieved","picture":"{url}"}}"#),
+ 2_000_000_000,
+ ),
+ 2_000_000_100,
+ )
+ .await;
+ let picture = runtime
+ .phase1_me(&context, &public_key, 2_000_000_200)
+ .await
+ .expect("me")
+ .profile
+ .expect("profile")
+ .picture
+ .expect("picture");
+ let artifact = runtime
+ .phase1_retrieve_media(
+ &context,
+ *picture.structural().fingerprint(),
+ [9; 16],
+ Phase1MediaCachePolicy::new(1_024, 8).unwrap(),
+ BlossomCancellation::default(),
+ )
+ .await
+ .expect("verified retrieval");
+ server.await.expect("server");
+ assert!(
+ artifact
+ .local_path()
+ .starts_with(owner_directory.join("inbound_media.v1"))
+ );
+ assert_eq!(tokio::fs::read(artifact.local_path()).await.unwrap(), bytes);
+ assert_eq!(artifact.bytes(), bytes);
+ assert!(
+ !tokio::fs::symlink_metadata(artifact.local_path())
+ .await
+ .unwrap()
+ .file_type()
+ .is_symlink()
+ );
+ let verified = runtime
+ .phase1_me(&context, &public_key, 2_000_000_201)
+ .await
+ .expect("verified me")
+ .profile
+ .expect("verified profile")
+ .picture
+ .expect("verified picture");
+ assert!(matches!(
+ verified.retrieval(),
+ Phase1InboundMediaState::Verified(_)
+ ));
+ let mut corrupt = bytes.clone();
+ let last = corrupt.len() - 1;
+ corrupt[last] ^= 1;
+ tokio::fs::write(artifact.local_path(), corrupt)
+ .await
+ .expect("corrupt cached bytes");
+ assert!(matches!(
+ runtime
+ .phase1_verified_media_artifact(
+ &context,
+ artifact.artifact_id(),
+ 2_000_000_203_000,
+ )
+ .await,
+ Err(TodayError::InboundMedia(
+ Phase1InboundMediaError::CorruptArtifact
+ ))
+ ));
+ assert!(!artifact.local_path().exists());
+ let current_configuration = runtime
+ .phase1_media_cache_status(&context)
+ .await
+ .unwrap()
+ .configuration
+ .expect("configuration");
+ let replacement_bytes = if current_configuration.as_bytes() == &[1; 32] {
+ [2; 32]
+ } else {
+ [1; 32]
+ };
+ let replacement_configuration =
+ Phase1MediaConfigurationFingerprint::new(replacement_bytes).unwrap();
+ runtime
+ .phase1_invalidate_media_configuration(&context, replacement_configuration)
+ .await
+ .expect("invalidate configuration");
+ let unavailable = runtime
+ .phase1_me(&context, &public_key, 2_000_000_202)
+ .await
+ .expect("unavailable me")
+ .profile
+ .expect("unavailable profile")
+ .picture
+ .expect("unavailable picture");
+ assert!(matches!(
+ unavailable.retrieval(),
+ Phase1InboundMediaState::Unavailable
+ ));
+ runtime.shutdown().await.expect("shutdown");
+ }
+
+ #[tokio::test]
+ async fn fail_closed_requests_cursors_overlays_and_projection_guards_are_executable() {
+ let mut runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let context = context(None, 1);
+ let note = signed(1, Vec::new(), "guarded alpha", 2_000_000_000);
+ assert!(matches!(
+ runtime
+ .phase1_ingest_visible(raw_admission(note.clone(), 2_000_000_000_000), &context, 1)
+ .await,
+ Err(TodayError::EventNotVisible)
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_refresh_today(&context, 0, TodayProjectionUpdate::Incremental)
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_refresh_today(&context, u64::MAX, TodayProjectionUpdate::Incremental)
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ let empty = runtime
+ .phase1_refresh_today(&context, 1, TodayProjectionUpdate::Incremental)
+ .await
+ .expect("empty projection");
+ assert_eq!(empty.source_events, 0);
+ assert!(empty.changed);
+ assert!(
+ !runtime
+ .phase1_refresh_today(&context, 2, TodayProjectionUpdate::Incremental)
+ .await
+ .expect("unchanged projection")
+ .changed
+ );
+
+ for request in [
+ TodayPageRequest::first(0, 1),
+ TodayPageRequest::first(TODAY_PAGE_LIMIT_MAX + 1, 1),
+ TodayPageRequest {
+ limit: 1,
+ as_of: None,
+ cursor: None,
+ },
+ TodayPageRequest::first(1, 0),
+ ] {
+ assert!(matches!(
+ runtime.phase1_today_page(&context, request).await,
+ Err(TodayError::InvalidRequest)
+ ));
+ }
+ for (query, limit, as_of) in [
+ ("valid", 0, 1),
+ ("valid", TODAY_SEARCH_LIMIT_MAX + 1, 1),
+ ("valid", 1, 0),
+ (" ", 1, 1),
+ (&"x".repeat(257), 1, 1),
+ ("bad\nquery", 1, 1),
+ ] {
+ assert!(matches!(
+ runtime.phase1_search(&context, query, limit, as_of).await,
+ Err(TodayError::InvalidRequest)
+ ));
+ }
+ assert!(matches!(
+ runtime.phase1_me(&context, "bad", 1).await,
+ Err(TodayError::InvalidRequest)
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_me(&context, &keys().public_key().to_string(), 0)
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ assert!(
+ !runtime
+ .phase1_begin_media_retrieval(
+ &context,
+ [3; 32],
+ Phase1InboundMediaPending::new(
+ [4; 16],
+ Phase1MediaConfigurationFingerprint::new([5; 32]).unwrap(),
+ 1,
+ )
+ .unwrap(),
+ )
+ .await
+ .expect("unmatched media")
+ );
+
+ ingest(&runtime, &context, note.clone(), 2_000_000_100).await;
+ let page = runtime
+ .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200))
+ .await
+ .expect("page");
+ let card_id = page.items[0].card.card_id;
+ let rank = page.items[0].card.rank.expect("rank");
+ let generation = projection_generation().expect("generation");
+ let storage = runtime.client.storage().expect("storage");
+ let state = load_state(storage, &context, generation)
+ .await
+ .expect("load state")
+ .expect("state");
+ let scope = CursorScope::new(
+ context.id.clone(),
+ context.generation,
+ 2_000_000_200,
+ state.store_generation,
+ state.content_generation,
+ )
+ .expect("scope");
+
+ let cursor_for = |scope: CursorScope| {
+ TodayCursor::encode(&scope, TodayCursorPosition { rank })
+ .expect("cursor")
+ .as_str()
+ .to_owned()
+ };
+ let mut wrong_context = scope.clone();
+ wrong_context.context_id = "elsewhere".into();
+ assert!(matches!(
+ runtime
+ .phase1_today_page(
+ &context,
+ TodayPageRequest::after(1, cursor_for(wrong_context))
+ )
+ .await,
+ Err(TodayError::Cursor(CursorError::ContextMismatch))
+ ));
+ let mut wrong_context_generation = scope.clone();
+ wrong_context_generation.context_generation += 1;
+ assert!(matches!(
+ runtime
+ .phase1_today_page(
+ &context,
+ TodayPageRequest::after(1, cursor_for(wrong_context_generation)),
+ )
+ .await,
+ Err(TodayError::Cursor(CursorError::ContextMismatch))
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_today_page(
+ &context,
+ TodayPageRequest {
+ limit: 1,
+ as_of: Some(scope.as_of + 1),
+ cursor: Some(cursor_for(scope.clone())),
+ },
+ )
+ .await,
+ Err(TodayError::Cursor(CursorError::SnapshotMismatch))
+ ));
+ let mut stale = scope.clone();
+ stale.store_generation = [9; 32];
+ assert!(matches!(
+ runtime
+ .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(stale)))
+ .await,
+ Err(TodayError::Cursor(CursorError::Stale))
+ ));
+ let mut missing = scope.clone();
+ missing.projection_generation = missing.projection_generation.wrapping_add(1).max(1);
+ assert!(matches!(
+ runtime
+ .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(missing)))
+ .await,
+ Err(TodayError::SnapshotMissing)
+ ));
+
+ let snapshot = frozen_snapshot(&state, &context, scope.as_of).expect("snapshot");
+ assert!(validate_snapshot(&snapshot, &scope).is_ok());
+ for invalid in [
+ {
+ let mut value = snapshot.clone();
+ value.schema_version += 1;
+ value
+ },
+ {
+ let mut value = snapshot.clone();
+ value.context_id = "other".into();
+ value
+ },
+ {
+ let mut value = snapshot.clone();
+ value.context_generation += 1;
+ value
+ },
+ {
+ let mut value = snapshot.clone();
+ value.as_of += 1;
+ value
+ },
+ {
+ let mut value = snapshot.clone();
+ value.store_generation = [8; 32];
+ value
+ },
+ {
+ let mut value = snapshot.clone();
+ value.projection_generation = value.projection_generation.wrapping_add(1);
+ value
+ },
+ ] {
+ assert!(matches!(
+ validate_snapshot(&invalid, &scope),
+ Err(TodayError::CorruptProjection)
+ ));
+ }
+ let mut absent_rank = rank;
+ absent_rank.card_id = CardId::parse(&"f".repeat(64)).expect("absent card id");
+ assert!(matches!(
+ page_from_snapshot(snapshot.clone(), scope.clone(), Some(absent_rank), 1),
+ Err(TodayError::CursorPositionMissing)
+ ));
+
+ for invalid in [
+ {
+ let mut value = state.clone();
+ value.schema_version += 1;
+ value
+ },
+ {
+ let mut value = state.clone();
+ value.content_generation = 0;
+ value
+ },
+ {
+ let mut value = state.clone();
+ value.source_events += 1;
+ value
+ },
+ ] {
+ assert!(matches!(
+ decode_state(&encode(&invalid).expect("encode invalid state")),
+ Err(TodayError::CorruptProjection)
+ ));
+ }
+ let mut invalid_snapshot = snapshot.clone();
+ invalid_snapshot.schema_version += 1;
+ assert!(matches!(
+ decode_snapshot(&encode(&invalid_snapshot).expect("encode invalid snapshot")),
+ Err(TodayError::CorruptProjection)
+ ));
+ assert!(matches!(
+ decode_state(b"not-json"),
+ Err(TodayError::CorruptProjection)
+ ));
+
+ for overlay in [
+ LocalAuthorOverlay {
+ operation_id: String::new(),
+ state: "queued".into(),
+ },
+ LocalAuthorOverlay {
+ operation_id: "x".repeat(257),
+ state: "queued".into(),
+ },
+ LocalAuthorOverlay {
+ operation_id: "operation".into(),
+ state: String::new(),
+ },
+ LocalAuthorOverlay {
+ operation_id: "operation".into(),
+ state: "x".repeat(97),
+ },
+ LocalAuthorOverlay {
+ operation_id: "operation".into(),
+ state: "bad\nstate".into(),
+ },
+ ] {
+ assert!(matches!(
+ runtime
+ .phase1_set_local_author_overlay(&context, card_id, Some(overlay))
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ }
+ let other_keys = Keys::generate();
+ runtime.store_public_key = Some(
+ radroots_identity::PublicKey::from_hex(&other_keys.public_key().to_string())
+ .expect("other public key"),
+ );
+ assert!(matches!(
+ runtime
+ .phase1_me(&context, &keys().public_key().to_string(), 1)
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ assert!(matches!(
+ runtime
+ .phase1_set_local_author_overlay(
+ &context,
+ card_id,
+ Some(LocalAuthorOverlay {
+ operation_id: "operation".into(),
+ state: "queued".into(),
+ }),
+ )
+ .await,
+ Err(TodayError::InvalidRequest)
+ ));
+ runtime.store_public_key = None;
+ runtime
+ .phase1_set_local_author_overlay(
+ &context,
+ card_id,
+ Some(LocalAuthorOverlay {
+ operation_id: "operation".into(),
+ state: "queued".into(),
+ }),
+ )
+ .await
+ .expect("set overlay");
+ runtime
+ .phase1_set_local_author_overlay(&context, card_id, None)
+ .await
+ .expect("remove overlay");
+ assert_eq!(
+ runtime
+ .phase1_search(&context, "guarded", 1, 2_000_000_200)
+ .await
+ .expect("card-limited search")
+ .len(),
+ 1
+ );
+
+ let mut event_state = state.clone();
+ event_state.cards[0].card.card_type = TodayCardType::Event;
+ event_state.cards[0].card.event_start = Some(100);
+ event_state.cards[0].card.event_end = Some(200);
+ event_state.cards[0].card.effective_at = 100;
+ assert_eq!(
+ ranked_cards(&event_state, &context, 150).expect("live event")[0]
+ .card
+ .lifecycle,
+ CardLifecycleState::Active
+ );
+ assert_eq!(
+ ranked_cards(&event_state, &context, 200).expect("past event")[0]
+ .card
+ .lifecycle,
+ CardLifecycleState::Past
+ );
+
+ let root = admitted(¬e);
+ assert!(matches!(
+ profile_summary(&root),
+ Err(TodayError::CorruptProjection)
+ ));
+ assert!(matches!(
+ reply_entry(&root),
+ Err(TodayError::CorruptProjection)
+ ));
+ assert!(comment_entry(&root).is_none());
+ assert_eq!(tag_value(&[Vec::new()], &["x"]), None);
+ assert_eq!(tag_value(&[vec!["x".into()]], &["x"]), None);
+ assert_eq!(blossom_digest("not-a-url"), None);
+ assert_eq!(blossom_digest("https://blob.example/short"), None);
+ assert_eq!(
+ blossom_digest(&format!("https://blob.example/{}", "A".repeat(64))),
+ None
+ );
+
+ let tags = locality_tags(vec![
+ Vec::new(),
+ vec!["x".into(), "ignored".into()],
+ vec!["g".into()],
+ vec!["location".into(), " ".into()],
+ vec!["g".into(), "u10hr".into()],
+ ]);
+ assert_eq!(tags.len(), 1);
+ assert_eq!(
+ locality_evidence(Some("u10"), &tags),
+ LocalityEvidence::Match
+ );
+ assert_eq!(
+ locality_evidence(Some("u10hr7"), &tags),
+ LocalityEvidence::Match
+ );
+ assert_eq!(
+ locality_evidence(Some("other"), &tags),
+ LocalityEvidence::Nonmatch
+ );
+ assert_eq!(
+ locality_evidence(Some("selected"), &[]),
+ LocalityEvidence::Missing
+ );
+
+ let mut fields = BTreeMap::new();
+ fields.insert("value".into(), serde_json::json!(1));
+ assert_eq!(typed_profile_extra(&fields, "missing"), None);
+ assert_eq!(typed_profile_extra(&fields, "value"), None);
+ for value in [String::new(), "x".repeat(2_049), "bad\nvalue".into()] {
+ fields.insert("value".into(), serde_json::Value::String(value));
+ assert_eq!(typed_profile_extra(&fields, "value"), None);
+ }
+ }
+
+ #[test]
+ fn malformed_cursor_and_request_bounds_fail_closed() {
+ assert!(matches!(
+ TodayCursor::scope("bad"),
+ Err(CursorError::Malformed)
+ ));
+ assert!(!valid_public_key("short"));
+ assert!(!valid_public_key("A".repeat(64).as_str()));
+ assert!(valid_public_key("a".repeat(64).as_str()));
+ assert_eq!(locality_evidence(None, &[]), LocalityEvidence::Missing);
+ assert_eq!(blossom_digest("https://blob.example"), None);
+ assert_eq!(
+ blossom_digest(&format!(
+ "https://blob.example/{}/image.jpg",
+ "a".repeat(64)
+ )),
+ Some("a".repeat(64))
+ );
+ assert_eq!(TODAY_PAGE_LIMIT_MAX, 100);
+ assert_eq!(TODAY_SEARCH_LIMIT_MAX, 100);
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs
@@ -0,0 +1,532 @@
+use radroots_sdk::capability::{Availability, Maturity};
+
+pub use radroots_sdk::trade::{
+ RadrootsRhiEvidenceReportV1, RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestV1,
+ RadrootsTradeEvidenceOutcomeV1,
+};
+
+use super::RadrootsRuntime;
+#[cfg(feature = "mobile-social")]
+use super::product_surface::{BlossomPreferences, RelayPreferences};
+use crate::RadrootsAppError;
+
+#[derive(Clone, Debug)]
+pub struct SdkCapabilityRecord {
+ pub id: String,
+ pub compiled: bool,
+ pub configured: bool,
+ pub availability: String,
+ pub maturity: String,
+}
+
+#[derive(Clone, Debug)]
+pub struct SdkStorageStatusRecord {
+ pub backend: String,
+ pub open_mode: String,
+ pub shutdown: String,
+ pub integrity: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum SdkRelayAccessRecord {
+ ReadOnly,
+ ReadWrite,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkRelayStatusRecord {
+ pub relay_url: String,
+ pub access: SdkRelayAccessRecord,
+ 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>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkRelayStatusReportRecord {
+ pub profile: String,
+ pub state: String,
+ pub read_availability: String,
+ pub write_availability: String,
+ pub relays: Vec<SdkRelayStatusRecord>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkBlossomConfigurationRecord {
+ 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)]
+pub struct SdkBlossomEvidenceRecord {
+ 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,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SdkShutdownRecord {
+ pub state: String,
+ pub already_closed: bool,
+}
+
+impl RadrootsRuntime {
+ pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> {
+ self.client
+ .capabilities()
+ .iter()
+ .map(|status| SdkCapabilityRecord {
+ id: status.id().as_str().to_owned(),
+ compiled: status.is_compiled(),
+ configured: status.is_configured(),
+ availability: availability_label(status.availability()).to_owned(),
+ maturity: maturity_label(status.maturity()).to_owned(),
+ })
+ .collect()
+ }
+
+ pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> {
+ let status = self
+ .client
+ .storage_status()
+ .await
+ .map_err(RadrootsAppError::from_sdk)?;
+ Ok(SdkStorageStatusRecord {
+ backend: status.backend().as_str().to_owned(),
+ open_mode: status.open_mode().as_str().to_owned(),
+ shutdown: status.shutdown().as_str().to_owned(),
+ integrity: status.integrity().health().as_str().to_owned(),
+ })
+ }
+
+ /// Installs a validated public relay profile without probing it.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_public_relays(
+ &self,
+ writable_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_relay_endpoints(
+ radroots_sdk::transport::RelayProfileKind::Public,
+ radroots_sdk::transport::RelayUrlPolicy::Public,
+ writable_relays,
+ )
+ }
+
+ /// Installs an exact-loopback simulator profile without probing it.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_simulator_relays(
+ &self,
+ loopback_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_relay_endpoints(
+ radroots_sdk::transport::RelayProfileKind::Simulator,
+ radroots_sdk::transport::RelayUrlPolicy::Local,
+ loopback_relays,
+ )
+ }
+
+ /// Installs an explicit physical-device TLS relay profile without probing it.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_device_relays(
+ &self,
+ writable_relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_relay_endpoints(
+ radroots_sdk::transport::RelayProfileKind::Device,
+ radroots_sdk::transport::RelayUrlPolicy::PrivateNetwork,
+ writable_relays,
+ )
+ }
+
+ #[cfg(feature = "mobile-social")]
+ fn configure_relay_endpoints(
+ &self,
+ kind: radroots_sdk::transport::RelayProfileKind,
+ policy: radroots_sdk::transport::RelayUrlPolicy,
+ relays: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ let endpoints = relays
+ .into_iter()
+ .map(|relay| {
+ radroots_sdk::transport::RelayEndpoint::new(
+ relay,
+ policy,
+ radroots_sdk::transport::RelayAccess::ReadWrite,
+ )
+ })
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|error| RadrootsAppError::runtime(error.to_string()))?;
+ let profile = radroots_sdk::transport::RelayProfile::explicit(kind, endpoints)
+ .map_err(|error| RadrootsAppError::runtime(error.to_string()))?;
+ self.configure_relay_profile(profile)
+ }
+
+ #[cfg(feature = "mobile-social")]
+ fn configure_relay_profile(
+ &self,
+ profile: radroots_sdk::transport::RelayProfile,
+ ) -> Result<(), RadrootsAppError> {
+ self.client
+ .configure_nostr(profile)
+ .map_err(RadrootsAppError::from_sdk)
+ }
+
+ /// Installs the exact validated relay preferences persisted by the mobile product.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_relay_preferences(
+ &self,
+ preferences: &RelayPreferences,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_relay_profile(
+ preferences
+ .sdk_profile()
+ .map_err(|error| RadrootsAppError::runtime(error.code()))?,
+ )
+ }
+
+ /// Installs one canonical inert Blossom configuration without probing it.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_blossom(
+ &self,
+ host_kind: radroots_sdk::transport::BlossomHostKind,
+ endpoint_authority: radroots_sdk::transport::BlossomEndpointAuthority,
+ primary_origin: String,
+ fallback_origins: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ radroots_sdk::transport::BlossomProfile::new(
+ host_kind,
+ endpoint_authority,
+ primary_origin,
+ fallback_origins,
+ )
+ .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?,
+ )
+ }
+
+ #[cfg(feature = "mobile-social")]
+ fn configure_blossom_profile(
+ &self,
+ profile: radroots_sdk::transport::BlossomProfile,
+ ) -> Result<(), RadrootsAppError> {
+ self.client
+ .configure_blossom(radroots_sdk::transport::BlossomConfig::from_profile(
+ profile,
+ ))
+ .map_err(RadrootsAppError::from_sdk)
+ }
+
+ /// Installs the exact validated Blossom preferences persisted by the mobile product.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_blossom_preferences(
+ &self,
+ preferences: &BlossomPreferences,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ preferences
+ .sdk_profile()
+ .map_err(|error| RadrootsAppError::runtime(error.code()))?,
+ )
+ }
+
+ /// Returns the configured adapter slot for Rust-owned media binding.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_blossom_slot(
+ &self,
+ ) -> Result<Option<radroots_sdk::transport::BlossomSlot>, RadrootsAppError> {
+ self.client
+ .blossom()
+ .map(|slot| slot.cloned())
+ .map_err(RadrootsAppError::from_sdk)
+ }
+
+ /// Returns the complete inert Blossom configuration, when configured.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_blossom_configuration(
+ &self,
+ ) -> Result<Option<SdkBlossomConfigurationRecord>, RadrootsAppError> {
+ let configuration = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .and_then(radroots_sdk::transport::BlossomSlot::configuration);
+ Ok(
+ configuration.map(|(profile, fingerprint)| SdkBlossomConfigurationRecord {
+ host_kind: blossom_host_kind_label(profile.host_kind()).to_owned(),
+ endpoint_authority: blossom_authority_label(profile.authority()).to_owned(),
+ primary_origin: profile.primary().origin().to_owned(),
+ fallback_origins: profile
+ .fallbacks()
+ .iter()
+ .map(|endpoint| endpoint.origin().to_owned())
+ .collect(),
+ config_fingerprint: fingerprint.to_hex(),
+ }),
+ )
+ }
+
+ /// Returns the latest passive Blossom evidence without network I/O.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_blossom_evidence(
+ &self,
+ ) -> Result<Option<SdkBlossomEvidenceRecord>, RadrootsAppError> {
+ let evidence = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .and_then(radroots_sdk::transport::BlossomSlot::evidence);
+ Ok(evidence.map(sdk_blossom_evidence_record))
+ }
+
+ /// Explicitly probes the primary Blossom origin without mutation or authorization.
+ #[cfg(feature = "mobile-social")]
+ pub async fn probe_blossom(&self) -> Result<SdkBlossomEvidenceRecord, RadrootsAppError> {
+ let blossom = self
+ .client
+ .blossom()
+ .map_err(RadrootsAppError::from_sdk)?
+ .ok_or_else(|| RadrootsAppError::runtime("blossom_endpoint_not_configured"))?;
+ blossom
+ .probe(radroots_sdk::transport::BlossomCancellation::default())
+ .await
+ .map(sdk_blossom_evidence_record)
+ .map_err(|error| RadrootsAppError::runtime(error.code()))
+ }
+
+ /// Returns passive relay evidence without DNS, socket, or probe work.
+ #[cfg(feature = "mobile-social")]
+ pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> {
+ let report = self
+ .client
+ .nostr_status()
+ .map_err(RadrootsAppError::from_sdk)?;
+ Ok(report.map(|report| SdkRelayStatusReportRecord {
+ profile: relay_profile_label(report.profile_kind()).to_owned(),
+ state: relay_aggregate_label(report.state()).to_owned(),
+ read_availability: transport_availability_label(report.read_availability()).to_owned(),
+ write_availability: transport_availability_label(report.write_availability())
+ .to_owned(),
+ relays: report
+ .relays()
+ .iter()
+ .map(|relay| SdkRelayStatusRecord {
+ relay_url: relay.endpoint().url().to_string(),
+ access: if relay.endpoint().access().can_write() {
+ SdkRelayAccessRecord::ReadWrite
+ } else {
+ SdkRelayAccessRecord::ReadOnly
+ },
+ read_state: relay_evidence_label(relay.read().state()).to_owned(),
+ write_state: relay_evidence_label(relay.write().state()).to_owned(),
+ read_last_attempt_unix_ms: relay.read().last_attempt_unix_ms(),
+ write_last_attempt_unix_ms: relay.write().last_attempt_unix_ms(),
+ read_next_attempt_unix_ms: relay.read().next_attempt_unix_ms(),
+ write_next_attempt_unix_ms: relay.write().next_attempt_unix_ms(),
+ })
+ .collect(),
+ }))
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+fn sdk_blossom_evidence_record(
+ value: radroots_sdk::transport::BlossomEndpointEvidence,
+) -> SdkBlossomEvidenceRecord {
+ SdkBlossomEvidenceRecord {
+ schema_version: value.schema_version(),
+ origin: value.origin().to_owned(),
+ config_fingerprint: value.config_fingerprint().to_hex(),
+ state: blossom_evidence_label(value.state()).to_owned(),
+ last_successful_state: blossom_evidence_label(value.last_successful_state()).to_owned(),
+ transport_security: blossom_transport_security_label(value.transport_security()).to_owned(),
+ observed_at_unix_ms: value.observed_at_unix_ms(),
+ http_status: value.http_status(),
+ error_code: value.error_code().map(str::to_owned),
+ server_error_code: value.server_error_code().map(str::to_owned),
+ error_phase: value
+ .error_phase()
+ .map(blossom_phase_label)
+ .map(str::to_owned),
+ retryable: value.retryable(),
+ possible_orphan: value.possible_orphan(),
+ attempts: value.attempts(),
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_evidence_label(
+ value: radroots_sdk::transport::BlossomEvidenceState,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomEvidenceState::ConfiguredUnobserved => {
+ "configured_unobserved"
+ }
+ radroots_sdk::transport::BlossomEvidenceState::DnsPolicyValidated => "dns_policy_validated",
+ radroots_sdk::transport::BlossomEvidenceState::TlsHttpObserved => "tls_http_observed",
+ radroots_sdk::transport::BlossomEvidenceState::UploadVerified => "upload_verified",
+ radroots_sdk::transport::BlossomEvidenceState::RetrievalVerified => "retrieval_verified",
+ radroots_sdk::transport::BlossomEvidenceState::RetryableFailure => "retryable_failure",
+ radroots_sdk::transport::BlossomEvidenceState::TerminalFailure => "terminal_failure",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_transport_security_label(
+ value: radroots_sdk::transport::BlossomTransportSecurity,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomTransportSecurity::PublicWebPki => "public_webpki",
+ radroots_sdk::transport::BlossomTransportSecurity::DevelopmentTls => "development_tls",
+ radroots_sdk::transport::BlossomTransportSecurity::DevelopmentCleartext => {
+ "development_cleartext"
+ }
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_phase_label(value: radroots_sdk::transport::BlossomPhase) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomPhase::Configuration => "configuration",
+ radroots_sdk::transport::BlossomPhase::Probe => "probe",
+ radroots_sdk::transport::BlossomPhase::Authorization => "authorization",
+ radroots_sdk::transport::BlossomPhase::Upload => "upload",
+ radroots_sdk::transport::BlossomPhase::Descriptor => "descriptor",
+ radroots_sdk::transport::BlossomPhase::Retrieval => "retrieval",
+ radroots_sdk::transport::BlossomPhase::Verification => "verification",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_host_kind_label(value: radroots_sdk::transport::BlossomHostKind) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomHostKind::Native => "native",
+ radroots_sdk::transport::BlossomHostKind::Simulator => "simulator",
+ radroots_sdk::transport::BlossomHostKind::PhysicalDevice => "physical_device",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn blossom_authority_label(
+ value: radroots_sdk::transport::BlossomEndpointAuthority,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::BlossomEndpointAuthority::PublicWebPki => "public_webpki",
+ radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment => {
+ "loopback_development"
+ }
+ radroots_sdk::transport::BlossomEndpointAuthority::PrivateNetworkDevelopment => {
+ "private_network_development"
+ }
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn relay_evidence_label(value: radroots_sdk::transport::RelayEvidenceState) -> &'static str {
+ match value {
+ radroots_sdk::transport::RelayEvidenceState::Unsupported => "unsupported",
+ radroots_sdk::transport::RelayEvidenceState::Unobserved => "unobserved",
+ radroots_sdk::transport::RelayEvidenceState::Connecting => "connecting",
+ radroots_sdk::transport::RelayEvidenceState::Available => "available",
+ radroots_sdk::transport::RelayEvidenceState::Unavailable => "unavailable",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn relay_profile_label(value: radroots_sdk::transport::RelayProfileKind) -> &'static str {
+ match value {
+ radroots_sdk::transport::RelayProfileKind::Public => "public",
+ radroots_sdk::transport::RelayProfileKind::Simulator => "simulator_local",
+ radroots_sdk::transport::RelayProfileKind::Device => "device_development",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn relay_aggregate_label(
+ value: radroots_sdk::transport::RelayAggregateState,
+) -> &'static str {
+ match value {
+ radroots_sdk::transport::RelayAggregateState::Configured => "configured",
+ radroots_sdk::transport::RelayAggregateState::Connecting => "connecting",
+ radroots_sdk::transport::RelayAggregateState::ReadOnly => "read_only",
+ radroots_sdk::transport::RelayAggregateState::Writable => "writable",
+ radroots_sdk::transport::RelayAggregateState::Degraded => "degraded",
+ radroots_sdk::transport::RelayAggregateState::Offline => "offline",
+ radroots_sdk::transport::RelayAggregateState::Failed => "failed",
+ _ => "unknown",
+ }
+}
+
+#[cfg(feature = "mobile-social")]
+const fn transport_availability_label(
+ value: radroots_transport::capability::Availability,
+) -> &'static str {
+ match value {
+ radroots_transport::capability::Availability::Available => "available",
+ radroots_transport::capability::Availability::Degraded => "degraded",
+ radroots_transport::capability::Availability::Unavailable => "unavailable",
+ }
+}
+
+const fn availability_label(value: Availability) -> &'static str {
+ match value {
+ Availability::Available => "available",
+ Availability::Degraded => "degraded",
+ Availability::Unavailable => "unavailable",
+ Availability::Unsupported => "unsupported",
+ }
+}
+
+const fn maturity_label(value: Maturity) -> &'static str {
+ match value {
+ Maturity::Stable => "stable",
+ Maturity::Preview => "preview",
+ Maturity::Experimental => "experimental",
+ }
+}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::RadrootsRuntime;
+
+ #[tokio::test]
+ async fn explicit_test_runtime_is_memory_backed() {
+ let runtime = RadrootsRuntime::test_memory().expect("runtime");
+ let capabilities = runtime.sdk_capabilities();
+ assert!(capabilities.iter().any(|capability| {
+ capability.id == "storage.canonical"
+ && capability.configured
+ && capability.availability == "available"
+ && capability.maturity == "stable"
+ }));
+ let status = runtime.sdk_storage_status().await.expect("storage status");
+ assert_eq!(status.backend, "memory");
+ assert_eq!(status.integrity, "healthy");
+ runtime.shutdown().await.expect("shutdown");
+ assert!(runtime.sdk_storage_status().await.is_err());
+ }
+}
diff --git a/core/crates/tera_core/src/runtime/store.rs b/core/crates/tera_core/src/runtime/store.rs
@@ -0,0 +1,257 @@
+//! Validated host contract for one authenticated mobile user's durable store.
+
+use std::{
+ path::{Component, Path, PathBuf},
+ time::Duration,
+};
+
+use radroots_identity::PublicKey;
+use radroots_storage::event::SourceGeneration;
+
+use crate::RadrootsAppError;
+
+const PRODUCT_DIRECTORY: &str = "radroots";
+const USER_DIRECTORY: &str = "users";
+const GENERATION_HEX_LENGTH: usize = 64;
+
+/// Host-observed Apple protected-data state at runtime construction time.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ProtectedDataAvailability {
+ Available,
+ Unavailable,
+}
+
+/// Validated composition for one authenticated user's SQLite owner directory.
+///
+/// The Apple host owns directory creation and data-protection attributes. Rust
+/// derives the exact identity-scoped suffix and refuses alternate, relative,
+/// or symlinked directory layouts before SQLite is opened.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MobileUserStoreConfig {
+ application_support_directory: PathBuf,
+ owner_directory: PathBuf,
+ public_key: PublicKey,
+ source_generation: SourceGeneration,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+}
+
+impl MobileUserStoreConfig {
+ /// Validates encoded host values without touching SQLite.
+ pub fn from_encoded(
+ application_support_directory: impl Into<PathBuf>,
+ public_key_hex: &str,
+ source_generation_hex: &str,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ ) -> Result<Self, RadrootsAppError> {
+ let public_key = PublicKey::from_hex(public_key_hex)
+ .map_err(|_| RadrootsAppError::store_invalid_configuration())?;
+ let source_generation = parse_source_generation(source_generation_hex)?;
+ Self::new(
+ application_support_directory,
+ public_key,
+ source_generation,
+ source_generation_created_at_unix_ms,
+ protected_data,
+ )
+ }
+
+ /// Creates a validated store configuration from canonical typed values.
+ pub fn new(
+ application_support_directory: impl Into<PathBuf>,
+ public_key: PublicKey,
+ source_generation: SourceGeneration,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ ) -> Result<Self, RadrootsAppError> {
+ let application_support_directory = application_support_directory.into();
+ validate_absolute_normal_directory(&application_support_directory)?;
+ if source_generation_created_at_unix_ms == 0
+ || i64::try_from(source_generation_created_at_unix_ms).is_err()
+ {
+ return Err(RadrootsAppError::store_invalid_configuration());
+ }
+ let owner_directory = application_support_directory
+ .join(PRODUCT_DIRECTORY)
+ .join(USER_DIRECTORY)
+ .join(public_key.to_hex());
+ Ok(Self {
+ application_support_directory,
+ owner_directory,
+ public_key,
+ source_generation,
+ source_generation_created_at_unix_ms,
+ protected_data,
+ })
+ }
+
+ /// Returns the host-owned Application Support root.
+ pub fn application_support_directory(&self) -> &Path {
+ self.application_support_directory.as_path()
+ }
+
+ /// Returns the exact existing directory that must own both SQLite files.
+ pub fn owner_directory(&self) -> &Path {
+ self.owner_directory.as_path()
+ }
+
+ /// Returns the authenticated identity that scopes this store.
+ pub const fn public_key(&self) -> PublicKey {
+ self.public_key
+ }
+
+ pub(crate) const fn protected_data(&self) -> ProtectedDataAvailability {
+ self.protected_data
+ }
+
+ pub(crate) fn validate_host_filesystem(&self) -> Result<(), RadrootsAppError> {
+ let directories = [
+ self.application_support_directory.clone(),
+ self.application_support_directory
+ .join(PRODUCT_DIRECTORY)
+ .to_path_buf(),
+ self.application_support_directory
+ .join(PRODUCT_DIRECTORY)
+ .join(USER_DIRECTORY)
+ .to_path_buf(),
+ self.owner_directory.clone(),
+ ];
+ for directory in directories {
+ let metadata = std::fs::symlink_metadata(&directory)
+ .map_err(|_| RadrootsAppError::store_path_unavailable())?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(RadrootsAppError::store_invalid_configuration());
+ }
+ }
+ Ok(())
+ }
+
+ pub(crate) fn sqlite_options(
+ &self,
+ ) -> Result<radroots_sdk::storage::SqliteOptions, RadrootsAppError> {
+ let paths = radroots_sdk::storage::SqlitePaths::from_directory(&self.owner_directory)
+ .map_err(|_| RadrootsAppError::store_invalid_configuration())?;
+ radroots_sdk::storage::SqliteOptions::new(
+ paths,
+ radroots_sdk::storage::SqliteOpenMode::Create,
+ )
+ .with_busy_timeout(Duration::from_secs(5))
+ .and_then(|options| {
+ options.with_source_generation(
+ self.source_generation,
+ self.source_generation_created_at_unix_ms,
+ )
+ })
+ .map_err(|_| RadrootsAppError::store_invalid_configuration())
+ }
+}
+
+fn parse_source_generation(value: &str) -> Result<SourceGeneration, RadrootsAppError> {
+ if value.len() != GENERATION_HEX_LENGTH {
+ return Err(RadrootsAppError::store_invalid_configuration());
+ }
+ let bytes = hex::decode(value).map_err(|_| RadrootsAppError::store_invalid_configuration())?;
+ let bytes: [u8; 32] = bytes
+ .try_into()
+ .map_err(|_| RadrootsAppError::store_invalid_configuration())?;
+ SourceGeneration::new(bytes).map_err(|_| RadrootsAppError::store_invalid_configuration())
+}
+
+fn validate_absolute_normal_directory(path: &Path) -> Result<(), RadrootsAppError> {
+ if !path.is_absolute()
+ || path
+ .components()
+ .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
+ {
+ return Err(RadrootsAppError::store_invalid_configuration());
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+ const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101";
+
+ #[test]
+ fn encoded_scope_derives_the_exact_user_directory() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let config = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ PUBLIC_KEY,
+ GENERATION,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("config");
+ assert_eq!(
+ config.owner_directory(),
+ root.path().join("radroots").join("users").join(PUBLIC_KEY)
+ );
+ assert_eq!(config.public_key().to_hex(), PUBLIC_KEY);
+ }
+
+ #[test]
+ fn encoded_scope_rejects_invalid_identity_generation_time_and_path() {
+ let root = tempfile::tempdir().expect("tempdir");
+ for result in [
+ MobileUserStoreConfig::from_encoded(
+ "relative",
+ PUBLIC_KEY,
+ GENERATION,
+ 1,
+ ProtectedDataAvailability::Available,
+ ),
+ MobileUserStoreConfig::from_encoded(
+ root.path(),
+ "bad",
+ GENERATION,
+ 1,
+ ProtectedDataAvailability::Available,
+ ),
+ MobileUserStoreConfig::from_encoded(
+ root.path(),
+ PUBLIC_KEY,
+ "00",
+ 1,
+ ProtectedDataAvailability::Available,
+ ),
+ MobileUserStoreConfig::from_encoded(
+ root.path(),
+ PUBLIC_KEY,
+ GENERATION,
+ 0,
+ ProtectedDataAvailability::Available,
+ ),
+ ] {
+ assert!(matches!(result, Err(RadrootsAppError::Store { .. })));
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn host_filesystem_rejects_a_symlinked_user_scope() {
+ use std::os::unix::fs::symlink;
+
+ let root = tempfile::tempdir().expect("tempdir");
+ let config = MobileUserStoreConfig::from_encoded(
+ root.path(),
+ PUBLIC_KEY,
+ GENERATION,
+ 1,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("config");
+ std::fs::create_dir_all(root.path().join(PRODUCT_DIRECTORY).join(USER_DIRECTORY))
+ .expect("parents");
+ let target = tempfile::tempdir().expect("target");
+ symlink(target.path(), config.owner_directory()).expect("symlink");
+ assert!(matches!(
+ config.validate_host_filesystem(),
+ Err(RadrootsAppError::Store { .. })
+ ));
+ }
+}
diff --git a/core/crates/tera_core/tests/durable_runtime.rs b/core/crates/tera_core/tests/durable_runtime.rs
@@ -0,0 +1,111 @@
+use tera_core::{
+ RadrootsAppError,
+ runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ },
+};
+
+mod support;
+
+fn other_generation_store(root: &std::path::Path) -> MobileUserStoreConfig {
+ MobileUserStoreConfig::from_encoded(
+ root,
+ support::PUBLIC_KEY,
+ "0505050505050505050505050505050505050505050505050505050505050505",
+ 1_800_000_000_001,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("alternate store config")
+}
+
+#[tokio::test]
+async fn cold_create_shutdown_and_reopen_preserve_the_sqlite_store() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let store = support::store(root.path());
+ let runtime = RuntimeBuilder::new(store.clone())
+ .build()
+ .await
+ .expect("cold create");
+ let status = runtime.sdk_storage_status().await.expect("status");
+ assert_eq!(
+ runtime.authenticated_store_public_key_hex().as_deref(),
+ Some(support::PUBLIC_KEY)
+ );
+ assert_eq!(status.backend, "sqlite");
+ assert_eq!(status.open_mode, "create");
+ assert_eq!(status.integrity, "unknown");
+ assert!(store.owner_directory().join("runtime.sqlite").is_file());
+ assert!(store.owner_directory().join("private.sqlite").is_file());
+ runtime.shutdown().await.expect("shutdown");
+
+ let reopened = RuntimeBuilder::new(store).build().await.expect("reopen");
+ assert_eq!(
+ reopened.sdk_storage_status().await.expect("status").backend,
+ "sqlite"
+ );
+ reopened.shutdown().await.expect("shutdown");
+}
+
+#[tokio::test]
+async fn one_authenticated_user_store_has_one_writable_runtime() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let store = support::store(root.path());
+ let first = RuntimeBuilder::new(store.clone())
+ .build()
+ .await
+ .expect("first runtime");
+ let second = RuntimeBuilder::new(store.clone()).build().await;
+ let Err(RadrootsAppError::Sdk { report }) = second else {
+ panic!("second writable runtime must fail with a typed SDK error");
+ };
+ assert_eq!(report.code, "database_busy");
+ assert!(report.retryable);
+
+ first.shutdown().await.expect("first shutdown");
+ let recovered = RuntimeBuilder::new(store)
+ .build()
+ .await
+ .expect("writer lock recovery");
+ recovered.shutdown().await.expect("recovered shutdown");
+}
+
+#[tokio::test]
+async fn source_generation_mismatch_is_integrity_classified() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let store = support::store(root.path());
+ let runtime = RuntimeBuilder::new(store).build().await.expect("runtime");
+ runtime.shutdown().await.expect("shutdown");
+
+ let result = RuntimeBuilder::new(other_generation_store(root.path()))
+ .build()
+ .await;
+ let Err(RadrootsAppError::Sdk { report }) = result else {
+ panic!("generation mismatch must fail with a typed SDK error");
+ };
+ assert_eq!(report.code, "storage_integrity_failed");
+ assert!(!report.retryable);
+}
+
+#[tokio::test]
+async fn unrecognized_sqlite_bytes_are_corruption_classified() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let store = support::store(root.path());
+ let runtime = RuntimeBuilder::new(store.clone())
+ .build()
+ .await
+ .expect("runtime");
+ runtime.shutdown().await.expect("shutdown");
+ std::fs::write(
+ store.owner_directory().join("runtime.sqlite"),
+ b"not a sqlite database",
+ )
+ .expect("replace runtime database with corrupt fixture");
+
+ let result = RuntimeBuilder::new(store).build().await;
+ let Err(RadrootsAppError::Sdk { report }) = result else {
+ panic!("corrupt store must fail with a typed SDK error");
+ };
+ assert_eq!(report.code, "storage_integrity_failed");
+ assert!(!report.retryable);
+}
diff --git a/core/crates/tera_core/tests/package_boundary.rs b/core/crates/tera_core/tests/package_boundary.rs
@@ -0,0 +1,101 @@
+const MANIFEST: &str = include_str!("../Cargo.toml");
+const LIB: &str = include_str!("../src/lib.rs");
+const ERROR: &str = include_str!("../src/error.rs");
+const RUNTIME: &str = include_str!("../src/runtime/mod.rs");
+const APP_INFO: &str = include_str!("../src/runtime/app_info.rs");
+const INFO: &str = include_str!("../src/runtime/info.rs");
+const PRODUCT_SURFACE: &str = include_str!("../src/runtime/product_surface.rs");
+const PRODUCT_AUTHORING: &str = include_str!("../src/runtime/product_surface/authoring.rs");
+const PRODUCT_CONTEXT: &str = include_str!("../src/runtime/product_surface/context.rs");
+const PRODUCT_CURSOR: &str = include_str!("../src/runtime/product_surface/cursor.rs");
+const PRODUCT_IDENTITY: &str = include_str!("../src/runtime/product_surface/identity.rs");
+const PRODUCT_MODEL: &str = include_str!("../src/runtime/product_surface/model.rs");
+const PRODUCT_OUTBOX: &str = include_str!("../src/runtime/product_surface/outbox.rs");
+const PRODUCT_PROJECTION: &str = include_str!("../src/runtime/product_surface/projection.rs");
+const PRODUCT_RANKING: &str = include_str!("../src/runtime/product_surface/ranking.rs");
+const SDK: &str = include_str!("../src/runtime/sdk.rs");
+const BUILDER: &str = include_str!("../src/runtime/builder.rs");
+const STORE: &str = include_str!("../src/runtime/store.rs");
+
+#[test]
+fn core_owns_no_uniffi_or_process_global_logging_policy() {
+ for (name, source) in [
+ ("Cargo.toml", MANIFEST),
+ ("src/lib.rs", LIB),
+ ("src/error.rs", ERROR),
+ ("src/runtime/mod.rs", RUNTIME),
+ ("src/runtime/app_info.rs", APP_INFO),
+ ("src/runtime/info.rs", INFO),
+ ("src/runtime/product_surface.rs", PRODUCT_SURFACE),
+ (
+ "src/runtime/product_surface/authoring.rs",
+ PRODUCT_AUTHORING,
+ ),
+ ("src/runtime/product_surface/context.rs", PRODUCT_CONTEXT),
+ ("src/runtime/product_surface/cursor.rs", PRODUCT_CURSOR),
+ ("src/runtime/product_surface/identity.rs", PRODUCT_IDENTITY),
+ ("src/runtime/product_surface/model.rs", PRODUCT_MODEL),
+ ("src/runtime/product_surface/outbox.rs", PRODUCT_OUTBOX),
+ (
+ "src/runtime/product_surface/projection.rs",
+ PRODUCT_PROJECTION,
+ ),
+ ("src/runtime/product_surface/ranking.rs", PRODUCT_RANKING),
+ ("src/runtime/sdk.rs", SDK),
+ ] {
+ assert!(
+ !source.to_ascii_lowercase().contains("uniffi"),
+ "{name} contains UniFFI boundary policy"
+ );
+ assert!(
+ !source.contains("tracing_subscriber") && !source.contains("set_global_default"),
+ "{name} contains process-global logging policy"
+ );
+ }
+}
+
+#[test]
+fn mobile_runtime_has_no_secret_taking_or_local_signer_slot_surface() {
+ for source in [
+ MANIFEST,
+ RUNTIME,
+ BUILDER,
+ PRODUCT_AUTHORING,
+ PRODUCT_OUTBOX,
+ ] {
+ assert!(!source.contains("signing::Slot"));
+ assert!(!source.contains("secret_key: String"));
+ assert!(!source.contains("Provider::slot"));
+ }
+ assert!(!MANIFEST.contains("radroots_sdk/local-signing"));
+}
+
+#[test]
+fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() {
+ assert!(MANIFEST.contains("radroots_sdk = { workspace = true, features = [\"sqlite\"] }"));
+ assert_eq!(BUILDER.matches("ClientBuilder::sqlite").count(), 1);
+ assert!(!BUILDER.contains("memory_default") && !STORE.contains("memory_default"));
+ assert!(RUNTIME.contains("#[cfg(test)]\n pub(crate) fn test_memory()"));
+ assert!(!RUNTIME.contains("pub fn new()"));
+}
+
+#[test]
+fn mobile_core_reuses_the_final_sdk_evidence_vocabulary() {
+ for required in [
+ "RadrootsRhiEvidenceReportV1",
+ "RadrootsTradeEvidenceCoverageV1",
+ "RadrootsTradeEvidenceManifestV1",
+ "RadrootsTradeEvidenceOutcomeV1",
+ ] {
+ assert!(
+ SDK.contains(required),
+ "missing SDK evidence type `{required}`"
+ );
+ }
+ for forbidden in ["SecretKey", "sign_event", "publish_event", "tokio::spawn"] {
+ assert!(
+ !SDK.contains(forbidden),
+ "mobile evidence projection gained forbidden authority `{forbidden}`"
+ );
+ }
+}
diff --git a/core/crates/tera_core/tests/sdk_runtime.rs b/core/crates/tera_core/tests/sdk_runtime.rs
@@ -0,0 +1,55 @@
+use std::sync::Arc;
+
+use tera_core::{RadrootsAppError, RadrootsRuntime};
+
+mod support;
+
+#[tokio::test]
+async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() {
+ fn require_send_sync<T: Send + Sync>() {}
+ require_send_sync::<RadrootsRuntime>();
+
+ let (_root, runtime) = support::runtime().await;
+ let runtime = Arc::new(runtime);
+ let worker = {
+ let runtime = Arc::clone(&runtime);
+ std::thread::spawn(move || runtime.sdk_capabilities())
+ };
+ let capabilities = worker.join().expect("worker");
+ assert!(
+ capabilities
+ .iter()
+ .any(|capability| capability.id == "storage.canonical")
+ );
+ let first = runtime.shutdown().await.expect("first shutdown");
+ let second = runtime.shutdown().await.expect("second shutdown");
+ assert!(!first.already_closed);
+ assert!(second.already_closed);
+ assert!(runtime.info().sdk_closed);
+}
+
+#[tokio::test]
+async fn operations_fail_safely_after_explicit_close() {
+ let (_root, runtime) = support::runtime().await;
+ assert_eq!(
+ runtime.sdk_storage_status().await.expect("status").backend,
+ "sqlite"
+ );
+ runtime.shutdown().await.expect("shutdown");
+ assert!(matches!(
+ runtime.sdk_storage_status().await,
+ Err(RadrootsAppError::Sdk { .. })
+ ));
+}
+
+#[tokio::test]
+async fn dropping_unpolled_shutdown_has_no_effect_and_retry_closes() {
+ let (_root, runtime) = support::runtime().await;
+ drop(runtime.shutdown());
+ assert!(!runtime.info().sdk_closed);
+ assert!(!runtime.info().app.shutting_down);
+
+ runtime.shutdown().await.expect("retry shutdown");
+ assert!(runtime.info().sdk_closed);
+ assert!(runtime.info().app.shutting_down);
+}
diff --git a/core/crates/tera_core/tests/support/mod.rs b/core/crates/tera_core/tests/support/mod.rs
@@ -0,0 +1,33 @@
+use tera_core::{
+ RadrootsRuntime,
+ runtime::{
+ builder::RuntimeBuilder,
+ store::{MobileUserStoreConfig, ProtectedDataAvailability},
+ },
+};
+
+pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
+pub const GENERATION: &str = "0303030303030303030303030303030303030303030303030303030303030303";
+
+pub fn store(root: &std::path::Path) -> MobileUserStoreConfig {
+ let store = MobileUserStoreConfig::from_encoded(
+ root,
+ PUBLIC_KEY,
+ GENERATION,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .expect("store config");
+ std::fs::create_dir_all(store.owner_directory()).expect("owner directory");
+ store
+}
+
+#[allow(dead_code)]
+pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) {
+ let root = tempfile::tempdir().expect("tempdir");
+ let runtime = RuntimeBuilder::new(store(root.path()))
+ .build()
+ .await
+ .expect("runtime");
+ (root, runtime)
+}
diff --git a/core/provenance/tera_core/LICENSE-APACHE b/core/provenance/tera_core/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_core/LICENSE-GPL-3.0-only b/core/provenance/tera_core/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_core/LICENSE-GPL-3.0-or-later b/core/provenance/tera_core/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_core/LICENSE-MIT b/core/provenance/tera_core/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.