Skip to content

tool-first: the governance half — ToolContract, AuthzGate port + Principal, ToolCtx events, contracts over the serve wire, manifest parity #2716

Description

@macanderson

Why this issue exists

Epic #2694 defines the contract discipline for tool-first Stella — versioned I/O shapes, token-optimized outputs, journal discipline, discovery routing — but stops short of the governance half: nothing in the epic gives a tool a risk classification, a principal-aware authorization seam, an approval flow, or a channel for in-loop events. Those are required for two things Stella is committed to:

  1. Pluggable RBAC on top of Stella — an operator (or a product embedding Stella) must be able to plug a permission system in at one seam and have it govern every tool call, without patching tools.
  2. Oxagen hosting Stella as its engine — Oxagen's kernel (invoke(): IAM → billing → entitlement, input and output validated against the contract) must be able to authorize each remoted tool call from contract metadata. Today stella-serve hands the host only {name, input} (crates/stella-serve/src/frame.rs:45) plus a bare ToolSchema list (crates/stella-serve/src/session.rs:54) — the host has to maintain a side-table mapping tool names to its own capability model.

This issue is that governance half. It was scoped against a close read of oxagen-platform's capability-contract system (packages/oxagen/src/types.ts CapabilityDeclaration, packages/oxagen/src/kernel.ts _invokeCore, packages/agent/src/runtime/materialize-tools.ts) — both what to copy and what to deliberately not copy (recorded at the bottom so the port cannot silently re-import the defects).

Where Stella stands today (verified 2026-08-10)

Contract element Status
Input schema Advertised (ToolSchema.input_schema, crates/stella-protocol/src/tool.rs:23) as a hand-written json! blob per tool; never validated — every tool re-parses raw Value by hand (crates/stella-tools/src/read.rs:262-274).
Output schema Absent. ToolOutput is `Ok{content: String}
Read-only bit Present and load-bearing (tool.rs:29, declared in crates/stella-tools/src/catalog.rs, drives dispatch grouping and ReadOnlyTools); but MCP and custom TOML tools cannot claim it — crates/stella-tools/src/custom.rs:142-153 hardcodes read_only: false.
Risk level Absent — no risk/severity/side-effect type anywhere in the tree.
Principal-aware authz Absent. The pre-dispatch seams (tool.call.requested blocking chain, crates/stella-tools/src/registry.rs:1296; PreToolUse shell hooks, crates/stella-core/src/driver.rs:2372-2404) see {tool, input} only — no principal, no contract metadata. ToolPolicy is session-global config, not per-caller.
Approval flow RequireApproval dead-ends as a model-visible error (registry.rs:1310) — #2676.
In-loop events Structurally impossible: Tool::execute(&self, input, root) has no event handle. tool.call.progress is declared (crates/stella-core/src/bus/names.rs:60) and emitted nowhere.
Contract version Absent from ToolSchema.

Design

1. ToolContract in stella-protocol

Extends today's ToolSchema (types only, zero logic — the crate's contract):

Serde-first with a round-trip test (invariant #4), wire-schema'd like the protocol types (#2694 asks for exactly this). One byte-stability constraint: whatever the contract renders into the advertised tool list feeds the prompt-cache prefix (invariant #7) — field serialization order is fixed and tested, and adding a field is a deliberate cache-invalidation event, never drift.

catalog.rs is subsumed: one catalog! row per tool becomes one contract. Keep the existing invariant test and extend it: speculation_safe ⊆ read_only, and read_only → risk ∈ {Low, Medium} (a destructive read-only tool is a contradiction the registry rejects).

2. Validation both ways, in the registry

  • Before dispatch: input validated against input_schema. Failure is a structured invalid_input ToolOutput::Error naming the failing field — the model self-corrects on a stable shape instead of each tool's ad-hoc parse prose. (Also removes a whole class of hand-parse divergence: today the schema and the parser can silently disagree.)
  • After execution: data validated against output_schema when declared. Failure is invalid_output — a tool defect, logged and surfaced, never silently passed to the model. Oxagen validates both directions and it is what catches handler drift from contract.

3. AuthzGate port + Principal in stella-core::ports

pub trait AuthzGate: Send + Sync {
    fn check(&self, contract: &ToolContract, principal: &Principal, input: &Value)
        -> Result<Decision, AuthzEvalError>;
}
  • Decision = Allow | Deny { reason } | RequireApproval { reason } — deliberately the same vocabulary as HookDecision (crates/stella-core/src/bus.rs:142) minus Modify, so hooks, settings policy, and RBAC plugins share one taxonomy and one precedence order: operator deny > gate ask > any allow.
  • AuthzEvalError is an unconditional deny. This encodes oxagen-platform's headline defect (OXA-2056) at the type level: an enforcement/leniency flag may soften an Ok(Deny) into a warning, but can never touch an Err — an authorization check that failed to evaluate (store down, resolver bug) always refuses.
  • The gate is a constructor dependency on RuntimeBuilder with an explicit NoAuthz implementation chosen by name — never a nullable global defaulting open. (Oxagen has five module-level let _gate = null slots that default open when bootstrap forgets them; that shipped as a real no-authz surface.)
  • Principal is the engine-side identity of the caller: session user, pipeline role, sub-agent, or an opaque host-supplied principal over the serve wire. Nothing in stella-core interprets host principals — that's the RBAC plugin's job (invariant feat(brand): Stella logo assets + README logo #1: ports, not concretions).
  • The existing tool.call.requested hook chain and PolicyToolSet become two built-in implementations behind this port. Oxagen's IAM (or any RBAC system) is a third.
  • Policy resolvers should be pure functions over prefetched data returning the decision plus a rule-by-rule trace (copy oxagen's resolve.ts Trace shape) — no I/O in the engine (invariant fix(zai): don't misreport an insufficient-balance 429 as a rate limit #2), property-testable, and "why was this denied" becomes a value, not a log grep.

4. ToolCtx and in-loop events

Tool::execute(&self, input: &Value, ctx: &ToolCtx) where ToolCtx carries the workspace root and an event emitter.

5. Contracts over the serve wire

SessionSpec.tools: Vec<ToolSchema>Vec<ToolContract>; ToolRequest frames unchanged. The host now authorizes each request from contract metadata (risk, read_only, requires_approval) instead of a name side-table. Gate placement parity holds: RemoteToolExecutor's pre-frame gate (crates/stella-serve/src/remote.rs:417) stays byte-parity with the CLI's, and grows the same AuthzGate call.

6. Manifest parity + the trust boundary

.stella/tools/*.toml gains read_only, risk, idempotent, optional [output_schema] — the manifest is the TOML serialization of the same contract, not a second format. One asymmetry, stated explicitly:

  • A built-in's claims are trusted (reviewed code).
  • A script's claims are claims. read_only = true from a manifest never admits the tool into the read-only/speculation dispatch sets by itself — either it stays advisory (display + policy input only) or it becomes trusted through the existing foundry adoption gate (witnessed adoption + human enablement + byte-digest match, crates/stella-tools/src/foundry_gate.rs).
  • MCP tools: ingest the MCP spec's tool annotations (readOnlyHint / destructiveHint / idempotentHint) into contract fields as claims with the same untrusted posture, replacing today's hardcoded read_only: false. An MCP server's metadata is untrusted for the same reason its output is (IMPROVEMENT: prompt never warns that tool output can be adversarial — add an injection-flagging contract #2689).

7. Derive every surface from the one contract

Advertised schemas, MCP annotations (when Stella serves tools outward), SessionSpec, discovery listings, and docs all derive from the contract. The counterexample to avoid: oxagen has 351 hand-written MCP tool files restating contract facts, with hand-typed read-only/destructive annotations derived from nothing and checked by nothing.

What NOT to copy from oxagen-platform (so the port doesn't re-import the defects)

  1. Decorative risk. Oxagen's docs claim riskLevel: high requires approval; the code keys only on the requiresApproval boolean, and a high-risk/no-approval capability runs unprompted. Stella decides up front and writes it down: risk is policy input (role/principal risk ceilings, like oxagen's passesRisk() tool filtering — the one place risk does real work there), approval is its own boolean. Docs and code must agree; a parity test pins the claim.
  2. Conflated axes. Their sensitivity doc-comment smuggles "read-only" into a risk grade, and scoped means two unrelated things. One field, one meaning — which is why read_only, risk, and requires_approval are three fields here.
  3. Nullable-global gate injection defaulting open (covered in §3).
  4. A registry that cannot fail on duplicate names (bundler constraint — not a Rust problem). Stella's contract registration is a hard error on duplicates, which also retires the MCP wire-name collision class (CORRECTION: MCP wire-name collision (server 'acme_' + tool 'status' vs 'acme' + '_status') silently routes to the later client #2675).

Constraints for the implementer

Definition of done

  • ToolContract in stella-protocol with serde round-trip + wire-schema tests; catalog rows migrated.
  • Registry validates input and output. Witness: a call with schema-invalid input yields structured invalid_input without the tool executing — fails on main today.
  • AuthzGate port + Principal; PolicyToolSet and the hook chain re-expressed behind it. Witnesses: a DenyAll gate blocks a call the default path allows; a gate that returns Err denies even with enforcement softening on.
  • ToolCtx event emission, with tool.call.progress emitted by at least one long-running built-in (bash) and asserted in a test.
  • SessionSpec carries contracts; schema exported under docs/wire/.
  • Custom-TOML and MCP claim ingestion with the trust posture documented in the crate READMEs.

Refs #2694 (epic — this is its governance half), #2676 (approval flow), #2684 (shared decision enum), #2675 (name uniqueness), #2511.

Metadata

Metadata

Assignees

Labels

P1Important — next in linearea:corestella-core — engine: step loop, budget, compaction, retryfeatureNew capability or improvementwontfixThis will not be worked on

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions