Skip to content

feat(gate): issue-guard — a PR must name the CLOUD issue it serves - #100

Merged
wenzowski merged 3 commits into
mainfrom
claude/linear-connector-approval-loop-yobwnc
Aug 7, 2026
Merged

wenzowski merged 3 commits into
mainfrom
claude/linear-connector-approval-loop-yobwnc

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Refs CLOUD-178.

The board rule was prose, and rule 2 predicts what happens to prose: feedforward only. This is its mechanism.

The diagnosis

A session landed three PRs (#95, #97, #99) while following every gated discipline and skipping every ungated one.

It never skipped verify — ready-guard denies gh pr ready without the receipt. It never wrote a memory directly — memory-guard denies it. It never malformed a commit subject — commit-msg rejects it.

And it never once consulted the board: no issue pulled, no issue moved, no issue created. CLOUD-178 — which already described the defect being worked on, carrying measurements that contradicted the fix — sat unread in Todo the whole time. Follow-ups went into chat messages that die with the session.

That is not a memory failure. Behaviour tracked the enforcement surface exactly. graph-check (CLOUD-175) gates the board's internal coherence, but it is a pure function of stdin someone must choose to pipe — nothing makes a session consult the board at all.

The gate

issue-guard denies gh pr create and gh pr ready unless a CLOUD-<n> appears in the branch, in a commit the branch adds, or in the command. You cannot name an issue you have not looked up, so the gate that blocks landing is also what forces the search — at the one point in the lifecycle that cannot be routed around, and before the code is written rather than after.

Wired as a third PreToolUse hook on Bash alongside gh-guard and ready-guard. Wrapper-aware (mise exec -- gh … is the sandbox's only working form), quote-scrubbed so a commit message naming the command is not the command, fails open on unparseable input and outside a git repo, and honours BATTEN_ISSUE_GUARD_BYPASS=1 for a PR that genuinely precedes its issue.

It would have blocked all three PRs that motivated it.

What this deliberately does not gate

Whether outstanding items actually reach the issue. There is no computable predicate for that over any artifact the repo can see, and stating it beats leaving an unmet obligation implied. The compensating control is that every PR now names an issue, so a durable home always exists and "nowhere to put it" stops being available.

A test bug worth recording

The first suite ran in the real checkout and passed. Then the commit adding the guard put Refs: CLOUD-178 in its own message — the guard correctly allowed, and every deny case flipped red. A guard whose verdict reads live git state cannot be tested against live git state; the suite was asserting a property of this branch, not of the guard.

Every case now builds a throwaway repo it controls. That also made two cases expressible that weren't before: a reference on main but not on the branch does not count, and outside a git repo the guard fails open rather than blocking every PR.

Verification

mise run verify green on 69c57ff. 15 bats cases for this guard; 181 in the suite.


Generated by Claude Code

claude added 3 commits August 7, 2026 16:02
Refs: CLOUD-178

The board rule was prose, and this repo's own rule 2 predicts what happens to
prose: feedforward only. The evidence is a session that landed three PRs while
following every gated discipline and skipping every ungated one.

It never skipped verify — ready-guard denies gh pr ready without the receipt. It
never wrote a memory directly — memory-guard denies it. It never malformed a
commit subject — commit-msg rejects it. And it never once consulted the board:
no issue pulled, no issue moved, no issue created, and CLOUD-178 — which already
described the defect being worked on, with measurements that contradicted the
fix — sat unread in Todo the whole time. Follow-ups went into chat messages that
die with the session.

That is not a memory failure, it is an enforcement-surface failure: behaviour
tracked exactly which rules had a mechanism.

So give the board rule one, on the path that cannot be routed around. issue-guard
denies gh pr create and gh pr ready unless a CLOUD-<n> appears in the branch, in
a commit on the branch, or in the command. You cannot name an issue you have not
looked up, so the gate that blocks landing is also what forces the search — and
it forces it before the code is written, not after.

Deliberately NOT gated, stated rather than left as an unmet obligation: whether
outstanding items actually reach the issue. No computable predicate exists over
anything the repo can see. The compensating control is that a durable home now
always exists, so "nowhere to put it" stops being available.

Wrapper-aware (mise exec -- gh), quote-scrubbed so a commit message naming the
command is not the command, fails open on unparseable input, and honours
BATTEN_ISSUE_GUARD_BYPASS=1 for a PR that genuinely precedes its issue.

14 bats cases.
Refs: CLOUD-178

The first draft ran in the real checkout, and passed. Then the commit adding
the guard put `Refs: CLOUD-178` in its own message — the guard correctly
allowed, and every deny case flipped red.

A guard whose verdict reads live git state cannot be tested against live git
state: the suite was asserting a property of this branch, not of the guard.
Each case now builds a throwaway repo with no issue reference and works from
there.

Adds two cases the isolated fixture makes expressible: a reference on main but
not on this branch does not count, and outside a git repo the guard fails open
rather than blocking every PR.
@linear-code

linear-code Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
CLOUD-178 claude.ai connector tools flip between readable and UUID names, silently breaking the permission allowlist

Split out of CLOUD-177, which is Done on its own scope. Evidence thread: the comments on CLOUD-177, in particular the final correction establishing verdict (a).

Ready

A claude.ai connector MCP server is exposed to a session under two different names over its lifetime, and a permission allow rule can only name one of them.

At session start the Linear connector appears as mcp__Linear__*. On a mid-session disconnect/re-register it comes back as mcp__4db58e41-cd4e-4818-8922-46cf616593f4__*, and the readable tools vanish from the listing entirely. .claude/settings.json allows "mcp__Linear", a prefix match, which matches nothing under the UUID name. Every Linear call then prompts for approval, with no signal that anything changed.

This hits the board-in-lockstep rule directly: moving a CLOUD-* issue between states is a save_issue call, so the workflow AGENTS.md mandates starts requiring a human tap per transition, mid-session, without warning.

Measured, across two containers

d38efda7 047c8272
At session start readable readable
After reconnect UUID UUID
Linear UUID 4db58e41-cd4e-4818-8922-46cf616593f4 same
Gmail UUID d648a34b-ef40-4201-b1af-9123d05d7a1e same
Xero UUID 0c388dc8-630a-4020-96c7-ff71ce1af383 same

Verdict: the UUID is stable and equal across containers. It is a durable identity for the connector; what varies is which of the two names is live at a given moment. This was initially mis-called as "unstable per boot" from a session that had not yet reconnected — absence of a reconnect is not evidence of naming stability, and any future measurement here needs to span at least one reconnect to say anything.

Confirmed load-bearing, not cosmetic: a save_issue under mcp__4db58e41-…__save_issue prompted, while a Linear write under the readable name moments earlier did not. (Both prompt-or-not observations are human-reported — an agent cannot observe its own approval prompts, and asserting otherwise is what made this take four passes to get right.)

Note this is a second, independent defect from the one that originally masked it: an org-level ask control on the Linear connector was overriding allow rules entirely, per Organization controls on connector tools. That control has been cleared and verified. Clearing it is what made this defect visible.

Ready predicate

A session survives a connector reconnect with Linear calls still auto-approved — no prompt on a save_issue issued under either the readable or the UUID name.

Done (proposed)

  • ~/.claude/settings.json allows both names:
"mcp__Linear",
"mcp__4db58e41-cd4e-4818-8922-46cf616593f4"
  • User-level, not the repo. A connector UUID is an account-specific identifier: meaningless to any other contributor or fork, and silently rotting if the connector is re-authorized. Repo rule 1 keeps those out of committed config. .claude/settings.json keeps "mcp__Linear" alone — correct and portable for anyone cloning batten — and the UUID lives in the personal scope where account facts belong.
  • Verified by observation across a reconnect, human-reported, not inferred from a session with no reconnect in it.

Open questions

  • Does the UUID survive re-authorization of the connector? Stable across two containers is not stable across an OAuth re-grant. If it rotates there, the user-level entry needs re-deriving and the failure mode returns silently. Worth knowing before treating this as closed.
  • Gmail and Xero have the same exposure and are not covered by any allow rule today. Not in scope here, but the same two-name fix applies if either is ever allowlisted.
  • No gate is possible for this one. Repo rule 2 wants a rule to ship with a runnable mechanism, and there is none available: the failure lives in a settings file outside the repo, keyed to an identifier the repo must not contain, and triggered by a remote-host event. Worth stating explicitly rather than leaving as an unmet obligation — this is a documented limitation, and the compensating control is that the failure is loud to the human (a prompt) even though it is silent to the agent.

Why the repo cannot fix this itself

In Claude Code on the web, connectors are "provisioned by the remote host and arrive as explicit --mcp-config entries" (MCP docs), which is also why they appear as mcp__Linear__* rather than the documented mcp__claude_ai_<server>__<tool> form. The naming is chosen per registration episode by the host, not by anything under this repo's control. The durable upstream fix would be a stable server name across re-registration, or permission matching on server identity rather than exposed tool-name prefix; until then, allowlisting both names is the available mitigation.


Generated by Claude Code

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 7, 2026 16:04
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 69c57ff into main Aug 7, 2026
10 checks passed
@wenzowski
wenzowski deleted the claude/linear-connector-approval-loop-yobwnc branch August 7, 2026 16:06

Copy link
Copy Markdown
Contributor Author

Outstanding items from this session, recorded where they persist

Posting here because Linear writes are blocked by the permission classifier in this session. This PR is linked to CLOUD-178 via linkback, so these reach the issue. They belong on CLOUD-178 proper — please move or let me know when Linear writes are permitted.

1. #95's fix targets a different mechanism than CLOUD-178 measured

#95 added mcp__claude_ai_Linear__* to the repo's .claude/settings.json. That form is real — the CLI bundle's built-in allowlist carries mcp__claude_ai_Slack__slack_send_message, and its scope resolver classes any server named claude_ai_* as connector-scoped — but it is the local-CLI naming.

CLOUD-178's measured finding is the readable ↔ UUID flip, chosen per registration episode by the host. That flip occurred live during this session: Linear disconnected and returned as mcp__4db58e41-…__*. It was dismissed as sandbox noise at the time, which was wrong — it was the defect itself.

2. #95 put the entry in the wrong scope

CLOUD-178's Done specifies user-level ~/.claude/settings.json, keeping the repo's .claude/settings.json at "mcp__Linear" alone so it stays portable for anyone cloning batten. #95 put a connector-shaped entry in committed config and added nothing user-level.

claude_ai_Linear is not account-specific, so this does not breach repo rule 1 the way a UUID would. But it is not what the issue specifies, and CLOUD-178's Ready predicate — surviving a reconnect with no prompt — remains unmet. The issue should stay in Todo.

3. mcp-allow-check has a broad predicate the evidence does not support

#97 landed a gate requiring a mcp__claude_ai_<server>__* companion for every allowed MCP server, repo-wide.

  • Its narrow half is sound and worth keeping: an allow rule whose server segment is globbed is skipped by the CLI with a warning, so it reads as a grant and is not one. That is verifiable from the repo alone.
  • Its broad half encodes local-CLI naming as universal. CLOUD-178 establishes the exposed name is host-chosen; in a web session, where connectors arrive via --mcp-config as mcp__Linear__*, the companion the gate demands is inert.

Proposed: drop the mandatory companion (or demote it to a warning) and keep the server-segment-glob predicate. Not done unilaterally — #95 and #97 are already on main, and CLOUD-178's author holds the measurement history.

Note also that CLOUD-178 states no gate is possible for its defect, with sound reasoning: the failure lives in a settings file outside the repo, keyed to an identifier the repo must not contain. mcp-allow-check does not contradict that — it gates a different, repo-visible failure — but the overlap is worth reconciling on the issue.

4. Already answered, and asked anyway

CLOUD-178 records that the org-level ask control was cleared and verified. This session repeatedly suggested checking it, after the user had already said the connector was set to Always. Both the user and the board had answered it before the session began.


Generated by Claude Code

wenzowski pushed a commit that referenced this pull request Aug 7, 2026
…he transition

Refs: CLOUD-178

issue-guard shipped with a correct predicate and a wrong premise. It said the
agent must be forced to remember the board. Measured since: a commit carrying
`Refs: CLOUD-178` moved that issue Todo -> In Progress, set its assignee and
attached the PR, with no write call from the session — the tracker's GitHub
integration keys on the identifier and performs the transitions itself.

So the identifier is not traceability, it is the automation's input, and the
agent's job is to supply it rather than to mirror the board by hand. Hand-moving
is also the fragile path: a state change is a tracker write, and a write can be
denied mid-session when the connector re-registers under a name no allow rule
matches — which is precisely how this session lost the board for three landed
PRs. An identifier in a commit travels in git, where nothing can deny it.

Records two limits observed rather than assumed: the merge-side transition did
not fire (#100 merged, the issue stayed In Progress), and the tracker's own
per-issue branch name carries the key from the first push, earlier than any
commit message does.

No behaviour change — the gate's predicate was already right.
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.

2 participants