A plugin is a directory with a plugin.toml manifest and (usually) a
program. The manifest declares what say the plugin wants in Stella's turn loop
and what it wants to reach outside it; a human reads that declaration at
install and says yes or no. Nothing a plugin does is inferred.
This directory holds one plugin, implemented three times:
| Path | Language | Entry point |
|---|---|---|
verify-rs/ |
Rust | a compiled binary, bin/verify-rs |
verify-py/ |
Python | main.py, stdlib only |
verify-ts/ |
TypeScript | dist/main.js, built with tsc |
They are the same plugin on purpose. verify runs the test the request's
candidate grant names, in the workspace root that grant carries, and reports
what it observed: whether the test went red-to-green, and what it exited with.
It reports no verdict: Stella decides done from the rule the manifest
declares as data. Diff the three directories and the only differences are three
languages, never three protocols.
Why this matters, in the words of the design doc that asks for it
(doc:pipeline-as-plugins §9): "a plugin surface exercised only by its
authors' language is a library with extra steps." These three are the test
that Stella's plugin surface is a platform.
Verification is delivered by the plugin, and the install prompt says so. Stella does not run this plugin's oracle and does not re-check what comes back: the flip and every measurement are what
verify's own process said it saw. Stella applies the declared rule to those reported claims and will not credit a requirement they leave undecided, and it owns the tamper finding a plugin cannot write — but it cannot tell an earned result from a typed one. That is the architecture rather than an apology (#3511): base Stella verifies nothing, and a verification plugin is the product.
[loop].participation is how much say a plugin has asked for. Each grade
includes every grade below it, and the powers that separate the rungs are
rejected below the rung that grants them, so a manifest cannot quietly hold
more than its grade.
| Grade | May | Declared by |
|---|---|---|
none |
nothing — a content bundle of skills, commands, agents, custom tools | the default when there is no [loop] block |
observer |
subscribe to the turn event stream; influence nothing | participation = "observer" |
steering |
act at declared hook points — inject context, rewrite tool input, decide permissions. May not touch completion | participation = "steering" + hooks = [...] |
arbiter |
additionally bind the Stop gate: at each would-be completion Stella asks the plugin, and an unmet requirement re-enters the loop |
participation = "arbiter" + hooks = ["Stop", ...] + [requirements] |
verify is an arbiter, because holding a turn open until its tests flip is
exactly what the top rung is for.
Three rules worth knowing before you write a manifest:
- An undeclared hook is never invoked. Registering for an event the manifest did not name gets you nothing, silently — the manifest is the authority, not the process.
- An undeclared socket point is never dispatched.
[loop].pointsnames the wrapper points the plugin answers;verifydeclares["after_turn"]and nothing else. Before #3501 there was nowhere to say this, so a host learned that a plugin refusesbefore_turnby getting the refusal at run time. - Unknown keys are a load error. A typo'd grant fails loudly at install rather than granting nothing quietly.
[[capabilities]] is orthogonal to the ladder: it declares what the plugin
wants to reach outside the turn (a tool, at a named risk level, with the
reason a human reads). A none-grade content bundle shipping one custom tool
that runs git push is asking for more of the world than an observer that
only watches, so tying the two together would let the widest grant hide behind
the weakest grade.
A wrapper plugin is two calls it answers, plus two functions the host runs on
its behalf (doc:wrapper-socket §1):
| Point | Who runs it | What it is |
|---|---|---|
before_turn |
the plugin, out of process | contribute context, narrow scope, name a role intent, publish signals. May not run the loop, may not reach for ambient authority. |
after_turn |
the plugin, out of process | gather evidence: run the granted test, read a diff. Receives the turn's outcome; holds no channel into it. |
judge |
the host, in process | evidence in, verdict out. Synchronous, total, no model call. |
again? |
the host, in process | verdict in, continuation out: another turn with a correction, or stop. |
judge and again? are not messages a plugin can be sent. WrapperPoint
has two variants and neither is a verdict. That is the design, not an
omission: a plugin declares its verdict rule as data in plugin.toml, and
Stella evaluates it. "A verification plugin quietly calls a model to decide
done" is impossible by construction, in Rust and in Python alike.
The honest cost, stated plainly: a plugin author cannot write a verdict as a loop. What stays open is what counts as evidence and what done means — and both of those are where the interesting variation actually lives.
The host spawns [runtime].argv directly — never through a shell — writes
one JSON request on stdin, closes it, and reads one JSON response from stdout.
No handshake, no framing, no state between calls. That is the whole transport,
and it was chosen by measurement rather than by argument
(doc:plugin-transport-spike).
Four properties of that exchange are worth stating, because each one is a decision someone can get wrong:
protocol_versionrides on every message and the contract is additive-only — but every table also denies unknown fields, the envelope included. Those are not in tension: a field the host does not know, at a version it accepts, is a typo, and a message that quietly does nothing is worse than one that refuses. A genuinely new field arrives either as an optional one at the same version (stagedid) or with a version bump.- There is no error variant.
AfterTurnResponsecannot carry a failure. A plugin that cannot answer fails — non-zero exit, one line on stderr, nothing on stdout — and the host substitutes the "nothing was observed" evidence set on its behalf. That set is deliberately not an empty one that reads as "nothing was wrong": it makesjudgeabstain rather than blame the worker for evidence nobody collected. - Every capability arrives in the request.
candidateis aCandidateGrant: the handle, the canonical workspaceroot, and — when the host has one to give — thetestto run there, as argv and a baseline. A wrapper never reaches for a terminal, a git checkout, a credential, an environment variable or a working directory. That is what lets the same plugin run under the CLI, understella-serve, and inside an application that embedded the loop. - A plugin cannot report a tamper finding.
ObservedEvidencehas no field for one, in any language. Snapshotting witness-artifact identity is host-side by design, and the host merges its own finding in beforejudgeruns (#3499).
# the shared conformance suite — the same vectors and goldens for all three
python3 plugins/ci/conformance.py -- python3 plugins/verify-py/main.py
python3 plugins/ci/conformance.py -- node plugins/verify-ts/dist/main.js
python3 plugins/ci/conformance.py -- plugins/verify-rs/bin/verify-rs
# rule 1, enforced: the manifests differ only in the argv naming the program
python3 plugins/ci/check-manifests-identical.py
python3 plugins/ci/generate-manifests.py --checkplugins/testdata/ holds the vectors. Each is exactly two files:
| file | meaning |
|---|---|
NN-name.request.json |
written to the plugin's stdin |
NN-name.expected.json |
the plugin must exit 0 and print this |
NN-name.refusal.txt |
the plugin must exit non-zero and print this on stderr |
A vector carries an expected.json or a refusal.txt, never both. Every
plugin is spawned with PATH and nothing else — which is the whole
[runtime].env allowlist now, and is the sharpest way to state #3498's result:
a vector that needed an environment would mean the plugin reached for something
the host did not send. The vectors name /tmp as the grant root because a
vector has to name a directory that exists on any POSIX runner; a real host
mints the canonical root of the candidate worktree.
.github/workflows/plugins.yml runs all of it on every PR — Track C rule 4.
doc:pipeline-as-plugins §9 states four rules that keep this honest. They are
the deliverable, not decoration.
1. Identical manifests except [runtime].argv. Held, exactly.
The three plugin.toml files are generated from one template and differ in
two lines — one argv removed, one added. Nothing else differs: not a key,
not a value, not a comment. check-manifests-identical.py re-establishes that
from the committed files (structurally with tomllib, and as a literal line
diff), and fails if it ever stops being true. No language needed a manifest
shape another did not.
It was four lines in the first cut, because [oracle].command was mandatory
and every manifest wrote its [runtime].argv out a second time there, byte for
byte. #3501 made it optional beside a [runtime] block — absent now means "the
oracle is this plugin's own process" — so the redundant declaration is gone and
the script refuses a manifest that brings it back.
2. The Rust example uses the wire path. Held.
verify-rs/tests/wire.rs spawns the compiled binary and drives it through
stdin/stdout against the same goldens the other two run; it does not import the
library. The in-process path exists as well — lib.rs exposes observe() with
the test runner injected — which rule 2 explicitly allows, but the wire path is
the one CI grades.
3. No SDK in the first cut. Held.
Python is json, subprocess, sys, time — one import fewer than before,
because there is no environment left to read. TypeScript has an empty
dependencies and one dev dependency, the compiler, with the Node surface it
uses hand-declared in a short .d.ts that also shrank: process.env is
gone, so a plugin that tried to read one would not compile. Rust depends on
serde/serde_json and deliberately not on stella-plugin: a third-party
author could not, so the reference implementation does not either.
4. CI runs all three on every PR. Held.
See .github/workflows/plugins.yml.
Track C exists to find contract defects by building against it. Four of the
findings from the first cut were fixed in macanderson/stella and are the
reason this directory changed:
- #3498 — the request carries the candidate.
candidatewas a bare handle string with no root and no way to spend it, so all three plugins took their test command and pre-turn baseline from two[runtime].envnames (VERIFY_TEST_COMMAND,VERIFY_BASELINE_EXIT_CODE). It is aCandidateGrantnow, and those names are deleted: from the code, from the manifests, and from this documentation. - #3499 — tamper is host-owned.
EvidenceSet.tamperwas a field the plugin filled in, while the snapshot it reports on is host-side, so an honest plugin could only ever saynot-checkedandjudge— correctly refusing to credit a flip nobody checked the artifacts of — returned undecided forever. What a plugin returns is now anObservedEvidence, which has no such field. - #3500 — the envelope denies unknown fields. These plugins refused an extra
key beside
pointandbodyfrom the first cut, which made them stricter than the host's own decoder. The host now refuses it too. - #3501 — a manifest declares its points.
[loop].points = ["after_turn"], and[oracle].commandbecame optional beside[runtime].
The tracking issue for this half of the work is
macanderson/stella#3516.
Reported rather than worked around.
- The manifest filename is not specified. These use
plugin.toml; nothing in the crate or the spec fixes that yet, so it is a guess that a loader may contradict. - A plugin cannot declare the measurement it reports as absent-on-purpose.
[oracle].measurementsnames the numbers a plugin may report, and a name missing from a response is "missing, never a satisfied budget" — which is the right default. Butverifyreports no numbers at all when the test could not be run, and there is no way to distinguish that from a plugin that simply forgot: both arrive as an absent name.
- A plugin cannot say why a flip was unobservable.
unobservableis one value covering a root that does not resolve, a program that is not there, a run that outlived its budget, and a baseline that never watched an assertion. Those are four different operational problems and the host is told the same thing about each. - A plugin cannot report
unsatisfiable.FlipObservationhas the variant — the witness failing the same way before and after (#2540) — but the baseline a plugin receives is one of four enum values, not a failure it can compare against the one it just saw. Nothing an out-of-process plugin is given can distinguish "red for the same reason" from "red for a different one", so this plugin never emits the value in any language. - A
TestPlannames no environment and no timeout. The host says which program to run and where, but not what environment it would have run it in or how long it would have waited. So "the same invocation" is the same argv, not provably the same run — and each plugin has to pick its own budget (240s here, bounded inside[runtime].timeout_secs). - The version check is the reader's, not the type's. A body carrying
protocol_version: 2still decodes as the realWrapperRequest; the host compares the number separately. These plugins refuse it (vector06-bad-version), which is the behaviour the contract asks for, but it is a behaviour every plugin author has to remember rather than one the shape enforces.
The installer exists now:
stella plugin install plugins/verify-py # this workspace
stella plugin install plugins/verify-py --scope user # every workspace
stella plugin list
stella plugin remove verify # by manifest nameinstall prints the whole declaration — the grade, the hooks, the requirements,
the process it runs as, the environment slice it inherits, the capabilities it
asks for, and the "it reports its own evidence" disclosure — and installs
nothing until you accept it. Project scope is <workspace>/.stella/plugins/,
user scope is ~/.stella/plugins/, and ${plugin_dir} in the manifest's argv
resolves to whichever it landed in.
The other half exists now too. stella run --pipeline verify — naming this
manifest's own name — hands the turn to the wrapper socket, and a real
turn's after_turn request reaches the installed plugin's process:
grep -rn after_turn crates/stella-cli/src now finds the live dispatcher
(wrapper_plugin.rs), not just the install command's own module. stella goal drives the same socket per round, but only at steering/observer
grade — arbiter participation, the grade verify asks for, is refused
pre-flight there (#3832), because a goal run is already one loop with its
own arbiter and does not nest a second one inside it. So these three plugins
are graded two ways today: the shared conformance harness above, against the
wire contract in isolation, and stella run --pipeline verify, against a
real turn.