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

decision

string

Always "deny" for a denial — the coarse allow/deny/defer outcome.

denial_class

enum

One of the five classes below — the recovery category the UI consumes.

reason_code

enum

Fine-grained, additive-only reason that disambiguates audit semantics (e.g. capability_quota_exhausted).

user_message

string

Operator-facing explanation, rendered on the dashboard / spoken by the demo.

recommended_action

string

What the operator (or end user) should do to recover.

override_available

bool

True iff an operator-issued signed override can unblock this denial.

purchase_available

bool

True iff a commerce path (minting a fresh entitlement) can unblock it.

audit_id

string

Correlates the denial to its decision in the WAL — the key autonomy explain takes.

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

override_available

purchase_available

DENY_FINAL

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

DENY_OVERRIDEABLE

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

DENY_PURCHASABLE

An entitlement is missing or exhausted and a commerce path exists for the claim.

Purchase mints a fresh entitlement bundle.

false

true

DENY_RETRYABLE

A transient runtime condition (evaluator briefly unavailable, ledger lock contention, fetch in progress).

The caller retries within a bounded, backed-off window.

false

false

DENY_STALE_STATE

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_available is true only for DENY_OVERRIDEABLE.

  • purchase_available is true only for DENY_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_class and reason_code documents (with optional user_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.decision WAL frame the denial is a flat projection, not the nested object above. The frame already carries the coarse outcome as its own outcome key ("deny") and the correlation id as audit_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 no decision key on the frame — the frame’s outcome is 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.