aion-server 0.27.1

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The boot path's required-no-default config set, in one place: the probe
//! that composes the requirement functions, the declared upgrade defaults the
//! boot-side config heal mints for them, and the declared list of required
//! keys the heal deliberately does NOT mint.
//!
//! Ruled 2026-08-25 (Tom): a config from an older version that lacks a
//! newly-required field is HEALED at boot, not refused — "it just needs to
//! check to make sure all the default required fields are there, like every
//! one of them, and insert them if they're not." The refuse-on-missing
//! posture stands for a value the operator wrote WRONG (an explicit zero is
//! still a refusal naming the key); only ABSENCE is healed, because absence
//! on an upgrade means the field did not exist when the operator wrote the
//! file.
//!
//! Every `is required and has no default` message constant in
//! `config::defaults` must appear in exactly ONE of the two declared lists
//! here — [`BOOT_REQUIRED_FIELD_DEFAULTS`] (the heal mints it) or
//! [`NOT_UPGRADE_HEALABLE`] (the heal deliberately does not, with the reason
//! stated). The census test below scans the defaults source for the phrase
//! and fails on a constant that joined neither, so a new requirement cannot
//! silently become an unhealable upgrade refusal.

use crate::error::ServerError;

use super::{required_node_cache_budget, required_transcript_batch_policy};

/// One required-no-default config field's declared upgrade default: what the
/// boot-side config heal inserts into a config FILE that predates the field.
///
/// This table does NOT enumerate which fields are required: the heal
/// discovers that by running [`boot_required_probe`] (the same functions boot
/// runs) and looking each refusal up here by its exact message. A new
/// required field whose refusal has no row in this table is a loud boot-time
/// gap naming the message, never a silently-unhealed config; the enumeration
/// test in `config::heal_tests` and the census test below trip the same gap
/// in CI.
pub(crate) struct RequiredFieldDefault {
    /// The dotted config path, e.g. `store.node_cache_budget` — how logs and
    /// refusals name the field.
    pub(crate) path: &'static str,
    /// The TOML `[section]` the field lives in.
    pub(crate) section: &'static str,
    /// The key within [`Self::section`].
    pub(crate) key: &'static str,
    /// The EXACT operator-facing message the requirement function returns
    /// when the field is absent — the lookup key that ties this row to its
    /// requirement function without a second hand-kept list of fields.
    pub(crate) requirement_message: &'static str,
    /// The declared default, as a TOML value literal. This is the single
    /// authoritative inserted value; it is the value the shipped teaching
    /// configs (`dev-config.toml`, the first-run scaffold, the `aion new`
    /// shared template) chose deliberately in 0.24.0, pinned against those
    /// files by `config::shipped_configs_tests`.
    pub(crate) default_toml: &'static str,
    /// The `AION_*` environment variable that overrides this field at
    /// runtime. When the heal mints the field into a file while this
    /// variable is set, the boot warns that the file value and the running
    /// value differ — the file is healed for its own sake (it must stand
    /// alone), but the log must not assert the file value as the running
    /// fact.
    pub(crate) env_override: &'static str,
    /// The teaching comment written above the inserted key, in the shipped
    /// configs' voice (one `# `-prefixed line per entry).
    pub(crate) teaching_comment: &'static [&'static str],
    /// Whether the field is ABSENT from a parsed config — as opposed to
    /// present with a value the requirement function refuses (an explicit
    /// zero shares the absence message but is the operator's own value, and
    /// the heal never rewrites a value the operator wrote).
    pub(crate) is_absent: fn(&crate::config::ServerConfig) -> bool,
    /// Satisfy this field on an IN-MEMORY config copy so the heal's planner
    /// can step past it and let [`boot_required_probe`] — which stops at its
    /// first refusal — surface the next requirement. Planner bookkeeping
    /// only: the value written into the FILE is always [`Self::default_toml`]
    /// (pinned by the enumeration test's healed-file assertions), and a
    /// present-but-refused operator value stepped over here is never written
    /// anywhere — the boot still refuses it.
    pub(crate) satisfy_in_memory: fn(&mut crate::config::ServerConfig),
}

/// The declared upgrade defaults for every required-no-default config field —
/// the "one standard place" of the 2026-08-25 ruling. Values are the shipped
/// teaching configs' values; see each row's requirement function for why the
/// field exists and why it has no silent runtime default.
pub(crate) const BOOT_REQUIRED_FIELD_DEFAULTS: [RequiredFieldDefault; 3] = [
    RequiredFieldDefault {
        path: "store.node_cache_budget",
        section: "store",
        key: "node_cache_budget",
        requirement_message: crate::config::STORE_NODE_CACHE_BUDGET_REQUIRED,
        default_toml: "\"unlimited\"",
        env_override: "AION_STORE_NODE_CACHE_BUDGET",
        teaching_comment: &[
            "The node cache's BYTE ceiling. Required: a node is not a fixed-size thing",
            "(a leaf reaches the ~96KB class), so a cache bounded only by its entry",
            "count is unbounded in bytes. `unlimited` is the pre-budget behaviour,",
            "stated out loud; set a ceiling once you know your box, e.g.:",
            "  node_cache_budget = { bytes = 1073741824 }   # 1 GiB",
        ],
        is_absent: |config| config.store.node_cache_budget.is_none(),
        satisfy_in_memory: |config| {
            config.store.node_cache_budget = Some(haematite::NodeCacheBudget::Unlimited);
        },
    },
    RequiredFieldDefault {
        path: "observability.max_batch_events",
        section: "observability",
        key: "max_batch_events",
        requirement_message: crate::config::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED,
        default_toml: "64",
        env_override: "AION_OBSERVABILITY_MAX_BATCH_EVENTS",
        teaching_comment: &[
            "The most transcript events one durable commit carries. Every commit",
            "re-persists its whole containing storage leaf, so bigger means fewer",
            "commits (less store) — and more events lost by a single refused commit.",
        ],
        is_absent: |config| config.observability.max_batch_events.is_none(),
        satisfy_in_memory: |config| config.observability.max_batch_events = Some(64),
    },
    RequiredFieldDefault {
        path: "observability.max_batch_hold_ms",
        section: "observability",
        key: "max_batch_hold_ms",
        requirement_message: crate::config::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED,
        default_toml: "200",
        env_override: "AION_OBSERVABILITY_MAX_BATCH_HOLD_MS",
        teaching_comment: &[
            "How long a PARTIAL batch may be held open waiting to fill, in",
            "milliseconds. Bounds how much not-yet-durable transcript a crash can",
            "cost; 0 means never wait — commit whatever is already queued.",
        ],
        is_absent: |config| config.observability.max_batch_hold_ms.is_none(),
        satisfy_in_memory: |config| config.observability.max_batch_hold_ms = Some(200),
    },
];

/// Required-no-default message constants the upgrade heal deliberately does
/// NOT mint, each with the reason stated — the other half of the census
/// contract in the module doc. These requirements are CONDITIONAL on an
/// operator act inside the same file, so absence is not an upgrade gap:
/// minting a value would silently commission (or half-commission) a feature
/// the operator never turned on.
pub(crate) const NOT_UPGRADE_HEALABLE: [(&str, &str); 3] = [
    (
        crate::config::OUTBOX_RECONCILE_INTERVAL_REQUIRED,
        "required only when live outbox reconciliation is being enabled — \
         both reconcile knobs absent means reconciliation is OFF by the \
         operator's standing choice, and minting an interval would silently \
         commission a sweep no one asked for",
    ),
    (
        crate::config::OUTBOX_RECONCILE_STALE_AFTER_REQUIRED,
        "the second half of the reconcile pair: same reasoning — absence \
         selects the feature-off state, so there is no upgrade gap to heal",
    ),
    (
        crate::config::AUTHORING_PROJECT_ROOT_REQUIRED,
        "required only once authoring.gleam_path commissions the Gleam \
         authoring loop, and it names an operator-provisioned project \
         directory (gleam.toml, aion_flow, schemas/) that no default can \
         invent — a minted path would point the loop at nothing",
    ),
];

/// Hold one parsed config to the boot path's required-no-default set, each
/// requirement applied exactly where boot applies it: the node-cache budget at
/// the haematite connect seam only (haematite is the only backend with a node
/// cache), the transcript batch policy on every backend (the transcript
/// channel is always mounted).
///
/// This is the ONE composition of the requirement functions in `state`; the
/// boot-side config heal (`config::heal`) and the shipped-config sweep
/// (`config::shipped_configs_tests`) both run it, so neither can drift from
/// what boot actually demands. The backend gate reads `store.backend` from
/// the config it is HANDED — callers owe it the resolved view of that fact
/// (the heal overlays the environment's backend onto its file view for
/// exactly this reason; see `config::heal`).
///
/// # Errors
///
/// Returns the first requirement function's own [`ServerError::Config`]
/// refusal, message untouched — the heal keys on that exact message.
pub(crate) fn boot_required_probe(config: &crate::config::ServerConfig) -> Result<(), ServerError> {
    let (store, runtime) = config.clone().into_parts();
    if matches!(store.backend, crate::config::StoreBackend::Haematite) {
        required_node_cache_budget(&store)?;
    }
    required_transcript_batch_policy(&runtime)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{BOOT_REQUIRED_FIELD_DEFAULTS, NOT_UPGRADE_HEALABLE};

    type TestResult = Result<(), Box<dyn std::error::Error>>;

    /// The census: every `is required and has no default` message constant in
    /// `config::defaults` — the module that holds the operator-facing
    /// validation messages by contract — appears in exactly ONE of the two
    /// declared lists. A new requirement constant that joins neither fails
    /// here naming itself, so it cannot silently become an unhealable
    /// upgrade refusal; one that joins both fails the disjointness arm.
    ///
    /// The scan reads the defaults SOURCE and extracts its string literals,
    /// so a constant is found by its content, not by a hand-kept name list —
    /// renaming or adding constants cannot dodge the census.
    #[test]
    fn every_required_no_default_message_is_declared_healable_or_not() -> TestResult {
        let source_path = format!("{}/src/config/defaults.rs", env!("CARGO_MANIFEST_DIR"));
        let source = std::fs::read_to_string(&source_path)
            .map_err(|error| format!("cannot read `{source_path}`: {error}"))?;
        let required: Vec<String> = string_literals(&source)
            .into_iter()
            .filter(|literal| literal.contains("is required and has no default"))
            .collect();
        assert!(
            !required.is_empty(),
            "the census found no required-no-default messages in {source_path}; \
             the extraction is broken, not the config surface"
        );
        // Positive control on the scanner itself: every declared-list entry
        // must be FOUND by the extraction — a scanner that stops seeing the
        // literals it is supposed to census fails here, not silently.
        for declared in BOOT_REQUIRED_FIELD_DEFAULTS
            .iter()
            .map(|entry| entry.requirement_message)
            .chain(NOT_UPGRADE_HEALABLE.iter().map(|(message, _)| *message))
        {
            assert!(
                required.iter().any(|found| found == declared),
                "the census scanner did not find a declared message in \
                 {source_path}; the extraction has gone blind: {declared}"
            );
        }
        for message in &required {
            let healable = BOOT_REQUIRED_FIELD_DEFAULTS
                .iter()
                .filter(|entry| entry.requirement_message == message)
                .count();
            let declared_not = NOT_UPGRADE_HEALABLE
                .iter()
                .filter(|(unhealable, _reason)| unhealable == message)
                .count();
            assert_eq!(
                healable + declared_not,
                1,
                "a required-no-default message must appear in exactly one of \
                 BOOT_REQUIRED_FIELD_DEFAULTS and NOT_UPGRADE_HEALABLE \
                 (found in {healable} + {declared_not}): {message}"
            );
        }
        Ok(())
    }

    /// L6: the requirement messages are the heal's lookup keys, so two table
    /// rows sharing one message would make the lookup ambiguous — the first
    /// row would absorb the second's refusal and the second field would never
    /// heal. Uniqueness is asserted across BOTH declared lists.
    #[test]
    fn requirement_messages_are_unique_across_both_declared_lists() {
        let mut seen: Vec<&str> = Vec::new();
        for message in BOOT_REQUIRED_FIELD_DEFAULTS
            .iter()
            .map(|entry| entry.requirement_message)
            .chain(NOT_UPGRADE_HEALABLE.iter().map(|(message, _)| *message))
        {
            assert!(
                !seen.contains(&message),
                "duplicate requirement message across the declared lists: {message}"
            );
            seen.push(message);
        }
    }

    /// N2 census: every `env_override` name declared in
    /// [`BOOT_REQUIRED_FIELD_DEFAULTS`] must exist as a string literal in the
    /// NON-TEST source of `config/env.rs` — the overlay's match arms are the
    /// only place those names have effect, and nothing else ties the table's
    /// free strings to them. Rename an arm and a stale table name would
    /// silently stop the H1 override warning from firing, leaving the boot
    /// log asserting the file value as the running fact; this census fails
    /// that rename by name instead. The scan is truncated at the first
    /// `#[cfg(test)]` so a stale name surviving only in a test fixture
    /// cannot satisfy it.
    ///
    /// The census carries its own controls: a known overlay arm OUTSIDE the
    /// declared table must be found (the scanner sees arms at all), and an
    /// injected bogus name must NOT be found (absence is detectable — the
    /// membership check the census applies would really fail on a stale
    /// name, not pass vacuously).
    #[test]
    fn every_declared_env_override_is_an_overlay_arm() -> TestResult {
        let source_path = format!("{}/src/config/env.rs", env!("CARGO_MANIFEST_DIR"));
        let source = std::fs::read_to_string(&source_path)
            .map_err(|error| format!("cannot read `{source_path}`: {error}"))?;
        let non_test = source
            .split("#[cfg(test)]")
            .next()
            .ok_or("splitting a string yields at least one piece")?;
        let literals = string_literals(non_test);
        // Positive control: the scanner finds overlay arms at all. The
        // control name is a stable arm that is NOT in the declared table, so
        // it proves the scanner independently of the census assertion below.
        // If this arm is ever renamed, update the control — loudly, here.
        assert!(
            literals.iter().any(|found| found == "AION_STORE_BACKEND"),
            "the census scanner did not find the AION_STORE_BACKEND arm in \
             {source_path}; the extraction has gone blind"
        );
        // Negative control: a name that is not an arm is reported absent, so
        // a stale table name would fail the census rather than pass silently.
        assert!(
            !literals
                .iter()
                .any(|found| found == "AION_CENSUS_BOGUS_CONTROL"),
            "the census scanner claims to find a name that exists nowhere in \
             {source_path}; the extraction cannot discriminate"
        );
        for entry in &BOOT_REQUIRED_FIELD_DEFAULTS {
            assert!(
                literals.iter().any(|found| found == entry.env_override),
                "env_override `{}` declared for {} is not an overlay arm \
                 literal in {source_path}: the H1 override warning keys on it \
                 and would silently stop firing — rename the table entry to \
                 match the arm",
                entry.env_override,
                entry.path
            );
        }
        Ok(())
    }

    /// Every plain `"…"` string literal in a Rust source text, unescaped.
    /// Raw strings are not scanned: the defaults module's message constants
    /// are plain literals by observation, and a raw-string message would
    /// still fail the census loudly through the non-empty assertion above if
    /// the module ever converted wholesale.
    fn string_literals(source: &str) -> Vec<String> {
        let mut literals = Vec::new();
        let mut chars = source.chars().peekable();
        while let Some(c) = chars.next() {
            if c != '"' {
                continue;
            }
            let mut literal = String::new();
            let mut closed = false;
            while let Some(inner) = chars.next() {
                match inner {
                    '"' => {
                        closed = true;
                        break;
                    }
                    '\\' => match chars.next() {
                        Some('n') => literal.push('\n'),
                        Some('t') => literal.push('\t'),
                        Some('r') => literal.push('\r'),
                        Some('0') => literal.push('\0'),
                        Some('\n') => {
                            // A line-continuation escape: skip the leading
                            // whitespace of the next line, as rustc does.
                            while matches!(chars.peek(), Some(' ' | '\t')) {
                                chars.next();
                            }
                        }
                        Some(escaped) => literal.push(escaped),
                        None => break,
                    },
                    other => literal.push(other),
                }
            }
            if closed {
                literals.push(literal);
            }
        }
        literals
    }
}