An agent will gate three routes and forget the fourth — unless the gate is a function it has to call.

Scatter feature-gating logic inline and consistency depends on luck. Collapse the policy into one named helper and make 'call this gate' a rule, and uniform enforcement becomes the default across every endpoint the agent writes.

An agent will gate three routes and forget the fourth — unless the gate is a function it has to call.

The route the agent just added works. POST /collections — clean handler, validation, a database write, tests green. It also lets a free-tier user create their eleventh collection when the limit is ten. The agent wrote the limit check on POST /items last week, wrote it again on POST /uploads yesterday, and on this one it simply didn’t think to. Three routes gated. One missed. No error, no failing test, just a quiet hole in your business model that nobody finds until a free account is sitting on fifty collections.

This is the most predictable failure an agent has, and it has nothing to do with the agent being dumb. It’s the inevitable result of asking a contextless worker to re-derive the same policy at every call site.

And it isn’t a niche agent quirk. Skipping the check on one endpoint has a name — Broken Function Level Authorization — and it sits at number five on the OWASP API Security Top 10. Its read-side cousin, returning an object by ID without confirming the caller owns it, is number one. Three of the ten most-exploited API risks are the same mistake at three different depths: authorization not enforced, consistently, in every place it should be. An agent that re-derives the gate per route doesn’t dodge that mistake. It manufactures it, one forgotten endpoint at a time, faster than any human ever could.

The mainstream advice is “be explicit in your prompt” — and it scales to exactly zero routes

Section titled “The mainstream advice is “be explicit in your prompt” — and it scales to exactly zero routes”

The standard fix is to tell the agent, every time, “remember to enforce the free-tier limit.” That’s not wrong; it’s just not durable. You’re handing the policy to the agent as conversation, and conversation evaporates. The next session starts cold. The next route is written by a different invocation that never saw your reminder. You’ve made enforcement a property of how well you remembered to nag, which is the same as making it luck.

The deeper issue is what the agent is missing. It’s broad and fast — it’ll scaffold a CRUD route in seconds across any framework. But it has no idea that “can this user do this” is a decision your company has already made and encoded somewhere. To the agent, every route is a blank page. So it reinvents the gate, and reinvention drifts: here it checks user.plan === 'pro', there it checks user.subscriptionTier !== 'free', on the upload route it forgets the seat count entirely. Four call sites, four subtly different policies. Which one is correct? You don’t know either — and that’s the open question worth holding onto.

Collapse the policy into one named, testable place

Section titled “Collapse the policy into one named, testable place”

The fix is structural, not motivational. Stop letting authorization be a thing the agent writes. Make it a thing the agent calls.

// authz/gate.ts — the single source of truth
export type Action = "create_item" | "create_collection" | "upload";
export function canPerform(user: User, action: Action): GateResult {
const limits = PLAN_LIMITS[user.plan]; // one table, one place
switch (action) {
case "create_collection":
return user.collectionCount < limits.collections
? { ok: true }
: { ok: false, reason: "collection_limit_reached" };
case "upload":
return user.storageUsed < limits.storageBytes
? { ok: true }
: { ok: false, reason: "storage_limit_reached" };
// ...one case per gated action
}
}

Now the policy lives in PLAN_LIMITS and canPerform. Every mutation route narrows to the same two lines:

const gate = canPerform(req.user, "create_collection");
if (!gate.ok) return res.status(402).json({ error: gate.reason });

The win isn’t elegance. It’s that the agent can no longer drift, because there’s nothing to reinvent — the decision is made in one function, and the only correct move at a call site is to call it. When you change the free-tier collection limit from ten to five, you change one number. The eleven routes that import the gate inherit it for free.

The deeper reason this works is that it changes the kind of task you’re handing the agent. Inlining a limit check is a generative task: re-derive the policy from scratch, in this file, in this framework’s idioms, and hope the result matches the eleven other places you derived it. Agents are unreliable at that — every generation is an independent draw, and independent draws diverge. Calling a named function is a lookup task: find the gate, pass the action, honor the result. Agents are extremely reliable at lookup. You haven’t made the agent smarter. You’ve converted a probabilistic decision it gets right most of the time into a deterministic call it gets right every time. The security literature has a name for this shape — the gate is a Policy Decision Point, the call site a Policy Enforcement Point — and the entire reason that pattern exists is that decisions made in one place and enforced in many is the only arrangement that survives contact with people who forget. Agents are just people who forget faster.

The same gate kills the read-side bug, too

Section titled “The same gate kills the read-side bug, too”

Plan limits are the function-level case: can this user perform this action at all. The other half of the problem is object-level: can this user touch this specific record. The agent reproduces that one by default too. It writes GET /collections/:id, fetches the row, returns it — and never checks that the row’s ownerId matches the caller. That’s the number-one API vulnerability in the world, written in four clean lines, with passing tests, because the test fixture’s user happens to own the fixture’s collection.

The same collapse fixes it. Ownership is just another action the gate decides:

case "view_collection":
return collection.ownerId === user.id
? { ok: true }
: { ok: false, reason: "not_owner" };
const gate = canPerform(req.user, "view_collection", collection);
if (!gate.ok) return res.status(403).json({ error: gate.reason });

Now “did you check ownership” stops being a thing the agent has to remember on every read route and becomes a thing it calls — identical mechanism, different failure mode. One gate covers both the limit you’ll lose money on and the leak you’ll lose customers over.

Make “call the gate” a rule, not a hope

Section titled “Make “call the gate” a rule, not a hope”

A helper the agent doesn’t know about is just dead code. The second half of the fix is telling the agent the helper exists and is mandatory — durably, not in chat. That’s exactly what rules are for: persistent, project-scoped context the agent loads on every session without being reminded. A few lines in your AGENTS.md do the work:

## Authorization
Every mutation route (POST/PUT/PATCH/DELETE) MUST call `canPerform(user, action)`
from `authz/gate.ts` before writing. Never inline a plan or limit check.
If the action isn't in the `Action` union, add it to the gate — don't gate inline.

This is the whole context-engineering move in miniature. The agent doesn’t know your billing policy and never will. So you write the policy down once, in the place the agent reads on every run, and the gap between “broad capable worker” and “knows our specific rules” closes — not for this session, for all of them. The rule isn’t documentation. It’s the missing context, installed permanently.

Verify the rule held instead of trusting that it did

Section titled “Verify the rule held instead of trusting that it did”

Rules shape behavior; they don’t guarantee it. The agent is far more likely to call the gate now, but “far more likely” still isn’t “always.” So you add a deterministic check that fails loudly when a mutation route skips the helper — a hook that runs on every change the agent makes:

Terminal window
# .agent/hooks/pre-commit — block mutation routes that bypass the gate
routes=$(git diff --cached --name-only | grep 'routes/.*\.ts$')
for f in $routes; do
if grep -Eq '(POST|PUT|PATCH|DELETE)' "$f" && ! grep -q 'canPerform(' "$f"; then
echo "BLOCKED: $f mutates but never calls canPerform()." >&2
exit 1
fi
done

Now the system has teeth. The rule tells the agent what to do; the hook makes “skipped the gate” a hard failure the agent has to fix before the change lands. The reminder you used to repeat by hand is now a gate the machine enforces. You can go a step further and wrap the whole setup-a-gated-route flow in a slash command so a single invocation scaffolds the route and the gate call together — but the rule plus the hook is what makes consistency the default.

Be honest about the blind spot, though, because a grep-based hook gives more confidence than it earns. It checks that the string canPerform( appears in the file. It does not check that the result is honored. An agent under pressure to make the hook pass can write the most literal possible compliance — call the gate, drop the return value on the floor, write the row anyway:

canPerform(req.user, "create_collection"); // called, result ignored
await db.collections.insert(...); // gate green, hole open

The hook is satisfied. The bug shipped. This is the general failure of any presence check standing in for a behavior check, and it’s worth naming so you don’t mistake a passing hook for a proof. The durable answer is to make the gate hard to misuse rather than to grep harder: have canPerform throw on a denied action instead of returning a result a caller can ignore, or return a branded type the insert function refuses to accept without an { ok: true } token. Then “called but ignored” stops compiling, and the hook is a cheap second line, not the only one. A gate you can call and discard is a suggestion; a gate you can’t write past is policy.

The honest boundary: don’t build this on day one. A single route with a single check doesn’t have a consistency problem, and a canPerform switch with one case is ceremony, not safety — you’ve added an indirection the agent now has to learn before it can read the simplest handler. The pattern earns its keep at the second call site, the moment the same decision needs to be true in two places. That’s the trigger: not the first gated route, but the first time a policy has to agree with a copy of itself. Centralize then, and you’re collapsing a duplication that already exists. Centralize before, and you’re predicting one that might not.

There’s also a real version of this that the helper makes worse, not better: when each route’s authorization is genuinely bespoke — different inputs, different external calls, no shared shape — forcing it all through one canPerform signature produces a god-function with a tangle of optional parameters and a switch nobody can hold in their head. At that point the single gate stops being a source of truth and becomes a place bugs hide. The test is whether the routes share a decision, not just share the word “authorization.” If they do, one gate. If they don’t, separate gates that each do one thing — and a rule that points the agent at the right one.

Watch what happens the next time the agent adds the upload route and the create-collection action: it reaches for canPerform on both, because the rule said to and the hook would have stopped it otherwise. Free-tier limits get enforced identically across endpoints that were written days apart by sessions that never met. Not because the agent remembered. Because remembering was never its job.

Authorization scattered inline is a policy you re-decide at every keystroke. Authorization in one gate the agent must call is a policy you decide once and never lose.


For the persistent-context mechanics, see Rules; for the deterministic enforcement layer, Hooks; and to scaffold the gated-route flow in one shot, Slash commands.