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