Skip to content

feat(protocol): adopt lanok-core for errors, classification, and version negotiation - #80

Draft
chaliy wants to merge 7 commits into
mainfrom
claude/stdout-protocol-lib-kjhd84
Draft

chaliy wants to merge 7 commits into
mainfrom
claude/stdout-protocol-lib-kjhd84

Conversation

@chaliy

@chaliy chaliy commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

What changed

Deletes mira's hand-rolled copies of three things the yolop extension protocol also maintains.

204 lines removed, 99 added. Draft, because it cannot merge yet (see Blockers).

Removed from mira Replaced by Lines
RpcError + codes lanok_core::RpcError, re-exported 83
enum Inbound + fn classify lanok_core::Message 47
version_major / version_compatible lanok_core::Negotiation 12

protocol.rs loses 84 lines, host.rs loses 100.

Is it drop-in? Measured, not asserted

The question worth answering is not "does it still work" but "are the bytes the
same". Measured by building the same wire_probe example against origin/main
in a worktree and against this branch, and diffing the emitted lines:

message main this branch identical
request {"jsonrpc":"2.0","id":1,"method":"run","params":{...}} same yes
notification {"jsonrpc":"2.0","method":"progress","params":{...}} same yes
ok response {"jsonrpc":"2.0","id":1,"result":{...}} same yes
error response {"jsonrpc":"2.0","id":1,"error":{...}} same yes
retryable error {"code":-32603,"message":"busy","retryable":true} same yes
plain error {"code":-32603,"message":"boom","retryable":false} {"code":-32603,"message":"boom"} no
coded error {"code":-32602,"message":"bad","retryable":false} {"code":-32602,"message":"bad"} no

Six of seven byte-identical. One difference: retryable is omitted when
false
instead of written as false.

That difference sits inside mira's own forward-compatibility contract, and the
proof is that the readers already existed before this PR:

  • mira's published schema lists only message as required; retryable carries
    "default": false, restored explicitly so the artifact still says so.
  • the Python SDK declares retryable: bool = False.
  • the TypeScript SDK declares retryable?: boolean.
  • both polyglot example studies drive the adopted host green unmodified.

So: functionally drop-in, and byte-identical except for one field whose absence
every existing reader already reads as false. Both shapes are now pinned by
tests in this repo and in lanok, so neither can drift again unnoticed.

Why

lanok is the extraction of the plumbing mira and yolop each wrote separately. yolop's crates/yolop-yep/src/protocol.rs opens with "Conventions (from the mira eval protocol)", and its server SDK says it "mirrors the mira SDK's serve() design". Both grew the same field-based classification, the same MAJOR.MINOR rule, the same JSON-RPC-shaped error.

The classification rule is the one worth sharing. mira's own comment calls it the safety property:

a reverse request's id lives in the study's own id space and would otherwise collide with the host's pending ids (both start at 1), spuriously completing an unrelated in-flight request with an "empty response"

That is a subtle, load-bearing invariant. Having one implementation of it, tested once, is the point of the kit.

It fixes a bug on the way

MIN_PROTOCOL_VERSION has always been published in meta.json and never checked. version_compatible compared majors only, so a study announcing a version this build had dropped support for connected anyway and failed later at whichever method it could not satisfy. Today the constant equals the major floor so nothing is observably broken; the moment mira raises it, it was silently ignored. A malformed version is now refused instead of parsing as major 0.

Adopting it required fixing lanok, not mira

This is the part worth reading, because it is what a real adoption surfaces and a design document cannot.

  1. retryable was in the wrong place. lanok buried it inside data, on the argument that JSON-RPC enumerates the members of an error object. The spec says code and message are required and data is optional; it does not forbid more. Both protocols lanok exists to serve already had the flag at the top level, in their Rust types and in mira's Python and TypeScript SDKs. The strict reading bought nothing and would have broken every existing study. Fixed in lanok@814f482.

  2. serde_json/preserve_order leaked. Taking the dependency reordered the keys of every committed schema artifact mira generates: 1159 lines of diff, content identical. Cargo unifies features across the whole graph, so lanok-core was changing how mira serialises JSON. Fixed in lanok@d2ce390 and generalised into lanok's architecture concept.

  3. Missing with_code, and thinner field docs than mira's. The schema artifacts a protocol publishes carry those doc comments, so adopting lanok would have replaced better prose with worse. Ported mira's upstream instead.

  4. Cancellation was a setting, not a hook, and the handshake was lanok's to name. Both surfaced when this PR tried to take the next step (below). Fixed in lanok@3e865ed and lanok@d824c12: a protocol now supplies an abandon hook and its own handshake method names, rather than choosing among knobs lanok guessed at.

The pattern across all four: lanok kept deciding things that were not its to decide. Each was found by contact with a real protocol, none by design thinking.

Before / After

// before: min advertised, never enforced
pub fn version_compatible(other: &str) -> bool {
    version_major(other) == version_major(PROTOCOL_VERSION)
}

// after
pub fn version_compatible(other: &str) -> bool {
    match other.parse::<lanok_core::Version>() {
        Ok(peer) => negotiation().accepts(peer).is_ok(),
        Err(_) => false,
    }
}

Gate green: cargo fmt --check, clippy -D warnings --all-features, cargo test --workspace (167 in mira-eval, no failures), --features protocol-unstable, mira-schema-gen --check, and mira run --study examples/greet.rs.

The schema diff is five lines: the same fields with better descriptions, and int32 becoming int64.

Behaviour deltas

Both narrow, both tested:

  • A missing code reads as INTERNAL_ERROR rather than 0. That is how an error declining to classify itself should be read, and 0 is not a JSON-RPC code. Retry behaviour keys on retryable and the message, not this value.
  • A response with neither result nor error surfaces as a deserialization failure at the typed call site rather than the string "empty response". Still an error, more specific.

What is left

host.rs still carries ~150 lines that lanok_peer::Peer already owns: the pending map, id allocation, the write-under-lock, RequestGuard's cancel-on-drop, and reader_loop's EOF drain. Adopting it would also add two things mira lacks: a connection-end drain that fails pending requests at once rather than per-timeout, and per-request timeouts.

That step was blocked on two lanok gaps, both now closed by the fixes above:
mira's cancel is a request the study acknowledges, not a notification, and it
is armed only for cancelable methods against a study that advertises cancel.
The abandon hook expresses both. study.rs's serve loop is the same story on
the other side. Neither is in this PR, because the peer migration deserves its
own diff.

Risk

  • Low. One wire byte changed, in a field every reader already defaults; 167 tests pass; the two behaviour deltas are covered by tests that state their reasoning.
  • The dependency is the risk, not the diff. See below.

Blockers

  1. lanok is unpublished. The dependency is git + pinned rev, which makes mira unpublishable. It must become a version dependency before the next release. This is why the PR is a draft.
  2. lanok is pre-1.0, so its Rust API may still move. Its wire contract is separate and stable by design.

Checklist

  • Tests added or updated
  • Backward compatibility considered: wire measured field by field above; the one difference and the two behaviour deltas are documented and tested

🤖 Generated with Claude Code

https://claude.ai/code/session_014LYBU1vgSfMU9EjmUCku8z

A spike showing what adopting the lanok protocol kit looks like in mira, scoped
to the one piece that is cleanly separable: `MAJOR.MINOR` negotiation.

It fixes a real bug on the way. `MIN_PROTOCOL_VERSION` has always been
published in `meta.json` and never checked. `version_compatible` compared
majors only, so a study announcing a version this build had dropped support for
connected anyway and failed later at whichever method it could not satisfy.
Today the constant happens to equal the major floor, so nothing is observably
broken; the moment mira raises it, the constant is silently ignored. A
malformed version string is also refused now instead of parsing as major `0`,
which made `"not-a-version"` merely incompatible rather than invalid.

lanok-core is the same wire contract the yolop extension protocol follows, so
the rule is implemented once rather than per protocol. The dependency is git
and pinned while lanok is unpublished; it must become a version dependency
before mira can be released again.

Nothing else moves. Request/Response/Notification, RpcError, and the payload
types stay exactly as they are, because swapping them is wire-visible and needs
a decision rather than a refactor: lanok carries `retryable` inside `data` for
strict JSON-RPC conformance, while mira carries it at the top level and both
SDKs read it there.
…ification

Deletes mira's hand-rolled copies of three things the yolop extension protocol
also maintains, and keeps the wire identical.

**`RpcError` and `codes`** (83 lines) are now re-exported from lanok-core, which
implements exactly the shape mira already used: `code`, `message`, a top-level
`retryable` hint omitted when false, and optional `data`. Adopting it required
fixing lanok, not mira: lanok had buried `retryable` inside `data` on a strict
reading of JSON-RPC that the spec does not require, which would have broken
every existing study. It also needed `with_code`, and better field
documentation, since the schema artifacts a protocol publishes carry those doc
comments and lanok's said less than mira's did.

**Message classification** (47 lines) is now `lanok_core::Message`. The rule it
encodes is the one mira's comments call out as the safety property: a line
bearing `method` is a request or a notification, and only a `method`-less line
is a response, because a reverse request's id lives in the study's own id space
and both sides number from 1. Misreading one completes an unrelated in-flight
request with an empty response. That is worth having one implementation of.

**Version negotiation** now enforces `MIN_PROTOCOL_VERSION`, which mira has
always published in `meta.json` and never checked, and refuses a malformed
version instead of reading it as major `0`.

Two behaviour deltas, both narrow and both tested. A missing `code` reads as
`INTERNAL_ERROR` rather than `0`, which is how an error that declines to
classify itself should be interpreted, and `0` is not a JSON-RPC code; retry
behaviour keys on `retryable` and the message, not on this value. A response
with neither `result` nor `error` now surfaces as a deserialization failure at
the typed call site rather than the string "empty response".

Net 204 lines removed, 99 added. The schema diff is five lines: the same fields
with better descriptions, and `int32` becoming `int64`.
@chaliy chaliy changed the title feat(protocol): adopt lanok-core for version negotiation feat(protocol): adopt lanok-core for errors, classification, and version negotiation Sep 13, 2026
The adoption claimed the wire was unchanged. That was asserted, not measured,
so this measures it: the serialized bytes for every envelope and error shape,
compared against what main produces.

Six of seven are byte-identical. One is not. `retryable` used to be written
even when false, and lanok omits it:

  before  {"code":-32603,"message":"boom","retryable":false}
  after   {"code":-32603,"message":"boom"}

That is inside mira's own forward-compatibility contract rather than a break.
Every implementation already defaults the field (`retryable: bool = False` in
the Python SDK, `retryable?: boolean` in the TypeScript one, `#[serde(default)]`
here), and the published schema lists only `message` as required. Both SDKs
drive the adopted host green without modification, and the schema again
declares `default: false`, so an implementor is told what absence means rather
than left to guess.

The tests pin both directions: the exact bytes written, and that an error
omitting the field parses the same as one sending it explicitly false. A future
change to either is now a deliberate act rather than a silent wire change.

chaliy commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

CI is red, and I am not fixing it in this PR. The cause is one thing, and it is Blocker 1, not the diff.

All four failing jobs (Lint, Test, Audit, Examples) die in the same place, within ~8 seconds, before compiling anything:

failed to acquire username/password from local configuration

Cargo.toml points lanok-core at https://github.com/everruns/lanok, which is a private repository. GitHub Actions checks out mira with a token scoped to mira, so cargo cannot fetch the dependency. Confirmed: anonymous GET https://github.com/everruns/lanok returns 404.

Nothing in the change is failing. Locally, with access to lanok, the full gate is green: cargo fmt --check, clippy -D warnings --all-features, cargo test --workspace (167 tests in mira-eval), --features protocol-unstable, mira-schema-gen --check, and mira run --study examples/greet.rs.

Two ways to make CI green, both decisions rather than patches:

  1. Publish lanok to crates.io and switch to a version dependency. This is the path the PR already names as required before mira's next release, since a git dependency makes mira unpublishable either way. It also clears cargo-deny check sources, which rejects git sources.
  2. Give CI a credential for the private repo (the GITHUB_TOKEN already in Doppler, via git config url.insteadOf before cargo runs). Faster, but it puts a cross-repo token into every mira CI job and still leaves the publishability blocker.

I have not done either: option 1 is a release decision and option 2 widens what mira's CI holds. The PR stays a draft until one is chosen.


Generated by Claude Code

The protocol was described twice and checked nowhere. Method names were
string literals at the host's call sites (host.rs:64, 90, 107, 148, 169,
187, 215) and again as match arms in the study's dispatch (study.rs:237
onward). Nothing made the two lists agree, and a method's direction,
whether it expects a reply, and which capability it needs lived only in
prose.

One declaration now says all of it, and both sides read from it:

- `method::*` replaces every literal on both sides, so a rename is a
  compile error rather than a silent "unknown method".
- `capabilities::*` is generated from the `capabilities` block. The
  tokens keep their documentation, which is the part that matters: a
  token is a promise about behaviour, and the promise is what an
  implementor on the other side needs. Generating a one-liner over the
  top of mira's prose would have been the same downgrade the error-type
  adoption already caught once, so lanok grew documented capability
  tokens instead.
- `negotiation()` is the declaration's `NEGOTIATION`. PROTOCOL_VERSION
  and MIN_PROTOCOL_VERSION stay strings, because meta.json and both
  SDKs publish them that way; a test pins them to the declaration.
- `InitializeParams` types the handshake request, which was an inline
  `json!` object. Both fields default, so a study SDK calling
  `handle("initialize", {})` still parses.

The declaration also states the reverse-channel seam as structure
rather than a comment: everything the host calls is `initiator`, the
two notifications are `responder`, and a study to host request would be
an additive `responder fn`.

The wire is unchanged. 296 tests pass, the schema artifacts are
unchanged (`mira-schema-gen --check`), and the Python and TypeScript
studies drive the host green unmodified.

The generated stubs, handlers and dispatchers are not wired in yet:
that is the host.rs and study.rs migration, and it belongs in its own
diff.
… SDKs the method table

`meta.json` is the artifact the Python and TypeScript SDK generators read.
Its method list and capability tokens were typed out again in
`mira-schema-gen`, a third copy of the names with nothing keeping them in
step: adding a method to the protocol and forgetting this list published a
`meta.json` describing the previous protocol, and both SDKs generated from
it inherited the omission without a word.

It is now `serde_json::to_value(protocol::META)`, the declaration as data.
The two mira-specific keys stay, since neither is protocol vocabulary:
`schema` names the sibling artifact, and `event_kinds` is the `event`
payload's own token set.

That makes the artifact say more than a name list. Each method carries its
direction, whether it expects a response, the capability it needs, and its
documentation, which is enough for a generator to emit a typed method
rather than a string constant.

Both SDKs take the first half of that now. Each had a hand-written tuple
of the methods its serve loop dispatches, with a test asserting it covered
`METHODS`. The list is the declaration's instead (`SERVED_METHODS`), so a
new method arrives by regenerating. The test that guarded it was also
wrong in a way the flat list could not express: it asserted that a study
answers every method in the protocol, including `event` and `log`, which
are the notifications a study *sends*. Direction splits them.

Generating the typed method stubs themselves needs the params/result type
per method, which meta.json does not carry yet.

296 Rust tests, 114 pytest, 112 node; both `codegen --check` guards and
`mira-schema-gen --check` clean; the Rust, Python and TypeScript greet
studies all drive the host green.
The declaration landed with no consumer: the stubs, handlers and
dispatchers it generates were compiled and unused, so it read as 200 lines
added for nothing. This is the host half of using them.

`host.rs` had its own JSON-RPC client underneath the eval-specific part: a
reader task owning the study's stdout, a pending map keyed by request id, an
id allocator, a drop guard freeing the slot, a hand-rolled `cancel` line
writer, and the EOF sweep that fails every waiter. None of that is about
evals, and all of it is what lanok exists to hold.

The peer holds it now. The calls are generated stubs, so `run` is
`peer.run(params)` rather than `request("run", to_value(params))` followed
by a `from_value` on the way back, and a capability-gated method answers
locally instead of making a round trip that ends in `method not found`.
Notifications arrive through the generated `InitiatorHandler` with their
params typed, and a malformed one is dropped before it reaches the
callback. `supports_cancel` is no longer a second `AtomicBool` beside the
peer's own capability record.

Cancel-on-drop stays mira's, which is the point of lanok making it a hook:
mira cancels with an acknowledged *request*, not the usual fire-and-forget
notification, and only for `run`/`execute`/`score` against a study that
said it can.

Two behaviour changes come from the transport. A spawned study's stderr is
now drained and forwarded rather than inherited, which is what stops a
chatty study wedging at a full pipe buffer and what puts a crashing study's
final lines in front of whoever is debugging it. And `shutdown` returns
once the child is actually reaped, via the `Peer::shutdown` this migration
needed lanok to grow.

165 insertions against 335 deletions, and much of what is added is prose
about what the file still owns.

295 tests pass (the one dropped test asserted that a reverse request cannot
complete an unrelated pending request, which is now structurally impossible
here and covered in lanok). Both cancel tests pass, and the Rust, Python
and TypeScript greet studies all drive the host green.
The SDKs generated their wire types but not their dispatch. Each carried a
hand-written chain — `if method == "run"` in Python, `case "run":` in
TypeScript — doing its own decode, call and encode per branch, with the
payload types present only as casts: `params["eval"]` on one side,
`params.eval as string` on the other. Adding a protocol method meant
editing that chain in every language and hoping.

It could not be generated before because `meta.json` said which methods
exist but not what any of them carries. It now names each method's params
and result types, so `codegen` emits:

- `StudyHandler`, one typed method per method a host sends, each
  defaulting to method-not-found so a study implements only what it
  answers; and
- `dispatch`, the decode/call/encode, exhaustive over the declaration.

`Study` implements that surface in both languages. The methods are
`run(params: RunParams) -> RunResult`, so an eval name is `params.eval`
with a type behind it, and the `toWire("RunResult", …)` literal that used
to be repeated per branch comes from the declaration. `handle()` stays
dict-in/dict-out, so no authoring code moves.

Two things fall out. A method missing from a serve loop is now a refusal
the declaration produces rather than a branch nobody wrote. And
method-not-found is classified by exception type instead of by testing
whether the error message starts with "unknown method".

Generating this exposed that `schema.json`'s payload registration was
itself a hand-kept list, and that it had drifted: `InitializeParams` was
missing, so `meta.json` named a type the schema did not define. The
registration now comes from the declaration's own `schema_document()`,
which produces the identical `$defs` plus the one that was missing, and
adds a `messages` map of the payload shapes per method. A new test asserts
every type named in `meta.json` resolves in `schema.json` — the guard whose
absence let that drift sit there.

296 Rust tests, 114 pytest, 112 node; all three drift guards clean; the
Rust, Python and TypeScript greet studies each drive the host green.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant