diff --git a/desktop/src-tauri/src/managed_agents/library.rs b/desktop/src-tauri/src/managed_agents/library.rs index 592ee8493..a441eeeba 100644 --- a/desktop/src-tauri/src/managed_agents/library.rs +++ b/desktop/src-tauri/src/managed_agents/library.rs @@ -208,6 +208,28 @@ impl LibraryEntry { .filter(|d| seen.insert((d.scope_id.as_str(), d.agent_pubkey.as_str()))) .collect() } + + /// The §3.5 retirement-due derived condition (P6-I2; permanently + /// conservative per P15-I2/P16-I1/P17-C1). Retirement is a DERIVED condition, + /// never a stored state: an entry is retirement-due when it has at least one + /// projection, EVERY projection is terminal (`Excluded`/`Deleted`), and a + /// binding key or a `deferred_archives` marker still names it. The empty- + /// projection guard keeps a never-deployed entry (vacuously "all terminal") + /// from being flagged — retirement means it WAS live and now every + /// projection is terminal. + /// + /// Whatever write produces this condition (a cascade confirming, another + /// scope's activation reconcile confirming, a §3.6 direct deletion completing + /// a pending removal), the finalizer OBSERVES it at recovery points and + /// discharges NOTHING — see [`retirement_due_entries`]. + /// + /// [`retirement_due_entries`]: LoadedLibrary::retirement_due_entries + pub fn is_retirement_due(&self) -> bool { + !self.projections.is_empty() + && self.projections.values().all(|p| p.state.is_terminal()) + && (!self.identity_bindings.is_empty() + || !self.deferred_archive_obligations().is_empty()) + } } /// `(origin scope_id, origin slug)` — the share idempotency key (§2.3). @@ -352,6 +374,31 @@ impl LoadedLibrary { orphan_keys: self.orphan_keys.clone(), }) } + + /// The §3.5 binding-retirement finalizer — MARKER MAINTENANCE ONLY, the sole + /// v1 behavior (P6-I2, permanently conservative per P15-I2/P16-I1/P17-C1). + /// Returns the healthy entries that are retirement-due (every projection + /// terminal while a binding key or `deferred_archives` marker still names the + /// pubkey — [`LibraryEntry::is_retirement_due`]). + /// + /// It is a pure OBSERVER: it discharges nothing. No v1 path deletes an + /// ever-bound key or archives an ever-bound identity, because library + /// metadata alone cannot prove a process-global secret or a community-visible + /// identity is unused — an unrelated plain carrier of the bound pubkey may + /// live in an inactive scope no deleting authority may read (P17-C1). The + /// keyring entry, the deferred rows, and the tombstoned entry's binding + /// record therefore persist as PERMANENT journaled markers a future indexed + /// version may safely consume. The recovery-point that calls this (§4) + /// records the observation and leaves every marker in place; wiring it into + /// the recovery sweep is Phase 4b. Quarantined entries are excluded — their + /// markers already protect the key via [`LibraryDocument::key_archive_protected`], + /// and a malformed entry's projection set cannot be trusted for terminality. + pub fn retirement_due_entries(&self) -> Vec<&LibraryEntry> { + self.healthy + .iter() + .filter(|entry| entry.is_retirement_due()) + .collect() + } } /// Read and classify `library.json` under `base_dir` (§2.1). Never mutates the diff --git a/desktop/src-tauri/src/managed_agents/library/binding_tests.rs b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs index 4526ab566..da2f62c7f 100644 --- a/desktop/src-tauri/src/managed_agents/library/binding_tests.rs +++ b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs @@ -640,3 +640,173 @@ fn test_reap_persist_failure_propagates() { "delete ran before the failed persist" ); } + +// ── retirement finalizer: §3.5 marker maintenance (P6-I2/P15-I2/P16-I1/P17-C1) ─── + +/// A projection in the given state (only `state` matters to the finalizer; the +/// other fields are fixed placeholders). +fn projection(state: ProjectionState) -> ProjectionEntry { + ProjectionEntry { + state, + local_slug: "lib-x".into(), + relay_url: "wss://r".into(), + workspace_label: None, + } +} + +/// A healthy entry with the given projections, bindings, and deferred rows. +fn entry( + library_id: &str, + projections: Vec<(&str, ProjectionState)>, + bindings: Vec<&str>, + deferred: Vec<&str>, +) -> LibraryEntry { + let mut identity_bindings = std::collections::BTreeMap::new(); + for (i, agent_pubkey) in bindings.into_iter().enumerate() { + identity_bindings.insert( + format!("owner-{i}"), + IdentityBinding { + agent_pubkey: agent_pubkey.into(), + auth_tag: "{}".into(), + }, + ); + } + let mut e = LibraryEntry { + library_id: library_id.into(), + origin: OriginKey { + scope_id: "s".into(), + slug: library_id.into(), + }, + revision: 1, + deleted: false, + owner_pubkey_at_share: "owner".into(), + shared: SharedDefinition { + display_name: "A".into(), + avatar_url: None, + system_prompt: "p".into(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + }, + deferred_archives: vec![], + identity_bindings, + projections: projections + .into_iter() + .map(|(scope, state)| (scope.to_string(), projection(state))) + .collect(), + }; + for agent_pubkey in deferred { + e.upsert_deferred_archive("s".into(), agent_pubkey.into()); + } + e +} + +fn loaded(healthy: Vec) -> LoadedLibrary { + LoadedLibrary { + healthy, + quarantined: vec![], + orphan_keys: vec![], + degradations: vec![], + } +} + +#[test] +fn test_retirement_due_when_all_terminal_and_key_or_row_survives() { + // Every projection terminal (mixed Excluded + Deleted) while a binding key + // still exists ⇒ retirement-due. A second entry with a deferred row and no + // binding is equally due — the marker alone qualifies. + let bound = entry( + "lib-bound", + vec![ + ("a", ProjectionState::Excluded), + ("b", ProjectionState::Deleted), + ], + vec![&"aa".repeat(32)], + vec![], + ); + let deferred_only = entry( + "lib-deferred", + vec![("a", ProjectionState::Deleted)], + vec![], + vec![&"bb".repeat(32)], + ); + let lib = loaded(vec![bound, deferred_only]); + + let due: Vec<&str> = lib + .retirement_due_entries() + .iter() + .map(|e| e.library_id.as_str()) + .collect(); + assert_eq!(due, vec!["lib-bound", "lib-deferred"]); +} + +#[test] +fn test_not_retirement_due_while_any_projection_live() { + // One non-terminal projection keeps the entry off the list — the binding + // still backs a live projection, so its key is not even a retirement marker. + let lib = loaded(vec![entry( + "lib-live", + vec![ + ("a", ProjectionState::Excluded), + ("b", ProjectionState::Materialized { revision: 1 }), + ], + vec![&"cc".repeat(32)], + vec![], + )]); + assert!(lib.retirement_due_entries().is_empty()); +} + +#[test] +fn test_not_retirement_due_without_key_or_deferred_row() { + // All terminal but neither a binding nor a deferred row survives — there is + // no marker to maintain, so the entry is not retirement-due. + let lib = loaded(vec![entry( + "lib-clean", + vec![("a", ProjectionState::Deleted)], + vec![], + vec![], + )]); + assert!(lib.retirement_due_entries().is_empty()); +} + +#[test] +fn test_never_projected_entry_is_not_retirement_due() { + // Zero projections is not "vacuously all terminal": retirement means the + // entry WAS live and is now fully terminal. A bound entry that never + // projected anywhere must not be flagged. + let lib = loaded(vec![entry( + "lib-fresh", + vec![], + vec![&"dd".repeat(32)], + vec![], + )]); + assert!(lib.retirement_due_entries().is_empty()); +} + +#[test] +fn test_finalizer_discharges_nothing_markers_persist() { + // The finalizer is a pure observer: reading it does not touch the entry's + // markers. The keyring is untouched by construction (no KeyStore parameter); + // here we assert the binding + deferred rows survive the observation, the v1 + // permanent-marker contract (P17-C1). + let lib = loaded(vec![entry( + "lib-due", + vec![("a", ProjectionState::Deleted)], + vec![&"ee".repeat(32)], + vec![&"ee".repeat(32)], + )]); + + assert_eq!(lib.retirement_due_entries().len(), 1); + + // Markers untouched: the binding and the deferred row are exactly as journaled. + let e = &lib.healthy[0]; + assert_eq!(e.identity_bindings.len(), 1); + assert_eq!(e.deferred_archive_obligations().len(), 1); + // And the pubkey stays protected — no discharge weakened the predicate. + let doc = lib.rebuild_document().expect("rebuild"); + assert!(doc.key_archive_protected(&"ee".repeat(32))); +}