Repository navigation
feat(mcp): spend a declared credential and name a per-session wiring path (CLOUD-1261, CLOUD-1251) - #802
Conversation
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:
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 backwardsThe 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:
What the material actually is, measured
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 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.
Acceptance
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 That bound is correct AND it makes one real consumer unreachable, which nothing currently records.
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 firstCLOUD-1260 needs the same file for the opposite reason. 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 How this was found, and why the reason matters more than the instanceCLOUD-1163 recorded its unit 4 ( The decision, which comes before any designDo not assume this should be built. Widening
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.
DECIDED 2026-09-01 — answer 2, and a correction to this row's own premiseAnswer 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 — 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 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 deliverThis 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. A count answers a module deciding a predicate. It cannot carry an endpoint and headers, which is what DISPATCH needs. So:
What deliberately did NOT landNo 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 Acceptance
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. |
|
Warning Review limit reachedNext included review available in 27 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (5)
Note 🎁 Summarized by CodeRabbit FreeYour 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 |
4696106 to
a89f7a6
Compare
… 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.
a89f7a6 to
4947bbc
Compare
`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.
4947bbc to
422982a
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
Closes CLOUD-1261
Closes CLOUD-1251
batten mcp calllanded in #799 able to dispatch and reduce, and unable toauthenticate. 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.
Across four live
get_issuecalls: 63,140 bytes of payload for 1,670 bytes ofcontext, 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, notauthorization. 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 withcurlon CLOUD-673, one withhyperhere — bottomed out at a bare status with nothing to act on.What it wants is
Authorization: Bearer <token>, where the token is written toa file whose path the launcher exports under a named environment variable.
What changed
[mcp.source.credential]— a credential named, never carried.envnames avariable holding the value;
file_fromnames 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]]'sroot 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.
Secrethas noDisplay, noas_str, nointo_inner; itsDebugprints a fixed marker, so a derivedDebugoverWiringcannot reach the value either; and the single method thatyields 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 sourcepath, plusbase = "temp". CLOUD-1251recorded 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 anoperator has no variable for —
TMPDIRis unset on many hosts, soroot = "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-Authenticatescheme token only, uppercased — never its parameters, whichcarry a realm and an error description and are the operator's content.
Refusals, all at the right layer
envnorfile_from, or both1baseandrootboth declared1${or a non-name placeholder13, naming the variable3, naming the variable, never the path3,PathUnusable, carrying no path[mcp.source.credential]escapesruns twice — once at load over the literal, once over the expandedpath. The load-time check sees
${ID}and not whatIDholds, so a valuecarrying an upward step is the one way this could have become a file-read
primitive.
Unresolvedgains two arms rather than reusingUnreadable, 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 escapecheck), credential resolution and each refusal,
SecretandWiringDebugcontainment, scheme folding, challenge parsing.
Compiled-binary tier in
crates/batten/tests/mcp_dispatch.rs: every refusalabove 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.rsreadsSSL_CERT_FILEand adds to the vendored roots, so it is reachable, and thecompiled binary drove a full streamable-HTTP handshake against one on
2026-09-01 (
initialize,Mcp-Session-Idround trip,tools/call, SSEframing, 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 callstaysEffect::Unclassifiedand its stated reason now names thecredential 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