Skip to main content

kernel/event/record/
provider.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! `provider_degraded`: something this city leans on answered less than
7//! it should, and the line says which of two things that was.
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::AxError;
12
13/// `provider_degraded` has two writers with two shapes.
14///
15/// The carrier table in `kernel::error` sends `E_PROVIDER` here, written
16/// as the error itself; the vault's startup probe writes its fallback to
17/// session memory. A reader tells them apart by reading, not by guessing
18/// from a key, and a line that is neither does not read.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(untagged)]
22pub enum ProviderDegraded {
23    /// A provider refused a call; this is the refusal, flat.
24    Refused(AxError),
25    /// The platform credential service failed its startup round trip.
26    VaultFellBack(VaultFellBack),
27}
28
29/// Tries the refusal, then the fallback, and when neither reads names
30/// what each shape missed: a derived untagged enum reports only that no
31/// variant matched, which leaves the reader of an unreadable line without
32/// the field to look at.
33impl<'de> Deserialize<'de> for ProviderDegraded {
34    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
35        let line = serde_json::Value::deserialize(deserializer)?;
36        AxError::deserialize(&line)
37            .map(Self::Refused)
38            .or_else(|as_refusal| {
39                VaultFellBack::deserialize(&line)
40                    .map(Self::VaultFellBack)
41                    .map_err(|as_fallback| {
42                        serde::de::Error::custom(format!(
43                            "neither a provider refusal ({as_refusal}) nor a vault fallback \
44                             ({as_fallback})"
45                        ))
46                    })
47            })
48    }
49}
50
51/// The vault's fallback, as the startup probe writes it.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct VaultFellBack {
55    /// Which part degraded; the probe writes `vault`.
56    pub component: String,
57    /// Where credentials rest instead; the probe writes `session-memory`.
58    pub fallback: String,
59    /// `gateway::Persistence` in its own spelling.
60    pub persistence: String,
61    /// Why the platform service was not used.
62    pub reason: String,
63}
64
65#[cfg(test)]
66#[allow(clippy::unwrap_used, reason = "test code")]
67mod tests {
68    use super::*;
69    use crate::event::Payload;
70
71    #[test]
72    fn a_vault_line_keeps_the_bytes_its_ledger_already_holds() {
73        let line = r#"{"component":"vault","fallback":"session-memory","persistence":"this-process","reason":"probe reference unparsable"}"#;
74        let read: ProviderDegraded = serde_json::from_str::<Payload>(line)
75            .unwrap()
76            .read()
77            .unwrap();
78        assert!(matches!(read, ProviderDegraded::VaultFellBack(_)));
79        assert_eq!(
80            serde_json::to_string(&Payload::of(&read).unwrap()).unwrap(),
81            line
82        );
83    }
84
85    #[test]
86    fn a_line_that_is_neither_shape_names_what_each_shape_missed() {
87        let line = r#"{"code":"E_PROVIDER","component":"vault"}"#;
88        let err = serde_json::from_str::<Payload>(line)
89            .unwrap()
90            .read::<ProviderDegraded>()
91            .unwrap_err()
92            .to_string();
93        assert!(
94            err.contains("neither a provider refusal (missing field `action`)")
95                && err.contains("nor a vault fallback (missing field `fallback`)"),
96            "{err}"
97        );
98    }
99}
100
101/// One ordered account declaration; plaintext credentials stay in the vault.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct ProviderAccount {
105    pub id: crate::ServerLabel,
106    #[serde(default)]
107    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
108    pub reference: Option<crate::SecretRef>,
109    #[serde(default)]
110    pub header: Option<String>,
111}
112
113/// Validates the ordered account authority before registration or replay.
114///
115/// # Errors
116/// Refuses an empty list, repeated id/reference, or an anonymous header.
117pub fn validate_provider_accounts(
118    accounts: Option<&[ProviderAccount]>,
119) -> Result<(), crate::AxError> {
120    let Some(accounts) = accounts else {
121        return Ok(());
122    };
123    let refuse = |subject: &str| {
124        crate::AxError::failure(
125        crate::AxCode::ConfigInvalid, "register provider accounts", subject,
126    ).with_recovery("give each account a distinct id and vault reference; use an explicit anonymous account for an unkeyed provider")
127    };
128    if accounts.is_empty() {
129        return Err(refuse("the account list is empty"));
130    }
131    let mut ids = std::collections::BTreeSet::new();
132    let mut references = std::collections::BTreeSet::new();
133    for account in accounts {
134        if !ids.insert(account.id.as_str()) {
135            return Err(refuse("an account id is repeated"));
136        }
137        if let Some(reference) = &account.reference {
138            if !references.insert(reference) {
139                return Err(refuse("a vault reference is repeated"));
140            }
141        } else if account.header.is_some() {
142            return Err(refuse(
143                "an anonymous account cannot carry a credential header",
144            ));
145        }
146        if let Some(header) = &account.header
147            && (header.is_empty()
148                || !header
149                    .bytes()
150                    .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)))
151        {
152            return Err(refuse("an account header name is invalid"));
153        }
154    }
155    Ok(())
156}
157
158/// The non-secret identity of an account attempted by a model call.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161pub struct ProviderAccountBinding {
162    pub provider: String,
163    pub account: crate::ServerLabel,
164}