Skip to main content

car_context/
resolve.rs

1//! Which work context a new session binds.
2//!
3//! One CAR can hold logins for several organizations at once. Each session
4//! binds exactly one [`WorkContext`] (or none, for personal work) when it is
5//! created, and this module is the single rule that picks it.
6//!
7//! **Hard constraints** hold against every source, including an explicit choice
8//! and an attested inbound request:
9//!
10//! - a session inside a repository that declares an org binds that org;
11//! - the org is in the agent's permitted set (or the session is personal and
12//!   the agent allows personal work);
13//! - a signed-in login holds the org, and exactly one does.
14//!
15//! **Sources** fill in the context, first match wins: an attested inbound
16//! context, an explicit choice, the repository's org, the agent's default, the
17//! user's default org, then personal. The user's default is a preference among
18//! the orgs an agent may already work in: it applies only to an agent permitted
19//! there, so setting a default org never enrolls an agent that never joined
20//! one, and an agent with no organization settings keeps running personal work
21//! exactly as it did before.
22//!
23//! A refusal never falls back. The defaults fill in a context nobody named;
24//! they are never a way around a failed constraint, because the failure being
25//! prevented is work for one organization quietly running — and billing, and
26//! remembering — as another.
27//!
28//! The rule is pure: no clock, network, filesystem or credential store. The
29//! caller supplies what it read, and afterwards proves it can mint a token for
30//! the chosen context before the session starts.
31
32use crate::WorkContext;
33use std::fmt;
34
35/// What the person or host explicitly asked for when creating the session.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Explicit {
38    /// An org by id. The signed-in login that holds it supplies the account.
39    Org(String),
40    /// A complete context, for hosts that already hold one.
41    Context(WorkContext),
42    /// Personal, org-less work.
43    Personal,
44}
45
46/// The org a repository declares in its `.car/config.toml`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RepositoryOrg {
49    /// Not inside a repository, or the repository declares no org.
50    Undeclared,
51    /// `[organization] id = "<org>"`; `declared_in` names the file for messages.
52    Declared { org: String, declared_in: String },
53    /// The declaration exists but cannot be read. The constraint cannot be
54    /// checked, so every session there is refused until it is fixed.
55    Unreadable { location: String, error: String },
56}
57
58/// Which orgs an agent may work in.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Permitted {
61    /// Exactly these orgs.
62    Orgs(Vec<String>),
63    /// Any org a signed-in login holds. For callers with no agent definition to
64    /// consult, such as the CLI previewing a resolution. Agent definitions name
65    /// their orgs, so joining a new company never silently enrolls an agent.
66    AnyHeld,
67}
68
69/// The agent's organization settings, as its definition declares them.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AgentOrganizations {
72    /// The agent's id, for messages.
73    pub agent: String,
74    pub permitted: Permitted,
75    /// The org a new session uses when nothing more specific applies.
76    pub default: Option<String>,
77    /// Whether the agent may run personal, org-less sessions.
78    pub personal: bool,
79}
80
81impl AgentOrganizations {
82    /// An agent with no organization settings: personal work only, which is
83    /// what every agent did before organizations existed.
84    pub fn personal_only(agent: impl Into<String>) -> Self {
85        Self {
86            agent: agent.into(),
87            permitted: Permitted::Orgs(Vec::new()),
88            default: None,
89            personal: true,
90        }
91    }
92}
93
94/// Everything the rule reads. Borrowed, so a caller can resolve repeatedly
95/// without cloning the held-login index.
96#[derive(Debug, Clone, Copy)]
97pub struct Inputs<'a> {
98    /// The context of an inbound request whose sender was attested as a member
99    /// of that org AND authorized to drive this agent. Attestation alone is not
100    /// enough; the caller must have checked both.
101    pub inbound: Option<&'a WorkContext>,
102    pub explicit: Option<&'a Explicit>,
103    pub repository: &'a RepositoryOrg,
104    pub agent: &'a AgentOrganizations,
105    /// The person's default org.
106    pub user_default: Option<&'a WorkContext>,
107    /// Every (login, org) context a signed-in login holds.
108    pub held: &'a [WorkContext],
109}
110
111/// Which source supplied the context.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Source {
114    Inbound,
115    Explicit,
116    Repository,
117    AgentDefault,
118    UserDefault,
119    Personal,
120}
121
122impl Source {
123    /// Stable wire name.
124    pub fn as_str(&self) -> &'static str {
125        match self {
126            Self::Inbound => "inbound",
127            Self::Explicit => "explicit",
128            Self::Repository => "repository",
129            Self::AgentDefault => "agent_default",
130            Self::UserDefault => "user_default",
131            Self::Personal => "personal",
132        }
133    }
134}
135
136impl fmt::Display for Source {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(match self {
139            Self::Inbound => "the inbound request",
140            Self::Explicit => "the explicit choice",
141            Self::Repository => "the repository",
142            Self::AgentDefault => "the agent's default",
143            Self::UserDefault => "your default org",
144            Self::Personal => "personal work",
145        })
146    }
147}
148
149/// The binding a session starts with.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Resolved {
152    /// `None` is a personal, org-less session.
153    pub context: Option<WorkContext>,
154    pub source: Source,
155}
156
157/// Why a session cannot start. Each message names the fix.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum Refusal {
160    RepositoryUnreadable {
161        location: String,
162        error: String,
163    },
164    /// `requested` is an org id, or `None` for personal work.
165    RepositoryMismatch {
166        repository_org: String,
167        declared_in: String,
168        requested: Option<String>,
169        source: Source,
170    },
171    NotPermitted {
172        agent: String,
173        org: String,
174        source: Source,
175    },
176    PersonalNotAllowed {
177        agent: String,
178    },
179    NotHeld {
180        org: String,
181        source: Source,
182    },
183    Ambiguous {
184        org: String,
185        accounts: Vec<String>,
186    },
187    ContextNotHeld {
188        context: WorkContext,
189    },
190    DefaultUnavailable {
191        org: String,
192    },
193    Unresolved {
194        agent: String,
195    },
196}
197
198impl Refusal {
199    /// Stable wire code, so a host can react to a refusal without parsing its
200    /// message.
201    pub fn code(&self) -> &'static str {
202        match self {
203            Self::RepositoryUnreadable { .. } => "repository_unreadable",
204            Self::RepositoryMismatch { .. } => "repository_mismatch",
205            Self::NotPermitted { .. } => "not_permitted",
206            Self::PersonalNotAllowed { .. } => "personal_not_allowed",
207            Self::NotHeld { .. } => "not_held",
208            Self::Ambiguous { .. } => "ambiguous",
209            Self::ContextNotHeld { .. } => "context_not_held",
210            Self::DefaultUnavailable { .. } => "default_unavailable",
211            Self::Unresolved { .. } => "unresolved",
212        }
213    }
214}
215
216impl fmt::Display for Refusal {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match self {
219            Self::RepositoryUnreadable { location, error } => write!(
220                f,
221                "cannot read the repository's organization from {location}: {error}. \
222                 Fix the file before starting work here"
223            ),
224            Self::RepositoryMismatch {
225                repository_org,
226                declared_in,
227                requested,
228                source,
229            } => {
230                let requested = requested
231                    .as_deref()
232                    .map(|org| format!("org `{org}`"))
233                    .unwrap_or_else(|| "personal work".to_string());
234                write!(
235                    f,
236                    "this repository belongs to org `{repository_org}` (declared in \
237                     {declared_in}); a session here cannot bind {requested} from {source}. \
238                     Work in `{repository_org}`, or start the session outside this repository"
239                )
240            }
241            Self::NotPermitted { agent, org, source } => write!(
242                f,
243                "agent `{agent}` is not permitted in org `{org}` (from {source}). \
244                 Add `{org}` to the agent's permitted organizations, or choose another org"
245            ),
246            Self::PersonalNotAllowed { agent } => write!(
247                f,
248                "agent `{agent}` does not run personal work. Choose one of its organizations"
249            ),
250            Self::NotHeld { org, source } => write!(
251                f,
252                "no signed-in Parslee login belongs to org `{org}` (from {source}). \
253                 Sign in to it with `car auth login --add`"
254            ),
255            Self::Ambiguous { org, accounts } => write!(
256                f,
257                "more than one signed-in login belongs to org `{org}` ({}). \
258                 Name the account explicitly",
259                accounts.join(", ")
260            ),
261            Self::ContextNotHeld { context } => write!(
262                f,
263                "no signed-in login holds the requested context (account `{}`, org `{}`). \
264                 Sign in to it with `car auth login --add`",
265                context.account_id,
266                context.organization_id.as_deref().unwrap_or("personal")
267            ),
268            Self::DefaultUnavailable { org } => write!(
269                f,
270                "your default org `{org}` is no longer held by any signed-in login. \
271                 Choose a new one with `car auth default-org <org>`"
272            ),
273            Self::Unresolved { agent } => write!(
274                f,
275                "no organization applies to this session and agent `{agent}` does not run \
276                 personal work. Name an org, or set a default with `car auth default-org <org>`"
277            ),
278        }
279    }
280}
281
282impl std::error::Error for Refusal {}
283
284/// A candidate before the constraints are checked.
285enum Candidate<'a> {
286    Exact(&'a WorkContext),
287    Org(&'a str),
288    Personal,
289}
290
291impl Candidate<'_> {
292    fn org(&self) -> Option<&str> {
293        match self {
294            Self::Exact(context) => context.organization_id.as_deref(),
295            Self::Org(org) => Some(org),
296            Self::Personal => None,
297        }
298    }
299}
300
301/// Pick the context a new session binds, or say why none can be.
302pub fn resolve(inputs: Inputs<'_>) -> Result<Resolved, Refusal> {
303    if let RepositoryOrg::Unreadable { location, error } = inputs.repository {
304        return Err(Refusal::RepositoryUnreadable {
305            location: location.clone(),
306            error: error.clone(),
307        });
308    }
309
310    let (candidate, source) = pick(&inputs)?;
311
312    if let RepositoryOrg::Declared { org, declared_in } = inputs.repository {
313        if candidate.org() != Some(org.as_str()) {
314            return Err(Refusal::RepositoryMismatch {
315                repository_org: org.clone(),
316                declared_in: declared_in.clone(),
317                requested: candidate.org().map(str::to_string),
318                source,
319            });
320        }
321    }
322
323    let agent = inputs.agent;
324    if !permits(agent, candidate.org()) {
325        return Err(match candidate.org() {
326            None => Refusal::PersonalNotAllowed {
327                agent: agent.agent.clone(),
328            },
329            Some(org) => Refusal::NotPermitted {
330                agent: agent.agent.clone(),
331                org: org.to_string(),
332                source,
333            },
334        });
335    }
336
337    let context = match candidate {
338        Candidate::Personal => None,
339        Candidate::Exact(context) if context.organization_id.is_none() => None,
340        Candidate::Exact(context) => {
341            if !inputs.held.contains(context) {
342                return Err(if source == Source::UserDefault {
343                    Refusal::DefaultUnavailable {
344                        org: context.organization_id.clone().unwrap_or_default(),
345                    }
346                } else {
347                    Refusal::ContextNotHeld {
348                        context: context.clone(),
349                    }
350                });
351            }
352            Some(context.clone())
353        }
354        Candidate::Org(org) => Some(held_by_one(inputs.held, org, source)?.clone()),
355    };
356    Ok(Resolved { context, source })
357}
358
359fn pick<'a>(inputs: &Inputs<'a>) -> Result<(Candidate<'a>, Source), Refusal> {
360    if let Some(context) = inputs.inbound {
361        return Ok((Candidate::Exact(context), Source::Inbound));
362    }
363    if let Some(explicit) = inputs.explicit {
364        let candidate = match explicit {
365            Explicit::Org(org) => Candidate::Org(org),
366            Explicit::Context(context) => Candidate::Exact(context),
367            Explicit::Personal => Candidate::Personal,
368        };
369        return Ok((candidate, Source::Explicit));
370    }
371    if let RepositoryOrg::Declared { org, .. } = inputs.repository {
372        return Ok((Candidate::Org(org), Source::Repository));
373    }
374    if let Some(org) = inputs.agent.default.as_deref() {
375        return Ok((Candidate::Org(org), Source::AgentDefault));
376    }
377    if let Some(context) = inputs
378        .user_default
379        .filter(|context| permits(inputs.agent, context.organization_id.as_deref()))
380    {
381        return Ok((Candidate::Exact(context), Source::UserDefault));
382    }
383    if inputs.agent.personal {
384        return Ok((Candidate::Personal, Source::Personal));
385    }
386    Err(Refusal::Unresolved {
387        agent: inputs.agent.agent.clone(),
388    })
389}
390
391/// Whether the agent may work in `org` (`None` = personal).
392fn permits(agent: &AgentOrganizations, org: Option<&str>) -> bool {
393    match (org, &agent.permitted) {
394        (None, _) => agent.personal,
395        (Some(_), Permitted::AnyHeld) => true,
396        (Some(org), Permitted::Orgs(orgs)) => orgs.iter().any(|o| o == org),
397    }
398}
399
400fn held_by_one<'h>(
401    held: &'h [WorkContext],
402    org: &str,
403    source: Source,
404) -> Result<&'h WorkContext, Refusal> {
405    let mut matches = held
406        .iter()
407        .filter(|context| context.organization_id.as_deref() == Some(org));
408    let Some(first) = matches.next() else {
409        return Err(Refusal::NotHeld {
410            org: org.to_string(),
411            source,
412        });
413    };
414    let others: Vec<&WorkContext> = matches.filter(|context| *context != first).collect();
415    if others.is_empty() {
416        return Ok(first);
417    }
418    let mut accounts: Vec<String> = std::iter::once(first)
419        .chain(others)
420        .map(|context| context.account_id.clone())
421        .collect();
422    accounts.sort();
423    accounts.dedup();
424    Err(Refusal::Ambiguous {
425        org: org.to_string(),
426        accounts,
427    })
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    const BASE: &str = "https://api.parslee.ai";
435
436    fn ctx(account: &str, org: &str) -> WorkContext {
437        WorkContext::new(BASE, account, Some(org.into())).unwrap()
438    }
439
440    /// The three logins this feature was built for, plus a demo org on the
441    /// Parslee login.
442    fn held() -> Vec<WorkContext> {
443        vec![
444            ctx("acc_parslee", "org_parslee"),
445            ctx("acc_parslee", "org_parslee_steelpath"),
446            ctx("acc_fly", "org_fly_exclusive"),
447            ctx("acc_volato", "org_volato"),
448        ]
449    }
450
451    fn agent(permitted: &[&str], default: Option<&str>, personal: bool) -> AgentOrganizations {
452        AgentOrganizations {
453            agent: "parslee-core".into(),
454            permitted: Permitted::Orgs(permitted.iter().map(|s| s.to_string()).collect()),
455            default: default.map(str::to_string),
456            personal,
457        }
458    }
459
460    fn declared(org: &str) -> RepositoryOrg {
461        RepositoryOrg::Declared {
462            org: org.into(),
463            declared_in: "/repo/.car/config.toml".into(),
464        }
465    }
466
467    struct Case<'a> {
468        inbound: Option<WorkContext>,
469        explicit: Option<Explicit>,
470        repository: RepositoryOrg,
471        agent: AgentOrganizations,
472        user_default: Option<WorkContext>,
473        held: &'a [WorkContext],
474    }
475
476    impl Case<'_> {
477        fn resolve(&self) -> Result<Resolved, Refusal> {
478            resolve(Inputs {
479                inbound: self.inbound.as_ref(),
480                explicit: self.explicit.as_ref(),
481                repository: &self.repository,
482                agent: &self.agent,
483                user_default: self.user_default.as_ref(),
484                held: self.held,
485            })
486        }
487    }
488
489    fn base(held: &[WorkContext]) -> Case<'_> {
490        Case {
491            inbound: None,
492            explicit: None,
493            repository: RepositoryOrg::Undeclared,
494            agent: agent(
495                &["org_parslee", "org_fly_exclusive", "org_volato"],
496                None,
497                true,
498            ),
499            user_default: Some(ctx("acc_parslee", "org_parslee")),
500            held,
501        }
502    }
503
504    fn bound(result: Result<Resolved, Refusal>) -> (Option<String>, Option<String>, Source) {
505        let resolved = result.expect("expected a binding");
506        let context = resolved.context;
507        (
508            context.as_ref().map(|c| c.account_id.clone()),
509            context.and_then(|c| c.organization_id),
510            resolved.source,
511        )
512    }
513
514    fn some(s: &str) -> Option<String> {
515        Some(s.to_string())
516    }
517
518    #[test]
519    fn the_user_default_fills_in_when_nothing_else_applies() {
520        let held = held();
521        assert_eq!(
522            bound(base(&held).resolve()),
523            (
524                some("acc_parslee"),
525                some("org_parslee"),
526                Source::UserDefault
527            )
528        );
529    }
530
531    #[test]
532    fn sources_apply_in_order() {
533        let held = held();
534        // Agent default beats the user default.
535        let mut case = base(&held);
536        case.agent.default = some("org_volato");
537        assert_eq!(
538            bound(case.resolve()),
539            (some("acc_volato"), some("org_volato"), Source::AgentDefault)
540        );
541        // Repository beats the agent default.
542        case.repository = declared("org_fly_exclusive");
543        assert_eq!(
544            bound(case.resolve()),
545            (
546                some("acc_fly"),
547                some("org_fly_exclusive"),
548                Source::Repository
549            )
550        );
551        // An explicit choice that agrees with the repository wins as Explicit.
552        case.explicit = Some(Explicit::Org("org_fly_exclusive".into()));
553        assert_eq!(bound(case.resolve()).2, Source::Explicit);
554        // Inbound beats everything, when it satisfies the constraints.
555        case.inbound = Some(ctx("acc_fly", "org_fly_exclusive"));
556        assert_eq!(bound(case.resolve()).2, Source::Inbound);
557    }
558
559    #[test]
560    fn the_org_supplies_the_account() {
561        let held = held();
562        let mut case = base(&held);
563        case.explicit = Some(Explicit::Org("org_parslee_steelpath".into()));
564        case.agent.permitted = Permitted::AnyHeld;
565        assert_eq!(
566            bound(case.resolve()),
567            (
568                some("acc_parslee"),
569                some("org_parslee_steelpath"),
570                Source::Explicit
571            )
572        );
573    }
574
575    #[test]
576    fn a_repository_org_constrains_every_source() {
577        let held = held();
578        let mismatch = |case: &Case<'_>, source: Source| match case.resolve() {
579            Err(Refusal::RepositoryMismatch {
580                repository_org,
581                source: got,
582                ..
583            }) => {
584                assert_eq!(repository_org, "org_fly_exclusive");
585                assert_eq!(got, source);
586            }
587            other => panic!("expected a repository mismatch, got {other:?}"),
588        };
589        let mut case = base(&held);
590        case.repository = declared("org_fly_exclusive");
591
592        case.explicit = Some(Explicit::Org("org_volato".into()));
593        mismatch(&case, Source::Explicit);
594
595        case.explicit = Some(Explicit::Personal);
596        mismatch(&case, Source::Explicit);
597
598        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
599        mismatch(&case, Source::Explicit);
600
601        // An attested, authorized peer still cannot pull a flyExclusive
602        // repository into Volato.
603        case.explicit = None;
604        case.inbound = Some(ctx("acc_volato", "org_volato"));
605        mismatch(&case, Source::Inbound);
606    }
607
608    #[test]
609    fn an_unreadable_repository_refuses_instead_of_falling_back() {
610        let held = held();
611        let mut case = base(&held);
612        case.repository = RepositoryOrg::Unreadable {
613            location: "/repo/.car/config.toml".into(),
614            error: "invalid TOML".into(),
615        };
616        // Even an explicit choice: the constraint cannot be checked.
617        case.explicit = Some(Explicit::Org("org_parslee".into()));
618        assert!(matches!(
619            case.resolve(),
620            Err(Refusal::RepositoryUnreadable { .. })
621        ));
622    }
623
624    #[test]
625    fn the_permitted_set_binds_every_source() {
626        let held = held();
627        let mut case = base(&held);
628        case.agent = agent(&["org_parslee"], None, true);
629
630        case.explicit = Some(Explicit::Org("org_volato".into()));
631        assert!(matches!(
632            case.resolve(),
633            Err(Refusal::NotPermitted {
634                source: Source::Explicit,
635                ..
636            })
637        ));
638
639        // The repository's org is not a way in either.
640        case.explicit = None;
641        case.repository = declared("org_volato");
642        assert!(matches!(
643            case.resolve(),
644            Err(Refusal::NotPermitted {
645                source: Source::Repository,
646                ..
647            })
648        ));
649
650        // Nor an inbound request.
651        case.repository = RepositoryOrg::Undeclared;
652        case.inbound = Some(ctx("acc_volato", "org_volato"));
653        assert!(matches!(
654            case.resolve(),
655            Err(Refusal::NotPermitted {
656                source: Source::Inbound,
657                ..
658            })
659        ));
660    }
661
662    #[test]
663    fn the_user_default_applies_only_where_the_agent_is_permitted() {
664        let held = held();
665        let mut case = base(&held);
666        case.agent = agent(&["org_parslee"], None, true);
667        case.user_default = Some(ctx("acc_volato", "org_volato"));
668        // The default is a preference among the agent's orgs, not a grant.
669        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
670
671        case.agent.personal = false;
672        assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
673    }
674
675    #[test]
676    fn an_explicit_refusal_never_falls_back_to_a_default() {
677        let held = held();
678        let mut case = base(&held);
679        case.agent = agent(&["org_parslee"], Some("org_parslee"), true);
680        case.explicit = Some(Explicit::Org("org_volato".into()));
681        // The agent default and the user default would both succeed; neither
682        // is consulted once an explicit choice was refused.
683        assert!(matches!(
684            case.resolve(),
685            Err(Refusal::NotPermitted {
686                source: Source::Explicit,
687                ..
688            })
689        ));
690    }
691
692    #[test]
693    fn personal_work_needs_the_agent_to_allow_it() {
694        let held = held();
695        let mut case = base(&held);
696        case.user_default = None;
697        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
698
699        case.agent.personal = false;
700        assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
701
702        case.explicit = Some(Explicit::Personal);
703        assert!(matches!(
704            case.resolve(),
705            Err(Refusal::PersonalNotAllowed { .. })
706        ));
707    }
708
709    #[test]
710    fn an_agent_with_no_org_settings_stays_personal() {
711        let held = held();
712        let mut case = base(&held);
713        case.agent = AgentOrganizations::personal_only("milo");
714        // Today's behaviour is preserved, with or without a default org:
715        // setting one does not enroll an agent that never joined it.
716        case.user_default = None;
717        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
718        case.user_default = Some(ctx("acc_parslee", "org_parslee"));
719        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
720        // But an explicit org is refused, not silently run as personal.
721        case.explicit = Some(Explicit::Org("org_parslee".into()));
722        assert!(matches!(case.resolve(), Err(Refusal::NotPermitted { .. })));
723    }
724
725    #[test]
726    fn an_org_no_login_holds_is_refused() {
727        let held = held();
728        let mut case = base(&held);
729        case.agent.permitted = Permitted::AnyHeld;
730        case.explicit = Some(Explicit::Org("org_acme".into()));
731        assert!(matches!(
732            case.resolve(),
733            Err(Refusal::NotHeld {
734                source: Source::Explicit,
735                ..
736            })
737        ));
738    }
739
740    #[test]
741    fn a_stale_user_default_is_refused_by_name() {
742        // The Volato login was removed; the default still names it.
743        let held: Vec<WorkContext> = held()
744            .into_iter()
745            .filter(|c| c.account_id != "acc_volato")
746            .collect();
747        let mut case = base(&held);
748        case.user_default = Some(ctx("acc_volato", "org_volato"));
749        match case.resolve() {
750            Err(Refusal::DefaultUnavailable { org }) => assert_eq!(org, "org_volato"),
751            other => panic!("expected DefaultUnavailable, got {other:?}"),
752        }
753    }
754
755    #[test]
756    fn an_explicit_context_must_be_held_exactly() {
757        let held = held();
758        let mut case = base(&held);
759        // Right org, wrong account.
760        case.explicit = Some(Explicit::Context(ctx("acc_parslee", "org_volato")));
761        assert!(matches!(
762            case.resolve(),
763            Err(Refusal::ContextNotHeld { .. })
764        ));
765        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
766        assert_eq!(
767            bound(case.resolve()),
768            (some("acc_volato"), some("org_volato"), Source::Explicit)
769        );
770    }
771
772    #[test]
773    fn two_logins_in_one_org_is_ambiguous_not_a_guess() {
774        let mut held = held();
775        held.push(ctx("acc_parslee", "org_volato"));
776        let mut case = base(&held);
777        case.explicit = Some(Explicit::Org("org_volato".into()));
778        match case.resolve() {
779            Err(Refusal::Ambiguous { org, accounts }) => {
780                assert_eq!(org, "org_volato");
781                assert_eq!(accounts, vec!["acc_parslee", "acc_volato"]);
782            }
783            other => panic!("expected Ambiguous, got {other:?}"),
784        }
785        // A full context removes the ambiguity.
786        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
787        assert_eq!(bound(case.resolve()).0, some("acc_volato"));
788    }
789
790    #[test]
791    fn duplicate_index_entries_are_not_ambiguous() {
792        let mut held = held();
793        held.push(ctx("acc_volato", "org_volato"));
794        let mut case = base(&held);
795        case.explicit = Some(Explicit::Org("org_volato".into()));
796        assert_eq!(bound(case.resolve()).0, some("acc_volato"));
797    }
798
799    #[test]
800    fn every_refusal_names_a_next_step() {
801        let refusals = [
802            Refusal::RepositoryUnreadable {
803                location: "x".into(),
804                error: "y".into(),
805            },
806            Refusal::RepositoryMismatch {
807                repository_org: "a".into(),
808                declared_in: "x".into(),
809                requested: None,
810                source: Source::Explicit,
811            },
812            Refusal::NotPermitted {
813                agent: "a".into(),
814                org: "o".into(),
815                source: Source::Explicit,
816            },
817            Refusal::PersonalNotAllowed { agent: "a".into() },
818            Refusal::NotHeld {
819                org: "o".into(),
820                source: Source::Explicit,
821            },
822            Refusal::Ambiguous {
823                org: "o".into(),
824                accounts: vec!["a".into(), "b".into()],
825            },
826            Refusal::ContextNotHeld {
827                context: ctx("a", "o"),
828            },
829            Refusal::DefaultUnavailable { org: "o".into() },
830            Refusal::Unresolved { agent: "a".into() },
831        ];
832        for refusal in refusals {
833            let message = refusal.to_string();
834            let actionable = ["Fix", "Work in", "Add", "Choose", "Sign in", "Name"]
835                .iter()
836                .any(|verb| message.contains(verb));
837            assert!(actionable, "refusal without a next step: {message}");
838        }
839    }
840}