sprawling-kernel 0.0.11

Every decision of a sprawling city - addresses, events, gates, plans and errors - with nothing that touches a disk.
Documentation
// 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, specified by `crates/kernel/spec/Config.lean`.
//! `FrozenConfig` and `LiveConfig` share no field; tests check this property.

use serde::{Deserialize, Serialize};

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

mod container;
mod interpreter;
mod sandbox;
mod search;
pub use container::{ContainerImage, ContainerLimits};
pub use interpreter::Interpreter;
pub use sandbox::{SandboxArm, SandboxLimits};
pub use search::{SearchConfiguration, SearchSupplier};

/// 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,
}

/// 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>,
    /// Which supplier `web_search` reaches, frozen for the reason `mcp`
    /// is: the tool and its parameters enter the catalog at run start,
    /// and a supplier changed mid-run would change who receives the
    /// run's queries without anybody reviewing it. Absent in a snapshot
    /// written before search existed, which reads as `Default`.
    #[serde(default)]
    pub search: SearchConfiguration,
}

/// Every concern's ladder, handed to [`freeze`] together: the values
/// always travel from the configuration reader to the freeze as one, so
/// a concern added later is a field here and `freeze` keeps its
/// signature.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatedConfig {
    pub clock_stamp: LayeredValue<ClockStampGranularity>,
    pub clock_zones: LayeredValue<Vec<ClockZone>>,
    pub effort: LayeredValue<Effort>,
    pub sandbox: LayeredValue<SandboxLimits>,
    pub mcp: LayeredValue<Vec<McpServer>>,
    pub second_threshold: LayeredValue<SecondThreshold>,
    pub search: LayeredValue<SearchConfiguration>,
}

/// 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(stated: &StatedConfig) -> FrozenConfig {
    FrozenConfig {
        clock_stamp: *stated.clock_stamp.resolve().unwrap_or(&CLOCK_STAMP_DEFAULT),
        clock_zones: stated.clock_zones.resolve().cloned().unwrap_or_default(),
        sandbox: stated.sandbox.resolve().cloned().unwrap_or_default(),
        effort: stated.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: stated.mcp.resolve().cloned().unwrap_or_default(),
        second_threshold: stated.second_threshold.resolve().copied(),
        // Silence on every rung is the default supplier, which is what
        // a city written before `[search]` existed meant.
        search: stated.search.resolve().cloned().unwrap_or_default(),
    }
}

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