A denial with no reason is a bug
Fail-closed is the correct default for an authorization system. If the component that decides "should this action happen" can't reach the component that knows the policy, the safe answer is no. AgentTrust ID is built that way on purpose: when in doubt, deny.
But fail-closed has a failure mode that is easy to miss, and it is worth naming: a denial that doesn't say why.
Two systems that both "deny"
Imagine two authorization systems. Both deny a write action. The first returns:
denied: action 'email:send' is read-only in this session; elevation required
The second returns:
denied
From the caller's seat they look similar. The action didn't happen, the system failed closed, no harm done. But operationally they are worlds apart. The first is a working system telling you something true. The second is a black box, and a black box that always says "no" is indistinguishable from a system that is completely broken.
That is the trap. A component that is supposed to make nuanced allow/deny decisions, but is actually misconfigured and rejecting everything upstream, looks exactly like a strict policy doing its job, as long as the denials carry no reason. Your read paths keep working. Your health checks stay green. And the entire decision layer underneath could be returning a blank "no" to every consequential action without anyone noticing.
Why this matters more for agents
For an agent platform the cost is higher than a confusing error message.
The whole point of per-action authorization is the reason. "This was denied because the session is read-only and no elevation was approved" is the thing a developer debugs against, the thing a security reviewer reads in the audit trail, and the thing that lets you tell a customer why their agent couldn't do something. Strip the reason out, and you've quietly broken the audit story, because an audit entry that records "denied, no reason" is noise.
So we treat it as a hard rule: every decision carries its reason and the tier that made it. Allow or deny, Fast Guard, Spot Guard, or Deep Guard, the response says which path ran and why. A blank denial is a bug, and we surface it as one (guardian returned HTTP 401, empty decision, and so on) rather than letting it masquerade as a decision.
You only know this works if you test the risky path
There is an uncomfortable corollary. The happy path will not tell you any of this.
If your tests only check that a read is allowed, you are exercising the cheapest, safest branch in the whole system, the one most likely to work even when everything behind it is broken. The denial path, the escalation path, the "this is a mutation so it has to go through real policy" path: those are where the bugs that matter live, because those are the branches that actually depend on every downstream component being wired correctly.
So the test that earns its keep is the one that drives the risky action end to end and asserts three things:
- the decision is what you expect,
- the response carries a reason and a tier, not a blank "no", and
- an audit record actually landed.
A green suite that never drives a mutation, never asserts a reason, and never checks the audit write can sit on top of a decision layer that is doing nothing at all. The feature can look shipped and be hollow.
The principle
Deny by default. Always. But make the system explain itself every single time, treat a reasonless denial as a defect rather than a decision, and write the tests that drive the dangerous path so you find out when it stops working, before your users do.
