Skip to content

feat(mcp): spend a declared credential and name a per-session wiring path (CLOUD-1261, CLOUD-1251) - #802

Merged
wenzowski merged 2 commits into
mainfrom
claude/mcp-credential-85xadi
Sep 1, 2026
Merged

wenzowski merged 2 commits into
mainfrom
claude/mcp-credential-85xadi

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-1261
Closes CLOUD-1251

batten mcp call landed in #799 able to dispatch and reduce, and unable to
authenticate. It is now a working client: measured 2026-09-01 against the live
tracker connector, from this repository's own committed config with no fixture.

$ batten mcp call <server> get_issue '{"id":"CLOUD-1251"}'
batten: mcp call: response:47d1cffb… get_issue via claude-code-remote reduced — stored 19980 bytes, emitted 670

Across four live get_issue calls: 63,140 bytes of payload for 1,670 bytes of
context, 97.4%.
The issue in the run above is CLOUD-1251 itself — the row that
recorded this path as unspellable.

Why the 401 stood for as long as it did

The wiring file's three X- headers are routing identifiers, not
authorization
. CLOUD-1260's Ready block asserted the opposite ("those headers
ARE the credential"
), CLOUD-673 concluded from the same 401 that the headers
were not authorization and stopped there, and my own first pass on this branch
wrote the gap up as a blocker instead of closing it. The endpoint publishes no
WWW-Authenticate, so every probe — three with curl on CLOUD-673, one with
hyper here — bottomed out at a bare status with nothing to act on.

What it wants is Authorization: Bearer <token>, where the token is written to
a file whose path the launcher exports under a named environment variable.

What changed

[mcp.source.credential] — a credential named, never carried. env names a
variable holding the value; file_from names a variable holding a path to it.
The two-hop spelling is the one a harness actually offers: a launcher that mints
a per-session token writes it to a file and exports the path, so the path is not
writable in advance while the variable name is. That is [[rule.external]]'s
root discipline applied to key material, and it lets a committed config say
which credential a server needs while remaining structurally unable to carry
one.

Containment is structural, not remembered. Secret has no Display, no
as_str, no into_inner; its Debug prints a fixed marker, so a derived
Debug over Wiring cannot reach the value either; and the single method that
yields its bytes feeds the transport's header list and nothing else. The
assertion runs on the failure path — the case dispatches at an unresolvable
endpoint so the credential is read and folded into a header before the run
fails, which is the window a happy-path assertion never opens.

${VAR} expansion in a source path, plus base = "temp". CLOUD-1251
recorded three fatal objections to naming the per-session wiring. All three were
objections to a glob and none survives expansion: the launcher exports the
session id, the match count is exactly one by construction, the engine expands a
variable rather than walking a filesystem, and the id is the row's own. The glob
is still declined; this is not one. base = "temp" covers the one directory an
operator has no variable for — TMPDIR is unset on many hosts, so
root = "TMPDIR" would silently fail to resolve exactly where the file is.

A 401 now says what the server wants. A non-2xx reports the
WWW-Authenticate scheme token only, uppercased — never its parameters, which
carry a realm and an error description and are the operator's content.

Refusals, all at the right layer

case when answer
credential row names neither env nor file_from, or both load exit 1
base and root both declared load exit 1
malformed ${ or a non-name placeholder load exit 1
named variable unset or empty dispatch exit 3, naming the variable
credential file will not read dispatch exit 3, naming the variable, never the path
expanded path escapes its base dispatch exit 3, PathUnusable, carrying no path
no [mcp.source.credential] — byte-identical to before this key existed

escapes runs twice — once at load over the literal, once over the expanded
path. The load-time check sees ${ID} and not what ID holds, so a value
carrying an upward step is the one way this could have become a file-read
primitive. Unresolved gains two arms rather than reusing Unreadable, because
"the wiring is fine and an environment variable is not" sends a reader to a
different file than "this will not parse".

Tests

Module tier: expansion (no-op, bare $ literal, malformed, unset, second escape
check), credential resolution and each refusal, Secret and Wiring Debug
containment, scheme folding, challenge parsing.

Compiled-binary tier in crates/batten/tests/mcp_dispatch.rs: every refusal
above over the real binary, plus a resolved credential reaching no output on
any path
, and the byte-identical arm.

One correction there too. That suite's header claimed a loopback listener was
unreachable by construction — an argument for never trying. fetch.rs reads
SSL_CERT_FILE and adds to the vendored roots, so it is reachable, and the
compiled binary drove a full streamable-HTTP handshake against one on
2026-09-01 (initialize, Mcp-Session-Id round trip, tools/call, SSE
framing, reduction) over a genuine 23,371-byte tracker payload. What bounds that
suite is cost, not impossibility, and the header now says so.

Surface

mcp call stays Effect::Unclassified and its stated reason now names the
credential reach separately from the network reach — a reader who priced the
outbound call may still not have priced a verb that reads a token out of a file
an operator named. Both keep it off house-style §5's derived read-only
allowlist.

Not in this PR

Minting, rotating or revoking credentials — the operator's tooling does that.
Re-measuring CLOUD-1260's 73% → 15% acceptance on a full session, which is now
reachable but is that row's to measure.


Generated by Claude Code

@linear-code

linear-code Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
CLOUD-1261 No verb gives the engine a credential to SPEND: `provision` installs binaries and `secrets` only ever suppresses, so `batten mcp` has no way to authenticate the call it dispatches

Why

CLOUD-1260 has Batten dispatch an MCP call itself so it can reduce the response. Dispatching means authenticating, and nothing in the surface can hand the engine a credential to authenticate with. This row is that missing surface.

SCOPE CORRECTED 2026-08-31, and the correction is what unblocked CLOUD-1260. This row was filed as that row's blocker. **It is not one. **CLOUD-1260 dispatches from INSIDE the session that would otherwise have made the call, and the config file its Layer 2 already resolves carries that session's own headers — so v1 authenticates with a credential that is on the machine and in scope, and needs nothing from here. What this row owns is the strictly larger case: a DURABLE credential, usable outside the session that minted it. That is a real surface and worth building; it is not a precondition, and treating it as one parked the measured 13.2 MB win behind an unbuilt mechanism — the punt shape AGENTS.md names outright. Dropped to Medium and unblocked accordingly.

Two things were mistaken for it, and both are checked rather than assumed:

  • batten provision is "Pinned tools this repository provisions, cached out of tree" — status and apply, which "fetch, verify against the pinned checksum, and install into the out-of-tree cache." That is binaries. A draft of CLOUD-1260's design cited it as the existing route and was wrong.
  • crates/batten/src/secrets.rs is "Secret-class scanning: key custody, and the adapter that keeps a matched byte from ever leaving the process." That is custody of a hashing identity key used to fingerprint findings — CLOUD-311's and CLOUD-529's subject. It proves Batten can hold key material safely; it is not a route for supplying one to a call.

So the gap is precise: there is a credential Batten must SPEND, and every existing mechanism is about credentials Batten must SUPPRESS. Those are opposite directions and the second does not imply the first.

Why Batten is the right custodian, which is the part that looks backwards

The instinct is that a gate holding a credential is a widening of its blast radius. Measured against this repository's actual arrangement, it is a tightening:

  • The threat model is honest error — the wrong entity, time, or completion signal. The gate is the trusted component; the model is the untrusted one, which is what rule 4, protected, and the whole mediated boundary exist to constrain.
  • Containment is enforced by types, not discipline. identity::SecretSpan "has no route back to a &str", and rules::Finding carries no span field, so "pointer-only output is structural … rather than a property of the renderer." A component built to be incapable of emitting key material is the right place to put some.
  • The status quo is leakier. Today the connector's headers sit in /tmp/mcp-config-cse_<session>.json, mode 0600, readable by the agent — a session read them on 2026-08-31. Moving custody into the engine takes the material out of the model's reach. Leaving it where it is, is the risk; moving it is the mitigation.

What the material actually is, measured

~/.claude/.credentials.json is 15 bytes holding an empty mcpOAuth map, so there is no local OAuth store in play. The injected config's servers carry X-MCP-Server-ID, X-MCP-Server-Origin and X-Session-UUID and no bearer token — the endpoint authenticates the SESSION.

That is the hard constraint and it is what divides this row from CLOUD-1260: the live credential is session-bound. IN-session it is perfectly usable, which is why CLOUD-1260 needs no new surface. It cannot be carried OUT of the session — not cached, not reused tomorrow, not handed to a scheduled run — so anything wanting dispatch beyond the minting session needs a credential supplied by an operator, and that is a new surface rather than a lookup. This row is exactly that case and nothing narrower.

Concrete consumers, so this is not speculative: a scheduled or CI-side reduction with no interactive session behind it; a land lap that wants a board read (CLOUD-673 measured that exact 401 — "a task cannot authenticate to the session's own MCP endpoint"); and any harness whose config this repository cannot reach. Each is real and none of them gates CLOUD-1260.


Refinement — Ready (a credential the engine can spend, held so the model cannot read it)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Source of truth (§1). ** The operator, out of band. Never the session, never a scavenged header, and never a value committed to the repository — non-negotiable rule 1 keeps account-specific material out of crates/batten and batten.toml alike. batten.toml may name which credential a declared server needs; it may never carry one.
  • **Computable predicate (§2). ** A declared server either has a resolvable credential or it does not, and the three answers stay apart: resolvable → dispatch; not configured → the call is not dispatched and the tool passes through untouched, byte-identically to no-Batten; configured but unreadable → could-not-look, exit 3. The middle case is the one that makes CLOUD-1260 safe to ship incrementally.
  • **The negative half is the load-bearing one (§2). ** State as a predicate and test directly that no command, finding, log line, error message or policy input can render the credential — including the failure paths, which is where a secret escapes. secrets.rs's structural containment is the model: make it unrenderable by type rather than by remembering to redact.
  • **Deliberately not in scope (§2). ** Minting, rotating or revoking anything — the operator's tooling does that. Storing a credential for any purpose other than a declared outbound call. Becoming a secret manager, which the scope reminder's "not a secret scanner" clause is the near neighbour of.
  • **Effect (§3). ** A new effect class, declared rather than smuggled: the verb reads operator-supplied material and spends it on a network call. House-style §5's read-only allowlist does not cover this and must be amended in the same change, per rule 2.
  • **Output & exit (§5). ** Pointer-only, and it is the whole point here: configured / not configured / unreadable, a server id, never a byte and never a prefix. A "first four characters" convenience is the exact affordance to refuse.
  • **Commit / bump (§6). ** feat(surface) — patch until 0.1.0.
  • **Test obligation (§7). ** Compiled-binary tier in crates/batten/tests/*.rs, never a .bats (V-SHELL-RULE-ADDED). Shown able to fail per CLOUD-418: a configured credential dispatches; an unconfigured one passes the call through byte-identically to no-Batten; an unreadable one is exit 3 rather than a silent pass; and — the case that matters — the credential appears in no output on any path, asserted over the error paths too, since a happy-path-only assertion is how a redaction gets shipped that leaks on failure.
  • **Blockers (§8). ** None, and it blocks nothing — the blockedBy edge on CLOUD-1260 was removed 2026-08-31 because that row's v1 authenticates in-session. This is a follow-on that widens where dispatch can happen, not a precondition for it happening at all. relatedTo CLOUD-311 and CLOUD-529 (identity-key custody — the nearest prior art, and a different direction), CLOUD-178 (where the session-bound headers and the empty mcpOAuth map are recorded), CLOUD-1251 (which resolves WHERE the server config is).

Acceptance

  • The engine can authenticate a declared outbound call with operator-supplied material.
  • The credential is unrenderable by construction, asserted on the error paths and not only the happy one.
  • An unconfigured server is byte-identical to no-Batten — proven, since a silent behaviour change here is the failure that would make the whole reduction untrustworthy.
  • House-style §5's effect model carries the new class, in the same change rather than after it.

Not in this issue

Reducing anything — that is CLOUD-1260's. Rotation, revocation and minting. The IN-SESSION dispatch path, which needs no credential from here and must not wait on this row.

Refs: CLOUD-1260, CLOUD-311, CLOUD-529, CLOUD-178, CLOUD-1251, CLOUD-418

CLOUD-1251 `[[rule.external]]` declares ONE path under ONE root, so a set of out-of-root files discovered at runtime is unspellable — and the id cannot be written in advance because the name is minted per session

Why

CLOUD-1167 landed input.tree.external and it is the right shape for what it was built for: a consumer declares a root environment variable and a path beneath it, and the engine projects the parsed node under the declaring row's own id. The generated schema/batten.schema.json states the bound as the design rather than as a limitation — "A module reads the ids ITS OWN row declares and nothing else, so no module can read a path no row names — which is the difference between a fact and a filesystem scanner".

That bound is correct AND it makes one real consumer unreachable, which nothing currently records.

mise-tasks/connector-allow-resolve.sh:142 resolves the live MCP configuration by globbing /tmp/mcp-config-cse_*.json. The suffix is minted per session by the launcher. So:

  • the path is not known when batten.toml is written, which is when an external row must name it;
  • the number of matches is not one, and external is keyed one id to one parsed node;
  • and the row's id — the key a module reads — cannot be authored in advance for a file whose name does not yet exist.

Each of the three would be fatal alone. This is not "a path outside the root", which CLOUD-1167 solved; it is a set discovered at runtime, which is a different question and was never asked.

A SECOND named consumer, found 2026-08-31 — and it is not a variation on the first

CLOUD-1260 needs the same file for the opposite reason. connector-allow-resolve reads /tmp/mcp-config-cse_*.json to decide whether a connector is allowed; CLOUD-1260's config-resolution layer reads it to learn where to dispatch — the server's transport, endpoint and headers, so Batten can be the MCP client and reduce a response the model would otherwise pay in full. Same unspellable path, same per-session mint, two unrelated purposes.

Why a second consumer changes this row rather than merely lengthening it. With one consumer, answer 3 ("unit 4 does not migrate") is cheap and arguably correct — a launcher-specific discovery loop staying in a launcher-specific program. With two, answer 3 stops being a bounded verdict: it would decline a fact that a second, unrelated subsystem also requires, and the same globbing loop then gets written twice outside the engine. That is the argument FOR answer 2 getting stronger and answer 3 getting weaker, and it is evidence rather than preference.

The other installs are already spellable and that bounds the row usefully. Claude Code local is $HOME/.claude.json — root = "HOME", one path, one node, exactly what CLOUD-1167 landed. Cursor's .cursor/mcp.json is repo-relative and is not an external question at all. Only the remote-session case is unspellable, so whatever answer wins here is what stands between CLOUD-1260 and working on Claude Code remote; every other supported install needs nothing from this row.

How this was found, and why the reason matters more than the instance

CLOUD-1163 recorded its unit 4 (connector-allow-guard, connector-allow-resolve, mcp-allow-check — 9.5s, 3 gates, 4 #MUTANT rows) as "blocked, out-of-root", and CLOUD-1167 is Done. A reader checking only the blocker's status would conclude the unit is unblocked and dispatch it, and an implementer would then discover mid-build that the fact they were sent to use cannot name their file. That is the class this row exists to stop repeating: a blocked verdict whose stated reason is answered while the actual obstruction is not.

The decision, which comes before any design

Do not assume this should be built. Widening external toward a glob is exactly the move the schema's own text refuses — "the difference between a fact and a filesystem scanner" — and non-negotiable rule 1 and house-style §5's read-only allowlist both sit on that line. Three candidate answers, and the row picks one on evidence:

  1. A DECLARED GLOB under a declared root, projecting id → a map of matched basename to parsed node. Keeps the root variable and the declaration; widens only the leaf. The consumer still cannot reach a directory no row names, so the scanner objection is answerable — but the id's arity changes from one node to many, and every module reading it must handle the empty match as could-not-look rather than as absent.
  2. A PRODUCER writes the resolution into the record store, and the module reads input.tree.records — the same shape CLOUD-1154 used for forge data and CLOUD-1170 chose for liveness. The globbing stays outside the engine, where a discovery loop belongs, and the engine reads what was written. Cheapest, and consistent with two landed precedents.
  3. Unit 4 does not migrate. MCP wiring resolution is launcher-specific discovery; the scope reminder's "not a hook runner" clause is arguable here. A recorded verdict, and CLOUD-1080's withdrawal arm is how it lands.

Answer 2 is the likeliest and answer 1 is the one to argue against, because it is the one that looks like a small change.


Refinement — Ready (decide the shape; build nothing until it is decided)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Authority boundary (§1). **crates/batten/src/facts.rs and the [[rule.external]] declaration in batten.toml, if answer 1 wins; a [[recorder]] row and its producer if answer 2 wins; neither if answer 3 wins. Second tier in crates/batten/tests/*.rs. **No **mise-tasks/ **program and no **tests/**/*.bats is added or edited — V-SHELL-RULE-EDITED and V-SHELL-RULE-ADDED refuse both.
  • Computable predicate (§2). A module declaring a runtime-discovered out-of-root set reads exactly the files the declaration reaches, and a declaration that matches nothing resolves to could-not-look rather than to an empty set — the input.tree.missing discipline, which is the whole safety property here: a glob matching zero files and a launcher that wrote no config are byte-identical on the decision surface unless the engine distinguishes them.
  • The negative half is the load-bearing one (§2). Whatever lands, a module must still be unable to read a path no row's declaration reaches. State the bound as a predicate and test it directly, because "it is still declared" is the claim a widening most easily loses while looking correct.
  • Deliberately not in scope (§2). Retiring unit 4 or any of its three programs — that stays CLOUD-1163's. Widening documents (repo-relative) by the same argument; it is a different surface with a different glob gate. Reading an environment variable's VALUE rather than a file beneath it, which is CLOUD-1202's class and already decided against.
  • **Effect (§3). **read under answers 1 and 2 — a declared file read and a record read are both reads. Answer 3 has no code. Nothing spawns; evaluator-io-check stays the gate.
  • **Generated artifacts (§4). **schema/policy-input.schema.json and schema/batten.schema.json regenerate if a declaration or projection changes (CLOUD-879). mise run fix; never hand-edit.
  • Output and exit (§5). Pointer-only: the declaring row's id and the count it matched, never a resolved absolute path — a resolved path is a machine's home directory, which is the reason external is keyed by id rather than by path in the first place. Exit follows the 0/1/2/3 table; an unreadable declaration is 3, never a false 2.
  • **Commit / bump (§6). **feat(facts) — patch until 0.1.0, if anything lands. docs and no bump if answer 3 is taken.
  • Test obligation (§7). Over the compiled binary, because a with input as case fabricates the very shape the engine may be unable to produce (CLOUD-845). Shown able to fail per CLOUD-418, three discriminating observations: a declaration matching two files reads both; a declaration matching none is could-not-look and is distinguishable from one matching an empty file — the anti-vacuity mirror, and the case a widening loses first; and a path no declaration reaches stays unreadable by any module.
  • Blockers (§8). None. **It blocks **CLOUD-1260's config-resolution layer on Claude Code remote sessions ONLY — the local installs are spellable today, so that row is not gated on this one in general. It also blocks CLOUD-1163's unit 4. relatedTo CLOUD-1167 (the fact this extends), CLOUD-1154 and CLOUD-1170 (answer 2's two precedents), CLOUD-1080 (answer 3's landing shape), CLOUD-418.

DECIDED 2026-09-01 — answer 2, and a correction to this row's own premise

Answer 2 is taken: a producer resolves the runtime-discovered set outside the engine, and a module reads what was written. The engine does not grow a glob. Recorded in the tree beside the rows it bounds — batten.toml's [mcp] section and crates/batten/src/mcp.rs's module header — rather than only here, because the reader who hits the limit is reading those.

Cost of rejecting answer 1 (a declared glob under a declared root): it is the one that looks like a small change and is not. An id's arity goes from one node to many, every module reading it must handle the empty match as could-not-look rather than as absent, and what is spent is the schema's own line — "the difference between a fact and a filesystem scanner". That line is the whole safety property input.tree.external was built to state.

Cost of rejecting answer 3 (decline outright): CLOUD-1260 arrived as a second, unrelated consumer of the same file, so declining would leave one globbing loop written twice outside the engine — the argument this row already makes, and it holds.

The correction, and it changes what answer 2 can deliver

This row expects the second consumer to STRENGTHEN answer 2. It cannot be served by answer 2 at all, and the reason is structural rather than a matter of effort. crates/batten/src/facts.rs's Sourced states it outright: "No byte of it reaches THIS record. rows_in reduces the buffer to a COUNT at the boundary and the count is what reaches disk" — the whole record family is payload-free BY CONSTRUCTION, which is exactly right and is why it can sit on Surface::Hook at all.

A count answers a module deciding a predicate. It cannot carry an endpoint and headers, which is what DISPATCH needs. So:

  • Consumer A — unit 4's permission question (is this connector allowed?) is served by answer 2, and CLOUD-1163's unit 4 is buildable on it.
  • Consumer B — CLOUD-1260's remote dispatch is NOT, and never was. It needs the engine to read content, which is answer 1's shape or an extension inside the [[mcp.source]] family rather than the fact family. That is now a known gap with a stated cause rather than an assumed solution.

What deliberately did NOT land

No [[fact]] or [[recorder]] row was declared. Its only reader would be a module that has not been written — unit 4's migration is CLOUD-1163's and is explicitly out of this row's scope — and a declaration nothing reads is the dead gate this repository refuses everywhere else. The shape is available when unit 4 gets there; declaring it early would ship coverage.

Because nothing mechanical landed, acceptance clauses 2 and 3 are answered by their own precondition ("if anything lands") rather than skipped: the engine gained no glob, so a path no row's declaration reaches is still unreadable by any module for exactly the reason it was before — asserted by crates/batten/tests/mcp_dispatch.rs, which pins that an unmatched server and an undeclared source set stay distinguishable and that no refusal carries a resolved path.

Acceptance

  • One of the three answers is taken in writing, with the cost of the two rejected ones.
  • If anything lands, a zero-match declaration is could-not-look and is asserted distinguishable from a match on an empty file.
  • A path no declaration reaches is still unreadable by any module, asserted rather than reviewed.
  • CLOUD-1163's unit 4 carries the resulting verdict — buildable, or recorded as not migrating.

Found while pressure-testing whether the retirement campaign was 100% unblocked: CLOUD-1163 named unit 4's blocker as out-of-root, that blocker is Done, and the unit is still blocked for a reason no row held.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 27 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 40979576-e22a-42a0-b1ab-ecbeb960c059

📥 Commits

Reviewing files that changed from the base of the PR and between a6013ff and 422982a.

📒 Files selected for processing (5)
  • batten.toml
  • crates/batten/src/mcp.rs
  • crates/batten/src/surface.rs
  • crates/batten/tests/mcp_dispatch.rs
  • schema/batten.schema.json

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/mcp-credential-85xadi branch 3 times, most recently from 4696106 to a89f7a6 Compare September 1, 2026 05:44
wenzowski added a commit that referenced this pull request Sep 1, 2026
… wiring (CLOUD-1251)

The remote-session wiring was recorded here as undeclarable, on three
objections: the path is a glob, the match count is not one, and the id cannot be
authored for a name that does not yet exist. All three were objections to a
GLOB and none of them survives expansion. The launcher exports the session id,
so a row spells it where it appears in the filename; the match count is exactly
one by construction; the engine expands a variable rather than walking a
filesystem, which is `[[rule.external]]`'s discipline unchanged; and the id is
the row's own. Reading a refusal of one shape as a refusal of the family is
what kept this undeclared. The glob is still declined and this is not one.

`base = "temp"` is the other half, and it exists because the OS temp directory
is the one location an operator has no variable for. `TMPDIR` is unset on a
great many hosts, this one included, so `root = "TMPDIR"` would silently fail to
resolve exactly where the file is; the platform defines the directory and the
engine asks it, rather than a config spelling a literal `/tmp` that is wrong on
Windows anyway. It is a closed set of one, because a member the platform does
not define would be this crate learning a harness's layout.

Only `${NAME}` is a placeholder. A bare `$NAME` stays a literal: a path may
contain a `$`, and guessing where such a name ends is how one spelling becomes
two. An unclosed `${`, or a name that is not `[A-Za-z_][A-Za-z0-9_]*`, is
refused at load — reading either as text would resolve a path nobody wrote. An
UNSET variable is could-not-look and skips the source rather than substituting
empty, which would turn `mcp-config-${ID}.json` into `mcp-config-.json`: a real
path, almost certainly somebody else's, reported as an absent file.

`escapes` now runs TWICE, and the second run is what keeps this from being a
file-read primitive. The load-time check sees the placeholder and not what the
variable holds, so a value carrying an upward step would otherwise walk out of
the declared base. `Unresolved` gains `PathUnusable` for both refusals, carrying
the source id and no path — rule 4 applies hardest to the arm whose whole
subject is a path somebody controls.

With this the repository declares its own session's wiring in its committed
config, credential included by name, and `batten mcp call` needs no fixture:
measured 2026-09-01, `get_issue CLOUD-1251` resolved via `claude-code-remote`,
exit 0, 19,980 bytes stored and 670 emitted — the row that called this
unspellable, fetched through the declaration that spells it.

Refs: CLOUD-1251, CLOUD-1261, CLOUD-1260

BREAKING CHANGE: `mcp::Source` gains a `base` field and `mcp::Unresolved` gains
a `PathUnusable` variant — the same two classes as the commit before it: a
struct literal outside the crate and an exhaustive match over the enum both stop
compiling.

Admits: 113b8bb4a5b81eb73751811a8df9796447158d05f2c6b9451d6fbc1be4c65a36
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: batten.toml
Admits-head: a89f7a6
Admits-epoch: b8c0c822c422174dc52af7212a26672e34e8e89607557ec1820a380701e0f0aa
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The declaration this whole change exists to make. Without the row, `batten mcp call` resolves nothing on a remote session and the repository still cannot reach its own connector — which is exactly the state CLOUD-1251 recorded as "unspellable" and this change closes. The credential surface (CLOUD-1261) would ship with no consumer, which is the dead-gate shape this repository refuses everywhere else.
Admits-answer-precondition: A `[[mcp.source]]` row and its `[mcp.source.credential]` table can only live in `batten.toml`: it IS the surface, and `crates/batten` may not carry a harness's config layout at all (non-negotiable rule 1, asserted by `mcp_dispatch.rs`'s own rule-1 test). So no other file can express "this repository's session wiring lives at this path with this credential", and the write lands in a pull request where a reviewer reads it in the diff — #802, where `mise run config-lint` passes over it at 0 smells.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is rejected because `batten.toml` IS the owning surface for a `[[mcp.source]]` row; there is no narrower one to route through, and putting the row anywhere else would violate rule 1. R-RESTORE-IT is rejected because the edit is the deliverable rather than accidental damage: restoring the file would revert the declaration and leave the gap CLOUD-1251 names.
@wenzowski
wenzowski force-pushed the claude/mcp-credential-85xadi branch from a89f7a6 to 4947bbc Compare September 1, 2026 06:01
`batten mcp call` could dispatch and reduce, and could not authenticate. The
endpoint every measurement was aimed at answers 401 to the wiring file's own
headers: those are routing identifiers, not authorization. It publishes no
`WWW-Authenticate`, so three separate probes — CLOUD-673's three with `curl`,
and one here with `hyper` — all bottomed out at a bare status with nothing to
act on, and two issues drew opposite wrong conclusions from it.

`[mcp.source.credential]` closes it by naming a credential rather than carrying
one: `env` names a variable holding the value, or `file_from` names a variable
holding a PATH to it. The two-hop spelling is the one a harness actually offers
— a launcher that mints a per-session token writes it to a file and exports the
path, so the path is not writable in advance while the variable name is. That is
`[[rule.external]]`'s root discipline applied to key material, and it keeps
`batten.toml` naming which credential a server needs while never being able to
carry one.

Measured 2026-09-01 against the live connector: three `get_issue` calls, exit 0,
43,160 bytes stored for 1,000 emitted — 97.7%, against the endpoint that had
refused an hour earlier.

Containment is structural rather than remembered. `Secret` has no `Display`, no
`as_str` and no `into_inner`; its `Debug` prints a fixed marker, so a derived
`Debug` over `Wiring` cannot reach the value either; and the single method that
yields its bytes feeds the transport's header list and nothing else. The
assertion is on the FAILURE path, where a secret actually escapes — the case
dispatches at an unresolvable endpoint so the credential is read and folded into
a header before the run fails.

A row that cannot mean one thing is refused at LOAD: naming neither variable
resolves to no header, naming both has no single answer, and either would
dispatch unauthenticated and produce a 401 that reads as the server's fault.
`Unresolved` gains a fourth arm rather than reusing `Unreadable`, because the
wiring is fine and an environment variable is not, and collapsing them sends a
reader to the wrong file. Every refusal names the VARIABLE and never the path it
expanded to.

An absent `[mcp.source.credential]` is byte-identical to before this key
existed, asserted directly — the clause that makes the key safe to add to a
landed feature.

Two corrections travel with it. A non-2xx now reports the `WWW-Authenticate`
scheme TOKEN only, uppercased, never its parameters, which are the operator's
content. And `mcp_dispatch.rs`'s header claimed a loopback listener was
unreachable by construction; `fetch.rs` reads `SSL_CERT_FILE` and adds to the
vendored roots, so it is reachable, and the compiled binary drove a full
handshake against one on 2026-09-01. What bounds that suite is cost, not
impossibility, and the header now says so.

Refs: CLOUD-1261, CLOUD-1260, CLOUD-673, CLOUD-1251

BREAKING CHANGE: `mcp::Source` gains a `credential` field, so the struct is no
longer constructible from outside the crate with a literal, and `mcp::Unresolved`
gains a `CredentialUnusable` variant, so an exhaustive match over it no longer
compiles. Both are the API surface of a config type a consumer builds by hand.
… wiring (CLOUD-1251)

The remote-session wiring was recorded here as undeclarable, on three
objections: the path is a glob, the match count is not one, and the id cannot be
authored for a name that does not yet exist. All three were objections to a
GLOB and none of them survives expansion. The launcher exports the session id,
so a row spells it where it appears in the filename; the match count is exactly
one by construction; the engine expands a variable rather than walking a
filesystem, which is `[[rule.external]]`'s discipline unchanged; and the id is
the row's own. Reading a refusal of one shape as a refusal of the family is
what kept this undeclared. The glob is still declined and this is not one.

`base = "temp"` is the other half, and it exists because the OS temp directory
is the one location an operator has no variable for. `TMPDIR` is unset on a
great many hosts, this one included, so `root = "TMPDIR"` would silently fail to
resolve exactly where the file is; the platform defines the directory and the
engine asks it, rather than a config spelling a literal `/tmp` that is wrong on
Windows anyway. It is a closed set of one, because a member the platform does
not define would be this crate learning a harness's layout.

Only `${NAME}` is a placeholder. A bare `$NAME` stays a literal: a path may
contain a `$`, and guessing where such a name ends is how one spelling becomes
two. An unclosed `${`, or a name that is not `[A-Za-z_][A-Za-z0-9_]*`, is
refused at load — reading either as text would resolve a path nobody wrote. An
UNSET variable is could-not-look and skips the source rather than substituting
empty, which would turn `mcp-config-${ID}.json` into `mcp-config-.json`: a real
path, almost certainly somebody else's, reported as an absent file.

`escapes` now runs TWICE, and the second run is what keeps this from being a
file-read primitive. The load-time check sees the placeholder and not what the
variable holds, so a value carrying an upward step would otherwise walk out of
the declared base. `Unresolved` gains `PathUnusable` for both refusals, carrying
the source id and no path — rule 4 applies hardest to the arm whose whole
subject is a path somebody controls.

With this the repository declares its own session's wiring in its committed
config, credential included by name, and `batten mcp call` needs no fixture:
measured 2026-09-01, `get_issue CLOUD-1251` resolved via `claude-code-remote`,
exit 0, 19,980 bytes stored and 670 emitted — the row that called this
unspellable, fetched through the declaration that spells it.

Refs: CLOUD-1251, CLOUD-1261, CLOUD-1260

BREAKING CHANGE: `mcp::Source` gains a `base` field and `mcp::Unresolved` gains
a `PathUnusable` variant — the same two classes as the commit before it: a
struct literal outside the crate and an exhaustive match over the enum both stop
compiling.

Admits: 113b8bb4a5b81eb73751811a8df9796447158d05f2c6b9451d6fbc1be4c65a36
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: batten.toml
Admits-head: a89f7a6
Admits-epoch: b8c0c822c422174dc52af7212a26672e34e8e89607557ec1820a380701e0f0aa
Admits-author: alec@wenzowski.com
Admits-prev: -
Admits-answer-lost: The declaration this whole change exists to make. Without the row, `batten mcp call` resolves nothing on a remote session and the repository still cannot reach its own connector — which is exactly the state CLOUD-1251 recorded as "unspellable" and this change closes. The credential surface (CLOUD-1261) would ship with no consumer, which is the dead-gate shape this repository refuses everywhere else.
Admits-answer-precondition: A `[[mcp.source]]` row and its `[mcp.source.credential]` table can only live in `batten.toml`: it IS the surface, and `crates/batten` may not carry a harness's config layout at all (non-negotiable rule 1, asserted by `mcp_dispatch.rs`'s own rule-1 test). So no other file can express "this repository's session wiring lives at this path with this credential", and the write lands in a pull request where a reviewer reads it in the diff — #802, where `mise run config-lint` passes over it at 0 smells.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is rejected because `batten.toml` IS the owning surface for a `[[mcp.source]]` row; there is no narrower one to route through, and putting the row anywhere else would violate rule 1. R-RESTORE-IT is rejected because the edit is the deliverable rather than accidental damage: restoring the file would revert the declaration and leave the gap CLOUD-1251 names.
@wenzowski
wenzowski marked this pull request as ready for review September 1, 2026 06:51
@wenzowski
wenzowski force-pushed the claude/mcp-credential-85xadi branch from 4947bbc to 422982a Compare September 1, 2026 06:51
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 422982a into main Sep 1, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/mcp-credential-85xadi branch September 1, 2026 07:10
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