Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ jobs:
if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
run: python3 ./scripts/check-core-reachability.py

# Invariant 2 says the engine does no I/O, and until this step nothing
# read the source to check: the retry ladder drew its jitter from the
# OS entropy pool and the hook bus stamped events from SystemTime while
# both files' headers said otherwise. A floor over the I/O surfaces and
# the manifest, plus a down-only count of Instant::now() reads.
- name: no I/O in stella-core, and no new clock read
if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
run: python3 ./scripts/check-core-no-io.py

# Also toolchain-free. AGENTS.md requires that anything noticed and not
# fixed becomes an issue written as a handoff; nothing checked it, and an
# unenforced standard reads as one the codebase is meeting. The decidable
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/guard-self-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ jobs:
if: ${{ !cancelled() }}
run: ./scripts/test-core-reachability.sh

- name: the core-no-io floor, manifest check and clock ratchet
if: ${{ !cancelled() }}
run: ./scripts/test-core-no-io.sh

- name: the unverified-main detector, and that it fails open
if: ${{ !cancelled() }}
run: ./scripts/test-main-verified.sh
Expand Down
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ make gate # = no-scratch + no-secrets + design-refs
# + stat-portability + module-reachability
# + core-reachability (a stella-core module is
# reachable from the engine's step path; down-only)
# + core-no-io (no shipping stella-core source or
# dependency names an I/O surface; Instant::now()
# reads are a down-only count)
# + typed-errors
# + tool-error-class (#3167 unclassified-ToolOutput::error ratchet)
# + dead-code-allows
Expand Down Expand Up @@ -938,6 +941,27 @@ Append; do not renumber. `scripts/check-invariants.sh` enforces both halves.
property-testable. Anything that spawns processes, reads files, or hits the
network belongs in `stella-tools`, `stella-model`, `stella-cli`, or
`stella-store` — injected as a port/trait, not called directly.

Enforced by `scripts/check-core-no-io.py` (`make core-no-io`), which
reads the crate's shipping source — `#[cfg(test)]` bodies, `tests/`
directories and `tests.rs` files stripped — and its `[dependencies]`.
Three questions. A **floor**: no line names the filesystem, a process,
the network, the environment, a standard stream, the wall clock
(`SystemTime::now`), a sleep, an entropy source, or a print macro, and
no dependency is an I/O or entropy crate (`tokio` may take only
`sync`, `time`, `macros` and `rt`). There is no baseline for the floor:
the tree has none and gains none. A **ratchet**: `Instant::now()` reads
are counted per file in `scripts/core-no-io-baseline.txt`, down-only,
because the monotonic clock is ambient state a replay cannot reproduce
even though it is not I/O; `make core-no-io-update` refuses to add a
file or raise a count, so a red run is cleared by reading the clock once
at the edge and passing `now` in, or by taking `ports::Clock`. The
guard was written on 2026-09-09 over two breaches this rule had
carried unread: the retry ladder drew its jitter from `rand::rng()`, and
the hook bus stamped every event from `SystemTime::now()`, while each
file's header said the crate reads nothing directly. Both take a port
now — `retry::Sleeper::jitter` and the `Clock` handed to
`bus::HookBus::new`.
3. **Zero telemetry egress by default.** Community/default Stella sends no
telemetry anywhere; model-provider traffic remains the normal network
exception selected by the user. The sole additional egress is an explicitly
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ python3 ./scripts/check-retired-model-keys.py
./scripts/check-stat-portability.sh
python3 ./scripts/check-module-reachability.py
python3 ./scripts/check-core-reachability.py
python3 ./scripts/check-core-no-io.py
python3 ./scripts/check-typed-errors.py
python3 ./scripts/check-tool-error-class.py
python3 ./scripts/check-dead-code-allows.py
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ GATE_GUARDS_FAST := no-scratch no-secrets design-refs action-pins cargo-install-
release-wiring left-behind \
retired-model-keys \
stat-portability module-reachability core-reachability \
core-no-io \
typed-errors \
tool-error-class \
dead-code-allows measured-constants diagnostic-codes consumer-sites \
Expand Down Expand Up @@ -927,6 +928,18 @@ core-reachability-update: ## Shrink the core-reachability baseline after an evic
core-reachability-test: ## Test the core-reachability walker (hermetic; not part of `gate`)
./scripts/test-core-reachability.sh

.PHONY: core-no-io
core-no-io: ## Assert no shipping stella-core source names an I/O surface, and no new Instant::now() read (down-only)
@python3 ./scripts/check-core-no-io.py

.PHONY: core-no-io-update
core-no-io-update: ## Shrink the core-no-io clock-read baseline after removing a read (refuses to grow)
@python3 ./scripts/check-core-no-io.py --update

.PHONY: core-no-io-test
core-no-io-test: ## Test the core-no-io guard (hermetic; not part of `gate`)
./scripts/test-core-no-io.sh

.PHONY: god-files
god-files: ## Assert AGENTS.md and the crate READMEs name the baselined god files (#1435)
@./scripts/check-god-files.sh
Expand Down
3 changes: 3 additions & 0 deletions crates/stella-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
hmac.workspace = true
# The entropy behind retry jitter. `stella-core` takes it through
# `retry::Sleeper::jitter` and links no entropy source of its own.
rand.workspace = true
reqwest.workspace = true
futures-util.workspace = true
tokio = { workspace = true, features = ["sync", "signal", "net"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/stella-cli/src/agent/tool_stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ mod tests {
let registry = Arc::new(stella_tools::registry::ToolRegistry::new(
dir.path().to_path_buf(),
));
let bus = HookBus::new("gate-2793");
let bus = HookBus::new("gate-2793", stella_core::ports::FixedClock(0));
bus.on_blocking(hook_names::TOOL_CALL_REQUESTED, |event| {
match event.payload["tool"].as_str() {
Some("mcp__vendor__deploy") | Some("my_tool") => {
Expand Down
3 changes: 2 additions & 1 deletion crates/stella-cli/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::runtime::WallClock;
use stella_core::bus::{HookBus, HookDecision, names as hook_names};
use stella_learn::rules::{
LoadRulesOptions, ProposedAction, Rule, RuleFile, RuleSource, evaluate_guards, load_rules,
Expand Down Expand Up @@ -587,7 +588,7 @@ pub(crate) fn attach_rule_guards(registry: &ToolRegistry, rules: &ResolvedRules)
return;
}
let rules = Arc::clone(&rules.rules);
let bus = HookBus::new(format!("rules-{}", std::process::id()));
let bus = HookBus::new(format!("rules-{}", std::process::id()), WallClock);
bus.on_blocking(hook_names::TOOL_CALL_REQUESTED, move |event| {
let tool = canonical_tool(event.payload["tool"].as_str().unwrap_or_default());
let input = &event.payload["input"];
Expand Down
10 changes: 9 additions & 1 deletion crates/stella-cli/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! keeps `stella-core` free of production `tokio::time` calls.

use async_trait::async_trait;
use rand::RngExt;
use stella_core::ports::Clock;
use stella_core::retry::Sleeper;

Expand Down Expand Up @@ -55,7 +56,10 @@ impl Clock for WallClock {
}
}

/// The production [`Sleeper`]: a thin wrapper over `tokio::time::sleep`.
/// The production [`Sleeper`]: `tokio::time::sleep` for the wait, and the
/// OS entropy pool for the jitter that spreads concurrent retriers across
/// the backoff window. Both live here so `stella-core` links neither a
/// timer nor an entropy source.
#[derive(Debug, Default, Clone, Copy)]
pub struct TokioSleeper;

Expand All @@ -64,6 +68,10 @@ impl Sleeper for TokioSleeper {
async fn sleep(&self, duration_ms: u64) {
tokio::time::sleep(std::time::Duration::from_millis(duration_ms)).await;
}

fn jitter(&self, upper: u64) -> u64 {
rand::rng().random_range(0..=upper)
}
}

/// The budget guard for a one-shot invocation, with its wall-clock task
Expand Down
5 changes: 4 additions & 1 deletion crates/stella-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "time"] }
async-trait = { workspace = true }
futures-util = { workspace = true }
rand = { workspace = true }
sha2 = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
# Seeds the retry tests' jitter double. Shipping code draws nothing: the
# entropy behind backoff jitter is a port (`retry::Sleeper::jitter`), and
# `make core-no-io` refuses this crate an entropy source.
rand = { workspace = true }
proptest = { workspace = true }
6 changes: 5 additions & 1 deletion crates/stella-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ filesystem, never spawns a process, never opens a socket. Anything needing the
outside world is a trait the caller implements: `ToolExecutor`, `Clock`,
`TurnGate`, `TurnSteering` ([`src/ports.rs`](src/ports.rs)), `Sleeper`
([`src/retry.rs`](src/retry.rs)), `HookRunner` ([`src/hooks.rs`](src/hooks.rs))
— plus `Provider` from `stella-protocol`.
— plus `Provider` from `stella-protocol`. `make core-no-io`
(`scripts/check-core-no-io.py`) reads the shipping source and the manifest
and fails on any of those surfaces named directly; the `Instant::now()` reads
the deadline arithmetic still makes are a down-only count in
`scripts/core-no-io-baseline.txt`, cleared by passing `now` in from the edge.
Even the working directory is passed in (`EngineConfig::cwd`) rather than read
from `std::env`. That is what makes compaction, eviction, loop detection and
budget arithmetic plain synchronous functions over owned data, testable against
Expand Down
11 changes: 11 additions & 0 deletions crates/stella-core/src/accounted_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,11 @@ mod tests {
#[async_trait]
impl Sleeper for NoopSleeper {
async fn sleep(&self, _duration_ms: u64) {}

// The floor: a test that asserts on retry timing wants no spread in it.
fn jitter(&self, _upper: u64) -> u64 {
0
}
}

struct RetryThenSuccess {
Expand Down Expand Up @@ -976,6 +981,12 @@ mod tests {
async fn sleep(&self, duration_ms: u64) {
tokio::time::sleep(Duration::from_millis(duration_ms)).await;
}

// The floor: the timeout under test is placed against the exact
// backoff, so the draw must not move it.
fn jitter(&self, _upper: u64) -> u64 {
0
}
}

struct AlwaysRetryable;
Expand Down
Loading
Loading