mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user