Skip to main content

oxibrain_cli/cmd/
foundation.rs

1//! Oxi Foundation v1 — provider profile parsing and Keychain-backed secrets.
2//!
3//! Parses `~/.oxi/foundation/v1/profiles.json` (or the path rooted at
4//! `$OXI_FOUNDATION_HOME` for tests and deployment overrides) according to the
5//! schema in `doc/spec/oxi-foundation-v1.md` §2. Profiles are non-secret by
6//! construction: any field that could carry a secret is a parse-level rejection.
7//!
8//! The `SecretResolver` trait lives at this CLI boundary and is never named
9//! from `oxibrain-core` / `oxibrain-store` / `oxibrain-index` (see ARCHITECTURE
10//! §15.7, ADR-007). Tests use `InMemorySecretResolver`; production uses
11//! `OsKeychainResolver` when the CLI is built with `--features os-keychain`.
12//!
13//! Resolution ladder for an extraction role (Task 3 §3):
14//!   1. `OXIBRAIN_LLM_PROVIDER` (CLI/env override) — wins outright.
15//!   2. Foundation profile for the requested role whose declared capabilities
16//!      satisfy the configured extraction mechanism, and whose Keychain secret
17//!      resolves. A missing/unavailable secret reports why that profile cannot
18//!      run and falls through to (3) without sending extraction elsewhere.
19//!   3. Existing `ANTHROPIC_*` / `OPENAI_*` compatibility environment.
20//!   4. Local GGUF (C2 — no API key required).
21use std::collections::HashSet;
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25use oxibrain_core::extraction::ExtractMechanism;
26use oxibrain_ports::LlmCapabilities;
27use serde::{Deserialize, Serialize};
28
29// ─── schema-version literal (doc/spec/oxi-foundation-v1.md §2.2) ───────────
30
31/// The only `schema_version` this host accepts. Any other value rejects the
32/// whole `profiles.json` at parse time — the host does not silently coerce.
33pub const SCHEMA_VERSION: u32 = 1;
34
35/// The set of legal role strings (§2.3). The host rejects profiles that list
36/// any role outside this set, so a typo never silently disables a profile.
37// Cross-host surface (spec §2.3 closed set): consumed by oxicode/oxios
38// for role validation. The oxibrain-cli parser routes through the typed
39// `ProfileRole` enum and never reads this constant.
40#[allow(dead_code)]
41pub const ALLOWED_ROLES: &[&str] = &[
42    "memory.extract",
43    "memory.consolidate",
44    "coding.primary",
45    "assistant.general",
46];
47
48/// Field names whose presence in `profiles.json` is a parse-level rejection
49/// (§2.5). The locator is the only credential surface; anything that smells
50/// like an inline secret is a hard fail.
51pub const SECRET_FIELD_NAMES: &[&str] = &[
52    "api_key",
53    "apikey",
54    "api-token",
55    "bearer",
56    "access_token",
57    "refresh_token",
58    "secret",
59    "password",
60    "private_key",
61];
62
63// ─── parsed schema types ──────────────────────────────────────────────────
64
65/// The four roles a profile can declare (§2.3). Strongly typed so call sites
66/// compose against an enum, not a raw string.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ProfileRole {
70    #[serde(rename = "memory.extract")]
71    MemoryExtract,
72    #[serde(rename = "memory.consolidate")]
73    MemoryConsolidate,
74    #[serde(rename = "coding.primary")]
75    CodingPrimary,
76    #[serde(rename = "assistant.general")]
77    AssistantGeneral,
78}
79
80impl ProfileRole {
81    /// Cross-host surface: external hosts serialise roles back to the wire
82    /// string when emitting config / logs; the in-crate callers work in
83    /// the typed enum.
84    #[allow(dead_code)]
85    pub fn as_str(self) -> &'static str {
86        match self {
87            ProfileRole::MemoryExtract => "memory.extract",
88            ProfileRole::MemoryConsolidate => "memory.consolidate",
89            ProfileRole::CodingPrimary => "coding.primary",
90            ProfileRole::AssistantGeneral => "assistant.general",
91        }
92    }
93
94    pub fn parse(s: &str) -> Option<Self> {
95        Some(match s {
96            "memory.extract" => ProfileRole::MemoryExtract,
97            "memory.consolidate" => ProfileRole::MemoryConsolidate,
98            "coding.primary" => ProfileRole::CodingPrimary,
99            "assistant.general" => ProfileRole::AssistantGeneral,
100            _ => return None,
101        })
102    }
103}
104
105impl fmt::Display for ProfileRole {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(self.as_str())
108    }
109}
110
111/// A Keychain locator for the profile's secret. The shape is fixed by §2.4:
112/// `{service, account}`. The host's [`SecretResolver`] turns this into a
113/// secret at runtime; the locator itself is safe to share.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SecretLocator {
116    pub service: String,
117    pub account: String,
118}
119
120/// Capabilities the profile's remote model declares (§2.5 lists which
121/// `ExtractMechanism` flags it advertises). Profiles whose declared set does
122/// not satisfy the configured mechanism are rejected before any Keychain
123/// lookup happens.
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(default, deny_unknown_fields)]
126pub struct DeclaredCapabilities {
127    pub grammar: bool,
128    pub structured_output: bool,
129    pub tool_call: bool,
130    pub json_schema: bool,
131}
132
133impl DeclaredCapabilities {
134    /// Does this capability set satisfy `mechanism`? The mapping mirrors the
135    /// existing adapter implementations — grammar for local GGUF, json_schema
136    /// for OpenAI, tool_call for Anthropic, json_mode always true (the
137    /// validator is the only gate). Profiles whose declared set fails this
138    /// check are rejected before the Keychain is touched.
139    pub fn satisfies(&self, mechanism: ExtractMechanism) -> bool {
140        match mechanism {
141            ExtractMechanism::Grammar => self.grammar,
142            ExtractMechanism::JsonSchema => self.json_schema || self.structured_output,
143            ExtractMechanism::ToolCall => self.tool_call,
144            ExtractMechanism::JsonMode => true,
145        }
146    }
147
148    /// Convert to the ports-trait representation. The CLI bridge carries this
149    /// across the boundary to the adapter selection logic.
150    /// Cross-host surface: oxicode/oxios map a profile's declared
151    /// capabilities into the adapter's `LlmCapabilities` view.
152    #[allow(dead_code, clippy::wrong_self_convention)]
153    pub fn as_llm_capabilities(self) -> LlmCapabilities {
154        LlmCapabilities {
155            grammar: self.grammar,
156            structured_output: self.structured_output,
157            tool_call: self.tool_call,
158            json_schema: self.json_schema,
159        }
160    }
161}
162
163/// A single Foundation profile (§2.1, §2.2). Parsed strictly: extra fields that
164/// look like secrets cause the whole file to be rejected (§2.5).
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct ProviderProfile {
167    pub id: String,
168    pub provider: String,
169    pub model: String,
170    pub roles: Vec<ProfileRole>,
171    pub credential: SecretLocator,
172    /// Optional declared capabilities. When present, the host uses them to
173    /// decide whether the profile can satisfy the configured extraction
174    /// mechanism before contacting the Keychain. When absent, the profile is
175    /// treated as capable of every mechanism (preserves the v0 behaviour for
176    /// profiles that haven't been upgraded yet).
177    #[serde(default)]
178    pub capabilities: DeclaredCapabilities,
179}
180
181/// The full `profiles.json` document (§2.1).
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct FoundationProfiles {
184    pub schema_version: u32,
185    pub profiles: Vec<ProviderProfile>,
186}
187
188/// A parsed and validated `FoundationProfiles` ready for role resolution.
189#[derive(Debug, Clone)]
190pub struct ResolvedProfiles {
191    /// The profile list, in declaration order. `pick_for_role` returns the
192    /// first profile whose role membership matches and whose declared
193    /// capabilities satisfy the configured mechanism.
194    pub profiles: Vec<ProviderProfile>,
195}
196
197/// Why a `profiles.json` was rejected at the parse boundary. The host must
198/// report one of these — never silently fall through to a different provider.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum FoundationError {
201    /// `schema_version` is not `1`.
202    UnsupportedSchemaVersion(u32),
203    /// The JSON document is not a valid `FoundationProfiles` shape.
204    InvalidShape(String),
205    /// `profiles.json` carries a field that smells like a secret (§2.5).
206    SecretFieldPresent(String),
207    /// A profile ID appears twice.
208    DuplicateProfileId(String),
209    /// A role string is not one of the four legal values.
210    ///
211    /// Cross-host surface: the strict parser does not currently produce
212    /// this (serde rejects unknown `ProfileRole` enum variants before the
213    /// value reaches us), but the variant is part of the public error
214    /// contract so external hosts can map their own role validation to it.
215    #[allow(dead_code)]
216    UnknownRole(String),
217    /// A role appears twice within the same profile.
218    DuplicateRole(ProfileRole),
219    /// A profile field is the empty string (id, provider, model, credential).
220    EmptyField(&'static str),
221    /// `roles` is empty.
222    EmptyRoles,
223    /// The on-disk document could not be read.
224    IoError(String),
225    /// The OS Keychain refused or did not contain the locator's secret. The
226    /// caller logs this verbatim and falls through to the next step in the
227    /// resolution ladder (§3 — explicit override wins, profile for role wins,
228    /// ANTHROPIC_*/OPENAI_* env, then local). It never silently sends
229    /// extraction to a different remote provider.
230    SecretUnavailable {
231        service: String,
232        account: String,
233        reason: String,
234    },
235    /// A Foundation profile was selected, but its declared capabilities do
236    /// not satisfy the configured extraction mechanism. The host must reject
237    /// the profile before contacting the Keychain.
238    ///
239    /// Cross-host surface: constructed by
240    /// `ResolvedProfiles::pick_for_role`; the oxibrain-cli binary does not
241    /// exercise that path directly today, but the integration tests in
242    /// `tests/foundation_profiles.rs` do.
243    #[allow(dead_code)]
244    CapabilityUnsatisfied {
245        profile_id: String,
246        mechanism: ExtractMechanism,
247    },
248    /// A profile was selected but its role membership does not include the
249    /// role the caller asked for.
250    ///
251    /// Cross-host surface: same reason as
252    /// [`FoundationError::CapabilityUnsatisfied`].
253    #[allow(dead_code)]
254    RoleDenied {
255        profile_id: String,
256        requested: ProfileRole,
257    },
258}
259
260impl fmt::Display for FoundationError {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        match self {
263            FoundationError::UnsupportedSchemaVersion(v) => {
264                write!(
265                    f,
266                    "profiles.json schema_version={v} is not supported (expected 1)"
267                )
268            }
269            FoundationError::InvalidShape(detail) => {
270                write!(f, "profiles.json shape invalid: {detail}")
271            }
272            FoundationError::SecretFieldPresent(field) => write!(
273                f,
274                "profiles.json rejected: carries secret-shaped field `{field}` (§2.5)"
275            ),
276            FoundationError::DuplicateProfileId(id) => {
277                write!(f, "profiles.json rejected: duplicate profile id `{id}`")
278            }
279            FoundationError::UnknownRole(role) => write!(
280                f,
281                "profiles.json rejected: role `{role}` is not one of memory.extract / memory.consolidate / coding.primary / assistant.general"
282            ),
283            FoundationError::DuplicateRole(role) => write!(
284                f,
285                "profiles.json rejected: role `{role}` appears twice in the same profile"
286            ),
287            FoundationError::EmptyField(field) => write!(
288                f,
289                "profiles.json rejected: field `{field}` is the empty string"
290            ),
291            FoundationError::EmptyRoles => {
292                write!(f, "profiles.json rejected: a profile lists no roles")
293            }
294            FoundationError::IoError(detail) => write!(f, "profiles.json I/O error: {detail}"),
295            FoundationError::SecretUnavailable {
296                service,
297                account,
298                reason,
299            } => write!(
300                f,
301                "Foundation profile secret unavailable (Keychain service=`{service}` account=`{account}`): {reason}"
302            ),
303            FoundationError::CapabilityUnsatisfied {
304                profile_id,
305                mechanism,
306            } => write!(
307                f,
308                "Foundation profile `{profile_id}` rejected: declared capabilities do not satisfy extraction mechanism {mechanism:?}"
309            ),
310            FoundationError::RoleDenied {
311                profile_id,
312                requested,
313            } => write!(
314                f,
315                "Foundation profile `{profile_id}` rejected: does not declare role `{requested}`"
316            ),
317        }
318    }
319}
320
321impl std::error::Error for FoundationError {}
322
323// ─── SecretResolver ───────────────────────────────────────────────────────
324
325/// Resolves a Keychain locator to a secret. Production implementations read
326/// the OS Keychain; tests use an in-memory map. The trait is never named from
327/// `oxibrain-core` / `oxibrain-store` / `oxibrain-index`; only the CLI
328/// adapter boundary calls into it (ARCHITECTURE §15.7).
329pub trait SecretResolver: Send + Sync {
330    /// Fetch the secret bytes for a validated locator. Returns a structured
331    /// [`FoundationError::SecretUnavailable`] so the caller can report why
332    /// the profile cannot run and fall through to the next resolution step.
333    fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError>;
334}
335
336/// Deterministic in-memory `SecretResolver` for tests and the local-dev
337/// default. Constructed from a `(service, account) -> secret` map or, when no
338/// map is provided, refuses every lookup so missing-secret behaviour is
339/// exercised by tests rather than masked.
340#[derive(Debug, Default, Clone)]
341pub struct InMemorySecretResolver {
342    entries: std::collections::HashMap<(String, String), String>,
343}
344
345impl InMemorySecretResolver {
346    /// Cross-host surface: integration tests in
347    /// `tests/foundation_profiles.rs` and external-host test harnesses
348    /// build an empty `InMemorySecretResolver` and then `.with_secret(...)`.
349    #[allow(dead_code)]
350    pub fn new() -> Self {
351        Self::default()
352    }
353
354    /// Register a `(service, account) -> secret` mapping. Tests use this to
355    /// shape Keychain behaviour for the assertion.
356    ///
357    /// Cross-host surface: integration tests in
358    /// `tests/foundation_profiles.rs`.
359    #[allow(dead_code)]
360    pub fn with_secret(
361        mut self,
362        service: impl Into<String>,
363        account: impl Into<String>,
364        secret: impl Into<String>,
365    ) -> Self {
366        self.entries
367            .insert((service.into(), account.into()), secret.into());
368        self
369    }
370}
371
372impl SecretResolver for InMemorySecretResolver {
373    fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError> {
374        self.entries
375            .get(&(locator.service.clone(), locator.account.clone()))
376            .cloned()
377            .ok_or_else(|| FoundationError::SecretUnavailable {
378                service: locator.service.clone(),
379                account: locator.account.clone(),
380                reason: "no entry in InMemorySecretResolver (test default)".into(),
381            })
382    }
383}
384
385/// Production Keychain resolver. Only compiled when the `os-keychain` Cargo
386/// feature is enabled; otherwise the CLI defaults to `InMemorySecretResolver`
387/// via [`default_secret_resolver`] so the standalone build (no `keyring`
388/// crate, no Foundation runtime) stays keychain-free.
389///
390/// The implementation uses the `keyring` crate (cross-platform: macOS
391/// Keychain via Security.framework, Linux `libsecret`, Windows Credential
392/// Manager). When the feature is enabled, the resolver is constructed with a
393/// default backend; tests can construct their own.
394#[cfg(feature = "os-keychain")]
395pub struct OsKeychainResolver {
396    service_prefix: String,
397}
398
399#[cfg(feature = "os-keychain")]
400impl OsKeychainResolver {
401    pub fn new() -> Self {
402        Self {
403            service_prefix: "oxibrain/foundation/v1/".to_string(),
404        }
405    }
406
407    /// Cross-host surface: production hosts configure the OS keychain
408    /// resolver with a per-deployment service prefix; the oxibrain-cli
409    /// crate default uses `Default::default` and never names the prefix.
410    #[allow(dead_code)]
411    pub fn with_service_prefix(prefix: impl Into<String>) -> Self {
412        Self {
413            service_prefix: prefix.into(),
414        }
415    }
416}
417
418#[cfg(feature = "os-keychain")]
419impl Default for OsKeychainResolver {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425#[cfg(feature = "os-keychain")]
426impl SecretResolver for OsKeychainResolver {
427    fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError> {
428        use std::collections::BTreeMap;
429        // We use an `Entry` map per (service, account) so multiple lookups
430        // share an in-process cache and the keyring daemon is not flooded.
431        thread_local! {
432            static CACHE: std::cell::RefCell<BTreeMap<(String, String), Result<String, String>>> =
433                const { std::cell::RefCell::new(BTreeMap::new()) };
434        }
435        let service = format!("{}{}", self.service_prefix, locator.service);
436        let key = (service.clone(), locator.account.clone());
437
438        CACHE.with(|cache| {
439            if let Some(cached) = cache.borrow().get(&key) {
440                return cached
441                    .clone()
442                    .map_err(|reason| FoundationError::SecretUnavailable {
443                        service: locator.service.clone(),
444                        account: locator.account.clone(),
445                        reason,
446                    });
447            }
448            let entry = keyring::Entry::new(&service, &locator.account);
449            let outcome = match entry.and_then(|e| e.get_password()) {
450                Ok(secret) => Ok(secret),
451                Err(e) => Err(e.to_string()),
452            };
453            cache.borrow_mut().insert(key, outcome.clone());
454            outcome.map_err(|reason| FoundationError::SecretUnavailable {
455                service: locator.service.clone(),
456                account: locator.account.clone(),
457                reason,
458            })
459        })
460    }
461}
462
463/// Construct the production-default `SecretResolver` for the current build.
464///
465/// - With `--features os-keychain`: returns an `OsKeychainResolver`.
466/// - Without: returns `InMemorySecretResolver::new()` so the standalone build
467///   resolves cleanly even when a Foundation profile is present; the
468///   in-memory resolver refuses every lookup, which the call site treats as
469///   "profile cannot run, fall through to compat env / local".
470pub fn default_secret_resolver() -> Box<dyn SecretResolver> {
471    #[cfg(feature = "os-keychain")]
472    {
473        Box::new(OsKeychainResolver::new())
474    }
475    #[cfg(not(feature = "os-keychain"))]
476    {
477        Box::new(InMemorySecretResolver::new())
478    }
479}
480
481// ─── directory resolution ────────────────────────────────────────────────
482
483/// Resolve the Foundation home directory.
484///
485/// Honours `$OXI_FOUNDATION_HOME` for tests and deployment overrides; falls
486/// back to `~/.oxi/foundation/v1` for normal operation. The host never reads
487/// secrets from disk (§0); only the Keychain does that.
488pub fn foundation_home() -> PathBuf {
489    if let Some(home) = std::env::var_os("OXI_FOUNDATION_HOME") {
490        PathBuf::from(home)
491    } else if let Some(home) = std::env::var_os("HOME") {
492        PathBuf::from(home)
493            .join(".oxi")
494            .join("foundation")
495            .join("v1")
496    } else {
497        PathBuf::from(".oxi").join("foundation").join("v1")
498    }
499}
500
501fn profiles_path(home: &Path) -> PathBuf {
502    home.join("profiles.json")
503}
504
505/// Load `profiles.json` from the standard location and validate it strictly.
506///
507/// - `home`: directory containing `profiles.json`. Tests pass a tempdir; the
508///   CLI passes [`foundation_home()`].
509/// - Returns `Ok(None)` when the file does not exist (the standalone default
510///   has no Foundation profiles — the local path is the resolution step).
511/// - Returns `Err(FoundationError::…)` on any parse-level rejection; the
512///   caller must report the reason and fall through, never silently pick a
513///   different provider.
514pub fn load_profiles(home: &Path) -> Result<Option<ResolvedProfiles>, FoundationError> {
515    let path = profiles_path(home);
516    let bytes = match std::fs::read(&path) {
517        Ok(b) => b,
518        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
519        Err(e) => {
520            return Err(FoundationError::IoError(format!("{}: {e}", path.display())));
521        }
522    };
523
524    // First parse the JSON loosely so we can run the secret-field scan on
525    // raw key names — serde would silently drop unknown fields if we used
526    // `FoundationProfiles` directly with `deny_unknown_fields`.
527    let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
528        FoundationError::InvalidShape(format!("profiles.json is not valid JSON: {e}"))
529    })?;
530
531    let obj = raw
532        .as_object()
533        .ok_or_else(|| FoundationError::InvalidShape("root is not a JSON object".into()))?;
534
535    // §2.5 — secret-shaped field scan covers every profile. We bail out at
536    // the first such field rather than continuing, because the file is
537    // contract-incorrect and the caller must reject the whole document.
538    for profile_value in obj
539        .get("profiles")
540        .and_then(|p| p.as_array())
541        .ok_or_else(|| FoundationError::InvalidShape("`profiles` is not an array".into()))?
542    {
543        let profile_obj = profile_value.as_object().ok_or_else(|| {
544            FoundationError::InvalidShape("a profile entry is not a JSON object".into())
545        })?;
546        for key in profile_obj.keys() {
547            if SECRET_FIELD_NAMES.iter().any(|s| s == key) {
548                return Err(FoundationError::SecretFieldPresent(key.clone()));
549            }
550        }
551    }
552
553    // Strict parse now that we've cleared the secret-field check.
554    let parsed: FoundationProfiles = serde_json::from_value(raw)
555        .map_err(|e| FoundationError::InvalidShape(format!("profiles.json: {e}")))?;
556
557    if parsed.schema_version != SCHEMA_VERSION {
558        return Err(FoundationError::UnsupportedSchemaVersion(
559            parsed.schema_version,
560        ));
561    }
562
563    // §2.2: required fields non-empty. §2.5: roles non-empty, no duplicates.
564    let mut seen_ids: HashSet<String> = HashSet::new();
565    for profile in &parsed.profiles {
566        if profile.id.is_empty() {
567            return Err(FoundationError::EmptyField("id"));
568        }
569        if profile.provider.is_empty() {
570            return Err(FoundationError::EmptyField("provider"));
571        }
572        if profile.model.is_empty() {
573            return Err(FoundationError::EmptyField("model"));
574        }
575        if profile.credential.service.is_empty() {
576            return Err(FoundationError::EmptyField("credential.service"));
577        }
578        if profile.credential.account.is_empty() {
579            return Err(FoundationError::EmptyField("credential.account"));
580        }
581        if !seen_ids.insert(profile.id.clone()) {
582            return Err(FoundationError::DuplicateProfileId(profile.id.clone()));
583        }
584        if profile.roles.is_empty() {
585            return Err(FoundationError::EmptyRoles);
586        }
587        let mut seen_roles: HashSet<ProfileRole> = HashSet::new();
588        for role in &profile.roles {
589            if !seen_roles.insert(*role) {
590                return Err(FoundationError::DuplicateRole(*role));
591            }
592        }
593    }
594
595    Ok(Some(ResolvedProfiles {
596        profiles: parsed.profiles,
597    }))
598}
599
600impl ResolvedProfiles {
601    /// Pick the first profile that lists `role` and whose declared capabilities
602    /// satisfy `mechanism`. Capability rejection is reported as
603    /// `FoundationError::CapabilityUnsatisfied` so the caller can show the
604    /// user why that profile is unsuitable and continue the resolution
605    /// ladder.
606    /// Cross-host surface: tests in `tests/foundation_profiles.rs`
607    /// exercise the role-resolution ladder; the oxibrain-cli binary
608    /// uses `oxibrain_cli::cmd::llm::resolve_provider` (which iterates
609    /// profiles internally) and never calls this method directly.
610    #[allow(dead_code)]
611    pub fn pick_for_role(
612        &self,
613        role: ProfileRole,
614        mechanism: ExtractMechanism,
615    ) -> Result<&ProviderProfile, FoundationError> {
616        for profile in &self.profiles {
617            if !profile.roles.contains(&role) {
618                continue;
619            }
620            if !profile.capabilities.clone().satisfies(mechanism) {
621                return Err(FoundationError::CapabilityUnsatisfied {
622                    profile_id: profile.id.clone(),
623                    mechanism,
624                });
625            }
626            return Ok(profile);
627        }
628        Err(FoundationError::RoleDenied {
629            // Pick the first profile id we saw so the caller can identify the
630            // set; an empty list means "no profiles at all".
631            profile_id: self
632                .profiles
633                .first()
634                .map(|p| p.id.clone())
635                .unwrap_or_default(),
636            requested: role,
637        })
638    }
639
640    /// Profiles in declaration order.
641    pub fn iter(&self) -> std::slice::Iter<'_, ProviderProfile> {
642        self.profiles.iter()
643    }
644}
645
646// ─── provider-kind to adapter mapping ────────────────────────────────────
647
648/// Map a Foundation profile's `provider` field to the host's adapter
649/// catalogue. Foundation v1 deliberately does not name "anthropic" or "openai"
650/// in the spec; the host picks the adapter that can honour the declared
651/// mechanism. Unknown provider kinds are a FoundationError::InvalidShape so a
652/// typo never silently maps to the wrong adapter.
653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
654pub enum ProviderKind {
655    Anthropic,
656    OpenAi,
657}
658
659impl ProviderKind {
660    pub fn parse(s: &str) -> Option<Self> {
661        Some(match s {
662            "anthropic" | "claude" => ProviderKind::Anthropic,
663            "openai" | "gpt" => ProviderKind::OpenAi,
664            _ => return None,
665        })
666    }
667
668    /// Cross-host surface: external hosts serialise the resolved provider
669    /// kind for logging / config maps; the in-crate callers use
670    /// `ProviderKind::Anthropic / OpenAi` directly without naming the
671    /// string spelling.
672    #[allow(dead_code)]
673    pub fn as_str(self) -> &'static str {
674        match self {
675            ProviderKind::Anthropic => "anthropic",
676            ProviderKind::OpenAi => "openai",
677        }
678    }
679}
680
681// ─── tests ────────────────────────────────────────────────────────────────
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    /// Process-wide lock for tests that mutate `OXI_FOUNDATION_HOME`.
688    /// cargo defaults to running tests in parallel across threads; env vars
689    /// are process-global, so any two tests that touch the same variable
690    /// race. Every set-var / remove-var call in this module MUST hold this
691    /// lock for the duration of the test body and any restore.
692    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
693
694    fn write_profiles(dir: &Path, body: &str) {
695        std::fs::create_dir_all(dir).unwrap();
696        std::fs::write(dir.join("profiles.json"), body).unwrap();
697    }
698
699    #[test]
700    fn missing_file_is_not_an_error() {
701        let dir = tempfile::tempdir().unwrap();
702        let got = load_profiles(dir.path()).unwrap();
703        assert!(got.is_none());
704    }
705
706    #[test]
707    fn rejects_secret_shaped_fields() {
708        let dir = tempfile::tempdir().unwrap();
709        write_profiles(
710            dir.path(),
711            r#"{
712              "schema_version": 1,
713              "profiles": [
714                {
715                  "id": "leaky",
716                  "provider": "anthropic",
717                  "model": "claude-sonnet-4-5",
718                  "roles": ["memory.extract"],
719                  "credential": {"service": "oxibrain", "account": "a7"},
720                  "api_key": "sk-test"
721                }
722              ]
723            }"#,
724        );
725        let err = load_profiles(dir.path()).unwrap_err();
726        assert!(matches!(&err, FoundationError::SecretFieldPresent(f) if f == "api_key"));
727    }
728
729    #[test]
730    fn rejects_each_secret_field_by_name() {
731        for field in SECRET_FIELD_NAMES {
732            let dir = tempfile::tempdir().unwrap();
733            let body = format!(
734                r#"{{"schema_version":1,"profiles":[{{"id":"p","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{{"service":"s","account":"a"}},"{field}":"x"}}]}}"#
735            );
736            write_profiles(dir.path(), &body);
737            let err = load_profiles(dir.path()).unwrap_err();
738            assert!(
739                matches!(&err, FoundationError::SecretFieldPresent(f) if f == field),
740                "expected SecretFieldPresent({field}), got {err:?}"
741            );
742        }
743    }
744
745    #[test]
746    fn rejects_unsupported_schema_version() {
747        let dir = tempfile::tempdir().unwrap();
748        write_profiles(dir.path(), r#"{"schema_version":2,"profiles":[]}"#);
749        assert!(matches!(
750            load_profiles(dir.path()),
751            Err(FoundationError::UnsupportedSchemaVersion(2))
752        ));
753    }
754
755    #[test]
756    fn empty_profiles_array_is_valid() {
757        let dir = tempfile::tempdir().unwrap();
758        write_profiles(dir.path(), r#"{"schema_version":1,"profiles":[]}"#);
759        let got = load_profiles(dir.path()).unwrap().unwrap();
760        assert!(got.profiles.is_empty());
761    }
762
763    #[test]
764    fn rejects_duplicate_profile_id() {
765        let dir = tempfile::tempdir().unwrap();
766        let body = r#"{
767          "schema_version": 1,
768          "profiles": [
769            {"id":"same","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}},
770            {"id":"same","provider":"openai","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}}
771          ]
772        }"#;
773        write_profiles(dir.path(), body);
774        assert!(matches!(
775            &load_profiles(dir.path()),
776            Err(FoundationError::DuplicateProfileId(id)) if id == "same"
777        ));
778    }
779
780    #[test]
781    fn rejects_unknown_role() {
782        let dir = tempfile::tempdir().unwrap();
783        let body = r#"{
784          "schema_version": 1,
785          "profiles": [
786            {"id":"p","provider":"anthropic","model":"m","roles":["memory.unknown"],"credential":{"service":"s","account":"a"}}
787          ]
788        }"#;
789        write_profiles(dir.path(), body);
790        let err = load_profiles(dir.path()).unwrap_err();
791        // Unknown role lands in serde's strict-deserialize path; we surface
792        // it as InvalidShape so the user can read the underlying detail.
793        assert!(matches!(err, FoundationError::InvalidShape(_)));
794    }
795
796    #[test]
797    fn rejects_empty_roles() {
798        let dir = tempfile::tempdir().unwrap();
799        let body = r#"{
800          "schema_version": 1,
801          "profiles": [
802            {"id":"p","provider":"anthropic","model":"m","roles":[],"credential":{"service":"s","account":"a"}}
803          ]
804        }"#;
805        write_profiles(dir.path(), body);
806        assert!(matches!(
807            &load_profiles(dir.path()),
808            Err(FoundationError::EmptyRoles)
809        ));
810    }
811
812    #[test]
813    fn rejects_duplicate_role_in_profile() {
814        let dir = tempfile::tempdir().unwrap();
815        let body = r#"{
816          "schema_version": 1,
817          "profiles": [
818            {"id":"p","provider":"anthropic","model":"m","roles":["memory.extract","memory.extract"],"credential":{"service":"s","account":"a"}}
819          ]
820        }"#;
821        write_profiles(dir.path(), body);
822        assert!(matches!(
823            &load_profiles(dir.path()),
824            Err(FoundationError::DuplicateRole(ProfileRole::MemoryExtract))
825        ));
826    }
827
828    #[test]
829    fn rejects_empty_provider_or_model() {
830        let dir = tempfile::tempdir().unwrap();
831        let body = r#"{
832          "schema_version": 1,
833          "profiles": [
834            {"id":"p","provider":"","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}}
835          ]
836        }"#;
837        write_profiles(dir.path(), body);
838        assert!(matches!(
839            &load_profiles(dir.path()),
840            Err(FoundationError::EmptyField("provider"))
841        ));
842    }
843
844    #[test]
845    fn rejects_empty_credential_locator() {
846        let dir = tempfile::tempdir().unwrap();
847        let body = r#"{
848          "schema_version": 1,
849          "profiles": [
850            {"id":"p","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{"service":"","account":"a"}}
851          ]
852        }"#;
853        write_profiles(dir.path(), body);
854        assert!(matches!(
855            &load_profiles(dir.path()),
856            Err(FoundationError::EmptyField("credential.service"))
857        ));
858    }
859
860    #[test]
861    fn accepts_well_formed_canonical_profile() {
862        let dir = tempfile::tempdir().unwrap();
863        let body = r#"{
864          "schema_version": 1,
865          "profiles": [
866            {
867              "id": "work-summariser",
868              "provider": "anthropic",
869              "model": "claude-sonnet-4-5",
870              "roles": ["memory.consolidate", "assistant.general"],
871              "credential": {"service": "oxibrain", "account": "work"}
872            }
873          ]
874        }"#;
875        write_profiles(dir.path(), body);
876        let got = load_profiles(dir.path()).unwrap().unwrap();
877        assert_eq!(got.profiles.len(), 1);
878        assert_eq!(got.profiles[0].id, "work-summariser");
879        assert_eq!(got.profiles[0].provider, "anthropic");
880        assert_eq!(
881            got.profiles[0].roles,
882            vec![
883                ProfileRole::MemoryConsolidate,
884                ProfileRole::AssistantGeneral
885            ]
886        );
887    }
888
889    #[test]
890    fn pick_for_role_skips_non_members() {
891        let dir = tempfile::tempdir().unwrap();
892        let body = r#"{
893          "schema_version": 1,
894          "profiles": [
895            {"id":"a","provider":"anthropic","model":"m","roles":["coding.primary"],"credential":{"service":"s","account":"a"}},
896            {"id":"b","provider":"openai","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"b"},"capabilities":{"grammar":false,"structured_output":true,"tool_call":true,"json_schema":true}}
897          ]
898        }"#;
899        write_profiles(dir.path(), body);
900        let got = load_profiles(dir.path()).unwrap().unwrap();
901        let pick = got
902            .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
903            .unwrap();
904        assert_eq!(pick.id, "b");
905    }
906
907    #[test]
908    fn pick_for_role_rejects_when_capabilities_unsatisfy() {
909        let dir = tempfile::tempdir().unwrap();
910        // Profile declares only `grammar` but the configured mechanism is
911        // JsonSchema — must be rejected before any Keychain call.
912        let body = r#"{
913          "schema_version": 1,
914          "profiles": [
915            {
916              "id":"constrained",
917              "provider":"anthropic",
918              "model":"m",
919              "roles":["memory.extract"],
920              "credential":{"service":"s","account":"a"},
921              "capabilities":{"grammar":true,"structured_output":false,"tool_call":false,"json_schema":false}
922            }
923          ]
924        }"#;
925        write_profiles(dir.path(), body);
926        let got = load_profiles(dir.path()).unwrap().unwrap();
927        let err = got
928            .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
929            .unwrap_err();
930        assert!(
931            matches!(&err, FoundationError::CapabilityUnsatisfied { profile_id, .. } if profile_id == "constrained")
932        );
933    }
934
935    #[test]
936    fn pick_for_role_role_denied_when_no_match() {
937        let dir = tempfile::tempdir().unwrap();
938        let body = r#"{
939          "schema_version": 1,
940          "profiles": [
941            {"id":"a","provider":"anthropic","model":"m","roles":["coding.primary"],"credential":{"service":"s","account":"a"}}
942          ]
943        }"#;
944        write_profiles(dir.path(), body);
945        let got = load_profiles(dir.path()).unwrap().unwrap();
946        let err = got
947            .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
948            .unwrap_err();
949        assert!(matches!(
950            &err,
951            FoundationError::RoleDenied {
952                requested: ProfileRole::MemoryExtract,
953                ..
954            }
955        ));
956    }
957
958    #[test]
959    fn in_memory_resolver_hits_and_misses() {
960        let resolver =
961            InMemorySecretResolver::new().with_secret("oxibrain", "work", "secret-value");
962        let hit = resolver
963            .resolve(&SecretLocator {
964                service: "oxibrain".into(),
965                account: "work".into(),
966            })
967            .unwrap();
968        assert_eq!(hit, "secret-value");
969        let miss = resolver.resolve(&SecretLocator {
970            service: "oxibrain".into(),
971            account: "missing".into(),
972        });
973        assert!(matches!(
974            miss,
975            Err(FoundationError::SecretUnavailable { .. })
976        ));
977    }
978
979    #[test]
980    fn foundation_home_uses_env_when_set() {
981        // Hold the process-wide env lock for the entire set/run/restore
982        // window so a parallel test cannot observe a half-set home and our
983        // restore on exit doesn't clobber another test's set-var.
984        //
985        // SAFETY: env vars are process-global; we serialise every mutation
986        // in this module through `ENV_LOCK`.
987        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
988        let saved = std::env::var_os("OXI_FOUNDATION_HOME");
989        // SAFETY: see `_guard` above.
990        unsafe {
991            std::env::set_var("OXI_FOUNDATION_HOME", "/tmp/foundation-test-home");
992        }
993        let got = foundation_home();
994        // SAFETY: see above.
995        unsafe {
996            match saved {
997                Some(v) => std::env::set_var("OXI_FOUNDATION_HOME", v),
998                None => std::env::remove_var("OXI_FOUNDATION_HOME"),
999            }
1000        }
1001        assert_eq!(got, PathBuf::from("/tmp/foundation-test-home"));
1002    }
1003
1004    #[test]
1005    fn declared_capabilities_satisfy() {
1006        let caps = DeclaredCapabilities {
1007            tool_call: true,
1008            ..DeclaredCapabilities::default()
1009        };
1010        assert!(caps.clone().satisfies(ExtractMechanism::ToolCall));
1011        assert!(!caps.satisfies(ExtractMechanism::JsonSchema));
1012        assert!(!caps.satisfies(ExtractMechanism::Grammar));
1013    }
1014
1015    #[test]
1016    fn openai_profile_with_only_json_schema_passes_capability_check() {
1017        // Mirror of the integration test in tests/foundation_profiles.rs:
1018        // a profile declaring provider=openai with capabilities {json_schema:
1019        // true, tool_call: false, structured_output: false, grammar: false}
1020        // must be selected for `memory.extract` because the OpenAI adapter's
1021        // native mechanism is JsonSchema, not ToolCall.
1022        let dir = tempfile::tempdir().unwrap();
1023        let body = r#"{
1024          "schema_version": 1,
1025          "profiles": [
1026            {
1027              "id": "openai-json",
1028              "provider": "openai",
1029              "model": "gpt-4o",
1030              "roles": ["memory.extract"],
1031              "credential": {"service": "oxibrain", "account": "openai"},
1032              "capabilities": {"grammar": false, "structured_output": false, "tool_call": false, "json_schema": true}
1033            }
1034          ]
1035        }"#;
1036        write_profiles(dir.path(), body);
1037        let got = load_profiles(dir.path())
1038            .unwrap()
1039            .expect("profiles present");
1040        // The resolver picks mechanism from `provider`, so OpenAI is
1041        // validated against JsonSchema; a truthful {json_schema: true}
1042        // profile must pass.
1043        // Validate against JsonSchema (the OpenAI adapter's native
1044        // mechanism). With truthful `{json_schema: true}` capabilities the
1045        // profile must pass.
1046        let pick = got
1047            .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
1048            .unwrap();
1049        assert_eq!(pick.id, "openai-json");
1050    }
1051
1052    #[test]
1053    fn role_round_trip() {
1054        for role in [
1055            ProfileRole::MemoryExtract,
1056            ProfileRole::MemoryConsolidate,
1057            ProfileRole::CodingPrimary,
1058            ProfileRole::AssistantGeneral,
1059        ] {
1060            assert_eq!(ProfileRole::parse(role.as_str()), Some(role));
1061        }
1062        assert!(ProfileRole::parse("memory.unknown").is_none());
1063    }
1064}