commit 6fc190f6e7bcbeb5b58a86fed6b31dd0f18225a8
parent e25819267b51f659e5dfdf7b318239a8969bf45c
Author: triesap <tyson@radroots.org>
Date: Tue, 8 Sep 2026 17:25:22 +0000
tera: pin migration compatibility fixtures
- Freeze installed bundle and custody namespaces against parsed configuration.
- Preserve a synthetic queued operation and card identity through the real FFI reader.
- Verify signed operation recovery with an ephemeral signer and unchanged locked versions.
- Pass two Rust persistence tests, 44 package tests and 89 native tests without skips.
Diffstat:
6 files changed, 283 insertions(+), 0 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1721,7 +1721,12 @@ dependencies = [
name = "radroots_ios_source_lock"
version = "0.1.0-alpha"
dependencies = [
+ "async-trait",
"radroots_mobile_ffi",
+ "secp256k1",
+ "serde_json",
+ "tempfile",
+ "tokio",
]
[[package]]
diff --git a/crates/source_lock/Cargo.toml b/crates/source_lock/Cargo.toml
@@ -12,5 +12,12 @@ publish = false
[dependencies]
radroots_mobile_ffi = { workspace = true }
+[dev-dependencies]
+async-trait = "=0.1.91"
+secp256k1 = { version = "=0.29.1", features = ["rand-std"] }
+serde_json = "=1.0.151"
+tempfile = "=3.27.0"
+tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread"] }
+
[lints]
workspace = true
diff --git a/crates/source_lock/tests/compatibility.rs b/crates/source_lock/tests/compatibility.rs
@@ -0,0 +1,201 @@
+//! Pre-transfer compatibility at the consumed FFI boundary. All stores are
+//! isolated fixtures; signer keys are generated in memory and never serialized.
+
+use radroots_mobile_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiDraftStatusRecord,
+ FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest,
+ HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner,
+ RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord,
+};
+use secp256k1::{Keypair, Message, Secp256k1};
+use serde_json::Value;
+use std::path::Path;
+
+fn fixture() -> Value {
+ serde_json::from_str(include_str!(
+ "../../../test-fixtures/tera-compatibility.v1.json"
+ ))
+ .expect("checked synthetic fixture")
+}
+
+fn field<'a>(fixture: &'a Value, key: &str) -> &'a str {
+ fixture[key].as_str().expect("fixture string")
+}
+
+fn input(fixture: &Value) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: field(fixture, "content").to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: Vec::new(),
+ }
+}
+
+async fn runtime(root: &Path, public_key: &str, signer: Option<Keypair>) -> RadrootsRuntime {
+ std::fs::create_dir_all(root.join("radroots/users").join(public_key))
+ .expect("isolated application owner directory");
+ let fixture = fixture();
+ let generation = field(&fixture["queued_update"], "source_generation").to_owned();
+ if let Some(keypair) = signer {
+ RadrootsRuntime::with_host_signer(
+ root.to_string_lossy().into_owned(),
+ public_key.to_owned(),
+ generation,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(EphemeralSigner(keypair)),
+ )
+ .await
+ .expect("runtime with ephemeral fixture signer")
+ } else {
+ RadrootsRuntime::new(
+ root.to_string_lossy().into_owned(),
+ public_key.to_owned(),
+ generation,
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("runtime using current persistent reader")
+ }
+}
+
+async fn queue(runtime: &RadrootsRuntime, fixture: &Value) -> FfiDraftStatusRecord {
+ let id = field(fixture, "draft_id");
+ let persisted = fixture["persisted_at_unix_ms"].as_u64().unwrap();
+ let saved = runtime
+ .phase1_save_draft(
+ id.to_owned(),
+ input(fixture),
+ fixture["authored_at_unix_s"].as_u64().unwrap(),
+ None,
+ persisted,
+ )
+ .await
+ .expect("save synthetic draft through real FFI");
+ let policy = FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: fixture["relay_urls"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|value| value.as_str().unwrap().to_owned())
+ .collect(),
+ satisfaction: FfiRelaySatisfaction::AllAccepted,
+ delivery_deadline_unix_ms: fixture["delivery_deadline_unix_ms"].as_u64().unwrap(),
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ };
+ runtime
+ .phase1_queue_draft(id.to_owned(), saved.revision, policy, persisted + 1)
+ .await
+ .expect("queue locally without starting relay delivery")
+}
+
+#[tokio::test]
+async fn queued_update_reopens_with_frozen_operation_and_card_identity() {
+ let fixture = fixture()["queued_update"].clone();
+ let root = tempfile::tempdir().unwrap();
+ let public_key = field(&fixture, "public_key");
+ let first = runtime(root.path(), public_key, None).await;
+ let queued = queue(&first, &fixture).await;
+ assert_eq!(
+ queued.operation_id.as_deref(),
+ Some(field(&fixture, "expected_operation_id"))
+ );
+ assert_eq!(queued.card_id, field(&fixture, "expected_card_id"));
+ first.shutdown().await.unwrap();
+ drop(first);
+ let reopened = runtime(root.path(), public_key, None).await;
+ let restored = reopened
+ .phase1_draft_status(field(&fixture, "draft_id").to_owned())
+ .await
+ .expect("existing persisted draft reader");
+ assert_eq!(restored, queued);
+ let recovered = reopened
+ .phase1_recover_draft_queue(field(&fixture, "draft_id").to_owned(), 1_900_000_000_002)
+ .await
+ .expect("existing queued operation reader");
+ assert_eq!(recovered, queued);
+ reopened.shutdown().await.unwrap();
+}
+
+#[tokio::test]
+async fn signed_operation_reopens_without_replacing_its_author_or_identity() {
+ let fixture = fixture()["queued_update"].clone();
+ let root = tempfile::tempdir().unwrap();
+ let keypair = Keypair::new(&Secp256k1::new(), &mut secp256k1::rand::thread_rng());
+ let public_key = keypair.x_only_public_key().0.to_string();
+ let first = runtime(root.path(), &public_key, Some(keypair)).await;
+ let queued = queue(&first, &fixture).await;
+ let signed = first
+ .phase1_sign_queued_draft(queued.draft_id.clone(), queued.revision)
+ .await
+ .expect("sign and durably admit the fixed operation");
+ assert_eq!(signed.operation_id, queued.operation_id);
+ assert_eq!(signed.card_id, queued.card_id);
+ assert_eq!(signed.author_public_key, public_key);
+ assert_eq!(signed.settlement.unwrap().signed, 1);
+ first.shutdown().await.unwrap();
+ drop(first);
+ // Reopening has no signer. Reading must preserve the already signed facts.
+ let reopened = runtime(root.path(), &public_key, None).await;
+ let restored = reopened
+ .phase1_draft_status(signed.draft_id.clone())
+ .await
+ .unwrap();
+ assert_eq!(restored, signed);
+ reopened.shutdown().await.unwrap();
+}
+
+struct EphemeralSigner(Keypair);
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for EphemeralSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ assert_eq!(request.public_key, self.0.x_only_public_key().0.to_string());
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .try_into()
+ .expect("exact event digest");
+ let signature =
+ Secp256k1::new().sign_schnorr_no_aux_rand(&Message::from_digest(digest), &self.0);
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Signed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: Some(signature.to_string()),
+ completed_at_unix_ms: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_millis()
+ .try_into()
+ .unwrap(),
+ }
+ }
+}
diff --git a/scripts/package_contract.py b/scripts/package_contract.py
@@ -527,6 +527,30 @@ def _verify_apple_configuration(root: Path) -> None:
"http:$(SLASH)$(SLASH)127.0.0.1:21100",
"debug Blossom origin",
)
+ _verify_installation_compatibility(root, base, debug)
+
+
+def _verify_installation_compatibility(
+ root: Path, base: dict[str, str], debug: dict[str, str]
+) -> None:
+ baseline = _read_json(root / "test-fixtures/tera-compatibility.v1.json")
+ _exact(
+ baseline.get("schema"), "tera.compatibility-baseline.v1", "compatibility schema"
+ )
+ production = parse_xcconfig_assignments(
+ _read_text(root / "Radroots/radroots.xcconfig")
+ )
+ actual = {
+ "production_bundle_identifier": production.get("PRODUCT_BUNDLE_IDENTIFIER"),
+ "debug_bundle_identifier": debug.get("PRODUCT_BUNDLE_IDENTIFIER"),
+ "production_keychain_service_prefix": base.get(
+ "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX"
+ ),
+ "debug_keychain_service_prefix": debug.get(
+ "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX"
+ ),
+ }
+ _exact(actual, baseline.get("installation"), "installed identity compatibility")
def _verify_package_locks(root: Path, apple_revision: str) -> None:
diff --git a/scripts/test_package_contract.py b/scripts/test_package_contract.py
@@ -120,6 +120,25 @@ class PackageContractTests(unittest.TestCase):
self.assertEqual(version, "0.1.0-alpha")
self.assertRegex(revision, r"^[0-9a-f]{40}$")
+ def test_compatibility_baseline_rejects_bundle_or_custody_rename(self) -> None:
+ root = SCRIPTS.parent
+ base = contract.parse_xcconfig_assignments(
+ (root / "Radroots/Config/Base.xcconfig").read_text()
+ )
+ debug = contract.parse_xcconfig_assignments(
+ (root / "Radroots/Config/Debug.xcconfig").read_text()
+ )
+ for key in (
+ "PRODUCT_BUNDLE_IDENTIFIER",
+ "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX",
+ ):
+ with self.subTest(key=key):
+ changed = {**debug, key: "org.tera.accidental-rename"}
+ with self.assertRaisesRegex(
+ contract.PackageContractError, "identity compatibility"
+ ):
+ contract._verify_installation_compatibility(root, base, changed)
+
def test_comment_token_does_not_define_xcconfig_field(self) -> None:
values = contract.parse_xcconfig_assignments(
"// RADROOTS_FIELD_IOS_RUNTIME_MODE = production\n"
diff --git a/test-fixtures/tera-compatibility.v1.json b/test-fixtures/tera-compatibility.v1.json
@@ -0,0 +1,27 @@
+{
+ "schema": "tera.compatibility-baseline.v1",
+ "provenance": {
+ "tera_commit": "e25819267b51f659e5dfdf7b318239a8969bf45c",
+ "consumed_lib_commit": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb",
+ "apple_kit_commit": "35aedb6b54ff645b663fecff26082b3e91fcb232",
+ "purpose": "Synthetic compatibility inputs and exact pre-transfer source observations; no credentials or captured user state. Source revisions are provenance, not a prohibition on later approved producer adoption."
+ },
+ "installation": {
+ "production_bundle_identifier": "org.radroots.field-ios",
+ "debug_bundle_identifier": "dev.local.radroots",
+ "production_keychain_service_prefix": "org.radroots.field_ios",
+ "debug_keychain_service_prefix": "org.radroots.field_ios.local"
+ },
+ "queued_update": {
+ "public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ "source_generation": "0404040404040404040404040404040404040404040404040404040404040404",
+ "draft_id": "07070707070707070707070707070707",
+ "content": "Synthetic Tera compatibility update",
+ "authored_at_unix_s": 1900000000,
+ "persisted_at_unix_ms": 1900000000000,
+ "delivery_deadline_unix_ms": 2000000000000,
+ "relay_urls": ["wss://relay-one.example", "wss://relay-two.example"],
+ "expected_operation_id": "9a1de0df9497259052ae7c68a7aee1b7",
+ "expected_card_id": "371d75785c77f9f8c313aa1e293c24f7d87abf1e6ca9b35d84b605be1f3dfdb6"
+ }
+}