Skip to content
381 changes: 381 additions & 0 deletions drafts/cycles-aps-denial-mapping-v0.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,381 @@
# Cycles → APS Tier-1 denial reason mapping (v0.1 draft)

**Status:** DRAFT. Sister artifact to `drafts/cycles-evidence-v0.1.yaml`.

**Purpose:** Specifies the contract for `mapCyclesDenialToFoundation()` —
the function the APS-side Cycles adapter at
`src/v2/payment-rails/cycles/index.ts` (in `aeoess/agent-passport-system`)
will implement to translate Cycles denial signals into APS `PaymentDenial`
Tier-1 `denial_reason` values. Note: in APS, `PaymentReceipt` (success
path) and `PaymentDenial` (denial path) are distinct top-level types —
`denial_reason` is only carried on `PaymentDenial`, never on
`PaymentReceipt` (see `aeoess/agent-passport-system` `src/v2/payment-rails/types.ts`
L92 and L137 respectively, plus the GovernanceHooks `emitReceipt` /
`emitDenial` split at L340 / L344).

The mapping is intentionally **lossy by design**: Cycles emits 15
`ErrorCode` values and 6 known `DecisionReasonCode` values, and APS
Tier-1 is a closed 6-value enum. The richer Cycles-specific detail is
preserved Tier-2 in the `cycles.denial_detail` namespace per
`aeoess/agent-passport-system#25` (comments 4413731054, 4422627045).

## Scope

This doc covers the **v0.1.25 base protocol surface only.** The
`cycles-protocol-extensions-v0.1.26.yaml` extension spec is published
in the repo but **not yet implemented in any Cycles deployment**, so
its three additional ErrorCodes / DecisionReasonCodes
(`ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_DENIED`,
`ACTION_KIND_NOT_ALLOWED`) and the v0.1.26 `DenyDetail` structure are
treated as v0.2 promotion criteria — see the dedicated section below.
Including v0.1.26 mappings now would document a contract that no
deployment exercises and no fixture can verify against.

## Source references

| Layer | Source | Lines |
|---|---|---|
| Cycles `ErrorCode` (closed, 15 values) | `cycles-protocol-v0.yaml` | 429-446 |
| Cycles `DecisionReasonCode` (open string, 6 known values in v0.1.25 base) | `cycles-protocol-v0.yaml` | 487-545 |
| APS Tier-1 `denial_reason` (closed, 6 values) | `aeoess/agent-passport-system#25` comment 4422182941 (citing the APS Tier-1 enum) | n/a |
| Per-row note requirement (lossy compression must be explicit) | `aeoess/agent-passport-system#25` comment 4422627045 | n/a |

The mapping uses ONLY the six closed APS Tier-1 values:

- `no_commerce_scope`
- `spend_limit_exceeded`
- `wallet_revoked`
- `time_window_violation`
- `rail_error`
- `requires_owner_confirmation`

A conformant implementation MUST NOT invent new Tier-1 values. The
closed taxonomy is enforced on three axes in `aeoess/agent-passport-system`:

- `DenialReason` is a closed string-literal TypeScript union in
`src/v2/payment-rails/types.ts`.
- `VALID_DENIAL_REASONS` is the runtime array in
`src/v2/payment-rails/hooks.ts:87-94` — the same six strings.
- `emitDenial` (`hooks.ts:381`) and the two verifier paths
(`hooks.ts:606`, `hooks.ts:629`) all reject values outside this
set with `INVALID_DENIAL_REASON`.

## Mapping table 1 — Cycles `ErrorCode` → APS Tier-1

Surfaces on 4xx/5xx HTTP responses from any of the four Cycles runtime
endpoints. In the CyclesEvidence envelope, these appear in
`payload.error.response.error` (see `drafts/cycles-evidence-v0.1.yaml`
`ErrorResponseMirror`).

**HTTP-status column key.** Status codes are canonical-explicit where the
"compression notes" cite a `cycles-protocol-v0.yaml` line (e.g.
"canonical L55"). For codes the canonical doesn't normatively pin
(`UNAUTHORIZED`, `INTERNAL_ERROR`, `BUDGET_FROZEN`, `BUDGET_CLOSED`,
`MAX_EXTENSIONS_EXCEEDED`), the doc uses the HTTP-standard or
same-pattern-as-`BUDGET_EXCEEDED` inferred value — flagged "(inferred)"
in the column.

| Cycles `ErrorCode` | HTTP status | APS Tier-1 | Compression notes |
|---|---|---|---|
| `INVALID_REQUEST` | 400 (canonical L614, L1782) | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. |
| `UNAUTHORIZED` | 401 (inferred) | `rail_error` | Authentication failure at the Cycles rail. APS has its own auth layer above; this is the downstream rail saying "you didn't auth to me." Not `no_commerce_scope` because that's a scope/policy mismatch, not an auth failure. |
| `FORBIDDEN` | 403 (canonical L30, L1356) | `no_commerce_scope` | Per canonical L1356: "subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN." The APS analog is "this delegation doesn't grant scope to this rail's tenancy." |
| `NOT_FOUND` | 404 (canonical L57) | `rail_error` | Generic NOT_FOUND is rail-internal (e.g., reservation_id not on the ledger). Lossy: when the missing thing is structurally the wallet, the underlying semantic IS `wallet_revoked`, but the wire signal doesn't tell us that — defaulting to `rail_error` keeps APS receipts conservative. |
| `BUDGET_EXCEEDED` | 409 (canonical L48) | `spend_limit_exceeded` | The clean one. This is the canonical non-dry reserve denial path that motivates the whole integration (issue #25). |
| `BUDGET_FROZEN` | 409 (inferred) | `wallet_revoked` | Operator-set FROZEN status on a budget is semantically equivalent to a revoked wallet — the holder can no longer spend until manual reconciliation. Canonical doesn't normatively pin the status for this code; 409 follows the `BUDGET_EXCEEDED` pattern. |
| `BUDGET_CLOSED` | 409 (inferred) | `wallet_revoked` | Permanently closed budget — terminal revocation. Same Tier-1 as `BUDGET_FROZEN`; the closed-vs-frozen distinction is preserved Tier-2 in `cycles.denial_detail.code`. Same inferred-409 caveat as `BUDGET_FROZEN`. |
| `RESERVATION_EXPIRED` | 410 (canonical L55) | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). Note: canonical specifies 410 (Gone), not 409. |
| `RESERVATION_FINALIZED` | 409 (canonical L54) | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. |
| `IDEMPOTENCY_MISMATCH` | 409 (canonical L99) | `rail_error` | Idempotency key replay collision — rail-internal concern. |
| `UNIT_MISMATCH` | 400 (canonical L59) | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. Note: canonical specifies 400 (request-validity error), not 409. |
| `OVERDRAFT_LIMIT_EXCEEDED` | 409 (canonical L49-L51, L80) | `spend_limit_exceeded` | Out of budget plus exhausted overdraft allowance — semantically still "spend limit exceeded." The overdraft-specific detail is preserved Tier-2 in `cycles.denial_detail.code`. |
| `DEBT_OUTSTANDING` | 409 (canonical L52) | `wallet_revoked` | Debt > 0 locks the scope from new reservations until reconciled — equivalent to a temporarily revoked wallet. Some implementations may prefer mapping this to `spend_limit_exceeded`; the `wallet_revoked` choice matches the canonical L900 framing of debt as a state-machine-level block, not a balance-level block. |
| `MAX_EXTENSIONS_EXCEEDED` | 409 (inferred) | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. Canonical doesn't normatively pin the status; 409 follows the reservation-state-error pattern. |
| `INTERNAL_ERROR` | 500 (inferred) | `rail_error` | Server-side error — by definition rail-side. HTTP-standard 500. |

## Mapping table 2 — Cycles `DecisionReasonCode` → APS Tier-1

Surfaces on 200 OK responses with `decision: DENY` (i.e., `/v1/decide`
pre-checks and `dry_run: true` reserves). In the CyclesEvidence envelope,
these appear in `payload.decide.response.reason_code` or
`payload.reserve.response.reason_code`.

`DecisionReasonCode` is an **open string** per canonical L496-L505;
unknown values MUST gracefully degrade to a generic DENY treatment.
The mapping below covers all 6 known values from the v0.1.25 base.

| Cycles `DecisionReasonCode` | APS Tier-1 | Compression notes |
|---|---|---|
| `BUDGET_EXCEEDED` | `spend_limit_exceeded` | Same as ErrorCode counterpart — this is the `/v1/decide` and dry-run version of the same condition. |
| `BUDGET_FROZEN` | `wallet_revoked` | Same as ErrorCode counterpart. |
| `BUDGET_CLOSED` | `wallet_revoked` | Same as ErrorCode counterpart. |
| `BUDGET_NOT_FOUND` | `rail_error` | No budget exists for the requested `(scope, unit)` — rail-side configuration gap, not a user-facing Tier-1 reason. NOTE: on non-dry reserve and `/v1/events`, the same underlying condition surfaces as HTTP 404 `NOT_FOUND` instead (per canonical L514-L516), which maps to `rail_error` via Table 1. The two-paths-same-Tier-1 outcome is intentional. |
| `OVERDRAFT_LIMIT_EXCEEDED` | `spend_limit_exceeded` | Same as ErrorCode counterpart. |
| `DEBT_OUTSTANDING` | `wallet_revoked` | Same as ErrorCode counterpart. |

### v0.1.26 extension is out of scope for v0.1

`cycles-protocol-extensions-v0.1.26.yaml` defines three additional
codes that surface both as ErrorCode (L516-L543, MUST-add to the
ErrorCode enum) and as DecisionReasonCode (L520, alongside `DenyDetail`
at L530-L532):

- `ACTION_QUOTA_EXCEEDED` — per-action-kind or risk-class quota hit
- `ACTION_KIND_DENIED` — action kind in `denied_action_kinds`
- `ACTION_KIND_NOT_ALLOWED` — action kind not in `allowed_action_kinds`

**The extension spec is published but not yet implemented in any
Cycles deployment.** Mapping it now would document a contract no
deployment exercises and no fixture can verify against. v0.1 of this
doc deliberately excludes the extension from both mapping tables and
from the reference implementation to keep the contract honest about
what real envelopes will carry.

The sister `drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror.error`
enum is correspondingly fixed to the 15 v0.1.25 base values, and the
response mirrors have `additionalProperties: false` (no `deny_detail`
field). Both reflect the same v0.1.25-only scope.

**v0.2 promotion criterion.** When a Cycles deployment ships with
the v0.1.26 extension implemented, the v0.2 revision of this doc
will:

1. Add three rows to mapping table 1 (extension ErrorCodes on the
live non-dry path, mapping to `spend_limit_exceeded` /
`no_commerce_scope` / `no_commerce_scope` respectively).
2. Add three rows to mapping table 2 (same codes via DecisionReasonCode
on `/v1/decide` and dry-run reserve), with identical Tier-1
mappings for cross-path consistency.
3. Add a `deny_detail` field to the CyclesEvidence response mirrors
and define a `cycles.denial_detail.deny_detail` Tier-2 ride-along
shape carrying the v0.1.26 `DenyDetail` structure.
4. Add at least one fixture exercising the extension end-to-end.

Until those conditions are met, the v0.1 adapter handles only the
15 base ErrorCodes and 6 base DecisionReasonCodes documented above.

### Unknown `DecisionReasonCode` values

Per canonical L503-L505: "Clients MUST gracefully handle unknown values
— log them and map to generic DENY handling (i.e., 'the request was
denied; treat as a terminal failure even if we don't recognize the
specific reason')."

The adapter MUST mirror this rule: any reason_code outside the 6 known
v0.1.25 base values maps to `rail_error` (the most conservative Tier-1
mapping for "we got a DENY but don't recognize the specific reason").
The raw reason_code MUST still be preserved Tier-2 in
`cycles.denial_detail.code` so audit consumers retain the unrecognized
value byte-for-byte.

## APS Tier-1 reasons with no Cycles source

`requires_owner_confirmation` has **no Cycles counterpart**. The Cycles
model is deterministic decide / reserve / commit / release; there is
no "user confirmation required" intermediate state. No `ErrorCode` or
`DecisionReasonCode` value will ever map to this Tier-1 reason.

This is documented forward-compat: if a future Cycles minor version
adds a `REQUIRES_OWNER_CONFIRMATION` style decision/error
(unlikely — the integration model assumes Cycles is downstream of the
agent's auth/policy layer), this mapping doc gets a row.

## Tier-2 detail preservation (`cycles.denial_detail`)

The lossy compression above only applies at the APS receipt's Tier-1
`denial_reason` field. The full Cycles signal is preserved Tier-2 under
the `cycles.denial_detail` namespace per `aeoess/agent-passport-system#25`:

Example shape (ErrorCode-sourced denial):

```json
{
"denial_reason": "spend_limit_exceeded",
"cycles": {
"denial_detail": {
"layer": "cycles",
"source": "ErrorCode",
"code": "BUDGET_EXCEEDED",
"http_status": 409,
"message": "Insufficient remaining budget for scope tenant=acme",
"request_id": "req_01H...",
"trace_id": "0af7651916cd43dd8448eb211c80319c"
}
}
}
```

The `source` field carries `"ErrorCode"` for 4xx/5xx-sourced denials
or `"DecisionReasonCode"` for 2xx-DENY-sourced denials; ErrorCode-source
envelopes additionally carry `http_status`, `message`, and `request_id`
from the canonical `ErrorResponse` body, while DecisionReasonCode-source
envelopes have only `code` and `trace_id` (the canonical
`DecisionResponse` / `ReservationCreateResponse` body carries no
`request_id` or `http_status`).

The Tier-2 fields populate from the CyclesEvidence
`payload.error.response.*` (for ErrorCode source) or
`payload.decide.response.*` / `payload.reserve.response.*` (for
DecisionReasonCode source) depending on where the denial surfaced.

## TypeScript reference implementation

The shape below maps directly into the adapter at
`src/v2/payment-rails/cycles/index.ts`. The mapping is a pure
data-driven lookup; the rules above are encoded in two const tables and
one function.

```typescript
import type { CyclesEvidence } from './evidence-envelope';
import type { DenialReason } from '../types';

// Local return shape: Tier-1 reason + Tier-2 cycles.denial_detail
// ride-along. The actual emit path (emitDenial in hooks.ts) consumes
// `denial_reason` directly; the `cycles` namespace is attached to the
// PaymentDenial envelope by the caller before signing.
interface FoundationDenialMapping {
denial_reason: DenialReason;
cycles: {
denial_detail: {
layer: 'cycles';
source: 'ErrorCode' | 'DecisionReasonCode';
code: string;
http_status?: number;
message?: string;
request_id?: string;
trace_id?: string;
};
};
}

// Cycles ErrorCode — 15 v0.1.25 base values, canonical
// cycles-protocol-v0.yaml L429-L446. The v0.1.26 extension is OUT
// OF SCOPE for v0.1 (not yet implemented in any deployment); v0.2
// will add ACTION_QUOTA_EXCEEDED / ACTION_KIND_DENIED /
// ACTION_KIND_NOT_ALLOWED.
const ERROR_CODE_TO_TIER1: Record<string, DenialReason> = {
INVALID_REQUEST: 'rail_error',
UNAUTHORIZED: 'rail_error',
FORBIDDEN: 'no_commerce_scope',
NOT_FOUND: 'rail_error',
BUDGET_EXCEEDED: 'spend_limit_exceeded',
BUDGET_FROZEN: 'wallet_revoked',
BUDGET_CLOSED: 'wallet_revoked',
RESERVATION_EXPIRED: 'time_window_violation',
RESERVATION_FINALIZED: 'rail_error',
IDEMPOTENCY_MISMATCH: 'rail_error',
UNIT_MISMATCH: 'rail_error',
OVERDRAFT_LIMIT_EXCEEDED: 'spend_limit_exceeded',
DEBT_OUTSTANDING: 'wallet_revoked',
MAX_EXTENSIONS_EXCEEDED: 'time_window_violation',
INTERNAL_ERROR: 'rail_error',
};

// Cycles DecisionReasonCode — 6 known v0.1.25 base values (open string
// per canonical L487). v0.1.26 extension values are OUT OF SCOPE; see
// note above ERROR_CODE_TO_TIER1.
const DECISION_REASON_TO_TIER1: Record<string, DenialReason> = {
BUDGET_EXCEEDED: 'spend_limit_exceeded',
BUDGET_FROZEN: 'wallet_revoked',
BUDGET_CLOSED: 'wallet_revoked',
BUDGET_NOT_FOUND: 'rail_error',
OVERDRAFT_LIMIT_EXCEEDED: 'spend_limit_exceeded',
DEBT_OUTSTANDING: 'wallet_revoked',
};

/**
* Map a Cycles denial signal (extracted from a CyclesEvidence envelope)
* to an APS Tier-1 denial reason, preserving the full Cycles-side
* detail in the Tier-2 `cycles.denial_detail` namespace.
*
* Returns `null` if the evidence envelope does not represent a denial
* (artifact_type ∉ {error, decide-DENY, reserve-DENY}); callers should
* NOT invoke this on permit-class evidence.
*/
export function mapCyclesDenialToFoundation(
evidence: CyclesEvidence,
): FoundationDenialMapping | null {
const p = evidence.payload;

// Path A: HTTP 4xx/5xx error envelope.
if (p.error) {
const { error: code, message, request_id, trace_id } = p.error.response;
return {
denial_reason: ERROR_CODE_TO_TIER1[code] ?? 'rail_error',
cycles: {
denial_detail: {
layer: 'cycles',
source: 'ErrorCode',
code,
http_status: p.error.http_status,
message,
request_id,
trace_id,
},
},
};
}

// Path B: 2xx DecisionResponse with decision: DENY (either /decide
// or dry_run: true reserve).
const denyResponse =
p.decide?.response.decision === 'DENY' ? p.decide.response :
p.reserve?.response.decision === 'DENY' ? p.reserve.response :
null;

if (denyResponse) {
const code = denyResponse.reason_code ?? 'UNKNOWN';
return {
denial_reason: DECISION_REASON_TO_TIER1[code] ?? 'rail_error',
cycles: {
denial_detail: {
layer: 'cycles',
source: 'DecisionReasonCode',
code,
trace_id: evidence.trace_id,
},
},
};
}

// Permit-class evidence — caller should not have invoked us here.
return null;
}
```

## Round-trip verification

The CyclesEvidence reference fixtures at
`drafts/fixtures/cycles-evidence-v0.1/cases/` cover the denial-path
inputs this function consumes:

| Fixture | Denial source | Expected Tier-1 |
|---|---|---|
| `03-reserve-dry-run-deny.json` | DecisionReasonCode: `BUDGET_EXCEEDED` | `spend_limit_exceeded` |
| `11-reserve-live-budget-exceeded.json` | ErrorCode: `BUDGET_EXCEEDED` | `spend_limit_exceeded` |
| `12-decide-live-forbidden.json` | ErrorCode: `FORBIDDEN` | `no_commerce_scope` |

These three fixtures should be the **golden test cases** for the
adapter's denial-mapping unit tests. They prove both the Tier-1
compression and the Tier-2 detail preservation work end-to-end against
real signed envelopes — a verifier that holds the public key can
recover the canonical Cycles denial code byte-for-byte from
`cycles.denial_detail.code` and compare it back to the signed evidence.

## Promotion path

This doc lives under `drafts/` for review and external feedback. It
will move to a numbered spec file at repo root (e.g.
`cycles-aps-denial-mapping-v0.2.md`) once:

1. The APS-side adapter (`src/v2/payment-rails/cycles/index.ts` in
`aeoess/agent-passport-system`) ships with this mapping implemented.
2. The APS-side SDK PR adding `rail.budget_reservation.{permit,release,denial}.v1`
literals has merged (committed-to but not yet open as of this
draft per `aeoess/agent-passport-system#25` comment 4433715146).
3. End-to-end test coverage demonstrates the round-trip: a Cycles
denial → CyclesEvidence envelope → APS PaymentDenial with the
mapped Tier-1 reason and intact Tier-2 detail, verifiable offline.

Until those land, this doc is the canonical reference for the mapping
contract that the future adapter will implement.
Loading