Liking cljdoc? Tell your friends :D

11. Open questions

  • ~~Trace retention.~~ Resolved 2026-07-31: strong references, released wholesale. See §5.

  • ~~Output attribution: scope or thread-local.~~ Resolved 2026-07-31: scope. See §5.

  • Registry scope across MCP sessions. Streamable HTTP carries a session id, so two agents — or an agent and a human in Cursive — can be connected at once. Global is the leaning: ids then mean the same thing to everyone, and you can inspect from the editor what the agent just evaluated, which is most of why a shared JVM is worth having. The cost is that concurrent callers interleave ids and evict each other's entries out from under themselves. Per-session registries fix that and lose the shared view.

    Measured 2026-08-04: the spine is real — a server that answers initialize with an Mcp-Session-Id header gets the id echoed back on every subsequent request from claude-code 2.1.220 (notifications/initialized, tools/list, tools/call all carried it). The client doesn't bring an identity; the server assigns one, exactly as the spec says. A connection initialized before the header existed keeps sending nothing — the id-less legacy shape is observable, not an error. A reconnect (measured same day) is a clean rebirth: the fresh initialize arrives bare rather than dragging the old id, and the client echoes whatever new id it is minted. So sessions are cheap and ephemeral — one conversation can span several — which sets two requirements: owner tags on registry entries must outlive their session gracefully (the entries stay reachable by id; only the dead session's delivery claims lapse), and anything owed to a session that will never call again — told-once news, the unread-results eviction shield — needs a liveness notion so it doesn't accumulate as permanent debt.

    On that liveness notion, two more measurements (2026-08-04): the client sends no DELETE on reconnect — the old session is abandoned without the spec's terminating request — so expiry-by-silence (a last-seen stamp per session, refreshed by every request) is the only release mechanism, not a backstop. Release means marking the dead session's entries seen: the eviction shield lifts, the entries join the LRU pool still reachable by res until capacity pressure claims them — death degrades gracefully rather than deleting anything. And the client does send a GET right after notifications/initialized — offering to open the spec's server-initiated SSE channel, session id attached — which this server 405s and the client tolerates. News keeps riding responses (§5), but if a standing channel is ever wanted, the client is already knocking.

    Direction settled by discussion the same day: split the concern rather than choose isolation or sharing wholesale. Session-scoped — the conversational state whose single-caller assumptions actually break with two clients: sticky namespace, news/told-once delivery, the fetched-tools marker, the client-info capture. Shared — the registry substrate: one JVM, global ids, every entry tagged with the session that minted it; res reaches anything, so two models on the same JVM can hand results to each other by id. Delivery to the owning session is what counts as delivered. Crowding is met with capacity extension rather than partitioning.

    Built 2026-08-04, all mechanisms verified live against claude-code and curl side by side. The shape as shipped: the session namespace holds defonce state keyed by minted id (nil is a real key — the shared identity of id-less callers; revoked 2026-08-06: bare requests are 400'd, nil is the in-process caller only — see the MCP revision record below); the transport mints at initialize and refreshes on every request, and successful heartbeat writes refresh too, so open streams are liveness and a session mid-await cannot expire under its own wait. Unknown ids are adopted, not 404'd — a deliberate spec deviation: after a server restart the client keeps presenting its old id, and forcing re-initialization buys strictness at the price of churn. Expiry is by silence at the shared 24 h constant (session/expiry-ms, derefed by the await clamp so the two cannot drift), swept lazily at admission — no reaper thread. Release lifts the eviction shield (entries marked seen and told, still res-able until LRU pressure claims them): death degrades, it does not delete. The spec's DELETE is honored as immediate release (measured: claude-code never sends it). evals lists everything, entries from other sessions tagged :by — attribution, not isolation; the caller's identity rides both attribution rails as session/*session*, beside the bank. Evictions inflicted on another session's entries are queued and drain into that owner's next response of any kind, cleared only after the write, like every delivery mark. Capacity extends by +10 per live session beyond the first — one session sees the configured cap exactly; the extension was observed expanding (no evictions while a second session lived) and contracting (a six-entry convergence the admission after its DELETE). Accepted cost: a reconnect resets the sticky namespace, since a new session is genuinely a new conversation as far as the server can tell.

    Two-Claude exercise, 2026-08-05: two Claude Code instances worked the same JVM concurrently, one running a scripted battery and writing notes, the other clobbering alongside. Everything held: owner tags read as attribution on both sides (interleaved ids registered as "normal id-sequence gaps", not confusion); values were handed both directions by id (res 203 / res 208); sticky namespaces moved independently; output attribution never leaked a line across sessions; settled-news announced each side's ids to that side only, in both the positive and negative cases. Findings with edges: an ordinary client restart (which also picked up a client update, 2.1.220 → 2.1.222) re-initialized one side between days — one human conversation sprayed three session ids, vindicating adoption and orphan-grace, but meaning a session must never bake its current id into data; it asks the rails (sc/ask session/*session*) — an __r helper is a candidate. And the shared substrate's sharp edge is real: res-ing another session's running entry would mark it delivered and eat the owner's settled-news announcement — any delivery counts, by design; collaborators read each other's settled work, not their in-flight ids.

    Cross-session affordances, built 2026-08-05 — the exercise's frictions, turned into three __r functions. sessions: the roster — sid, idleness, client, note, owned-entry count, :you on the caller's row — answering by one read what the second tenant reconstructed by inference. introduce!: a self-description on that roster, a commit message for a session; it dies with the session, which is right, because so does the purpose. release!: punctuation — (release!) is a comma, marking the caller's settled unread entries absorbed and evictable while running work stays a live concern; (release! true) is a period, cancelling the caller's running entries too (cancel-authors-the-outcome doctrine; each turns evictable only when the cancellation bites). Both return {:released [...] :cancelled [...]} — the report is the audit the caller would otherwise do by sifting its conversation. Also the polite goodbye, in a system whose clients never send DELETE. One bite mid-build: the first period cancelled the eval that spoke it — the release-eval is itself a running entry the caller owns. Exempted by its bank, the eval's identity on the rails: punctuation ends the leg behind you, not the sentence being spoken.

  • Is impl.cljc:443 the only site a task receives a scope? race.cljc:89's (with-scope (call/getScope t) …) may imply other creation paths needing the same hook. A ten-minute read of impl.cljc decides whether the incision is one line or four.

  • babashka compatibility. rewrite-clj is bundled in bb, clj-kondo is not. Either split the parse layer (bb-capable) from the analysis layer, or shell out to the clj-kondo binary. Affects whether outline runs in a 100ms process.

  • Print-budget defaults — purely empirical, to be learned from the first week of use.

  • ~~The model API layer.~~ Built 2026-08-04 — see §4; contents tuned in use since. The teaching surface's history — the instructions arc, the manual's delivery, and the smoke tests that shaped both — lives in the logbook (doc/logbook/), as do the test-suite freeze and the first in-situ smoke test.

  • The sessionless identity endpoint, GET /probe (2026-08-06, built for deployment rollouts). Rollout context first, worth keeping: where each deployed app container joins the tailnet as its own ephemeral node via tailscaled --tun=userspace-networking, inbound tailnet connections are proxied to loopback — so the default :bind "127.0.0.1" is already the tailnet-only exposure there, no binding change needed. The consumer to design for is an operator-side discovery helper — a Babashka script, say — that enumerates tailnet nodes and regenerates .mcp.json from the Remontoire instances it finds, which means it must verify "a live Remontoire answers here" — port-open is not evidence, and mid-rollout many environments would still run pre-Remontoire builds. The natural verification, an MCP initialize, mints a session that lives to expiry-ms of silence: discovery probes from several operators, several times a day, against every environment would fill the roster with ghosts — polluting precisely the surface built for tenant mutual awareness. The sessionless 400 was the other candidate fingerprint: identifying the server by an error message, and learning nothing about it. So the transport grew a second context: GET /probe, plain JSON, no session minted, no registry or session state touched — {"server": "remontoire", "version": …, "describe": …}. server is the fixed discriminator the helper keys on. describe is the init!-time self-description — §4's "the environment names itself," here behind a sessionless door, so discovery reports each environment in its own voice. version reads mcp.clj's server-info, a site the release checklist already syncs — no fifth version location. Consumers key on field presence, not shape completeness, so fields (session count, uptime) can accrete without coordination. GET-only and curl-friendly, doubling as a post-deploy sanity check and a monitoring target; exposure and origin policy are exactly /mcp's, and no auth — the tailnet is the lock, as everywhere. Consumer contract for the record: connection-refused or timeout means no Remontoire; a 404 on /probe means a pre-0.1.2 Remontoire (acceptable mid-rollout, when no deployed environment has any version yet); a 200 with "server": "remontoire" means verified alive.

  • Output attribution rides two rails (measured 2026-08-04; built the same day — see §4). Quiescent captures the scoped-library scope at task construction (impl.cljc:444) and re-establishes it around every body, so a scoped var bound around eval-chain construction reaches every link and every descendant task, through compel moats (measured: nested and compelled tasks both see it). Clojure conveyance (future/agent/bound-fn) carries binding frames but not scope (measured: :conveyed via frames, nil via scope) — so the bank is also thread-bound per link, and sc/ask's var-deref fallback makes one tee-side lookup cover both rails: (future (println …)) from eval'd code attributes with no helper. Irreducibly manual: raw Java threads, executor callbacks, and futures launched from nested tasks (no frames there, and futures don't carry scope) — those need spawn-site capture, a bound-fn-for-scope helper that likely belongs in scoped itself and is exposed through the API layer. Third-party internal threads pass through the tee unattributed — visible on the console, never mis-banked. Attribution follows the task tree, which is the same line structured concurrency draws.

  • Extraction timing. Keep the entry/site/node shapes moving in one place until they stop changing; version churn while the element types are unsettled buys nothing.

  • The 2026-07-28 MCP revision (assessed 2026-08-05 from the changelog and spec pages). The spec went stateless: initialize/initialized and Mcp-Session-Id are removed, every request carries protocol version, capabilities and clientInfo in _meta, and a mandatory server/discover RPC advertises versions and carries the instructions field — so the teaching surface keeps its slot. clientInfo is {name, version}, not instance-unique: there is no covert way to correlate requests into a session, and the changelog's migration line is aimed straight at this design — "servers that need cross-call state use explicit, server-minted handles passed as ordinary tool arguments."

    Impact now: none. Dual-era servers are explicitly blessed (legacy initialize and stateless _meta served concurrently on one endpoint, era selected by how the client opens), claude-code speaks the legacy handshake, and deprecations carry a twelve-month minimum window. The standing version-pinning item acquires its content: pin the legacy revision we answer today, go dual-era when claude-code actually sends modern requests, not before. The mechanical adoption items are small — Mcp-Method/Mcp-Name header validation (HeaderMismatchError −32020), ttlMs/cacheScope on tools/list, deterministic tool order (three tools; already deterministic). Non-impacts: Roots/Sampling/Logging deprecated (unused here), HTTP+SSE deprecated (this is Streamable HTTP), SSE resumability removed (never built), the GET channel replaced by subscriptions/listen (this server 405s the GET already), MRTR (no server-initiated requests made), OAuth changes (the tailnet is the auth layer). One flattering convergence: experimental tasks became the io.modelcontextprotocol/tasks extension — poll-based tasks/get, servers may return task handles unsolicited — the registry's shape arriving at the protocol level. Not needed (in-band ids are richer: composition, cross-session handoff), but a possible someday-surface for generic clients, and decent evidence for the eval-as-task-under-an-id grain.

    The one real design question is the session, and it stays open. Everything keyed off the transport sid — scratch namespace, sticky ns, delivery claims, :by, expiry, release! — would key off a server-minted handle threaded as a tool argument; the blast radius is mcp.clj alone, since session already takes an opaque sid and does not care where it came from. Two positions recorded from the day's discussion. For: a handle in tool-space survives reconnects and client restarts — the "one conversation sprayed three session ids" finding gets its real fix, since any instance could deliberately assume any recorded session; sessions were never a security boundary anyway (the registry is shared and res reaches everything), so assumability inside the tailnet trust is a feature. And the concern was arguably never transport-shaped: scratch/claims/attribution are application state that borrowed transport keying because it was free — the model already threads server-minted handles competently, because eval ids are that, and "ids are how sessions hand work to each other" extends one level up to the sessions themselves. Against: it puts what feels like plumbing on the model, a per-request affordance of exactly the kind the smoke tests showed gets stripped unless the laziest call is the compliant call. Mitigation if built: the response footer carries the handle back on every response, so it is ambient rather than remembered. A related idea considered and shelved: collapse the surface to a single introduction tool that returns instructions and unlocks the rest interactively. It runs against the revision's grain — the same changelog makes list endpoints per-connection-invariant and cacheable (ttlMs, cacheScope: public), so per-caller tool gating is precisely what statelessness forbids, and the carrying job it would do is already homed in server/discover's instructions. Decision: no build work now; session-as-handle gets its own design pass when a modern client exists to measure against.

    The handle-less call, settled for the legacy wire and narrowed for the modern one (2026-08-06). The first draft of this record had a handle-less request degrading to a shared anonymous session — the nil-sid identity the transport already carried. Interrogated a day later: was that path ever actually walked? Measured live — no. The session table held exactly two sids, both real tenants; the shared scratch namespace had never been minted; every registry entry had a real owner. Structurally so: every MCP client must initialize before anything else and claude-code echoes its id on every request, so the id-less population is hand-written curl and broken clients — and for a broken client, silent state-pooling is the worst answer: two such tenants would share defs, sticky ns and delivery claims with no signal anything was shared. So the wire went strict the same day: a non-initialize POST without Mcp-Session-Id is 400'd (http/session! returns nil, serve! refuses — the spec's own SHOULD, and the DELETE branch was already doing it), and nil narrowed to the sessionless in-process caller — a human at the actual REPL — who keeps the shared scratch and reads as :by nil. Cost accepted: curl follow-ups must carry the header, which makes probes exercise the real path. For the modern era the anonymous-degradation option is gone from the menu, leaving two live candidates: mint-on-missing — a handle-less tool call implicitly births a session and the footer announces it, so every bare caller is a fresh tenant and collision is impossible, at the price of a model that drops its handle silently forking into a new identity mid-conversation; or explicit mint — a dedicated session-minting tool, every other call erroring without a handle, the error text itself teaching the incantation — initialize reborn as an in-band tool, loud where mint-on-missing is forgiving. The strictness decision leans the same way the legacy one did: the loud error is the one that never pools strangers and never forks anyone silently. To be settled by measurement when a modern client exists. Verified against the revision's authorization spec (2026-08-06): auth is untouched by statelessness and has not moved to the model layer — the OAuth 2.1 bearer token rides the Authorization header on every request, was never coupled to the session id, and never appears in tool arguments or _meta — so token and handle are cleanly orthogonal: the token says who may talk to the server at all, the handle says which conversation this is, and session assumability is bounded by transport credentials (here: the tailnet), not by the handle's secrecy.

    That orthogonality suggested an inversion, same day: don't hand the model a handle — infer the session from the credentials the caller holds. It buys everything the handle-burden objection feared losing: no model-layer plumbing, identity stable across reconnects and restarts (the sprayed-session-ids problem dissolves), and it is spec-aligned — cacheScope: "private" exists precisely because responses may vary per-credential; the revision evicted per-connection state, not per-principal state. But the grain is wrong as the whole key, and our own measurements say so: a credential names a principal, a session names a conversation, and the two-Claude exercise ran two concurrent conversations from one principal — same machine, same tailnet identity, and under any realistic OAuth the same user token, since harnesses mint per user, not per conversation. Key sessions by credential alone and your own tenants collapse into one scratch — the pooling the 400 refuses, rebuilt inside one account. (The web analogy: login-keyed state is right for the shopping cart, wrong for the wizard's current step; sticky ns, told-once news and scratch defs are all wizard.) Where it lands: as the outer key. The credential gives the principal — attribution, the security boundary, the roster's "who" — and a handle distinguishes conversations within it. The composition improves both open candidates: the handle-less fallback becomes per-principal rather than global, so forgetting the handle degrades to "your own tenants share" — the two-Claude exercise's measured, survivable degradation — never "strangers pool"; and assumability is scoped for free to conversations your credential owns, with handle secrecy never load-bearing. The principal rail already exists in the current transport unused: the tailnet's credential is the peer address, and ctx carries :peer today.

    Pushed one level further (2026-08-06): the credential's grain is mint-time policy, not a fact of nature — rig the minting and reauthentication ≈ new session. The precise rig is session ≡ grant, not token string: naive TTL-forced reauth would conflate "new conversation" with "expired token mid-conversation" and fork sessions on refresh; the clean invariant is a session claim the AS carries through token refresh (same grant → same session) and renews on fresh authorization (new dance → new conversation). Running the AS ourselves — RFC 9728 discovery lets the MCP server name any AS, including one in the same JVM — makes that claim ours to mint. Two dependencies and one new cost, though. Dependencies: client storage behavior (claude-code stores MCP credentials per server in shared config, so concurrent instances would present one grant and collapse again — controllable when we own both ends, a bet otherwise), and OAuth-mode only (the tailnet's peer-address credential has no reauth to rig). Cost: conversation lifecycle moves into the auth layer — birth becomes a human-gated dance (friction for unattended agents), and resumption/assumption becomes credential sharing, i.e. exfiltrating a bearer token, precisely what token hygiene forbids. Registry-id work handoff survives regardless; it is session assumption, the feature that motivated explicit handles, that turns into the thing auth exists to prevent. Net: the design space collapsed to one question — where does the conversation handle live? Tool-space: model-visible, forgettable, cheap to hand off. Credential-space: invisible, unforgeable, handoff-hostile, client-behavior-dependent. Same handle, different substrate, opposite failure modes.

    The credential-space option, taken to its logical end (2026-08-06): co-opt the auth mechanics so the credential has an infinite (or very long) lifespan — every new client dances once and gets its own, nothing ever refreshes — or, if tokens must expire, the co-located AS re-keys the session at refresh (it sees the grant lineage; renaming the key is state migration, the stateful twin of an in-token session claim). Both implement one invariant: new dance = new session, one instance = one continuous identity. What this is, named plainly: a session cookie. The spec removed Mcp-Session-Id, but Authorization is the one header clients must still store and replay on every request — the last cookie jar in MCP — and the rig moves the remembering into the client's credential storage, which is the durable kind (survives restarts; the actual win). The infinite lifespan is safe precisely because the token's security function is vestigial — the tailnet is the lock, the token a name tag — and becomes wrong the moment the endpoint would face the public web. Symmetry with the shipped design: session state still expires by silence; an eternal credential outliving its session state is reborn under the same key, exactly today's adopted-unknown-sid semantics. The whole rig is the legacy design with mint moved from initialize into the auth dance. Unchanged from above: the client-behavior dependency (shared credential storage collapses concurrent instances into one cookie) and handoff-hostility (assuming a session = copying the credential). Established as feasible, not endorsed — the cost of a session id in tool calls may simply be worth paying.

    Where Remontoire sits in the space the revision was drawn for, stated plainly to close the record (2026-08-06): at the opposite pole from the deployment statelessness enables, and legitimately so. The stateless core exists for servers that are interchangeable windows onto state living elsewhere; here the state is the server — the registry holds live Task objects, scratch namespaces are interned in this runtime, res 12 is an object identity in this heap, and the conversation is about this JVM. Round-robin is not merely unsupported, it is meaningless: a second instance would answer about a different world, and serialization cannot rescue what is unserializable by premise — access to the running, the loaded, the live is the product. This is conformant: protocol statelessness means the protocol does not carry the session, not that the server has none. Restart is the already-lived semantics, sharpened: expiry-by-silence made sessions ephemeral from birth, and a restart is the same cliff, total — clients reborn empty under adopted ids. One nuance, raised and then rightly narrowed: a signed credential could survive the cliff (verifiable by key alone, no table) — but only if the key material is durably stored, and the coherent choice here is that it is not. Opaque token in a defonce atom: authentication is membership, not decoding, and "not in the atom" means "not authenticated." That makes credential mortality match content mortality, which is the honest semantics — the signed variant's continuity-of-identity would sail a tenant through the restart recognized, to discover piecemeal that every id dangles and every def is gone; the atom variant makes the discontinuity loud at the door (401, the world you knew is gone, dance again), the same principle as the bare-request 400 and drop!'s loud ghost-eval failure. Identity that outlives everything it names is not continuity but a haunting. Noted with some satisfaction: opaque-token-in-an-atom is literally the shipped sid machinery — UUID minted, defonce atom, client replays per request, unknown-at-the-door handled there — relocated from Mcp-Session-Id to Authorization. The credential-space option at its simplest implementation reconverges on what is already built, wearing a different header; the genuine deltas are who does the dance (auth flow vs initialize) and which client storage remembers (credential store — durable — vs MCP session state — connection-lived).

    The modern-era design, settled pending measurement (2026-11-03). The trigger named above — a modern client to measure against — has arrived: Claude Code follows io.modelcontextprotocol/tasks against a server that answers long work as tasks. Taking tasks means taking the stateless era (the extension is declared per request in _meta, and that era has no Mcp-Session-Id), so the session question is no longer separable; it resolves to the explicit-mint candidate. The design:

    • create-session, the first call, mints the handle every other tool requires; a call without one, or with one the server does not hold, is a tool error whose text teaches the incantation. Unknown handles are refused, not adopted — a handle that outlives its session is the haunting the paragraph above names. The handle is three random letters, redrawn against live sessions at mint: short to repeat per call, and sparse, which is what an integer counter is not — in a dense space a typo or a pre-restart handle lands silently in a neighbour, in a sparse one it misses and fails loudly. Guessability is irrelevant (the tailnet is the lock). Every footer echoes it. MCP task ids are the handle plus the entry id (ujw-12); entry ids stay integers for res.
    • Capabilities are recorded at create-session — for the teaching surface only: (__r/manual) serves the variant matching what the session declared. What a client is sent follows each request's own _meta: the tool list varies with the declaration on the tools/list request (which precedes any session), and whether a call answers with a task follows that call's declaration — an undeclared request never receives one.
    • The principle: when the client follows tasks, trust its task machinery over Remontoire's own tools. Waiting and cancelling are the harness's business — await all, wake on any, stop — so a declaring client is listed create-session and eval with code, ns and background: no await parameter, no await or cancel tool. Listed beside tasks, await would itself background and hand the model two handles to one piece of work. The unlisted handlers stay live for clients holding a stale list, and the registry stays inspectable — (evals), res, q/await/q/cancel on an entry from eval — underneath, not instead.
    • A fixed 10 s grace replaces the caller's deadline: anything settling inside it answers inline, anything longer becomes a task — well inside every client timer measured (§4), so neither the 60 s first-byte limit nor the 120 s backgrounding is ever approached. tasks/get is a registry lookup: working while running, the eval's latest output line as statusMessage (the heartbeat's display, carried over), completed with the rendering the eval would have returned (isError for a failure), cancelled for cancellation; delivery marks fire after the write as they do for any response, and a client-followed entry is left out of ;; settled news. tasks/cancel is a real q/cancel. pollIntervalMs 1000 — polling scales with running tasks, not connected clients, and the interval only sets delivery latency. A task answer has no content; the entry id rides statusMessage (;; id 12 · running in the background) so the model can bridge to the registry if the client shows it.
    • background skips the grace for work the caller knows is long — a polling loop it means to read and cancel later. The grace exists to spare quick work a task, and a caller who knows better should not pay 10 s for it. It is a boolean, not a deadline: the one choice the tasks path leaves the model is whether to wait at all, and work that has settled by the time it is checked still answers inline.
    • Non-declaring modern clients keep today's running path under the handle; legacy support can be dropped once every client in use is confirmed to speak 2026-07-28.

    To measure in the spike: whether Claude Code declares the extension on tools/list as well as tools/call; what the model sees when a call becomes a task (does statusMessage reach it); whether TaskStop sends tasks/cancel; the client's actual poll cadence; and, once, what a task answer after the client's own 120 s backgrounding does.

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