Skip to content
Open
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
94 changes: 53 additions & 41 deletions crates/stella-cli/src/tool_docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@
//! | Field | Source of truth |
//! |---|---|
//! | `name`, `description`, `input_schema` | the tool's own [`ToolSchema`] |
//! | `read_only`, `available_for_speculation`, `category`, `availability` | [`stella_tools::catalog`] |
//! | `read_only`, `available_for_speculation`, `category`, `availability`, `risk_level` | [`stella_tools::catalog`] |
//! | `output_schema` | [`ToolOutput`]'s serde encoding, read off the type |
//! | `risk_level` | **nothing declares it** — see [`RISK_NOTE`] |
//! | the example payloads | an observed bench capture, via a committed fixture |
//!
//! # Why this lives in a `#[cfg(test)]` module of a binary crate
Expand Down Expand Up @@ -51,35 +50,33 @@ const REFRESH_ENV: &str = "STELLA_REFRESH_TOOL_DOCS";
/// Where the generated pages live, relative to the repository root.
const DOCS_DIR: &str = "docs/tools";

/// The one thing in these pages that is a stated absence rather than a value.
/// What `risk_level` means on these pages, and why it is declared rather than
/// computed.
///
/// `risk_level` was requested as a field and does not exist as data. Nothing
/// in [`stella_tools::catalog`], in [`ToolSchema`], or in the policy layer
/// carries a per-tool risk, and the one PR that would have introduced the
/// machinery for it (#2716's `ToolContract`) was closed `wontfix`.
/// This field used to read `"undeclared"`: nothing in the repository carried a
/// per-tool risk, because #2716's `ToolContract` — the machinery for it — had
/// been closed `wontfix`. #3060 asked for the fix to land as a `risk` column
/// on `ToolEntry`, declared once beside the flags it sits with and reviewed
/// like them, and that is where it now lives
/// ([`stella_tools::catalog::ToolEntry::risk`]).
///
/// Two options were available and only one is honest. Deriving a three-rung
/// ordering from `read_only` + `speculation_safe` is arithmetic on two
/// booleans the page already prints: it would put `save_state` — which writes
/// into a self-deleting `TempDir` — at the same rung as `task` — which spends
/// money — and it would read to every future maintainer as a reviewed
/// judgement rather than as a relabelling. Writing a judgement call per tool
/// by hand is worse: it manufactures a source of truth nobody reviewed, in
/// the one artifact whose entire value proposition is that it is derived.
///
/// So the field is emitted, and its value is `"undeclared"`, with the note
/// below and an issue. That keeps the requested shape, keeps the page honest,
/// and puts the fix where it belongs: a `risk` column on `ToolEntry`, declared
/// once beside the flags it sits with and reviewed like them.
/// The note survives the fix because the reasoning it records is still what
/// keeps the column honest: a rung derived from `read_only` +
/// `speculation_safe` would be arithmetic on two booleans this page already
/// prints, and it would put `save_state` — which writes into a self-deleting
/// `TempDir` — at the same rung as `task`, which spends money. The grades are
/// reviewed judgements against a stated rubric, which is exactly why they are
/// declared.
const RISK_NOTE: &str = "\
# risk_level: NOT DECLARED. Nothing in this repository carries a per-tool risk
# level — not the catalog, not ToolSchema, not the policy layer (#2716, which
# would have introduced the machinery, was closed wontfix). The two booleans
# above are the only machine-checked safety claims a tool makes. Relabelling
# them \"low/medium/high\" would add no information while reading as a reviewed
# judgement, and hand-writing one judgement per tool would manufacture a source
# of truth nobody reviewed. Tracked in #3060: put a `risk` column on
# `ToolEntry`, where it is declared once and reviewed like every other column.";
# risk_level: how bad one honest call is — a reviewed judgement, declared in
# crates/stella-tools/src/catalog.rs beside the flags above and graded against
# the rubric on `ToolEntry::risk` (#2716, #3060). A DIFFERENT axis from
# `read_only`: `task` mutates no file and spends real money, while
# `task_create` mutates a board that dies with the session. Deliberately not
# derived from the two booleans above — that would be a relabelling, not
# information. A policy grant is expressed as a ceiling over this grade, and
# every non-built-in tool (MCP, custom manifest) is graded `high` for being
# unreviewed.";

// ── the committed example fixture ───────────────────────────────────────────

Expand Down Expand Up @@ -413,9 +410,8 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str
# Where each field comes from:\n\
# name / description / input_schema the tool's own ToolSchema\n\
# read_only / available_for_speculation / category / availability\n\
# crates/stella-tools/src/catalog.rs\n\
# output_schema stella_protocol::ToolOutput\n\
# risk_level nothing declares it (see below)",
# risk_level crates/stella-tools/src/catalog.rs\n\
# output_schema stella_protocol::ToolOutput",
name = entry.name,
);

Expand All @@ -434,7 +430,7 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str
available_for_speculation = {speculation}\n\
\n\
{risk_note}\n\
risk_level = \"undeclared\"\n\
risk_level = {risk:?}\n\
\n\
description = {description}\n\
\n\
Expand All @@ -457,6 +453,7 @@ fn render_tool(entry: &ToolEntry, schema: &ToolSchema, fixture: &Fixture) -> Str
read_only = entry.read_only,
speculation = entry.speculation_safe,
risk_note = RISK_NOTE,
risk = entry.risk.as_str(),
description = literal_block("description", &schema.description),
input_schema = literal_block("input_schema", &input_schema),
output_schema = literal_block("output_schema", &output_schema),
Expand Down Expand Up @@ -502,12 +499,18 @@ fn render_index(entries: &[&ToolEntry], fixture: &Fixture) -> String {
));
out.push_str(
"Each page carries the tool's name, description, input schema, output schema, \
`read_only`, `available_for_speculation`, category, and a commented example \
input and output payload.\n\n\
Two fields are stated absences rather than values, because inventing them \
`read_only`, `available_for_speculation`, `risk_level`, category, and a \
commented example input and output payload.\n\n\
`risk_level` is a reviewed judgement declared beside the flags it sits with \
in `crates/stella-tools/src/catalog.rs`, graded against the rubric on \
`ToolEntry::risk` (#2716, #3060). It answers a different question from \
`read_only` — what one honest call costs the world, rather than whether the \
workspace changes — and is deliberately not derived from the booleans above \
it, which would be a relabelling rather than information. A policy grant is \
expressed as a ceiling over this grade; every tool that is not a built-in \
(MCP, custom manifest) is graded `high` for being unreviewed.\n\n\
One field remains a stated absence rather than a value, because inventing it \
would manufacture a source of truth nobody reviewed:\n\n\
- **`risk_level` is `\"undeclared\"`.** Nothing in the repository carries a \
per-tool risk level. Tracked in #3060.\n\
- **`output_schema` is the envelope only.** Every tool answers in \
`ToolOutput { ok | error }`, which is declared and is what the field \
holds; the shape of the text inside `ok.content` is a per-tool \
Expand Down Expand Up @@ -682,10 +685,19 @@ fn generated_pages_parse_and_carry_every_promised_field() {
Some(name.trim_end_matches(".toml")),
"{name} documents a different tool than its filename claims"
);
// The two declared gaps are gaps on purpose. If either ever becomes
// real data, this assertion is the reminder to change the prose that
// explains why it is not.
assert_eq!(table["risk_level"].as_str(), Some("undeclared"));
// `risk_level` stopped being a declared gap in #2716/#3060, so the
// assertion got stronger rather than disappearing: the page must print
// the grade its catalog row actually declares. Presence alone would
// let the generator drift from the declaration it derives from, which
// is the one failure this whole directory exists to prevent.
let declared = catalog::get(name.trim_end_matches(".toml"))
.expect("every page is generated from a catalog row")
.risk;
assert_eq!(
table["risk_level"].as_str(),
Some(declared.as_str()),
"{name} prints a risk grade its catalog row does not declare"
);
let schema = table["input_schema"]
.as_str()
.expect("input_schema is a string");
Expand Down
1 change: 1 addition & 0 deletions crates/stella-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ lib.rs), never as a planning assumption.
| [`src/restore.rs`](src/restore.rs) + [`src/driver/restore.rs`](src/driver/restore.rs) | Working-set restoration (#2685): the pure decide-what-to-restore half (span reads, active skill bodies, the budget-proven fit with named drops), and the driver's overflow summarizer plus the restoration loop that re-reads files through `ToolExecutor` with zero model calls. |
| [`src/driver/live_services.rs`](src/driver/live_services.rs) | The end-of-turn assertion (#2764): a turn about to declare done while a service it started is still listening is asked about it once. Names handles; stops nothing. |
| [`src/ports.rs`](src/ports.rs) | The port boundary: `ToolExecutor`, `ReadOnlyTools`, `GrantedTools`, `LiveService`, `Clock`, `TurnGate`, `TurnSteering`, `FallbackResolver`. |
| [`src/ports/authz.rs`](src/ports/authz.rs) | The pluggable authorization seam (#2716): `AuthzGate`, `Principal`, `AuthzDecision`, `AuthzEvalError`, and the `NoAuthz`/`RiskCeiling` built-ins. An `Err` from a gate is an unconditional deny no enforcement flag can soften, folded through `hooks::decision::resolve_precedence` so the gate joins the one precedence ladder rather than forking it. |
| [`src/budget.rs`](src/budget.rs) | `BudgetGuard` — USD spend against a turn and/or session cap. Returns `BudgetOutcome`; aborts nothing itself. |
| [`src/compaction.rs`](src/compaction.rs) | `compact()` — dedup, supersession, aging, eviction. Open when the conversation is being rewritten wrongly. |
| [`src/estimator.rs`](src/estimator.rs) | Conservative token estimate plus `Calibration`/`CalibrationMap`, the per-model drift correction fed by reported usage. |
Expand Down
17 changes: 0 additions & 17 deletions crates/stella-core/src/driver/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,6 @@ mod tests {
//! neither the constant nor the behavior); `the_literal_is_the_constant`
//! pins the spelling.

use std::sync::Mutex;

use async_trait::async_trait;
use serde_json::Value;
use std::sync::Mutex;
Expand All @@ -444,21 +442,6 @@ mod tests {
async fn sleep(&self, _duration_ms: u64) {}
}

/// An executor that offers nothing. The starvation witnesses below drive
/// the summarizer, never a tool, so the port only has to exist.
struct NoTools;
#[async_trait]
impl ToolExecutor for NoTools {
fn schemas(&self) -> Vec<ToolSchema> {
Vec::new()
}
async fn execute(&self, _name: &str, _input: &Value) -> ToolOutput {
ToolOutput::Ok {
content: String::new(),
}
}
}

/// Always answers "SUMMARY" — the summarizer path under test is the
/// restoration that follows the splice, not the summary itself.
struct SummaryProvider;
Expand Down
4 changes: 4 additions & 0 deletions crates/stella-core/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
//! never imports a provider SDK, a filesystem call, or a terminal library —
//! it drives through these traits, mirroring the TS engine's `ports.ts`.

pub mod authz;

use async_trait::async_trait;
use serde_json::Value;
use stella_protocol::{Provider, ToolOutput, ToolSchema};

pub use authz::{AuthzDecision, AuthzEvalError, AuthzGate, NoAuthz, Principal, RiskCeiling};

/// Executes one tool call. Implemented by `stella-tools::ToolRegistry` (and
/// by test doubles). The engine treats it as a black box that never panics.
#[async_trait]
Expand Down
Loading