Denial Contract¶
When ADK denies a governed action, it does not just say no. Every structured denial carries why (a machine reason code), what the operator can do about it (a recovery class and two booleans), and what to tell the human (an operator-facing message). This is the contract behind operational entitlement and operator override: a denial is an actionable, explainable event, not a dead end.
The contract is frozen — field names, the set of denial classes, and the
class → recovery mapping are stable within a major version. It is produced by the
policy layer, persisted to the audit WAL, and rendered by autonomy explain.
The canonical contract¶
This is the canonical object — the shape the policy layer produces and the
policy/denial package validates. The fields are a stable, closed set.
Downstream surfaces use a projection of it, not this nested object: both the
audit WAL frame and autonomy explain --output json carry the coarse outcome and
audit_id from the decision record itself, plus the six denial-specific fields
(denial_class, reason_code, user_message, recommended_action,
override_available, purchase_available). See Recording under Lifecycle
below for the exact WAL keys.
{
"decision": "deny",
"denial_class": "DENY_PURCHASABLE",
"reason_code": "capability_quota_exhausted",
"user_message": "I can't run explore, your explore capability is used up.",
"recommended_action": "Purchase additional explore capability or wait until quota reset.",
"override_available": false,
"purchase_available": true,
"audit_id": "audit-7f3c"
}
Field |
Type |
Meaning |
|---|---|---|
|
string |
Always |
|
enum |
One of the five classes below — the recovery category the UI consumes. |
|
enum |
Fine-grained, additive-only reason that disambiguates audit semantics (e.g. |
|
string |
Operator-facing explanation, rendered on the dashboard / spoken by the demo. |
|
string |
What the operator (or end user) should do to recover. |
|
bool |
True iff an operator-issued signed override can unblock this denial. |
|
bool |
True iff a commerce path (minting a fresh entitlement) can unblock it. |
|
string |
Correlates the denial to its decision in the WAL — the key |
override_available and purchase_available are derived from
denial_class, never set independently — see the invariant below.
Denial classes¶
Every denial carries exactly one class. Classes are stable; new classes are additive only. The class disambiguates UI behaviour — surfaces consume the class directly and never re-derive it from the booleans.
Class |
When emitted |
Operator recovery |
|
|
|---|---|---|---|---|
|
Terminal denial, no in-band recovery (an inherently disallowed action, a forged grant, a corrupted bundle past last-known-good). |
A different request, or out-of-band remediation. |
false |
false |
|
Policy denied and the author marked the denial overrideable, and no active grant covers the scope. |
The operator issues a signed override grant. |
true |
false |
|
An entitlement is missing or exhausted and a commerce path exists for the claim. |
Purchase mints a fresh entitlement bundle. |
false |
true |
|
A transient runtime condition (evaluator briefly unavailable, ledger lock contention, fetch in progress). |
The caller retries within a bounded, backed-off window. |
false |
false |
|
Policy, entitlement, or override is past TTL with no fresh substitute (most common during a disconnect window expiry). |
Reconnect / refresh — no operator action unblocks it on its own. |
false |
false |
The two demo scenes map directly onto this table: Scene 1 (entitlement exhaustion)
is DENY_PURCHASABLE; Scene 2 (restricted zone) is DENY_OVERRIDEABLE.
The boolean-exclusivity invariant¶
The runtime computes the class first, then derives the two recovery booleans mechanically from it:
override_availableis true only forDENY_OVERRIDEABLE.purchase_availableis true only forDENY_PURCHASABLE.No class derives both — the two booleans are mutually exclusive, and a terminal/transient/stale class derives neither.
Because the mapping is one-directional (class → booleans, never the reverse), a denial can never advertise a recovery its class does not support, and a UI that reads the booleans can never disagree with one that reads the class. Recovery paths are also non-fungible: an override never satisfies an entitlement, and a purchase never substitutes for an override — the class names the single legitimate path.
reason_code¶
reason_code is the fine-grained enum. The class disambiguates UI behaviour; the
code disambiguates audit semantics. The set is additive-only and
semver-locked: a new code is a minor version bump, a rename or removal a breaking
one. The MVP-A scenes ship two codes:
capability_quota_exhausted— a metered capability’s entitlement is used up.restricted_zone_entry_denied— a route enters a restricted zone.
Lifecycle¶
Policy (Rego) author declares denial_class + reason_code (+ messages) on a deny
↓
Evaluator reads them, computes the class, DERIVES the recovery booleans
↓
Runtime stamps the audit_id; enforces fail-closed
↓
Audit WAL the denial rides the autonomy.decision frame (stable wire keys)
↓
autonomy explain reconstructs + renders the contract for an operator
Authoring. A policy marks a denial by returning the top-level
denial_classandreason_codedocuments (with optionaluser_message/recommended_action). An ungraded deny that declares neither keeps only its free-text reason — the contract is opt-in and backward-compatible.Recording. On the
autonomy.decisionWAL frame the denial is a flat projection, not the nested object above. The frame already carries the coarse outcome as its ownoutcomekey ("deny") and the correlation id asaudit_id, so the denial contributes only its six denial-specific keys at the top level of the frame’s attrs:denial_class,reason_code,user_message,recommended_action,override_available,purchase_available. There is nodecisionkey on the frame — the frame’soutcomeis the coarse verdict. An allow, a legacy deny, or a runtime-layer denial contributes none of the six keys, so old audit logs replay unchanged.Explaining.
autonomy explain decision <audit_id>reads the decision frame and renders the class, the reason code, the two recovery booleans, and the operator messages — the first user-visible surface of the contract and the entry point to the operator triage loop.
Stability¶
The contract is frozen so downstream surfaces — dashboards, the voice explanation pipeline, audit tooling — can bind to it:
Field names are fixed; a rename is a breaking change.
Denial classes and reason codes are additive-only.
The class → booleans mapping is fixed.
A schema version pins the frozen shape; a change to any of the above is a deliberate version bump, guarded by freeze tests in the contract package.