mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(agents): add pure identity-binding helpers for the agent library
Phase-3 chunk 1 of the cross-workspace agent library ($2.5). Two keyring-free pure functions with no production callers yet — the crash-safe mint protocol and their insertion/removal transaction wiring land in Phase 4b per the resequencing ruling: - key_archive_protected(agent_pubkey): the deletion-safety predicate (P13-C1, widened by P17-C1). Scans RAW library.json entries so a quarantined entry that ever bound the pubkey still protects it; over-protecting only leaves a secret resident, while mis-deleting a live global key is irreversible. - select_binding_seed: deterministic seed selection (P3-I2) — earliest created_at, ties broken by lowest pubkey; None only when zero instances exist. Tests live in a new binding_tests submodule to keep the Phase-1 tests.rs suite clear of the 1000-line file ratchet. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -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<Item = SeedCandidate<'a>>,
|
||||
) -> 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;
|
||||
|
||||
@@ -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<serde_json::Value>) -> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user