diff --git a/desktop/src-tauri/src/managed_agents/library.rs b/desktop/src-tauri/src/managed_agents/library.rs index 16e7edab4..7360d898d 100644 --- a/desktop/src-tauri/src/managed_agents/library.rs +++ b/desktop/src-tauri/src/managed_agents/library.rs @@ -588,5 +588,91 @@ fn identity_collisions<'a>( colliders } +// ── §2.5 pure identity-binding helpers ──────────────────────────────────────── + +impl LibraryDocument { + /// The §2.5 deletion-protection predicate (P13-C1, widened by P17-C1): + /// answers, purely from `library.json`, whether the key for `agent_pubkey` + /// must NEVER be deleted or its identity archived in v1. `true` iff ANY + /// entry — live or tombstoned, any `deleted` value, any projection state — + /// has EVER bound this pubkey, OR any outstanding `deferred_archives` row + /// names it. Every removal path (direct delete, persona cascade, inbound + /// kind:5, forced delete, §3.4/§3.5 cascades, the finalizer, every recovery + /// point) MUST consult this for the record's pubkey before `delete_agent_key` + /// or NIP-IA archival, regardless of the record's own linkage — the keyring + /// is process-global by pubkey, so an unrelated plain carrier of a bound + /// pubkey would otherwise destroy the binding's one global identity. + /// + /// Scans the RAW entries (not the decoded healthy set) so a QUARANTINED + /// entry that names the pubkey still protects it: mis-deleting a live key is + /// catastrophic and irreversible, while over-protecting only leaves a secret + /// resident — the v1 posture is conservative by design. Ever-bound is + /// observable forever because tombstoned entries and their binding records + /// are kept forever (P1-OQ2). + pub fn key_archive_protected(&self, agent_pubkey: &str) -> bool { + self.entries + .iter() + .any(|entry| entry_names_agent(entry, agent_pubkey)) + } +} + +/// Whether one raw entry `Value` binds `agent_pubkey` in `identity_bindings` or +/// carries an outstanding `deferred_archives` row for it (§2.5). Field-name +/// exact matches on the raw JSON so a quarantined entry still counts. +fn entry_names_agent(entry: &Value, agent_pubkey: &str) -> bool { + let bound = entry + .get("identity_bindings") + .and_then(Value::as_object) + .is_some_and(|bindings| { + bindings + .values() + .any(|b| b.get("agent_pubkey").and_then(Value::as_str) == Some(agent_pubkey)) + }); + if bound { + return true; + } + entry + .get("deferred_archives") + .and_then(Value::as_array) + .is_some_and(|rows| { + rows.iter() + .any(|r| r.get("agent_pubkey").and_then(Value::as_str) == Some(agent_pubkey)) + }) +} + +/// One linked instance's identity coordinates for §2.5 seed selection. +pub(crate) struct SeedCandidate<'a> { + /// ISO-8601 UTC creation timestamp — same format for every record, so + /// lexical order equals chronological order. + pub created_at: &'a str, + pub pubkey: &'a str, +} + +/// Deterministic binding-seed selection (§2.5, P3-I2): among the linked +/// instances that exist at share/first-deploy time, the seed is the instance +/// with the earliest `created_at`, ties broken by the lowest pubkey — the +/// longest-lived identity collaborators are most likely to know. `None` is +/// legal ONLY when zero instances exist (no identity to carry yet; the first +/// deploy anywhere seeds the binding). The chosen pubkey is what carries across +/// the owner's scopes; the others keep their identities locally, unchanged. +/// +/// Pure selection only; the caller performs the §2.5 mint/verify protocol on +/// the result inside the insertion transaction (P8-C2, Phase 4b). +pub(crate) fn select_binding_seed<'a>( + instances: impl IntoIterator>, +) -> Option<&'a str> { + instances + .into_iter() + .min_by(|a, b| { + a.created_at + .cmp(b.created_at) + .then_with(|| a.pubkey.cmp(b.pubkey)) + }) + .map(|winner| winner.pubkey) +} + #[cfg(test)] mod tests; + +#[cfg(test)] +mod binding_tests; diff --git a/desktop/src-tauri/src/managed_agents/library/binding_tests.rs b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs new file mode 100644 index 000000000..b954677b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs @@ -0,0 +1,149 @@ +//! Phase-3 §2.5 pure identity-binding helper tests: the `key_archive_protected` +//! deletion-safety predicate (P13-C1/P17-C1) and deterministic `select_binding_seed` +//! (P3-I2). Both are keyring-free pure functions; the crash-safe mint protocol and +//! their transaction wiring land later (Phase 4b). Kept in their own module so the +//! Phase-1 `tests.rs` suite stays clear of the 1000-line file ratchet. + +use serde_json::json; + +use super::*; + +// ── key_archive_protected ────────────────────────────────────────────────────── + +/// A raw entry `Value` binding `agent_pubkey` under one owner, with the given +/// `deleted` tombstone flag. Only the fields the predicate reads are populated — +/// the predicate scans raw entries, so a partial value is a faithful stand-in +/// for both a healthy and a quarantined on-disk entry. The owner map key is a +/// fixed placeholder: the predicate reads binding values, never the key. +fn bound_entry(agent_pubkey: &str, deleted: bool) -> serde_json::Value { + json!({ + "deleted": deleted, + "identity_bindings": { + "owner-key-placeholder": { "agent_pubkey": agent_pubkey, "auth_tag": "{}" } + } + }) +} + +fn deferred_entry(agent_pubkey: &str) -> serde_json::Value { + json!({ + "deferred_archives": [ { "scope_id": "scope-1", "agent_pubkey": agent_pubkey } ] + }) +} + +fn library(entries: Vec) -> LibraryDocument { + LibraryDocument { + version: SUPPORTED_LIBRARY_VERSION, + entries, + orphan_keys: vec![], + } +} + +#[test] +fn test_ever_bound_live_entry_protects_key() { + let target = "aa".repeat(32); + let doc = library(vec![bound_entry(&target, false)]); + assert!(doc.key_archive_protected(&target)); +} + +#[test] +fn test_ever_bound_tombstoned_entry_still_protects_key() { + // Tombstones are kept forever (P1-OQ2); a deleted entry that once bound the + // pubkey must keep protecting it — the global keyring identity outlives the + // tombstone. + let target = "bb".repeat(32); + let doc = library(vec![bound_entry(&target, true)]); + assert!(doc.key_archive_protected(&target)); +} + +#[test] +fn test_quarantined_entry_binding_pubkey_protects_key() { + // A structurally invalid entry (would be quarantined on decode) that still + // names the pubkey in identity_bindings must protect it: the predicate scans + // RAW entries so quarantine can never strip protection. + let target = "cc".repeat(32); + let garbage = json!({ + "not_a_real_field": 7, + "identity_bindings": { + "owner-key-placeholder": { "agent_pubkey": target, "auth_tag": "{}" } + } + }); + let doc = library(vec![garbage]); + assert!(doc.key_archive_protected(&target)); +} + +#[test] +fn test_outstanding_deferred_archive_protects_key() { + // A deferred-archive obligation with no surviving binding must still protect: + // the retirement marker is the record that the key was never archived. + let target = "dd".repeat(32); + let doc = library(vec![deferred_entry(&target)]); + assert!(doc.key_archive_protected(&target)); +} + +#[test] +fn test_unrelated_entry_does_not_protect_key() { + let target = "ee".repeat(32); + let other = "11".repeat(32); + let doc = library(vec![bound_entry(&other, false), deferred_entry(&other)]); + assert!(!doc.key_archive_protected(&target)); +} + +#[test] +fn test_empty_library_protects_nothing() { + let doc = library(vec![]); + assert!(!doc.key_archive_protected(&"22".repeat(32))); +} + +// ── select_binding_seed ───────────────────────────────────────────────────────── + +#[test] +fn test_seed_picks_earliest_created() { + let seed = select_binding_seed(vec![ + SeedCandidate { + created_at: "2026-08-11T10:00:00Z", + pubkey: "aaa", + }, + SeedCandidate { + created_at: "2026-08-11T09:00:00Z", + pubkey: "bbb", + }, + SeedCandidate { + created_at: "2026-08-11T11:00:00Z", + pubkey: "ccc", + }, + ]); + assert_eq!(seed, Some("bbb")); +} + +#[test] +fn test_seed_breaks_created_tie_by_lowest_pubkey() { + let seed = select_binding_seed(vec![ + SeedCandidate { + created_at: "2026-08-11T09:00:00Z", + pubkey: "ffff", + }, + SeedCandidate { + created_at: "2026-08-11T09:00:00Z", + pubkey: "0001", + }, + SeedCandidate { + created_at: "2026-08-11T09:00:00Z", + pubkey: "abcd", + }, + ]); + assert_eq!(seed, Some("0001")); +} + +#[test] +fn test_seed_single_instance() { + let seed = select_binding_seed(vec![SeedCandidate { + created_at: "2026-08-11T09:00:00Z", + pubkey: "solo", + }]); + assert_eq!(seed, Some("solo")); +} + +#[test] +fn test_seed_empty_is_none() { + assert_eq!(select_binding_seed(Vec::new()), None); +}