ADR-0029: A refusal is a Result failure carrying ErrorCategory.Forbidden, and the model's 401 collapses into it
Status: Accepted Date: 2026-08-01 Deciders: Repository owner · Platform architecture Amends: ADR-0007 · 15-Security §4
The third of P4's four authorisation decisions. The first half of it is ADR-0007 applied without argument. The second half is a published model losing a distinction it drew, and this record exists mostly for that.
ADR-0027 decides where the check runs. Something has to come back from it.
ADR-0007 settles this: expected failures are values.
"You are not allowed to do that" is the answer to the question the request asked, not a fault
in the platform. Thrown, it would have to be caught by every trigger's consumer loop, and a bus
consumer would dead-letter a message that was merely not permitted. The engine's one general
catch exists to convert a capability throwing into capability.unhandled
(ErrorCategory.Internal) — routing a refusal through it would report a denied caller as a
platform defect and a 500.
15 §4's flowchart draws two different answers:
D -- no --> X["401"] ← Authenticated, no valid principal
E -- no --> Y["403 + audit event"] ← Permission / Policy / Internal
ErrorCategory has no member for the first. It carries Validation, NotFound, Conflict,
Forbidden, Unavailable and Internal, and its own remarks close the set:
The set is closed on purpose (ADR-0009 §"not extensible"). Add error codes, never categories — a new category would silently change the transport mapping table for every existing consumer.
- Add
ErrorCategory.Unauthenticated. Rejected on the type's own stated rule. Every consumer'sswitchover the category —ToHttpStatusCode,IsTerminal, a gRPC mapping, a dead-letter rule — would silently acquire an unhandled member, andIsTerminal's default arm returnsfalse, so the new member would be retryable by omission. A closed set that is opened once is not closed. - Report it as
Validation(400). Rejected: it is not a malformed request, and a400tells a caller to fix the payload when the remedy is to obtain a credential. - Throw for the unauthenticated case only. Rejected: two mechanisms for one control, and the more surprising one for the more common case.
- Carry the status on the
Error's metadata and have the HTTP mapper prefer it. Rejected: it puts a transport concept on an error the engine produces, which is what ADR-0004 exists to prevent, and it would be read by one transport and ignored by the rest.
A refused step is a Result failure carrying ErrorCategory.Forbidden, never an exception —
and an anonymous caller refused by Authenticated carries the same category, so
15 §4's 401 becomes a 403 at the HTTP boundary.
| Code | Raised when |
|---|---|
authorization.not_authenticated |
the stance needs a principal and the invocation carried none, or one no scheme authenticated |
authorization.permission_denied |
the caller is authenticated and does not hold the named grant |
authorization.stance_not_enforceable |
a stance reached the engine that it cannot decide |
The category drives the transport mapping; the code is what an operator reads. "Sign in" and
"ask for a grant" lead to different repairs, and collapsing those would be the real loss.
ErrorCategory was always the coarse axis — that is what makes it mappable — and Error.Code
was always the fine one.
The refusal names the grant and never the caller. The grant is already public: it is in
flowx.manifest.json, which is the whole point of attaching the stance to the capability, so
naming it tells the caller what to ask for. The caller's claims in an RFC 7807 body would be
the information disclosure 15 §3's Boundary 1 row
refuses. ARefusalNamesTheGrantAndNotTheCaller asserts both halves against the real endpoint.
ErrorCategoryExtensions.IsTerminal already returns true for Forbidden, so no Retry
policy acts on a refusal and no bus consumer treats it as transient. Asking the same question
of the same principal three times gets the same answer three times.
ADR-0027 §2.3 places the check outside the
retry loop anyway, so the flow does not depend on this remaining true — but it is true, and
the two agree.
Positive:
- A refusal composes with everything the engine already does with a failure. The saga
unwinds behind it:
AnAuthenticatedCallerWithoutThePermissionIsRefusedAndTheHoldIsReleasedobserves a caller refused at step 2 and the step-1 inventory hold released, over the real endpoint. An exception would have skipped the unwind, and a refusal that leaked inventory would be the one failure mode that does. ErrorCategorystays closed, so no consumer's mapping table silently acquires a member, and nothing becomes retryable by omission.- The mapping was already written.
Forbidden→403, terminal, never retried, andProblemDetailsMapperneeded no change: the body is built fromError.Code,Categoryand redacted detail, which is whyErrorsDoNotLeakInternalsstill holds by construction. - A caller can still tell the two apart, by code, which is the axis intended for it.
Negative / accepted trade-offs:
- A published model lost a distinction, and this is the cost.
15 §4's flowchart says
401and the platform answers403. A caller cannot distinguish "you did not authenticate" from "you did and it was not enough" by status code — which is what an HTTP client library's retry-with-token logic keys on. They have to readcodein the body. That is a real ergonomic loss for a documented behaviour, and §15's diagram is now amended rather than implemented. - No
WWW-Authenticateheader, and that is why a401would have been wrong anyway. RFC 9110 obliges a401to carry a challenge naming a scheme. The engine is transport-agnostic by construction, so it has no scheme to name and no header to put it in — a401with no challenge is a malformed response, and emitting one would trade a coarse answer for an invalid one. This makes the collapse defensible rather than merely forced, but it does not make it free. stance_not_enforceableis unreachable and still costs a code.FLOWX1037makes it so, and it exists for the plan assembled by hand and the enum member a later release forgets. A code nothing raises is a code in a catalogue that has to be explained — accepted, because aswitchwhose default arm permits is the defect this whole phase existed to end.- Nothing is audited. 15 §4 says "403 + audit
event" and there is no audit event:
Auditis a stage-7 policy that ADR-0025 leaves unexecuted, and no store persists one. The refusal is a returnedErrorand appears in whatever the host logs. Half of that row of the model is unmet, and this record does not close it.
Revisit when: the closed ErrorCategory set gains an authentication member for an
unrelated reason, at which point §1.3's argument has already been overruled and this should
follow rather than persist; or a caller demonstrates that it must distinguish the two by status
code to act correctly — a token-refreshing client is the obvious candidate, and a real one
would settle it; or stage 7's Audit executes, at which point the second half of
15 §4's 403 + audit event becomes implementable
and this record's last negative can be closed.
See also: ADR-0007 · ADR-0027 · ADR-0030 · 15 — Security