Skip to content

0.11.0: a deny reaches the model, and says what to do next - #17

Merged
davidcrowe merged 2 commits into
mainfrom
fix/692-deny-reason-and-rate
Aug 21, 2026
Merged

0.11.0: a deny reaches the model, and says what to do next#17
davidcrowe merged 2 commits into
mainfrom
fix/692-deny-reason-and-rate

Conversation

@davidcrowe

Copy link
Copy Markdown
Collaborator

Client half of gatewaystack-connect#692. Pairs with gatewaystack-connect#766.

The bug

Claude Code reads hookSpecificOutput.permissionDecisionReason into the blocking error the model sees (hooks.ts:598-610); systemMessage goes to the human. The cloud path set only systemMessage.

So every ACP deny arrived at the agent as the bare fallback Hook PreToolUse:<tool> denied this tool — indistinguishable from a harness bug, and impossible to re-plan on. Local mode has always set both. That asymmetry is gatewaystack-connect#690's "no reason" mystery seen from the client end: the reason was written all along, into a field the model cannot read.

What changed

Denies now carry the reason to the model, plus a steer saying what to do next — because a denied call is one refused operation, not a refused task:

verdict steer
policy continue with the rest; if you should have this capability, draft a rule with acp_propose_rule for a human to approve
floor / governance surface cannot be allowed by policy or approval; do not retry and do not look for another route
outage availability, NOT a judgment about the call — nothing decided the operation was wrong, so retry later
throttle your task is not over; retry the identical call shortly, and do not rewrite it to dodge the limit
approval a human is deciding; under codex it lands as a block, so the out-of-band route is named

Floors deliberately get no proposal path — offering one sends the agent to file something no human is able to approve.

The steer is selected from the server's typed kind field rather than by pattern-matching the reason, so wording can change without silently changing the instruction the agent acts on. A regex fallback covers gateways older than the field, and is tested so back-compat can't rot.

Verification

77 tests pass. New coverage for: the reason reaching the model at all, the propose path appearing only where it can be honoured, each typed steer, and the pre-kind fallback.

Note

The installer fetches bin/govern.mjs from main, so this needs to land there before any machine picks it up.

Claude Code reads hookSpecificOutput.permissionDecisionReason into the
blocking error the model sees (hooks.ts:598-610); systemMessage goes to the
human. The cloud path set only systemMessage, so every ACP deny arrived at
the agent as the bare fallback "Hook PreToolUse:<tool> denied this tool" —
indistinguishable from a harness bug and impossible to re-plan on. Local
mode has always set both. That asymmetry is #690's "no reason" mystery seen
from the client end: the reason was written all along, into a field the
model cannot read.

Denies now carry the reason to the model, plus a steer that says what to do
next, because a denied call is one refused operation and not a refused task:

  policy deny  — continue with the rest; if you should have this capability,
                 draft a rule with acp_propose_rule for a human to approve
  floor / gov  — cannot be allowed by policy or approval; do not retry and
                 do not look for another route to the same effect
  outage       — availability, NOT a judgment about the call; nothing decided
                 the operation was wrong, so retry later
  approval     — a human is deciding; under codex it lands as a block, so
                 name the out-of-band route explicitly

Floors deliberately get no proposal path. Offering one would send the agent
to file something no human is able to approve.

Refs gatewaystack-connect#692, #627, #690
The gateway now types every verdict by what the agent should DO
(HookResponse.kind): terminal, delegate, reformulate, retry. Reading that
field beats pattern-matching the reason text — wording can change without
anyone noticing it also changed the instruction the agent acts on, and the
prose fallback only ever recognized the two prefixes it knew about, so an
immutable-platform-rule deny was steering agents toward a proposal no human
could approve.

retry gets its own steer, which is the case that actually killed runs: a
throttle is not a capability question, so the agent is told its task is not
over, to retry the identical call shortly, and specifically NOT to rewrite
it to dodge the limit or file a rule proposal about it.

The regex fallback stays for gateways older than the field, and is tested
so back-compat can't rot.

77 tests pass.

Refs gatewaystack-connect#692
@davidcrowe
davidcrowe merged commit 8014c4e into main Aug 21, 2026
2 checks passed
davidcrowe added a commit that referenced this pull request Aug 26, 2026
plugin.json and marketplace.json still said 0.10.3 while bin/govern.mjs
ships PLUGIN_VERSION 0.11.0, so marketplace installs never see the
deny-reaches-the-model update. Bump both to match.
@davidcrowe
davidcrowe deleted the fix/692-deny-reason-and-rate branch August 26, 2026 23:20
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