Plugin/Core compatibility
Theurian Core and the Claude Code plugin are separate artifacts with separate versions and separate release trains (ADR-0001). This document defines how a client decides whether it may talk to an installed Core.
Three versions
| Thing | Example | Changes when |
|---|---|---|
| Core version | 0.4.0 |
any Core release |
| Plugin version | 0.2.1 |
any plugin release |
| Protocol version | theurian/v1 |
the wire contract changes incompatibly |
The protocol version is what actually matters for interoperability. The version range exists because a Core release can change behaviour a client depends on without changing the wire format.
The declaration
plugins/claude-code/compatibility.yaml, validated against
schemas/protocol/compatibility.schema.json:
pluginVersion: 0.2.1
coreCompatibility:
minimum: 0.4.0
maximumExclusive: 0.5.0
protocolVersion: theurian/v1
maximumExclusive is the first Core version the plugin does not support.
Pre-1.0 it is the next MINOR, because a pre-1.0 MINOR bump may break the
protocol. Post-1.0 it becomes the next MAJOR.
Core performs the comparison
A client must not implement SemVer ordering itself. Ordering has genuinely subtle edges — pre-releases sort before their release, numeric identifiers rank below alphanumeric ones, build metadata is excluded from precedence — and every client that reimplements them will get a different subset right.
There is also a cross-ecosystem seam: Core is a Python package, so it reports
PEP 440 (0.1.0.dev0, 0.2.0rc1), while plugins declare SemVer. Core owns that
translation too, and it is the sharpest of the three edges — a client that
implements SemVer §11.4 faithfully still gets the next section wrong, because
the two ecosystems disagree about where a development build sits.
theurian compat check \
--plugin-version 0.2.1 \
--core-minimum 0.4.0 \
--core-maximum-exclusive 0.5.0 \
--protocol-version theurian/v1 \
--json
{
"outcome": "compatible",
"compatible": true,
"message": "Theurian plugin 0.2.1 is compatible with Core 0.4.0 (theurian/v1).",
"remedy": "",
"pluginVersion": "0.2.1",
"coreVersion": "0.4.0",
"protocolVersion": "theurian/v1"
}
| Exit code | Meaning |
|---|---|
| 0 | compatible |
| 2 | the declaration itself was malformed |
| 3 | incompatible |
Distinct codes matter: a caller has to tell "your versions do not match" apart from "the command failed", because the remedies are completely different.
PEP 440 to SemVer
| Core reports | Treated as | Sorts |
|---|---|---|
0.1.0 |
0.1.0 |
— |
0.1.0.dev0 |
0.1.0-dev.0 |
before every 0.1.0 pre-release |
0.2.0a3 |
0.2.0-alpha.3 |
before 0.2.0-beta.1 |
0.2.0a3.dev1 |
0.2.0-alpha.3.dev.1 |
before 0.2.0-alpha.3 |
0.2.0rc1 |
0.2.0-rc.1 |
before 0.2.0 |
1.2 |
1.2.0 |
— |
1.2.0a |
1.2.0-alpha.0 |
PEP 440 defaults an omitted number to 0 |
Both sides are ordered by Core's release train, not by the alphabet. Within one release that order is
dev < alpha < beta < rc < final
and a development build of a pre-release sorts below that pre-release
(0.2.0-alpha.3.dev.1 before 0.2.0-alpha.3). Both rules come from PEP 440 and
both differ from a literal reading of SemVer §11.4, which compares the phase
words as ASCII — putting dev between beta and rc — and ranks a longer
identifier list higher — putting alpha.3.dev.1 above alpha.3.
Core applies the release-train order to the declaration's bounds and to its own version alike. Applying it to one side only would move the version and leave the floor behind.
This is why a client must not compare versions itself. A client that gets
SemVer §11.4 exactly right produces a floor with a hole in the middle:
minimum: 0.1.0-dev.0 would accept 0.1.0.dev1 and 0.1.0rc1 while refusing
every alpha and beta between them — a minimum that is not a minimum. Getting
SemVer right is not the same as getting this right, so there is no level of
care at which reimplementing it becomes safe.
The practical consequence for a declaration: a plugin developed against Core
0.1.0 should declare minimum: 0.1.0-dev.0. That is the earliest 0.1.0 Core
can report, so every pre-release of 0.1.0 — 0.1.0.dev1, 0.1.0a1,
0.1.0b1, 0.1.0rc1 — sorts above it and resolves compatible. It is also
the SemVer form of 0.1.0.dev0, Core's first release, so the floor sits
exactly at the released Core rather than below anything installable. Pinning
the minimum at 0.1.0 instead rejects all of them, and the reason is the
ordering rather than the spelling: 0.1.0rc1 carries no development segment
and is still below 0.1.0.
Resolution
flowchart TD
A["Read compatibility.yaml"] --> B["theurian compat check"]
B -->|"CLI not on PATH"| Z1["core-missing<br/>→ 'uv tool install --python 3.13 'theurian[daemon]',<br/>then /theurian:setup.'"]
B --> C{"core >= minimum?"}
C -->|no| Z2["core-too-old<br/>→ 'Upgrade Core.'"]
C -->|yes| D{"core < maximumExclusive?"}
D -->|no| Z3["core-too-new<br/>→ 'Update the plugin. Core was not changed.'"]
D -->|yes| E{"protocol matches exactly?"}
E -->|no| Z4["protocol-mismatch<br/>→ 'Update both to a matching pair.'"]
E -->|yes| F["compatible → proceed"]
style F fill:#1f6f4a,color:#fff
style Z1 fill:#8a6f2f,color:#fff
style Z2 fill:#8a2f2f,color:#fff
style Z3 fill:#8a2f2f,color:#fff
style Z4 fill:#8a2f2f,color:#fff
Outcomes
| Outcome | Meaning | Remedy |
|---|---|---|
compatible |
Proceed | — |
core-missing |
The CLI is not on PATH |
Install Core with uv tool install --python 3.13 'theurian[daemon]' or pipx install --python 3.13 'theurian[daemon]', then run /theurian:setup. Not an error to repair automatically — this is the normal "plugin installed, Core not yet installed" state (FR-L3). |
core-too-old |
Core predates minimum |
Upgrade Core |
core-too-new |
Core is at or past maximumExclusive |
Update the plugin. Downgrading Core would break every other client on the machine. |
protocol-mismatch |
Wire protocols differ | Update both to a matching pair |
Show the verdict's own remedy string rather than a copy of this column. The
column says what the outcome means; remedy is the sentence Core has already
written for the user, and the two staying in step is why it is in the response
at all.
core-missing is the one where that matters most: the client cannot reach Core
to ask, so it must not offer a remedy that needs Core to carry it out.
/theurian:setup is such a remedy — it shells out to the theurian binary
whose absence produced the verdict — which is why the installer is named first
here and in every other surface that answers this question.
What "stop" means
A mismatch is terminal. The client prints an actionable message and exits non-zero. It never installs, upgrades, downgrades, or deletes anything to resolve the mismatch (§30 of the brief).
Automatic remediation is tempting and wrong: a "helpful" auto-upgrade during a
SessionStart hook changes software on a machine while the user is thinking
about something else, and a "helpful" auto-downgrade breaks every other client
sharing that Core.
Unknown protocol is a mismatch, not an assumption
If Core reports no protocol version, the outcome is protocol-mismatch. Assuming
compatibility from an absent field means an old daemon that predates the field is
silently treated as current. A clear stop beats a silent wrong answer.
Per-feature degradation
Version gating is coarse. When only an optional capability is missing —
say, no summarization provider is configured — a client should degrade per
feature instead of stopping entirely. system.capabilities reports what this
Core build actually supports.
Bumping the protocol
Bump protocolVersion when the change is breaking:
| Change | Breaking? |
|---|---|
| New optional field in a response | No |
| New MCP tool | No |
| New optional CLI flag | No |
| Removing a field | Yes |
| Making an optional field required | Yes |
| Changing a field's type | Yes |
| Changing an exit code's meaning | Yes |
| Renaming a tool | Yes |
system.capabilities.milestone's removal (#206) is breaking by this table;
protocolVersion is not bumped for it — a recorded, narrowly-scoped
exemption, not a silent exception. See "Changing this contract" in
mcp-tools.md for the reasoning. theurian propose accept's
exit code for an already-accepted proposal (#254) carries a second exemption
of the same shape, recorded in the same place. Both are scoped to the one
field and the one code they name; the codes above — which a plugin script does
branch on — are covered by neither.
theurian index build's exit 6 (#329)
needs no exemption, and the distinction is worth stating because the row above
is easy to read too widely. The row is changing an exit code's meaning: a
number a client already branches on coming to mean something else. 6 is a code
this command did not previously emit, and every code it did emit keeps
exactly the meaning it had — 0 published and clean, 1 published nothing, and the
shared codes any command can reach for a malformed invocation or a state error
(2 from Typer, 4 from EXIT_STATE_ERROR) unchanged. What is new is an outcome the
command could not previously report at all: a complete index published whose
content carries a secret-shaped string (SEC-11). A client that has never seen 6
treats it as "non-zero" and reports a failure, which is wrong but not silently
wrong — the same class as any new field a client does not read — where reusing 1
would have made a successful publish indistinguishable from a failed one for
every client, including the ones that are updated. The three plugin commands
that read this code (index.md, reindex.md, propose.md) state the branch in
the same change, which is the condition on taking this reading rather than the
exemption route.
A containment refusal's move from exit 1 to 4
(#525,
PR #549) is the fourth decision
recorded here, and the first that meets the changing an exit code's meaning row
head-on and still declines the bump. A path under .theurian/ that resolves
outside the working tree used to be graded by whichever ProjectPaths helper
resolved first — 0, 1 and 4 for one root cause — and now reports
EXIT_STATE_ERROR at every position that was measured: the nine swept commands,
and four more measured by hand beside them (init, findings build, propose,
propose accept, which reported 1, 1, 2 and 1). "Uniformly" is not claimed
for a route neither covers. The population is derived rather than listed:
CONTAINMENT_PLANTS in
packages/theurian-core/tests/integration/test_contained_path_envelope.py gives
thirty-two (artefact × command) positions over the nine swept commands, six of
which reach theurian index build; Core's changelog carries how many moved off 1
and the sweep that measured them.
This is not #254's ground, and saying so is the point. That exemption rested
on the code having zero consumers. This one has three, so the "search-verified,
plugins included" fact cannot be asserted for it. It rests instead on the
condition #329 states one paragraph up — the plugin commands that read this code
state the branch in the same change — discharged here by index.md,
reindex.md and propose.md in the same commit that moves the code.
reindex.md is why the condition is worth enforcing rather than reciting: it
gated step 3, theurian index gc, on exit 1 alone, so an exit-4 build left the
reclaim step ungated by the document.
What supports declining the bump beyond that condition: the project is pre-1.0 on
a dev line with no known external integration; the CP-2 envelope is unchanged,
so the machine channel a client parses — one {error, remedy} document on
stderr, stdout empty — does not move at all, only the number beside it; and the
change removes a distinction rather than adding one, so a consumer branching on
1-versus-4 for these refusals was branching on an inconsistency. Scoped to
containment refusals over a contained path: it says nothing about compat
check's 0/2/3, which a plugin script does branch on, nor about exit 1 elsewhere
in index build, which still means the build failed.
The guard one level up followed, and it is the same decision
(#550). #525's move covered
paths under .theurian/; .theurian itself delivered as a symbolic link
out of the working tree is refused earlier, by the root-join check in
ProjectPaths.of, and kept whatever each caller assigned to "could not resolve a
project". Measured at 8372cc8c against the real CLI, one fresh repository per
command: migrate status, migrate validate, migrate apply, index build,
index status and index gc each answered 1 for that face and 4 for the
identical link one level deeper, and theurian project status answered 0
with a payload calling the project registered. Thirteen commands now report
EXIT_STATE_ERROR for it — the six above, project status, init, project
register, ingest, findings build, propose and propose accept. A sibling
face moves with it: an escaping .theurian/migrations (honest .theurian, the
loader's PathEscapeError, #233) that the ten _require_project commands already
graded 4 but project status graded 0 and init/project register graded
1 — now uniformly 4, so a doctored .theurian answers one code at whichever
level the link sits. The population is derived from the source by
packages/theurian-core/tests/integration/test_escaping_knowledge_dir_grading.py,
which reads each resolver's except arms from the AST and asserts they grade both
escape types alike, and classifies the five doctor/setup call sites that
absorb the refusal into a conflicting step; those keep their verdict, only the
withheld detail's type name refining from ProjectError to
ProjectPathEscapeError.
The same three plugin commands read the code, and none of their branches changes
meaning: index.md's "exit 1 and exit 4 both mean nothing was published" and its
selected-exit set (1, 4, 6) are unaffected because both codes were already in it,
reindex.md's exit-4 paragraph is broadened to name the new doctored-checkout
shapes while keeping "relay the remedy it prints", and propose.md's "4 means
the project's knowledge state refuses the move" is what a doctored .theurian was
previously answering 1 for. So the #329 condition is discharged by the
branches already being right (or broadened in the same change), rather than by
re-meaning them — a weaker claim than #525's and stated as one.
On a bump: raise CURRENT_PROTOCOL_VERSION in Core, release Core, then update
every client's protocolVersion and coreCompatibility, and release the
clients. In that order — clients that stop working loudly are recoverable;
clients that keep working against a changed contract are not.
Testing
tests/unit/test_compatibility.py— SemVer ordering, PEP 440 translation, every outcome, range boundariestests/unit/test_plugin_boundary.py— the declaration validates, and the Core beside it is inside the declared rangetests/contract/test_cli_contract.py— exit codes and JSON shape, against the installed binary
Inside test_compatibility.py, the ordering above is held by properties rather
than by a table of cases, because a table agrees with an ordering that is wrong.
The one that preceded them asserted only same-kind pairs — a1 against a2 —
which is the single comparison the translation never got wrong. Over a release
train enumerated exhaustively from the grammar:
- the translation is strictly monotone;
- a
minimum's accepted set is upward-closed — once a Core is accepted, every Core above it is; - a
maximumExclusive's refused set is closed the other way — once a Core is refused as too new, every Core above it is. It is the same comparison read from the other end, so it fails the same way and needs holding separately.