Skip to main content

kernel/
config.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//! Three-layer config resolution and the frozen/live split. `FrozenConfig` and `LiveConfig` share no field: the freeze
7//! line is a machine-checkable property, not a review note.
8//! The part `crates/kernel/spec/Config.lean` specifies this module.
9
10use serde::{Deserialize, Serialize};
11
12use crate::address::Address;
13use crate::consts_policy::CLOCK_STAMP_DEFAULT;
14use crate::error::{AxCode, AxError};
15use crate::model::Effort;
16use crate::tool::ServerLabel;
17
18/// One environment variable name a scope declares its runs may inherit.
19///
20/// A `String` would put the judgement in whichever caller remembered it.
21/// This has one constructor, and that constructor is reached where a
22/// configuration file is read — which is the only moment the refusal is
23/// still worth anything, because a name handed to a child process is a
24/// name the child cannot be asked to forget.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(try_from = "String", into = "String")]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28pub struct EnvVarName(String);
29
30impl EnvVarName {
31    /// Sole constructor.
32    ///
33    /// # Errors
34    /// Refuses an empty name, a name carrying the `=` that separates a
35    /// name from a value, a name carrying a control character, and a
36    /// name [`crate::secret::names_a_credential`] reads as a credential.
37    pub fn parse(raw: &str) -> Result<EnvVarName, AxError> {
38        let reject = |violation: &str| {
39            Err(AxError::failure(
40                AxCode::InvalidArgs,
41                "declare an environment variable",
42                raw.to_owned(),
43            )
44            .with_recovery(format!(
45                "{violation}; declare only the names a build needs, and keep a credential in \
46                 the vault as a `secret:realm/name` reference"
47            )))
48        };
49        if raw.is_empty() {
50            return reject("the name is empty");
51        }
52        if raw.contains('=') {
53            return reject("the name carries `=`, which separates a name from its value");
54        }
55        if raw.chars().any(char::is_control) {
56            return reject("the name carries a control character");
57        }
58        if crate::secret::names_a_credential(raw) {
59            return reject("the name reads as a credential");
60        }
61        Ok(EnvVarName(raw.to_owned()))
62    }
63
64    #[must_use]
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70impl std::fmt::Display for EnvVarName {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl TryFrom<String> for EnvVarName {
77    type Error = AxError;
78
79    fn try_from(raw: String) -> Result<EnvVarName, AxError> {
80        EnvVarName::parse(&raw)
81    }
82}
83
84impl From<EnvVarName> for String {
85    fn from(name: EnvVarName) -> String {
86        name.0
87    }
88}
89
90/// Clock-stamp cadence for result envelopes. The
91/// granularity rides the enum; `Off` costs zero bytes in the window.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum ClockStampGranularity {
95    Off,
96    Minute,
97    FiveMinute,
98    Hour,
99}
100
101/// Where the context reminder's second rung sits: a whole percent of the
102/// window, `31` through `90` (the three numbers live in `consts_policy`).
103///
104/// One construction point, [`SecondThreshold::parse`], so a rung the city
105/// refuses cannot be spelled, and its refusal carries the domain: that is
106/// the only sentence a person editing a `CONFIG.toml` gets. Why the domain
107/// is bounded is `consts_policy::INTERVAL_CAP_BYTES`'s derivation.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(try_from = "u64", into = "u64")]
110pub struct SecondThreshold(u64);
111
112impl SecondThreshold {
113    /// Sole constructor. `raw` is a whole percent of the window.
114    ///
115    /// # Errors
116    /// `E_INVALID_ARGS`, naming the value and the domain it is outside,
117    /// when `raw` is not `31..=90`. Never clamped: a file that states 30
118    /// means something its writer has to be told is not accepted.
119    pub fn parse(raw: u64) -> Result<SecondThreshold, AxError> {
120        let floor = crate::consts_policy::CTX_REMINDER_SECOND_MIN;
121        let ceiling = crate::consts_policy::CTX_REMINDER_SECOND_MAX;
122        if (floor..=ceiling).contains(&raw) {
123            return Ok(SecondThreshold(raw));
124        }
125        Err(AxError::failure(
126            AxCode::InvalidArgs,
127            "set the second context-reminder threshold",
128            raw.to_string(),
129        )
130        .with_recovery(format!(
131            "the legal domain is {floor} through {ceiling} percent of the window; state a whole \
132             percent in it, or leave the key out for {}",
133            crate::consts_policy::CTX_REMINDER_SECOND_DEFAULT
134        )))
135    }
136
137    /// The rung, as a whole percent of the window.
138    #[must_use]
139    pub fn percent(self) -> u64 {
140        self.0
141    }
142}
143
144impl TryFrom<u64> for SecondThreshold {
145    type Error = AxError;
146
147    fn try_from(raw: u64) -> Result<SecondThreshold, AxError> {
148        SecondThreshold::parse(raw)
149    }
150}
151
152impl From<SecondThreshold> for u64 {
153    fn from(rung: SecondThreshold) -> u64 {
154        rung.0
155    }
156}
157
158/// One value across the City -> Building -> Resident override ladder.
159/// Lower layers win; absence falls through upward.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct LayeredValue<T> {
162    pub city: Option<T>,
163    pub building: Option<T>,
164    pub resident: Option<T>,
165}
166
167// Manual impl: the derive would demand `T: Default`, an undesired bound —
168// an all-None ladder is a valid default for any T.
169impl<T> Default for LayeredValue<T> {
170    fn default() -> Self {
171        LayeredValue {
172            city: None,
173            building: None,
174            resident: None,
175        }
176    }
177}
178
179impl<T> LayeredValue<T> {
180    pub fn resolve(&self) -> Option<&T> {
181        self.resident
182            .as_ref()
183            .or(self.building.as_ref())
184            .or(self.city.as_ref())
185    }
186}
187
188/// One concern timezone: an already-resolved offset, never a tz name —
189/// re-resolving names against a moving tz database would fork replayed
190/// history.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ClockZone {
193    pub id: String,
194    pub offset_min: i32,
195}
196
197/// What a run's execution boundary allows. Resolved as one value rather
198/// than field by field: a layer that speaks about the sandbox speaks
199/// about all of it, so an under-specified layer can only ever reduce
200/// what a run may do, never silently grant something the layer above it
201/// never mentioned.
202///
203/// Host facts are deliberately absent — where the Python artifact lives
204/// and which shell binary exists belong to the machine, not to the city,
205/// and a city carried to another machine must not carry its paths.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208pub struct SandboxLimits {
209    /// Whether the shell arm may be offered at all. Off by default: a
210    /// shell line is the one arm whose reach cannot be read off its
211    /// arguments.
212    pub shell: bool,
213    /// Instruction budget for one sandboxed call.
214    pub fuel: u64,
215    /// Extra readable paths, relative to the city root. The write domain
216    /// is decided elsewhere; this only widens what may be read.
217    pub mounts: Vec<Address>,
218    /// Environment variable names a child process started by this scope
219    /// may inherit, on top of the four every run gets.
220    ///
221    /// Declared name by name rather than widened once for everybody: a
222    /// build chain that needs to find its linker needs a handful of
223    /// names this machine happens to set, and a longer built-in list
224    /// would hand every run in every city whatever those names hold.
225    #[serde(default)]
226    pub env_passthrough: Vec<EnvVarName>,
227    /// Which connector servers this floor lets reach effects nothing
228    /// in the city can take back — today, this machine's own desktop.
229    ///
230    /// Named server by server, and empty by default, for the reason
231    /// `env_passthrough` is: a run that presses keys on somebody's
232    /// keyboard is doing something no checkpoint puts back, so the floor
233    /// says which connector may, in the one file a person edits.
234    #[serde(default)]
235    pub trusted: Vec<ServerLabel>,
236}
237
238impl SandboxLimits {
239    /// Whether this floor lets `label` reach what nothing here can
240    /// undo. The one reader of the trusted list, so the answer cannot
241    /// be spelled two ways.
242    #[must_use]
243    pub fn trusts(&self, label: &ServerLabel) -> bool {
244        self.trusted.iter().any(|allowed| allowed == label)
245    }
246}
247
248impl Default for SandboxLimits {
249    fn default() -> Self {
250        SandboxLimits {
251            shell: false,
252            fuel: crate::consts_policy::SANDBOX_FUEL_DEFAULT,
253            mounts: Vec::new(),
254            env_passthrough: Vec::new(),
255            trusted: Vec::new(),
256        }
257    }
258}
259
260/// One external tool server this run may reach, as its configuration
261/// states it: a label, a program on this machine, and that program's
262/// arguments.
263///
264/// The command and its arguments are host facts, for the same reason
265/// the sandbox keeps them out: a city carried to another machine must
266/// not carry this machine's paths inside its history. They live in
267/// `CONFIG.toml` and never in a ledger payload.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct McpServer {
271    pub label: ServerLabel,
272    pub transport: McpTransport,
273}
274
275/// How this city reaches one server. Exhaustive rather than a URL that
276/// might also be a command: the three are different machines to start
277/// talking to, they fail differently, and a configuration that leaves it
278/// to be guessed is one that guesses wrong on the day it matters.
279///
280/// Closed rather than `#[non_exhaustive]`: every reader of this enum is
281/// inside this one binary, so a wildcard arm here would buy nothing and
282/// would hide the next transport from the three modules that must
283/// decide about it.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub enum McpTransport {
288    /// A program on this machine, spoken to over its own pipes.
289    Stdio {
290        command: String,
291        args: Vec<String>,
292        /// What the child is started with, name before value, in the
293        /// order the configuration lists them.
294        ///
295        /// A value may be a `secret:realm/name` reference, which is
296        /// redeemed at the moment the process is started and never
297        /// written into the configuration: an API key a server needs is
298        /// still a credential, and `claude mcp add -e KEY=value` is the
299        /// place people are used to putting one.
300        env: Vec<(String, String)>,
301    },
302    /// A server reached over HTTP, which is how a hosted catalogue is
303    /// published. The city holds no account with it: whatever it needs
304    /// travels in the headers the configuration names.
305    Http {
306        url: String,
307        /// Headers to send, name before value. A value may be a
308        /// `secret:realm/name` reference, redeemed at the last slot
309        /// before the wire.
310        ///
311        /// A list rather than one header, because a hosted server
312        /// commonly wants an account header beside its key, and a
313        /// configuration that can spell only one of the two describes a
314        /// server nobody can reach.
315        headers: Vec<(String, String)>,
316    },
317    /// A server that answers on a stream of server-sent events: the
318    /// stream is opened first and names where messages are posted, and
319    /// every answer comes back down the stream rather than in the body
320    /// of the request that asked.
321    ///
322    /// Its own variant rather than a flag on [`McpTransport::Http`],
323    /// because the two open differently and fail differently: an HTTP
324    /// server refuses a request, and this one can accept every request
325    /// and answer none.
326    Sse {
327        url: String,
328        /// As [`McpTransport::Http`], and sent on both the stream and
329        /// the posts.
330        headers: Vec<(String, String)>,
331    },
332}
333
334/// Frozen at Run start, never re-read within the Run.
335/// Fields only grow.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct FrozenConfig {
338    pub clock_stamp: ClockStampGranularity,
339    pub clock_zones: Vec<ClockZone>,
340    /// Frozen for the same reason the tool table is: what a run may
341    /// reach decides what it was told it could do, and a capability that
342    /// widens mid-run is one nobody reviewed.
343    pub sandbox: SandboxLimits,
344    /// External tool servers, frozen for the same reason as the tool
345    /// table itself: their tools enter the catalog at run start, a
346    /// provider hashes that array ahead of the system prompt, and a
347    /// table that widened mid-run would both discard the cache and
348    /// hand the model a capability nobody reviewed.
349    pub mcp: Vec<McpServer>,
350    /// How hard the model may think. Frozen rather than live because the
351    /// provider renders it into the cached prompt prefix: changing the
352    /// effort value mid-run invalidates the message cache breakpoints,
353    /// so a run that could retune itself would keep paying to rebuild
354    /// the cache it just discarded. A change applies to the next run.
355    pub effort: Option<Effort>,
356    /// Where the context reminder's second rung sits. `None` when no
357    /// layer states one: the rung falls back to
358    /// `consts_policy::CTX_REMINDER_SECOND_DEFAULT` where it is read.
359    /// Frozen with the run: a rung that moved mid-run would make "each
360    /// rung sounds once per run" depend on when somebody edited a file.
361    pub second_threshold: Option<SecondThreshold>,
362}
363
364/// Hot-reloadable surface. Empty: the type exists so the no-field-overlap
365/// assertion guards every field added to it.
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
367pub struct LiveConfig {}
368
369/// Resolves the ladder into the Run-start snapshot. Absence everywhere
370/// falls back to the policy default; the zones ladder overrides as a
371/// whole list (a building that writes zones replaces the city list).
372pub fn freeze(
373    clock_stamp: &LayeredValue<ClockStampGranularity>,
374    clock_zones: &LayeredValue<Vec<ClockZone>>,
375    effort: &LayeredValue<Effort>,
376    sandbox: &LayeredValue<SandboxLimits>,
377    mcp: &LayeredValue<Vec<McpServer>>,
378    second_threshold: &LayeredValue<SecondThreshold>,
379) -> FrozenConfig {
380    FrozenConfig {
381        clock_stamp: *clock_stamp.resolve().unwrap_or(&CLOCK_STAMP_DEFAULT),
382        clock_zones: clock_zones.resolve().cloned().unwrap_or_default(),
383        sandbox: sandbox.resolve().cloned().unwrap_or_default(),
384        effort: effort.resolve().copied(),
385        // A layer that speaks about servers speaks about all of them:
386        // an unstated layer reaches nothing rather than inheriting a
387        // reach nobody at that layer wrote down.
388        mcp: mcp.resolve().cloned().unwrap_or_default(),
389        second_threshold: second_threshold.resolve().copied(),
390    }
391}
392
393#[cfg(test)]
394#[allow(
395    clippy::unwrap_used,
396    clippy::expect_used,
397    clippy::panic,
398    clippy::indexing_slicing,
399    reason = "test code"
400)]
401mod tests;