feat(managed-agents): add §3.5 retirement finalizer marker observer

The §3.5 binding-retirement finalizer in v1 is permanently conservative
(P15-I2/P16-I1/P17-C1): it OBSERVES retirement-due entries at recovery
points and discharges nothing, because library metadata alone cannot
prove a process-global key 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. The keyring entry, deferred rows,
and tombstoned binding record persist as permanent journaled markers.

Add LibraryEntry::is_retirement_due (derived condition: at least one
projection, all terminal, a binding key or deferred row still names it)
and LoadedLibrary::retirement_due_entries (the pure observer selector).
The recovery-point wiring is Phase 4b.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-17 17:16:31 -04:00
co-authored by Will Pfleger
parent 1e369e9ac2
commit 383db8a69a
2 changed files with 217 additions and 0 deletions
@@ -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
@@ -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<LibraryEntry>) -> 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)));
}