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[]`, not a flat
121    // `slot_token` field — so check the endpoints (keeping the flat read for
122    // any legacy relay-state). Pre-Part-B this only read the flat field, which
123    // Part B emptied → every freshly-paired peer wrongly showed PENDING_ACK.
124    let has_slot = peer_obj
125        .and_then(|p| p.get("slot_token"))
126        .and_then(Value::as_str)
127        .map(|t| !t.is_empty())
128        .unwrap_or(false)
129        || crate::endpoints::peer_endpoints_in_priority_order(relay_state, handle)
130            .iter()
131            .any(|e| !e.slot_token.is_empty());
132    if has_slot {
133        raw
134    } else {
135        "PENDING_ACK".to_string()
136    }
137}
138
139/// Resolve a bare peer handle to the full DID stored in trust. Falls back
140/// to `did:wire:<peer_handle>` (the bare-handle form) when the peer isn't
141/// pinned — preserves pre-pair best-effort routing for unknown peers.
142///
143/// v0.14.2 (#162 fix #4): without this, send paths (`cmd_send` /
144/// `tool_send`) built `to: did:wire:sunlit-aurora`, but pinned peers'
145/// real DIDs carry the long fingerprint suffix
146/// (`did:wire:sunlit-aurora-ec6f890d`). A bare-handle `to:` mismatches
147/// the receiver's self-DID and risks rejection at canonical / cursor
148/// check time (honey-pine's report observed this on the first queued
149/// event). Use this helper at every send-build site to canonicalize
150/// against the pinned peer's actual DID.
151pub fn resolve_peer_did(trust: &Value, peer_handle: &str) -> String {
152    trust
153        .get("agents")
154        .and_then(|a| a.get(peer_handle))
155        .and_then(|p| p.get("did"))
156        .and_then(Value::as_str)
157        .map(str::to_string)
158        .unwrap_or_else(|| format!("did:wire:{peer_handle}"))
159}
160
161/// Pin a peer's card into our trust at the given tier (default UNTRUSTED).
162///
163/// The caller must independently run SAS confirmation (via `compute_sas`)
164/// before calling `promote_to_verified`. Pinning alone DOES NOT verify.
165pub fn add_agent_card_pin(trust: &mut Trust, card: &Value, tier: Option<&str>) {
166    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
167    // v0.5.7+: prefer the explicit `handle` field on the card (display name).
168    // Fall back to stripping the DID prefix for legacy cards. For v0.5.7+
169    // pubkey-suffixed DIDs (`did:wire:paul-abc12345`), the display_handle
170    // helper strips the pubkey suffix back off.
171    let handle = card
172        .get("handle")
173        .and_then(Value::as_str)
174        .map(str::to_string)
175        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(did).to_string());
176    if handle.is_empty() {
177        panic!("card has no resolvable handle (did={did:?})");
178    }
179    let tier = tier.unwrap_or("UNTRUSTED");
180    let now = now_iso();
181
182    let mut public_keys = Vec::new();
183    if let Some(vks) = card.get("verify_keys").and_then(Value::as_object) {
184        for (key_id_full, key_record) in vks {
185            // Strip the `ed25519:` algorithm prefix to match v3.1 trust.json shape.
186            let key_id = key_id_full.strip_prefix("ed25519:").unwrap_or(key_id_full);
187            public_keys.push(json!({
188                "key_id": key_id,
189                "key": key_record.get("key").cloned().unwrap_or(Value::Null),
190                "added_at": now,
191                "active": true,
192            }));
193        }
194    }
195
196    let agents = trust
197        .as_object_mut()
198        .expect("trust must be an object")
199        .entry("agents")
200        .or_insert_with(|| json!({}));
201
202    agents[handle] = json!({
203        "tier": tier,
204        "did": did,
205        "public_keys": public_keys,
206        "card": card.clone(),
207        "pinned_at": now,
208    });
209}
210
211/// Promote UNTRUSTED or ORG_VERIFIED → VERIFIED. Returns `Err(reason)` if
212/// not pinned or already past VERIFIED.
213///
214/// RFC-001 §5: a SAS-confirmed peer that happens to share our org is
215/// recorded at VERIFIED, not downgraded — so ORG_VERIFIED is an accepted
216/// source for VERIFIED promotion. ATTESTED and TRUSTED are above VERIFIED
217/// and would be a downgrade; we refuse.
218pub fn promote_to_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
219    let agents = trust
220        .as_object_mut()
221        .ok_or("trust is not an object")?
222        .get_mut("agents")
223        .and_then(Value::as_object_mut)
224        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
225
226    let agent = agents
227        .get_mut(peer_handle)
228        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
229
230    let current = agent
231        .get("tier")
232        .and_then(Value::as_str)
233        .unwrap_or("UNTRUSTED")
234        .to_string();
235    if current != "UNTRUSTED" && current != "ORG_VERIFIED" {
236        return Err(format!(
237            "peer {peer_handle:?} already at tier {current:?} — promotion is one-way"
238        ));
239    }
240    agent["tier"] = json!("VERIFIED");
241    agent["verified_at"] = json!(now_iso());
242    Ok(())
243}
244
245/// Promote UNTRUSTED → ORG_VERIFIED. Returns `Err(reason)` if not pinned or
246/// already past UNTRUSTED.
247///
248/// RFC-001 §5: ORG_VERIFIED is granted on cryptographic + policy grounds
249/// (the peer's `member_cert` for an org we accept verifies against that
250/// org's pubkey) but DOES NOT satisfy the SAS-confirmation ceremony that
251/// VERIFIED requires. It is a one-way intermediate step a peer may cross
252/// before or after VERIFIED, but never *instead of* VERIFIED.
253///
254/// This function does NOT perform the cryptographic verification of
255/// `member_cert` — that lives in [`crate::identity::verify_member_cert`]
256/// and the caller must run it first. The trust mutation here is the policy
257/// recording: "we accept this peer as ORG_VERIFIED under our active org
258/// policy."
259pub fn promote_to_org_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
260    let agents = trust
261        .as_object_mut()
262        .ok_or("trust is not an object")?
263        .get_mut("agents")
264        .and_then(Value::as_object_mut)
265        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
266
267    let agent = agents
268        .get_mut(peer_handle)
269        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
270
271    let current = agent
272        .get("tier")
273        .and_then(Value::as_str)
274        .unwrap_or("UNTRUSTED")
275        .to_string();
276    if current != "UNTRUSTED" {
277        return Err(format!(
278            "peer {peer_handle:?} already at tier {current:?} — \
279             org_verified promotion fires from UNTRUSTED only"
280        ));
281    }
282    agent["tier"] = json!("ORG_VERIFIED");
283    agent["org_verified_at"] = json!(now_iso());
284    Ok(())
285}
286
287/// RFC-001 §6 project fan-out: the pinned peer handles eligible to receive a
288/// `wire send --project <tag>` broadcast.
289///
290/// A peer is eligible iff (a) its effective tier is **>= ORG_VERIFIED** (so we
291/// never fan out to an unverified or unreachable peer) AND (b) its pinned
292/// agent-card carries `project == <tag>`. `project` is **unsigned routing
293/// metadata** (RFC-001 §6) — it selects recipients, it never grants trust; the
294/// tier floor is the trust gate, the project tag is only the address book.
295///
296/// `self_handle` is excluded (our own ATTESTED self-pin must never be a
297/// recipient). Pure over the two state blobs so it unit-tests without any CLI
298/// or live relay. Result is sorted for deterministic output.
299pub fn project_recipients(
300    trust: &Value,
301    relay_state: &Value,
302    self_handle: &str,
303    project: &str,
304) -> Vec<String> {
305    let order = tier_order();
306    let floor = order.get("ORG_VERIFIED").copied().unwrap_or(1);
307    let mut out = Vec::new();
308    if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
309        for (handle, agent) in agents {
310            if handle == self_handle {
311                continue;
312            }
313            let tier = effective_tier(trust, relay_state, handle);
314            let rank = order.get(tier.as_str()).copied().unwrap_or(0);
315            if rank < floor {
316                continue;
317            }
318            let proj = agent.get("card").and_then(crate::agent_card::card_project);
319            if proj == Some(project) {
320                out.push(handle.clone());
321            }
322        }
323    }
324    out.sort();
325    out
326}
327
328/// Self-pin our own keypair into trust at ATTESTED. Convenience for `wire init`.
329pub fn add_self_to_trust(trust: &mut Trust, handle: &str, public_key: &[u8]) {
330    let agents = trust
331        .as_object_mut()
332        .expect("trust must be an object")
333        .entry("agents")
334        .or_insert_with(|| json!({}));
335    let key_id = make_key_id(handle, public_key);
336    agents[handle] = json!({
337        "tier": "ATTESTED",
338        "did": crate::agent_card::did_for_with_key(handle, public_key),
339        "public_keys": [{
340            "key_id": key_id,
341            "key": b64encode(public_key),
342            "added_at": now_iso(),
343            "active": true,
344        }],
345    });
346}
347
348fn now_iso() -> String {
349    let now = OffsetDateTime::now_utc();
350    now.format(&Rfc3339)
351        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::agent_card::{build_agent_card, sign_agent_card};
358    use crate::signing::generate_keypair;
359
360    #[test]
361    fn empty_trust_shape() {
362        let t = empty_trust();
363        assert_eq!(t["version"], 1);
364        assert!(t["agents"].is_object());
365        assert_eq!(t["agents"].as_object().unwrap().len(), 0);
366    }
367
368    #[test]
369    fn get_tier_unknown_returns_untrusted() {
370        assert_eq!(get_tier(&empty_trust(), "ghost"), "UNTRUSTED");
371    }
372
373    #[test]
374    fn resolve_peer_did_returns_pinned_did_with_full_suffix() {
375        // v0.14.2 (#162 fix #4): a pinned peer's full DID includes the
376        // long-fingerprint suffix; a bare-handle DID would mismatch the
377        // receiver's self-DID and risk rejection at canonical/cursor
378        // verification.
379        let (sk, pk) = generate_keypair();
380        let card = sign_agent_card(
381            &build_agent_card("sunlit-aurora", &pk, None, None, None),
382            &sk,
383        );
384        let pinned_did = card.get("did").and_then(Value::as_str).unwrap();
385        assert!(
386            pinned_did.starts_with("did:wire:sunlit-aurora-"),
387            "test setup: card DID should carry long-hex suffix"
388        );
389        let mut t = empty_trust();
390        add_agent_card_pin(&mut t, &card, Some("VERIFIED"));
391
392        let resolved = resolve_peer_did(&t, "sunlit-aurora");
393        assert_eq!(
394            resolved, pinned_did,
395            "pinned peer must resolve to its full DID, not the bare handle"
396        );
397    }
398
399    #[test]
400    fn resolve_peer_did_falls_back_to_bare_for_unknown_peer() {
401        // Pre-pair best-effort: an unknown peer canonicalizes to the
402        // bare-handle DID. cmd_send / tool_send keep working pre-pair;
403        // post-pair the resolve path takes over.
404        let t = empty_trust();
405        assert_eq!(
406            resolve_peer_did(&t, "ghost-peer"),
407            "did:wire:ghost-peer",
408            "unknown peer falls back to bare-handle DID"
409        );
410    }
411
412    #[test]
413    fn add_agent_card_pin_defaults_untrusted() {
414        let (sk, pk) = generate_keypair();
415        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
416        let mut t = empty_trust();
417        add_agent_card_pin(&mut t, &card, None);
418        assert_eq!(get_tier(&t, "paul"), "UNTRUSTED");
419        // v0.5.7+: DID is pubkey-suffixed.
420        let did = t["agents"]["paul"]["did"].as_str().unwrap();
421        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
422    }
423
424    #[test]
425    fn add_pin_strips_ed25519_prefix_from_key_id() {
426        let (sk, pk) = generate_keypair();
427        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
428        let mut t = empty_trust();
429        add_agent_card_pin(&mut t, &card, None);
430        let kid = t["agents"]["paul"]["public_keys"][0]["key_id"]
431            .as_str()
432            .unwrap();
433        assert!(kid.contains(':'));
434        assert!(!kid.starts_with("ed25519:"));
435    }
436
437    #[test]
438    fn promote_to_verified_one_way() {
439        let (sk, pk) = generate_keypair();
440        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
441        let mut t = empty_trust();
442        add_agent_card_pin(&mut t, &card, None);
443        promote_to_verified(&mut t, "paul").unwrap();
444        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
445        assert!(t["agents"]["paul"]["verified_at"].is_string());
446    }
447
448    #[test]
449    fn promote_to_verified_idempotent_block() {
450        let (sk, pk) = generate_keypair();
451        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
452        let mut t = empty_trust();
453        add_agent_card_pin(&mut t, &card, None);
454        promote_to_verified(&mut t, "paul").unwrap();
455        let err = promote_to_verified(&mut t, "paul").unwrap_err();
456        assert!(err.contains("VERIFIED"), "got: {err}");
457    }
458
459    #[test]
460    fn promote_unknown_peer_fails() {
461        let mut t = empty_trust();
462        let err = promote_to_verified(&mut t, "ghost").unwrap_err();
463        assert!(err.contains("not pinned"), "got: {err}");
464    }
465
466    #[test]
467    fn add_self_to_trust_attests() {
468        let (_, pk) = generate_keypair();
469        let mut t = empty_trust();
470        add_self_to_trust(&mut t, "paul", &pk);
471        assert_eq!(get_tier(&t, "paul"), "ATTESTED");
472        let did = t["agents"]["paul"]["did"].as_str().unwrap();
473        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
474    }
475
476    #[test]
477    fn tier_order_matches_promotion_semantics() {
478        let order = tier_order();
479        assert!(order["UNTRUSTED"] < order["ORG_VERIFIED"]);
480        assert!(order["ORG_VERIFIED"] < order["VERIFIED"]);
481        assert!(order["VERIFIED"] < order["ATTESTED"]);
482        assert!(order["ATTESTED"] < order["TRUSTED"]);
483    }
484
485    // ─── RFC-001 §5: Tier::OrgVerified ────────────────────────────────────
486
487    #[test]
488    fn tier_as_str_covers_org_verified() {
489        assert_eq!(Tier::OrgVerified.as_str(), "ORG_VERIFIED");
490    }
491
492    #[test]
493    fn promote_to_org_verified_one_way() {
494        let (sk, pk) = generate_keypair();
495        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
496        let mut t = empty_trust();
497        add_agent_card_pin(&mut t, &card, None);
498        promote_to_org_verified(&mut t, "paul").unwrap();
499        assert_eq!(get_tier(&t, "paul"), "ORG_VERIFIED");
500        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
501    }
502
503    #[test]
504    fn promote_to_org_verified_refuses_already_verified() {
505        // Once a peer is VERIFIED (bilateral SAS), regressing them to
506        // ORG_VERIFIED would be a downgrade. Refuse.
507        let (sk, pk) = generate_keypair();
508        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
509        let mut t = empty_trust();
510        add_agent_card_pin(&mut t, &card, None);
511        promote_to_verified(&mut t, "paul").unwrap();
512        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
513        assert!(err.contains("VERIFIED"), "got: {err}");
514        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
515    }
516
517    #[test]
518    fn promote_to_org_verified_refuses_self_idempotent() {
519        // Twice-applied org promotion is a no-op error, not a silent reset
520        // of `org_verified_at` — keeps the audit trail intact.
521        let (sk, pk) = generate_keypair();
522        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
523        let mut t = empty_trust();
524        add_agent_card_pin(&mut t, &card, None);
525        promote_to_org_verified(&mut t, "paul").unwrap();
526        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
527        assert!(err.contains("ORG_VERIFIED"), "got: {err}");
528    }
529
530    #[test]
531    fn promote_to_verified_accepts_org_verified_source() {
532        // RFC-001 §5: a peer can be ORG_VERIFIED then later cross the SAS
533        // ceremony into VERIFIED — without losing the cryptographic
534        // membership claim. We preserve `org_verified_at` for audit.
535        let (sk, pk) = generate_keypair();
536        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
537        let mut t = empty_trust();
538        add_agent_card_pin(&mut t, &card, None);
539        promote_to_org_verified(&mut t, "paul").unwrap();
540        promote_to_verified(&mut t, "paul").unwrap();
541        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
542        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
543        assert!(t["agents"]["paul"]["verified_at"].is_string());
544    }
545
546    #[test]
547    fn promote_to_verified_refuses_attested_source() {
548        // ATTESTED is reserved-but-above VERIFIED; a downgrade would lose
549        // information. Refuse.
550        let (_, pk) = generate_keypair();
551        let mut t = empty_trust();
552        add_self_to_trust(&mut t, "self", &pk);
553        let err = promote_to_verified(&mut t, "self").unwrap_err();
554        assert!(err.contains("ATTESTED"), "got: {err}");
555    }
556
557    #[test]
558    fn effective_tier_matrix() {
559        use serde_json::json;
560        // VERIFIED in trust + bilateral_completed_at present → stays VERIFIED.
561        let trust = json!({"agents": {"a": {"tier": "VERIFIED"}}});
562        let relay = json!({"peers": {"a": {"bilateral_completed_at": "t"}}});
563        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
564        // VERIFIED in trust + slot_token non-empty (back-compat path) → VERIFIED.
565        let relay = json!({"peers": {"a": {"slot_token": "tok"}}});
566        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
567        // VERIFIED in trust + no bilateral_at + empty slot_token → PENDING_ACK.
568        let relay = json!({"peers": {"a": {"slot_token": ""}}});
569        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
570        // VERIFIED in trust + peer missing from relay.peers entirely → PENDING_ACK.
571        let relay = json!({"peers": {}});
572        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
573        // RFC-006 Part B: the slot lives in endpoints[], not a flat field. A
574        // non-empty slot_token there must read as VERIFIED (the regression this
575        // guards: Part B emptied the flat field, so a flat-only reader wrongly
576        // downgraded every freshly-paired peer to PENDING_ACK).
577        let relay = json!({"peers": {"a": {"endpoints": [
578            {"relay_url": "https://r", "slot_id": "s", "slot_token": "tok", "scope": "federation"}
579        ]}}});
580        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
581        // endpoints[] present but its slot_token empty → still PENDING_ACK.
582        let relay = json!({"peers": {"a": {"endpoints": [
583            {"relay_url": "https://r", "slot_id": "s", "slot_token": "", "scope": "federation"}
584        ]}}});
585        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
586        // Non-VERIFIED trust tiers pass through unchanged.
587        let trust = json!({"agents": {"a": {"tier": "UNTRUSTED"}}});
588        assert_eq!(effective_tier(&trust, &relay, "a"), "UNTRUSTED");
589        let trust = json!({"agents": {"a": {"tier": "ORG_VERIFIED"}}});
590        assert_eq!(effective_tier(&trust, &relay, "a"), "ORG_VERIFIED");
591    }
592
593    #[test]
594    fn project_recipients_filters_by_tier_and_project() {
595        use serde_json::json;
596        let trust = json!({"agents": {
597            "alice":  {"tier": "ORG_VERIFIED", "card": {"project": "print-shop"}},
598            "bob":    {"tier": "ORG_VERIFIED", "card": {"project": "lora-training"}},
599            "carol":  {"tier": "UNTRUSTED",    "card": {"project": "print-shop"}},
600            "dave":   {"tier": "VERIFIED",     "card": {"project": "print-shop"}},
601            "selfie": {"tier": "ATTESTED",     "card": {"project": "print-shop"}},
602            "noproj": {"tier": "ORG_VERIFIED", "card": {}},
603        }});
604        // VERIFIED dave needs a relay signal to read as VERIFIED (else PENDING_ACK).
605        let relay = json!({"peers": {"dave": {"bilateral_completed_at": "t"}}});
606        let r = project_recipients(&trust, &relay, "selfie", "print-shop");
607        // alice (ORG_VERIFIED+match) and dave (VERIFIED+match) only. bob wrong
608        // project; carol below floor; selfie is self; noproj has no tag.
609        assert_eq!(r, vec!["alice".to_string(), "dave".to_string()]);
610    }
611
612    #[test]
613    fn project_recipients_excludes_unreachable_verified() {
614        use serde_json::json;
615        // VERIFIED in trust but no relay signal → effective PENDING_ACK → we
616        // can't actually deliver, so it must not be a fan-out recipient.
617        let trust = json!({"agents": {
618            "ghost": {"tier": "VERIFIED", "card": {"project": "x"}},
619        }});
620        let relay = json!({"peers": {}});
621        assert!(project_recipients(&trust, &relay, "selfie", "x").is_empty());
622    }
623
624    #[test]
625    fn org_verified_does_not_satisfy_verified_policy_check() {
626        // The load-bearing RFC-001 invariant: a policy gate of
627        // `tier >= VERIFIED` MUST refuse an ORG_VERIFIED peer.
628        let order = tier_order();
629        let verified_rank = order["VERIFIED"];
630        let org_rank = order["ORG_VERIFIED"];
631        assert!(
632            org_rank < verified_rank,
633            "ORG_VERIFIED ({org_rank}) must rank strictly below VERIFIED ({verified_rank})"
634        );
635    }
636}