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:
Duncan
2026-08-17 14:08:51 -04:00
co-authored by Will Pfleger
parent 58ead527a9
commit 3f933e1751
2 changed files with 235 additions and 0 deletions
@@ -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);
}