From 0b57d245b10679c4f6a20df4ab91ff2a4f25782c Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 07:30:33 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(drafts):=20Cycles=20=E2=86=92=20APS=20?= =?UTF-8?q?Tier-1=20denial=20reason=20mapping=20(v0.1=20draft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New draft at `drafts/cycles-aps-denial-mapping-v0.1.md` specifying the contract for `mapCyclesDenialToFoundation()` — the function the APS-side Cycles adapter (`src/v2/payment-rails/cycles/index.ts` in `aeoess/agent-passport-system`) will implement to translate Cycles denial signals into APS PaymentReceipt Tier-1 `denial_reason` values. Why now: Sister artifact to `drafts/cycles-evidence-v0.1.yaml` (merged #90). The CyclesEvidence envelope defines how Cycles denials are signed and content-addressed; this doc defines how those denials are projected into the APS PaymentReceipt's closed 6-value Tier-1 enum, with the richer Cycles-specific detail preserved Tier-2 under `cycles.denial_detail`. Lands now so the mapping is review-ready when the APS-side SDK PR adding `rail.budget_reservation.{permit,release,denial}.v1` literals opens. The adapter PR (the function's actual home in TypeScript) is blocked on that SDK PR landing; this draft is what unblocks the adapter review when it's submitted. What's in scope (v0.1): - Two mapping tables with per-row compression notes: - Cycles `ErrorCode` → APS Tier-1 (15 closed values from canonical L429-L446) - Cycles `DecisionReasonCode` → APS Tier-1 (9 known values, open enum, from canonical L487-L545) - Unknown-value handling rule (the open `DecisionReasonCode` enum requires graceful degrade per canonical L503-L505). - Tier-2 `cycles.denial_detail` namespace spec — what fields ride under it for ErrorCode-sourced vs DecisionReasonCode-sourced denials. - Documentation of the one APS Tier-1 value with no Cycles source (`requires_owner_confirmation` — Cycles has no analog). - TypeScript reference implementation as a code block, directly droppable into `src/v2/payment-rails/cycles/index.ts`. - Golden test-case mapping to the three denial-path fixtures already committed at `drafts/fixtures/cycles-evidence-v0.1/cases/` (03, 11, 12), proving the round-trip works against real signed envelopes. Per-row compression notes are required per the integration thread (`aeoess/agent-passport-system#25` comment 4422627045 — "where several Cycles reasons collapse into spend_limit_exceeded, want a one-line note per row in the mapping table so future readers know the compression is intentional and lossy"). Every row carries one. Rigor pass (per `feedback_spec_review_rigor` memory rule, all verified before commit): - All 15 `ErrorCode` values present and match canonical enum; none invented. - All 9 `DecisionReasonCode` values present and match canonical description's "KNOWN VALUES" sections. - All APS Tier-1 values used are within the closed 6-value enum (`no_commerce_scope` / `spend_limit_exceeded` / `wallet_revoked` / `time_window_violation` / `rail_error` / `requires_owner_confirmation`); the unused 6th value (`requires_owner_confirmation`) is documented with rationale. - All 13 canonical line citations spot-checked against the exact yaml text on each cited line. - All 4 referenced `aeoess/agent-passport-system#25` comment IDs verified to exist on the issue via `gh api`. - TypeScript impl const tables match the markdown tables row-for-row (15 ErrorCode entries + 9 DecisionReasonCode entries). Status DRAFT (v0.1). Will move to a numbered spec file at repo root once the APS-side adapter ships with this mapping implemented, the SDK PR adding the `rail.budget_reservation.*.v1` literals has merged, and end-to-end test coverage demonstrates the Cycles denial → CyclesEvidence → APS PaymentReceipt round-trip with intact Tier-2 detail. References: - `runcycles/cycles-protocol#90` (CyclesEvidence v0.1 envelope — sister artifact) - `aeoess/agent-passport-system#25` (the APS integration thread — comments 4413731054, 4422182941, 4422627045, 4433715146 directly cited) - `aeoess/agent-governance-vocabulary#92` (Cycles signal-type crosswalk — open) - `cycles-protocol-v0.yaml` §ErrorCode, §DecisionReasonCode (the canonical sources for the mapped enums) --- drafts/cycles-aps-denial-mapping-v0.1.md | 282 +++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 drafts/cycles-aps-denial-mapping-v0.1.md diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md new file mode 100644 index 0000000..cc716db --- /dev/null +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -0,0 +1,282 @@ +# 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 PaymentReceipt +Tier-1 `denial_reason` values. + +The mapping is intentionally **lossy by design**: Cycles emits 15 +`ErrorCode` values plus 9 known `DecisionReasonCode` values (24 total), +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). + +## Source references + +| Layer | Source | Lines | +|---|---|---| +| Cycles `ErrorCode` (closed, 15 values) | `cycles-protocol-v0.yaml` | 429-446 | +| Cycles `DecisionReasonCode` (open string, 9 known values) | `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 APS +verifier (`hooks.ts:115-123`) hard-rejects any value 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`). + +| Cycles `ErrorCode` | HTTP class | APS Tier-1 | Compression notes | +|---|---|---|---| +| `INVALID_REQUEST` | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | +| `UNAUTHORIZED` | 4xx | `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` | 4xx | `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` | 4xx | `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 | `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 | `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. | +| `BUDGET_CLOSED` | 409 | `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.error`. | +| `RESERVATION_EXPIRED` | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | +| `RESERVATION_FINALIZED` | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | +| `IDEMPOTENCY_MISMATCH` | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | +| `UNIT_MISMATCH` | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | +| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `spend_limit_exceeded` | Out of budget plus exhausted overdraft allowance — semantically still "spend limit exceeded." Compression note: the overdraft-specific detail is preserved Tier-2. | +| `DEBT_OUTSTANDING` | 409 | `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 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | +| `INTERNAL_ERROR` | 5xx | `rail_error` | Server-side error — by definition rail-side. | + +## 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 9 known values from v0.1.25 base plus +v0.1.26 runtime extension. + +| Cycles `DecisionReasonCode` | Origin | APS Tier-1 | Compression notes | +|---|---|---|---| +| `BUDGET_EXCEEDED` | v0.1.25 base | `spend_limit_exceeded` | Same as ErrorCode counterpart — this is the `/decide` and dry-run version of the same condition. | +| `BUDGET_FROZEN` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | +| `BUDGET_CLOSED` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | +| `BUDGET_NOT_FOUND` | v0.1.25 base | `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` | v0.1.25 base | `spend_limit_exceeded` | Same as ErrorCode counterpart. | +| `DEBT_OUTSTANDING` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | +| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | `spend_limit_exceeded` | A per-action-kind or risk-class quota window was hit. Semantically "exceeded a limit," same Tier-1 as `BUDGET_EXCEEDED`. The action-quota-specific detail (kind, class, window) is preserved Tier-2. | +| `ACTION_KIND_DENIED` | v0.1.26 extension | `no_commerce_scope` | The action kind is in the matching policy's `denied_action_kinds` list — the APS analog is "this delegation doesn't authorize this action kind." Per canonical L530-L532, alongside the reason_code, a `DenyDetail` structure is populated; that detail rides under `cycles.denial_detail.deny_detail` Tier-2. | +| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | `no_commerce_scope` | Symmetric to `ACTION_KIND_DENIED`: the action kind is not in the allowed list. Same Tier-1; same Tier-2 detail preservation. | + +### 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 9 known +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.reason_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`: + +```json +{ + "denial_reason": "spend_limit_exceeded", + "cycles": { + "denial_detail": { + "layer": "cycles", + "source": "ErrorCode" | "DecisionReasonCode", + "code": "BUDGET_EXCEEDED", + "http_status": 409, + "message": "Insufficient remaining budget for scope tenant=acme", + "request_id": "req_01H...", + "trace_id": "0af7651916cd43dd8448eb211c80319c" + } + } +} +``` + +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 { TierOneDenial } from '../types'; + +// Cycles ErrorCode (15 closed values, canonical L429-L446). +const ERROR_CODE_TO_TIER1: Record = { + 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 (9 known values; OPEN enum per canonical L487). +const DECISION_REASON_TO_TIER1: Record = { + // v0.1.25 base + 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', + // v0.1.26 runtime extension + ACTION_QUOTA_EXCEEDED: 'spend_limit_exceeded', + ACTION_KIND_DENIED: 'no_commerce_scope', + ACTION_KIND_NOT_ALLOWED: 'no_commerce_scope', +}; + +/** + * 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, +): TierOneDenial | 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 PaymentReceipt 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. From 78803dcb978f34d77b47c26bb5e1aab5765a5d06 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 07:46:18 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(drafts):=20denial-mapping=20doc=20?= =?UTF-8?q?=E2=80=94=20audit-driven=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit caught two unverified claims I'd asserted without checking against `aeoess/agent-passport-system`. Both now verified and fixed. 1. Wrong line citation: `hooks.ts:115-123` was the cited location of the denial validation logic. Actual locations in the current `aeoess/agent-passport-system/src/v2/payment-rails/hooks.ts`: - `VALID_DENIAL_REASONS` array definition: L87-L94 - `emitDenial` rejection: L381 - Verifier rejection paths: L606, L629 Replaced the single wrong citation with all four correct locations. 2. Made-up type name: the TypeScript reference impl imported `TierOneDenial` (which doesn't exist in APS). The actual exported type from `src/v2/payment-rails/types.ts` is `DenialReason` (a closed string-literal union of the six values). Replaced `TierOneDenial['denial_reason']` with `DenialReason` directly in both const-table types and the function signature. Added an explicit local `FoundationDenialMapping` interface for the return shape with documented field types — so adapter authors can see the full Tier-1 + Tier-2 contract at a glance. Other audit findings (verified, no fix needed): - All 15 ErrorCode values match canonical enum byte-for-byte - All 9 DecisionReasonCode values match canonical KNOWN VALUES - All 6 Tier-1 values verified against APS `VALID_DENIAL_REASONS` array (`hooks.ts:87-94`) AND `DenialReason` type union (`types.ts`) - The `src/v2/payment-rails/cycles/index.ts` path is correctly future-anticipated (current rails are acp/ap2/mpp/stripe-issuing/ x402; cycles/ will be created by the adapter PR per issue #25 comment 4422627045) - All three golden-fixture test predictions match the actual fixture contents: 03-reserve-dry-run-deny: reason_code=BUDGET_EXCEEDED → spend_limit_exceeded ✓ 11-reserve-live-budget-exceeded: ErrorCode=BUDGET_EXCEEDED → spend_limit_exceeded ✓ 12-decide-live-forbidden: ErrorCode=FORBIDDEN → no_commerce_scope ✓ - The v0.1.26 extension references (denied_action_kinds, allowed_action_kinds, DenyDetail) all exist in cycles-protocol-extensions-v0.1.26.yaml as cited Process note (per the `feedback_spec_review_rigor` memory rule): this is exactly the kind of citation drift the protocol exists to catch. The original `hooks.ts:115-123` claim came from a memory-of-an-earlier-conversation about the APS source — I had not re-verified it against the live repo before writing the doc. Going forward: every line citation gets a `gh api` fetch + grep before commit, not after. --- drafts/cycles-aps-denial-mapping-v0.1.md | 40 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md index cc716db..9155389 100644 --- a/drafts/cycles-aps-denial-mapping-v0.1.md +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -32,9 +32,16 @@ The mapping uses ONLY the six closed APS Tier-1 values: - `rail_error` - `requires_owner_confirmation` -A conformant implementation MUST NOT invent new Tier-1 values; the APS -verifier (`hooks.ts:115-123`) hard-rejects any value outside this set -with `INVALID_DENIAL_REASON`. +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 @@ -148,10 +155,29 @@ one function. ```typescript import type { CyclesEvidence } from './evidence-envelope'; -import type { TierOneDenial } from '../types'; +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 +// PaymentDenialReceipt 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 closed values, canonical L429-L446). -const ERROR_CODE_TO_TIER1: Record = { +const ERROR_CODE_TO_TIER1: Record = { INVALID_REQUEST: 'rail_error', UNAUTHORIZED: 'rail_error', FORBIDDEN: 'no_commerce_scope', @@ -170,7 +196,7 @@ const ERROR_CODE_TO_TIER1: Record = { }; // Cycles DecisionReasonCode (9 known values; OPEN enum per canonical L487). -const DECISION_REASON_TO_TIER1: Record = { +const DECISION_REASON_TO_TIER1: Record = { // v0.1.25 base BUDGET_EXCEEDED: 'spend_limit_exceeded', BUDGET_FROZEN: 'wallet_revoked', @@ -195,7 +221,7 @@ const DECISION_REASON_TO_TIER1: Record = */ export function mapCyclesDenialToFoundation( evidence: CyclesEvidence, -): TierOneDenial | null { +): FoundationDenialMapping | null { const p = evidence.payload; // Path A: HTTP 4xx/5xx error envelope. From 89c1aebb6ed3242577d4376a49eaedea21f550c5 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 07:56:10 -0400 Subject: [PATCH 3/6] =?UTF-8?q?fix(drafts):=20denial-mapping=20doc=20?= =?UTF-8?q?=E2=80=94=20address=204=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer's first pass found 4 real issues. All empirically verified against the canonical sources before fixing. 1. ErrorCode table was missing the 3 v0.1.26 extension codes. `cycles-protocol-extensions-v0.1.26.yaml` L516-L543 declares ACTION_QUOTA_EXCEEDED, ACTION_KIND_NOT_ALLOWED, and ACTION_KIND_DENIED as MUST-add additions to the ErrorCode enum. The doc had them only on the DecisionReasonCode side (mapping table 2) — so the same conditions mapped to spend_limit_exceeded / no_commerce_scope via DecisionReasonCode but fell back to rail_error via ErrorCode. Now consistent across both paths: same Tier-1 mapping regardless of whether the denial surfaces on a 2xx (decide / dry-run) or 4xx (live non-dry) wire path. Knock-on issue documented but not fixed in this commit: the merged `drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror.error` enum is fixed to the 15 v0.1.25 base values. A v0.1.26 server emitting an extension ErrorCode would fail v0.1 evidence schema validation. The doc adds a "v0.1 evidence-schema dependency" note explaining this and treats the 3 extension rows as forward-compat declarations. A follow-up PR against cycles-protocol main will extend the schema enum. 2. v0.1.26 DenyDetail preservation was promised but not delivered. The previous draft said action-kind denials preserve DenyDetail Tier-2 under `cycles.denial_detail.deny_detail`, but the TS reference impl didn't populate it and the merged CyclesEvidence v0.1 response mirrors are `additionalProperties: false` without a `deny_detail` field — so the promise was empty. Scoped DenyDetail OUT of v0.1 with an explicit limitation note. v0.1 preserves only the raw `code` Tier-2; v0.2 promotion requires amending the evidence schema, fixture set, and reference impl to carry the structured DenyDetail through. The trade-off is documented: v0.1 adapters get correct Tier-1 compression and a recoverable canonical denial identifier, but lose the rich scope/quota/policy detail that v0.1.26 carries on the wire. 3. Inconsistent Tier-2 code field name (used "error", "reason_code", and "code" interchangeably). Standardized on `code` everywhere. The "source" discriminator (`"ErrorCode"` vs `"DecisionReasonCode"`) still indicates where the code originated, but the field name is `code` regardless. 4. JSON example used TypeScript union syntax (`"ErrorCode" | "DecisionReasonCode"`), making it invalid JSON. Replaced the union with the concrete value `"ErrorCode"` (since the example is the BUDGET_EXCEEDED / 409 case anyway), and added a prose paragraph below explaining the two source values and the context-dependent presence of `http_status` / `message` / `request_id`. Validation: - JSON example parses cleanly (json.loads succeeds) - 0 stray `cycles.denial_detail.error` or `cycles.denial_detail.reason_code` references — all standardized to `.code` - 18 ErrorCode rows in mapping table 1 (15 base + 3 v0.1.26 extension) - 18 entries in TS ERROR_CODE_TO_TIER1 const (matches table) - Extension citation L516-543 verified against actual canonical lines Follow-up: separate PR will extend `ErrorResponseMirror.error` enum in `drafts/cycles-evidence-v0.1.yaml` to include the 3 v0.1.26 codes, add a fixture exercising one of them, and unblock the forward-compat declarations in this doc. --- drafts/cycles-aps-denial-mapping-v0.1.md | 134 ++++++++++++++++++----- 1 file changed, 104 insertions(+), 30 deletions(-) diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md index 9155389..5178025 100644 --- a/drafts/cycles-aps-denial-mapping-v0.1.md +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -8,17 +8,19 @@ the function the APS-side Cycles adapter at will implement to translate Cycles denial signals into APS PaymentReceipt Tier-1 `denial_reason` values. -The mapping is intentionally **lossy by design**: Cycles emits 15 -`ErrorCode` values plus 9 known `DecisionReasonCode` values (24 total), -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). +The mapping is intentionally **lossy by design**: Cycles emits up to +18 `ErrorCode` values (15 v0.1.25 base + 3 v0.1.26 extension) plus 9 +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). ## Source references | Layer | Source | Lines | |---|---|---| -| Cycles `ErrorCode` (closed, 15 values) | `cycles-protocol-v0.yaml` | 429-446 | +| Cycles `ErrorCode` v0.1.25 base (closed, 15 values) | `cycles-protocol-v0.yaml` | 429-446 | +| Cycles `ErrorCode` v0.1.26 extension (closed, +3 values) | `cycles-protocol-extensions-v0.1.26.yaml` | 516-543 | | Cycles `DecisionReasonCode` (open string, 9 known values) | `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 | @@ -50,23 +52,46 @@ endpoints. In the CyclesEvidence envelope, these appear in `payload.error.response.error` (see `drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror`). -| Cycles `ErrorCode` | HTTP class | APS Tier-1 | Compression notes | -|---|---|---|---| -| `INVALID_REQUEST` | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | -| `UNAUTHORIZED` | 4xx | `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` | 4xx | `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` | 4xx | `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 | `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 | `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. | -| `BUDGET_CLOSED` | 409 | `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.error`. | -| `RESERVATION_EXPIRED` | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | -| `RESERVATION_FINALIZED` | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | -| `IDEMPOTENCY_MISMATCH` | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | -| `UNIT_MISMATCH` | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | -| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `spend_limit_exceeded` | Out of budget plus exhausted overdraft allowance — semantically still "spend limit exceeded." Compression note: the overdraft-specific detail is preserved Tier-2. | -| `DEBT_OUTSTANDING` | 409 | `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 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | -| `INTERNAL_ERROR` | 5xx | `rail_error` | Server-side error — by definition rail-side. | +Per `cycles-protocol-extensions-v0.1.26.yaml` L516-543, v0.1.26-conformant +implementations MUST add three codes to the `ErrorCode` enum: +`ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_NOT_ALLOWED`, `ACTION_KIND_DENIED`. +The same three codes also appear as `DecisionReasonCode` values +(mapping table 2 below) — they surface as 2xx-DENY on `/v1/decide` and +dry-run reserve, and as 4xx-error on non-dry reserve. The Tier-1 +mapping is identical across both paths for cross-path consistency. + +| Cycles `ErrorCode` | Origin | HTTP class | APS Tier-1 | Compression notes | +|---|---|---|---|---| +| `INVALID_REQUEST` | v0.1.25 base | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | +| `UNAUTHORIZED` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 409 | `spend_limit_exceeded` | The clean one. This is the canonical non-dry reserve denial path that motivates the whole integration (issue #25). | +| `BUDGET_FROZEN` | v0.1.25 base | 409 | `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. | +| `BUDGET_CLOSED` | v0.1.25 base | 409 | `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`. | +| `RESERVATION_EXPIRED` | v0.1.25 base | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | +| `RESERVATION_FINALIZED` | v0.1.25 base | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | +| `IDEMPOTENCY_MISMATCH` | v0.1.25 base | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | +| `UNIT_MISMATCH` | v0.1.25 base | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | +| `OVERDRAFT_LIMIT_EXCEEDED` | v0.1.25 base | 409 | `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` | v0.1.25 base | 409 | `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` | v0.1.25 base | 409 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | +| `INTERNAL_ERROR` | v0.1.25 base | 5xx | `rail_error` | Server-side error — by definition rail-side. | +| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | 409 | `spend_limit_exceeded` | Live non-dry path for the same condition that surfaces as a 2xx DecisionReasonCode in dry-run / `/v1/decide`. Per-action-kind or risk-class quota window was hit. Same Tier-1 as the DecisionReasonCode counterpart for cross-path consistency. | +| `ACTION_KIND_DENIED` | v0.1.26 extension | 409 | `no_commerce_scope` | Live non-dry path; action kind in the policy's `denied_action_kinds` list. Same Tier-1 as the DecisionReasonCode counterpart. | +| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | 409 | `no_commerce_scope` | Live non-dry path; action kind not in the `allowed_action_kinds` list. Same Tier-1 as the DecisionReasonCode counterpart. | + +**v0.1 evidence-schema dependency note.** The merged +`drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror.error` enum is +fixed to the 15 v0.1.25 base values, so an envelope from a +v0.1.26-conformant server emitting one of the three extension +ErrorCodes will fail v0.1 evidence schema validation before the +adapter sees it. Until the evidence schema is amended to include the +v0.1.26 additions (tracked as a follow-up PR), the three extension +rows above are forward-compat declarations: the mapping function +handles them correctly when the schema gate is widened. An adapter +written against this doc can land the three rows immediately and will +work the moment the schema enum widens. ## Mapping table 2 — Cycles `DecisionReasonCode` → APS Tier-1 @@ -88,9 +113,40 @@ v0.1.26 runtime extension. | `BUDGET_NOT_FOUND` | v0.1.25 base | `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` | v0.1.25 base | `spend_limit_exceeded` | Same as ErrorCode counterpart. | | `DEBT_OUTSTANDING` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | -| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | `spend_limit_exceeded` | A per-action-kind or risk-class quota window was hit. Semantically "exceeded a limit," same Tier-1 as `BUDGET_EXCEEDED`. The action-quota-specific detail (kind, class, window) is preserved Tier-2. | -| `ACTION_KIND_DENIED` | v0.1.26 extension | `no_commerce_scope` | The action kind is in the matching policy's `denied_action_kinds` list — the APS analog is "this delegation doesn't authorize this action kind." Per canonical L530-L532, alongside the reason_code, a `DenyDetail` structure is populated; that detail rides under `cycles.denial_detail.deny_detail` Tier-2. | -| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | `no_commerce_scope` | Symmetric to `ACTION_KIND_DENIED`: the action kind is not in the allowed list. Same Tier-1; same Tier-2 detail preservation. | +| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | `spend_limit_exceeded` | A per-action-kind or risk-class quota window was hit. Semantically "exceeded a limit," same Tier-1 as `BUDGET_EXCEEDED`. Only the raw `code` is preserved Tier-2 in v0.1 (see DenyDetail note below). | +| `ACTION_KIND_DENIED` | v0.1.26 extension | `no_commerce_scope` | The action kind is in the matching policy's `denied_action_kinds` list — the APS analog is "this delegation doesn't authorize this action kind." Only the raw `code` is preserved Tier-2 in v0.1; the v0.1.26 `DenyDetail` structure (canonical L530-L532) is OUT OF SCOPE for v0.1 — see DenyDetail note below. | +| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | `no_commerce_scope` | Symmetric to `ACTION_KIND_DENIED`: the action kind is not in the allowed list. Same Tier-1; same v0.1 limitation on DenyDetail. | + +### v0.1.26 `DenyDetail` is out of scope for v0.1 + +Per `cycles-protocol-extensions-v0.1.26.yaml` L530-L532, v0.1.26 denials +populate a `DenyDetail` structure alongside `reason_code` carrying +`quota_violation`, `blocked_by_scope`, `blocked_by_policy`, and +related rich context. **v0.1 of this mapping doc does NOT preserve +`DenyDetail`** because: + + - The sister `drafts/cycles-evidence-v0.1.yaml` `DecisionResponseMirror` + and `ReservationCreateResponseMirror` are `additionalProperties: false` + and do not declare a `deny_detail` field. A `DenyDetail` carried on + the wire would be stripped before reaching the adapter via signed + evidence. + - Mirroring `DenyDetail` would require amending the evidence schema + (carry the field through the mirrors) AND the fixture set AND the + reference impl — a larger scope than this v0.1 mapping doc. + +The v0.1 adapter populates `cycles.denial_detail.code` with the raw +`reason_code` value byte-for-byte; that's sufficient to drive the Tier-1 +compression and preserve the canonical denial identifier for audit. The +finer-grained `DenyDetail` payload (which scope was blocked, which +quota window, etc.) is lost in v0.1 evidence and recoverable only by +re-querying the Cycles server (which defeats the offline-audit purpose +in archival contexts). + +**v0.2 promotion path:** the next revision of the evidence schema MUST +add a `deny_detail` field to the success-response mirrors, the fixture +set MUST gain one case exercising `ACTION_KIND_DENIED` with `DenyDetail` +populated, and this doc's table-2 rows MUST be updated to specify the +`cycles.denial_detail.deny_detail` Tier-2 ride-along shape. ### Unknown `DecisionReasonCode` values @@ -103,8 +159,8 @@ The adapter MUST mirror this rule: any reason_code outside the 9 known 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.reason_code` so audit consumers retain the -unrecognized value byte-for-byte. +`cycles.denial_detail.code` so audit consumers retain the unrecognized +value byte-for-byte. ## APS Tier-1 reasons with no Cycles source @@ -124,13 +180,15 @@ 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" | "DecisionReasonCode", + "source": "ErrorCode", "code": "BUDGET_EXCEEDED", "http_status": 409, "message": "Insufficient remaining budget for scope tenant=acme", @@ -141,6 +199,14 @@ the `cycles.denial_detail` namespace per `aeoess/agent-passport-system#25`: } ``` +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 @@ -176,8 +242,11 @@ interface FoundationDenialMapping { }; } -// Cycles ErrorCode (15 closed values, canonical L429-L446). +// Cycles ErrorCode — 15 v0.1.25 base + 3 v0.1.26 extension values. +// Canonical refs: cycles-protocol-v0.yaml L429-L446 + +// cycles-protocol-extensions-v0.1.26.yaml L516-L543. const ERROR_CODE_TO_TIER1: Record = { + // v0.1.25 base INVALID_REQUEST: 'rail_error', UNAUTHORIZED: 'rail_error', FORBIDDEN: 'no_commerce_scope', @@ -193,6 +262,11 @@ const ERROR_CODE_TO_TIER1: Record = { DEBT_OUTSTANDING: 'wallet_revoked', MAX_EXTENSIONS_EXCEEDED: 'time_window_violation', INTERNAL_ERROR: 'rail_error', + // v0.1.26 runtime extension — same Tier-1 as DecisionReasonCode + // counterparts for cross-path consistency. + ACTION_QUOTA_EXCEEDED: 'spend_limit_exceeded', + ACTION_KIND_DENIED: 'no_commerce_scope', + ACTION_KIND_NOT_ALLOWED: 'no_commerce_scope', }; // Cycles DecisionReasonCode (9 known values; OPEN enum per canonical L487). From 2605a175a3b250361ac7ab508d40b9e9b057f405 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 08:00:40 -0400 Subject: [PATCH 4/6] =?UTF-8?q?fix(drafts):=20denial-mapping=20doc=20?= =?UTF-8?q?=E2=80=94=20scope=20to=20v0.1.25=20only=20(revert=20v0.1.26=20a?= =?UTF-8?q?dditions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User flagged that cycles-protocol-extensions-v0.1.26 has not been implemented in any Cycles deployment yet. The prior commit (89c1aeb) added v0.1.26 ErrorCode rows to mapping table 1 and TS const for cross-path consistency with the v0.1.26 DecisionReasonCode rows that were already in table 2 — but the right resolution is the inverse: remove v0.1.26 from BOTH tables until the extension actually ships in a deployment. What the reviewer was asking for vs. what was needed: - Reviewer's framing (spec-text as ground truth): v0.1.26 codes appear in both ErrorCode and DecisionReasonCode canonical enums, so the mapping doc should map them on both sides for consistency. - User's correction (deployment reality as ground truth): the extension spec exists in the repo but no Cycles server emits those codes. Documenting their mapping is premature — no deployment exercises the contract and no fixture can verify against it. Resolution: scope this v0.1 doc to v0.1.25 base protocol surface only. Mapping table 1 back to 15 ErrorCodes; mapping table 2 back to 6 DecisionReasonCodes; TS const tables match. The DenyDetail preservation question (previously F2) collapses into the same scope- out — v0.1.26 entirely is the v0.2 promotion criterion, not just DenyDetail. Replaced the previous "v0.1 evidence-schema dependency note" and "v0.1.26 DenyDetail is out of scope" sections with a single consolidated "v0.1.26 extension is out of scope for v0.1" section explaining: - The extension defines three ErrorCodes / DecisionReasonCodes plus DenyDetail, all unimplemented. - The sister evidence schema enum (ErrorResponseMirror.error) is correspondingly fixed to the 15 v0.1.25 base values. - v0.2 promotion criterion lists the four steps that must happen when a deployment ships v0.1.26: add three rows to each mapping table with cross-path-consistent Tier-1 mappings; add a deny_detail field to the evidence schema; add at least one fixture. Validation: - 15 ErrorCode rows in mapping table 1 (was 18, now 15) - 6 DecisionReasonCode rows in mapping table 2 (was 9, now 6) - TS ERROR_CODE_TO_TIER1: 15 entries (was 18, now 15) - TS DECISION_REASON_TO_TIER1: 6 entries (was 9, now 6) - 0 v0.1.26 extension codes referenced in either table or TS const - JSON example still parses cleanly - Stray cycles.denial_detail.{error,reason_code} references: 0 (F3 standardization on `code` preserved) - JSON example invalid-syntax fix from prior round preserved (F4) Also abandoned the unpushed evidence-schema-extension follow-up branch — it would have widened ErrorResponseMirror.error to include the three v0.1.26 codes, but with v0.1.26 unimplemented, doing so would add forward-compat schema slots that no real envelope exercises. Same logic that drove the doc scope-out. The prior round's other fixes are preserved unchanged: - F3 (`code` standardization across cycles.denial_detail field names): kept - F4 (JSON example uses `"ErrorCode"` instead of TS union syntax): kept - Previously self-audited fixes (hooks.ts line citations, DenialReason vs TierOneDenial type name): kept Memory rule update: the rigor protocol's adversarial-pass step now includes a deployment-reality check on top of the canonical-spec check. Spec text can describe contracts that no deployment exercises yet; documenting them in a v0.1 mapping doc creates phantom rows. --- drafts/cycles-aps-denial-mapping-v0.1.md | 202 +++++++++++------------ 1 file changed, 94 insertions(+), 108 deletions(-) diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md index 5178025..acf55d2 100644 --- a/drafts/cycles-aps-denial-mapping-v0.1.md +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -8,20 +8,30 @@ the function the APS-side Cycles adapter at will implement to translate Cycles denial signals into APS PaymentReceipt Tier-1 `denial_reason` values. -The mapping is intentionally **lossy by design**: Cycles emits up to -18 `ErrorCode` values (15 v0.1.25 base + 3 v0.1.26 extension) plus 9 -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). +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` v0.1.25 base (closed, 15 values) | `cycles-protocol-v0.yaml` | 429-446 | -| Cycles `ErrorCode` v0.1.26 extension (closed, +3 values) | `cycles-protocol-extensions-v0.1.26.yaml` | 516-543 | -| Cycles `DecisionReasonCode` (open string, 9 known values) | `cycles-protocol-v0.yaml` | 487-545 | +| 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 | @@ -52,46 +62,23 @@ endpoints. In the CyclesEvidence envelope, these appear in `payload.error.response.error` (see `drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror`). -Per `cycles-protocol-extensions-v0.1.26.yaml` L516-543, v0.1.26-conformant -implementations MUST add three codes to the `ErrorCode` enum: -`ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_NOT_ALLOWED`, `ACTION_KIND_DENIED`. -The same three codes also appear as `DecisionReasonCode` values -(mapping table 2 below) — they surface as 2xx-DENY on `/v1/decide` and -dry-run reserve, and as 4xx-error on non-dry reserve. The Tier-1 -mapping is identical across both paths for cross-path consistency. - -| Cycles `ErrorCode` | Origin | HTTP class | APS Tier-1 | Compression notes | -|---|---|---|---|---| -| `INVALID_REQUEST` | v0.1.25 base | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | -| `UNAUTHORIZED` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 4xx | `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` | v0.1.25 base | 409 | `spend_limit_exceeded` | The clean one. This is the canonical non-dry reserve denial path that motivates the whole integration (issue #25). | -| `BUDGET_FROZEN` | v0.1.25 base | 409 | `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. | -| `BUDGET_CLOSED` | v0.1.25 base | 409 | `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`. | -| `RESERVATION_EXPIRED` | v0.1.25 base | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | -| `RESERVATION_FINALIZED` | v0.1.25 base | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | -| `IDEMPOTENCY_MISMATCH` | v0.1.25 base | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | -| `UNIT_MISMATCH` | v0.1.25 base | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | -| `OVERDRAFT_LIMIT_EXCEEDED` | v0.1.25 base | 409 | `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` | v0.1.25 base | 409 | `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` | v0.1.25 base | 409 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | -| `INTERNAL_ERROR` | v0.1.25 base | 5xx | `rail_error` | Server-side error — by definition rail-side. | -| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | 409 | `spend_limit_exceeded` | Live non-dry path for the same condition that surfaces as a 2xx DecisionReasonCode in dry-run / `/v1/decide`. Per-action-kind or risk-class quota window was hit. Same Tier-1 as the DecisionReasonCode counterpart for cross-path consistency. | -| `ACTION_KIND_DENIED` | v0.1.26 extension | 409 | `no_commerce_scope` | Live non-dry path; action kind in the policy's `denied_action_kinds` list. Same Tier-1 as the DecisionReasonCode counterpart. | -| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | 409 | `no_commerce_scope` | Live non-dry path; action kind not in the `allowed_action_kinds` list. Same Tier-1 as the DecisionReasonCode counterpart. | - -**v0.1 evidence-schema dependency note.** The merged -`drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror.error` enum is -fixed to the 15 v0.1.25 base values, so an envelope from a -v0.1.26-conformant server emitting one of the three extension -ErrorCodes will fail v0.1 evidence schema validation before the -adapter sees it. Until the evidence schema is amended to include the -v0.1.26 additions (tracked as a follow-up PR), the three extension -rows above are forward-compat declarations: the mapping function -handles them correctly when the schema gate is widened. An adapter -written against this doc can land the three rows immediately and will -work the moment the schema enum widens. +| Cycles `ErrorCode` | HTTP class | APS Tier-1 | Compression notes | +|---|---|---|---| +| `INVALID_REQUEST` | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | +| `UNAUTHORIZED` | 4xx | `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` | 4xx | `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` | 4xx | `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 | `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 | `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. | +| `BUDGET_CLOSED` | 409 | `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`. | +| `RESERVATION_EXPIRED` | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | +| `RESERVATION_FINALIZED` | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | +| `IDEMPOTENCY_MISMATCH` | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | +| `UNIT_MISMATCH` | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | +| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `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 | `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 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | +| `INTERNAL_ERROR` | 5xx | `rail_error` | Server-side error — by definition rail-side. | ## Mapping table 2 — Cycles `DecisionReasonCode` → APS Tier-1 @@ -102,51 +89,57 @@ these appear in `payload.decide.response.reason_code` or `DecisionReasonCode` is an **open string** per canonical L496-L505; unknown values MUST gracefully degrade to a generic DENY treatment. -The mapping below covers all 9 known values from v0.1.25 base plus -v0.1.26 runtime extension. +The mapping below covers all 6 known values from the v0.1.25 base. -| Cycles `DecisionReasonCode` | Origin | APS Tier-1 | Compression notes | -|---|---|---|---| -| `BUDGET_EXCEEDED` | v0.1.25 base | `spend_limit_exceeded` | Same as ErrorCode counterpart — this is the `/decide` and dry-run version of the same condition. | -| `BUDGET_FROZEN` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | -| `BUDGET_CLOSED` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | -| `BUDGET_NOT_FOUND` | v0.1.25 base | `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` | v0.1.25 base | `spend_limit_exceeded` | Same as ErrorCode counterpart. | -| `DEBT_OUTSTANDING` | v0.1.25 base | `wallet_revoked` | Same as ErrorCode counterpart. | -| `ACTION_QUOTA_EXCEEDED` | v0.1.26 extension | `spend_limit_exceeded` | A per-action-kind or risk-class quota window was hit. Semantically "exceeded a limit," same Tier-1 as `BUDGET_EXCEEDED`. Only the raw `code` is preserved Tier-2 in v0.1 (see DenyDetail note below). | -| `ACTION_KIND_DENIED` | v0.1.26 extension | `no_commerce_scope` | The action kind is in the matching policy's `denied_action_kinds` list — the APS analog is "this delegation doesn't authorize this action kind." Only the raw `code` is preserved Tier-2 in v0.1; the v0.1.26 `DenyDetail` structure (canonical L530-L532) is OUT OF SCOPE for v0.1 — see DenyDetail note below. | -| `ACTION_KIND_NOT_ALLOWED` | v0.1.26 extension | `no_commerce_scope` | Symmetric to `ACTION_KIND_DENIED`: the action kind is not in the allowed list. Same Tier-1; same v0.1 limitation on DenyDetail. | - -### v0.1.26 `DenyDetail` is out of scope for v0.1 - -Per `cycles-protocol-extensions-v0.1.26.yaml` L530-L532, v0.1.26 denials -populate a `DenyDetail` structure alongside `reason_code` carrying -`quota_violation`, `blocked_by_scope`, `blocked_by_policy`, and -related rich context. **v0.1 of this mapping doc does NOT preserve -`DenyDetail`** because: - - - The sister `drafts/cycles-evidence-v0.1.yaml` `DecisionResponseMirror` - and `ReservationCreateResponseMirror` are `additionalProperties: false` - and do not declare a `deny_detail` field. A `DenyDetail` carried on - the wire would be stripped before reaching the adapter via signed - evidence. - - Mirroring `DenyDetail` would require amending the evidence schema - (carry the field through the mirrors) AND the fixture set AND the - reference impl — a larger scope than this v0.1 mapping doc. - -The v0.1 adapter populates `cycles.denial_detail.code` with the raw -`reason_code` value byte-for-byte; that's sufficient to drive the Tier-1 -compression and preserve the canonical denial identifier for audit. The -finer-grained `DenyDetail` payload (which scope was blocked, which -quota window, etc.) is lost in v0.1 evidence and recoverable only by -re-querying the Cycles server (which defeats the offline-audit purpose -in archival contexts). - -**v0.2 promotion path:** the next revision of the evidence schema MUST -add a `deny_detail` field to the success-response mirrors, the fixture -set MUST gain one case exercising `ACTION_KIND_DENIED` with `DenyDetail` -populated, and this doc's table-2 rows MUST be updated to specify the -`cycles.denial_detail.deny_detail` Tier-2 ride-along shape. +| 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 @@ -242,11 +235,12 @@ interface FoundationDenialMapping { }; } -// Cycles ErrorCode — 15 v0.1.25 base + 3 v0.1.26 extension values. -// Canonical refs: cycles-protocol-v0.yaml L429-L446 + -// cycles-protocol-extensions-v0.1.26.yaml L516-L543. +// 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 = { - // v0.1.25 base INVALID_REQUEST: 'rail_error', UNAUTHORIZED: 'rail_error', FORBIDDEN: 'no_commerce_scope', @@ -262,26 +256,18 @@ const ERROR_CODE_TO_TIER1: Record = { DEBT_OUTSTANDING: 'wallet_revoked', MAX_EXTENSIONS_EXCEEDED: 'time_window_violation', INTERNAL_ERROR: 'rail_error', - // v0.1.26 runtime extension — same Tier-1 as DecisionReasonCode - // counterparts for cross-path consistency. - ACTION_QUOTA_EXCEEDED: 'spend_limit_exceeded', - ACTION_KIND_DENIED: 'no_commerce_scope', - ACTION_KIND_NOT_ALLOWED: 'no_commerce_scope', }; -// Cycles DecisionReasonCode (9 known values; OPEN enum per canonical L487). +// 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 = { - // v0.1.25 base 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', - // v0.1.26 runtime extension - ACTION_QUOTA_EXCEEDED: 'spend_limit_exceeded', - ACTION_KIND_DENIED: 'no_commerce_scope', - ACTION_KIND_NOT_ALLOWED: 'no_commerce_scope', }; /** From 1e3dae90c858c68d77bb067302896e9c253d394a Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 08:08:15 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(drafts):=20denial-mapping=20doc=20?= =?UTF-8?q?=E2=80=94=20correct=20HTTP=20status=20codes=20(review=20round?= =?UTF-8?q?=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer findings, both empirically verified against cycles-protocol-v0.yaml ERROR SEMANTICS section before fixing. 1. HTTP status column in mapping table 1 had two spec-inaccuracies: - RESERVATION_EXPIRED listed as 409. Canonical L55 says "Expired reservations MUST return HTTP 410 with error=RESERVATION_EXPIRED" — corrected to 410. - UNIT_MISMATCH listed as 409. Canonical L59 says "Unit mismatch MUST return HTTP 400 with error=UNIT_MISMATCH" — corrected to 400. Per the rigor protocol, also did an adversarial sweep on the other HTTP status entries (which had been imprecise "4xx" / "5xx" categories) and tightened to specific canonical-cited or HTTP-standard values: - INVALID_REQUEST: 400 (canonical L614, L1782) - UNAUTHORIZED: 401 (HTTP standard; no explicit canonical) - FORBIDDEN: 403 (canonical L30, L1356) - NOT_FOUND: 404 (canonical L57) - BUDGET_FROZEN: 409 (inferred — same pattern as BUDGET_EXCEEDED L48) - BUDGET_CLOSED: 409 (inferred) - RESERVATION_FINALIZED: 409 (canonical L54) - IDEMPOTENCY_MISMATCH: 409 (canonical L99) - OVERDRAFT_LIMIT_EXCEEDED: 409 (canonical L49-L51, L80) - DEBT_OUTSTANDING: 409 (canonical L52) - MAX_EXTENSIONS_EXCEEDED: 409 (inferred) - INTERNAL_ERROR: 500 (HTTP standard) Added an HTTP-status column key noting that codes without explicit canonical citation are flagged "(inferred)" — the doc is honest about which statuses are spec-pinned vs. pattern-derived. 2. "Unknown DecisionReasonCode values" section still said "outside the 9 known values" — leftover from the pre-v0.1.26-scope-out wording. Corrected to "outside the 6 known v0.1.25 base values" matching the rest of the doc. Validation (all empirical): - 15 ErrorCode rows in mapping table 1, all with specific HTTP status codes - RESERVATION_EXPIRED: now 410 (canonical L55) - UNIT_MISMATCH: now 400 (canonical L59) - 0 "9 known values" references; 3 "6 known values" references - 0 stray cycles.denial_detail.error / .reason_code references (F3 standardization from prior round preserved) - JSON example still parses cleanly (F4 fix preserved) Process note: missing the HTTP status codes was a coverage-gap class of bug I should have caught in my own audit. The rigor rule (cross- check every literal value against canonical) applies to status-code columns the same way it applies to line citations and enum values. Adding to memory. --- drafts/cycles-aps-denial-mapping-v0.1.md | 48 ++++++++++++++---------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md index acf55d2..1148bdc 100644 --- a/drafts/cycles-aps-denial-mapping-v0.1.md +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -62,23 +62,31 @@ endpoints. In the CyclesEvidence envelope, these appear in `payload.error.response.error` (see `drafts/cycles-evidence-v0.1.yaml` `ErrorResponseMirror`). -| Cycles `ErrorCode` | HTTP class | APS Tier-1 | Compression notes | +**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` | 4xx | `rail_error` | Malformed request — rail-side validation failure. Not a budget/scope/wallet semantic; nearest fit is `rail_error`. | -| `UNAUTHORIZED` | 4xx | `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` | 4xx | `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` | 4xx | `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 | `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 | `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. | -| `BUDGET_CLOSED` | 409 | `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`. | -| `RESERVATION_EXPIRED` | 409 | `time_window_violation` | Direct semantic match — TTL elapsed. Explicitly called out as "clean" by aeoess in issue #25 (comment 4422627045). | -| `RESERVATION_FINALIZED` | 409 | `rail_error` | Attempting to commit/release an already-finalized reservation — rail-state error, not a Tier-1-mappable user-facing reason. | -| `IDEMPOTENCY_MISMATCH` | 409 | `rail_error` | Idempotency key replay collision — rail-internal concern. | -| `UNIT_MISMATCH` | 409 | `rail_error` | Commit `actual.unit` doesn't match reservation `reserved.unit` — rail-internal concern. | -| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `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 | `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 | `time_window_violation` | The reservation has been extended too many times — temporal-window concern. | -| `INTERNAL_ERROR` | 5xx | `rail_error` | Server-side error — by definition rail-side. | +| `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 @@ -148,10 +156,10 @@ Per canonical L503-L505: "Clients MUST gracefully handle unknown values 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 9 known -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 +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. From ab403ca3041283b83ab9694a92eb59f8f4c1b4b5 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 08:30:24 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fix(drafts):=20denial-mapping=20doc=20?= =?UTF-8?q?=E2=80=94=20PaymentDenial=20vs=20PaymentReceipt=20type=20confus?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer flagged that the doc said "APS PaymentReceipt denial_reason values" and "APS PaymentReceipt with the mapped Tier-1 reason" — implying the success-path receipt type carries denial fields. It doesn't. Verified against `aeoess/agent-passport-system/src/v2/payment-rails/types.ts`: - PaymentReceipt (L92): proof of rail event occurrence. Success path only. No denial_reason field. - PaymentDenial (L137): closed-taxonomy denial with denial_reason. - GovernanceHooks splits the two emit paths (L340 emitReceipt, L344 emitDenial), both returning their respective top-level type. The header comment in types.ts L13-L18 makes the distinction explicit: success → "fully-signed PaymentReceipt that binds (rail_name, action_ref, delegation_ref, amount, currency, tx_proof)"; failure → "fully-signed PaymentDenial citing one of the closed-taxonomy denial_reason values." Three occurrences fixed (reviewer flagged two; sweep found a third): 1. Purpose paragraph: "APS PaymentReceipt Tier-1 denial_reason values" → "APS PaymentDenial Tier-1 denial_reason values". Plus added an explanatory note clarifying the two types are distinct and citing the four canonical line numbers. 2. TS comment in the reference impl (was undetected by reviewer — said `PaymentDenialReceipt envelope`, which doesn't exist as a type name at all). Fixed to `PaymentDenial envelope`. 3. Round-trip verification section: "Cycles denial → CyclesEvidence envelope → APS PaymentReceipt with the mapped Tier-1 reason" → "APS PaymentDenial with the mapped Tier-1 reason". Verified post-fix: - 3 PaymentReceipt references remaining (all in the new explanatory note that *explicitly* distinguishes the two types — correct usage, not the conflation pattern) - 3 PaymentDenial references at the operative locations (purpose, TS comment, round-trip section) - PaymentDenialReceipt: 0 references (was 1, fixed) - Line citations (L92, L137, L340, L344) all verified via `gh api` against current types.ts on aeoess/agent-passport-system main Process note: this is the third round-trip where the reviewer caught a cross-repo terminology slip I should have caught via rule 7 (fetch + grep cross-repo type names before commit). The pattern is consistent: I keep using approximate-sounding names from memory of earlier conversations (`PaymentDenialReceipt`, `TierOneDenial`, `hooks.ts:115-123`) without re-verifying. Updating memory rule 7 to make the verification explicit — not just for ImportedType names but also for prose references to top-level type names. --- drafts/cycles-aps-denial-mapping-v0.1.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/drafts/cycles-aps-denial-mapping-v0.1.md b/drafts/cycles-aps-denial-mapping-v0.1.md index 1148bdc..2b21bc5 100644 --- a/drafts/cycles-aps-denial-mapping-v0.1.md +++ b/drafts/cycles-aps-denial-mapping-v0.1.md @@ -5,8 +5,13 @@ **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 PaymentReceipt -Tier-1 `denial_reason` values. +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 @@ -227,7 +232,7 @@ 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 -// PaymentDenialReceipt envelope by the caller before signing. +// PaymentDenial envelope by the caller before signing. interface FoundationDenialMapping { denial_reason: DenialReason; cycles: { @@ -369,7 +374,7 @@ will move to a numbered spec file at repo root (e.g. 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 PaymentReceipt with the + 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