Liking cljdoc? Tell your friends :D

ADR 0009 — The Clojure port is a single .cljc source tree on clojure.core alone, JVM-first, Go-hosted second

  • Status: Proposed — spike battery in progress on branch cljc. Nothing in SPEC.md or the six existing ports has been changed by this document.
  • Date: 2026-07-27
  • Driver: toolnexus ships six ports (js / python / golang / java / csharp / elixir). A seventh — Clojure — is wanted, and it must run on every host from one source: JVM Clojure (the ecosystem, Clojars, clojure.test) and the Go-hosted dialects — cljgo, Glojure and let-go. All four are verified below.
  • Evidence note: every claim below was measured on this machineclojure CLI 1.12.5, cljgo 0.1.0-dev, and Glojure and let-go built from source — not reasoned from documentation. Reader-feature claims are cited to each implementation's source, not its README. Measurements are reproduced in clojure/spikes/ and in the koine conformance runner. Where a claim is unverified it says so.

Context

The prime directive (CLAUDE.md) is byte-parity across ports against the shared examples/ fixtures. A Clojure port that behaves differently on JVM and cljgo would violate that directive within a single port — the worst possible outcome, and the exact failure mode cljgo itself calls "unforgivable" (REPL-vs-binary divergence, cljgo ADR 0007).

So the port's design question is not "which Clojure do we target" but "what is the smallest surface on which the two hosts can be proven identical?"

Measurement 1 — one .cljc tree runs on both hosts, unmodified

A two-namespace spike (toolnexus.host + toolnexus.core) loaded via require on both hosts from identical source produced identical output apart from the deliberate platform probe:

JVM   → platform: jvm    sanitized: add_two  execute: 5  sh: {:out "HELLO DUAL HOST", :exit 0}
cljgo → platform: cljgo  sanitized: add_two  execute: 5  sh: {:out "HELLO DUAL HOST", :exit 0}

This works because of two ratified cljgo decisions: reader features are exactly #{:cljgo :default} and cljgo never answers :clj (cljgo ADR 0036 §A), and .cljc is a resolvable extension for require (cljgo ADR 0068 §1). The JVM elides :cljgo branches; cljgo elides :clj branches. Neither host ever reads the other's interop.

Measurement 2 — the pure-Clojure Clojars ecosystem is empty, not thin

Eleven candidate libraries were resolved and their .clj/.cljc sources scanned for Java interop (clojure/spikes/s03-dependency-purity/). Every one carries Java interop — including libraries that sound pure:

libraryJava surface
dev.weavejester/medley (utility fns)java.util.ArrayList, java.util.UUID
org.clojure/tools.clia static call
org.clojure/data.jsonjava.io.Reader/Writer, Boolean, Byte, CharSequence, Double, 5 statics
borkdude/edamamejava.io.Reader, Character
org.babashka/http-client5 imports, java.io.* streams
rewrite-cljjava.io.Writer, Character, String
org.clojure/data.csv, core.match, cuerdas, lambdaisland/uri, tools.readerall Java

cljgo ADR 0054 predicted a "thin" pure subset. The measurement says it is zero. And cljgo ADR 0054 constraint 2 is categorical: "cljgo does not do Java at all. Java interop is not a supported host operation, in either direction."

Therefore: any third-party Clojure dependency makes the port JVM-only. Not slower to port — impossible to port, until cljgo grows a Java shim (see Consequences).

Measurement 3 — clojure.core itself is already at parity

22 core vars were probed on both hosts. All 22 resolve on both, including the ones that decide whether the port is even expressible:

future future? deref promise deliver atom swap! slurp spit pmap send agent read-string pr-str re-seq subs format with-out-str bytes byte-array char string?

future/promise cover §8's parallel tool calls. slurp/spit cover skills and the agent-home files. format/re-seq/subs cover parsing. This is the whole port.

Measurement 4 — the seam is four items, and one is a trap

Four capabilities genuinely fall outside clojure.core:

#needJVMcljgostatus
1HTTP requestjava.net.httpcljg.net.httpboth present, verified
2subprocess with streaming pipesProcessBuildermissingcljgo gap
3directory listingfile-seq on java.io.Filefile-seq on a stringdivergent
4environment variablesSystem/getenvcljg.osneeds a branch

Item 3 is the trap and the reason this ADR exists rather than a wiki page: file-seq resolves on both hosts, so it reads as portable — but the argument types differ. (file-seq "sk") returns ("sk" "sk/a" "sk/a/SKILL.md") on cljgo and throws ClassCastException: String cannot be cast to java.io.File on the JVM. A "portable" name is not a portable function.

Item 2 is a genuine cljgo gap: cljg.io/exec (core/cljg/io.cljg:155) is run-to-completion — it takes :in as a string and returns {:out :err :exit}. MCP's stdio transport needs a long-lived child exchanging line-delimited JSON-RPC. Go interop is not an escape hatch either: (require-go '[os/exec :as ex]) binds nothing in interpreted mode (verified — no such namespace: ex).

Decision

1. One .cljc source tree, four dialects: :clj, :cljgo, :glj, :lg

No .clj/.cljg forks, no per-host copies. .cljc is chosen as the portable extension.

ClojureScript, :cljr, :bb and jank are explicitly out of scope. ClojureScript cannot spawn a subprocess, so stdio MCP servers — roughly half of what toolnexus does — are impossible there. A port that silently drops half the spec is not a port. This is a decision, not an omission; revisit only if :cljs gains a process model.

2. toolnexus depends on clojure.core + koine only — never on a third-party library

Given Measurement 2, any third-party Clojure dependency in the port itself makes the port JVM-only. Given Measurement 3, clojure.core covers everything except the seam.

koine is exempt and is where host libraries live. It may use Java libraries on the JVM branch and Go libraries on the cljgo branch freely — that is its whole purpose. The port calls its portable API and never sees either.

2a. koine's contract is byte-identical output, not "wraps a library" [Measurement 5]

Wrapping two host libraries and calling it portable does not survive contact with the prime directive. Six basic payloads were encoded through Go's encoding/json and JVM clojure.data.json (clojure/spikes/s05-json/). Four of six diverge:

payloadGo encoding/jsonJVM data.json
{b 1, a 2, c 3}{"a":2,"b":1,"c":3} (sorted){"b":1,"a":2,"c":3} (insertion)
"a<b>c&d""a<b>c&d""a<b>c&d"
1.0 / 100.01 / 100 — fraction dropped1.0 / 100.0
"café ☃"literal UTF-8"café ☃"
tab/newline escapesagreeagree
[1 2.0 "x" nil true][1,2,"x",null,true][1,2.0,"x",null,true]

Two of these are semantic, not cosmetic:

  • Floats. {"temperature": 1.0} encoded as {"temperature": 1} changes the JSON type. Tool schemas declaring "type":"number" and providers that coerce integers behave differently.
  • Key order. Byte-identical prompt prefixes are what make provider prompt caching work (the cache_control breakpoints deferred in ADR 0008). Sorted-vs-insertion ordering silently destroys cache hits.

Therefore koine MUST normalize every host library it wraps to one agreed output, and a conformance test MUST assert the two hosts produce identical bytes.

2b. Both JSON encode and decode are ours

Every one of the four divergences is an output-formatting choice. Parsing is unambiguous — both hosts turn a given document into the same Clojure data. So:

  • encode — pure portable Clojure inside koine, ~80 lines, no host library.
  • decodealso pure portable Clojure. Delegation was the original decision here and it was revised on evidence: with four hosts it would mean four parsers to keep in agreement, and two are unreachable (cljgo's decoder is a private builtin; Glojure ships no encoding/json by default). A core-only parser is smaller and more portable, and it leaves koine with zero third-party dependencies. Controlling key order, escaping and number formatting is the entire encoder; normalizing someone else's encoder into agreement costs the same code while leaving us debugging two libraries' escaping rules. The Elixir port made the identical call with its in-house MCP client.

The encoder MUST NOT use StringBuilder, Long/parseLong, Double/parseDouble or any other Java interop — those are JVM-only and were the first thing this spike got wrong. Build strings with apply str / clojure.string/join.

clojure.test is permitted for tests — it is clojure.*, present on both hosts (cljgo reports it complete against the 1.12.5 oracle, 39 vars).

3. Reader conditionals live in exactly one namespace: toolnexus.host

Every other namespace is dialect-blind pure Clojure and MUST NOT contain #? or #?@. The seam is the four items of Measurement 4 and nothing else — it is small enough to read in one sitting, which is the entire point. A test asserts the invariant by scanning the source tree for #? outside host.cljc.

Corollary from Measurement 4 item 3: a function that resolves on both hosts is not thereby portable. Anything whose argument or return types differ across hosts goes behind the seam even when the name is shared. file-seq is the first such case.

3b. The seam is built in-tree first, and extracted to its own library once proven

The four seam functions are not specific to toolnexus — every dual-host Clojure library needs the same four. The intent (owner, 2026-07-27) is therefore to extract them into a standalone portability library, published to Clojars, with a JVM-hosted and a Go-hosted implementation, so all future libraries build on it instead of re-solving the seam.

Shape when extracted: one artifact, not three. A single .cljc library carrying both hosts' branches — so reader conditionals exist in exactly one place in the whole ecosystem and consumers write (:require [koine.process :as proc]) with no conditional and no implementation-selection step. The alternative (a pure api artifact of protocols plus -jvm and -go implementation artifacts) buys pluggable third hosts, which Decision 1 has already ruled out of scope, at the cost of a second dependency for every consumer.

Sequencing: prove, then extract — not the reverse. The seam is built as toolnexus.host and subjected to the full spike battery before it is lifted out. Publishing an API to Clojars freezes it: changing it afterwards costs a breaking release. S7 is already expected to reshape the process API (cljgo has no streaming child at all), which is precisely the kind of finding that must land before the interface is public. Extraction is mechanical once the shape has stopped moving.

4. JVM-first, cljgo-second — sequenced, not simultaneous

The port is built and proven excellent on JVM Clojure first: full clojure.test suite, green against the shared examples/ fixtures, parity-checked against the existing six ports. Only then is the same .cljc pointed at cljgo and the gaps recorded.

This ordering is deliberate: it means a cljgo gap cannot block the port from shipping, and it puts the port where the users, the tooling and Clojars already are. cljgo support is a milestone, not a release gate.

5. Distribution: Clojars for the JVM, git coordinate for cljgo

Published to Clojars as a normal Clojure library (tools.build jar carrying the .cljc source), consumed by deps.edn/Leiningen the ordinary way.

cljgo consumes the same source tree via a git coordinate, because cljgo cannot consume from Clojars today: ADR 0054 built the publish side only and explicitly defers consume-side interop; cljgo resolves Clojure deps as git source roots (ADR 0052). One source, two coordinates, until Clojars consumption lands in cljgo.

We do not use cljgo publish clojars — its validator rejects any Go interop, and it emits a git coordinate rather than a Clojars artifact anyway (ADR 0054, deferred items).

6. Four hosts are verified: :clj, :cljgo, :glj, :lg

cljgo is not the only Clojure hosted on Go. Glojure and let-go are now verified alongside it — built from source, one .cljc file, all four hosts:

jvm        9/9 pass
cljgo      9/9 pass
let-go     9/9 pass
glojure    9/9 pass

Reader features were read out of each implementation's source, not its README:

hostfeaturesource
Clojure:clj
cljgo:cljgo + :default, never :cljcljgo ADR 0036 §A
Glojure:glj + :defaultpkg/reader/reader.go:1403
let-go:lg + :default; opt-in :clj/:bb via set-read-clj! / LG_READ_CLJ, off by defaultpkg/compiler/reader.go:1122-1145

The three Go dialects do not share an implementation, because their interop models are unrelated: cljgo uses require-go + cljg.*; Glojure exposes Go's stdlib directly with / munged to : (os:exec.Command, net:http), shipping ~26 packages by default; let-go has its own os/io/http/json namespaces plus Java-shaped shims — its System/getenv works. Adding a dialect is therefore a branch, not a fork, and koine is structured for it:

  • every seam function ends in a :default branch that either delegates to a dialect-agnostic implementation or throws a named, actionable error ("koine: no <capability> implementation for this host; add a branch in koine/<ns>.cljc") — never a silent nil or an obscure resolution failure;
  • branch order is #?(:clj … :cljgo … :default …), extended in place;
  • the conformance suite is host-parameterised, so a new dialect is onboarded by running the existing suite against it and fixing what fails.

Honesty: :clj (1.12.5), :cljgo (0.1.0-dev), :glj and :lg are measured for JSON and env only. http, process and fs have per-host branches but are verified on the JVM alone. gloat and Joker are untested.

Measurement 6 — portability bugs that are invisible on the JVM

Writing one file for four hosts surfaced five traps, each now encoded as a rule:

  1. (= key-fn keyword) throws on Glojure — "comparing uncomparable type lang.ArityFn". Apply a function; never compare one.
  2. ^:dynamic is not honoured on Glojure — "cannot dynamically bind non-dynamic var". Thread the parameter; it is also less code.
  3. Go's os.Getenv returns "" where the JVM returns null, and "" is truthy in Clojure — so (or (getenv x) default) silently never falls back on Go-hosted dialects.
  4. file-seq takes different argument types (Measurement 4, item 3).
  5. Map print order differs per host, so any assertion over pr-str of a map is a false failure waiting to happen. This bit our own conformance script.

Consequences

  • The port carries no supply chain. No CVE surface, no version conflicts with a host application's own data.json — a real ergonomic win for a library, which is what toolnexus is.
  • We own a JSON implementation and must test it as such (round-trip, unicode escapes, deep nesting, number formats, malformed input). It is spiked before it is trusted.
  • ~95% of the port is dialect-blind, so cljgo support reduces to four functions.
  • The seam is implemented in koine — its own public repo, per Decision 3b. S5's encoder contract passes byte-identically on both hosts there (7/7 on cljgo, 38 assertions on the JVM).
  • cljgo work items, each independently useful to cljgo beyond toolnexus:
    1. ~~Streaming subprocess~~ — CLOSED upstream 2026-07-30. cljgo ADR 0104 ("require-go reaches the Go standard library") admits stdlib import paths to the existing zero-bindings AOT route, so exec.Cmd.StdinPipe/StdoutPipe are reachable directly. Spike S57 built a 6.8 MB AOT binary driving a long-lived cat child over two stdin→stdout round trips — stdio MCP is expressible on cljgo. The fix was ~15 lines in one file (pkg/eval/host.go), commit 4423d12; it also had to add the missing HostUnlinkedTolerant guards to OpHostMethod/OpHostField, without which every method on a host-returned value dies in the AOT discovery pass.
    2. ~~Environment-variable access~~ — CLOSED upstream 2026-07-30, same change: os.Getenv in an AOT binary (spike S56). Unblocks ${ENV_VAR} expansion in MCP headers, hence remote MCP auth. Correction to this ADR's original evidence: (require-go '[os]) does not fail at the form — it returns cleanly with exit 0 and interns nothing, and the error surfaces later at the call site. Re-measured 2026-07-30. 2b. Streaming HTTP responses — a blocker this ADR never listed. cljgo's only HTTP shim did io.ReadAll then closed the body, so streaming LLM responses were impossible. Also closed by ADR 0104 (spike S58 reads resp.Body line-by-line through bufio with the body still open). 2c. Caveat carried forward. cljgo's AOT discovery pass evaluates Clojure with nil substituted for every host result, so a nil-intolerant pure function applied to a host value fails at build time, not run time. koine's cljgo branch must keep host results on a nil-tolerant path; this is the most likely source of future "works in run, fails in build" reports.
    3. A public JSON namespace. -json-decode is a private builtin, unreachable from user code. No longer affects toolnexus — koine ships its own parser — but it is a real hole in cljgo's stdlib.
    4. Clojars consumption — so cljgo users get the same coordinate as JVM users. Not a blocker; a git coordinate works today.
    5. A java.io/java.lang/java.util shim (large, strategic, NOT required by this port). Measurement 2 showed the ecosystem's interop is concentrated in ~20 boring classes with direct Go equivalents. Shimming them would unlock a wide slice of Clojars for cljgo. Recorded here because the measurement is the evidence for it; it belongs to cljgo and to its own ADR, not to this port.
  • The port is a seventh implementation of SPEC.md and inherits the prime directive: a behavior change lands in all ports or it is not done. This ADR does not change SPEC.md and does not alter any existing port.

Spike battery (gate — this ADR is not accepted until these pass)

#spikeverifiesstatus
S1dual-host .cljc require + conditional selectionDecision 1✅ passed
S2seam confined to one namespaceDecision 3✅ passed
S3dependency purity scanDecision 2✅ passed (0 of 11 pure)
S4clojure.core parity probe (22 vars)Decision 2✅ passed (22/22)
S5JSON encode/decode identical on all hostsDecision 2a/2b✅ passed (9/9 × 4 hosts)
S6HTTP POST both hosts against a local serverseam 1
S7streaming stdio subprocess, both hostsseam 2✅ passed on both — see S15
S14Glojure + let-go run the same .cljcDecision 6✅ passed
S8skills discovery: **/SKILL.md glob + frontmatterseam 3✅ passed — see S15
S9clojure.test suite runs identically on both hostsDecision 4
S10parallel tool calls via future/promise§8 loop
S11real MCP handshake against examples/mcp.json§2passed — see S15
S12cljgo build AOT-compiles a program requiring the portcljgo release path✅ passed — see S15
S13Clojars publish dry-run + cljgo git-coord consumptionDecision 5✅ moot — koine 0.4.1 is on Clojars and cljgo resolves Clojars (its ADR 0095); one coordinate, both hosts
S15SPEC §0 vertical slice, one .cljc, byte-identical on both hostsDecisions 1–3, seams 2–3, §0.2–0.7passed 2026-07-31 (clojure/spikes/s15-spec0-slice)

S5, S7 and S11 were the ones that could still kill or reshape this design. All three have now passed. S7 did fail on cljgo as expected, that failure became the specification for cljgo work item 1, the fix landed upstream (cljgo ADR 0104 → shipped as ADR 0109), and koine 0.4.1 ships it from Clojars.

S15 subsumes S7, S8, S11 and S12 in a single measurement and is the strongest evidence this ADR has. One 295-line .cljczero reader conditionals, zero java.*, zero Go interop — implements §0.2 sanitize/naming, §0.3 mcp.json parsing with ${ENV} header expansion, §0.4 a live MCP stdio session (initializenotifications/initializedtools/listtools/call, skipping interleaved notifications), §0.5/§0.6 skill discovery and the byte-exact skill output, and §0.7 all three schema adapters — against the shared examples/ fixtures the other six ports run. Clojure (JVM), a cljgo build AOT binary and cljgo run interpreted all emit the same 2730 bytes.

This settles Decision 3 more strongly than it was written: the port's own source needs no reader conditional, not merely few. It also retires the concern that JVM-first meant cljgo would be discovered late — both hosts are green from the first line of real code.

S15 found one cljgo trap worth carrying into CI: cljgo run <file> does not call -main. It evaluates top-level forms and exits 0 having printed nothing, which is indistinguishable from a program that ran successfully and had nothing to say. Together with the cljgo test behaviour koine reported (zero .cljc tests collected, Ran 0 tests, exit 0), the rule for this port is: on cljgo, assert on output, never on the exit code.

Note that Decision 4 (JVM-first) means none of the cljgo work was ever a release gate; it is recorded here because the measurements were the specification cljgo built against.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close