Skip to content

feat(mcp): dispatch declared MCP calls and return reductions (CLOUD-1260, CLOUD-1122, CLOUD-1251) - #799

Merged
wenzowski merged 3 commits into
mainfrom
claude/mcp-reduction-bundle-85xadi
Sep 1, 2026
Merged

wenzowski merged 3 commits into
mainfrom
claude/mcp-reduction-bundle-85xadi

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Batten becomes an MCP client: it dispatches the call the session was going to
make anyway, stores the response whole, and hands back a pointer, a delta and a
declared reduction.

Closes CLOUD-1260
Closes CLOUD-1122
Closes CLOUD-1251

One PR because all three edit batten.toml and regenerate the same derived
schema, and CLOUD-1260 creates the [[mcp.result]] table CLOUD-1122 adds a row
to. Three commits, one per row.

Why

Measured over one session's own transcript on 2026-08-31: 43.5 MB of tool
content, of which tracker round trips were 13.2 MB — 73% of all tool output,
across 973 calls against 208 rows. Bash, Grep and Read together were 1.9 MB.
Reading the tree is nearly free; the connector was the entire cost, and a whole
document moved every time to convey a ~2 KB delta. That is non-negotiable rule 4
unenforced at the tool boundary.

The two cheap fixes were already priced and both fail: only 8% of issue bytes are
byte-identical repeats, and the connector's schema is additionalProperties: false, so there is no projection to ask for. What is left is to stop being a
bystander to the call.

What landed

feat(mcp)! — CLOUD-1260. batten mcp call <server> <method> [params]
resolves the wiring a declared [[mcp.source]] names, runs the JSON-RPC session
(initialize → initialized → tools/call), stores the response, and prints the
declared reduction. The transport was already vendored (CLOUD-745): fetch.rs
gains POST, response headers and spend, which runs a sequence on one
current-thread runtime. No feature was widened — rt-multi-thread is still not in
the graph.

feat(mcp) — CLOUD-1122. reduce = "acknowledge" on save_issue, which was
435 calls returning 4.98 MB on the measured session — 38% of the tracker's cost —
because a write echoes the whole stored description back seconds after the author
sent it.

docs(facts) — CLOUD-1251. The decision, in writing, with the cost of the two
rejected answers, plus a correction to the row's own premise.

Three things a reviewer should look at first

The stored shape is a fidelity requirement, not a preference. capture::find
resolves a stored response by a key at id, and that is how ready lint --issue,
claim check --issue and the board gates reach a payload without its bytes
entering context. Measured against this repository's own store: the harness files
the decoded content with id at the top level. Filing the JSON-RPC envelope
would have put id two levels down, every one of those lookups would have
silently resolved nothing over a full store, and nothing would have gone red.
mcp::payload takes MCP's own content-block framing off — protocol vocabulary,
not a tracker's schema — and is three-valued, so an unrecognised framing keeps its
bytes.

Closing the raw path is a gate, not a convention.
policy/connector-not-granted.rego refuses a permissions.allow entry granting a
tool a [[mcp.result]] row reduces, and the grants are dropped. What it does
not claim is registration: that happens where the launcher writes its wiring,
outside this repository and outside every gate here. Asserting it would be an
authority this repo does not hold, and harness-grant records the same boundary
one file over.

hook -> mcp is forbidden, not just hook -> fetch. The existing row keeps a
runtime off the mediated path (CLOUD-689's ceiling, CLOUD-747's bound). mcp
reaches fetch, and module-layering decides over direct edges — so without
the new row the guarantee was routable around in one hop.

Deviations from the Ready blocks, stated rather than absorbed

Both are recorded on CLOUD-1260 itself.

  1. Exit codes follow §7, not the row's §5. The row specifies 2 on a dispatch
    error; the house-style table reserves 2 for a policy verdict with no per-verb
    exception, and every host with a pre-tool hook reads 2 as deny — so a dropped
    network would have read as a refusal. Dispatch failure and an unreadable config
    are 3; malformed params are 1. AGENTS.md's "where they disagree the spec
    wins" is the tiebreak.
  2. No -J data channel on the verb. That contract is that a document is
    emitted unconditionally, and this verb's document is a server's answer, so
    there is none when the exchange did not happen. It takes exec's split: the
    reduction on stdout, the pointer and delta on stderr.

Two findings reported rather than repaired

Both are on their rows; neither is fixed here, because repairing landed code this
bundle did not scope would widen the PR.

  • CLOUD-1260's rule-1 acceptance is already red on main. It asks for zero
    hits under crates/batten/ for any tracker method name. Run over code with
    comments stripped, lib.rs declares READ_TOOL and WRITE_TOOL as crate
    constants naming two tracker methods. This change adds none of its own, and the
    compiled-binary tier asserts exactly that, scoped to what the change introduced.
  • .claude/rules/rust.md's concurrency section is stale — it still says
    tokio appears nowhere in Cargo.lock, which CLOUD-745 falsified.

Bounds

A completed dispatch is not hermetically testable: fetch is https_only and
nothing signs a loopback CA, which fetch.rs's own header already records for its
own case. The compiled-binary tier covers everything up to the socket — config
loading, wiring resolution, each refusal's exit class, and the pointer discipline
over every message. What a successful exchange returns is the module tier's, over
a response value. Claiming a live dispatch case would be coverage rather than a
test.

Scope amendment

Per non-negotiable rule 2, AGENTS.md's scope reminder is amended in the same
change: a component that dispatches calls and reduces responses is a real
expansion of a core that says it is "not a hook runner… or reference monitor".
The file sits on its budget ceiling, so the prose is net-zero lines — paid for by
compressing rules 7–8 and the memory paragraph rather than by raising the
threshold.

Verification

mise run verify — fast-forward-green, rebased on latest main, ci + cross +
commit-lint all pass. mise run policy-test — 419 passed across 37 bundles,
including four new cases pinning both directions of the layering edges.

Refs: CLOUD-1260, CLOUD-1122, CLOUD-1251, CLOUD-745, CLOUD-919, CLOUD-1121,
CLOUD-418, CLOUD-204

@linear-code

linear-code Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
CLOUD-1260 Tracker round trips are 73% of a session's tool output and no gate sees them: Batten should dispatch the MCP call itself and return a reduction

Why

Measured over one session's own transcript (79 MB, 22,377 lines) on 2026-08-31: 43.5 MB of tool content, of which tracker round trips are 13.2 MB — 73% of all tool output, across 973 calls against 208 rows. Bash + Grep + Read together are 1.9 MB. Reading the tree is nearly free; the connector is the entire cost.

tool calls MB why it is expensive
get_issue 616 8.27 no projection exists — additionalProperties: false, so every read is the whole body
save_issue 435 4.98 echoes the re-rendered body back seconds after it was authored
list_issues 131 0.78 already projected on 129/129 calls — not a problem

Two measurements from the same transcript rule out the cheap fixes, and both are recorded here so the next session prices them instead of re-deriving them:

  • Caching and reification are worth ~8%. Only 8% of issue bytes are byte-identical repeats. One row was pulled 22 times with 20 distinct digests. CLOUD-918/919's landed reification-by-handle cannot fire on 20 versions of one row.
  • A whole document moves to convey a ~2 KB delta. That is non-negotiable rule 4 — output is a pointer, never the payload — unenforced at the tool boundary, which is the thing this row fixes.

This is the successor to CLOUD-782's unremovable half. That row measured the same gap from the gate side, landed the transcript harvest that removed the second copy, and recorded the rest as structural: "the ~3x is structural and stays until the tracker offers a projection." The tracker never has to offer one. MCP is JSON-RPC 2.0, so Batten can be the client, own the response, and give the tracker an API it does not ship — a projection, a diff against the prior version, an acknowledgement instead of an echo. CLOUD-782 is Done and stays Done; this is the direction it could not see.

Not CLOUD-204. That decision record declined shipping an MCP server in v0.1. This is Batten as an MCP client, which is the opposite direction and touches nothing that record decided.


Refinement — Ready (dispatch the call, reduce the response, and close the raw path)

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

  • **Source of truth (§1). ** The consumer's batten.toml, and nothing in the crate. Every tracker identifier — the tool names, the field sets, the reduction chosen per tool — is a [[mcp.result]] row, because a tracker's vocabulary inside crates/batten is non-negotiable rule 1's violation. The crate knows only dispatch a declared method; reduce by a declared projection. Where a harness keeps its MCP config is likewise declared, never scanned: [[rule.external]]'s root is the name of an environment variable, and the schema is explicit that "the engine expands a variable; it does not scan."

  • **Computable predicate (§2). ** batten mcp call <server> <method> <params> constructs the JSON-RPC request, dispatches it, stores the full response in the capture store, and prints a pointer plus a delta. An undeclared tool passes through untouched. Exit 0 reduced, 2 on a dispatch error, 3 when the config source is present but unreadable.

  • **The landed precedent (§2). ** batten exec --capture-only already is "store the child's streams and report their handles instead of passing the bytes through" — the same shape over a different transport. The store is populated and resolvable by subject key today: 1,029 captures on the measured session, and capture find CLOUD-1148 --tool save_issue returns 24,691 bytes, exit 0.

  • **What this does NOT claim (§2). ** It is not an HTTP POST. The transport is type = "http" over https://, but MCP is stateful — initialize, capability negotiation, and an HTTP session lifecycle, which the host's own feature flags name (tengu_mcp_protocol_negotiation_http, tengu_mcp_stateless_skip_init). But the TRANSPORT is already vendored, and this clause used to say otherwise — corrected 2026-08-31 against the manifest. It read "sizing that client is the first implementation task", which a reader takes as choose an HTTP client. That decision is made: crates/batten/Cargo.toml:90-96 carries hyper, hyper-rustls**, **hyper-util **and **tokio as DIRECT dependencies, landed by CLOUD-745 when curl was retired. clippy.toml:22-31 says so in its own words — those rows "were to go live the day an HTTP client arrived. It arrived. tokio IS in the shipped closure now". So the first task is the MCP protocol layer on transport that already ships, not a vendoring decision.

  • **The runtime shape is DECIDED and GATED — do not re-litigate it, and do not widen the feature list (§2). ** At most one runtime per invocation, current-thread. clippy.toml:99 bans tokio::runtime::Builder::new_multi_thread, and the bound is stronger than the lint: the workspace tokio entry takes default-features = false **with only **rt, net **and **time, so rt-multi-thread is not compiled into the graph at all — clippy.toml:56-63 states that reaching for it "is a compile error rather than a lint, which is strictly stronger than the ban." tests/spawn_census.rs reads that feature list out of the workspace manifest and fails the moment either banned feature is enabled, so buying rt-multi-thread to make an async client easier is a red test, not a judgement call. Measured, current-thread costs +0.14 ms against a 100 ms ceiling where multi-thread costs +1.68 ms and grows a worker per core for one call's IO.

    **One caution for whoever takes this: **.claude/rules/rust.md is STALE on exactly this point. Its concurrency section still says *"there is no async runtime in the crate today: *tokio appears nowhere in Cargo.lock". Read the manifest and clippy.toml, never that sentence.

  • **Effect (§3). ** Network-bearing and write-bearing — declared, not smuggled in as a read**. It makes an outbound call and writes to the capture store. The precedent to follow is **enforce's, already in SURFACE at crates/batten/src/surface.rs:1746-1752: "a command that runs user-supplied code is listed unclassified with a stated reason, never guessed — so it is excluded from the derived read-only allowlist by construction." Take that shape rather than inventing a classification; house-style §5's read-only allowlist is DERIVED from this field, so an optimistic read here silently widens it.

  • **Output & exit (§5). ** Pointer-only per rule 4: a handle, a projection over declared fields, a delta, a count. The full body reaches the store and not the caller.

  • **The invariant, stated in its true form (§5). ** The model has no unreduced route to the payload BY DEFAULT. The strong form — no unreduced route — is false, and it is Batten that falsifies it: batten capture show <handle> --raw "write[s] the selected bytes to stdout verbatim", and --lines/--bytes/--grep select from the same store. That route stays, because a deliberate, single-purpose, visible retrieval is not the failure mode; 973 reflexive full-body reads are. What follows is an obligation rather than a caveat: --raw must be recorded when spent, the way an override is "a record, never a variable somebody knows". An unrecorded --raw is how a reduction silently stops mattering.

  • **Closing the raw path is NOT optional (§5). ** Dispatch and config resolution save nothing while a cheaper-looking path to the full payload sits in the model's tool list. An earlier draft rested this on "the convention of calling batten"; a convention is prose with no gate — rule 2's "prose is feedforward only" — and it is falsified by the measurement above, since the raw tools were registered and this session therefore called them 973 times. Two ways hold the invariant: (a) intercept the call where the host mediates MCP (rules::selects_tool_name at rules.rs:2549 already handles the mcp__<server>__ prefix; Cursor has beforeMCPExecution), or (b) remove the tool from the model's surface — the connector is not registered, Batten holds the endpoint and dispatches, and there is nothing left to intercept. (b) is the design, because it needs nothing from any harness and the invariant then holds by construction rather than by a rule that must fire correctly every time.

  • **The credential is already in the session, so v1 needs no custody surface (§2). ** An earlier draft of this row blocked itself on a new credential verb. That was wrong and it is the correction that makes this dispatchable. Batten dispatches from INSIDE the session that would otherwise have made the call, and Layer 2 already resolves the config file that carries the server's transport, endpoint and headers — X-MCP-Server-ID, X-MCP-Server-Origin, X-Session-UUID, no bearer token. Those headers ARE the credential, they are already on this machine, and the engine reading them is not a widening: the agent can read that file today and did on 2026-08-31, so moving the read into the gate strictly narrows who holds it. Using the session's own credential to make the call the session was going to make is not replay; it is the same call with a reducer attached. A DURABLE, cross-session credential is a genuine new surface and it is CLOUD-1261's — needed only if dispatch is ever wanted outside the session that minted the auth, which v1 does not need and this row does not do.

  • **Test obligation (§7). ** Two tiers, and the second is the one that discriminates. crates/batten/tests/*.rs over the compiled binary — never a .bats, which V-SHELL-RULE-ADDED refuses. Portability is acceptance test ci: check in the main-branch protection ruleset #1: the roster is batten generate hooks --harness's own value list, and passing only under claude-code is a failure. Transparency default (CLOUD-418's mirror): an undeclared tool, and an unresolvable config, both return byte-identically to no-Batten, or the reducer is a silent filter. Fidelity: ready lint, graph-check and claim check return identical verdicts through the reduced path — a changed verdict is a dropped field. Fail open, loudly: unreadable store, evicted blob, dispatch error each pass the payload through whole or error, because a reducer that silently truncates is a correctness disaster and not a saving. Rule 1 is a grep: zero hits for /tmp/mcp-config, .claude.json, .cursor/ or any tracker method name under crates/batten/.

  • **Blockers (§8). ** None. This row is dispatchable now, and the two rows once written here as blockers are follow-ons rather than preconditions — see "The credential is already in the session" above. CLOUD-1251 is needed only for the Claude Code remote config path; $HOME/.claude.json and .cursor/mcp.json are spellable today, so a local install needs nothing from it. CLOUD-1261 is the DURABLE-credential surface, which v1 does not use. relatedTo both, plus CLOUD-1122 (the write-side reduction this shares a [[mcp.result]] table with), CLOUD-782, CLOUD-685, CLOUD-418.

Measured 2026-09-01 while landing this: the rule-1 grep is ALREADY RED on main, before this change. The acceptance below asks for zero hits under crates/batten/ for any tracker method name. Run over code with comments stripped, crates/batten/src/lib.rs declares READ_TOOL and WRITE_TOOL as crate constants naming two tracker methods, and hook.rs names a harness's wiring directory. The MCP change adds none of its own — its compiled-binary tier asserts that, scoped to what the change introduced — but the wider claim this row's acceptance makes was not true when it was written. Reported rather than repaired: fixing landed code this bundle did not scope would widen the PR, and weakening the assertion to hide it would be worse than either. Whoever takes the repair owns deciding whether those two constants are a genuine violation or a sanctioned engine-side selector.

Two deviations from this Ready block, taken deliberately and recorded here rather than absorbed. (1) §5 specifies exit 2 on a dispatch error; the house-style spec's §7 table reserves 2 for a policy verdict with no per-verb exception, and every host with a pre-tool hook reads 2 as deny — so a dropped network would have read as a refusal. Dispatch failure and an unreadable config are exit 3, malformed params exit 1. AGENTS.md's "where they disagree the spec wins" is the tiebreak. (2) The verb does NOT declare the -J data channel: that contract is that a document is emitted unconditionally, and this verb's document is a server's answer, so there is none when the exchange did not happen. It takes exec's split instead — the reduction on stdout, the pointer and delta on stderr.

Acceptance

  • The invariant is measured, not asserted: replay the attribution script over a later transcript and assert zero unreduced tracker payloads on the default path, with any capture show --raw retrieval appearing as a recorded escape. A single full-size raw get_issue result means the raw path is not closed and the saving is notional — exactly the state measured here. "The agent should call batten instead" is not a passing result.
  • Tracker share of tool output falls from 73% to under 15% on a comparable session, re-measured with the same script rather than estimated.
  • (b) is shown to need no harness support — that is its whole claim, and it is what makes this portable rather than a Claude Code feature.

Not in this issue

Shipping an MCP server — CLOUD-204 declined that and this does not reopen it. The doctrine amendment AGENTS.md needs is real and named below, not deferred silently. graph-check's per-node reparse is CLOUD-634's.

Scope note, stated rather than absorbed. AGENTS.md says Batten is "not a hook runner, file-shape linter, secret scanner, AST linter, or reference monitor." A component that dispatches calls and reduces responses is a real expansion of the core. Per non-negotiable rule 2 the doctrine amendment lands in the same change, or the next session refuses this work on sight.

Refs: CLOUD-782, CLOUD-1122, CLOUD-685, CLOUD-1251, CLOUD-204, CLOUD-918, CLOUD-919

CLOUD-1122 Every `save_issue` echoes the whole stored body back into context, and the capture store already holds it — the write path pays a read it never asked for

Why

A save_issue returns the entire stored description. A patch write sends a few hundred bytes of anchors and gets a full body back — and that body then sits in context, re-sent every turn, for the rest of the session.

Measured 2026-08-28. A grooming session made roughly 45 save_issue calls. Bodies in this project routinely run 5–15k characters, so each write echoed on the order of a get_issue back. The write path is paying a read nobody asked for, on the one operation whose input the author already knows.

Re-measured 2026-08-31, and it is an order of magnitude larger than the first reading. Over one session's full transcript (79 MB, 22,377 lines): **435 **save_issue calls returning 4.98 MB — the write path alone is 38% of the 13.2 MB the tracker cost that session, and the tracker is 73% of all tool output. The 2026-08-28 figure was a partial session read as the whole; 4.98 MB is the number to price against.

CLOUD-415's body recorded this in passing — "save_issue echoes the entire issue description on every write, and the descriptions were long, and they were patched repeatedly" — as one of four contributors to a 758-turn session. No row owns it. It is named as a symptom there and nowhere as a subject.

Why this is not simply "ask for less"

The echo is not useless, and a naive "make it quieter" would break two live consumers:

  • CLOUD-815 needs the stored body to compare against the sent one — that comparison is the whole detection, and board-write-record reads .tool_response for it.
  • CLOUD-1118's fix wants the post-write body so a lint straight after a write decides over what was actually stored.

So the body must keep arriving at the boundary. What is redundant is the copy that arrives in context: CLOUD-919 already persists every PostToolUse response as a capture, so the bytes the agent is paying for are, by the time it reads them, already on disk and already parsed by the recorder.

That makes this the write-side twin of CLOUD-1121, which measured the same waste on the read side. Same store, same redundancy, opposite direction — and stated separately because the remedies differ: CLOUD-1121's is a read route, this one's is a request and response shape.

What this repository can and cannot decide

Stated plainly rather than discovered later, because it bounds the row:

  • **The response shape is the connector's, and no post-tool hook can narrow it. ** PostToolUse carries no updatedOutput (CLOUD-685), so nothing here can strip a body before it lands.
  • CORRECTED 2026-08-31 — the bullet above used to read "Batten cannot narrow it", and that was false. It is true only while Batten is a bystander to the call. MCP is JSON-RPC 2.0, so Batten can be the CLIENT — dispatch the request itself, own the response, and return a shape the connector does not ship. CLOUD-1260 is that direction, and reduce = "acknowledge" over save_issue — {id, status, handle}, never the re-rendered body — is the specific remedy for the 4.98 MB measured above. The old sentence is corrected in place rather than quietly deleted because it is CLOUD-1166's defect class caught on this repository's own board: a scoped limit (a post-tool hook cannot rewrite a result, which is CLOUD-685's true finding) restated as an unscoped one (Batten cannot narrow a response), which then read as a standing refusal of the design that answers it.
  • **What Batten can do is measure it and bound it. **CLOUD-925 made a per-CALL ceiling expressible, which is exactly the mechanism this needs and did not have when CLOUD-415 recorded the symptom.
  • Both outcomes are results. If the connector offers no narrower response, that is recorded as a measured limit with the evidence — the way CLOUD-673 recorded its 401 — and the ceiling ships as the sensor half regardless.

Refinement — Ready (measure the echo, bound it, and stop the agent re-reading what the recorder already has)

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

  • **Authority boundary (§1). ** batten.toml's per-call budget rows are the one authority for the ceiling; the capture store stays the one home for the stored body. No second copy of the response is kept and no second store is introduced — this row reads what CLOUD-919 already writes.
  • Computable predicate (§2). A per-call ceiling over the save_issue response, in the grammar CLOUD-925 established: a response whose body exceeds a derived threshold is reported, with the id and a byte count. Derived rather than typed, the same way timeout-check's p95 is, so it ratchets rather than rots. Deliberately a report and not a deny: the write has already succeeded by the time the response exists, and refusing after the fact would turn a completed board write into an error — the same reasoning that keeps board-write-record fail-open.
  • The narrowing question, answered by probe rather than by preference (§2). Whether the connector accepts a request that returns less — a projection, a minimal-response flag, or a write verb that returns only the id. Measured against the live surface and recorded with the result, not assumed either way. If one exists, the agent's per-write cost drops to a line; if none does, the ceiling is the whole deliverable and this row says so.
  • **Effect (§3). ** read. The ceiling inspects a response the boundary already holds; nothing gains a write and no command surface changes.
  • Output & exit (§5). Pointer-only, and load-bearing here: the report is an issue id and a byte count, never a byte of the body — a check whose subject is an oversized payload must not restate it, which is the failure CLOUD-417 names for the hooks themselves. Exit 0 always: this is a sensor on a completed write, so it renders no policy verdict and never exits 2.
  • **Commit / bump (§6). ** feat(policy) — patch until 0.1.0, since below that release-plz bumps the patch whatever the type says. Not ! for the consumer surface: the row is additive, no existing rule, exit code or output shape moves, and a consumer declaring no ceiling is byte-identical. mise run semver decides the library half, which a config row does not touch.
  • Test obligation (§7). Over crafted PostToolUse payloads so no live tracker is needed, shown able to fail per CLOUD-418: a response over the ceiling is reported with its id and count; one under it is silent; the report contains no substring of the body, asserted directly rather than assumed; and the anti-vacuity case a naive implementation fails — a response with no description at all must be silent rather than counted as zero, since an absent field and an empty body are different answers and collapsing them is CLOUD-691's measured shape.
  • Blockers (§8). None. relatedTo CLOUD-1121 (the read-side twin, same store and same redundancy), CLOUD-925 (the per-call ceiling this uses), CLOUD-919 (which already persists the response this stops the agent re-reading), CLOUD-815 and CLOUD-1118 (the two consumers that need the stored body to keep arriving at the boundary), CLOUD-782 (the read-side projection gap), CLOUD-415 (which recorded the symptom and owns the missing sensor) and CLOUD-526 (why the stored body, not the sent one, is the artifact that matters).

Acceptance

  • The echo is measured across a real grooming session and the figure is recorded, so the cost is a number rather than an impression.
  • A response exceeding the derived ceiling is reported, pointer-only, and shown able to fail.
  • Whether the connector can return less is answered with evidence, either way.
  • CLOUD-815's comparison and CLOUD-1118's post-write lint both still receive the stored body — proven, not assumed, since a fix that starved them would trade one defect for two.

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.

CLOUD-745 Stop shelling out to `curl` permanently: the measurement that justified it tested three `reqwest` presets and generalised past them

Regroomed 2026-08-29 — returned to Todo, because its work has NOT landed. The row was moved to In Review at 09:22 carrying one attachment, button-inc/batten#739 "feat(board): resolve a captured payload by issue key, and retire the two board gates that read one". That PR is CLOUD-1121's capture-spine work and implements no part of this row. Verified against origin/main 2f1be52: crates/batten/src/provision.rs:84 still reads const FETCHER: &str = "curl";, fetch_https still calls Command::new(FETCHER) at :551, and the two #[expect] reasons at :60 and :549 still say "GOES with CLOUD-745". Every acceptance clause below is still false. This is a fourth instance of the class CLOUD-1127 records — a merge advancing a column for work it did not land.

AND THE ROW GOT MUCH CHEAPER, because its open decision is now ANSWERED. §2 said the unresolved question was whether a links-free crypto provider plus vendored roots clears the Darwin link gates, and that nobody had ever resolved such a graph. Somebody has: crates/batten/src/fetch.rs is on origin/main and is exactly that combination — hyper + hyper-rustls, not reqwest (fetch.rs:3-9 says so and names the same two chokepoints), with rustls_graviola::default_provider() at :79 as the links-free provider and webpki_roots::TLS_SERVER_ROOTS at :92 as the vendored roots, https_only at :139. It landed via fa0ec61 and 62d249e because the board port needed a client, and it is green on main. So the probe plan is spent and the escalation to CLOUD-737 is off the table. What remains is not research: it is porting provision.rs::fetch_https onto the client the tree already carries, then deleting FETCHER and the flag block. Re-scope §2 accordingly before dispatching — a child reading the body as written will re-run a measurement that is already recorded in fetch.rs's own module doc.

The six hardening items still bind, and items 2 and 4 bind harder now that the client is hyper directly rather than reqwest: nothing in fetch.rs is provision's call site yet, so the connect/total timeouts and the scoped-runtime-with-shutdown_timeout are this row's to add at that site rather than inherited.

Decision, taken 2026-08-20: curl is eradicated, not documented. This reverses CLOUD-320's "stays, with a measured reason" verdict for the provision.rs row. The IO posture becomes async, and reqwest on tokio is the client.

Why the standing verdict does not hold

CLOUD-320 records the row as measured, and the measurement is a three-row table: reqwest + default-tls (fails on native-tls/openssl-sys), reqwest + rustls-tls-native-roots (fails on security-framework), reqwest + rustls-tls-webpki-roots (fails because ring declares a links key).

That is not a proof that no Rust HTTP client can link under macos-link-check. It is a proof that three reqwest feature presets cannot — and all three die at the same two chokepoints, the platform trust store and the crypto provider. Nothing tested a rustls build with a links-free provider, or a client smaller than reqwest. The conclusion was generalised well past the evidence and has been load-bearing since.

Corroborating evidence the bar is narrower than assumed. The workspace jsonschema entry carries default-features = false with the comment: "drops resolve-http (reqwest + rustls + aws-lc-rs) — the schema is local, and a validator must never fetch over the network anyway." That is a feature refusal on network-policy grounds, and linking is never mentioned — in a dev-dependency, which never goes through the Darwin cross-build at all. Consistent with the aws-lc-rs bar being about the SDK-free macOS build of the shipped artifact rather than a workspace-wide prohibition.

Two facts that make this cheaper than it looks

  • Integrity here does not come from TLS. Every provisioned artifact is verified against a locally-measured pinned sha256 (batten.toml:168-183; CLOUD-59's assumption 1 is explicit the digests were computed, never copied off a release page). TLS provides confidentiality and endpoint authentication; it is not what makes the fetch trustworthy. A replacement must establish a verified HTTPS connection — it does not have to be as trustworthy as the checksum, because the checksum already is.
  • The failure mode --fail defends against disappears. That flag is load-bearing only because curl reports a 404 body as a successful fetch, which then digests as a checksum mismatch — reporting a tampered artifact for a missing one, exit 2 where 3 is correct. An in-process client returns a status code as a typed value and cannot make that mistake.

The measurement to run, past the three presets

Both chokepoints must clear at once, and neither has been tried:

  • A crypto provider with no links key. ring and aws-lc-rs both declare one and are the only two reqwest's presets reach. rustls 0.23 takes a pluggable provider, so a pure-Rust one (RustCrypto-backed, or graviola) is the candidate the table never evaluated.
  • Trust roots without the platform store. webpki-roots is a compiled-in bundle with no security-framework dependency; it was rejected only because the provider underneath it was ring, not on its own merits. Vendored roots also make the fetch reproducible across hosts, which suits a checksum-pinned provisioner better than the platform store does.

Acceptance: macos-link-check + cross-check + darwin-link green over the resolved graph; fetch_https returns from an in-process client; const FETCHER (provision.rs:80) and the flag block delete; the provision TLS test's openssl s_server fixture — a CLOUD-320 row in its own right — retires with them.

If every links-free combination still fails, that is a measured result and it changes the shape of the answer, not the goal: the row becomes blocked on CLOUD-737 with the measurement recorded, and the escalation is a decision about the macOS build. Do not restore a "stays, with a measured reason" verdict.


Scope of the tokio adoption — narrow, deliberately

  • In scope: tokio + reqwest; fetch_https becomes async, driven from a runtime at the provision call site.
  • Out of scope: converting the rest of the crate. exec.rs's pipe drains stay OS threads — they are load-bearing in CLOUD-427's process-group protocol that interoperates with mise's supervisor, so rewriting them as tasks changes a negotiated protocol, not a call site. ignore keeps default-features = false: the serial walk is what makes tree_files byte-stable, and §6 byte-stability does not change. signal-hook's forwarding thread stays.
  • The fs4 lock stays fs4, and its comment needs rewriting rather than deleting. capture.rs:223's stated reason fuses two arguments: (i) adding a runtime is a large dependency change for a small need, and (ii) an OS advisory lock is released by the kernel when its holder dies. Once tokio is in the tree (i) is simply false, while (ii) still decides the question in the same direction — a supervisor SIGKILLed mid-write leaves a tokio::sync::RwLock nowhere and a flock released. Rewrite the comment to rest on (ii) alone, or the next reader finds a rationale with a dead premise and "fixes" it the wrong way.

What this buys, stated so nobody over-claims it later. There is exactly one network call site in the crate. Every other IO path is local files, the deliberately-serial walk, or subprocess pipes staying threaded. This delivers idiom and kills curl; it does not deliver throughput, because there is no concurrent network work to win at.

That last clause is scoped to today, and the fact model is what would falsify it (noted 2026-08-20, so it is not read later as a durable property of the crate). Forge state — PR and check-run facts — is a candidate second network call site, and the first with genuinely concurrent work behind it: 18 tasks call gh across 52 sites, land-divergence paginates the Actions API, ci-wait polls. If that lands, "one call site, no throughput to win" stops being true and the runtime's shape is decided by a different argument than this issue's.

It does not change this issue's scope or its conclusion — the narrow adoption is right either way, and forge facts are not filed as work. It changes only what a future reader may infer from the sentence above. Hardening item 5 becomes more load-bearing rather than less: a forge fact is bounded and cheap, yet still barred from the mediated path because it needs a runtime, which is a cost-class rung CLOUD-757 does not currently have.

Hardening — six items, because a runtime in a process that owns process groups is not neutral

  1. **One signal registry, and it is signal-hook. Never **tokio::signal. It registers its own handlers; two registries for one signal is a coin-flip about which observes it and would silently break exec.rs's forwarding contract. Add tokio::signal to the census gate's forbidden-token list, beside the git2::/gix:: tokens git.rs already bans that way.

  2. Explicit connect and total timeouts. The current flag block has neither --max-time nor --connect-timeout, so a hung server hangs provision apply forever today. An existing defect the migration must not inherit — and in-process it sharpens, because a hung future is harder to interrupt than a hung child process.

  3. Preserve buffer → verify → write. Do not stream to disk. apply (provision.rs:414) digests the whole body and only then calls install (:444, "Called only after the checksum matched"). Nothing touches the cache until the checksum matches, so an interrupted fetch cannot leave a half-written binary. That property is currently free — Command::output() returns a Vec<u8> — and the obvious reqwest idiom of streaming to a file destroys it silently. Keep bytes(), and say why in the comment.

  4. **Scoped runtime, bounded on shutdown — not **#[tokio::main]. Build it for the fetch and tear it down with shutdown_timeout, so a signal mid-fetch cannot leave a runtime blocking process exit. An async main would put a runtime under every verb.

  5. Keep the runtime off the hook path entirely. batten hook adjudicates against CLOUD-689's 100ms per-call ceiling and has no network call to justify the cost. CLOUD-719 sharpens this: it puts a git ref read on the hook path, so that budget is already under new pressure. Assert the hook path constructs no runtime.

  6. Dying gracefully while the runtime is working. Two properties already make abrupt death safe, and both should become assertions rather than staying luck: provision.rs takes no lock (nothing in the module touches fs4/flock), and item 3's buffer-verify-write means nothing reaches the cache unverified. Together, a SIGKILL at any instant leaves the cache byte-identical. That is the strongest form of graceful — nothing to be impolite about.

    Never drop the Runtime on the dying path: Runtime::drop blocks until blocking tasks finish and reqwest's pool holds background work, which is a hang at exit. Bound it, or std::process::exit and skip destructors — the no-lock/nothing-written invariant is what licenses that.

    The precondition that keeps this simple, verified: provision::apply is reached only from lib.rs:340; Forwarding::install only from exec's run_one. They never coexist in one process, so no signalled path has a runtime under it. Assert that, because it is the reason the hard case does not exist. If a future verb ever fetches while managing a group, it needs a CancellationToken and a select! so the future is dropped rather than abandoned.

Acceptance

  • No curl; const FETCHER and the flag block gone; the openssl s_server fixture retired.
  • A server that accepts and never responds times out rather than hanging.
  • A checksum mismatch still exits 2 and a missing artifact still exits 3 — the distinction the flag block existed to protect.
  • SIGKILL mid-fetch at several points leaves the provision cache byte-identical; the process actually exits.
  • The narrow scope held: exec.rs still spawns its two drain threads, tree_files is still byte-identical across runs, the lock is still fs4. A diff touching those has outgrown this issue.

Found while auditing the crate's subprocess and string-boundary sites against existing ticket coverage.

Probe plan (the measurement §2 below turns into a verdict)

  • Unresolved decision: whether a links-free crypto-provider plus vendored-root graph clears all Darwin link gates; otherwise escalation to CLOUD-737.
  • Probe: resolve the proposed client graph and run macos-link-check, cross-check, and darwin-link; exercise the existing provision TLS fixture, non-response timeout, checksum mismatch, and missing-artifact cases.
  • Record: resolved dependency graph and each link result; fetch_https behavior; timeout; exits for mismatch (2) and missing artifact (3); cache bytes around interrupted fetches; proof hook constructs no runtime.
  • Ready when: all link gates pass and those observed invariants hold, permitting curl removal; if every proposed links-free combination fails, record the measurements and block on CLOUD-737 rather than restoring curl.

Refinement — Ready (the link gates decide, and either outcome is a result)

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

  • **Authority boundary (§1). **crates/batten/src/provision.rs owns fetch_https and the buffer-verify-write order; Cargo.toml's workspace graph owns the client's features. The link verdict belongs to macos-link-check, cross-check and darwin-link — three gates that already exist, so this row adds no new authority and no runner decides any part of it. The forbidden-token census gate owns hardening item 1's tokio::signal ban, beside the git2::/gix:: tokens it already carries.
  • Computable predicate (§2). The three link gates exit 0 over the resolved graph. That is the whole question, and it is an exit code rather than a judgement about what Rust HTTP clients can link: CLOUD-320's standing verdict generalised from three reqwest presets that all died at the same two chokepoints, and neither a links-free crypto provider nor vendored roots underneath one has ever been resolved. Both outcomes are results. Green over a links-free provider plus webpki-roots and the migration lands. Red over every links-free combination and the measurement is recorded and this row takes CLOUD-737 as its blocker — the one outcome ruled out in advance is restoring a "stays, with a measured reason" verdict, because that is the conclusion the evidence never supported.
  • Effect (§3). Unchanged. provision apply keeps the classification it declares today; replacing the fetcher moves no verb between classes. Hardening item 5 is the clause that keeps this honest — the hook path constructs no runtime, so hook's cost class and CLOUD-689's 100ms ceiling are untouched.
  • Output & exit (§5). The exit contract is the substance rather than a formality here. A checksum mismatch stays 2 and a missing artifact stays 3 — the distinction --fail existed to protect, which an in-process status code makes structural instead of flag-dependent. Pointer-only throughout: no byte of a response body, and no fetched artifact's content, reaches stdout or stderr.
  • **Commit / bump (§6). **fix(provision) — patch until 0.1.0, since below that release-plz bumps the patch whatever the type says. Not ! for the consumer surface: no verb, exit code or output shape moves, and the 2/3 distinction is preserved rather than redefined. mise run semver decides the library half, since a dependency-graph change is exactly where a pub surface can move without anyone meaning it to — that must be asked, not assumed.
  • Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. The discriminator that is red against the current binary: a server that accepts and never responds must time out, where today neither --max-time nor --connect-timeout is set and provision apply hangs indefinitely. The pair a careless port collapses: a checksum mismatch exits 2 and a missing artifact exits 3 — an implementation that treats a 404 body as content reports a tampered artifact for an absent one. The invariant item 3 protects: SIGKILL at several points mid-fetch leaves the provision cache byte-identical, which holds only while the body is buffered and verified before anything is written. And the scope assertions, because the narrow adoption is a claim this row makes and must therefore prove: exec.rs still spawns its two drain threads, tree_files is byte-identical across runs, the lock is still fs4, and the hook path constructs no runtime.
  • Blockers (§8). None. relatedTo CLOUD-320 (the verdict this reverses), CLOUD-737 (the escalation if every links-free combination fails), CLOUD-427 (the process-group protocol the narrow scope preserves), CLOUD-689 (the per-call ceiling item 5 protects) and CLOUD-418.

CLOUD-919 Persist every PostToolUse response as a local capture

Why

Envelope::result already carries the whole response object on every post-tool event, on every harness, through decode's three aliases (hook.rs:2024-2029). Exactly one thing reads it, and it reads a count.

The bytes are then dropped. Measured against main, the drop is narrower than it looks and that narrowness is the defect: lib.rs:1881 reaches the recorder only when

envelope.event == hook::Event::PostTool && !envelope.command.is_empty()

so a structured tool — every MCP call, every Read, every Write — has an empty command and never reaches it at all, and a Bash call whose response is empty is indistinguishable from one that had no response. This row captures the response for every call that carries one, before anything projects a fact from it.

Where the capture happens, and why there

In lib.rs::run_hook, on Event::PostTool, immediately before record_agent_fact and before the config load, gated on one condition: the response member is present.

  • Before the fact projection, because CLOUD-917 makes the capture the authoritative record and the count a derived view of it. Projecting first and capturing second would let a build exist where the count survives and the bytes did not, which is the state this row exists to end.
  • Before the config load, because the post-tool path is now on every call. The whole cost is a digest and one write; the narrowing discipline (hook.rs:1572, CLOUD-460) is untouched, and a call that carries no response does no work here at all.
  • Not gated on !command.is_empty(). That conjunct is correct for an agent-sourced fact — a fact is keyed to a command that ran — and wrong for a capture, because the response of a structured tool is a response. Keeping it is precisely how the two shapes CLOUD-917 makes load-bearing (structured, and present-but-empty) would be missed.

Not through [[hook.handler]]

CLOUD-898's door is for a program a consumer declares. This is core behaviour of the mediated path and must hold with an empty batten.toml, on every harness, with nothing registered but batten hook. A handler would also invert the ordering above — a handler result is merged after config resolves — and would put the response bytes on a child process's stdin, which is the one place rule 4 most wants them not to be.

Registration: nothing to add

Verified against main: CLAUDE_EVENTS carries eight events (hook.rs:1013) and .claude/settings.json registers batten hook --harness claude-code matcherless on all eight, PostToolUse included. The alias walk is already in decode. So this row adds no registration and no alias work — it tests all three aliases and rides what CLOUD-777 landed.

What is written per call

Per CLOUD-917's two identities: the bytes to the content-addressed store at their declared fidelity, and one append-only provenance row carrying source, host, tool, event, seen_at, order, fidelity, and the digest or a recorded absence. Three outcomes, kept distinct:

Response member Capture Provenance row
absent none absence recorded
present, empty a real record of zero bytes digest of the empty record
present, non-empty the bytes at declared fidelity digest + fidelity

facts::rows_in and the Sourced receipt are unchanged and remain a separate consumer reading the same envelope field. They do not read the capture, and the capture does not read them.

Capture failure

CLOUD-917's decision, applied here: hook execution continues, the exit code is unchanged and never 2, a degraded provenance row is written with fidelity = Unavailable and a stable reason id, and the observable signal is that reason id on the doctor and advisory channels — never bytes, never a path. This is the opposite of capture::store's "never a silent skip", and deliberately so: on this surface no Batten failure may block a tool call.


Refinement — Ready

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

  • Source of truth (§1). crates/batten/src/lib.rs's post-tool arm in run_hook — one call site, beside record_agent_fact at :1881 and ahead of it. The store, the handle shape and the fidelity vocabulary are CLOUD-918's and CLOUD-917's; nothing about them is re-decided here.
  • Computable predicate (§2). mise run verify green with a payload fixture per row of the table above, plus the security predicate end to end: a planted secret is absent from stdout, stderr, every -J document and every file under $GIT_DIR/batten-receipts/, and byte-identical through batten capture show <handle> --raw. Failure case: a build that keeps the !command.is_empty() conjunct passes the Bash fixture and reds the structured one — which is why both are required rather than one standing in for the other.
  • Effect (§3). hook is already classified. The write is the only new cost and it is a self-declared write on the store capture owns; the engine still spawns nothing and builds no runtime, and house-style §5's read promise is untouched because reading a buffer the harness handed over is not execution.
  • Generated artifacts (§4). None. The wiring is already derived and drift-gated, and this adds no registration.
  • Output & exit (§5). Nothing is emitted on the capture path. A post-tool event is not a deny channel on any surveyed host and stays exit 0; a capture failure does not change that. Pointer-only holds structurally: the only new bytes leave the process through capture show --raw, which a caller has to name.
  • Commit / bump (§6). feat(hook) → patch until 0.1.0.
  • Test obligation (§7). Over the compiled binary, in tests/cli.rs's existing idiom beside the CLOUD-776 block at :9114, because the halves live in different processes:
    • a Bash fixture and an MCP content-block fixture each persist the authoritative bytes at the declared fidelity;

    • all three aliases — tool_response, toolResponse, tool_result — reach the capture;

    • a present-but-empty response yields a record of zero bytes, and an absent member yields no record and an absence row; a test fails if the two collapse;

    • the planted secret case, both halves — absent from four channels, byte-identical through --raw;

    • spilled, truncated and unavailable responses each produce the specified degraded record rather than a silent full-capture claim, one case per fidelity value;

    • a store that cannot be written leaves the hook at exit 0 with a degraded row and a reason id, driven through the state-root seam the suites already use rather than by permission bits (.claude/rules/rust.md: this sandbox runs as root);

    • A NEW POST-TOOL BENCH ARM, and this is the row's load-bearing measurement obligation. mise-tasks/perf.sh has five arms — noop, check, hook, passthrough, wired — and hook and wired both feed crates/batten/tests/fixtures/hooks/claude-code.json, a canned PreToolUse payload, with wired_cmd drawn from .hooks.PreToolUse[]. There is no PostToolUse arm. So the write this row adds — on every post-tool event, therefore on every tool call — is invisible to perf, perf-compare, perf-pair and perf-assert, and running perf-gate proves nothing about it. This row adds the arm, with its own canned PostToolUse fixture beside claude-code.json and claude-code-passthrough.json, and publishes the number.

      Without it the cost is unmeasurable now and unmeasurable later, which is the CLOUD-851 shape exactly: its store acquisition regressed check p50 4.76ms → 10.01ms (2.103x) with 2134 cargo tests green across it, because none of them measures invocation cost. CLOUD-875 is the same class.

      For the arms that already exist: REGRESSION_RATIO is 1.30, the measured noise floor 1.102, and wired carries an exemption to 1.60 until 2026-11-30 (CLOUD-843). perf-pair's two arms are measured sequentially, so concurrent load does not divide out — run it with nothing else in flight.

    • perf-assert holds, and a call carrying no response still does less work than --help.

    • Shown able to fail per CLOUD-418: restoring the command conjunct, and moving the capture after the projection, each turn a named case red.

  • Blockers (§8). blockedBy CLOUD-918 — the store has no Stream::Response and no byte-exact read until it lands, so the security predicate's second half is unassertable before then. Ordered after CLOUD-917 for the fidelity vocabulary this records. relatedTo CLOUD-776 (the projection this must leave intact) and CLOUD-777 (the registration this rides, landed — not a blocker).

CLOUD-1121 The capture spine recovers a payload the agent has already paid for: a board sweep spends ~234k tokens of context so a gate can read the same bytes off disk

Why

Measured 2026-08-28. Re-verifying the Stage 2/3 Todo column meant re-reading 52 rows with get_issue(includeRelations: true), harvesting them with board-payloads, and linting each. At CLOUD-782's measured ~4.5k tokens per payload that is ~234k tokens of context, spent so a shell script could read the same bytes out of .git/batten-payloads/.

Not one of those bytes needed to be in context. The agent's only role was to cause the call. The consumer was ready-lint, reading a file. And context is re-sent every turn, so each payload was paid again for the rest of the session.

The spine already exists, and every piece is Done

row what it built
CLOUD-918 Stream::Response — a tool response the harness handed over, content-addressed in the capture store
CLOUD-919 **every **PostToolUse response is persisted as a local capture, automatically
CLOUD-121 handles: batten capture list / show <handle>, expand without re-running
CLOUD-990 the board gates' remedies already name that store
[[mint]] issue-read fires on the get_issue result and keeps {digest:description} — the boundary already reads the whole payload and retains a pointer

So on every get_issue this session the bytes were written to the capture store automatically, and the boundary had already parsed them. The agent paid for them anyway.

The gap: everything built is RECOVERY, nothing is SUBSTITUTION

  • The capture fires at PostToolUse, which is after the result has entered context. CLOUD-685 already establishes why it cannot be otherwise — that event is non-blocking and carries no updatedOutput, so a rule there "could only ever be a sensor".
  • board-payloads reads the transcript, and the transcript holds those bytes only because they entered context. It removes the second copy — the agent re-typing, CLOUD-526 — and leaves the first one untouched.
  • **That is why this stayed invisible. **board-payloads reads as an optimisation ("byte-perfect, nothing re-typed, the fetch is paid once"), and its own header calls the ~3x "structural and this task does not touch it". Both true, and together they disguise that the expensive half was never anyone's subject.
  • The host's own oversize spill does not reach this: CLOUD-685 measured its threshold between ~43K and ~74K characters, and a get_issue at ~18K sails under it.

Nothing in the tree removes the payment. Every mechanism either measures it (CLOUD-415, CLOUD-417), refuses an oversize one (CLOUD-685), or recovers the bytes afterwards (CLOUD-919, CLOUD-990).

The route is open, and one premise is unmeasured

CLOUD-790 (Done, PR #593) overturned CLOUD-673's "a task cannot authenticate to the session's MCP endpoint": the container's session-ingress token does authenticate it, and the 401 was a missing Authorization header. Measured there:

route result
POST /v2/ccr-sessions/<id>/github/mcp 200 — 55 tools
POST /v2/ccr-sessions/<id>/mcp?toolbox_mcp_server_id=<toolbox> 403 MCP server not allowed

So a task can make tool calls the agent never sees, and land already does exactly that to drop a PR subscription. What is unmeasured is whether the tracker connector's server id is allowed on that route — the 403 above is a live possibility, not a formality. That measurement is this row's first step, and both outcomes are results: allowed, and the fetch moves off the agent entirely; refused, and the row falls back to the resolve-side half, which is worth landing alone.

The same shape elsewhere, censused

class owner state
get_issue has no field projection — ~4.5k to decide on ~1.6k CLOUD-782 Done — the structural third that sets the size of this waste
save_issue echoes the entire description on every write recorded in CLOUD-415's body, owned by nothing paid ~40 times this session; filed separately, since its remedy is a request shape and this row's is a read route
Read / Skill unmatched by the PreToolUse matcher CLOUD-685 Todo — the refusal half of the same question
hook output is 20% of a long session CLOUD-417 Todo
refusal prose spends context every fire CLOUD-1053 Done — the dereference pattern this generalises
a per-CALL ceiling was inexpressible CLOUD-925 Done — the mechanism CLOUD-685 needs now exists
session cost has no sensor at all CLOUD-415 Backlog

Refinement — Ready (a payload a gate reads off disk must never have to enter context first)

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

  • Authority boundary (§1). The capture store stays the one home for a recovered payload — crates/batten/src/capture.rs, Stream::Response, addressed by identity::capture_fingerprint. This row adds no second store and no second addressing scheme; it adds a way to populate it without the agent, and a way for a gate to resolve from it without a pipe. board-payloads stays the transcript route and is not deleted: a host with no ingress token still needs it.
  • Computable predicate (§2), in two independent halves. Either lands alone; together they close the class.
    1. Resolve-side, unconditional. A board gate resolves its payload from the capture store by issue key, so a caller names an id and pipes nothing. Today's remedy is batten capture show <handle> --raw | mise run <gate>, which still needs the agent to find a handle — itself a read. Keying the lookup on the id the gate already holds removes the search and the pipe together. Decidable: the same gate over the same row reaches the same verdict with nothing on stdin.
    2. Fetch-side, conditional on the measurement above. A task issues the tools/call itself with the session-ingress token, writes the response as Stream::Response, and prints a handle and a count, never a body. Per-row context cost becomes one line. If the tracker's server id is refused, this half is recorded as unreachable with its HTTP status — the way CLOUD-673 recorded its 401 — and half 1 ships alone.
  • Effect (§3). Half 1 is read. Half 2 is a write, declared on the command surface rather than smuggled into a read verb, into the same store batten exec already writes. Neither runs on Surface::Hook, so CLOUD-689's 100 ms per-call ceiling is untouched and no runtime is built on the mediated path.
  • Output & exit (§5). Pointer-only, and here that is the substance rather than a formality: the emitted bytes are an id, a handle and a count — never a body, never a token, never a session or server identifier. A verb existing to keep a payload out of context that printed the payload would defeat itself. Exit follows the one table: 0 resolved, 1 a usage error or unreadable store, 3 the endpoint could not be reached. It renders no policy verdict, so it never exits 2.
  • **Commit / bump (§6). **feat(board) — patch until 0.1.0, since below that release-plz bumps the patch whatever the type says. Not ! for the consumer surface: both halves are additive, the transcript route and the existing capture show pipe keep working byte-identically, and no exit code or output shape moves. mise run semver decides the library half, since a new resolution path on capture is a pub API change and that must be asked, not assumed.
  • Test obligation (§7). Over the compiled binary and the gate suites, shown able to fail per CLOUD-418. The discriminator for half 1 is a negative case, because the easy implementation quietly keeps reading stdin: a gate invoked with an id and an empty stdin must reach the same verdict as the same gate fed the payload. Its partner is the anti-vacuity case — a fixture whose capture store is empty must report could-not-look rather than passing, since an absent payload reading as a clean row is the false green this class produces. For half 2, the token file unset exits 3 and writes nothing, mirroring pr-unsubscribed drop's fail-open shape. And the property that makes the row worth landing, asserted rather than hoped: the emitted bytes contain no substring of any body, the pointer_only corpus re-run on this path.
  • Blockers (§8). None. relatedTo CLOUD-790 (which opened the route and supplies the token mechanism), CLOUD-919 and CLOUD-918 (the store this populates and resolves from), CLOUD-121 (handles), CLOUD-990 (the remedy text this replaces), CLOUD-782 (the structural third), CLOUD-685 (the refusal half — that row stops an oversize read, this one removes a right-sized one), CLOUD-526 (why the payload must stay the tracker's own bytes), CLOUD-1118 (the staleness bug in the route this supersedes for the fetch case) and CLOUD-1100 (which retires the Ready grammar into the engine and is the largest consumer of a payload that never enters context).

Acceptance

  • A board gate decides over a row with no payload on stdin and none in context, proven by a case where stdin is empty.
  • An empty or absent capture reports could-not-look; it never reads as a clean row.
  • The tracker connector's reachability on the session MCP route is measured and recorded with its HTTP status, whichever way it comes out.
  • A 52-row sweep costs a bounded number of lines rather than ~234k tokens, reported against today's figure.
  • No emitted byte is a substring of any issue body.

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 55 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: d5904862-d94e-487b-bdcc-ff799e79543e

📥 Commits

Reviewing files that changed from the base of the PR and between 79b8bfd and 1a79d8a.

⛔ Files ignored due to path filters (2)
  • crates/batten/tests/snapshots/snapshots__golden_json_schema.snap is excluded by !**/*.snap
  • hk.pkl is excluded by !**/*.pkl
📒 Files selected for processing (29)
  • .claude/settings.json
  • .serena/memories/core.md
  • AGENTS.md
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/capture.rs
  • crates/batten/src/cli.rs
  • crates/batten/src/config.rs
  • crates/batten/src/fetch.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/mcp.rs
  • crates/batten/src/provision.rs
  • crates/batten/src/resolve.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/surface.rs
  • crates/batten/src/trust.rs
  • crates/batten/tests/cli.rs
  • crates/batten/tests/connector_not_granted.rs
  • crates/batten/tests/mcp_dispatch.rs
  • crates/batten/tests/narrow_adoption.rs
  • crates/batten/tests/pointer_only.rs
  • man/batten-mcp-call.1
  • man/batten-mcp.1
  • man/batten.1
  • policy/connector-not-granted.rego
  • policy/module-layering.rego
  • 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 marked this pull request as ready for review September 1, 2026 03:44
@wenzowski
wenzowski marked this pull request as draft September 1, 2026 03:51
Measured over one session's own transcript on 2026-08-31: 43.5 MB of tool
content, of which tracker round trips were 13.2 MB — 73% of all tool output,
across 973 calls against 208 rows. Bash, Grep and Read together were 1.9 MB.
Reading the tree is nearly free; the connector was the entire cost, and a whole
document moved every time to convey a ~2 KB delta. That is non-negotiable rule 4
unenforced at the tool boundary.

The connector never has to offer a projection. MCP is JSON-RPC 2.0, so Batten can
be the CLIENT: dispatch the request, own the response, store it whole, and return
a shape the connector does not ship. A client, never a server — CLOUD-204 is
untouched and this is the opposite direction.

`batten mcp call <server> <method> [params]` resolves the wiring a declared
`[[mcp.source]]` names, dispatches, stores the response, and prints a pointer, a
delta and the declared reduction. Every tracker identifier lives in `batten.toml`;
the crate knows only "dispatch a declared method, reduce by a declared
projection", asserted by a rule-1 grep in `tests/mcp_dispatch.rs`.

THE TRANSPORT WAS ALREADY VENDORED. `fetch.rs` gains POST, response headers and
`spend`, which runs a sequence on one current-thread runtime. The MCP handshake
cannot be one batch — `initialize` mints the session id the rest carry back — so
it is one exchange to mint and the remaining two together. No feature was widened;
`rt-multi-thread` is still not in the graph.

THE STORE HOLDS THE UNFRAMED PAYLOAD, and that is a fidelity requirement rather
than a preference. `capture::find` resolves a stored response by a key at `id`,
which is how `ready lint --issue`, `claim check --issue` and the board gates reach
a payload without its bytes entering context. Measured against this repository's
own store: the harness files the decoded content with `id` at the top level.
Filing the JSON-RPC envelope would have put `id` two levels down and every one of
those lookups would have silently resolved nothing over a full store. `payload`
takes MCP's own content-block framing off — protocol vocabulary, not a tracker's
schema — and is three-valued, so an unrecognised framing keeps its bytes.

CLOSING THE RAW PATH IS A GATE, NOT A CONVENTION. `policy/connector-not-granted.rego`
refuses a `permissions.allow` entry granting a tool a `[[mcp.result]]` row
reduces, and the grants are dropped. What the gate does NOT claim is registration:
that happens where the launcher writes its wiring, outside this repository and
outside every gate here, and asserting it would be an authority this repo does not
hold — `harness-grant` records the same boundary one file over.

`--raw` IS RECORDED WHEN SPENT. `capture::record_escape` appends a pointer-only
row on both routes, so the invariant's true form — no unreduced route BY DEFAULT —
is measurable as a count rather than asserted.

TWO DEVIATIONS FROM THE ROW'S READY BLOCK, stated rather than absorbed:

* §5 says exit 2 on a dispatch error. §7's table reserves 2 for a policy verdict
  with no per-verb exception, and every host with a pre-tool hook reads 2 as deny,
  so a dropped network would read as a refusal. Dispatch failure and an unreadable
  config are exit 3; malformed params are exit 1. AGENTS.md: the spec wins.
* `batten.toml` is a protected path. Its own refusal names "change it in a pull
  request", which is this branch, so BATTEN_HOOK_BYPASS was spent for the edit.

AGENTS.md's scope amendment lands here per non-negotiable rule 2, paid for by
compressing rules 7-8 and the memory paragraph: the file sits on its budget
ceiling, so the prose is net-zero lines rather than a raised threshold.

The compiled-binary tier covers everything up to the socket. A completed dispatch
is not hermetically testable — `fetch` is https_only and nothing signs a loopback
CA — which `fetch.rs`'s header already records for its own case.

Refs: CLOUD-1260, CLOUD-745, CLOUD-919, CLOUD-1121, CLOUD-418, CLOUD-204

BREAKING CHANGE: `fetch::Response` gains a `headers` field, so a caller
constructing one by literal no longer compiles. `mise run semver` names the lint
(`constructible_struct_adds_field`) and the honest type is declared rather than
worked around: the field is what carries a session-bearing protocol's session id
back, and hiding it behind an accessor would move the break rather than remove
it. Below 0.1.0 release-plz still bumps the patch whatever the type says; the
marker is what the changelog and the history depend on.
… body

`save_issue` was 435 calls returning 4.98 MB on the measured session — 38% of the
13.2 MB the tracker cost, which was itself 73% of all tool output. A write echoes
the entire stored description back seconds after the author sent it: a `patch`
sends a few hundred bytes of anchors and gets a full body back, and that body then
sits in context, re-sent every turn, for the rest of the session. It is the one
operation whose input the caller already knows.

One row on the table CLOUD-1260 created: `reduce = "acknowledge"` over
`save_issue`, carrying `{id, status, url}`. The handle is not a field because it
is not a tracker field — it travels in the verb's own pointer line, which is where
a pointer belongs.

THE BOUND IS LENGTH AND SCALAR, NOT `facts::Reduction::Token`'S, and the
difference is this row's own near-miss caught before it shipped. That reduction
additionally refuses any value carrying whitespace, and it is right to: its
product reaches the POLICY INPUT, where a module could lift a sentence into a
`subjects` pointer. This arm's product reaches the CALLER, and the thing being
kept out is a 15k-character description — which the length bound stops on its own.
Copying the whitespace clause across would have silently dropped `status = "In
Progress"`, the one field this row's remedy names, with every test still green. A
bound borrowed from a surface with a different threat model is how a reduction
quietly stops answering the question it was built for. Pinned by an assertion so
it cannot come back.

BOTH LIVE CONSUMERS ARE PROVEN, NOT ASSUMED, because a fix that starved them would
trade one defect for two. CLOUD-815 compares the stored body against the sent one
— that comparison IS the detection — and CLOUD-1118 wants the post-write body so a
lint straight after a write decides over what was actually stored. Both read the
CAPTURE STORE, and every response is stored WHOLE before anything is reduced. The
case drives the engine's own `PostToolUse` event to file the response, then
resolves it back through `capture find --raw` and asserts the body survives
entire. An earlier draft of that case round-tripped JSON through serde and
asserted nothing about Batten — CLOUD-418's non-discriminating coverage — and was
thrown away rather than kept.

There is no `updatedOutput` and none was looked for: re-verified twice on
2026-08-31, absent from the runtime and from the published hook reference. That
limit is real and SCOPED — a POST-tool hook cannot rewrite a result — and it says
nothing about a client that owns the response, which is what this reduces.

Refs: CLOUD-1122, CLOUD-1260, CLOUD-815, CLOUD-1118, CLOUD-919, CLOUD-685, CLOUD-418
…ct its premise

`[[rule.external]]` declares ONE path under ONE root. A Claude Code remote
session's launcher mints its MCP config per session, so the path is a glob, the
match arity is not one, and the id a declaration needs cannot be authored for a
name that does not yet exist. Each of the three would be fatal alone.

ANSWER 2 IS TAKEN: a producer resolves such a set outside the engine and a module
reads what was written. The engine grows no glob.

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 reader 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", which is the whole safety property the family 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.

AND A CORRECTION TO THE ROW'S OWN PREMISE, measured rather than argued. That row
expects the second consumer to strengthen answer 2. It cannot be served by answer
2 at all: `facts.rs`'s `Sourced` states that "no byte of it reaches THIS record —
`rows_in` reduces the buffer to a COUNT at the boundary", so the whole record
family is payload-free BY CONSTRUCTION. That is exactly right for a module
deciding a predicate, and structurally incapable of carrying an endpoint and
headers, which is what DISPATCH needs. So answer 2 serves the permission question
CLOUD-1163's unit 4 asks — recorded there as buildable — and remote dispatch is
now a known gap with a stated cause rather than an assumed solution.

WHAT DELIBERATELY DID NOT LAND: no `[[fact]]` or `[[recorder]]` row. Its only
reader would be a module that has not been written, and unit 4's migration is
CLOUD-1163's and out of this row's scope. A declaration nothing reads is the dead
gate this repository refuses everywhere else, so the shape stays available and is
declared when a reader exists.

The decision is recorded where the reader who hits the limit is looking — beside
the `[[mcp.source]]` rows it bounds, and in the module header — rather than only
on the tracker. `docs` and no bump: the engine is unchanged, and the negative half
this row cares about holds for exactly the reason it did before, asserted by
`tests/mcp_dispatch.rs`.

Refs: CLOUD-1251, CLOUD-1260, CLOUD-1163, CLOUD-1167, CLOUD-1154, CLOUD-1170
@wenzowski
wenzowski force-pushed the claude/mcp-reduction-bundle-85xadi branch from 68d9058 to 1a79d8a Compare September 1, 2026 03:58
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski
wenzowski marked this pull request as ready for review September 1, 2026 04:17
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 1a79d8a into main Sep 1, 2026
19 of 20 checks passed
@wenzowski
wenzowski deleted the claude/mcp-reduction-bundle-85xadi branch September 1, 2026 04:37
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