sprawling-kernel 0.0.9

Every decision of a sprawling city - addresses, events, gates, plans and errors - with nothing that touches a disk.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! Three-layer config resolution and the frozen/live split. `FrozenConfig` and `LiveConfig` share no field: the freeze
//! line is a machine-checkable property, not a review note.
//! The part `crates/kernel/spec/Config.lean` specifies this module.

use serde::{Deserialize, Serialize};

use crate::address::Address;
use crate::consts_policy::CLOCK_STAMP_DEFAULT;
use crate::error::{AxCode, AxError};
use crate::model::Effort;
use crate::tool::ServerLabel;

mod interpreter;
pub use interpreter::Interpreter;

/// One environment variable name a scope declares its runs may inherit.
///
/// A `String` would put the judgement in whichever caller remembered it.
/// This has one constructor, and that constructor is reached where a
/// configuration file is read — which is the only moment the refusal is
/// still worth anything, because a name handed to a child process is a
/// name the child cannot be asked to forget.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EnvVarName(String);

impl EnvVarName {
    /// Sole constructor.
    ///
    /// # Errors
    /// Refuses an empty name, a name carrying the `=` that separates a
    /// name from a value, a name carrying a control character, and a
    /// name [`crate::secret::names_a_credential`] reads as a credential.
    pub fn parse(raw: &str) -> Result<EnvVarName, AxError> {
        let reject = |violation: &str| {
            Err(AxError::failure(
                AxCode::InvalidArgs,
                "declare an environment variable",
                raw.to_owned(),
            )
            .with_recovery(format!(
                "{violation}; declare only the names a build needs, and keep a credential in \
                 the vault as a `secret:realm/name` reference"
            )))
        };
        if raw.is_empty() {
            return reject("the name is empty");
        }
        if raw.contains('=') {
            return reject("the name carries `=`, which separates a name from its value");
        }
        if raw.chars().any(char::is_control) {
            return reject("the name carries a control character");
        }
        if crate::secret::names_a_credential(raw) {
            return reject("the name reads as a credential");
        }
        Ok(EnvVarName(raw.to_owned()))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for EnvVarName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl TryFrom<String> for EnvVarName {
    type Error = AxError;

    fn try_from(raw: String) -> Result<EnvVarName, AxError> {
        EnvVarName::parse(&raw)
    }
}

impl From<EnvVarName> for String {
    fn from(name: EnvVarName) -> String {
        name.0
    }
}

/// Clock-stamp cadence for result envelopes. The
/// granularity rides the enum; `Off` costs zero bytes in the window.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClockStampGranularity {
    Off,
    Minute,
    FiveMinute,
    Hour,
}

/// Where the context reminder's second rung sits: a whole percent of the
/// window, `31` through `90` (the three numbers live in `consts_policy`).
///
/// One construction point, [`SecondThreshold::parse`], so a rung the city
/// refuses cannot be spelled, and its refusal carries the domain: that is
/// the only sentence a person editing a `CONFIG.toml` gets. Why the domain
/// is bounded is `consts_policy::INTERVAL_CAP_BYTES`'s derivation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "u64", into = "u64")]
pub struct SecondThreshold(u64);

impl SecondThreshold {
    /// Sole constructor. `raw` is a whole percent of the window.
    ///
    /// # Errors
    /// `E_INVALID_ARGS`, naming the value and the domain it is outside,
    /// when `raw` is not `31..=90`. Never clamped: a file that states 30
    /// means something its writer has to be told is not accepted.
    pub fn parse(raw: u64) -> Result<SecondThreshold, AxError> {
        let floor = crate::consts_policy::CTX_REMINDER_SECOND_MIN;
        let ceiling = crate::consts_policy::CTX_REMINDER_SECOND_MAX;
        if (floor..=ceiling).contains(&raw) {
            return Ok(SecondThreshold(raw));
        }
        Err(AxError::failure(
            AxCode::InvalidArgs,
            "set the second context-reminder threshold",
            raw.to_string(),
        )
        .with_recovery(format!(
            "the legal domain is {floor} through {ceiling} percent of the window; state a whole \
             percent in it, or leave the key out for {}",
            crate::consts_policy::CTX_REMINDER_SECOND_DEFAULT
        )))
    }

    /// The rung, as a whole percent of the window.
    #[must_use]
    pub fn percent(self) -> u64 {
        self.0
    }
}

impl TryFrom<u64> for SecondThreshold {
    type Error = AxError;

    fn try_from(raw: u64) -> Result<SecondThreshold, AxError> {
        SecondThreshold::parse(raw)
    }
}

impl From<SecondThreshold> for u64 {
    fn from(rung: SecondThreshold) -> u64 {
        rung.0
    }
}

/// One value across the City -> Building -> Resident override ladder.
/// Lower layers win; absence falls through upward.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayeredValue<T> {
    pub city: Option<T>,
    pub building: Option<T>,
    pub resident: Option<T>,
}

// Manual impl: the derive would demand `T: Default`, an undesired bound —
// an all-None ladder is a valid default for any T.
impl<T> Default for LayeredValue<T> {
    fn default() -> Self {
        LayeredValue {
            city: None,
            building: None,
            resident: None,
        }
    }
}

impl<T> LayeredValue<T> {
    pub fn resolve(&self) -> Option<&T> {
        self.resident
            .as_ref()
            .or(self.building.as_ref())
            .or(self.city.as_ref())
    }
}

/// One concern timezone: an already-resolved offset, never a tz name —
/// re-resolving names against a moving tz database would fork replayed
/// history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClockZone {
    pub id: String,
    pub offset_min: i32,
}

/// What a run's execution boundary allows. Resolved as one value rather
/// than field by field: a layer that speaks about the sandbox speaks
/// about all of it, so an under-specified layer can only ever reduce
/// what a run may do, never silently grant something the layer above it
/// never mentioned.
///
/// Host facts are deliberately absent — where the Python artifact lives
/// and which shell binary exists belong to the machine, not to the city,
/// and a city carried to another machine must not carry its paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SandboxLimits {
    /// Whether the shell arm may be offered at all. Off by default: a
    /// shell line is the one arm whose reach cannot be read off its
    /// arguments.
    pub shell: bool,
    /// Which interpreter the shell arm runs a line under: a name, never a path.
    #[serde(default)]
    pub interpreter: Interpreter,
    /// Instruction budget for one sandboxed call.
    pub fuel: u64,
    /// Extra readable paths, relative to the city root. The write domain
    /// is decided elsewhere; this only widens what may be read.
    pub mounts: Vec<Address>,
    /// Environment variable names a child process started by this scope
    /// may inherit, on top of the four every run gets.
    ///
    /// Declared name by name rather than widened once for everybody: a
    /// build chain that needs to find its linker needs a handful of
    /// names this machine happens to set, and a longer built-in list
    /// would hand every run in every city whatever those names hold.
    #[serde(default)]
    pub env_passthrough: Vec<EnvVarName>,
    /// Which connector servers this floor lets reach effects nothing
    /// in the city can take back — today, this machine's own desktop.
    ///
    /// Named server by server, and empty by default, for the reason
    /// `env_passthrough` is: a run that presses keys on somebody's
    /// keyboard is doing something no checkpoint puts back, so the floor
    /// says which connector may, in the one file a person edits.
    #[serde(default)]
    pub trusted: Vec<ServerLabel>,
}

impl SandboxLimits {
    /// Whether this floor lets `label` reach what nothing here can
    /// undo. The one reader of the trusted list, so the answer cannot
    /// be spelled two ways.
    #[must_use]
    pub fn trusts(&self, label: &ServerLabel) -> bool {
        self.trusted.iter().any(|allowed| allowed == label)
    }
}

impl Default for SandboxLimits {
    fn default() -> Self {
        SandboxLimits {
            shell: false,
            interpreter: Interpreter::System,
            fuel: crate::consts_policy::SANDBOX_FUEL_DEFAULT,
            mounts: Vec::new(),
            env_passthrough: Vec::new(),
            trusted: Vec::new(),
        }
    }
}

/// One external tool server this run may reach, as its configuration
/// states it: a label, a program on this machine, and that program's
/// arguments.
///
/// The command and its arguments are host facts, for the same reason
/// the sandbox keeps them out: a city carried to another machine must
/// not carry this machine's paths inside its history. They live in
/// `CONFIG.toml` and never in a ledger payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct McpServer {
    pub label: ServerLabel,
    pub transport: McpTransport,
}

/// How this city reaches one server. Exhaustive rather than a URL that
/// might also be a command: the three are different machines to start
/// talking to, they fail differently, and a configuration that leaves it
/// to be guessed is one that guesses wrong on the day it matters.
///
/// Closed rather than `#[non_exhaustive]`: every reader of this enum is
/// inside this one binary, so a wildcard arm here would buy nothing and
/// would hide the next transport from the three modules that must
/// decide about it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum McpTransport {
    /// A program on this machine, spoken to over its own pipes.
    Stdio {
        command: String,
        args: Vec<String>,
        /// What the child is started with, name before value, in the
        /// order the configuration lists them.
        ///
        /// A value may be a `secret:realm/name` reference, which is
        /// redeemed at the moment the process is started and never
        /// written into the configuration: an API key a server needs is
        /// still a credential, and `claude mcp add -e KEY=value` is the
        /// place people are used to putting one.
        env: Vec<(String, String)>,
    },
    /// A server reached over HTTP, which is how a hosted catalogue is
    /// published. The city holds no account with it: whatever it needs
    /// travels in the headers the configuration names.
    Http {
        url: String,
        /// Headers to send, name before value. A value may be a
        /// `secret:realm/name` reference, redeemed at the last slot
        /// before the wire.
        ///
        /// A list rather than one header, because a hosted server
        /// commonly wants an account header beside its key, and a
        /// configuration that can spell only one of the two describes a
        /// server nobody can reach.
        headers: Vec<(String, String)>,
    },
    /// A server that answers on a stream of server-sent events: the
    /// stream is opened first and names where messages are posted, and
    /// every answer comes back down the stream rather than in the body
    /// of the request that asked.
    ///
    /// Its own variant rather than a flag on [`McpTransport::Http`],
    /// because the two open differently and fail differently: an HTTP
    /// server refuses a request, and this one can accept every request
    /// and answer none.
    Sse {
        url: String,
        /// As [`McpTransport::Http`], and sent on both the stream and
        /// the posts.
        headers: Vec<(String, String)>,
    },
}

/// Frozen at Run start, never re-read within the Run.
/// Fields only grow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrozenConfig {
    pub clock_stamp: ClockStampGranularity,
    pub clock_zones: Vec<ClockZone>,
    /// Frozen for the same reason the tool table is: what a run may
    /// reach decides what it was told it could do, and a capability that
    /// widens mid-run is one nobody reviewed.
    pub sandbox: SandboxLimits,
    /// External tool servers, frozen for the same reason as the tool
    /// table itself: their tools enter the catalog at run start, a
    /// provider hashes that array ahead of the system prompt, and a
    /// table that widened mid-run would both discard the cache and
    /// hand the model a capability nobody reviewed.
    pub mcp: Vec<McpServer>,
    /// How hard the model may think. Frozen rather than live because the
    /// provider renders it into the cached prompt prefix: changing the
    /// effort value mid-run invalidates the message cache breakpoints,
    /// so a run that could retune itself would keep paying to rebuild
    /// the cache it just discarded. A change applies to the next run.
    pub effort: Option<Effort>,
    /// Where the context reminder's second rung sits. `None` when no
    /// layer states one: the rung falls back to
    /// `consts_policy::CTX_REMINDER_SECOND_DEFAULT` where it is read.
    /// Frozen with the run: a rung that moved mid-run would make "each
    /// rung sounds once per run" depend on when somebody edited a file.
    pub second_threshold: Option<SecondThreshold>,
}

/// Hot-reloadable surface. Empty: the type exists so the no-field-overlap
/// assertion guards every field added to it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveConfig {}

/// Resolves the ladder into the Run-start snapshot. Absence everywhere
/// falls back to the policy default; the zones ladder overrides as a
/// whole list (a building that writes zones replaces the city list).
pub fn freeze(
    clock_stamp: &LayeredValue<ClockStampGranularity>,
    clock_zones: &LayeredValue<Vec<ClockZone>>,
    effort: &LayeredValue<Effort>,
    sandbox: &LayeredValue<SandboxLimits>,
    mcp: &LayeredValue<Vec<McpServer>>,
    second_threshold: &LayeredValue<SecondThreshold>,
) -> FrozenConfig {
    FrozenConfig {
        clock_stamp: *clock_stamp.resolve().unwrap_or(&CLOCK_STAMP_DEFAULT),
        clock_zones: clock_zones.resolve().cloned().unwrap_or_default(),
        sandbox: sandbox.resolve().cloned().unwrap_or_default(),
        effort: effort.resolve().copied(),
        // A layer that speaks about servers speaks about all of them:
        // an unstated layer reaches nothing rather than inheriting a
        // reach nobody at that layer wrote down.
        mcp: mcp.resolve().cloned().unwrap_or_default(),
        second_threshold: second_threshold.resolve().copied(),
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    reason = "test code"
)]
mod tests;