Skip to main content

wire/
trust.rs

1//! Trust state machine — v0.1 minimal subset, extended in v3.2 (RFC-001).
2//!
3//! Tier semantics:
4//!   - UNTRUSTED: card pinned, no claim verified yet; messages ignored.
5//!   - ORG_VERIFIED: (v3.2 / RFC-001 §5) peer shares a verified `org_did`
6//!     with us — *organisational* trust, NOT personal. Bilateral SAS is
7//!     still required to cross into VERIFIED. Promotion from UNTRUSTED is
8//!     one-way.
9//!   - VERIFIED: SAS confirmed bilateral; messages accepted. Promotion
10//!     accepts UNTRUSTED-or-ORG_VERIFIED as source (RFC-001 §5: "a
11//!     SAS-paired peer that happens to share our org is recorded at
12//!     VERIFIED, not downgraded").
13//!   - ATTESTED: reserved (v0.2+) — used today only for self-attest.
14//!   - TRUSTED: reserved (v0.2+).
15//!
16//! Promotion is one-way. Demotion would be ambiguous in a bilateral setting
17//! and is deliberately not modeled. RFC-001 §5 invariant:
18//!   "ORG_VERIFIED never satisfies a `>= VERIFIED` policy check."
19//! That invariant is captured by `tier_order` (ORG_VERIFIED=1 < VERIFIED=2)
20//! and by AC2 property test (tests/trust_ceiling_prop.rs) asserting no
21//! claim-event walk reaches VERIFIED without a SasConfirmed step.
22
23use serde_json::{Value, json};
24use std::collections::BTreeMap;
25use time::OffsetDateTime;
26use time::format_description::well_known::Rfc3339;
27
28use crate::signing::{b64encode, make_key_id};
29
30/// Tier ranking — higher is more trusted. Useful for `>=` gating.
31///
32/// RFC-001 §5 invariant: ORG_VERIFIED sits strictly between UNTRUSTED and
33/// VERIFIED. A policy check of `tier >= VERIFIED` MUST NOT pass for an
34/// ORG_VERIFIED peer — only an explicit SAS-confirmation can cross that line.
35pub fn tier_order() -> BTreeMap<&'static str, u32> {
36    [
37        ("UNTRUSTED", 0u32),
38        ("ORG_VERIFIED", 1),
39        ("VERIFIED", 2),
40        ("ATTESTED", 3),
41        ("TRUSTED", 4),
42    ]
43    .into_iter()
44    .collect()
45}
46
47#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
48pub enum Tier {
49    Untrusted,
50    OrgVerified,
51    Verified,
52    Attested,
53    Trusted,
54}
55
56impl Tier {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Tier::Untrusted => "UNTRUSTED",
60            Tier::OrgVerified => "ORG_VERIFIED",
61            Tier::Verified => "VERIFIED",
62            Tier::Attested => "ATTESTED",
63            Tier::Trusted => "TRUSTED",
64        }
65    }
66}
67
68/// Trust state — kept as a free-form JSON Value so we can persist + read with
69/// any conforming impl. v0.2+ may swap this for a typed struct.
70pub type Trust = Value;
71
72pub fn empty_trust() -> Trust {
73    json!({"version": 1, "agents": {}})
74}
75
76pub fn get_tier(trust: &Trust, peer_handle: &str) -> String {
77    trust
78        .get("agents")
79        .and_then(|a| a.get(peer_handle))
80        .and_then(|a| a.get("tier"))
81        .and_then(Value::as_str)
82        .unwrap_or("UNTRUSTED")
83        .to_string()
84}
85
86/// Effective trust tier — what the daemon can ACT on, not just what
87/// trust.json was promoted to.
88///
89/// Surface-honest. trust.json may say VERIFIED, but if relay_state
90/// has no `bilateral_completed_at` AND no `slot_token`, the daemon
91/// literally cannot push to that peer. Showing the operator a
92/// VERIFIED tag in that case is a lie about capability — fall back
93/// to PENDING_ACK so the diagnosis line + pending-push attribution
94/// agree.
95///
96/// Originally lived in `cli.rs::effective_peer_tier`. Moved to
97/// `trust.rs` 2026-06-01 so `config::compute_pending_push_breakdown`
98/// can call it without a circular dep, and so any future surface
99/// (web doctor, MCP wire_status, etc.) gets the same canonical
100/// answer.
101///
102/// History: v0.14.2 (#162 fix #5) introduced the
103/// `bilateral_completed_at` durable signal — pre-#162 peers fall
104/// back to `slot_token` presence as a legacy probe so already-paired
105/// peers keep reporting VERIFIED instead of regressing the moment
106/// they're upgraded.
107pub fn effective_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
108    let raw = get_tier(trust, handle);
109    if raw != "VERIFIED" {
110        return raw;
111    }
112    let peer_obj = relay_state.get("peers").and_then(|p| p.get(handle));
113    let bilateral_at = peer_obj
114        .and_then(|p| p.get("bilateral_completed_at"))
115        .and_then(Value::as_str);
116    if bilateral_at.is_some() {
117        return raw;
118    }
119    // A VERIFIED pin isn't effectively usable until we hold the peer's reply
120    // slot. RFC-006 Part B: the slot lives in `endpoints[]` — the single
121    // peer-routing source — not a flat `slot_token` field (Part B stopped
122    // writing it; reading it here is dead, no legacy state to tolerate).
123    let has_slot = crate::endpoints::peer_endpoints_in_priority_order(relay_state, handle)
124        .iter()
125        .any(|e| !e.slot_token.is_empty());
126    if has_slot {
127        raw
128    } else {
129        "PENDING_ACK".to_string()
130    }
131}
132
133/// Resolve a bare peer handle to the full DID stored in trust. Falls back
134/// to `did:wire:<peer_handle>` (the bare-handle form) when the peer isn't
135/// pinned — preserves pre-pair best-effort routing for unknown peers.
136///
137/// v0.14.2 (#162 fix #4): without this, send paths (`cmd_send` /
138/// `tool_send`) built `to: did:wire:sunlit-aurora`, but pinned peers'
139/// real DIDs carry the long fingerprint suffix
140/// (`did:wire:sunlit-aurora-ec6f890d`). A bare-handle `to:` mismatches
141/// the receiver's self-DID and risks rejection at canonical / cursor
142/// check time (honey-pine's report observed this on the first queued
143/// event). Use this helper at every send-build site to canonicalize
144/// against the pinned peer's actual DID.
145pub fn resolve_peer_did(trust: &Value, peer_handle: &str) -> String {
146    trust
147        .get("agents")
148        .and_then(|a| a.get(peer_handle))
149        .and_then(|p| p.get("did"))
150        .and_then(Value::as_str)
151        .map(str::to_string)
152        .unwrap_or_else(|| format!("did:wire:{peer_handle}"))
153}
154
155/// Pin a peer's card into our trust at the given tier (default UNTRUSTED).
156///
157/// The caller must independently run SAS confirmation (via `compute_sas`)
158/// before calling `promote_to_verified`. Pinning alone DOES NOT verify.
159///
160/// SECURITY (#245 — grindable nick-collision pin-overwrite): the trust store is
161/// keyed by the peer's persona nick, which is a deterministic function of the
162/// keypair over a small (~65k) word-list — so an attacker can grind keys (or
163/// simply set the card's `handle` field) until their nick collides with a
164/// victim's, then OVERWRITE the victim's pin (its keys/DID), hijacking the nick:
165/// the real victim's messages then fail verify (their key is gone) and the
166/// attacker's verify as the victim. This guard REFUSES to overwrite an existing
167/// nick whose pinned `did` differs from the incoming card's `did`. DIDs carry
168/// the key fingerprint (`did:wire:<nick>-<fp>`), so they are NOT grindable to
169/// match a *specific* full DID — and a same-identity re-pin / key-succession
170/// keeps the same DID, so legitimate updates are unaffected. (The complete fix —
171/// letting two distinct identities that collide on a nick COEXIST by re-keying
172/// the whole store by DID — is the larger follow-up tracked in #245.)
173pub fn add_agent_card_pin(
174    trust: &mut Trust,
175    card: &Value,
176    tier: Option<&str>,
177) -> Result<(), String> {
178    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
179    // v0.5.7+: prefer the explicit `handle` field on the card (display name).
180    // Fall back to stripping the DID prefix for legacy cards. For v0.5.7+
181    // pubkey-suffixed DIDs (`did:wire:paul-abc12345`), the display_handle
182    // helper strips the pubkey suffix back off.
183    let handle = card
184        .get("handle")
185        .and_then(Value::as_str)
186        .map(str::to_string)
187        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(did).to_string());
188    if handle.is_empty() {
189        return Err(format!("card has no resolvable handle (did={did:?})"));
190    }
191    // #245 collision guard: refuse to overwrite a different identity's pin.
192    if let Some(existing_did) = trust
193        .get("agents")
194        .and_then(|a| a.get(&handle))
195        .and_then(|e| e.get("did"))
196        .and_then(Value::as_str)
197        && !existing_did.is_empty()
198        && !did.is_empty()
199        && existing_did != did
200    {
201        return Err(format!(
202            "trust pin collision on nick '{handle}': already pinned to {existing_did}, refusing to overwrite with a DIFFERENT identity {did} (possible grindable-nick attack — #245). \
203             Use `wire forget-peer {handle}` first if you intend to replace it."
204        ));
205    }
206    let tier = tier.unwrap_or("UNTRUSTED");
207    let now = now_iso();
208
209    let mut public_keys = Vec::new();
210    if let Some(vks) = card.get("verify_keys").and_then(Value::as_object) {
211        for (key_id_full, key_record) in vks {
212            // Strip the `ed25519:` algorithm prefix to match v3.1 trust.json shape.
213            let key_id = key_id_full.strip_prefix("ed25519:").unwrap_or(key_id_full);
214            public_keys.push(json!({
215                "key_id": key_id,
216                "key": key_record.get("key").cloned().unwrap_or(Value::Null),
217                "added_at": now,
218                "active": true,
219            }));
220        }
221    }
222
223    let agents = trust
224        .as_object_mut()
225        .expect("trust must be an object")
226        .entry("agents")
227        .or_insert_with(|| json!({}));
228
229    agents[handle] = json!({
230        "tier": tier,
231        "did": did,
232        "public_keys": public_keys,
233        "card": card.clone(),
234        "pinned_at": now,
235    });
236    Ok(())
237}
238
239/// Promote UNTRUSTED or ORG_VERIFIED → VERIFIED. Returns `Err(reason)` if
240/// not pinned or already past VERIFIED.
241///
242/// RFC-001 §5: a SAS-confirmed peer that happens to share our org is
243/// recorded at VERIFIED, not downgraded — so ORG_VERIFIED is an accepted
244/// source for VERIFIED promotion. ATTESTED and TRUSTED are above VERIFIED
245/// and would be a downgrade; we refuse.
246pub fn promote_to_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
247    let agents = trust
248        .as_object_mut()
249        .ok_or("trust is not an object")?
250        .get_mut("agents")
251        .and_then(Value::as_object_mut)
252        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
253
254    let agent = agents
255        .get_mut(peer_handle)
256        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
257
258    let current = agent
259        .get("tier")
260        .and_then(Value::as_str)
261        .unwrap_or("UNTRUSTED")
262        .to_string();
263    if current != "UNTRUSTED" && current != "ORG_VERIFIED" {
264        return Err(format!(
265            "peer {peer_handle:?} already at tier {current:?} — promotion is one-way"
266        ));
267    }
268    agent["tier"] = json!("VERIFIED");
269    agent["verified_at"] = json!(now_iso());
270    Ok(())
271}
272
273/// Promote UNTRUSTED → ORG_VERIFIED. Returns `Err(reason)` if not pinned or
274/// already past UNTRUSTED.
275///
276/// RFC-001 §5: ORG_VERIFIED is granted on cryptographic + policy grounds
277/// (the peer's `member_cert` for an org we accept verifies against that
278/// org's pubkey) but DOES NOT satisfy the SAS-confirmation ceremony that
279/// VERIFIED requires. It is a one-way intermediate step a peer may cross
280/// before or after VERIFIED, but never *instead of* VERIFIED.
281///
282/// This function does NOT perform the cryptographic verification of
283/// `member_cert` — that lives in [`crate::identity::verify_member_cert`]
284/// and the caller must run it first. The trust mutation here is the policy
285/// recording: "we accept this peer as ORG_VERIFIED under our active org
286/// policy."
287pub fn promote_to_org_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
288    let agents = trust
289        .as_object_mut()
290        .ok_or("trust is not an object")?
291        .get_mut("agents")
292        .and_then(Value::as_object_mut)
293        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
294
295    let agent = agents
296        .get_mut(peer_handle)
297        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
298
299    let current = agent
300        .get("tier")
301        .and_then(Value::as_str)
302        .unwrap_or("UNTRUSTED")
303        .to_string();
304    if current != "UNTRUSTED" {
305        return Err(format!(
306            "peer {peer_handle:?} already at tier {current:?} — \
307             org_verified promotion fires from UNTRUSTED only"
308        ));
309    }
310    agent["tier"] = json!("ORG_VERIFIED");
311    agent["org_verified_at"] = json!(now_iso());
312    Ok(())
313}
314
315/// RFC-001 §6 project fan-out: the pinned peer handles eligible to receive a
316/// `wire send --project <tag>` broadcast.
317///
318/// A peer is eligible iff (a) its effective tier is **>= ORG_VERIFIED** (so we
319/// never fan out to an unverified or unreachable peer) AND (b) its pinned
320/// agent-card carries `project == <tag>`. `project` is **unsigned routing
321/// metadata** (RFC-001 §6) — it selects recipients, it never grants trust; the
322/// tier floor is the trust gate, the project tag is only the address book.
323///
324/// `self_handle` is excluded (our own ATTESTED self-pin must never be a
325/// recipient). Pure over the two state blobs so it unit-tests without any CLI
326/// or live relay. Result is sorted for deterministic output.
327pub fn project_recipients(
328    trust: &Value,
329    relay_state: &Value,
330    self_handle: &str,
331    project: &str,
332) -> Vec<String> {
333    let order = tier_order();
334    let floor = order.get("ORG_VERIFIED").copied().unwrap_or(1);
335    let mut out = Vec::new();
336    if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
337        for (handle, agent) in agents {
338            if handle == self_handle {
339                continue;
340            }
341            let tier = effective_tier(trust, relay_state, handle);
342            let rank = order.get(tier.as_str()).copied().unwrap_or(0);
343            if rank < floor {
344                continue;
345            }
346            let proj = agent.get("card").and_then(crate::agent_card::card_project);
347            if proj == Some(project) {
348                out.push(handle.clone());
349            }
350        }
351    }
352    out.sort();
353    out
354}
355
356/// Self-pin our own keypair into trust at ATTESTED. Convenience for `wire init`.
357pub fn add_self_to_trust(trust: &mut Trust, handle: &str, public_key: &[u8]) {
358    let agents = trust
359        .as_object_mut()
360        .expect("trust must be an object")
361        .entry("agents")
362        .or_insert_with(|| json!({}));
363    let key_id = make_key_id(handle, public_key);
364    agents[handle] = json!({
365        "tier": "ATTESTED",
366        "did": crate::agent_card::did_for_with_key(handle, public_key),
367        "public_keys": [{
368            "key_id": key_id,
369            "key": b64encode(public_key),
370            "added_at": now_iso(),
371            "active": true,
372        }],
373    });
374}
375
376fn now_iso() -> String {
377    let now = OffsetDateTime::now_utc();
378    now.format(&Rfc3339)
379        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::agent_card::{build_agent_card, sign_agent_card};
386    use crate::signing::generate_keypair;
387
388    #[test]
389    fn empty_trust_shape() {
390        let t = empty_trust();
391        assert_eq!(t["version"], 1);
392        assert!(t["agents"].is_object());
393        assert_eq!(t["agents"].as_object().unwrap().len(), 0);
394    }
395
396    #[test]
397    fn get_tier_unknown_returns_untrusted() {
398        assert_eq!(get_tier(&empty_trust(), "ghost"), "UNTRUSTED");
399    }
400
401    #[test]
402    fn resolve_peer_did_returns_pinned_did_with_full_suffix() {
403        // v0.14.2 (#162 fix #4): a pinned peer's full DID includes the
404        // long-fingerprint suffix; a bare-handle DID would mismatch the
405        // receiver's self-DID and risk rejection at canonical/cursor
406        // verification.
407        let (sk, pk) = generate_keypair();
408        let card = sign_agent_card(
409            &build_agent_card("sunlit-aurora", &pk, None, None, None),
410            &sk,
411        );
412        let pinned_did = card.get("did").and_then(Value::as_str).unwrap();
413        assert!(
414            pinned_did.starts_with("did:wire:sunlit-aurora-"),
415            "test setup: card DID should carry long-hex suffix"
416        );
417        let mut t = empty_trust();
418        add_agent_card_pin(&mut t, &card, Some("VERIFIED")).unwrap();
419
420        let resolved = resolve_peer_did(&t, "sunlit-aurora");
421        assert_eq!(
422            resolved, pinned_did,
423            "pinned peer must resolve to its full DID, not the bare handle"
424        );
425    }
426
427    #[test]
428    fn add_agent_card_pin_refuses_nick_collision_from_a_different_identity() {
429        // #245: two distinct keypairs can share a persona nick (the ~65k
430        // word-list is grindable, and the card's `handle` field is spoofable).
431        // Pinning a DIFFERENT identity under an already-pinned nick must be
432        // REFUSED, not silently overwrite (which would hijack the nick).
433        let (sk_a, pk_a) = generate_keypair();
434        let card_a = sign_agent_card(
435            &build_agent_card("raven-kettle", &pk_a, None, None, None),
436            &sk_a,
437        );
438        let did_a = card_a
439            .get("did")
440            .and_then(Value::as_str)
441            .unwrap()
442            .to_string();
443
444        let (sk_b, pk_b) = generate_keypair();
445        let card_b = sign_agent_card(
446            &build_agent_card("raven-kettle", &pk_b, None, None, None),
447            &sk_b,
448        );
449        let did_b = card_b
450            .get("did")
451            .and_then(Value::as_str)
452            .unwrap()
453            .to_string();
454        assert_ne!(did_a, did_b, "test setup: distinct keys ⇒ distinct DIDs");
455
456        let mut t = empty_trust();
457        add_agent_card_pin(&mut t, &card_a, Some("VERIFIED")).unwrap();
458
459        // Different identity, same nick → refused; incumbent A survives.
460        let err = add_agent_card_pin(&mut t, &card_b, Some("VERIFIED")).unwrap_err();
461        assert!(
462            err.contains("collision"),
463            "expected collision error, got: {err}"
464        );
465        assert_eq!(
466            t["agents"]["raven-kettle"]["did"], did_a,
467            "incumbent A's pin must NOT be overwritten by colliding identity B"
468        );
469
470        // Same identity re-pin (e.g. tier bump / key succession keeps the DID)
471        // is still allowed.
472        add_agent_card_pin(&mut t, &card_a, Some("TRUSTED")).unwrap();
473        assert_eq!(t["agents"]["raven-kettle"]["tier"], "TRUSTED");
474    }
475
476    #[test]
477    fn resolve_peer_did_falls_back_to_bare_for_unknown_peer() {
478        // Pre-pair best-effort: an unknown peer canonicalizes to the
479        // bare-handle DID. cmd_send / tool_send keep working pre-pair;
480        // post-pair the resolve path takes over.
481        let t = empty_trust();
482        assert_eq!(
483            resolve_peer_did(&t, "ghost-peer"),
484            "did:wire:ghost-peer",
485            "unknown peer falls back to bare-handle DID"
486        );
487    }
488
489    #[test]
490    fn add_agent_card_pin_defaults_untrusted() {
491        let (sk, pk) = generate_keypair();
492        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
493        let mut t = empty_trust();
494        add_agent_card_pin(&mut t, &card, None).unwrap();
495        assert_eq!(get_tier(&t, "paul"), "UNTRUSTED");
496        // v0.5.7+: DID is pubkey-suffixed.
497        let did = t["agents"]["paul"]["did"].as_str().unwrap();
498        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
499    }
500
501    #[test]
502    fn add_pin_strips_ed25519_prefix_from_key_id() {
503        let (sk, pk) = generate_keypair();
504        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
505        let mut t = empty_trust();
506        add_agent_card_pin(&mut t, &card, None).unwrap();
507        let kid = t["agents"]["paul"]["public_keys"][0]["key_id"]
508            .as_str()
509            .unwrap();
510        assert!(kid.contains(':'));
511        assert!(!kid.starts_with("ed25519:"));
512    }
513
514    #[test]
515    fn promote_to_verified_one_way() {
516        let (sk, pk) = generate_keypair();
517        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
518        let mut t = empty_trust();
519        add_agent_card_pin(&mut t, &card, None).unwrap();
520        promote_to_verified(&mut t, "paul").unwrap();
521        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
522        assert!(t["agents"]["paul"]["verified_at"].is_string());
523    }
524
525    #[test]
526    fn promote_to_verified_idempotent_block() {
527        let (sk, pk) = generate_keypair();
528        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
529        let mut t = empty_trust();
530        add_agent_card_pin(&mut t, &card, None).unwrap();
531        promote_to_verified(&mut t, "paul").unwrap();
532        let err = promote_to_verified(&mut t, "paul").unwrap_err();
533        assert!(err.contains("VERIFIED"), "got: {err}");
534    }
535
536    #[test]
537    fn promote_unknown_peer_fails() {
538        let mut t = empty_trust();
539        let err = promote_to_verified(&mut t, "ghost").unwrap_err();
540        assert!(err.contains("not pinned"), "got: {err}");
541    }
542
543    #[test]
544    fn add_self_to_trust_attests() {
545        let (_, pk) = generate_keypair();
546        let mut t = empty_trust();
547        add_self_to_trust(&mut t, "paul", &pk);
548        assert_eq!(get_tier(&t, "paul"), "ATTESTED");
549        let did = t["agents"]["paul"]["did"].as_str().unwrap();
550        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
551    }
552
553    #[test]
554    fn tier_order_matches_promotion_semantics() {
555        let order = tier_order();
556        assert!(order["UNTRUSTED"] < order["ORG_VERIFIED"]);
557        assert!(order["ORG_VERIFIED"] < order["VERIFIED"]);
558        assert!(order["VERIFIED"] < order["ATTESTED"]);
559        assert!(order["ATTESTED"] < order["TRUSTED"]);
560    }
561
562    // ─── RFC-001 §5: Tier::OrgVerified ────────────────────────────────────
563
564    #[test]
565    fn tier_as_str_covers_org_verified() {
566        assert_eq!(Tier::OrgVerified.as_str(), "ORG_VERIFIED");
567    }
568
569    #[test]
570    fn promote_to_org_verified_one_way() {
571        let (sk, pk) = generate_keypair();
572        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
573        let mut t = empty_trust();
574        add_agent_card_pin(&mut t, &card, None).unwrap();
575        promote_to_org_verified(&mut t, "paul").unwrap();
576        assert_eq!(get_tier(&t, "paul"), "ORG_VERIFIED");
577        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
578    }
579
580    #[test]
581    fn promote_to_org_verified_refuses_already_verified() {
582        // Once a peer is VERIFIED (bilateral SAS), regressing them to
583        // ORG_VERIFIED would be a downgrade. Refuse.
584        let (sk, pk) = generate_keypair();
585        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
586        let mut t = empty_trust();
587        add_agent_card_pin(&mut t, &card, None).unwrap();
588        promote_to_verified(&mut t, "paul").unwrap();
589        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
590        assert!(err.contains("VERIFIED"), "got: {err}");
591        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
592    }
593
594    #[test]
595    fn promote_to_org_verified_refuses_self_idempotent() {
596        // Twice-applied org promotion is a no-op error, not a silent reset
597        // of `org_verified_at` — keeps the audit trail intact.
598        let (sk, pk) = generate_keypair();
599        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
600        let mut t = empty_trust();
601        add_agent_card_pin(&mut t, &card, None).unwrap();
602        promote_to_org_verified(&mut t, "paul").unwrap();
603        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
604        assert!(err.contains("ORG_VERIFIED"), "got: {err}");
605    }
606
607    #[test]
608    fn promote_to_verified_accepts_org_verified_source() {
609        // RFC-001 §5: a peer can be ORG_VERIFIED then later cross the SAS
610        // ceremony into VERIFIED — without losing the cryptographic
611        // membership claim. We preserve `org_verified_at` for audit.
612        let (sk, pk) = generate_keypair();
613        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
614        let mut t = empty_trust();
615        add_agent_card_pin(&mut t, &card, None).unwrap();
616        promote_to_org_verified(&mut t, "paul").unwrap();
617        promote_to_verified(&mut t, "paul").unwrap();
618        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
619        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
620        assert!(t["agents"]["paul"]["verified_at"].is_string());
621    }
622
623    #[test]
624    fn promote_to_verified_refuses_attested_source() {
625        // ATTESTED is reserved-but-above VERIFIED; a downgrade would lose
626        // information. Refuse.
627        let (_, pk) = generate_keypair();
628        let mut t = empty_trust();
629        add_self_to_trust(&mut t, "self", &pk);
630        let err = promote_to_verified(&mut t, "self").unwrap_err();
631        assert!(err.contains("ATTESTED"), "got: {err}");
632    }
633
634    #[test]
635    fn effective_tier_matrix() {
636        use serde_json::json;
637        // VERIFIED in trust + bilateral_completed_at present → stays VERIFIED.
638        let trust = json!({"agents": {"a": {"tier": "VERIFIED"}}});
639        let relay = json!({"peers": {"a": {"bilateral_completed_at": "t"}}});
640        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
641        // RFC-006 Part B: a flat-only `slot_token` (no endpoints[]) is NOT a
642        // routing source anymore — it reads PENDING_ACK, like any flat-only pin.
643        let relay = json!({"peers": {"a": {"slot_token": "tok"}}});
644        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
645        // VERIFIED in trust + no bilateral_at + empty slot_token → PENDING_ACK.
646        let relay = json!({"peers": {"a": {"slot_token": ""}}});
647        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
648        // VERIFIED in trust + peer missing from relay.peers entirely → PENDING_ACK.
649        let relay = json!({"peers": {}});
650        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
651        // RFC-006 Part B: the slot lives in endpoints[], not a flat field. A
652        // non-empty slot_token there must read as VERIFIED (the regression this
653        // guards: Part B emptied the flat field, so a flat-only reader wrongly
654        // downgraded every freshly-paired peer to PENDING_ACK).
655        let relay = json!({"peers": {"a": {"endpoints": [
656            {"relay_url": "https://r", "slot_id": "s", "slot_token": "tok", "scope": "federation"}
657        ]}}});
658        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
659        // endpoints[] present but its slot_token empty → still PENDING_ACK.
660        let relay = json!({"peers": {"a": {"endpoints": [
661            {"relay_url": "https://r", "slot_id": "s", "slot_token": "", "scope": "federation"}
662        ]}}});
663        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
664        // Non-VERIFIED trust tiers pass through unchanged.
665        let trust = json!({"agents": {"a": {"tier": "UNTRUSTED"}}});
666        assert_eq!(effective_tier(&trust, &relay, "a"), "UNTRUSTED");
667        let trust = json!({"agents": {"a": {"tier": "ORG_VERIFIED"}}});
668        assert_eq!(effective_tier(&trust, &relay, "a"), "ORG_VERIFIED");
669    }
670
671    #[test]
672    fn project_recipients_filters_by_tier_and_project() {
673        use serde_json::json;
674        let trust = json!({"agents": {
675            "alice":  {"tier": "ORG_VERIFIED", "card": {"project": "print-shop"}},
676            "bob":    {"tier": "ORG_VERIFIED", "card": {"project": "lora-training"}},
677            "carol":  {"tier": "UNTRUSTED",    "card": {"project": "print-shop"}},
678            "dave":   {"tier": "VERIFIED",     "card": {"project": "print-shop"}},
679            "selfie": {"tier": "ATTESTED",     "card": {"project": "print-shop"}},
680            "noproj": {"tier": "ORG_VERIFIED", "card": {}},
681        }});
682        // VERIFIED dave needs a relay signal to read as VERIFIED (else PENDING_ACK).
683        let relay = json!({"peers": {"dave": {"bilateral_completed_at": "t"}}});
684        let r = project_recipients(&trust, &relay, "selfie", "print-shop");
685        // alice (ORG_VERIFIED+match) and dave (VERIFIED+match) only. bob wrong
686        // project; carol below floor; selfie is self; noproj has no tag.
687        assert_eq!(r, vec!["alice".to_string(), "dave".to_string()]);
688    }
689
690    #[test]
691    fn project_recipients_excludes_unreachable_verified() {
692        use serde_json::json;
693        // VERIFIED in trust but no relay signal → effective PENDING_ACK → we
694        // can't actually deliver, so it must not be a fan-out recipient.
695        let trust = json!({"agents": {
696            "ghost": {"tier": "VERIFIED", "card": {"project": "x"}},
697        }});
698        let relay = json!({"peers": {}});
699        assert!(project_recipients(&trust, &relay, "selfie", "x").is_empty());
700    }
701
702    #[test]
703    fn org_verified_does_not_satisfy_verified_policy_check() {
704        // The load-bearing RFC-001 invariant: a policy gate of
705        // `tier >= VERIFIED` MUST refuse an ORG_VERIFIED peer.
706        let order = tier_order();
707        let verified_rank = order["VERIFIED"];
708        let org_rank = order["ORG_VERIFIED"];
709        assert!(
710            org_rank < verified_rank,
711            "ORG_VERIFIED ({org_rank}) must rank strictly below VERIFIED ({verified_rank})"
712        );
713    }
714}