---
title: "Capability Gates Beat Risk Labels in Autonomous Agent Design"
canonical: https://dxdev.com/blog/capability-gates-beat-risk-labels-agent-autonomy/
datePublished: 2026-06-05
---
The question that gates every autonomous agent is "what can it do without asking me?" I'd already answered it the right way, on paper. The permission model for the co-CEO harness I was building, the thing that decides whether my agent station can act on its own backlog without me in the loop, split everything the agent could do into five lanes, ordered by the capability each one required. Tests were green: 80 passing. I read that as proof the gate was live.

It wasn't. A same-day Codex review, followed by Manus checking the same code independently, found that the dispatcher never called it.

## Five lanes, one working day

The lanes, in order of how much they let the agent touch:

- **read-only**: query files, JIRA, logs, DNS, the database. Touches nothing.
- **local-write**: write to a scratch file, the vault, a draft. Lives on disk, reversible by deleting it.
- **branch-write**: commit to a feature branch, mutate local repo state. Reversible by git, but now it's in version control.
- **external-write**: a network write to a system outside my control: a JIRA transition, a DNS record, a third-party API call. Leaves the building.
- **money-comms-prod**: anything that spends money, talks to a customer, or mutates production. The point of no return.

I mapped each lane to a tier, 0 through 2, in `tiers.py`: `tier_for(action)` reads `requiredCapability` off the proposed action and returns the number. Tier 0 auto-runs. Tier 1 sits in a 30-minute veto window before it fires. Tier 2, money or prod or anything customer-facing, always waits on me. 80 tests covered it, and they all passed, because `tier_for()` really does return the right number for a given capability. What none of those tests checked was whether the thing that dispatches work ever calls it.

It didn't. I asked Codex for a review at high effort before letting the harness run unattended, then had Manus check the same code independently. Codex's reply opened with the sentence that mattered: "tick.py bypasses the resolver entirely. Once pause is lifted, the live sweep will dispatch straight from proposed based on tier_for only; it never calls tiers.resolve, never opens a veto window, and dispatch does not require an approved or auto-approved state." I went and read the dispatch function myself. The line that decided whether an action ran was `if not paused: dispatch(item)`. Not a tier check. Not a veto window. Not `tiers.resolve()` at all, the function that actually applies the policy, records the state transition, and enforces the escalation. `tier_for()` got called two lines above it, for a log line. Manus read the same files independently and came back with the same verdict, worded just as flatly: "The safety gate exists and is correct in isolation. It is never called on the live dispatch path. That is not a configuration gap; it is a wiring failure." It also flagged something Codex hadn't touched: the 30-minute veto window assumes someone is watching the ledger, and I sleep. That's where the presence gate came from, not the tier model. Codex's closing line named the actual failure mode better than I could: the safety story wasn't conservative but unproven, it was policy exists on paper, but the live execution path skips it.

The fix, same day, commit `9d23a2b`:

```python
# Resolve the proposal through tier POLICY before any dispatch.
# tier_for() alone is not a gate. tiers.resolve() runs the Tier-2
# escalation, the Tier-1 consult tie-break plus veto window, and records
# the state transition the dispatch state-guard keys off. Skipping it
# (the old path) made the whole tier/veto/escalation policy dead code
# on the live dispatch path.
resolution, recorded_state = tiers.resolve_and_record(item)
if resolution["decision"] == "auto" and not paused:
    disp = delegation.dispatch(item, contract)
```

The comment in that diff is longer than the code it explains, which is usually a sign the code used to be wrong in a way worth remembering.

## The rest of what a review like that finds

Once Codex and Manus were both inside the dispatch path, they kept finding the same shape of gap: something that looked like a safeguard from a distance and was a no-op up close. `delegation.dispatch` would run even with no ledger history for an item, so I made it fail closed, refuse unless the item's state was already `auto-approved`, no history means not dispatchable. A broken consult call or a broken spawn used to fail open; now both fail closed too. A worker that goes stale mid-run now trips a circuit breaker that pauses the whole harness instead of quietly stalling. Verification switched from trusting a worker's self-report to reading the actual artifact it was supposed to produce. And because none of this should run unattended the first time, a presence gate now checks whether I'm actually at the keyboard: if I'm not, the harness drops to propose-only regardless of tier, no matter how confident the classifier is.

Propose-only means every wake cycle the agent does the full reasoning, produces one recommended action, a blocked-on-human queue capped at three to five items, and the exact handoff payload it would have dispatched, and then executes none of it. That's what it's doing right now. The test suite went from 80 to 111 green over that one day. The internal ticket tracking the hardening sits at six items closed out of seven. The seventh is a shadow-validation week: run the harness in dry-dispatch, watch it propose things for a week, and see if I'd have said yes. It hasn't started yet. The five lanes were right the whole time. The harness is still paused.

## Related

- [Scope Is the Unit, Not Persona: Rethinking How You Load Context Into AI Agents](scope-not-persona-ai-agent-context): how scoping an agent's context is as important as scoping its permissions
- [AI Can Only Say "Suggested": The Invariant That Keeps AI Triage From Flooding Your Team](ai-can-only-say-suggested-flag-is-the-handoff): the handoff boundary where agents stop and humans decide
- [The URL is the bearer: a capability-link surface for AI fetchers](capability-url-is-the-bearer-ai-fetcher-share-surface): capability scoping applied to the read surface an AI agent sees
- [The most valuable line in an agent's ruleset: verify the fix in the browser before claiming it's fixed](close-the-verify-loop-browser-cursor-rule): a runtime enforcement rule that pairs with capability gating
- [Encoding Hard-Won Corrections as Code Defaults Instead of Trusting Yourself to Remember](encode-corrections-as-code-defaults-not-memories): encoding constraints as code rather than relying on editorial memory
- [My agent scheduled a daily job against a repo that didn't exist](agent-scheduled-job-against-hallucinated-repo): what happens when an agent lacks runtime checks on its own assumptions
