Skip to main content

wire/
pair_decision.rs

1//! RFC-001 Phase 1b — map a verified org-membership outcome + the receiver's
2//! per-org policy to a pairing action.
3//!
4//! This is the bridge between [`crate::org_membership::evaluate_card_membership`]
5//! (Phase 1, the offline verify chain) and the live accept/pin path. It is a
6//! pure function over an [`OrgPolicy`] lookup that Phase 3 (slate-lotus's
7//! `org_policies.json` table) implements. Keeping it pure means the Option-A /
8//! Option-B / default-deny decision is unit-testable without any live state.
9//!
10//! Invariant (RFC-001 §5): the strongest action this can return is
11//! `ORG_VERIFIED` (auto or via one-tap). `VERIFIED` still requires bilateral
12//! SPAKE2+SAS and is never produced here. Anything that isn't a verified
13//! membership in a *trusted* org falls through to `Manual` (today's default-deny
14//! bilateral flow), preserving the v0.5.14 phonebook-scrape closure.
15
16use crate::org_membership::MembershipOutcome;
17
18/// Receiver-side inbound treatment for a peer that is a verified member of a
19/// trusted org. Phase 3's policy table maps an `org_did` to one of these (or to
20/// `None` = not in the receiver's trusted set → default-deny).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum InboundMode {
23    /// Option A (opt-in): pin `ORG_VERIFIED` with no operator tap.
24    Auto,
25    /// Option B (default): enqueue one pending-inbound; one operator tap → `ORG_VERIFIED`.
26    Notify,
27}
28
29/// The receiver's per-org pairing policy. Phase 3 (slate-lotus) implements this
30/// over `config/wire/org_policies.json` (first-match-wins, immutable
31/// default-deny). `None` means the org is not in the receiver's trusted set.
32pub trait OrgPolicy {
33    fn inbound_mode(&self, org_did: &str) -> Option<InboundMode>;
34}
35
36/// The action P1b takes for a received card.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum PairAction {
39    /// Pin `ORG_VERIFIED` with no tap — the matched org opted into Option A.
40    AutoOrgVerified { org_did: String },
41    /// Enqueue one pending-inbound annotated org-vouched (Option B default);
42    /// one operator tap promotes to `ORG_VERIFIED`.
43    NotifyOrgEligible { org_did: String },
44    /// No verified vouch from a trusted org — fall through to today's bilateral
45    /// manual pending flow (default-deny).
46    Manual,
47}
48
49/// Map a membership outcome + the receiver's policy to a pairing action.
50///
51/// Fail-closed: `NoClaim` / `Rejected` → `Manual`. Among the verified
52/// `org_did`s, the strongest opted-in treatment wins (`Auto` > `Notify`); orgs
53/// the receiver does not trust (`inbound_mode` → `None`) are ignored. The
54/// result never exceeds `ORG_VERIFIED`.
55pub fn decide(outcome: &MembershipOutcome, policy: &dyn OrgPolicy) -> PairAction {
56    let org_dids = match outcome {
57        MembershipOutcome::Verified { org_dids, .. } => org_dids,
58        // No claim, or a claim that failed verification → no easing.
59        MembershipOutcome::NoClaim | MembershipOutcome::Rejected { .. } => {
60            return PairAction::Manual;
61        }
62    };
63
64    // Auto wins if any verified org opted into it; otherwise the first Notify
65    // org; otherwise no trusted vouch → manual. (inbound_mode is a cheap lookup.)
66    if let Some(org_did) = org_dids
67        .iter()
68        .find(|&od| policy.inbound_mode(od) == Some(InboundMode::Auto))
69    {
70        return PairAction::AutoOrgVerified {
71            org_did: org_did.clone(),
72        };
73    }
74    if let Some(org_did) = org_dids
75        .iter()
76        .find(|&od| policy.inbound_mode(od) == Some(InboundMode::Notify))
77    {
78        return PairAction::NotifyOrgEligible {
79            org_did: org_did.clone(),
80        };
81    }
82    PairAction::Manual
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::collections::HashMap;
89
90    struct MapPolicy(HashMap<String, InboundMode>);
91    impl OrgPolicy for MapPolicy {
92        fn inbound_mode(&self, org_did: &str) -> Option<InboundMode> {
93            self.0.get(org_did).copied()
94        }
95    }
96
97    fn verified(orgs: &[&str]) -> MembershipOutcome {
98        MembershipOutcome::Verified {
99            op_did:
100                "did:wire:op:darby-0000000000000000000000000000000000000000000000000000000000000000"
101                    .into(),
102            org_dids: orgs.iter().map(|s| s.to_string()).collect(),
103        }
104    }
105
106    fn policy(entries: &[(&str, InboundMode)]) -> MapPolicy {
107        MapPolicy(entries.iter().map(|(k, v)| (k.to_string(), *v)).collect())
108    }
109
110    #[test]
111    fn auto_when_org_opted_into_auto() {
112        let out = verified(&["did:wire:org:slanchaai-1"]);
113        let pol = policy(&[("did:wire:org:slanchaai-1", InboundMode::Auto)]);
114        assert_eq!(
115            decide(&out, &pol),
116            PairAction::AutoOrgVerified {
117                org_did: "did:wire:org:slanchaai-1".into()
118            }
119        );
120    }
121
122    #[test]
123    fn notify_when_org_is_notify() {
124        let out = verified(&["did:wire:org:slanchaai-1"]);
125        let pol = policy(&[("did:wire:org:slanchaai-1", InboundMode::Notify)]);
126        assert_eq!(
127            decide(&out, &pol),
128            PairAction::NotifyOrgEligible {
129                org_did: "did:wire:org:slanchaai-1".into()
130            }
131        );
132    }
133
134    #[test]
135    fn manual_when_org_untrusted() {
136        let out = verified(&["did:wire:org:stranger-1"]);
137        let pol = policy(&[("did:wire:org:slanchaai-1", InboundMode::Auto)]);
138        assert_eq!(decide(&out, &pol), PairAction::Manual);
139    }
140
141    #[test]
142    fn auto_beats_notify_across_orgs() {
143        // Verified in two orgs; one Notify (listed first), one Auto. Auto wins.
144        let out = verified(&["did:wire:org:notifyco-1", "did:wire:org:autoco-1"]);
145        let pol = policy(&[
146            ("did:wire:org:notifyco-1", InboundMode::Notify),
147            ("did:wire:org:autoco-1", InboundMode::Auto),
148        ]);
149        assert_eq!(
150            decide(&out, &pol),
151            PairAction::AutoOrgVerified {
152                org_did: "did:wire:org:autoco-1".into()
153            }
154        );
155    }
156
157    #[test]
158    fn manual_on_no_claim() {
159        let pol = policy(&[("did:wire:org:slanchaai-1", InboundMode::Auto)]);
160        assert_eq!(
161            decide(&MembershipOutcome::NoClaim, &pol),
162            PairAction::Manual
163        );
164    }
165
166    #[test]
167    fn manual_on_rejected() {
168        let pol = policy(&[("did:wire:org:slanchaai-1", InboundMode::Auto)]);
169        let rejected = MembershipOutcome::Rejected {
170            reason: "forged".into(),
171        };
172        assert_eq!(decide(&rejected, &pol), PairAction::Manual);
173    }
174}