Skip to content

Plugin platform: the substrate mostly exists across two planes that do not know about each other — design review and sequencing #3246

Description

@macanderson

Companion to #3243. That issue covers the retrieval plane (what context to
inject); this one covers the authority plane (what may change the loop). They
meet at one place — §5's sequencing — and are otherwise independent.

Evidence basis. main at a4b87649a (2026-08-13T14:44:24-07:00). Every
"shipped/not shipped" claim below was checked against the tree, not against the
issue that proposed it.


1. The requirement (Mac, 2026-08-13)

A plugin is more than a skill or a custom tool. It can:

  • change the definition of done — the exit condition of the turn loop
  • block turn completion to do synchronous work (author a witness, run it,
    read the oracle flip) before the turn may finish
  • append to the system prompt when installed, and to the volatile part per turn
  • subscribe to lifecycle events — before/after enter loop, before/after exit
    loop, on error/abort — where the event cannot complete until the plugin
    answers OK
  • fire its own events
  • write to traces in a form that supports training custom models from
    trace + oracle flip
  • be written in Python against an SDK, with Rust running parts of the loop
    through it

The first customer is Oxagen's paid verification plugin.


2. The search: five issues, and the two that matter were closed five days ago

Issue State Actually in the tree?
#1400 Stella Apps — manifest, OAuth lifecycle, host API, marketplace closed, not planned (frozen behind #2374) no
#2716 tool-first governance — ToolContract, AuthzGate, Principal, RiskLevel closed NOT_PLANNED 2026-08-12 no — verified zero code
#2836 hook events UserPromptSubmit, SubagentStart/Stop closed NOT_PLANNED 2026-08-12 no — verified zero code
#2684 hook surface: Stop, PreCompact, Modify, decisions closed, completed yes
#1133 HookBus lifecycle emitters closed, completed yes
#459 HookBus resilience — observer budget, bounded forward closed, completed yes
#1042 TraceSink closed, completed yes (crates/stella-cli/src/trace.rs)
#2793 MCP + custom tools bypass the blocking policy chains open the hole is live

Verification of the two NOT_PLANNED closes:

rg -n "struct ToolContract|trait AuthzGate|enum RiskLevel|struct Principal" crates/   → 0 hits
rg -n "UserPromptSubmit|SubagentStart" crates/stella-core/src/hooks.rs                → 0 hits

Both were closed in the 2026-08-12 mission-scope sweep with the same reason
("doesn't serve Stella's 4 mission goals… reopen with a mission-tie if that's
wrong"). A paid plugin platform is that mission tie. #2716 in particular is
not a nice-to-have here — see §4, O4.


3. The headline finding

Stella already has two plugin planes. They have complementary strengths, they
do not know about each other, and the plugin system is largely the work of
merging them.

Plane A — HookBus (in-process, compiled)

crates/stella-core/src/bus.rs + crates/stella-core/src/bus/names.rs

  • 97 declared event names, dotted and namespaced, matched exactly, by
    wildcard (tool.*), or globally (*).
  • 9 of them can block (names.rs:243-253): tool.call.requested,
    file.created/updated/deleted, command.started,
    git.commit/push.requested, pull_request.requested,
    deployment.requested.
  • Decision vocabulary: Allow | Modify{payload} | Deny{reason} | RequireApproval
    (bus.rs:142). The chain folds Modify into the payload and stops on Deny
    (bus.rs:489-540).
  • Explicitly open to extensions: "Extensions may emit custom names — the
    catalog is the contract for what the host emits, not a closed set"
    (names.rs:3-4).
  • HookBus resilience: observers run inline on the tool thread (no time budget); forward_to is unbounded #459's resilience shipped: a 500 ms per-dispatch observer budget with
    quarantine after 3 consecutive overruns (bus.rs:277-292), and forward_to
    is now a bounded mpsc::Sender with a ForwardDropped counter (bus.rs:809).
  • Two hard limits. Handlers are synchronous closures
    (Fn(&HookEvent) -> HookDecision, bus.rs:190) — a blocking handler
    cannot await. And registration requires compiled Rust.

Plane B — settings-declared shell hooks (out-of-process, any language)

crates/stella-core/src/hooks.rs + crates/stella-core/src/driver/user_hooks.rs

  • 5 events: SessionStart, PreToolUse, PostToolUse, Stop, PreCompact.
  • JSON payload on stdin, JSON decision on stdout — same HookDecision
    vocabulary as Plane A, folded by hooks/decision.rs::run_decision_hooks.
  • Async with a real timeout: 60 s default, 10 min hard ceiling
    (hooks.rs:86-89).
  • Language-agnostic by construction. Python works today, with no SDK and no
    new protocol.

Why this matters more than it looks

Three of the seven requirements in §1 are already shipped on Plane B:

  1. "the system prompt needs to be updated/appended when the plugin is installed"
    SessionStart stdout is appended to the system prompt, budgeted at 4,000 chars
    with a visible truncation marker, fired exactly once per session
    (crates/stella-cli/src/agent/engine.rs:459-524). The byte-stability
    contract is stated there and is the plugin author's side of the bargain.

  2. "the plugin needs to be called prior to a turn being called done" and
    "a way of blocking turn completion"stop_hook_feedback
    (crates/stella-core/src/driver/user_hooks.rs:254-308) runs at completion,
    and a Deny{reason} holds the turn open and keeps stepping, injecting the
    reason as a marked tail message. This is the verification plugin's core
    mechanic, already built.

  3. "blocking work"Deny from a shell hook may take up to 10 minutes, in a
    subprocess, without holding an engine thread.

And one property is deliberately, correctly in place for exactly this use case:

"Blocking policy hooks are never timed or skipped — a slow Deny must
still run." — bus.rs:283-285


4. Five opinions on the design as it exists

O1 — The "future object" is the wrong primitive. The deny-loop you already have is the right one.

The requirement says a plugin "has authority to return a future object that
promises something that isn't there yet." I'd argue against building that.

A future is a poor fit for three reasons specific to this codebase:

  • It is unreplayable. Determinism and replayability are non-negotiable for
    the agent loop (CLAUDE.md). A promise resolved out-of-band has no deterministic
    position in the journal; a Deny{reason} followed by more steps has exactly
    one.
  • It has no natural failure mode. A future that never resolves wedges the
    turn with no diagnostic. Deny already has the documented posture: a broken
    Stop hook is a diagnostic and never a block (user_hooks.rs:300-306), because
    "a broken Stop hook must not wedge the turn into never completing."
  • You already have the loop. Verification is inherently iterative: author
    witness → run → no flip → tell the worker why → it works more → re-check. That
    is deny-and-continue. A promise models it as one long await; the deny loop
    models it as what it is.

What actually needs to change, and it is small:

  1. The once-per-turn latch. stop_fired (user_hooks.rs:262-265) fires the
    Stop hooks at most once per turn, whatever they decide. Correct as a
    footgun-guard for shell hooks; wrong for verification, which must be
    consulted again after the worker responds to its feedback. Replace the
    boolean with a bounded counter (the loop-steer precedent — MAX_LOOP_STEERS
    — is the shape, and The last stuck-loop steer does not say it is the last one #2810 is the live lesson that the last iteration must
    say it is the last one).
  2. Deny{reason} carries only a String. For verification the plugin must
    hand back structure: which witness, which test, flip achieved or not, the
    digest. Widen the payload — this is invariant feat: multi-provider adapters (Gemini/Vertex/Bedrock) + STELLA logomark #5's "a caller has to branch on
    it" test, and here the caller (the driver, and the trace fold) genuinely does.
  3. RequireApproval at a turn boundary is currently surfaced as inapplicable
    (user_hooks.rs:284-296). For a paid plugin that may need a human ("your
    verification budget is exhausted, continue?") that will want a real answer.

O2 — Do not let plugins write traces. Make them emit journal events and leave the trace a fold.

The requirement is "plugins write to traces in a manner that supports using the
trace files + oracle flip to train custom models"
— with the honest caveat "i
don't know the right architecture pattern on how to do this yet."

The pattern is already written down in this repo, in the module that would
receive the writes. crates/stella-cli/src/trace.rs:8-18:

Assembly, not a second capture path. …this module deliberately captures
nothing at runtime — it is a fold over what the closeout just settled… A
second live capture path would duplicate the journal and could drift from it;
the journal is already the append-only trace store.

So: a plugin emits events into the journal; the trace fold picks them up.
Nothing writes traces.jsonl but the fold. Consequences, all of them good:

  • Plugin-contributed facts inherit replayability for free.
  • They inherit TRACE_SCHEMA_VERSION (currently 3), whose own doc explains why
    a reader must skip an unknown version rather than guess (trace.rs:50-66) —
    precisely the discipline a training-data consumer needs.
  • They inherit the privacy posture: .stella/private/ 0700, file 0600, every
    string leaf through redact_secrets, and nothing enters store.db, so
    invariant feat: port Phase 3/4/5 crates (context/fleet/graph/mcp/media/pipeline/tui) from the monorepo #3's content-free gate is not even in the path (trace.rs:30-37).
    A plugin writing traces directly would route around all of that.
  • The oracle-flip half is not new work: RewardLabel/RewardPolicy are already
    on the record (stella_pipeline::reward), and the flip oracle already exists
    in crates/stella-pipeline/src/verify.rs.

The one thing to add is a plugin event namespace in the bus catalog
(plugin.<id>.*), which names.rs:3-4 already contemplates, plus the fold arm
that reads it.

O3 — Out-of-process is the better substrate here, not the weaker one.

The instinct is usually that in-process = powerful, subprocess = fallback.
For this requirement it is inverted, and the code says so:

Plane A (in-process) Plane B (subprocess)
Events 97 5
Can block 9 events 3 events
Async / long work no — sync closure, cannot await yes — 10 min ceiling, own process
Language Rust only any
Isolation none — a panic is caught, a hang is not process boundary
Packaging recompile a file path

A verification pass that runs a test suite is exactly the workload Plane A
cannot host and Plane B can. So the plugin system should be Plane B extended
with Plane A's event catalog
, not Plane A opened up to dynamic loading. That is
also what #2684 already did for two events — the pattern is proved, it just needs
generalizing.

O4 — Authority is the real blocker, and it is the thing that was closed.

This is my strongest recommendation. A plugin that changes the definition of
done is the highest-authority object in the system
— higher than a tool, higher
than a rule, because it decides when work stops. Today there is no vocabulary to
say so:

Without this, a paid verification plugin and a hostile plugin have identical
authority, and the only control is "did the user put it in settings.json." That
is not a platform an enterprise buys.

Recommendation: reopen #2716 with the mission tie its close invited
dataset generation for open-weight models (mission goal 4) runs on
plugin-authored trace records, and those are only trustworthy if the authoring
principal is named. It is a prerequisite, not a parallel track.

Note also #2780 (open): the evolution-surface ledger. A plugin is a sixth way
Stella changes itself, and that issue is the reviewed table where its
evidence/authority/rollback row belongs.

O5 — Python: do not embed. And MCP is the candidate to beat.

Three existing out-of-process patterns, zero embedded interpreters
(rg pyo3 finds only tree-sitter-python, a grammar):

  1. shell hooks — JSON on stdin, JSON decision on stdout
  2. custom tools — argv spawn, JSON on stdin + STELLA_INPUT_* env
    (crates/stella-tools/src/custom.rs:32-67)
  3. MCP — JSON-RPC over stdio, with a full client in stella-mcp

Embedding CPython would mean the GIL on an async runtime, a packaging story for
every platform Stella ships to, and a concretion inside crates that invariant #1
keeps free of them. The SDK should be a thin Python client over a protocol the
host already speaks.

Which protocol is the real decision, and MCP deserves a serious look. As a
plugin transport it brings, already built and already tested in-tree: stdio
JSON-RPC, a registry + stella mcp search/install, OAuth with auto-refreshing
tokens (.stella/private/mcp_oauth.json), a 401 suppression cache, per-server
enable/disable, and — decisively — an official, mature Python SDK Oxagen would
not have to write or maintain.

What MCP does not give you is the lifecycle-event half: it has no notion of
"the host is about to finish a turn; may it?" That is the delta, and it is a
documented protocol extension rather than a new protocol.

The honest counterweight: MCP tools are currently hardcoded
read_only: false, speculation_safe: false (crates/stella-mcp/src/toolset.rs:776)
and are therefore invisible to every ReadOnlyTools view, and #2793's bypass is
an MCP-shaped hole. Both are fixable and both are worth fixing anyway.

I would not decide this from the armchair. The question is narrow enough to
answer with a spike: implement the verification plugin's Stop-hook path twice —
once as a settings-declared shell hook with a Python script, once as an MCP
server with a lifecycle extension — and compare on packaging, auth, and how much
protocol we end up owning.


5. What I would sequence

Deliberately ordered so the first two are useful even if the platform never
ships, and nothing irreversible happens before authority exists.

P0 — Reopen and land the authority vocabulary (#2716). Principal,
RiskLevel, ToolContract, AuthzGate. Close #2793's bypass in the same
breath, since a plugin platform inherits it. Nothing else here should start
first.

P1 — Make the existing Stop-hook path production-grade for verification.
Bounded re-consultation instead of the once-per-turn latch; structured Deny
payload; RequireApproval answerable at a turn boundary. This alone makes the
verification plugin buildable as a shell hook, in Python, today.

P2 — Plugin event namespace + trace fold arm. plugin.<id>.* in the bus
catalog; the trace fold reads them. Delivers the training-data requirement
without a second capture path.

P3 — Loop-lifecycle events, and only then make them blocking. The names you
want (agent.turn.*, step.*) exist and emit, but none is in BLOCKING
agent.turn.parked is documented as "deliberately NOT in BLOCKING"
(names.rs:36-39). Adding turn/loop events to the blocking set is a real
change: blocking handlers are never timed or skipped by design, so a wedged
plugin on before_enter_loop stalls every turn forever. Blocking loop events
need Plane B's subprocess+timeout shape, not Plane A's sync closure.

P4 — Manifest, identity, install, uninstall (#1400's core, minus the
marketplace).
Note #1400's non-negotiable rule still applies and is worth
re-reading before anyone writes a line: "the control plane is a guest, never a
component"
, enforced by rg -i 'oxagen' crates/ → zero matches. The Oxagen
verification plugin must be indistinguishable from any third party's
, which is
a constraint on P0–P3 too, not just on P4.

P5 — The Python SDK, over whichever protocol the P1 spike settles.

P6 — Plugins as a steering source. #3243's SteeringSet gains a fifth
variant. Designing the source enum in that epic's Phase 2 with this in mind
costs nothing now.


6. What I did not verify

  • I did not run anything. No plugin, no hook, no trace was executed; every
    claim is from reading main@a4b87649a.
  • The one load-bearing claim was checked, and holds. Stop-hook Deny
    really does produce more steps: dispatch_completion
    (crates/stella-core/src/driver/completion.rs:183-189) pushes the reason as a
    STOP_HOOK_MARKER_PREFIX-marked user message and returns None, so no
    TurnOutcome is produced and the step loop continues. The assistant message
    is pushed to history first (:170-178), deliberately, "so the hook's
    feedback answers a recorded turn." What I have not run is a live hook
    end-to-end; this is a read of both halves of the call, not an execution.
  • The MCP recommendation in O5 is an argument, not a finding. I did not
    check whether MCP's notification model can carry a blocking host→server
    request, which is the question the spike exists to answer.
  • The 97 event names is a count of pub const declarations in
    bus/names.rs, not a hand-audit that each is emitted. HookBus lifecycle event catalog exists (session.*, model.*, step.*) but nothing calls emit_named at those boundaries #1133 wired the
    emitters; I did not re-verify each one.
  • I did not assess cost, pricing, or licensing implications of a paid plugin
    against the AGPL/commercial dual track. That interacts with Epic: Stella Apps — a vendor-neutral extension platform (TOML manifest · OAuth lifecycle · host API · marketplace) #1400's "an app is
    data, never Rust in this tree" rule and deserves its own read.

Refs #1400, #2716, #2836, #2684, #1133, #459, #1042, #2793, #2780, #3243.

Metadata

Metadata

Assignees

Labels

P1Important — next in linearea:clistella-cli — commands, flags, wiringarea:corestella-core — engine: step loop, budget, compaction, retryarea:toolsstella-tools — built-in tools incl. verify_doneepicLarge effort spanning multiple issuesfeatureNew capability or improvement

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions