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.00.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 &#39;theurian[daemon]&#39;,<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 boundaries
  • tests/unit/test_plugin_boundary.py — the declaration validates, and the Core beside it is inside the declared range
  • tests/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.