Every released version of net.clojars.muthuishere/koine, what changed, and
which host versions it was actually run against.
Two rules for this file, both earned:
koine's version is its own. It tracks koine's API, not cljgo's. The minimum supported cljgo is v0.8.5.
Verified, everywhere below, means: 13 conformance checks on both hosts, the JVM
clojure.test suite, and both example projects — interpreted and AOT — consuming
the published Clojars artifact and producing byte-identical output.
Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.9 (released build, verified by Go module checksum)
Response header names were unreadable portably. The hosts disagree natively and there was no third spelling that worked:
| host | key returned | (get headers "Mcp-Session-Id") |
|---|---|---|
| JVM | mcp-session-id — java.net.http lowercases | nil |
| cljgo | Mcp-Session-Id — Go's http.Header canonicalises | works |
So koine.http/request handed back headers no portable code could read, and
it failed silently — a missing header and a mis-cased one are both nil.
Names are now lowercased on every host, which is the form the wire already
uses (HTTP/2 requires it; RFC 7230 makes names case-insensitive, so nothing is
lost). New: koine.http/header for a case-insensitive read and
koine.http/normalize-headers.
Found by the toolnexus MCP port, whose session id lives in exactly such a header. Seventh defect of the ADR 0001 shape, and a new dimension: no check had ever asserted header key case, and the fixtures all used lowercase header names — which one host matches by accident. The fixture now sends mixed case, because a lowercase one cannot discriminate.
Note this was not catchable by comparing the two hosts' header maps for
equality: they legitimately differ (date, and cljgo's server adds a
content-type the JVM's does not). Only asking for a known key by a fixed
spelling tells the two apart.
sse-post surfaces the response head before the first event.
(sse-post url headers body on-event {:on-open f}) applies f once to
{:status n :headers {…}} as soon as the head is available, while the stream
is still open. sse-post also now returns :headers alongside :status.
The 4-arity is unchanged and still supported.
Why a callback rather than the returned map: MCP streamable-HTTP issues a
session id in the response headers, and a server→client reverse request
arriving as an SSE event must be answered by a separate POST carrying that
id, before the first stream closes. Headers returned when the stream ends
arrive strictly too late, and the buffered koine.http/request never streams —
so a consumer previously had to choose between learning the session id and
receiving events incrementally. Asked for by the toolnexus MCP port, which
checked what koine already had before asking.
The contract here is a timing one, so the check asserts the clock: that
on-open fires before the first event and leads it by more than the server's
gap. Verified by mutation — moving the callback to after the read loop failed
exactly those two cases while all 43 value assertions still passed.
koine.host capabilities: :http/response-headers, :stream/response-head.
run-conformance.sh prints the host versions it measured and exits
non-zero on failure, where failure includes a check that did not report at
all. It reads cljgo's provenance from the binary's Go module checksum, not
from cljgo version, and says NOT a release for a build from a checkout or
a wrapper script. See CLAUDE.md.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.8 and v0.8.9 —
both released builds, verified by their Go module checksum rather than by
cljgo version. (v0.8.9 measured 2026-08-02, after the release: 26/26
host-checks green, exit 0.)
:timeout-ms where
cljgo's API takes :timeout. The unknown key was ignored silently, so every
deadline on that host did nothing from 0.1.0 onward: a request budgeted at
150 ms ran the full 1500 ms and returned a plausible 200. Found by the
toolnexus port, which probed four key spellings against a deliberately slow
server instead of filing "the timeout is broken". http_check now asserts the
clock, not just the result map — a value-only assertion could not have seen
this, because with the wrong key there is no error to classify.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.6
koine.process/await-exit! and await-stderr — the polling loop as API,
not advice. Twice a docstring had told callers to loop around a racy
snapshot; nothing verifies a docstring. Where the correct usage is a loop, koine
ships the loop.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.6
stderr-lines returning [] means nothing has arrived yet, not "nothing
was written". The distinction decides whether a caller may stop waiting.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.6
exit-code returning nil means has not been seen to exit, not "still
running". A caller treating nil as "alive" will loop forever on a child that
already died.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.5
koine.process/exit-code and koine.fs/real-path — the last two upstream asks
cljgo accepted, now consumed rather than shimmed.koine.host capabilities extended: :process/exit-code, :fs/real-path.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
0.7.2 sorted JSON object keys by LENGTH before content — clojure.core/compare
on vectors is length-first, not lexicographic, so {"config":2,"artifacts":1}.
koine now uses an explicit code-point comparator.
This one is worth remembering: the bug was identical on both hosts, so the cross-host differential gate was structurally blind to it. It was caught by the example suite asserting a property of a real payload — and every new case 0.7.2 had added used equal-length keys.
Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
sort compares UTF-16 code units
on the JVM and UTF-8 bytes on Go. They agree across the entire BMP — which is
why every conformance case passed for months — and diverge above it, because a
supplementary character is a surrogate pair whose lead unit is below U+FFFD
while its UTF-8 bytes are above. One emoji in a key broke byte-identity. Keys
now sort by code point via a pure-clojure.core scan.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
:timeout-ms bounded the report, not the call, on cljgo — measured
314 ms of budget against a 5008 ms actual. Both hosts returned an identical,
entirely plausible {:timed-out? true :exit nil}; only the clock told them
apart.mkdirs! over an existing file threw on cljgo and silently no-opped on the
JVM. fs_check now enters the states where a function must refuse, not only
the ones where it must succeed.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
alive? meant two different things — false on the JVM and true on
cljgo for a child that had exited on its own. The checks had covered liveness
mid-conversation and around kill!, never a child nobody had stopped.run-async! is public — a daemon thread on the JVM, a future on cljgo.src/shadow_check.cljc: asserts that no koine name accidentally shadows
clojure.core on either host, with an allowlist for declared shadows
(koine.process/close!).Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
kill!, :timeout-ms) and filesystem
mutation (delete!, delete-tree!, temp-dir!).sh's pipe readers ran on future, whose pool
threads are non-daemon with a 60 s keepalive — so the JVM would not exit for a
minute after the work was done. Caught because a passing check took 61 s.Tested against: Clojure (JVM) 1.12.5 · cljgo v0.8.2
test/: cljgo v0.8.2 shipped the .cljc walk fix
(its test walk had skipped .cljc entirely and reported "Ran 0 tests").Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
stderr-lines.{:status nil :error :timeout | :dns | :connect-failed | :transport}. No portable catch can tell those
apart — the JVM has real exception classes but naming one is Java interop, and
cljgo wraps all three in *fmt.wrapError. koine classifies once, at the seam.Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
koine.host would not AOT-compile on cljgo — a comment was truncated at
90 bytes, splitting a multi-byte rune and producing emit: illegal UTF-8
pointing at generated source the author never wrote. Fixed upstream in cljgo
(with a test), not worked around here.Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
koine.host — which host, what it can do, and supports?, so a caller
degrades without a host-specific catch. (A try/catch cannot be a
capability probe: the catch symbol itself is not the same on every host.)examples/ — real consumer projects on the published Clojars artifact, one
shared source.Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
koine.route/proxy → forward.Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
byte is unsigned — a byte read on cljgo is 128/255 where the JVM
gives -128/-1. koine normalises to the JVM contract at the boundary and
bytes_check asserts it.Tested against: Clojure (JVM) 1.12.5 · cljgo — version not recorded
First release, as net.clojars.muthuishere/koine.
koine.http, koine.server, koine.stream, koine.time, koine.route,
koine.process, koine.fs, koine.env, koine.json.clojure.core — no host library, no dependency. Keys sorted,
floats keep their fraction (1.0 never becomes 1), non-ASCII emitted
literally. Wrapping two host JSON libraries does not survive contact with
reality: Go's encoding/json and JVM clojure.data.json disagree on four of
six basic payloads.The order is: CHANGELOG → Clojars → examples → tag → GitHub release.
Clojars sits in the middle for a measured reason: cljgo resolves Maven deps
from remote repositories only, not ~/.m2, so the cljgo example — which
consumes the published artifact on purpose — cannot run before publishing. The
tag and the GitHub release stay last, so nothing is discoverable until the
artifact behind it is green.
clojure -M:test,
env -u CLJGO_SRC PATH="$HOME/go/bin:$PATH" ./run-conformance.sh (exits
non-zero on any failure and prints the host versions it measured), and an
AOT compile of each changed namespace from source — cljgo build in
single-file mode resolves koine from the working tree, and 0.4.1 was a bug
that appeared only under AOT.Tested against: line from that header. Only a
(released build) counts: the header asks the Go binary for its mod
checksum rather than trusting cljgo version, because a PATH cljgo that
rebuilds from a local tree reports a release-shaped number while measuring
something else entirely. That mistake produced two retracted claims.build.clj and both example coordinates,
commit.clojure -T:build deploy — the irreversible step. A Clojars version can
never be re-deployed or withdrawn; if what follows fails, ship a patch.examples/cljgo-app/build.lock.edn first so it re-pins, and commit the
regenerated one. Never hand-edit it.gh release create with that same section — the notes are
the changelog verbatim, so the tag, the log and the notes cannot drift.The full version of this, with the commands, is in CLAUDE.md.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |