You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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.tsCapabilityDeclaration, 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):
name, version, description
input_schema — derived from the serde input type via schemars, not hand-written JSON
events: Vec<String> — the event names this tool may emit mid-execution (§4)
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_inputToolOutput::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
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.tsTrace 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.
Makes tool.call.progress real (declared today, emitted nowhere).
contract.events declares what a tool may emit; the registry drops undeclared event names (the contract is the allowlist).
Event emission must never ride ToolOutput (loop-detector keys on output bytes — the timings lesson).
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).
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)
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.
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.
Nullable-global gate injection defaulting open (covered in §3).
Invariants §1 (ports), §2 (no I/O in engine), §4 (serde-first), §7 (byte-stable prompts) all bind here; §7 is the easy one to break — the contract's rendered form is prompt-prefix material.
crates/stella-tools/src/registry.rs is a grandfathered god file closed to growth — validation, the AuthzGate call, and ToolCtx wiring land in sibling submodules.
stella-parity gets a row: contract-carrying tools must have parity across CLI and serve surfaces, witnessed.
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.
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:
invoke(): IAM → billing → entitlement, input and output validated against the contract) must be able to authorize each remoted tool call from contract metadata. Todaystella-servehands the host only{name, input}(crates/stella-serve/src/frame.rs:45) plus a bareToolSchemalist (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.tsCapabilityDeclaration,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)
ToolSchema.input_schema,crates/stella-protocol/src/tool.rs:23) as a hand-writtenjson!blob per tool; never validated — every tool re-parses rawValueby hand (crates/stella-tools/src/read.rs:262-274).ToolOutputis `Ok{content: String}tool.rs:29, declared incrates/stella-tools/src/catalog.rs, drives dispatch grouping andReadOnlyTools); but MCP and custom TOML tools cannot claim it —crates/stella-tools/src/custom.rs:142-153hardcodesread_only: false.tool.call.requestedblocking chain,crates/stella-tools/src/registry.rs:1296;PreToolUseshell hooks,crates/stella-core/src/driver.rs:2372-2404) see{tool, input}only — no principal, no contract metadata.ToolPolicyis session-global config, not per-caller.RequireApprovaldead-ends as a model-visible error (registry.rs:1310) — #2676.Tool::execute(&self, input, root)has no event handle.tool.call.progressis declared (crates/stella-core/src/bus/names.rs:60) and emitted nowhere.ToolSchema.Design
1.
ToolContractinstella-protocolExtends today's
ToolSchema(types only, zero logic — the crate's contract):name,version,descriptioninput_schema— derived from the serde input type viaschemars, not hand-written JSONoutput_schema— plus a structured variant onToolOutput(Ok { content, data: Option<Value> }) so "references, not payloads" (EPIC: tool-first architecture — every capability is a tool with a token-optimized contract; state, environment, and PROOF become tools the agent must drive #2694 §4) is checkableread_only: bool(keep),speculation_safe: bool(keep),idempotent: bool(new — dispatch guard and retry logic both want it)risk: RiskLevel—Low | Medium | High | Destructiverequires_approval: bool— separate from risk, and the docs must say which one gates approval (see "not to copy" feat(brand): Stella logo assets + README logo #1)events: Vec<String>— the event names this tool may emit mid-execution (§4)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.rsis subsumed: onecatalog!row per tool becomes one contract. Keep the existing invariant test and extend it:speculation_safe ⊆ read_only, andread_only → risk ∈ {Low, Medium}(a destructive read-only tool is a contradiction the registry rejects).2. Validation both ways, in the registry
inputvalidated againstinput_schema. Failure is a structuredinvalid_inputToolOutput::Errornaming 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.)datavalidated againstoutput_schemawhen declared. Failure isinvalid_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.
AuthzGateport +Principalinstella-core::portsDecision = Allow | Deny { reason } | RequireApproval { reason }— deliberately the same vocabulary asHookDecision(crates/stella-core/src/bus.rs:142) minusModify, so hooks, settings policy, and RBAC plugins share one taxonomy and one precedence order: operator deny > gate ask > any allow.AuthzEvalErroris an unconditional deny. This encodes oxagen-platform's headline defect (OXA-2056) at the type level: an enforcement/leniency flag may soften anOk(Deny)into a warning, but can never touch anErr— an authorization check that failed to evaluate (store down, resolver bug) always refuses.RuntimeBuilderwith an explicitNoAuthzimplementation chosen by name — never a nullable global defaulting open. (Oxagen has five module-levellet _gate = nullslots that default open when bootstrap forgets them; that shipped as a real no-authz surface.)Principalis the engine-side identity of the caller: session user, pipeline role, sub-agent, or an opaque host-supplied principal over the serve wire. Nothing instella-coreinterprets host principals — that's the RBAC plugin's job (invariant feat(brand): Stella logo assets + README logo #1: ports, not concretions).tool.call.requestedhook chain andPolicyToolSetbecome two built-in implementations behind this port. Oxagen's IAM (or any RBAC system) is a third.resolve.tsTraceshape) — 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.
ToolCtxand in-loop eventsTool::execute(&self, input: &Value, ctx: &ToolCtx)whereToolCtxcarries the workspace root and an event emitter.tool.call.progressreal (declared today, emitted nowhere).create_skill,create_agent_tool,promote_context_record) emit replayable events the same way file mutations already do — today only the registry itself can author events onto the stream.contract.eventsdeclares what a tool may emit; the registry drops undeclared event names (the contract is the allowlist).ToolOutput(loop-detector keys on output bytes — the timings lesson).5. Contracts over the serve wire
SessionSpec.tools: Vec<ToolSchema>→Vec<ToolContract>;ToolRequestframes 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 sameAuthzGatecall.6. Manifest parity + the trust boundary
.stella/tools/*.tomlgainsread_only,risk,idempotent, optional[output_schema]— the manifest is the TOML serialization of the same contract, not a second format. One asymmetry, stated explicitly:read_only = truefrom 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).readOnlyHint/destructiveHint/idempotentHint) into contract fields as claims with the same untrusted posture, replacing today's hardcodedread_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)
riskLevel: highrequires approval; the code keys only on therequiresApprovalboolean, 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'spassesRisk()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.sensitivitydoc-comment smuggles "read-only" into a risk grade, andscopedmeans two unrelated things. One field, one meaning — which is whyread_only,risk, andrequires_approvalare three fields here.Constraints for the implementer
crates/stella-tools/src/registry.rsis a grandfathered god file closed to growth — validation, theAuthzGatecall, andToolCtxwiring land in sibling submodules.Decision::RequireApprovalis CORRECTION: RequireApproval dead-ends as a model-visible error — no path from the policy chain to an interactive grant #2676's deliverable; the hook-surface expansion (IMPROVEMENT: expand the user hook surface — Stop/PreCompact events, input-rewriting and permission-deciding PreToolUse via the extension bus #2684) must share thisDecisionenum rather than invent a parallel one.stella-paritygets a row: contract-carrying tools must have parity across CLI and serve surfaces, witnessed.Definition of done
ToolContractinstella-protocolwith serde round-trip + wire-schema tests; catalog rows migrated.invalid_inputwithout the tool executing — fails onmaintoday.AuthzGateport +Principal;PolicyToolSetand the hook chain re-expressed behind it. Witnesses: aDenyAllgate blocks a call the default path allows; a gate that returnsErrdenies even with enforcement softening on.ToolCtxevent emission, withtool.call.progressemitted by at least one long-running built-in (bash) and asserted in a test.SessionSpeccarries contracts; schema exported underdocs/wire/.Refs #2694 (epic — this is its governance half), #2676 (approval flow), #2684 (shared decision enum), #2675 (name uniqueness), #2511.